* @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('#]*>.*?#si', ' ', $t); $t = preg_replace('#]*>.*?#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」挡掉了。 */ function yang_writing_stopwords() { static $stop = null; if ($stop !== null) { return $stop; } $stop = array( '我们', '你们', '他们', '她们', '这个', '那个', '什么', '可以', '就是', '没有', '因为', '所以', '但是', '如果', '这样', '那样', '一个', '自己', '现在', '已经', '还是', '或者', '而且', '虽然', '然后', '时候', '问题', '东西', '知道', '觉得', '应该', '需要', '可能', '真的', '这么', '那么', '一下', '一些', '这些', '那些', '不是', '其实', '大家', '每个', '这种', '很多', '不能', '不会', '还有', '只是', '一样', '别人', '有人', '之前', '之后', '起来', '出来', '一点', '有点', '非常', '特别', '事情', '地方', '时间', '感觉', '开始', '发现', '看到', '听到', '告诉', '只要', '然而', '于是', '不过', '当然', '确实', '似乎', '好像', '比较', '更加', '一直', '每天', '今年', '今天', '昨天', '明天', '现在', '后来', '当时', ); return $stop; } /** * 中文分句。 * * 只按句末标点与换行切,且丢掉过短(< 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; } /* ══════════════════════════════════════════════════════════════ * 三、词典(标签池)—— 标签引擎与摘要引擎共用 * ══════════════════════════════════════════════════════════════ */ /** * 把现有标签建成词典。 * * ── 为什么拿标签本身当词典 ───────────────────────────────── * 这是整个标签方案的支点。没有分词扩展,从零切词不可行;但**用户已经 * 手工沉淀了 703 个标签**,那是一份高质量的领域词典 —— 写「Git」「鸿蒙」 * 「失业」这些词的人,本来就会把它们当标签用。所以「在已有标签里挑最 * 相关的」这件事,用最大匹配 + TF-IDF 就能做得不错。 * * ── 实测数字(3.2.57,可复现,别照抄旧说法)────────────────── * 早期注释里写过「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 换成上面这组实测值。 * * 49.2% 不惊艳,但要看清结构:本站 967 篇文章里人工标签平均只有 2 个, * 而且大量是「生活」「工作」「情感」这种泛化标签 —— 一篇讲吃饭的文章, * 人工打「生活」,引擎给「习惯、免费、推荐、幻想、吃饭」,按"标签质量" * 判引擎其实更准,但按"猜中作者手笔"判就是没中。所以这个指标对 * **标签自动生成**这个用途偏悲观,README 里是按"人工抽检是否通顺"表述的。 * * ── 为什么键要小写 ──────────────────────────────────────── * `Git` 与 `git` 在 WordPress 里可以是两个标签,但匹配时应当一视同仁。 * 小写只用于**匹配键**,返回时仍用标签自己的原始大小写。 * * @return array{0: array, 1: int} * 词典 + 最长词长度 */ 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; } $dict[$key] = array( 'name' => $name, 'count' => isset($t->count) ? max(1, (int) $t->count) : 1, ); } } $maxlen = 0; foreach ($dict as $w => $_) { $l = mb_strlen($w, 'UTF-8'); if ($l > $maxlen) { $maxlen = $l; } } $cache = array($dict, $maxlen); return $cache; } /** * 词典正向最大匹配。 * * 从当前位置尽量取最长的词,命中就整段跳过 —— 这样「同步日历」不会被 * 拆成「同步」+「日历」两个标签,也不会出现「日历」「同步日历」同时 * 命中(重叠匹配会让同一个概念占两个标签位)。 * * @return array 小写词 => 出现次数 */ 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, '-'); } /* ══════════════════════════════════════════════════════════════ * 五、引擎 ②:标签 * ══════════════════════════════════════════════════════════════ */ /** * 生成标签建议。 * * ── 算法 ──────────────────────────────────────────────────── * 在 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() 头注释。) * * ── 返回值为什么是两段 ────────────────────────────────────── * [0] 命中的**已有**标签 —— 直接用,不占新增额度 * [1] 建议**新建**的标签 —— 受「每篇最多新增 N 个」限制 * * ── 新词从哪来(3.2.57 修正)─────────────────────────────── * 第一版只返回了 [0],[1] 永远是空 —— 因为输入就是"词典命中结果", * 它当然全部已在词典里。那等于「新建」这条路根本没实现,与用户 * 「如果不能新增,一个新博客永远无法使用这个功能」的要求直接冲突。 * * 现在的做法:从**标题与正文的 n-gram** 里找词典没覆盖的候选。纯 n-gram * 单独用效果很差(实测 Top50 只有 4~7 个真标签),所以这里不靠它排序, * 只用它做**受限补充**,三道闸门过滤: * ① 长度 2~6 字(超过 6 字几乎不可能是标签) * ② 在全文出现 ≥ 2 次(只出现一次的多半是偶然搭配) * ③ 只由中文/字母数字构成,且不是停用词 * 再要求它在 n-gram 频次里排进前列,且**不与已命中词重叠**(重叠说明 * 只是长词的一部分,不该单独成标签)。 * * @return array{0:string[],1:string[]} [命中的已有标签, 建议新建的标签] */ function yang_writing_make_tags($title, $body) { list($dict, $maxlen) = yang_writing_dict(); if (!$dict) { return array(array(), array()); } $N = yang_writing_doc_count(); $stop = yang_writing_stopwords(); $full = $title . ' ' . $body; $hits = yang_writing_match($full, $dict, $maxlen); /* 词长(按字数)作为额外权重:两字标签(「工作」)天然比四字标签 (「信息焦虑」)更容易出现,不惩罚一下的话 Top 里全是两字词。 */ $body_len = max(1, mb_strlen($body, 'UTF-8')); $scores = array(); foreach ($hits as $w => $tf) { if (in_array($w, $stop, true)) { 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; } arsort($scores); /* 取前 8 个作为候选。 为什么是 8:再往上加,多出来的基本都是「的利」「的信」之外的低分噪声, 而 WordPress 后台标签框一次列太多反而要用户删。8 个是"够挑"与"不烦" 的折中,没有更强的依据支持别的数字。 */ $exist = array(); foreach (array_slice(array_keys($scores), 0, 8) as $w) { $exist[] = $dict[$w]['name']; } /* ── 新词候选(受限补充)── */ $new = yang_writing_find_new_terms($full, $dict, $maxlen, $stop, $exist); return array($exist, $new); } /** * 在词典之外找值得新建的标签候选。 * * ── 为什么这么保守 ────────────────────────────────────────── * 站内已有 703 个标签,其中 338 个只用过 1 次 —— 说明"随手建标签" * 的行为模式会把标签库迅速变成几千个长尾词,标签云和归档页都会废掉。 * 用户也明确要求「每篇文章有新增数量限制」,所以这里宁可少找、 * 不找错。找不到就返回空,功能降级为"只用已有标签",无害。 * * ── 3.2.57 第二轮:朴素 n-gram 的碎片问题(实测不过关,已修)── * 第一版只有三道闸门(长度 2~6 / 出现 ≥2 次 / 非停用词非词典词), * 拿「新博客」场景实测,输出是这样的: * 天文标题「星等换算与赤纬测量」 * → 星等换算与赤、等换算与赤纬、换算与赤纬测、算与赤纬测量 * 「锔瓷工艺」→ 套结的 * 全是**跨词边界的机械切片**,一个都不是标签。这正是当初排除纯 * n-gram 方案的同一个毛病,第一版等于把它又请回来了。 * * 修法不是调阈值,而是**先把句子切成短语再切 n-gram**: * ① 在虚词/标点处断开,只在短语内部取 n-gram * ——「星等换算与赤纬测量」被「与」切成「星等换算」「赤纬测量」 * ② 中文候选最多 4 字 * —— 回查 703 个真实标签:中文 6 字以上只有 3 个 * (「网上办公系统」「我在大学那几年」「基于Java的B/S系统构建」), * 都是人工写的完整短语,n-gram 永远造不出来;≤4 字覆盖 590/703 * (84%) * ③ 不许以虚字结尾(的/与/和/了/是/在/就/都/也/说/把/被) * —— 回查真实标签,以此结尾的 7 个里 6 个 count=0 是垃圾, * 唯一存活的「小说」count=69 是个完整的二字词,不是碎片 * * 实测对照(同一批用例): * A 传统手艺 → 竹编、漆器、锔瓷、竹编漆器、手艺、讲究、篾丝、髹涂 * B 天文观测 → 星等、星等换算、赤纬测量、子午仪读、蒙气差、做不准、消光、修正 * C 生造词 → 套结、珂玛珞套结、工艺、收口(切片「珂玛珞套」「玛珞套结」已消灭) * * ── 残留缺陷与为什么不继续修(诚实记录)──────────────────────── * 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[] 建议新建的词(已去重、按频次降序) */ /** * 虚词表 —— 切分短语与「不许以虚字结尾」**共用这一份**。 * * 为什么必须共用:3.2.57 第一版把同一批 28 个字写了两处($sep 内联一份、 * 闸门 ④ 里再抄一份)。实测 _probe_gate4.php 证明两边完全相同, * 于是闸门 ④ 成了死代码($sep 已把虚字全换成空格,chunk 里不可能有虚字)。 * 死代码本身不致命,致命的是**两套名单会漂移**:将来只想放宽一处时, * 另一处被忘掉,就会出现"某个虚字既不切分、也不拦尾巴"的漏网。 * * @return string[] 单字虚词 */ function yang_writing_seps() { return array('的', '与', '和', '了', '是', '在', '就', '都', '也', '及', '或', '而', '但', '把', '被', '从', '向', '对', '为', '以', '于', '之', '其', '这', '那', '个', '们', '说'); } function yang_writing_find_new_terms($text, $dict, $maxlen, $stop, $exist) { /* 先把已命中的词从文本里"抹掉",避免它们的长词片段被当成新词。 比如已命中「信息焦虑」时,不该再冒出「息焦虑」这种切片。 */ $masked = mb_strtolower($text, 'UTF-8'); foreach ($exist as $name) { $masked = str_replace(mb_strtolower($name, 'UTF-8'), '█', $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(); $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); if (!is_array($chunks)) { return array(); } $freq = array(); foreach ($chunks as $chunk) { $len = mb_strlen($chunk, 'UTF-8'); /* 2~6 字。低于 2 无意义;高于 6 基本不可能是标签。 注意:这里放宽到 6 是为了让长短语先进入候选, 真正的中文上限在闸门 ③ 收紧到 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; } } } if (!$freq) { return array(); } /* 闸门 ①:出现 ≥ 2 次。 闸门 ②:不是停用词、不在词典里。 这两条一起把候选量从上千压到个位数。 */ $cand = array(); foreach ($freq as $g => $c) { if ($c < 2) { 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; } 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]; $glen = mb_strlen($g, 'UTF-8'); for ($j = 0; $j < $nkeys; $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_strpos($bigger, $g, 0, 'UTF-8') !== false) { unset($cand[$g]); break; } } } if (!$cand) { return array(); } /* 按频次降序;频次相同按长度降序(长词信息量更大)。 */ uksort($cand, function ($a, $b) use ($cand) { if ($cand[$a] !== $cand[$b]) { return $cand[$b] <=> $cand[$a]; } return mb_strlen($b, 'UTF-8') <=> mb_strlen($a, 'UTF-8'); }); /* 取前 8 个。 * * ⚠️ 3.2.57 第一版这里还有一段「按已保留项过滤子串」的循环, * 现在删掉了 —— 闸门 ⑤ 已经在**全局**做过切片消除(对所有候选 * 两两比对,而不是只跟已保留的那几个比),再来一遍是重复劳动, * 而且这段是先排序后过滤,会漏掉「排序靠后的长词包含前面短词」 * 这种方向,判据在两种实现下结果不同,留着容易误判。 * 现在只做截断。 */ return array_slice(array_keys($cand), 0, 8); } /* ══════════════════════════════════════════════════════════════ * 六、引擎 ③:分类 —— 纯本地 * ══════════════════════════════════════════════════════════════ */ /** * 推荐一个分类。 * * ── 为什么不是朴素贝叶斯(我先写过一版,错的)─────────────── * 第一版用 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; } $weight[$w] = log(($N + 1) / ($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,不是洁癖。 */ list($exist_hits, $new_cands) = yang_writing_make_tags($title, $body); $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 —— 用户手工打的标签**不能删**。 */ wp_set_post_tags($post_id, $all, true); $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);