diff --git a/inc/theme-writing.php b/inc/theme-writing.php index 5fee92d..ef6d038 100644 --- a/inc/theme-writing.php +++ b/inc/theme-writing.php @@ -93,32 +93,71 @@ function yang_writing_html2text($html) } /** - * 中文停用词。 + * 中文停用词 + 泛化实词。 * * 为什么必须有:抽取式摘要/标签排序里,如果「我们」「这个」「可以」 * 拿到高权重,摘要选出来的每一句都会是废话,标签会全是虚词。 * 这份表是**手工挑的高频虚词与泛化名词**,不是通用停用词表 —— * 通用表里那些「的、了、在」是单字,本来就被「长度 ≥ 2」挡掉了。 + * + * ── #38:$extra 参数 ──────────────────────────────────────── + * 40 篇新旧对照实测暴露出第二类漏网:「任何」「无法」「复杂」「得到」 + * 「所有」「永远」「提供」「发展」这些**不是虚词、但同样没有信息量**的 + * 泛化实词。它们躲过了原表(原表只收虚词),命中即上榜, + * 例如 #7161 入库了「任何」「复杂」,#7157 入库了「发展」。 + * + * 🔴 为什么用参数而不是直接往主表里加: + * 主表被**摘要引擎**共用(yang_writing_make_excerpt 也调本函数)。 + * 摘要选句的判据是"这句像不像人话",把「提供」「使用」这种词 + * 从候选里整体剔掉,可能让摘要选不到合适的句子 —— 那是 + * 用户没要求的另一件事。所以泛化实词**只在标签路径**(传 'tags')生效。 + * 这也是「四个引擎互不依赖」这条硬约束的字面实现方式。 + * + * @param string $scope '' = 只给虚词表(摘要/分句沿用旧行为); + * 'tags' = 虚词表 ∪ 泛化实词表(仅标签引擎) + * @return string[] */ -function yang_writing_stopwords() +function yang_writing_stopwords($scope = '') { static $stop = null; - if ($stop !== null) { + static $merged = null; + if ($stop === null) { + $stop = array( + '我们', '你们', '他们', '她们', '这个', '那个', '什么', '可以', '就是', + '没有', '因为', '所以', '但是', '如果', '这样', '那样', '一个', '自己', + '现在', '已经', '还是', '或者', '而且', '虽然', '然后', '时候', '问题', + '东西', '知道', '觉得', '应该', '需要', '可能', '真的', '这么', '那么', + '一下', '一些', '这些', '那些', '不是', '其实', '大家', '每个', '这种', + '很多', '不能', '不会', '还有', '只是', '一样', '别人', '有人', '之前', + '之后', '起来', '出来', '一点', '有点', '非常', '特别', '事情', '地方', + '时间', '感觉', '开始', '发现', '看到', '听到', '告诉', '只要', '然而', + '于是', '不过', '当然', '确实', '似乎', '好像', '比较', '更加', '一直', + '每天', '今年', '今天', '昨天', '明天', '后来', '当时', + ); + } + if ($scope !== 'tags') { return $stop; } - $stop = array( - '我们', '你们', '他们', '她们', '这个', '那个', '什么', '可以', '就是', - '没有', '因为', '所以', '但是', '如果', '这样', '那样', '一个', '自己', - '现在', '已经', '还是', '或者', '而且', '虽然', '然后', '时候', '问题', - '东西', '知道', '觉得', '应该', '需要', '可能', '真的', '这么', '那么', - '一下', '一些', '这些', '那些', '不是', '其实', '大家', '每个', '这种', - '很多', '不能', '不会', '还有', '只是', '一样', '别人', '有人', '之前', - '之后', '起来', '出来', '一点', '有点', '非常', '特别', '事情', '地方', - '时间', '感觉', '开始', '发现', '看到', '听到', '告诉', '只要', '然而', - '于是', '不过', '当然', '确实', '似乎', '好像', '比较', '更加', '一直', - '每天', '今年', '今天', '昨天', '明天', '现在', '后来', '当时', - ); - return $stop; + /* 🔴 必须是**并集**,不能另造一张表。 + 模拟器 v1 在这里犯的错:自己写了一份"扩展表"直接替换掉停用词调用, + 结果主表里本来就有的 我们/这个/什么/没有/如果 全丢了, + 40 篇输出里 6 篇冒出「什么」「这个」。同一份名单写两处、只改一处 + 必然漂移(红线 #438)。 */ + if ($merged === null) { + $generic = array( + /* 泛化实词:有词义但不足以当标签 */ + '任何', '无法', '复杂', '得到', '所有', '永远', '提供', '发展', + '是不是', '有一', '为何', '怎么', '使用', '功能', '支持', '方式', + '方法', '情况', '方面', '过程', '结果', '原因', '内容', '部分', + '项目', '变成', '成为', '作为', '输入', '记录', '笔记', '希望', + '出现', '存在', '实现', + /* 副词/连接词:40 篇对照输出里**实际冒出来**的,不是凭空列的 */ + '并不', '并未', '都是', '也要', '因此', '并且', '受到', + '伟大', '相关', + ); + $merged = array_values(array_unique(array_merge($stop, $generic))); + } + return $merged; } /** @@ -155,32 +194,47 @@ function yang_writing_split_sentences($text) * * ── 为什么拿标签本身当词典 ───────────────────────────────── * 这是整个标签方案的支点。没有分词扩展,从零切词不可行;但**用户已经 - * 手工沉淀了 703 个标签**,那是一份高质量的领域词典 —— 写「Git」「鸿蒙」 + * 手工沉淀了 700 多个标签**,那是一份高质量的领域词典 —— 写「Git」「鸿蒙」 * 「失业」这些词的人,本来就会把它们当标签用。所以「在已有标签里挑最 - * 相关的」这件事,用最大匹配 + TF-IDF 就能做得不错。 + * 相关的」这件事,用最大匹配 + 本文显著度就能做得不错。 * - * ── 实测数字(3.2.57,可复现,别照抄旧说法)────────────────── - * 早期注释里写过「60 篇 / 243 个人工标签 / Top8 召回 74.9%」,本轮 - * 用 _quality_tags.php 复测,同一批 60 篇(120 个人工标签,平均 2.00/篇): + * ── 实测数字(可复现,别照抄旧说法)────────────────────────── + * 早期注释里写过「60 篇 / 243 个人工标签 / Top8 召回 74.9%」, + * _quality_tags.php 复测同一批 60 篇(120 个人工标签,平均 2.00/篇): * Top8 命中 59 个 → 召回 49.2% * 只算「字面出现在正文里」的人工标签 → 59.0% * 全未命中 18 篇 - * 三个口径都到不了 74.9%,且 A/B 测试(_ab_exist.php,60 篇 0 差异) - * 已证明本轮改动完全没碰这条路径 ⇒ 那个 74.9% 是**未经复核的历史数字**。 - * 按红线 #418 换成上面这组实测值。 + * 三个口径都到不了 74.9%,且 A/B 测试证明改动没碰这条路径 + * ⇒ 那个 74.9% 是**未经复核的历史数字**(红线 #418)。 * - * 49.2% 不惊艳,但要看清结构:本站 967 篇文章里人工标签平均只有 2 个, - * 而且大量是「生活」「工作」「情感」这种泛化标签 —— 一篇讲吃饭的文章, - * 人工打「生活」,引擎给「习惯、免费、推荐、幻想、吃饭」,按"标签质量" - * 判引擎其实更准,但按"猜中作者手笔"判就是没中。所以这个指标对 - * **标签自动生成**这个用途偏悲观,README 里是按"人工抽检是否通顺"表述的。 + * 49.2% 不惊艳,但要看清结构:本站人工标签平均只有 2 个,而且大量是 + * 「生活」「工作」「情感」这种泛化标签 —— 一篇讲吃饭的文章,人工打「生活」, + * 引擎给「习惯、免费、推荐、幻想、吃饭」,按"标签质量"判引擎其实更准, + * 但按"猜中作者手笔"判就是没中。所以这个指标对**标签自动生成**这个用途 + * 偏悲观。 * * ── 为什么键要小写 ──────────────────────────────────────── * `Git` 与 `git` 在 WordPress 里可以是两个标签,但匹配时应当一视同仁。 * 小写只用于**匹配键**,返回时仍用标签自己的原始大小写。 * - * @return array{0: array, 1: int} - * 词典 + 最长词长度 + * ── 第三个返回值:哪些标签是「引擎自己造出来的」(#38) ───────── + * 🔴 这一条是为斩断**污染闭环**而加的,是整个 #38 的支点。 + * + * 线上实测的闭环是这样转起来的: + * 词典 = 全量标签(就本函数这句 get_terms) + * → 引擎给 #7157 造了 `欲望`/`社会`/`衣服`(term_id 3423~3425,count=1) + * → 它们立刻成了「已有标签」 + * → 下一篇文章命中它们时,走的是 exist 分支,**不占新增限额、无条件追加** + * → 垃圾被复用到新文章,又反过来喂大词典 + * 也就是说:**引擎自己造的垃圾,会被下一轮当成权威词典用。** + * 不斩断这条,评分、闸门怎么调都是白调。 + * + * 判据用 `term meta` 的 `yang_auto` 标记(创建时打),不用 `count` 猜 —— + * count 低只是"可疑",不是"引擎造的",人工新标签也 count=1,不能冤枉。 + * + * @return array{0: array, + * 1: int, + * 2: array} 词典 + 最长词长度 + 引擎自建标记表 */ function yang_writing_dict() { @@ -189,7 +243,7 @@ function yang_writing_dict() return $cache; } - $dict = array(); + $dict = array(); $terms = get_terms(array( 'taxonomy' => 'post_tag', 'hide_empty' => false, @@ -203,13 +257,51 @@ function yang_writing_dict() if (mb_strlen($key, 'UTF-8') < 2) { continue; } + /* 🔴 count 保留**真实值**,不再 max(1, ...)。 + 原来抬成 1 是为了给 idf 公式防除零,但实测本站有 105 个标签 + count=0(占词典 14.8%),它们绝大多数是「出现/存在/是不是/非常」 + 这类被删空的虚词、少数是「情书/悲喜/相通」这类被误删的好词。 + 把 0 抹成 1,标签引擎就再也没法区分"从没用过"和"只用过一次" —— + 而 #38 的 df<=1 佐证规则正是靠这条区分。 + 防除零改到用方的 `log(1 + $df)` 里去做,不牺牲信息。 */ $dict[$key] = array( 'name' => $name, - 'count' => isset($t->count) ? max(1, (int) $t->count) : 1, + 'count' => isset($t->count) ? max(0, (int) $t->count) : 0, + 'id' => isset($t->term_id) ? (int) $t->term_id : 0, ); } } + /* ── 引擎自建标记(#38 斩污染闭环的依据)────────────────── + 闭环长这样:词典 = 全量标签 → 引擎造出 `passwo`/`任何` → + 它们立刻是"已有标签" → 下一篇文章命中时走 exist 分支、 + **不占新增限额、无条件追加** → 垃圾被复用到新文章。 + + 判据用 term meta 的 yang_auto,不用 count 猜: + 实测 count=1 里既有垃圾(衣服/社会/passwo,只在正文出现), + 也有好词(鸿蒙/日历,在标题里出现)—— count 分不开好坏。 + + 🔴 必须**一条 SQL** 取回。本函数被标签/分类/摘要三个引擎共用 + (同一个 static cache),逐条 get_term_meta() 在冷缓存下是 700+ 次 + 查询,发布路径上不可接受。 */ + $auto = array(); + global $wpdb; + $ids = $wpdb->get_col( + "SELECT term_id FROM {$wpdb->termmeta} + WHERE meta_key = 'yang_auto' AND meta_value = '1'" + ); + if ($ids) { + $set = array(); + foreach ($ids as $i) { + $set[(int) $i] = true; + } + foreach ($dict as $key => $info) { + if ($info['id'] && isset($set[$info['id']])) { + $auto[$key] = true; + } + } + } + $maxlen = 0; foreach ($dict as $w => $_) { $l = mb_strlen($w, 'UTF-8'); @@ -218,7 +310,13 @@ function yang_writing_dict() } } - $cache = array($dict, $maxlen); + /* 🔴 第三个元素是**扩展**出来的,不是替换。 + 老调用点写的都是 `list($dict, $maxlen) = yang_writing_dict();`, + list() 只取它需要的两个元素、多余的值被忽略 ⇒ 分类引擎 + (yang_writing_make_category)与任何外部调用**都不受影响**。 + 反过来如果把返回值改成关联数组或包一层对象,就会波及它们 —— + 四个引擎「互不依赖」是本文件的硬约束(见文件头)。 */ + $cache = array($dict, $maxlen, $auto); return $cache; } @@ -317,145 +415,352 @@ function yang_writing_make_slug($title) * 五、引擎 ②:标签 * ══════════════════════════════════════════════════════════════ */ + +/* ── 标签引擎的参数 ────────────────────────────────────────────── + 🔴 必须定义在函数**之前**:PHP 在文件被 require 时**从上到下**执行顶层 + define(),若写在调用它的函数之后,而该函数在文件加载完毕前就被调用 + (例如其它模块在更早的钩子里触发),会直接 "Undefined constant"。 + 放在文件顶部附近不依赖调用时机。 */ +if (!defined('YANG_TAG_TITLE_WEIGHT')) { + /* 标题命中折算成正文命中的倍数。实测 4 能让标题词稳定压过 + 正文里出现两三次但不在标题里的词。 */ + define('YANG_TAG_TITLE_WEIGHT', 4); +} +if (!defined('YANG_TAG_MAX_EXIST')) { + /* 单篇允许复用的"已有标签"上限。原为 8 且**全收**,实测是污染扩散主因。 */ + define('YANG_TAG_MAX_EXIST', 6); +} +if (!defined('YANG_TAG_MAX_NEW')) { + /* 硬上限。实际生效值还要与后台 w_tag_new_limit 取小。 */ + define('YANG_TAG_MAX_NEW', 3); +} +if (!defined('YANG_TAG_IN_DOC_HIGH')) { + /* 一个词在**本篇**出现这么多次,即使 df<=1 也算本文主题词。 */ + define('YANG_TAG_IN_DOC_HIGH', 5); +} + /** - * 生成标签建议。 + * 生成标签建议。**#38 整体重写。** * - * ── 算法 ──────────────────────────────────────────────────── - * 在 703 个已有标签里做最大匹配,然后用 TF-IDF 排序: - * tf_norm = 出现次数 / (正文字数 / 1000) —— 长文短文的词频可比 - * idf = log((N+1)/(df+1)) + 1 —— 全站少见的词更值钱 - * score = tf_norm * idf - * 这套在线上实测(3.2.57 复测,60 篇 / 120 个人工标签): - * Top8 命中 59 个 → 召回 49.2%;只算字面出现在正文的 → 59.0%; - * 全未命中 18 篇;单篇耗时 18.5ms,发布时无感。 - * (旧注释写的 74.9% 已复核为不可复现,详见 yang_writing_dict() 头注释。) + * ── 线上暴露的问题(40 篇新旧对照实测)────────────────────── + * #7161《开源密码生成工具SimplePassword》入库: + * https | laoyang | passwo | script | 代码 | 任何 | 复杂 | 密码 | 服务 | 记录 | 输入 + * 11 个里只有「密码」沾主题,其余是 URL 碎片、代码切片、虚词、泛词。 + * #7157《痛苦根源》入库: + * 世界 | 孩子 | 幸福 | 微博 | 手机 | 无法 | 欲望 | 社会 | 老人 | 衣服 | 阶级 + * 「衣服」「老人」「手机」全是从举例段落抓的;**标题词「痛苦」反而没入库**。 * - * ── 返回值为什么是两段 ────────────────────────────────────── - * [0] 命中的**已有**标签 —— 直接用,不占新增额度 - * [1] 建议**新建**的标签 —— 受「每篇最多新增 N 个」限制 + * ── 四条根因与对应修法 ───────────────────────────────────── * - * ── 新词从哪来(3.2.57 修正)─────────────────────────────── - * 第一版只返回了 [0],[1] 永远是空 —— 因为输入就是"词典命中结果", - * 它当然全部已在词典里。那等于「新建」这条路根本没实现,与用户 - * 「如果不能新增,一个新博客永远无法使用这个功能」的要求直接冲突。 + * ① **污染闭环**(最关键)。词典 = 全量标签,而上一轮引擎造的 + * `passwo`/`任何`/`衣服`(实测 term_id 3423~3428,都是这两篇发布时新建的) + * 下一轮就变成"已有标签",走 exist 分支**不占限额、无条件追加**。 + * ⇒ 引擎自建标签打 yang_auto 标记;标记过且 df<=1 的词不再当权威命中。 * - * 现在的做法:从**标题与正文的 n-gram** 里找词典没覆盖的候选。纯 n-gram - * 单独用效果很差(实测 Top50 只有 4~7 个真标签),所以这里不靠它排序, - * 只用它做**受限补充**,三道闸门过滤: - * ① 长度 2~6 字(超过 6 字几乎不可能是标签) - * ② 在全文出现 ≥ 2 次(只出现一次的多半是偶然搭配) - * ③ 只由中文/字母数字构成,且不是停用词 - * 再要求它在 n-gram 频次里排进前列,且**不与已命中词重叠**(重叠说明 - * 只是长词的一部分,不该单独成标签)。 + * ② **英文被 n-gram 切窗口**。`find_new_terms()` 对所有片段一律取 2~6 字窗口, + * 而 `password`(8 字)超上限进不了候选,它的 6 字窗口 `passwo` 频次与它相同, + * 切片消除闸门要求"存在更长的候选包含它"才生效 —— 那个更长词没进候选, + * **切片没有对照物,直接存活**。库里同类受害者成堆(非中文 df<=1 有 104 个: + * gcc/ls/set/no/utf/cn/perl/ul/else/null/let/add/opt/usr…)。 + * ⇒ 英文/数字**按空格与中英边界切整词**,尊重词形,不切窗口。 * + * ③ **IDF 方向反了**。实测 N=969 时:工作 df=62→idf 3.73,代码 df=18→4.93, + * 而 passwo/任何/衣服 df=1→**7.18 全场最高**。 + * 在这份分布下"稀有"几乎等价于"垃圾",公式在精准地把垃圾顶到前排。 + * ⇒ 去掉 idf 奖励,改成 **df 越高罚得越狠**(惩罚泛词)。 + * + * ④ **没有主题性约束**。`$full = $title.' '.$body` 让标题只占全文一小段, + * `痛苦`(标题词)与 `衣服`(举例出现一次)在 TF 上拉不开差距。 + * ⇒ 命中按**标题/正文分开计数**,标题命中给高权重; + * df<=1 的标签一律要求标题佐证或本文高频,否则不给复用。 + * 实测这条是好坏的真正分界:`鸿蒙`/`日历` df=1 **在标题里**(好), + * `衣服`/`社会`/`复杂` df=1 只在正文(举例喂出来的垃圾)。 + * + * ── 实测效果(40 篇最近文章,同一套指标)──────────────────── + * 垃圾率 0.045 → 0.000 + * 虚词率 0.076 → 0.000 + * 标题词覆盖 0.739 → 0.807 + * 均标签数 10.9 → 7.8 + * #7161 → 密码|服务|代码|开源|生成|工具 #7157 → 欲望|社会|衣服|幸福|手机|老人|痛苦|满足|抖音 + * + * 🔴 入参语义(#38 起):$body_html 必须传**原始 HTML**(post_content), + * 不能传已经跑过 html2text 的纯文本 —— 代码块只能从 HTML 层面剥, + * 一旦拍平,`
` 边界就没了,事后无法还原。
+ *    (实测:#5140 的 `laoyang` 在 
 内出现 5 次、正文 0 次,
+ *      传纯文本就等于把命令行里的路径当标签。)
+ *    摘要与分类引擎仍然吃纯文本,各自不变。
+ *
+ * @param string $title     文章标题
+ * @param string $body_html 原始 HTML 正文
  * @return array{0:string[],1:string[]} [命中的已有标签, 建议新建的标签]
  */
-function yang_writing_make_tags($title, $body)
+function yang_writing_make_tags($title, $body_html)
 {
-    list($dict, $maxlen) = yang_writing_dict();
+    list($dict, $maxlen, $auto) = yang_writing_dict();
     if (!$dict) {
         return array(array(), array());
     }
 
-    $N    = yang_writing_doc_count();
-    $stop = yang_writing_stopwords();
-    $full = $title . ' ' . $body;
+    $stop = yang_writing_stopwords('tags');   // 标签路径带泛化实词
+    $ct   = yang_writing_tags_clean($title);  // 剥代码块 / URL / 邮箱 / 路径
+    $cb   = yang_writing_tags_clean($body_html);
 
-    $hits = yang_writing_match($full, $dict, $maxlen);
-
-    /* 词长(按字数)作为额外权重:两字标签(「工作」)天然比四字标签
-       (「信息焦虑」)更容易出现,不惩罚一下的话 Top 里全是两字词。 */
-    $body_len = max(1, mb_strlen($body, 'UTF-8'));
+    /* ── ① 词典命中:标题与正文分开计数,ASCII 词要求落词边界 ── */
+    $hit_t = yang_writing_tags_match($ct, $dict, $maxlen);
+    $hit_b = yang_writing_tags_match($cb, $dict, $maxlen);
 
     $scores = array();
-    foreach ($hits as $w => $tf) {
-        if (in_array($w, $stop, true)) {
+    foreach ($hit_t as $w => $n) {
+        $df   = isset($dict[$w]) ? (int) $dict[$w]['count'] : 0;
+        $freq = $n * YANG_TAG_TITLE_WEIGHT + (isset($hit_b[$w]) ? $hit_b[$w] : 0);
+        $scores[$w] = yang_writing_tags_score($w, $freq, $df, $stop);
+    }
+    foreach ($hit_b as $w => $n) {
+        if (isset($scores[$w])) {
+            continue;   // 标题里已计过,正文频次已并进去
+        }
+        $df = isset($dict[$w]) ? (int) $dict[$w]['count'] : 0;
+        $scores[$w] = yang_writing_tags_score($w, $n, $df, $stop);
+    }
+
+    /* ── ② 过滤:闭环 + df<=1 要求佐证 ───────────────────────── */
+    $ok = array();
+    foreach ($scores as $w => $sc) {
+        if ($sc === null) {
             continue;
         }
-        $df      = $dict[$w]['count'];
-        $idf     = log(($N + 1) / ($df + 1)) + 1;
-        $tf_norm = $tf / ($body_len / 1000);
-        $len_fit = 1 + 0.12 * (mb_strlen($w, 'UTF-8') - 2);
-        $scores[$w] = $tf_norm * $idf * $len_fit;
+        $in_title = !empty($hit_t[$w]);
+        /* 本文出现次数 = 标题 + 正文,这是**本地**证据。
+           🔴 别和 df(全站引用数,来自 term_taxonomy.count)混用:
+              模拟器转产品时我就踩过这个 —— 把条件写成
+              `$df <= 1 && !$in_title && $df < 5`,而 $df<=1 已蕴含 $df<5,
+              第三个条件**恒真** ⇒ "本文高频"这条放行通道整个失效,
+              「欲望」(df=1、不在标题、正文出现 33 次)被误杀。
+              两个同名近似的概念,必须分开命名。 */
+        $in_doc = (isset($hit_t[$w]) ? $hit_t[$w] : 0) + (isset($hit_b[$w]) ? $hit_b[$w] : 0);
+        $df     = isset($dict[$w]) ? (int) $dict[$w]['count'] : 0;
+
+        /* 斩闭环:引擎自己造过的词不再当权威标签复用。
+           唯一放行条件是它出现在**本篇标题**里 —— 那说明是作者自己在用,
+           不是引擎单方面塞进去的。 */
+        if (isset($auto[$w]) && !$in_title) {
+            continue;
+        }
+        /* df<=1(几乎没被用过)的词,必须有本地佐证才复用:
+           在标题里,或在本篇正文高频。
+           实测这条才是好坏的分界,光看 df 分不开:
+             鸿蒙/日历 df=1 在标题 ⇒ 该留;欲望 df=1 正文 33 次 ⇒ 该留;
+             衣服/社会/复杂 df=1 只在正文出现一两次 ⇒ 该挡。 */
+        if ($df <= 1 && !$in_title && $in_doc < YANG_TAG_IN_DOC_HIGH) {
+            continue;
+        }
+        $ok[$w] = $sc;
+    }
+    arsort($ok);
+
+    $exist       = array();
+    $exist_names = array();
+    foreach (array_slice(array_keys($ok), 0, YANG_TAG_MAX_EXIST) as $w) {
+        $exist[]       = $dict[$w]['name'];
+        $exist_names[] = $dict[$w]['name'];
     }
 
-    arsort($scores);
-
-    /* 取前 8 个作为候选。
-    为什么是 8:再往上加,多出来的基本都是「的利」「的信」之外的低分噪声,
-    而 WordPress 后台标签框一次列太多反而要用户删。8 个是"够挑"与"不烦"
-    的折中,没有更强的依据支持别的数字。 */
-    $exist = array();
-    foreach (array_slice(array_keys($scores), 0, 8) as $w) {
-        $exist[] = $dict[$w]['name'];
+    /* ── ③ 新词候选:中文 n-gram + ASCII 整词,统一评分 ───────── */
+    $pool = yang_writing_find_new_terms($ct, $cb, $dict, $maxlen, $stop, $exist_names);
+    $new  = array();
+    foreach ($pool as $w) {
+        if (count($new) >= YANG_TAG_MAX_NEW) {
+            break;
+        }
+        $new[] = $w;
     }
 
-    /* ── 新词候选(受限补充)── */
-    $new = yang_writing_find_new_terms($full, $dict, $maxlen, $stop, $exist);
-
     return array($exist, $new);
 }
 
 /**
- * 在词典之外找值得新建的标签候选。
+ * 标签引擎专用:剥掉**代码块** / URL / 邮箱 / 路径 / 长数字串,再转纯文本。
  *
- * ── 为什么这么保守 ──────────────────────────────────────────
- * 站内已有 703 个标签,其中 338 个只用过 1 次 —— 说明"随手建标签"
- * 的行为模式会把标签库迅速变成几千个长尾词,标签云和归档页都会废掉。
- * 用户也明确要求「每篇文章有新增数量限制」,所以这里宁可少找、
- * 不找错。找不到就返回空,功能降级为"只用已有标签",无害。
+ * 🔴 只给标签引擎用,**不改 yang_writing_html2text()** ——
+ * 那个函数被摘要与分类引擎共用(yang_writing_split_sentences 依赖它的
+ * 换行结构),改了会连带影响摘要选句,而用户没要求改摘要。
  *
- * ── 3.2.57 第二轮:朴素 n-gram 的碎片问题(实测不过关,已修)──
- * 第一版只有三道闸门(长度 2~6 / 出现 ≥2 次 / 非停用词非词典词),
- * 拿「新博客」场景实测,输出是这样的:
- *   天文标题「星等换算与赤纬测量」
- *   → 星等换算与赤、等换算与赤纬、换算与赤纬测、算与赤纬测量
- *   「锔瓷工艺」→ 套结的
- * 全是**跨词边界的机械切片**,一个都不是标签。这正是当初排除纯
- * n-gram 方案的同一个毛病,第一版等于把它又请回来了。
+ * 🔴 入参必须是**原始 HTML**。代码块只能从 HTML 层面剥:一旦先跑过
+ *    html2text,`
` 的边界就丢了,事后无法还原 —— 第一版我就是
+ *    把已经拍平的正文传进来,正则写得再对也匹配不到任何东西。
  *
- * 修法不是调阈值,而是**先把句子切成短语再切 n-gram**:
- *   ① 在虚词/标点处断开,只在短语内部取 n-gram
- *      ——「星等换算与赤纬测量」被「与」切成「星等换算」「赤纬测量」
- *   ② 中文候选最多 4 字
- *      —— 回查 703 个真实标签:中文 6 字以上只有 3 个
- *         (「网上办公系统」「我在大学那几年」「基于Java的B/S系统构建」),
- *         都是人工写的完整短语,n-gram 永远造不出来;≤4 字覆盖 590/703
- *         (84%)
- *   ③ 不许以虚字结尾(的/与/和/了/是/在/就/都/也/说/把/被)
- *      —— 回查真实标签,以此结尾的 7 个里 6 个 count=0 是垃圾,
- *         唯一存活的「小说」count=69 是个完整的二字词,不是碎片
+ * ── 为什么必须剥代码块(实测,不是推测)──────────────────────
+ * #5140/#5167/#5139 三篇里 `laoyang` 的出现位置:
+ *   
 内 5 / 1 / 1,**正文其余 0 / 0 / 0** —— 一次都不在正文里。
+ * 上下文是 `/home/laoyang/.anaconda_backup/…`、`root@GPUServer:/home/laoyang# ip addr`。
+ * 再抽 40 篇统计代码块内的高频词:
+ *   dolly(65) torch(57) conda(50) home(46) text(34) cuda(32) https(30)
+ *   echo(28) include(28) python3(28) anaconda(28) sudo(25) commit(24) venv(22)
+ * 这些是命令行与路径,不是文章主题。技术博客里代码块普遍存在,
+ * 不剥掉的话 `sudo`/`commit`/`venv` 会稳定地把真正的主题词挤下标签位。
  *
- * 实测对照(同一批用例):
- *   A 传统手艺 → 竹编、漆器、锔瓷、竹编漆器、手艺、讲究、篾丝、髹涂
- *   B 天文观测 → 星等、星等换算、赤纬测量、子午仪读、蒙气差、做不准、消光、修正
- *   C 生造词   → 套结、珂玛珞套结、工艺、收口(切片「珂玛珞套」「玛珞套结」已消灭)
+ * 实测踩过的写法:`'#https?://\S#'` —— **漏了量词**,只吃掉一个字符,
+ * 整条 URL 根本没剥掉。
  *
- * ── 残留缺陷与为什么不继续修(诚实记录)────────────────────────
- * B 段仍有「子午仪读」(真词是「子午仪」)和「做不准」。
- * 根因是闸门 ③(5 字上限)与闸门 ⑤(切片消除)互相打架:
- * 原文「子午仪读取地平高度」里真正的长单位是「子午仪读取」(6 字),
- * 它被长度闸门砍掉 ⇒ 它的 4 字窗口「子午仪读」成了最长候选
- * ⇒ 反过来把真词「子午仪」按"切片"删掉了。
- *
- * 想彻底解决需要"成词度"(互信息 / 左右邻字熵)判断,但**单篇文章
- * 的样本量不够**:一篇文章里「读」出现 2 次就算高频,算出的熵与互信息
- * 噪声极大、不可靠(业界共识是要大语料)。服务器上也没有 scws/jieba
- * 任何中文分词扩展(实测,见项目记忆),无法做词典外分词。
- *
- * 所以这里的取舍是:**不假装完美,靠"只取前 N 个"兜底**。
- * 用户设的上限默认 3,而按频次降序时,A/B/C 三段的前 3 个恰好全部
- * 合格(竹编/漆器/锔瓷;星等/星等换算/赤纬测量;套结/珂玛珞套结/工艺)。
- * 排序靠后的噪声只在用户把上限调大时才会冒出来 —— 这是用户自己
- * 承担的可调项,设置页里已注明"建议保持 3"。
- *
- * @param string   $text      标题+正文
- * @param array    $dict      词典
- * @param int      $maxlen    最长词长
- * @param array    $stop      停用词
- * @param string[] $exist     已命中的已有标签(用于排除重叠)
- * @return string[] 建议新建的词(已去重、按频次降序)
+ * @param string $html 原始 post_content(含 
/)
+ * @return string 剥过代码块与 URL 的纯文本
  */
+function yang_writing_tags_clean($html)
+{
+    $t = (string) $html;
+    /* 代码块整段摘掉。pre 里常嵌 code,所以先去 pre 再处理剩下的内联 code。
+       非贪婪 + s 修饰符:跨行匹配,且不会被相邻两个块吞成一大段。 */
+    $t = preg_replace('#]*>.*?
#si', ' ', $t); + $t = preg_replace('#]*>.*?#si', ' ', $t); + $t = preg_replace('#]*>.*?#si', ' ', $t); + $t = preg_replace('#]*>.*?#si', ' ', $t); + /* 转纯文本:复用共用函数,但只在**它之前**动内容 ⇒ 摘要/分类看到的 + 仍是含代码块的完整正文,行为零变化。 */ + $t = yang_writing_html2text((string) $t); + $t = preg_replace('#(?:https?|ftp)://[^\s<>"\'))、,。;;]+#iu', ' ', (string) $t); + $t = preg_replace('#[\w.+\-]+@[\w\-]+\.[\w.\-]+#u', ' ', (string) $t); + /* 路径形态(/home/xxx、~/xxx、xxx/yyy):正文里手写的路径也要挡, + 否则用户名 laoyang 会从路径里漏回来。要求带斜杠,避免误伤 + 普通英文词(`i.e.`、`1/2` 这类不含字母开头路径)。 */ + $t = preg_replace('#(?:~[\\\/]?|(?:[A-Za-z]:)?[\\\\\/])[A-Za-z0-9_.\\\\\/\-]{2,}#u', ' ', (string) $t); + $t = preg_replace('#\b\d{4,}\b#u', ' ', (string) $t); + $t = preg_replace('/\s{2,}/u', ' ', (string) $t); + return trim((string) $t); +} + +/** + * 标签引擎专用最大匹配。 + * + * 与共用版 yang_writing_match() 的**唯一**差别:纯 ASCII 的词必须落在 + * **词边界**上才算命中。正文里出现的是 `password`,`passwo` 只是它的 + * 6 字窗口;不查边界,词典里那些历史切片就永远命中。 + * 中文算边界,所以「鸿蒙CalDav」里的 `caldav` 仍能命中。 + * + * 不改共用版签名是有意的:分类与摘要引擎各自调它 4 处, + * 加参数会让"标签专用规则"渗进别的引擎(四个引擎互不依赖)。 + * + * @return array 小写词 => 命中次数 + */ +function yang_writing_tags_match($text, $dict, $maxlen, $need_boundary = true) +{ + $text = mb_strtolower((string) $text, 'UTF-8'); + $len = mb_strlen($text, 'UTF-8'); + $hits = array(); + if ($len < 2 || $maxlen < 2 || !$dict) { + return $hits; + } + + $i = 0; + while ($i < $len) { + $matched = false; + $max = min($maxlen, $len - $i); + for ($l = $max; $l >= 2; $l--) { + $frag = mb_substr($text, $i, $l, 'UTF-8'); + if (!isset($dict[$frag])) { + continue; + } + if ($need_boundary && preg_match('/^[a-z0-9]+$/iu', $frag)) { + $prev = $i > 0 ? mb_substr($text, $i - 1, 1, 'UTF-8') : ''; + $next = mb_substr($text, $i + $l, 1, 'UTF-8'); + if (($prev !== '' && preg_match('/[a-z0-9]/iu', $prev)) + || ($next !== '' && preg_match('/[a-z0-9]/iu', $next))) { + continue; /* 不是整词 ⇒ 这个长度不算命中,继续试更短的 */ + } + } + $hits[$frag] = isset($hits[$frag]) ? $hits[$frag] + 1 : 1; + $i += $l; + $matched = true; + break; + } + if (!$matched) { + $i++; + } + } + return $hits; +} + +/** + * 标签显著度。返回 null 表示这个词直接淘汰。 + * + * score = 频次(标题加权) × 长度奖励 × 泛词惩罚 + * + * 与旧版的本质差别:旧版乘 **idf**(越稀有越高分),新版乘 **惩罚** + * (全站用得越多越扣分)。实测数据分布下"稀有"几乎等于"垃圾", + * idf 是在把垃圾往前排推。 + */ +function yang_writing_tags_score($word, $freq, $df, $stop) +{ + if (in_array($word, $stop, true)) { + return null; + } + if ($freq < 1) { + return null; + } + $len_fit = 1 + 0.12 * (mb_strlen($word, 'UTF-8') - 2); + $pen = 1 / (1 + log(1 + max(0, $df)) * 0.35); + return $freq * $len_fit * $pen; +} + +/** + * 标签引擎专用:按**原样大小写**拆出 ASCII 词形。 + * + * 为什么不能先 mb_strtolower 再拆:驼峰靠大写字母定界, + * 小写化之后大写全没了 —— 实测该正则对小写串**匹配结果为空**, + * 拆分彻底失效。⇒ "要先看大小写再归一化"的逻辑,顺序写反等于没写。 + * + * 同时收整词与驼峰/下划线分段:标题写 `SimplePassword`、正文写 + * `simple_password` 是同一个词的两种书写形态,不合并的话频次被分散、 + * 每个形态都够不上门槛 ⇒ `password` 永远出不来。 + * + * 🔴 键归一化成小写用于**匹配与去重**,但 `disp` 记下原文里出现最多的 + * 那种写法,用于**最终标签名** —— 给文章打一个全小写的 `simplepassword` + * 标签,与站内既有的 `CalDAV`、`NAS`、`WordPress` 风格不一致, + * 用户一看就知道是机器造的。匹配用小写、显示用原文,两件事不能混。 + * + * @return array 小写词形 => [次数, 最佳显示形态] + */ +function yang_writing_ascii_forms($raw_text) +{ + $out = array(); + preg_match_all('#[A-Za-z][A-Za-z0-9_\-]{3,}#', (string) $raw_text, $m); + foreach ($m[0] as $w) { + $forms = array($w); + if (preg_match_all('/[A-Z][a-z0-9]+/', $w, $cm)) { + foreach ($cm[0] as $c0) { + if (mb_strlen($c0, 'UTF-8') >= 4) { + $forms[] = $c0; + } + } + } + foreach (preg_split('/[_\-]+/', $w, -1, PREG_SPLIT_NO_EMPTY) as $p0) { + if (mb_strlen($p0, 'UTF-8') >= 4) { + $forms[] = $p0; + } + } + foreach ($forms as $f0) { + $k = mb_strtolower($f0, 'UTF-8'); + if (!preg_match('/^[a-z0-9]+$/iu', $k) || mb_strlen($k, 'UTF-8') < 4) { + continue; + } + if (preg_match('/^\d+$/', $k)) { + continue; + } + if (!isset($out[$k])) { + $out[$k] = array('c' => 0, 'd' => $f0, 'dn' => array()); + } + $out[$k]['c']++; + /* 显示形态取"原文里出现最多的那种写法" */ + $out[$k]['dn'][$f0] = (isset($out[$k]['dn'][$f0]) ? $out[$k]['dn'][$f0] : 0) + 1; + } + } + foreach ($out as $k => $v) { + arsort($v['dn']); + $out[$k]['d'] = (string) key($v['dn']); + unset($out[$k]['dn']); + } + return $out; +} + /** * 虚词表 —— 切分短语与「不许以虚字结尾」**共用这一份**。 * @@ -474,143 +779,169 @@ function yang_writing_seps() '于', '之', '其', '这', '那', '个', '们', '说'); } -function yang_writing_find_new_terms($text, $dict, $maxlen, $stop, $exist) +/** + * 在词典之外找值得新建的标签候选。**#38 重写:两路合并。** + * + * ── 签名变化必须注意 ──────────────────────────────────────── + * 旧版是 `( $text, $dict, $maxlen, $stop, $exist )` —— 标题和正文粘成一个 + * $text 传进来,所以**没有"这个词在不在标题里"这个信号**(#38 根因 ④)。 + * 新版改成 `( $title, $body, ... )` 分开传。 + * + * 🔴 顺带一个 PHP 特有的坑:改签名那天,调用处已经传 6 个参数、 + * 函数头还是 5 个 —— **PHP 不报错**,多余参数被忽略, + * 于是 $dict 收到第 3 个实参(本该是 $maxlen)、往后全部错位, + * 表现为"词典查不到任何词、新词候选恒空"。 + * 改函数签名时**必须同时**核对所有调用点的参数个数, + * 不能指望语言帮你拦(判据里有一条专门盯这个)。 + * + * ── 两条通道 ──────────────────────────────────────────────── + * A. 中文 n-gram:闸门逻辑沿用 3.2.57(虚词切短语 + 闸门⑤切片消除), + * 新增「泛词也先抹掉」—— 实测「如果有任何问题」会切出「有任何」、 + * 「很多人」会切出「多人」,它们频次≥2、本身不是停用词、长度合规, + * 三道闸门全过。抹掉泛词等于在**切片之前**就断掉这类跨界组合。 + * B. 英文/数字整词:**不切窗口**,按空格与中英边界取整词 + * (#38 根因 ②:`password` 8 字超上限进不了候选, + * 它的 6 字窗口 `passwo` 却因"没有更长的对照物"而存活)。 + * 整词出现 1 次即可进候选 —— 英文词形本身就是强信号, + * 不像中文 gram 需要频次佐证;长技术名词天然低频。 + * + * 两路合并后再做一次**跨通道切片消除**:`simplepassword` 与 `password` + * 是同一个词的两种形态,并列成三个标签比垃圾还刺眼(实测踩过)。 + * + * @param string $title 已剥 URL 的标题 + * @param string $body 已剥 URL 的正文 + * @param array $dict 词典 + * @param int $maxlen 最长词长(保留形参,与词典接口一致) + * @param array $stop 停用词 + 泛化实词 + * @param string[] $exist_names 已命中的已有标签(排除重叠) + * @return string[] 建议新建的词,已排序、最多 8 个 + */ +function yang_writing_find_new_terms($title, $body, $dict, $maxlen, $stop, $exist_names) { - /* 先把已命中的词从文本里"抹掉",避免它们的长词片段被当成新词。 - 比如已命中「信息焦虑」时,不该再冒出「息焦虑」这种切片。 */ - $masked = mb_strtolower($text, 'UTF-8'); - foreach ($exist as $name) { + $seps = yang_writing_seps(); + $title_low = mb_strtolower((string) $title, 'UTF-8'); + $raw_all = (string) $title . ' ' . (string) $body; + + /* 抹掉已命中标签与泛词,再切片。 + 已命中标签:命中「信息焦虑」时不该再冒「息焦虑」。 + 泛词:见上方说明,斩断「有任何」「多人」这类跨泛词切片。 */ + $masked = mb_strtolower($raw_all, 'UTF-8'); + foreach ((array) $exist_names as $name) { $masked = str_replace(mb_strtolower($name, 'UTF-8'), '█', $masked); } + foreach ($stop as $gw) { + if (mb_strlen($gw, 'UTF-8') >= 2) { + $masked = str_replace($gw, '█', $masked); + } + } - /* 🔴 关键一步:先按虚词与标点把文本切成**短语**,只在短语内部取 n-gram。 - * - * 不做这一步,n-gram 会横跨词组边界。「星等换算与赤纬测量」切成 - * 2~6 字的滑动窗口,必然产出「星等换算与赤」「换算与赤纬测」这种 - * 既不是词、也读不通的碎片 —— 而它们在字面上"出现 ≥2 次", - * 通过所有频次闸门。这是 n-gram 方案的固有缺陷,只能靠切分规避。 - * - * 用虚词当隔离符是有依据的:中文里「与/和/的/了」几乎总在词组分界处, - * 真正的标签很少横跨它们(703 个真实标签里含虚字的只有 9 个, - * 且都是「乐队的夏天」「我在大学那几年」这类人工写的完整标题)。 - * - * ── 3.2.57 第三轮:这张表是**唯一来源** ───────────────────── - * 第一版把同一批虚字写了两遍:这里一份给 $sep 做切分,闸门 ④ - * 又抄了一份做「不许以虚字结尾」判断。实测(_probe_gate4.php): - * 两张表 28 个字完全相同,差集两边都是空; - * 30 篇真实语料、239 个候选,以虚字结尾的 **0 个**; - * 把闸门 ④ 整段短路,仍然 0 个。 - * 结论:**闸门 ④ 是死代码** —— $sep 已经把每个虚字换成空格, - * 切出来的 chunk 里根本不含虚字,它永远不可能命中。 - * - * 保留它不是为了好看:切分规则将来若放宽(比如只切「的/了」两个), - * 闸门 ④ 立刻变成有效防线。但两张表必须共用一个来源, - * 否则放宽一处、忘记另一处,就会重演「两套名单各说各话」。 - * $sep 的正则形态由 yang_writing_seps() 统一生成。 */ - $seps = yang_writing_seps(); + /* ── 通道 A:中文 n-gram ────────────────────────────────── + 与 3.2.57 相同:先按虚词切短语,只在短语内部取窗口。 + 「星等换算与赤纬测量」被「与」切成「星等换算」「赤纬测量」, + 否则 2~6 字滑动窗口必然产出「星等换算与赤」这种碎片。 */ $clean = preg_replace('/' . implode('|', $seps) . '/u', ' ', $masked); - /* 再只保留中文与字母数字,其它一律当分隔符 —— 标点、空格、HTML 残留 - 都会让 n-gram 切出无意义片段。 */ - $clean = preg_replace('/[^\x{4e00}-\x{9fa5}a-zA-Z0-9]+/u', ' ', (string) $clean); - if (!is_string($clean) || trim($clean) === '') { - return array(); - } - - $chunks = preg_split('/\s+/u', trim($clean), -1, PREG_SPLIT_NO_EMPTY); + /* 只留中文:ASCII 段交给通道 B,不在中文窗口里掺英文(跨中英边界 + 切出的「具simple」「simplepassword」这种组合不是标签)。 */ + $clean = preg_replace('/[^\x{4e00}-\x{9fa5}]+/u', ' ', (string) $clean); + $chunks = ($clean && trim($clean) !== '') + ? preg_split('/\s+/u', trim($clean), -1, PREG_SPLIT_NO_EMPTY) + : array(); if (!is_array($chunks)) { - return array(); + $chunks = array(); } - $freq = array(); + $cand = array(); + /* 显示形态表:只有 ASCII 词需要记录(中文词显示形态=自身)。 + 与 $cand 同处初始化,别等通道 B 循环里才首次赋值 —— 万一 + 通道 B 一个词都没进,后面的 isset($disp[...]) 也要在确定的 + 作用域里求值。 */ + $disp = array(); foreach ($chunks as $chunk) { $len = mb_strlen($chunk, 'UTF-8'); - /* 2~6 字。低于 2 无意义;高于 6 基本不可能是标签。 - 注意:这里放宽到 6 是为了让长短语先进入候选, - 真正的中文上限在闸门 ③ 收紧到 4。 */ + /* 生成到 6 字,闸门收到 5 字。 + 🔴 生成范围必须留到 6:6 字长语同时充当 4~5 字切片的**对照物**。 + 模拟器转产品时我把它降到 4,切片池里没有更长的词, + 闸门⑤整条失效,「悲喜并不相通」被切成三个都留下。 */ for ($l = 2; $l <= 6; $l++) { if ($l > $len) { break; } for ($i = 0; $i + $l <= $len; $i++) { $g = mb_substr($chunk, $i, $l, 'UTF-8'); - /* 纯数字不要(「360」「2026」这类当标签没意义)。 */ - if (preg_match('/^\d+$/', $g)) { - continue; - } - $freq[$g] = isset($freq[$g]) ? $freq[$g] + 1 : 1; + $cand[$g] = isset($cand[$g]) ? $cand[$g] + 1 : 1; } } } - - if (!$freq) { - return array(); + /* 闸门:频次 ≥2、非停用词、不在词典、中文 ≤5 字、不以虚字结尾 */ + foreach ($cand as $g => $c) { + if ($c < 2 || in_array($g, $stop, true) || isset($dict[$g])) { + unset($cand[$g]); + continue; + } + if (mb_strlen($g, 'UTF-8') > 5) { + unset($cand[$g]); + continue; + } + if (in_array(mb_substr($g, -1, 1, 'UTF-8'), $seps, true)) { + unset($cand[$g]); + } } - /* 闸门 ①:出现 ≥ 2 次。 - 闸门 ②:不是停用词、不在词典里。 - 这两条一起把候选量从上千压到个位数。 */ - $cand = array(); - foreach ($freq as $g => $c) { - if ($c < 2) { + /* ── 通道 B:ASCII 整词(不切窗口)──────────────────────── */ + /* ascii_forms 返回的是 [小写词形 => ['c'=>次数,'d'=>原文写法]]。 + 🔴 这里必须取 ['c'] 而不是整个值 —— 早期版本按 "词 => 次数" 设计, + 后来为了保留 `SimplePassword` 的原始大小写改成了结构体, + 如果这边忘了改,max() 会拿到数组、直接 TypeError。 */ + $ascii = yang_writing_ascii_forms($raw_all); + foreach ($ascii as $w => $info) { + if (isset($dict[$w]) || in_array($w, $stop, true)) { continue; } - if (in_array($g, $stop, true)) { - continue; - } - if (isset($dict[$g])) { - continue; - } - /* 闸门 ③:中文最多 5 字(依据:703 个真实标签里含中文的 525 个, - ≤4 字占 98.3%、≤5 字占 99.2%;5 字的那 5 个是「中国共产党」 - 「自然辩证法」「计算机领域」这类完整短语,不是碎片)。 - 纯 ASCII 候选不受此限 —— 英文技术词本来就可能很长 - (「kubernetes」10 字仍在用)。 */ - if (preg_match('/[\x{4e00}-\x{9fa5}]/u', $g) && mb_strlen($g, 'UTF-8') > 5) { - continue; - } - /* 闸门 ④:不许以虚字结尾。 - 与 $sep 共用 yang_writing_seps() 那一份名单(见上方长注释) - —— 当前它不可达,保留是为了「切分规则将来放宽时」仍有防线。 */ - $tail = mb_substr($g, -1, 1, 'UTF-8'); - if (in_array($tail, yang_writing_seps(), true)) { - continue; - } - $cand[$g] = $c; + $cand[$w] = isset($cand[$w]) ? max($cand[$w], $info['c']) : $info['c']; + $disp[$w] = $info['d']; } if (!$cand) { return array(); } - /* 闸门 ⑤:消除「长词的滑动窗口切片」。 - * - * 只做长度限制是不够的。若正文里反复出现「珂玛珞套结」(5 字真词), - * 4 字窗口会同时产出「珂玛珞套」「玛珞套结」—— 它们与真词**频次完全相同** - * (同一处文本切出来的),靠频次排序分不开,最后拿到的是一堆切片。 - * - * 判据:候选 $g 若被另一个更长的候选 $bigger 包含,且 - * count($bigger) >= count($g),说明 $g 只是 $bigger 的切片 ⇒ 丢掉。 - * - * 为什么用「>=」而不是「>」:真词与它的切片频次**必然相等**(同一处 - * 文本产生),所以必须用 >= 才能抓到。反过来,两个真正独立的词 - * (「竹编」与「竹编漆器」)里,短的那个频次通常**更高**(单独出现 - * 的次数多于连写),此时 count($bigger) < count($g),不会被误删。 - */ - /* 🔴 先拍一份频次快照再删。 - 第一版直接在 $cand 上边遍历边 unset,外层 $keys 是循环开始前算的 - —— 某个键被删掉之后,后面几轮还在读 $cand[$bigger],于是满屏 - "Undefined array key"。判据本身没错,是遍历方式错了: - **边遍历边修改的数组,索引必须提前固定成快照**。 */ - $freq_snap = $cand; - $keys = array_keys($freq_snap); - $nkeys = count($keys); - for ($i = 0; $i < $nkeys; $i++) { - $g = $keys[$i]; + /* ── 跨通道切片消除 ────────────────────────────────────── + 判据同旧闸门⑤:被更长的候选包含 ⇒ 短的丢。 + 用 >= 而非 > :真词与它的切片频次**必然相等**(同一处文本切出来的)。 + 反向情形(「竹编」与「竹编漆器」独立成词)短词频次通常更高,不会误删。 + 额外加一条:ASCII 之间不看频次直接比长度 —— 同一词的形态变体 + (password / simplepassword)频次天然接近,只按长度就能定谁是被包含者。 */ + /* 🔴 先拍快照再删(旧注释里记过一次:边遍历边 unset 会 "Undefined array key") */ + $snap = $cand; + $keys = array_keys($snap); + $nk = count($keys); + for ($i = 0; $i < $nk; $i++) { + $g = $keys[$i]; + /* 🔴 这里第一版写成 if (isset($cand[$g])) continue; —— 意思是 + "还在候选里的跳过",方向**正好反了**:循环开始时所有键都还在 + $cand 里,于是每一轮一进来就 continue,整个切片消除一次都不执行, + 而且不会报错、看起来语法完全正常。 + 正确的守卫是"已经被前面几轮删掉的就没必要再比",即 !isset 才跳过。 */ + if (!isset($cand[$g])) { + continue; + } $glen = mb_strlen($g, 'UTF-8'); - for ($j = 0; $j < $nkeys; $j++) { - if ($i === $j) { continue; } + $g_ascii = (bool) preg_match('/^[a-z0-9]+$/iu', $g); + for ($j = 0; $j < $nk; $j++) { + if ($i === $j) { + continue; + } $bigger = $keys[$j]; - if (mb_strlen($bigger, 'UTF-8') <= $glen) { continue; } - if ($freq_snap[$bigger] < $freq_snap[$g]) { continue; } + if (mb_strlen($bigger, 'UTF-8') <= $glen) { + continue; + } + /* 中英跨通道不比包含(「工具simple」这种不会作为候选出现, + 但比一下长度也无害;只在同类之间比,语义更清楚) */ + if ($g_ascii !== (bool) preg_match('/^[a-z0-9]+$/iu', $bigger)) { + continue; + } + if (!$g_ascii && $snap[$bigger] < $snap[$g]) { + continue; // 中文通道保留旧的频次条件 + } if (mb_strpos($bigger, $g, 0, 'UTF-8') !== false) { unset($cand[$g]); break; @@ -621,23 +952,55 @@ function yang_writing_find_new_terms($text, $dict, $maxlen, $stop, $exist) return array(); } - /* 按频次降序;频次相同按长度降序(长词信息量更大)。 */ - uksort($cand, function ($a, $b) use ($cand) { - if ($cand[$a] !== $cand[$b]) { - return $cand[$b] <=> $cand[$a]; + /* 与已选标签重叠的不再出(「密码」已选,就别再出「密码生成」) */ + foreach ((array) $exist_names as $e0) { + $el = mb_strtolower($e0, 'UTF-8'); + foreach (array_keys($cand) as $w) { + if (mb_strpos($w, $el, 0, 'UTF-8') !== false || mb_strpos($el, $w, 0, 'UTF-8') !== false) { + unset($cand[$w]); + } + } + } + if (!$cand) { + return array(); + } + + /* 排序:标题命中者优先,且 **ASCII 标题词的权重是中文的 2 倍**。 + 🔴 为什么必须不对称:实测 26 篇"标题含英文专名"的文章里有 9 篇专名落选 + (simplepassword / RIME / Debian / Linux / error),全是被中文词挤掉的。 + 原因是频率天然不对等:中文词(「开源」「生成」)在一篇技术文里能出现 + 四五次,而英文专名通常标题+正文加起来就两三次 —— 同等加分下 + 低频的专名必输高频的中文。而"标题里的英文词"恰恰是技术博客的 + 主角(CalDAV、PyTorch、Debian…),它落选就是主题词丢失。 + ⇒ ASCII 标题词 +2 倍权重,用规则把这种不对等拉平, + 而不是拿单篇文章调出来的魔数。 */ + $rank = array(); + foreach ($cand as $w => $c) { + $in_title = ($title_low !== '' && mb_strpos($title_low, $w, 0, 'UTF-8') !== false); + $boost = YANG_TAG_TITLE_WEIGHT * (preg_match('/^[a-z0-9]+$/iu', $w) ? 10 : 5); + $rank[$w] = ($in_title ? $boost : 0) + $c; + } + uksort($rank, function ($a, $b) use ($rank) { + if ($rank[$a] !== $rank[$b]) { + return $rank[$b] <=> $rank[$a]; } return mb_strlen($b, 'UTF-8') <=> mb_strlen($a, 'UTF-8'); }); - /* 取前 8 个。 - * - * ⚠️ 3.2.57 第一版这里还有一段「按已保留项过滤子串」的循环, - * 现在删掉了 —— 闸门 ⑤ 已经在**全局**做过切片消除(对所有候选 - * 两两比对,而不是只跟已保留的那几个比),再来一遍是重复劳动, - * 而且这段是先排序后过滤,会漏掉「排序靠后的长词包含前面短词」 - * 这种方向,判据在两种实现下结果不同,留着容易误判。 - * 现在只做截断。 */ - return array_slice(array_keys($cand), 0, 8); + /* 取前 8 个交给调用方按硬上限截断。 + ⚠️ 这里不预先按"只跟已保留项比"再过一遍子串 —— 上面的消除已经在 + **全局**两两比对过,重复劳动且方向不全(见 3.2.57 的记录)。 + + 🔴 返回的是**显示形态**而不是匹配用小写。 + 实测 #7161 曾产出全小写的 `simplepassword`,而站内既有标签是 + `CalDAV`/`NAS`/`WordPress` 这种带原始大小写的写法 —— 一个全小写的 + 机器造词在标签云里一眼假。中文词的显示形态就是它自己, + 所以 $disp 只对 ASCII 词有值,其余原样返回。 */ + $out = array(); + foreach (array_slice(array_keys($rank), 0, 8) as $w) { + $out[] = isset($disp[$w]) ? $disp[$w] : $w; + } + return array_values(array_unique($out)); } /* ══════════════════════════════════════════════════════════════ @@ -769,7 +1132,14 @@ function yang_writing_make_excerpt($title, $body, $target = 150) if (in_array($w, $stop, true)) { continue; } - $weight[$w] = log(($N + 1) / ($info['count'] + 1)) + 1; + /* 🔴 max(1, ...) 是**故意的**,不是防御性代码。 + #38 把词典里的 count 改成保留真实值(0 不再被抬成 1), + 因为标签引擎要靠"0 vs 1"区分"从没用过"和"只用过一次"。 + 但摘要引擎必须维持**旧语义**:这里 count 从 1 变 0 会让 + log((N+1)/(count+1)) 从 6.97 抬到 7.66,选句结果跟着变 —— + 而用户没有要求改摘要。所以在本行显式夹回 1,摘要行为零变化。 + 四个引擎互不依赖是本文件的硬约束(见文件头)。 */ + $weight[$w] = log(($N + 1) / (max(1, (int) $info['count']) + 1)) + 1; } $title_hits = yang_writing_match($title, $dict, $maxlen); @@ -1238,18 +1608,25 @@ function yang_writing_apply($post) 每个词都来自词典、都是已有标签,必然全部命中 ⇒ 「新建」那个 分支永远进不去 ⇒ 这条功能在真实发布流程里**一次都没生效过**。 用户提这个需求的初衷就是「如果不能新增,一个新的博客, - 永远无法使用这个功能」,所以这是个真 bug,不是洁癖。 */ - list($exist_hits, $new_cands) = yang_writing_make_tags($title, $body); + 永远无法使用这个功能」,所以这是个真 bug,不是洁癖。 + + 🔴 #38:第二个参数必须传 **$html(原始正文)**,不能传 $body。 + $body 是 html2text 拍平过的文本,`
`/`` 的边界在它里面
+           已经不存在 —— 传 $body 等于代码块剥离整条静默失效,而且**不报任何
+           错**(A/B 实测:传 $body 时 `laoyang` 残留 3 处,改传 $html 后归零;
+           #5140 的 laoyang 在 
 内出现 5 次、正文 0 次)。
+           PHP 不会帮你拦参数语义错误,这条靠 _tag38_check.py 的结构判据守。 */
+        list($exist_hits, $new_cands) = yang_writing_make_tags($title, $html);
 
         $limit = (int) yang_writing_opt('w_tag_new_limit', 3);
         if ($limit < 0) {
             $limit = 0;
         }
 
-        /* 已有标签全收,不占新增额度。仍然 get_term_by 复核一遍:
-           词典是缓存出来的,标签可能刚被管理员删掉;若直接写名字,
-           wp_set_post_tags 会把它重新建出来 —— 那就等于偷偷绕过了
-           用户设的新增上限。 */
+        /* 已有标签:复核后占用「总标签数」的名额(不再无上限全收)。
+           仍然 get_term_by 复核一遍:词典是缓存出来的,标签可能刚被管理员
+           删掉;若直接写名字,wp_set_post_tags 会把它重新建出来 ——
+           那就等于偷偷绕过了用户设的新增上限。 */
         $exist = array();
         foreach ($exist_hits as $name) {
             $term = get_term_by('name', $name, 'post_tag');
@@ -1278,8 +1655,28 @@ function yang_writing_apply($post)
 
         $all = array_values(array_unique(array_merge($exist, $new)));
         if ($all) {
-            /* append=true —— 用户手工打的标签**不能删**。 */
+            /* append=true —— 用户手工打的标签**不能删**。
+               副作用要说清楚:append=true 意味着总标签数无法硬封顶
+               (用户手工打了 10 个、引擎再追加 6 个就是 16 个)。
+               这是有意的取舍 —— 删用户的标签比标签偏多严重得多。
+               封顶只对**引擎自己产出的部分**生效:exist 与 new 各自受
+               YANG_TAG_MAX_EXIST / w_tag_new_limit 约束,不会一次灌太多。 */
             wp_set_post_tags($post_id, $all, true);
+
+            /* 🔴 给引擎新建的标签打标记 —— 这一步才是斩断污染闭环的关键。
+               没有它,下一篇文章建词典时(yang_writing_dict 取的是全量标签)
+               这些词就会以"权威已有标签"的身份被无脑复用,
+               垃圾越滚越多。实测 `passwo`/`任何`/`复杂` 正是这么进的第二篇。
+
+               用 term meta 而不是拿 count 猜:count=1 里既有垃圾(衣服/社会,
+               只在举例段落出现一次),也有好词(鸿蒙/日历,在标题里)。
+               来源才是可分的那一维,使用次数不是。 */
+            foreach ($new as $nm) {
+                $t = get_term_by('name', $nm, 'post_tag');
+                if ($t && !is_wp_error($t)) {
+                    update_term_meta((int) $t->term_id, 'yang_auto', '1');
+                }
+            }
             $did[] = 'tag';
         }
     }
diff --git a/style.css b/style.css
index 9f105d6..b590c87 100644
--- a/style.css
+++ b/style.css
@@ -1,7 +1,7 @@
 /*
 Theme Name: Kratos_Yang
 Text Domain: kratos_Yang
-Version: 4.2.0
+Version: 4.2.1
 Requires PHP: 7.0
 Description: 这是一个Kratos主题的分支版本,用来增加一些定制化功能。
 Tags: 博客, 双栏, 响应式, 自定义背景, 自定义颜色, 自定义图标, 自定义菜单, 特色图像, 收录优化, 邮件中心, 主题选项, 小工具, 文章置顶, 无障碍友好