Files
wordpress-kratos-yang/inc/theme-writing.php
T

1412 lines
61 KiB
PHP
Raw Normal View History

<?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」挡掉了。
*/
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<string,array{name:string,count:int}>, 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<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, '-');
}
/* ══════════════════════════════════════════════════════════════
* 五、引擎 ②:标签
* ══════════════════════════════════════════════════════════════ */
/**
* 生成标签建议。
*
* ── 算法 ────────────────────────────────────────────────────
* 在 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);