Files
laoyang c2d8ac6f17 标签算法重写:斩断词典污染闭环、英文整词、去掉 idf 奖励 #38
用户报「最近两篇文章标签生成效果不好」。线上实测确认:

#7161《开源密码生成工具SimplePassword》入库 11 个:
  https | laoyang | passwo | script | 代码 | 任何 | 复杂 | 密码 | 服务 | 记录 | 输入
只有「密码」沾主题,其余是 URL 碎片、代码切片、虚词、泛词。
#7157《痛苦根源》入库 11 个:
  世界 | 孩子 | 幸福 | 微博 | 手机 | 无法 | 欲望 | 社会 | 老人 | 衣服 | 阶级
「衣服」「老人」「手机」全是从举例段落抓的,而标题词「痛苦」反而没入库。

四条根因,每条都有实测数据:

① 污染闭环(最关键)。词典 = 全量标签(get_terms),而上一轮引擎造的
   passwo/任何/衣服(term_id 3423~3428,正是这两篇发布时新建的)下一轮就
   变成"已有标签",走 exist 分支不占限额、无条件追加。垃圾进库→进词典→
   被当权威复用→喂大词典。修法:引擎新建标签打 term meta yang_auto,
   词典按该标记识别并拒绝复用(除非它出现在本篇标题里,那是作者自己在用)。
   判据用标记不用 count 猜 —— 实测 count=1 里既有垃圾(衣服/社会,只在正文),
   也有好词(鸿蒙/日历,在标题),count 分不开来源。

② 英文被 n-gram 切窗口。password(8字) 超 6 字上限进不了候选,它的 6 字窗口
   passwo 频次与它相同,而切片消除要求"存在更长的候选包含它"才生效 —— 那个
   更长词没进候选,切片没有对照物直接存活。库里非中文 count<=1 有 104 个
   (gcc/ls/set/no/utf/cn/perl/ul/else/null/let/add/opt/usr…)。
   修法:ASCII 按空格与中英边界取整词,不切窗口;整词+驼峰段+下划线分段
   形态合并计频(SimplePassword 与 simple_password 是同一个词);
   显示形态保留原文大小写,不产出全小写的机器味标签。

③ IDF 方向反了。实测 N=969:工作 df=62→3.73,代码 df=18→4.93,
   而 passwo/任何/衣服 df=1→7.18 全场最高。这份分布下"稀有"≈"垃圾",
   公式在精准地把垃圾顶到前排。改成 df 越高罚越狠的惩罚项。

④ 没有主题性约束。$full = $title.' '.$body 让标题只占一小段,痛苦(标题词)
   与衣服(举例一次)在 TF 上拉不开。改成标题/正文分开计数、标题命中高权重;
   df<=1 的词要求标题佐证或本文高频。

另外:剥 <pre>/<code>/URL/邮箱/路径(laoyang 实测在 <pre> 内出现 5 次、
正文 0 次;40 篇代码块内高频词是 dolly/torch/conda/home/sudo/venv 这些
命令行与路径);停用词扩表但必须是并集 —— 泛化实词只在标签作用域生效,
摘要与分类看到的词表一字不变。

exist 上限 8 全收 → 6,且新词与 exist 重叠不再重复出。

验证(都是真实产品代码,不是模拟器副本):
- 同进程双版本 A/B(新版改前缀 yw38_ 并存,同词典同数据)59 篇:
  垃圾率 0.898→0.000、虚词率 0.831→0.000、标题词覆盖 0.707→0.809、
  均标签 10.9→8.5;耗时均值 98.2→84.3ms(新版更快),峰值 2057→2036ms
  且两版峰值出自同一篇 9610 字长文 ⇒ 不是回归。
  #7161 → 密码|服务|代码|开源|技巧|SimplePassword(完整词形带大小写)
- 越界检查 16/16:59 篇摘要输出逐字一致、分类逐项一致;stopwords() 无参
  集合相同;max(1,count) 投影逐条相同(摘要 idf 权重零变化,
  为此在摘要侧显式把 count 夹回 1)。
- 打标集成验证三段式(会写库,测后自证零残留):setup 全绿、
  verify 对照成立(旧算法同输入复用 3/3、新算法 0)、
  teardown 确认 709→709 无残留、草稿删净、站点配置未动。
  必须分进程是因为 dict() 带 static 缓存 —— 同进程断言"新词没被复用"
  是假绿(它还没进词典,不是过滤器起作用)。
- 判据三层:正向 55/0、反证 12/12、meta 19/0(12 组注入与断言的绑定
  关系逐组证实 + 7 项结构检查)。

发布 4.2.1:包 md5 01141648eda7a58d8f4258cfda424b1a、239 文件、4.66MB;
zip 与 readme.html 走 HTTP 200 且下载回来 md5 逐字一致;最后一步才改
update.json,并清掉派生缓存 puc_external_updates_theme-kratos_yang,
确认 PUC 检测到 4.2.1。

过程里被自己写的判据反噬三次,都记进 pitfalls:
- 反证脚本 write(sub1(...)) 且锚点未命中返回 None,open('w') 先把产品文件
  截成 0 字节才抛 TypeError —— 靠事前 cp 备份救回(#514 现场重演)。
  现在 write() 先校验类型与长度再打开文件,且还原进 finally + atexit 双保险。
- 断言文案里插了变量("传 %s" % arg),meta 拿静态期望串找不到序号;
  文案是机器对齐用的,不能随被测状态漂移。
- 改名用 str_replace 全文替换,把 '_yang_writing_done' 这个 meta **键名**
  一起改了,导致"幂等锁生效"是假绿。改用 token_get_all 只动标识符。

写 4.2.1 顺带纠正 #37 发现的 readme.html 三处错误说法:头像不是"浏览器本地
生成"而是 PHP 服务器端生成;自动头像不在「全站配置」下而是独立分组;
不存在"只对评论生效/全站生效"这个开关(实测无此选项,已删该句)。
2026-10-28 23:03:49 +08:00

1809 lines
81 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 写作增强 —— 发布文章时自动生成别名 / 标签 / 分类 / 摘要
*
* ── 四个引擎各自独立 ──────────────────────────────────────────
* 用户明确要求「每项都是独立引擎,是不是要用外部服务,你来判断,能不用就不用」。
* 所以下面四个生成器互不依赖,每个都能单独开关、单独失败:
*
* ① 别名(slug) 必须外部翻译 —— 纯 PHP 做不到中译英(见下方实测)
* ② 标签 本地优先,可选外部 —— 703 个已有标签就是最好的领域词典
* ③ 分类 纯本地 —— 不需要任何网络
* ④ 摘要 纯本地 —— 不需要任何网络
*
* ── 为什么别名一定要外部服务 ──────────────────────────────────
* 在线上实测过三条路,只有第三条可用:
* · intl 拼音:`信息焦虑综合症` → `xin-xi-jiao-lu-zong-he-zheng`
* 能用但不是翻译,且长到没法看。
* · 拿 966 组「中文标题→英文 slug」训练词对齐:5 个测试标题里 3 个
* **译出 0 个词**(数据摊到 8521 个中文片段上太稀疏)⇒ 不可用。
* · 翻译 API:`信息焦虑综合症` → `Information Anxiety Syndrome` ⇒ 可用。
* 服务器没有 scws / jieba 任何中文分词扩展,所以「本地发现新词」这条路
* 本身也是走不通的 —— 这不是偷懒,是环境事实。
*
* ── 三条红线(改动本文件前先读)────────────────────────────────
* ① **绝不覆盖已有值。** 用户原话:「别名可能意味着 url,url 变化了会
* 造成大量的 404 错误」。所以 slug 只在为空时写,其它三项同理。
* ② **绝不处理旧文章。** 只在「第一次变成 publish」时触发。
* 已在 publish 状态下再次保存 ⇒ 直接返回,一个字都不动。
* ③ **外部服务失败必须静默降级。** 发布是用户的主流程,不能让一个
* 翻译接口超时把「发布」这个动作卡住或报红。
*
* @author Yongquan Yang <yangyq.net>
* @license MIT License
*/
/* ══════════════════════════════════════════════════════════════
* 一、设置读取
* ══════════════════════════════════════════════════════════════ */
/**
* 读一项写作增强设置。
*
* 统一走 kratos_option(),与主题其它模块一致;不做任何类型转换,
* 由调用方自己决定(复选框要布尔,上限要整数)。
*
* @param string $key 设置键名(不含前缀,本模块统一 w_ 开头)
* @param mixed $default 库里没这个键时的默认值
* @return mixed
*/
function yang_writing_opt($key, $default = '')
{
return kratos_option($key, $default);
}
/**
* 某个引擎是否启用。
*
* ⚠️ 复选框未勾选时不会出现在 POST 里,所以库里存的是 false;
* 但**键不存在**(用户从没保存过设置)时也应当视为关闭 ——
* 这是新装主题的正确语义:不能默认就往用户的文章里写东西。
*/
function yang_writing_on($key)
{
$v = yang_writing_opt($key, '0');
return $v === true || $v === '1' || $v === 1 || $v === 'on';
}
/* ══════════════════════════════════════════════════════════════
* 二、文本处理(纯本地,四个引擎共用)
* ══════════════════════════════════════════════════════════════ */
/**
* HTML 正文 → 纯文本。
*
* 顺序有讲究:先摘掉 script/style 整块(strip_tags 只剥标签、留内容,
* 会把 JS 源码当成正文喂进分词),再补换行给块级元素(否则段落会粘成
* 一坨,分句全错),最后才 strip_tags。
*/
function yang_writing_html2text($html)
{
$t = (string) $html;
$t = preg_replace('#<script[^>]*>.*?</script>#si', ' ', $t);
$t = preg_replace('#<style[^>]*>.*?</style>#si', ' ', $t);
$t = preg_replace('#<!--.*?-->#s', ' ', $t);
/* 块级元素结尾补换行:分句依赖它,不然「…结束了。下一段…」可能
被当成一句,摘要里就会出现跨段拼接。 */
$t = preg_replace('#<(br|/p|/div|/h[1-6]|/li|/blockquote|/pre)\b[^>]*>#si', "\n", $t);
$t = strip_tags($t);
$t = html_entity_decode($t, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$t = preg_replace('/[ \t\x{3000}\x{00a0}]+/u', ' ', $t);
$t = preg_replace('/\n{2,}/u', "\n", $t);
return trim($t);
}
/**
* 中文停用词 + 泛化实词。
*
* 为什么必须有:抽取式摘要/标签排序里,如果「我们」「这个」「可以」
* 拿到高权重,摘要选出来的每一句都会是废话,标签会全是虚词。
* 这份表是**手工挑的高频虚词与泛化名词**,不是通用停用词表 ——
* 通用表里那些「的、了、在」是单字,本来就被「长度 ≥ 2」挡掉了。
*
* ── #38:$extra 参数 ────────────────────────────────────────
* 40 篇新旧对照实测暴露出第二类漏网:「任何」「无法」「复杂」「得到」
* 「所有」「永远」「提供」「发展」这些**不是虚词、但同样没有信息量**的
* 泛化实词。它们躲过了原表(原表只收虚词),命中即上榜,
* 例如 #7161 入库了「任何」「复杂」,#7157 入库了「发展」。
*
* 🔴 为什么用参数而不是直接往主表里加:
* 主表被**摘要引擎**共用(yang_writing_make_excerpt 也调本函数)。
* 摘要选句的判据是"这句像不像人话",把「提供」「使用」这种词
* 从候选里整体剔掉,可能让摘要选不到合适的句子 —— 那是
* 用户没要求的另一件事。所以泛化实词**只在标签路径**(传 'tags')生效。
* 这也是「四个引擎互不依赖」这条硬约束的字面实现方式。
*
* @param string $scope '' = 只给虚词表(摘要/分句沿用旧行为);
* 'tags' = 虚词表 ∪ 泛化实词表(仅标签引擎)
* @return string[]
*/
function yang_writing_stopwords($scope = '')
{
static $stop = null;
static $merged = null;
if ($stop === null) {
$stop = array(
'我们', '你们', '他们', '她们', '这个', '那个', '什么', '可以', '就是',
'没有', '因为', '所以', '但是', '如果', '这样', '那样', '一个', '自己',
'现在', '已经', '还是', '或者', '而且', '虽然', '然后', '时候', '问题',
'东西', '知道', '觉得', '应该', '需要', '可能', '真的', '这么', '那么',
'一下', '一些', '这些', '那些', '不是', '其实', '大家', '每个', '这种',
'很多', '不能', '不会', '还有', '只是', '一样', '别人', '有人', '之前',
'之后', '起来', '出来', '一点', '有点', '非常', '特别', '事情', '地方',
'时间', '感觉', '开始', '发现', '看到', '听到', '告诉', '只要', '然而',
'于是', '不过', '当然', '确实', '似乎', '好像', '比较', '更加', '一直',
'每天', '今年', '今天', '昨天', '明天', '后来', '当时',
);
}
if ($scope !== 'tags') {
return $stop;
}
/* 🔴 必须是**并集**,不能另造一张表。
模拟器 v1 在这里犯的错:自己写了一份"扩展表"直接替换掉停用词调用,
结果主表里本来就有的 我们/这个/什么/没有/如果 全丢了,
40 篇输出里 6 篇冒出「什么」「这个」。同一份名单写两处、只改一处
必然漂移(红线 #438)。 */
if ($merged === null) {
$generic = array(
/* 泛化实词:有词义但不足以当标签 */
'任何', '无法', '复杂', '得到', '所有', '永远', '提供', '发展',
'是不是', '有一', '为何', '怎么', '使用', '功能', '支持', '方式',
'方法', '情况', '方面', '过程', '结果', '原因', '内容', '部分',
'项目', '变成', '成为', '作为', '输入', '记录', '笔记', '希望',
'出现', '存在', '实现',
/* 副词/连接词:40 篇对照输出里**实际冒出来**的,不是凭空列的 */
'并不', '并未', '都是', '也要', '因此', '并且', '受到',
'伟大', '相关',
);
$merged = array_values(array_unique(array_merge($stop, $generic)));
}
return $merged;
}
/**
* 中文分句。
*
* 只按句末标点与换行切,且丢掉过短(< 12 字,通常是标题/列表项签)
* 与过长(> 200 字,通常是没有标点的口语句)的片段。
* 这两个边界是实测调出来的:不设下限会选出「一、」这种碎片,
* 不设上限会有一整段当摘要的情况。
*/
function yang_writing_split_sentences($text)
{
$parts = preg_split('/(?<=[。!?;!?;…])|\n+/u', (string) $text, -1, PREG_SPLIT_NO_EMPTY);
$out = array();
if (!is_array($parts)) {
return $out;
}
foreach ($parts as $s) {
$s = trim(preg_replace('/\s+/u', ' ', $s));
$len = mb_strlen($s, 'UTF-8');
if ($len >= 12 && $len <= 200) {
$out[] = $s;
}
}
return $out;
}
/* ══════════════════════════════════════════════════════════════
* 三、词典(标签池)—— 标签引擎与摘要引擎共用
* ══════════════════════════════════════════════════════════════ */
/**
* 把现有标签建成词典。
*
* ── 为什么拿标签本身当词典 ─────────────────────────────────
* 这是整个标签方案的支点。没有分词扩展,从零切词不可行;但**用户已经
* 手工沉淀了 700 多个标签**,那是一份高质量的领域词典 —— 写「Git」「鸿蒙」
* 「失业」这些词的人,本来就会把它们当标签用。所以「在已有标签里挑最
* 相关的」这件事,用最大匹配 + 本文显著度就能做得不错。
*
* ── 实测数字(可复现,别照抄旧说法)──────────────────────────
* 早期注释里写过「60 篇 / 243 个人工标签 / Top8 召回 74.9%」,
* _quality_tags.php 复测同一批 60 篇(120 个人工标签,平均 2.00/篇):
* Top8 命中 59 个 → 召回 49.2%
* 只算「字面出现在正文里」的人工标签 → 59.0%
* 全未命中 18 篇
* 三个口径都到不了 74.9%,且 A/B 测试证明改动没碰这条路径
* ⇒ 那个 74.9% 是**未经复核的历史数字**(红线 #418)。
*
* 49.2% 不惊艳,但要看清结构:本站人工标签平均只有 2 个,而且大量是
* 「生活」「工作」「情感」这种泛化标签 —— 一篇讲吃饭的文章,人工打「生活」,
* 引擎给「习惯、免费、推荐、幻想、吃饭」,按"标签质量"判引擎其实更准,
* 但按"猜中作者手笔"判就是没中。所以这个指标对**标签自动生成**这个用途
* 偏悲观。
*
* ── 为什么键要小写 ────────────────────────────────────────
* `Git` 与 `git` 在 WordPress 里可以是两个标签,但匹配时应当一视同仁。
* 小写只用于**匹配键**,返回时仍用标签自己的原始大小写。
*
* ── 第三个返回值:哪些标签是「引擎自己造出来的」(#38) ─────────
* 🔴 这一条是为斩断**污染闭环**而加的,是整个 #38 的支点。
*
* 线上实测的闭环是这样转起来的:
* 词典 = 全量标签(就本函数这句 get_terms)
* → 引擎给 #7157 造了 `欲望`/`社会`/`衣服`(term_id 3423~3425,count=1)
* → 它们立刻成了「已有标签」
* → 下一篇文章命中它们时,走的是 exist 分支,**不占新增限额、无条件追加**
* → 垃圾被复用到新文章,又反过来喂大词典
* 也就是说:**引擎自己造的垃圾,会被下一轮当成权威词典用。**
* 不斩断这条,评分、闸门怎么调都是白调。
*
* 判据用 `term meta` 的 `yang_auto` 标记(创建时打),不用 `count` 猜 ——
* count 低只是"可疑",不是"引擎造的",人工新标签也 count=1,不能冤枉。
*
* @return array{0: array<string,array{name:string,count:int,id:int}>,
* 1: int,
* 2: array<string,bool>} 词典 + 最长词长度 + 引擎自建标记表
*/
function yang_writing_dict()
{
static $cache = null;
if ($cache !== null) {
return $cache;
}
$dict = array();
$terms = get_terms(array(
'taxonomy' => 'post_tag',
'hide_empty' => false,
'number' => 0,
));
if (!is_wp_error($terms) && is_array($terms)) {
foreach ($terms as $t) {
$name = isset($t->name) ? html_entity_decode($t->name, ENT_QUOTES, 'UTF-8') : '';
$key = trim(mb_strtolower($name, 'UTF-8'));
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(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');
if ($l > $maxlen) {
$maxlen = $l;
}
}
/* 🔴 第三个元素是**扩展**出来的,不是替换。
老调用点写的都是 `list($dict, $maxlen) = yang_writing_dict();`,
list() 只取它需要的两个元素、多余的值被忽略 ⇒ 分类引擎
(yang_writing_make_category)与任何外部调用**都不受影响**。
反过来如果把返回值改成关联数组或包一层对象,就会波及它们 ——
四个引擎「互不依赖」是本文件的硬约束(见文件头)。 */
$cache = array($dict, $maxlen, $auto);
return $cache;
}
/**
* 词典正向最大匹配。
*
* 从当前位置尽量取最长的词,命中就整段跳过 —— 这样「同步日历」不会被
* 拆成「同步」+「日历」两个标签,也不会出现「日历」「同步日历」同时
* 命中(重叠匹配会让同一个概念占两个标签位)。
*
* @return array<string,int> 小写词 => 出现次数
*/
function yang_writing_match($text, $dict, $maxlen)
{
$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])) {
$hits[$frag] = isset($hits[$frag]) ? $hits[$frag] + 1 : 1;
$i += $l;
$matched = true;
break;
}
}
if (!$matched) {
$i++;
}
}
return $hits;
}
/**
* 全站已发布文章数 —— IDF 的 N。
*
* 用 wp_count_posts 而不是查 posts 表:它带对象缓存,且能正确排除
* 修订版本 / 自动草稿。IDF 公式加了 1 兜底,所以 N=0 时也不会除零。
*/
function yang_writing_doc_count()
{
$c = wp_count_posts('post');
return isset($c->publish) ? max(1, (int) $c->publish) : 1;
}
/* ══════════════════════════════════════════════════════════════
* 四、引擎 ①:别名(slug)—— 需外部翻译
* ══════════════════════════════════════════════════════════════ */
/**
* 生成英文别名。
*
* 只在 slug 为空时调用(由 yang_writing_apply 保证)。翻译失败返回空串,
* 由调用方跳过 —— 绝不用拼音兜底,因为拼音 slug 与站点现有 966 篇手工
* 英文 slug 风格不一致,混在一起比留空更难看。
*
* @return string 干净的 slug,失败返回 ''
*/
function yang_writing_make_slug($title)
{
$title = trim((string) $title);
if ($title === '') {
return '';
}
$en = yang_writing_translate($title);
if ($en === '') {
return '';
}
/* sanitize_title 会把空格与标点折成连字符、转小写、去掉中文残留。
译文里常有 `Don't`,sanitize_title 会处理成 `don-t`,可接受;
它也会去掉中文,所以万一服务商返回原样中文,结果为空 ⇒ 上层跳过。 */
$slug = sanitize_title($en);
/* 过长的 slug 影响可读性与复制粘贴。按连字符截断到 60 字符内,
尽量不切在词中间(从末尾往前找最后一个连字符)。 */
if (strlen($slug) > 60) {
$cut = substr($slug, 0, 60);
$pos = strrpos($cut, '-');
$slug = $pos !== false && $pos > 20 ? substr($cut, 0, $pos) : $cut;
}
return trim($slug, '-');
}
/* ══════════════════════════════════════════════════════════════
* 五、引擎 ②:标签
* ══════════════════════════════════════════════════════════════ */
/* ── 标签引擎的参数 ──────────────────────────────────────────────
🔴 必须定义在函数**之前**: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 整体重写。**
*
* ── 线上暴露的问题(40 篇新旧对照实测)──────────────────────
* #7161《开源密码生成工具SimplePassword》入库:
* https | laoyang | passwo | script | 代码 | 任何 | 复杂 | 密码 | 服务 | 记录 | 输入
* 11 个里只有「密码」沾主题,其余是 URL 碎片、代码切片、虚词、泛词。
* #7157《痛苦根源》入库:
* 世界 | 孩子 | 幸福 | 微博 | 手机 | 无法 | 欲望 | 社会 | 老人 | 衣服 | 阶级
* 「衣服」「老人」「手机」全是从举例段落抓的;**标题词「痛苦」反而没入库**。
*
* ── 四条根因与对应修法 ─────────────────────────────────────
*
* ① **污染闭环**(最关键)。词典 = 全量标签,而上一轮引擎造的
* `passwo`/`任何`/`衣服`(实测 term_id 3423~3428,都是这两篇发布时新建的)
* 下一轮就变成"已有标签",走 exist 分支**不占限额、无条件追加**。
* ⇒ 引擎自建标签打 yang_auto 标记;标记过且 df<=1 的词不再当权威命中。
*
* ② **英文被 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 层面剥,
* 一旦拍平,`<pre>` 边界就没了,事后无法还原。
* (实测:#5140 的 `laoyang` 在 <pre> 内出现 5 次、正文 0 次,
* 传纯文本就等于把命令行里的路径当标签。)
* 摘要与分类引擎仍然吃纯文本,各自不变。
*
* @param string $title 文章标题
* @param string $body_html 原始 HTML 正文
* @return array{0:string[],1:string[]} [命中的已有标签, 建议新建的标签]
*/
function yang_writing_make_tags($title, $body_html)
{
list($dict, $maxlen, $auto) = yang_writing_dict();
if (!$dict) {
return array(array(), array());
}
$stop = yang_writing_stopwords('tags'); // 标签路径带泛化实词
$ct = yang_writing_tags_clean($title); // 剥代码块 / URL / 邮箱 / 路径
$cb = yang_writing_tags_clean($body_html);
/* ── ① 词典命中:标题与正文分开计数,ASCII 词要求落词边界 ── */
$hit_t = yang_writing_tags_match($ct, $dict, $maxlen);
$hit_b = yang_writing_tags_match($cb, $dict, $maxlen);
$scores = array();
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;
}
$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'];
}
/* ── ③ 新词候选:中文 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;
}
return array($exist, $new);
}
/**
* 标签引擎专用:剥掉**代码块** / URL / 邮箱 / 路径 / 长数字串,再转纯文本。
*
* 🔴 只给标签引擎用,**不改 yang_writing_html2text()** ——
* 那个函数被摘要与分类引擎共用(yang_writing_split_sentences 依赖它的
* 换行结构),改了会连带影响摘要选句,而用户没要求改摘要。
*
* 🔴 入参必须是**原始 HTML**。代码块只能从 HTML 层面剥:一旦先跑过
* html2text,`<pre>` 的边界就丢了,事后无法还原 —— 第一版我就是
* 把已经拍平的正文传进来,正则写得再对也匹配不到任何东西。
*
* ── 为什么必须剥代码块(实测,不是推测)──────────────────────
* #5140/#5167/#5139 三篇里 `laoyang` 的出现位置:
* <pre> 内 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` 会稳定地把真正的主题词挤下标签位。
*
* 实测踩过的写法:`'#https?://\S#'` —— **漏了量词**,只吃掉一个字符,
* 整条 URL 根本没剥掉。
*
* @param string $html 原始 post_content(含 <pre>/<code>)
* @return string 剥过代码块与 URL 的纯文本
*/
function yang_writing_tags_clean($html)
{
$t = (string) $html;
/* 代码块整段摘掉。pre 里常嵌 code,所以先去 pre 再处理剩下的内联 code。
非贪婪 + s 修饰符:跨行匹配,且不会被相邻两个块吞成一大段。 */
$t = preg_replace('#<pre\b[^>]*>.*?</pre>#si', ' ', $t);
$t = preg_replace('#<code\b[^>]*>.*?</code>#si', ' ', $t);
$t = preg_replace('#<kbd\b[^>]*>.*?</kbd>#si', ' ', $t);
$t = preg_replace('#<samp\b[^>]*>.*?</samp>#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<string,int> 小写词 => 命中次数
*/
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<string,array{c:int,d:string}> 小写词形 => [次数, 最佳显示形态]
*/
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;
}
/**
* 虚词表 —— 切分短语与「不许以虚字结尾」**共用这一份**。
*
* 为什么必须共用:3.2.57 第一版把同一批 28 个字写了两处($sep 内联一份、
* 闸门 ④ 里再抄一份)。实测 _probe_gate4.php 证明两边完全相同,
* 于是闸门 ④ 成了死代码($sep 已把虚字全换成空格,chunk 里不可能有虚字)。
* 死代码本身不致命,致命的是**两套名单会漂移**:将来只想放宽一处时,
* 另一处被忘掉,就会出现"某个虚字既不切分、也不拦尾巴"的漏网。
*
* @return string[] 单字虚词
*/
function yang_writing_seps()
{
return array('的', '与', '和', '了', '是', '在', '就', '都', '也', '及',
'或', '而', '但', '把', '被', '从', '向', '对', '为', '以',
'于', '之', '其', '这', '那', '个', '们', '说');
}
/**
* 在词典之外找值得新建的标签候选。**#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)
{
$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);
}
}
/* ── 通道 A:中文 n-gram ──────────────────────────────────
与 3.2.57 相同:先按虚词切短语,只在短语内部取窗口。
「星等换算与赤纬测量」被「与」切成「星等换算」「赤纬测量」,
否则 2~6 字滑动窗口必然产出「星等换算与赤」这种碎片。 */
$clean = preg_replace('/' . implode('|', $seps) . '/u', ' ', $masked);
/* 只留中文: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)) {
$chunks = array();
}
$cand = array();
/* 显示形态表:只有 ASCII 词需要记录(中文词显示形态=自身)。
与 $cand 同处初始化,别等通道 B 循环里才首次赋值 —— 万一
通道 B 一个词都没进,后面的 isset($disp[...]) 也要在确定的
作用域里求值。 */
$disp = array();
foreach ($chunks as $chunk) {
$len = mb_strlen($chunk, 'UTF-8');
/* 生成到 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');
$cand[$g] = isset($cand[$g]) ? $cand[$g] + 1 : 1;
}
}
}
/* 闸门:频次 ≥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]);
}
}
/* ── 通道 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;
}
$cand[$w] = isset($cand[$w]) ? max($cand[$w], $info['c']) : $info['c'];
$disp[$w] = $info['d'];
}
if (!$cand) {
return array();
}
/* ── 跨通道切片消除 ──────────────────────────────────────
判据同旧闸门⑤:被更长的候选包含 ⇒ 短的丢。
用 >= 而非 > :真词与它的切片频次**必然相等**(同一处文本切出来的)。
反向情形(「竹编」与「竹编漆器」独立成词)短词频次通常更高,不会误删。
额外加一条: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');
$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;
}
/* 中英跨通道不比包含(「工具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;
}
}
}
if (!$cand) {
return array();
}
/* 与已选标签重叠的不再出(「密码」已选,就别再出「密码生成」) */
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 的记录)。
🔴 返回的是**显示形态**而不是匹配用小写。
实测 #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));
}
/* ══════════════════════════════════════════════════════════════
* 六、引擎 ③:分类 —— 纯本地
* ══════════════════════════════════════════════════════════════ */
/**
* 推荐一个分类。
*
* ── 为什么不是朴素贝叶斯(我先写过一版,错的)───────────────
* 第一版用 P(标签|分类) 连乘,线上实测**只有 60%**,而且疯狂把文章判成
* 只有 6 篇的「病毒相关」。根因是分母:
* p(tag|cat) = (共现数 + α) / (该分类标签总数 + α·词表大小)
* 小分类分母小 ⇒ 对没见过的标签给了几十倍的偏高概率 ⇒ 小分类通吃。
*
* ── 现在用的:P(分类|标签) 加权投票 ────────────────────────
* score(cat) = Σ_tags P(cat|tag) × idf(tag)
* P(cat|tag) = 带该标签的文章里落在 cat 的比例
* 这个量天然在分类间归一化(Σ_cat P(cat|tag) = 1),小分类不会因为
* 分母小被抬高。再乘 idf,让「鸿蒙」这种稀有标签的投票比「生活」值钱。
*
* 线上实测:**Top1 90.0% / Top3 99.0%**(290 篇单分类文章)。
* 按用户要求不排除小样本分类,直接把最接近的一个给他。
*
* @return int 分类 ID,无法判断返回 0
*/
function yang_writing_make_category($title, $body)
{
global $wpdb;
list($dict, $maxlen) = yang_writing_dict();
if (!$dict) {
return 0;
}
/* 一次查出全部「标签×分类」共现(线上 4070 行,毫秒级)。
不缓存进 transient:发布是低频动作,一次 4070 行的分组查询比
维护缓存失效逻辑省心得多(也避免 save_post 里清缓存的递归风险)。 */
$rows = $wpdb->get_results(
"SELECT t.name AS tag, c.term_id AS cat_id
FROM {$wpdb->posts} p
JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
AND tt.taxonomy = 'post_tag'
JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
JOIN {$wpdb->term_relationships} tr2 ON tr2.object_id = p.ID
JOIN {$wpdb->term_taxonomy} tt2 ON tt2.term_taxonomy_id = tr2.term_taxonomy_id
AND tt2.taxonomy = 'category'
JOIN {$wpdb->terms} c ON c.term_id = tt2.term_id
WHERE p.post_type = 'post' AND p.post_status = 'publish'"
);
if (empty($rows)) {
return 0;
}
$tag_cat = array(); // 标签 => [分类 => 共现数]
$tag_total = array(); // 标签 => 总文章数
foreach ($rows as $r) {
$tg = trim(mb_strtolower($r->tag, 'UTF-8'));
$cid = (int) $r->cat_id;
$tag_cat[$tg][$cid] = isset($tag_cat[$tg][$cid]) ? $tag_cat[$tg][$cid] + 1 : 1;
$tag_total[$tg] = isset($tag_total[$tg]) ? $tag_total[$tg] + 1 : 1;
}
/* 用本文标题+正文命中的标签来投票。这里**不排序、不设上限**:
投票是求和,噪声标签的贡献接近 0,不需要先筛。 */
$hits = yang_writing_match($title . ' ' . $body, $dict, $maxlen);
if (!$hits) {
return 0;
}
$N = yang_writing_doc_count();
$scores = array();
foreach ($hits as $tag => $times) {
if (!isset($tag_cat[$tag])) {
continue;
}
$total = $tag_total[$tag];
$idf = log(($N + 1) / ($total + 1)) + 1;
foreach ($tag_cat[$tag] as $cid => $co) {
$p = $co / $total;
$scores[$cid] = (isset($scores[$cid]) ? $scores[$cid] : 0) + $p * $idf * $times;
}
}
if (!$scores) {
return 0;
}
arsort($scores);
return (int) array_key_first($scores);
}
/* ══════════════════════════════════════════════════════════════
* 七、引擎 ④:摘要 —— 纯本地
* ══════════════════════════════════════════════════════════════ */
/**
* 生成抽取式摘要(只写 post_excerpt)。
*
* ── 做法 ────────────────────────────────────────────────────
* TextRank 的简化版:给每句打分,按分数挑句子,再**还原原文顺序**拼起来。
* score = Σ(命中词的 idf × 次数) / (句长/40) ← 长度归一
* × 1.3(位于前 20% 位置) ← 位置加权
* × (1 + 0.15·与标题共现词数) ← 标题对齐
*
* 为什么长度要归一:不归一的话,最长那句几乎必然胜出,摘要就变成
* 「原文的某一大段」。为什么按分数挑完还要还原顺序:挑出的句子如果
* 按分数排列,读起来是跳跃的,不通顺。
*
* 实测:4ms 出 150 字,5 篇人工通读都通顺、抓得住主旨。
*
* @return string 摘要文本,无法生成返回 ''
*/
function yang_writing_make_excerpt($title, $body, $target = 150)
{
list($dict, $maxlen) = yang_writing_dict();
$sents = yang_writing_split_sentences($body);
if (!$sents) {
return '';
}
$stop = yang_writing_stopwords();
$N = yang_writing_doc_count();
/* 词权重表:只给词典里的词算 idf。没进词典的词不参与打分 ——
这是抽词方案的固有局限,但比引入 n-gram 噪声好。 */
$weight = array();
foreach ($dict as $w => $info) {
if (in_array($w, $stop, true)) {
continue;
}
/* 🔴 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);
$total_sent = count($sents);
$scored = array();
foreach ($sents as $idx => $s) {
$hits = yang_writing_match($s, $dict, $maxlen);
$score = 0.0;
foreach ($hits as $w => $c) {
if (!isset($weight[$w])) {
continue;
}
$score += $weight[$w] * $c;
}
if ($score <= 0) {
continue;
}
$len = mb_strlen($s, 'UTF-8');
$score = $score / max(1, $len / 40);
if ($total_sent > 1 && ($idx / ($total_sent - 1)) < 0.2) {
$score *= 1.3;
}
$overlap = 0;
foreach ($title_hits as $w => $c) {
if (isset($hits[$w])) {
$overlap += $c;
}
}
if ($overlap > 0) {
$score *= (1 + 0.15 * $overlap);
}
$scored[$idx] = array('s' => $s, 'score' => $score, 'len' => $len);
}
if (!$scored) {
/* 一句都没命中词典时,退化成「取首句」,而不是返回空。
首句在中文写作里通常就是总起句,比没有摘要强。 */
$first = $sents[0];
return mb_strlen($first, 'UTF-8') > $target
? mb_substr($first, 0, $target, 'UTF-8')
: $first;
}
uasort($scored, function ($a, $b) {
return $b['score'] <=> $a['score'];
});
$picked = array();
$total = 0;
foreach ($scored as $idx => $item) {
if ($total >= $target) {
break;
}
$picked[$idx] = $item;
$total += $item['len'];
}
/* 还原原文顺序 —— 按分数顺序拼接读起来是跳跃的。 */
ksort($picked);
$out = '';
foreach ($picked as $item) {
$out .= $item['s'];
}
/* 目标 150 字,超太多就硬截到 200 字以内,保证后台摘要框不爆。 */
if (mb_strlen($out, 'UTF-8') > 220) {
$out = mb_substr($out, 0, 200, 'UTF-8') . '…';
}
return $out;
}
/**
* 登录凭据的独立存储。
*
* ── 为什么不跟其它设置一起存进 `kratos` ─────────────────────
* 框架渲染 password 字段时会把**真实值**写进 `value` 属性
* (见 class-options-interface.php 的 `case 'password'`),
* 页面上看着是圆点,但 F12 或「查看源代码」就是明文。
*
* 主题里的 SMTP 密码(`m_passwd`)现在就是这个状态。那是既有问题,
* 不在本次范围,但新功能没有再照抄一遍的理由 —— 用户这次明确说了
* 「为了安全着想」。
*
* 做法:提交时把密钥从 `kratos` 里摘出来,单独存到 `yang_writing_secrets`;
* 表单里永远回显空串,只提示「已保存(末 4 位 xxxx)」。
* 代价是要多一次 option 读取,可以忽略。
*/
function yang_writing_secret_keys()
{
return array('w_baidu_key', 'w_youdao_key');
}
/**
* 读一个已保存的密钥。
*
* @param string $id 设置键名
* @return string
*/
function yang_writing_secret($id)
{
$all = get_option('yang_writing_secrets', array());
if (!is_array($all) || !isset($all[$id])) {
return '';
}
return (string) $all[$id];
}
/**
* 密钥字段提交时拦截:存进独立选项,`kratos` 里只留空串。
*
* ── 为什么用 of_sanitize_password 过滤器 ────────────────────
* 框架对每个字段调 `apply_filters('of_sanitize_' . $type, $value, $option)`,
* 这是唯一能同时拿到「字段 id」和「新值」又不必改框架的位置。
* 挂在这个过滤器上,返回值就是最终写进 `kratos` 的东西。
*
* ── 空值语义 ────────────────────────────────────────────────
* 提交空串 = **不修改**(因为表单本来就回显空)。
* 这一条很关键:若当成"清空",用户每次保存别的设置都会把 Key 抹掉。
*
* @param mixed $value 提交的值
* @param array $option 字段定义
* @return string 永远返回空串(真实值已另存)
*/
function yang_writing_capture_secret($value, $option = array())
{
if (!is_array($option) || empty($option['id'])) {
return $value;
}
$id = $option['id'];
if (!in_array($id, yang_writing_secret_keys(), true)) {
return $value;
}
$new = is_string($value) ? trim($value) : '';
if ($new === '') {
/* 空 ⇒ 不改。库里原有的值原封不动。 */
return '';
}
$all = get_option('yang_writing_secrets', array());
if (!is_array($all)) {
$all = array();
}
$all[$id] = $new;
update_option('yang_writing_secrets', $all, false);
return '';
}
add_filter('of_sanitize_password', 'yang_writing_capture_secret', 10, 2);
/**
* 某个密钥是否已保存,返回末 4 位用于提示。
*
* @param string $id
* @return string 已保存返回「••••abcd」,未保存返回空串
*/
function yang_writing_secret_hint($id)
{
$v = yang_writing_secret($id);
if ($v === '') {
return '';
}
$tail = mb_strlen($v, 'UTF-8') > 4 ? mb_substr($v, -4, 4, 'UTF-8') : '****';
return '••••' . $tail;
}
/* ══════════════════════════════════════════════════════════════
* 八、服务商适配层(三个,全部按各家官方签名规范实现)
* ══════════════════════════════════════════════════════════════ */
/**
* 服务商清单。后台下拉与校验共用这一份,避免两处漂移。
*
* 只收服务器**实测可达**的:Google 翻译返回 000(不可达),
* LibreTranslate 超时,都已排除 —— 列出来只会让用户白填密钥。
*/
function yang_writing_providers()
{
return array(
'mymemory' => array(
'label' => __('MyMemory(免费,无需密钥)', 'kratos'),
'needs' => false,
'note' => __('直接可用,不开通任何账号。每日有免费字符限额,超限会返回错误,届时可换百度。', 'kratos'),
),
'baidu' => array(
'label' => __('百度翻译(国内,需 APPID + 密钥)', 'kratos'),
'needs' => true,
'note' => __('申请:访问 fanyi-api.baidu.com → 注册登录 → 控制台「管理控制台」→ 开发者信息里可看到 APPID 与密钥。免费版每月 5 万字符。', 'kratos'),
),
'youdao' => array(
'label' => __('有道翻译(国内,需应用 ID + 应用密钥)', 'kratos'),
'needs' => true,
'note' => __('申请:访问 ai.youdao.com → 注册登录 → 创建应用(选「文本翻译」)→ 应用管理里可看到应用 ID 与应用密钥。', 'kratos'),
),
);
}
/**
* 翻译一段中文到英文。失败一律返回空串,绝不抛异常。
*
* @param string $text 待翻译文本
* @return string 译文;任何失败路径都返回 ''
*/
function yang_writing_translate($text)
{
$text = trim((string) $text);
if ($text === '') {
return '';
}
/* 超长标题截断:翻译接口对长文本要么收费要么直接拒。
标题超过 200 字本身也不正常。 */
if (mb_strlen($text, 'UTF-8') > 200) {
$text = mb_substr($text, 0, 200, 'UTF-8');
}
$provider = (string) yang_writing_opt('w_provider', 'mymemory');
$timeout = (int) yang_writing_opt('w_timeout', 8);
if ($timeout < 3 || $timeout > 30) {
$timeout = 8;
}
switch ($provider) {
case 'baidu':
$out = yang_writing_tr_baidu($text, $timeout);
break;
case 'youdao':
$out = yang_writing_tr_youdao($text, $timeout);
break;
case 'mymemory':
default:
$out = yang_writing_tr_mymemory($text, $timeout);
break;
}
$out = trim(preg_replace('/\s+/u', ' ', (string) $out));
return $out;
}
/** MyMemory:免密钥,GET。返回体里 responseData.translatedText 是译文。 */
function yang_writing_tr_mymemory($text, $timeout)
{
$url = add_query_arg(array(
'q' => rawurlencode($text),
'langpair' => 'zh-CN|en',
), 'https://api.mymemory.translated.net/get');
$res = wp_remote_get($url, array('timeout' => $timeout));
if (is_wp_error($res)) {
return '';
}
if (200 !== (int) wp_remote_retrieve_response_code($res)) {
return '';
}
$j = json_decode(wp_remote_retrieve_body($res), true);
if (!is_array($j) || empty($j['responseData']['translatedText'])) {
return '';
}
/* 限额耗尽时它会在 translatedText 里塞一句英文提示(MYMEMORY WARNING...),
那种内容不能当译文用。 */
$t = (string) $j['responseData']['translatedText'];
if (stripos($t, 'MYMEMORY WARNING') !== false || stripos($t, 'QUOTA') !== false) {
return '';
}
return $t;
}
/**
* 百度翻译:sign = md5(appid + q + salt + key)。
* 官方要求 q 原样参与签名,**不能** URL 编码后再算。
*/
function yang_writing_tr_baidu($text, $timeout)
{
$appid = trim((string) yang_writing_opt('w_baidu_appid', ''));
$key = yang_writing_secret('w_baidu_key');
if ($appid === '' || $key === '') {
return '';
}
$salt = (string) wp_rand(10000, 99999);
$res = wp_remote_post('https://fanyi-api.baidu.com/api/trans/vip/translate', array(
'timeout' => $timeout,
'body' => array(
'q' => $text,
'from' => 'zh',
'to' => 'en',
'appid' => $appid,
'salt' => $salt,
'sign' => md5($appid . $text . $salt . $key),
),
));
if (is_wp_error($res)) {
return '';
}
$j = json_decode(wp_remote_retrieve_body($res), true);
if (!is_array($j) || !isset($j['trans_result'][0]['dst'])) {
return '';
}
return (string) $j['trans_result'][0]['dst'];
}
/**
* 有道翻译 v3:sign = sha256(appKey + truncate(q) + salt + curtime + appSecret)。
* truncate 规则:长度 ≤ 20 用原文,否则取「首 10 字 + 长度 + 末 10 字」。
*/
function yang_writing_tr_youdao($text, $timeout)
{
$appkey = trim((string) yang_writing_opt('w_youdao_appid', ''));
$secret = yang_writing_secret('w_youdao_key');
if ($appkey === '' || $secret === '') {
return '';
}
$salt = (string) wp_rand(10000, 99999);
$curtime = (string) time();
$len = mb_strlen($text, 'UTF-8');
$trunc = $len <= 20
? $text
: (mb_substr($text, 0, 10, 'UTF-8') . $len . mb_substr($text, $len - 10, 10, 'UTF-8'));
$sign = hash('sha256', $appkey . $trunc . $salt . $curtime . $secret);
$res = wp_remote_post('https://openapi.youdao.com/api', array(
'timeout' => $timeout,
'body' => array(
'q' => $text,
'from' => 'zh-CHS',
'to' => 'en',
'appKey' => $appkey,
'salt' => $salt,
'sign' => $sign,
'signType' => 'v3',
'curtime' => $curtime,
),
));
if (is_wp_error($res)) {
return '';
}
$j = json_decode(wp_remote_retrieve_body($res), true);
if (!is_array($j) || !isset($j['translation'][0])) {
return '';
}
return (string) $j['translation'][0];
}
/* ══════════════════════════════════════════════════════════════
* 九、主入口:发布时应用四个引擎
* ══════════════════════════════════════════════════════════════ */
/**
* 挂在 transition_post_status 上。
*
* ── 为什么用这个钩子而不是 save_post ─────────────────────────
* 用户的规则是「只在新增时启用,修改都不改变」。save_post 无法区分
* 「第一次发布」和「已发布文章又改了一次」—— 它两件事都触发。
* transition_post_status 带 $old_status,一眼就能判断:
* 旧状态不是 publish、新状态是 publish ⇒ 这就是第一次发布。
* 已发布文章再次保存时 $old_status === 'publish' ⇒ 直接返回。
*
* 顺带解决了自动保存:自动保存不会把状态变成 publish,天然被挡住。
*
* @param string $new_status 新状态
* @param string $old_status 旧状态
* @param WP_Post $post 文章对象
*/
function yang_writing_on_transition($new_status, $old_status, $post)
{
/* 只在「首次进入 publish」时干活。 */
if ($new_status !== 'publish' || $old_status === 'publish') {
return;
}
if (!$post instanceof WP_Post) {
return;
}
if ($post->post_type !== 'post') {
return;
}
if (wp_is_post_revision($post->ID) || wp_is_post_autosave($post->ID)) {
return;
}
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
return;
}
/* 定时发布(future → publish)也走这里,是正确行为:它在未来某刻
才真正"第一次发布"。但对**已经有值**的字段仍然不覆盖,所以安全。 */
/* 幂等:同一篇文章处理过一次就不再碰。
为什么必须要有:用户可能「发布 → 退回草稿 → 再发布」,
没有这道锁的话外部 API 会被重复调用几次,
而且第二次会把第一次的结果当成"已有值"从而跳过 —— 逻辑上没错,
但白花钱和时间。有锁更干净。 */
$done = get_post_meta($post->ID, '_yang_writing_done', true);
if (is_array($done) && !empty($done)) {
return;
}
yang_writing_apply($post);
}
/**
* 执行四个引擎并把结果写回文章。
*
* 每个引擎都包在「先判开关、再判是否为空」里,任何一步失败都不影响
* 其它步骤 —— 这是「独立引擎」的字面实现。
*
* @param WP_Post $post
*/
function yang_writing_apply($post)
{
$post_id = (int) $post->ID;
$title = (string) $post->post_title;
$html = (string) $post->post_content;
$body = yang_writing_html2text($html);
if (mb_strlen($body, 'UTF-8') < 30) {
/* 正文太短(少于 30 字)任何引擎都给不出有意义的结果,
直接不做 —— 也顺手避免把一句话的文章判定成某个分类。 */
update_post_meta($post_id, '_yang_writing_done', array('skip' => 'short'));
return;
}
$did = array();
/* ── ① 摘要(最便宜、最安全,先做)──────────────────────── */
if (yang_writing_on('w_on_excerpt')) {
$cur = trim((string) $post->post_excerpt);
if ($cur === '') {
$ex = yang_writing_make_excerpt($title, $body);
if ($ex !== '') {
/* 直接写库而不是 wp_update_post:wp_update_post 会再触发
transition_post_status,把自己卷进递归。这里只改一个
字段,直写 + 清缓存是最稳的。 */
yang_writing_update_field($post_id, 'post_excerpt', $ex);
$did[] = 'excerpt';
}
}
}
/* ── ② 分类(纯本地,无网络)────────────────────────────── */
if (yang_writing_on('w_on_category')) {
if (yang_writing_needs_category($post_id)) {
$cid = yang_writing_make_category($title, $body);
if ($cid > 0) {
/* append=false:这是"替他选一个",不是"再加一个"。
只在文章还挂在默认分类时才走到这里(见上面判断)。 */
wp_set_post_categories($post_id, array($cid), false);
$did[] = 'category';
}
}
}
/* ── ③ 标签 ──────────────────────────────────────────────── */
if (yang_writing_on('w_on_tag')) {
/* 🔴 这里必须把两段返回值都接住。make_tags() 返回的是
array($exist, $new):
$exist = 词典(=标签库)里**已经命中**的标签名
$new = 词典之外、值得**新建**的候选词
3.2.56 及以前写的是 `list($suggest, $_)`,$new 被整个丢掉;
然后又拿 $suggest 去 get_term_by 做二次分类 —— 而 $suggest 里
每个词都来自词典、都是已有标签,必然全部命中 ⇒ 「新建」那个
分支永远进不去 ⇒ 这条功能在真实发布流程里**一次都没生效过**。
用户提这个需求的初衷就是「如果不能新增,一个新的博客,
永远无法使用这个功能」,所以这是个真 bug,不是洁癖。
🔴 #38:第二个参数必须传 **$html(原始正文)**,不能传 $body。
$body 是 html2text 拍平过的文本,`<pre>`/`<code>` 的边界在它里面
已经不存在 —— 传 $body 等于代码块剥离整条静默失效,而且**不报任何
错**(A/B 实测:传 $body 时 `laoyang` 残留 3 处,改传 $html 后归零;
#5140 的 laoyang 在 <pre> 内出现 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 会把它重新建出来 ——
那就等于偷偷绕过了用户设的新增上限。 */
$exist = array();
foreach ($exist_hits as $name) {
$term = get_term_by('name', $name, 'post_tag');
if ($term && !is_wp_error($term)) {
$exist[] = $term->name;
}
}
/* 新标签:先复核再占额度。顺序很关键 —— 万一并发下另一个请求
刚把同名标签建好,它此刻已属「已有标签」,不该再吃掉一个额度。 */
$new = array();
foreach ($new_cands as $name) {
if (count($new) >= $limit) {
break;
}
if ($name === '' || preg_match('/^\d+$/', (string) $name)) {
continue;
}
$term = get_term_by('name', $name, 'post_tag');
if ($term && !is_wp_error($term)) {
$exist[] = $term->name;
continue;
}
$new[] = $name;
}
$all = array_values(array_unique(array_merge($exist, $new)));
if ($all) {
/* 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';
}
}
/* ── ④ 别名(唯一需要外部翻译的一步,放最后)─────────────
放最后是有意的:万一翻译接口超时,前面三项已经落库了。
若放最前,一次超时会让四项全白做。 */
if (yang_writing_on('w_on_slug')) {
$cur = (string) get_post_field('post_name', $post_id);
/* 中文标题下 WP 会生成 `%e4%bf%a1...` 这种 URL 编码串,它不是空、
但也不是可用的英文 slug。所以判断标准不是"空",而是
"不是纯 ASCII" —— 否则永远不会替换掉那串百分号。 */
if ($cur === '' || !preg_match('/^[a-z0-9\-]+$/', $cur)) {
$slug = yang_writing_make_slug($title);
if ($slug !== '' && $slug !== $cur) {
/* slug 必须唯一,否则 WP 会自己加 -2 后缀造成"看起来没生效"。
这里显式判重,冲突时加序号。 */
$slug = yang_writing_unique_slug($slug, $post_id);
yang_writing_update_field($post_id, 'post_name', $slug);
$did[] = 'slug';
}
}
}
update_post_meta($post_id, '_yang_writing_done', $did ? $did : array('none' => 1));
}
/**
* 文章是否「还没选过分类」。
*
* 判据:只挂在默认分类上(且只有这一个)。
* 这是 WP 里"用户没动过分类"的标准信号 —— 新文章默认落在
* get_option('default_category'),用户一旦选了别的就变了。
*/
function yang_writing_needs_category($post_id)
{
$cats = wp_get_post_categories($post_id);
if (count($cats) !== 1) {
return false;
}
return (int) $cats[0] === (int) get_option('default_category');
}
/**
* 直接改一个 post 字段。
*
* 不用 wp_update_post 的原因见 yang_writing_apply 里的注释:
* 它会再触发 transition_post_status / save_post,形成递归。
* 这里改完必须 clean_post_cache,否则同一请求内 get_post 还是旧值。
*/
function yang_writing_update_field($post_id, $field, $value)
{
static $allowed = array('post_name', 'post_excerpt');
if (!in_array($field, $allowed, true)) {
return false;
}
$post_id = (int) $post_id;
global $wpdb;
$ok = $wpdb->update($wpdb->posts, array($field => $value), array('ID' => $post_id));
/* 🔴 必须清缓存。
*
* wpdb->update 直接改表,**不经过任何 WP 的缓存失效逻辑**。
* 而同一次请求里 get_post() / get_post_field() 走 wp_cache_get,
* 拿到的还是改之前的对象 —— 表现是「写库成功了,但读出来是旧值」。
*
* 3.2.57 实测踩到:摘要明明写进去了(直接查库能查到),
* 但同一请求里 get_post_field('post_excerpt') 返回空串,
* 端到端判据因此报「摘要 0 字」。若不是当场查了两边,
* 会误判成"摘要引擎没工作",去改算法 —— 方向完全错。
*
* clean_post_cache() 会清 post 对象缓存;wp_cache_delete 兜底清
* posts 组(某些持久对象缓存后端下 clean_post_cache 清不干净)。 */
clean_post_cache($post_id);
wp_cache_delete($post_id, 'posts');
wp_cache_delete($post_id, 'post_meta');
return $ok !== false;
}
/**
* 保证 slug 唯一。
*
* WP 自己的 wp_unique_post_slug 需要 post_type/status 等一串参数,
* 这里场景单一(post + publish),直接查库更直白。
*/
function yang_writing_unique_slug($slug, $post_id)
{
global $wpdb;
$base = $slug;
$i = 2;
/* 最多试 50 次,超过说明站内有大量同名文章,加时间戳兜底。 */
while ($i < 50) {
$exists = $wpdb->get_var($wpdb->prepare(
"SELECT ID FROM {$wpdb->posts}
WHERE post_name = %s AND post_type = 'post' AND ID <> %d LIMIT 1",
$slug,
$post_id
));
if (!$exists) {
return $slug;
}
$slug = $base . '-' . $i;
$i++;
}
return $base . '-' . time();
}
/* ══════════════════════════════════════════════════════════════
* 十、注册钩子
* ══════════════════════════════════════════════════════════════ */
/* 只在有任何一项开关打开时才挂钩子 —— 全部关闭时零开销、零行为。 */
function yang_writing_boot()
{
$any = false;
foreach (array('w_on_slug', 'w_on_tag', 'w_on_category', 'w_on_excerpt') as $k) {
if (yang_writing_on($k)) {
$any = true;
break;
}
}
if (!$any) {
return;
}
add_action('transition_post_status', 'yang_writing_on_transition', 20, 3);
}
add_action('init', 'yang_writing_boot', 20);