一、拾枝杂谈

我在发布第一篇文章的时候,出现了一个小失误,只是当时没有觉察到。

没想到一个空格的问题,会牵扯出脚本逻辑的缺陷,和写入飞书表格的失败。

文章原本的标题是“原来 AI 一直在白嫖我的文章”,但是我在某个平台📕发布的时候,漏掉了 “AI” 后面的一个空格,于是某平台📕文章的标题就变成了“原来 AI一直在白嫖我的文章。

前后就差了一个空格,而且还是中文和英文之间的空格。在我们人眼看来,其实没什么区别,或许就像我一样,要不是 WorkBuddy 告诉了我,我从头到尾都没注意到这个微小的差异。

这让我想起了《三体2》中的面壁者 比尔·希恩斯,仅仅改动了一个符号,却让智子都无法察觉。

我可比不上智子,也就更加做不到明察秋毫了。但是在程序看来,情况完全不同,因为代码的匹配逻辑是写死的。


二、CJK 归一化

我们遇到的问题,其实与CJK归一化有关。CJK全称是 Chinese / Japanese / Korean,就是中文 / 日文 / 韩文。

CJK归一化是指在计算机文本处理中,把 中日韩 文字通过统一规则转换为标准单一形态或统一编码的过程。这是由于 中日韩 相关的文字,受历史、字形变体或编码标准而影响导致外观不同,但本质是相同或相通的字符,就像日文中经常可以见到汉字一样。

问题出现的根本原因是:之前的代码只是处理了中文之间的空格,而没有正确处理CJK 和 ASCII字母之间的空格

比如我们刚才讨论的那个标题,某平台📕上的标题我不小心漏了一个空格,写成了“原来 AI一直在白嫖我的文章”。但同样一篇文章,在飞书子节点的标题是“原来 AI 一直在白嫖我的文章”,注意后一个标题的“AI” 前后都是有一个空格的,而前一个标题 把“AI”后面的那一个空格给漏掉了,只剩下了“AI” 前面的一个空格。

负责写入飞书的脚本 feishu_writer.py 里面有一个类叫做“FeishuSheetWriter”,这个类里面有一个方法专门用来匹配我们从平台上爬取到的标题以及飞书表格的标题,这个方法就是 match_article_to_child

注意,这个方法和 CJK归一化的失败 没关系,它全程没有变化,因为它只负责比对两个标题是否匹配,如果匹配,就向已有的飞书表格里追加数据;如果不匹配,就新建一个飞书表格。

而这个 match_article_to_child 方法的匹配逻辑大概是这样的:

  1. 把两个标题都 lowercased(小写);
  2. 按照空格划分,把标题切成一个个词块;
  3. 计算不同词之间的交集。

我用那个“不小心漏掉的空格”举例进行讲解。某平台抓取到的“AI一直在白嫖我的文章”,切词后变成了 [“AI一直在白嫖我的文章”],而实际的飞书节点表格的标题“AI 一直在白嫖我的文章”,切词后变成了 [“AI”, “一直在白嫖我的文章”]。

这两个集合的交集是空的,所以匹配分数很低,就会误以为这是两篇不同的文章,结果又新建了一个重复的 飞书子表。

但是 match_article_to_child 方法背后的支撑代码是如何编写的?如果 match_article_to_child 只负责比对标题,那么负责“CJK”归一化的又是哪个方法呢?


三、match_article_to_child 方法背后的代码

我们先来解决第一个问题:分词逻辑和标题的匹配逻辑,背后的代码实现是怎样的?

match_article_to_childfeishu_writer.py 文件中 “FeishuSheetWriter”类中的一个方法,它的代码如下:

def match_article_to_child(self, article_title: str, child_nodes: list) -> dict:
    norm_scraped = _normalize_title(article_title)      # 第一次:处理爬取到的标题


    best_match = None
    best_score = 0

    for node in child_nodes:
        child_title = node.get("title", "")
        stripped = _strip_geo_prefix(child_title)      # 去掉表格标题的前缀
        norm_child = _normalize_title(stripped)        # 第二次:处理飞书里的标题

        # 然后比较 norm_scraped 和 norm_child 这两个标题,计算相似度分数
        if norm_scraped == norm_child:
            score = 100
        elif norm_scraped in norm_child or norm_child in norm_scraped:
            score = 80
        else:
            scraped_words = set(norm_scraped.split())
            child_words = set(norm_child.split())
            overlap = len(scraped_words & child_words)
            total = max(len(scraped_words), len(child_words))
            score = int(overlap / total * 60) if total > 0 else 0

        if score > best_score:
            best_score = score
            best_match = node

    if best_match and best_score >= 50:
        return best_match
    return None

从代码的流程控制可以看出,标题的匹配分数有三个档次

  1. 如果 norm_scraped == norm_child,也就是说我们从平台上爬取到的标题 和 飞书表格的标题是完全一样的,就是100分,满分!
  2. 如果是其中一个字串包含另一个,那就是80分,也是匹配的。
  3. 进行切词,就和我们上面举的例子一样,按照空格划分,把标题切成一个个词块,然后比较两个集合里面的交集比例,然后乘以60.

匹配分数后进行比较,门槛是 50分,如果低于 50分 就认为是不匹配,就会去新建一个子节点。

还是用我们刚才的那个标题来举例讲解。某平台📕爬取到的标题是“AI一直在白嫖我的文章”。而飞书里的子节点标题是“AI 一直在白嫖我的文章”。

由于修复前的“归一化”只处理中文与中文之间的空格,而没有处理英文和中文之间的空格,如下所示:

title = re.sub(r'([\u4e00-\u9fff]) +([\u4e00-\u9fff])', r'\1\2', title)

于是两个标题归一化后分别是:

norm_scraped = "ai一直在白嫖我的文章"       # 某平台爬取到的标题
norm_child   = "ai 一直在白嫖我的文章"      # 飞书表格真正的标题

然后我们根据三层判断逻辑进行匹配:

  1. 首先因为一个没空格,一个有空格,所以不相同,第一层匹配失败;
  2. 两者也没有出现“其中一个标题是另一个标题的子串”的情况,因为中间有一个空格隔开了,所以第二层匹配失败;
  3. 开始进行切词,如下:
scraped_words = set("AI一直在白嫖我的文章".split())
# => {"AI一直在白嫖我的文章"}   因为没空格,整句被当成一个词

child_words = set("AI 一直在白嫖我的文章".split())
# => {"AI", "一直在白嫖我的文章"}  按空格切成两个词

overlap = len({"AI一直在白嫖我的文章"} & {"AI", "一直在白嫖我的文章"})
# => 0  没有共同词

total = max(1, 2)  # => 2

score = int(0 / 2 * 60)  # => 0

0 分,远低于 50 分的门槛, 于是 match_article_to_child 方法返回 None,程序就以为这是一篇新文章,新建了一个重复的飞书子表。

好的,第一个问题解决了,我们明白了从代码层面,一个空格是如何导致了匹配失败。


四、改进后的归一化

接下来我们解决第二个问题:修正前和修正后的归一化究竟是如何作用的?为什么修正好实现了正确的归一化?

细心的朋友们应该已经发现,在 match_article_to_child 方法中调用了两次 _normalize_title 方法,我还特地在代码中加了注释“第一次” 和 “第二次”。

没错,在方法最开始的 norm_scraped = _normalize_title(article_title) ,调用了一次 _normalize_title 方法,这是归一化处理我们在平台上爬取到的标题,而在后面 for 循环里面的 norm_child = _normalize_title(stripped) 是第二次调用 _normalize_title 方法,这是对飞书表格的标题进行归一化。

那我们先看一下“_normalize_title”方法修正之前的代码,如下:

def _normalize_title(title: str) -> str:
    title = re.sub(r'["\u201c\u201d\u2018\u2019]', '"', title)
    title = title.strip().lower()
    title = re.sub(r'\s+', ' ', title)      # 1. 先合并连续空格
    title = re.sub(r'([\u4e00-\u9fff]) +([\u4e00-\u9fff])', r'\1\2', title)  # 2. 删汉字间空格
    return title

关键在最后一行 title = re.sub(r'([\u4e00-\u9fff]) +([\u4e00-\u9fff])', r'\1\2', title)。这条正则只匹配 “汉字 + 空格 + 汉字” 的情况,比如把“迅 高”变成“迅高”,但它不匹配“英文字母 + 空格 + 汉字”,就比如 “AI 一直在白嫖我的文章”,这里的空格在英文和中文之间,一边是 ASCII,一边是 CJK,所以老规则不会删除它。结果就是 “AI 一直在白嫖我的文章” 归一化后 还是它自己,导致和我们爬取到的标题不一致。

修复后的代码如下:

def _normalize_title(title: str) -> str:
    title = re.sub(r'["\u201c\u201d\u2018\u2019]', '"', title)
    title = title.strip().lower()
    title = re.sub(r'\s+', ' ', title)                              # 1. 先合并连续空格
    title = re.sub(r'([\u4e00-\u9fff]) +([\u4e00-\u9fff])', r'\1\2', title) # 2. 删汉字间空格
    title = re.sub(r'([\u4e00-\u9fff]) +(\S)', r'\1\2', title)      # 3. 删汉字后的空格
    title = re.sub(r'(\S) +([\u4e00-\u9fff])', r'\1\2', title)      # 4. 删汉字前的空格
    return title

可以看到,相比于原来的代码,增加了两条:

title = re.sub(r'([\u4e00-\u9fff]) +(\S)', r'\1\2', title)
title = re.sub(r'(\S) +([\u4e00-\u9fff])', r'\1\2', title)

其中,\u4e00-\u9fff 是 Unicode 里中日韩统一表意文字的区间,也就是常见汉字。\S 表示任何非空白字符(包括英文字母、数字、标点,也包括汉字), 这两条规则合起来,会删掉“汉字 + 空格 + 英文” 以及 “英文 + 空格 + 汉字”的情况。

第一行代码 ([\u4e00-\u9fff]) +(\S) 表示 如果一个汉字后面跟着空格,再跟着任何非空白字符(比如英文、数字、标点),就删掉空格,也就是删除汉字后面的空格。例如:一直 AI → 一直AI。

而第二行代码 (\S) +([\u4e00-\u9fff]) 则表示 如果任何非空白字符后面跟着空格,再跟着汉字,也删掉空格。这两行合起来,就把中英文混排时边界上的空格都去掉了,也就是删除汉字前面的空格。例如:AI 一直 → AI一直。

这样的话,很明显两个标题归一化后是一致的,准确来讲,是一摸一样的了。所以 match_article_to_child方法中 的 norm_scraped == norm_child 判断成立, 匹配分数直接从 0分 变成了 100分。

以前不明白古人说的“差之毫厘,谬以千里”是什么意思,我想从程序员的角度来说,也许就是一个空格带来的差异


Δ总结

  • OK,以上就是本篇文章的全部内容了,感谢阅读!。
  • 这篇文章根据up 本人的亲身经历,从实战角度给大家讲解了 CJK 的定义,原理,以及 CJK和ASCII之间如果没有正确处理会有什么后果。这篇博文没有针对某个平台,说的是一个比较独立的问题。
  • 关注迅高AI实验室,学习AI不迷路!

更多推荐