Files
wordpress-kratos-yang/inc/theme-writing.php
T
laoyang 0a5486e195 3.2.57 新增「写作增强」:发布时自动生成别名/标签/分类/摘要 #25
需求(工单 #25):写文章时自动生成别名(翻译)、自动生成标签、
自动选择分类、自动生成摘要,问主题能不能做、是否必须用插件。
结论:主题自己就能做,不依赖任何插件。

新增 inc/theme-writing.php(四个独立引擎 + 设置读写 + 密钥处理):

- 触发点:transition_post_status,且要求
  new_status === publish && old_status !== publish
  ⇒ 只在「第一次点发布」时动手,自动保存不触发,已发布的文章一概不碰。
- 幂等锁 _yang_writing_done:同一篇只做一次,避免反复覆写。

四道「不覆盖」守卫(用户逐条确认的决策):
- 摘要:只在 post_excerpt 为空时才写。
- 分类:只在文章还挂默认分类(未分类)时才动。
- 别名:只在「空」或「不是纯 ASCII」时才生成。判据不是"空"而是
  "不是纯 ASCII" —— 中文标题下 WP 生成的是 %e4%bf%a1... 这种编码串,
  它非空但不可用。已有英文别名的 966 篇一概不动(别名=URL,改了
  就是成批 404)。
- 标签:wp_set_post_tags(..., true) 追加,绝不删除手工标签。

修一个真实产品 bug:3.2.56 及以前 apply() 里写的是
list($suggest, $_) = yang_writing_make_tags(...),$new 被整个丢掉;
接着又拿 $suggest 去 get_term_by 二次分类 —— 而 $suggest 里每个词
都来自词典、必然全部命中 ⇒「新建」分支永远进不去 ⇒ 这条功能在真实
发布流程里一次都没生效过。改为 list($exist_hits, $new_cands),
两段分别复核后合并。

标签两级策略(新博客也能用):先匹配已有标签池,匹配不到才新建,
每篇新增数量受 w_tag_new_limit 约束(默认 3,设 0 = 永不新建)。

中文分词(服务器无 scws/jieba):先按虚词与标点切短语,再在短语
内部取 2~6 字 n-gram。不切句直接取窗口会产出「星等换算与赤」
这类跨词碎片,且字面上"出现 ≥2 次"能通过所有频次闸门 —— 这是
n-gram 的固有缺陷,只能靠切分规避,调阈值无效。
虚词表统一由 yang_writing_seps() 提供(闸门④ 复用同一函数)。

翻译:默认 MyMemory(免注册),可选百度/有道。任何失败都返回空串
跳过该项,绝不阻塞发布。

密钥:存独立选项 yang_writing_secrets,不进 kratos;password 类型 +
std 空串;页面只显示末 4 位;留空提交 = 不修改。

设置页:新增「写作增强」独立 heading 标签页,四个开关默认全关
(升级不改变现有行为)。framework 里 'requires' 这个键根本没被读取,
条件显示改用 options-custom.js 显式逻辑;info 类型不生成 .section
包裹层,补一条 .section-info 规则才能藏住服务商申请说明。

验证(发布前):
- 写作端到端 71 条、后台渲染 21 条、密钥路径 9 条,合计 101 条全过
- 反证 22 组(W1~W16 端到端 / A1~A6 后台)× R1~R14 发布包注入,
  全部让失败数从 0 增加
- 测试自身不污染:每节差分回滚 + 总闸门,标签库稳定 703 / 967 篇
- 发布走 theme-update/,主题目录仍是 3.2.56,未被覆盖
2026-10-27 16:05:43 +08:00

1412 lines
61 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 写作增强 —— 发布文章时自动生成别名 / 标签 / 分类 / 摘要
*
* ── 四个引擎各自独立 ──────────────────────────────────────────
* 用户明确要求「每项都是独立引擎,是不是要用外部服务,你来判断,能不用就不用」。
* 所以下面四个生成器互不依赖,每个都能单独开关、单独失败:
*
* ① 别名(slug) 必须外部翻译 —— 纯 PHP 做不到中译英(见下方实测)
* ② 标签 本地优先,可选外部 —— 703 个已有标签就是最好的领域词典
* ③ 分类 纯本地 —— 不需要任何网络
* ④ 摘要 纯本地 —— 不需要任何网络
*
* ── 为什么别名一定要外部服务 ──────────────────────────────────
* 在线上实测过三条路,只有第三条可用:
* · intl 拼音:`信息焦虑综合症` → `xin-xi-jiao-lu-zong-he-zheng`
* 能用但不是翻译,且长到没法看。
* · 拿 966 组「中文标题→英文 slug」训练词对齐:5 个测试标题里 3 个
* **译出 0 个词**(数据摊到 8521 个中文片段上太稀疏)⇒ 不可用。
* · 翻译 API:`信息焦虑综合症` → `Information Anxiety Syndrome` ⇒ 可用。
* 服务器没有 scws / jieba 任何中文分词扩展,所以「本地发现新词」这条路
* 本身也是走不通的 —— 这不是偷懒,是环境事实。
*
* ── 三条红线(改动本文件前先读)────────────────────────────────
* ① **绝不覆盖已有值。** 用户原话:「别名可能意味着 url,url 变化了会
* 造成大量的 404 错误」。所以 slug 只在为空时写,其它三项同理。
* ② **绝不处理旧文章。** 只在「第一次变成 publish」时触发。
* 已在 publish 状态下再次保存 ⇒ 直接返回,一个字都不动。
* ③ **外部服务失败必须静默降级。** 发布是用户的主流程,不能让一个
* 翻译接口超时把「发布」这个动作卡住或报红。
*
* @author Yongquan Yang <yangyq.net>
* @license MIT License
*/
/* ══════════════════════════════════════════════════════════════
* 一、设置读取
* ══════════════════════════════════════════════════════════════ */
/**
* 读一项写作增强设置。
*
* 统一走 kratos_option(),与主题其它模块一致;不做任何类型转换,
* 由调用方自己决定(复选框要布尔,上限要整数)。
*
* @param string $key 设置键名(不含前缀,本模块统一 w_ 开头)
* @param mixed $default 库里没这个键时的默认值
* @return mixed
*/
function yang_writing_opt($key, $default = '')
{
return kratos_option($key, $default);
}
/**
* 某个引擎是否启用。
*
* ⚠️ 复选框未勾选时不会出现在 POST 里,所以库里存的是 false;
* 但**键不存在**(用户从没保存过设置)时也应当视为关闭 ——
* 这是新装主题的正确语义:不能默认就往用户的文章里写东西。
*/
function yang_writing_on($key)
{
$v = yang_writing_opt($key, '0');
return $v === true || $v === '1' || $v === 1 || $v === 'on';
}
/* ══════════════════════════════════════════════════════════════
* 二、文本处理(纯本地,四个引擎共用)
* ══════════════════════════════════════════════════════════════ */
/**
* HTML 正文 → 纯文本。
*
* 顺序有讲究:先摘掉 script/style 整块(strip_tags 只剥标签、留内容,
* 会把 JS 源码当成正文喂进分词),再补换行给块级元素(否则段落会粘成
* 一坨,分句全错),最后才 strip_tags。
*/
function yang_writing_html2text($html)
{
$t = (string) $html;
$t = preg_replace('#<script[^>]*>.*?</script>#si', ' ', $t);
$t = preg_replace('#<style[^>]*>.*?</style>#si', ' ', $t);
$t = preg_replace('#<!--.*?-->#s', ' ', $t);
/* 块级元素结尾补换行:分句依赖它,不然「…结束了。下一段…」可能
被当成一句,摘要里就会出现跨段拼接。 */
$t = preg_replace('#<(br|/p|/div|/h[1-6]|/li|/blockquote|/pre)\b[^>]*>#si', "\n", $t);
$t = strip_tags($t);
$t = html_entity_decode($t, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$t = preg_replace('/[ \t\x{3000}\x{00a0}]+/u', ' ', $t);
$t = preg_replace('/\n{2,}/u', "\n", $t);
return trim($t);
}
/**
* 中文停用词。
*
* 为什么必须有:抽取式摘要/标签排序里,如果「我们」「这个」「可以」
* 拿到高权重,摘要选出来的每一句都会是废话,标签会全是虚词。
* 这份表是**手工挑的高频虚词与泛化名词**,不是通用停用词表 ——
* 通用表里那些「的、了、在」是单字,本来就被「长度 ≥ 2」挡掉了。
*/
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);