小写词 => 出现次数
*/
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 层面剥,
* 一旦拍平,`` 边界就没了,事后无法还原。
* (实测:#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_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,`` 的边界就丢了,事后无法还原 —— 第一版我就是
* 把已经拍平的正文传进来,正则写得再对也匹配不到任何东西。
*
* ── 为什么必须剥代码块(实测,不是推测)──────────────────────
* #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` 会稳定地把真正的主题词挤下标签位。
*
* 实测踩过的写法:`'#https?://\S#'` —— **漏了量词**,只吃掉一个字符,
* 整条 URL 根本没剥掉。
*
* @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;
}
/**
* 虚词表 —— 切分短语与「不许以虚字结尾」**共用这一份**。
*
* 为什么必须共用: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 拍平过的文本,``/`` 的边界在它里面
已经不存在 —— 传 $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 会把它重新建出来 ——
那就等于偷偷绕过了用户设的新增上限。 */
$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);