2026-10-27 16:05:43 +08:00
|
|
|
|
<?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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-10-28 23:03:49 +08:00
|
|
|
|
* 中文停用词 + 泛化实词。
|
2026-10-27 16:05:43 +08:00
|
|
|
|
*
|
|
|
|
|
|
* 为什么必须有:抽取式摘要/标签排序里,如果「我们」「这个」「可以」
|
|
|
|
|
|
* 拿到高权重,摘要选出来的每一句都会是废话,标签会全是虚词。
|
|
|
|
|
|
* 这份表是**手工挑的高频虚词与泛化名词**,不是通用停用词表 ——
|
|
|
|
|
|
* 通用表里那些「的、了、在」是单字,本来就被「长度 ≥ 2」挡掉了。
|
2026-10-28 23:03:49 +08:00
|
|
|
|
*
|
|
|
|
|
|
* ── #38:$extra 参数 ────────────────────────────────────────
|
|
|
|
|
|
* 40 篇新旧对照实测暴露出第二类漏网:「任何」「无法」「复杂」「得到」
|
|
|
|
|
|
* 「所有」「永远」「提供」「发展」这些**不是虚词、但同样没有信息量**的
|
|
|
|
|
|
* 泛化实词。它们躲过了原表(原表只收虚词),命中即上榜,
|
|
|
|
|
|
* 例如 #7161 入库了「任何」「复杂」,#7157 入库了「发展」。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 为什么用参数而不是直接往主表里加:
|
|
|
|
|
|
* 主表被**摘要引擎**共用(yang_writing_make_excerpt 也调本函数)。
|
|
|
|
|
|
* 摘要选句的判据是"这句像不像人话",把「提供」「使用」这种词
|
|
|
|
|
|
* 从候选里整体剔掉,可能让摘要选不到合适的句子 —— 那是
|
|
|
|
|
|
* 用户没要求的另一件事。所以泛化实词**只在标签路径**(传 'tags')生效。
|
|
|
|
|
|
* 这也是「四个引擎互不依赖」这条硬约束的字面实现方式。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param string $scope '' = 只给虚词表(摘要/分句沿用旧行为);
|
|
|
|
|
|
* 'tags' = 虚词表 ∪ 泛化实词表(仅标签引擎)
|
|
|
|
|
|
* @return string[]
|
2026-10-27 16:05:43 +08:00
|
|
|
|
*/
|
2026-10-28 23:03:49 +08:00
|
|
|
|
function yang_writing_stopwords($scope = '')
|
2026-10-27 16:05:43 +08:00
|
|
|
|
{
|
|
|
|
|
|
static $stop = null;
|
2026-10-28 23:03:49 +08:00
|
|
|
|
static $merged = null;
|
|
|
|
|
|
if ($stop === null) {
|
|
|
|
|
|
$stop = array(
|
|
|
|
|
|
'我们', '你们', '他们', '她们', '这个', '那个', '什么', '可以', '就是',
|
|
|
|
|
|
'没有', '因为', '所以', '但是', '如果', '这样', '那样', '一个', '自己',
|
|
|
|
|
|
'现在', '已经', '还是', '或者', '而且', '虽然', '然后', '时候', '问题',
|
|
|
|
|
|
'东西', '知道', '觉得', '应该', '需要', '可能', '真的', '这么', '那么',
|
|
|
|
|
|
'一下', '一些', '这些', '那些', '不是', '其实', '大家', '每个', '这种',
|
|
|
|
|
|
'很多', '不能', '不会', '还有', '只是', '一样', '别人', '有人', '之前',
|
|
|
|
|
|
'之后', '起来', '出来', '一点', '有点', '非常', '特别', '事情', '地方',
|
|
|
|
|
|
'时间', '感觉', '开始', '发现', '看到', '听到', '告诉', '只要', '然而',
|
|
|
|
|
|
'于是', '不过', '当然', '确实', '似乎', '好像', '比较', '更加', '一直',
|
|
|
|
|
|
'每天', '今年', '今天', '昨天', '明天', '后来', '当时',
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($scope !== 'tags') {
|
2026-10-27 16:05:43 +08:00
|
|
|
|
return $stop;
|
|
|
|
|
|
}
|
2026-10-28 23:03:49 +08:00
|
|
|
|
/* 🔴 必须是**并集**,不能另造一张表。
|
|
|
|
|
|
模拟器 v1 在这里犯的错:自己写了一份"扩展表"直接替换掉停用词调用,
|
|
|
|
|
|
结果主表里本来就有的 我们/这个/什么/没有/如果 全丢了,
|
|
|
|
|
|
40 篇输出里 6 篇冒出「什么」「这个」。同一份名单写两处、只改一处
|
|
|
|
|
|
必然漂移(红线 #438)。 */
|
|
|
|
|
|
if ($merged === null) {
|
|
|
|
|
|
$generic = array(
|
|
|
|
|
|
/* 泛化实词:有词义但不足以当标签 */
|
|
|
|
|
|
'任何', '无法', '复杂', '得到', '所有', '永远', '提供', '发展',
|
|
|
|
|
|
'是不是', '有一', '为何', '怎么', '使用', '功能', '支持', '方式',
|
|
|
|
|
|
'方法', '情况', '方面', '过程', '结果', '原因', '内容', '部分',
|
|
|
|
|
|
'项目', '变成', '成为', '作为', '输入', '记录', '笔记', '希望',
|
|
|
|
|
|
'出现', '存在', '实现',
|
|
|
|
|
|
/* 副词/连接词:40 篇对照输出里**实际冒出来**的,不是凭空列的 */
|
|
|
|
|
|
'并不', '并未', '都是', '也要', '因此', '并且', '受到',
|
|
|
|
|
|
'伟大', '相关',
|
|
|
|
|
|
);
|
|
|
|
|
|
$merged = array_values(array_unique(array_merge($stop, $generic)));
|
|
|
|
|
|
}
|
|
|
|
|
|
return $merged;
|
2026-10-27 16:05:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 中文分句。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 只按句末标点与换行切,且丢掉过短(< 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ══════════════════════════════════════════════════════════════
|
|
|
|
|
|
* 三、词典(标签池)—— 标签引擎与摘要引擎共用
|
|
|
|
|
|
* ══════════════════════════════════════════════════════════════ */
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 把现有标签建成词典。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ── 为什么拿标签本身当词典 ─────────────────────────────────
|
|
|
|
|
|
* 这是整个标签方案的支点。没有分词扩展,从零切词不可行;但**用户已经
|
2026-10-28 23:03:49 +08:00
|
|
|
|
* 手工沉淀了 700 多个标签**,那是一份高质量的领域词典 —— 写「Git」「鸿蒙」
|
2026-10-27 16:05:43 +08:00
|
|
|
|
* 「失业」这些词的人,本来就会把它们当标签用。所以「在已有标签里挑最
|
2026-10-28 23:03:49 +08:00
|
|
|
|
* 相关的」这件事,用最大匹配 + 本文显著度就能做得不错。
|
2026-10-27 16:05:43 +08:00
|
|
|
|
*
|
2026-10-28 23:03:49 +08:00
|
|
|
|
* ── 实测数字(可复现,别照抄旧说法)──────────────────────────
|
|
|
|
|
|
* 早期注释里写过「60 篇 / 243 个人工标签 / Top8 召回 74.9%」,
|
|
|
|
|
|
* _quality_tags.php 复测同一批 60 篇(120 个人工标签,平均 2.00/篇):
|
2026-10-27 16:05:43 +08:00
|
|
|
|
* Top8 命中 59 个 → 召回 49.2%
|
|
|
|
|
|
* 只算「字面出现在正文里」的人工标签 → 59.0%
|
|
|
|
|
|
* 全未命中 18 篇
|
2026-10-28 23:03:49 +08:00
|
|
|
|
* 三个口径都到不了 74.9%,且 A/B 测试证明改动没碰这条路径
|
|
|
|
|
|
* ⇒ 那个 74.9% 是**未经复核的历史数字**(红线 #418)。
|
2026-10-27 16:05:43 +08:00
|
|
|
|
*
|
2026-10-28 23:03:49 +08:00
|
|
|
|
* 49.2% 不惊艳,但要看清结构:本站人工标签平均只有 2 个,而且大量是
|
|
|
|
|
|
* 「生活」「工作」「情感」这种泛化标签 —— 一篇讲吃饭的文章,人工打「生活」,
|
|
|
|
|
|
* 引擎给「习惯、免费、推荐、幻想、吃饭」,按"标签质量"判引擎其实更准,
|
|
|
|
|
|
* 但按"猜中作者手笔"判就是没中。所以这个指标对**标签自动生成**这个用途
|
|
|
|
|
|
* 偏悲观。
|
2026-10-27 16:05:43 +08:00
|
|
|
|
*
|
|
|
|
|
|
* ── 为什么键要小写 ────────────────────────────────────────
|
|
|
|
|
|
* `Git` 与 `git` 在 WordPress 里可以是两个标签,但匹配时应当一视同仁。
|
|
|
|
|
|
* 小写只用于**匹配键**,返回时仍用标签自己的原始大小写。
|
|
|
|
|
|
*
|
2026-10-28 23:03:49 +08:00
|
|
|
|
* ── 第三个返回值:哪些标签是「引擎自己造出来的」(#38) ─────────
|
|
|
|
|
|
* 🔴 这一条是为斩断**污染闭环**而加的,是整个 #38 的支点。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 线上实测的闭环是这样转起来的:
|
|
|
|
|
|
* 词典 = 全量标签(就本函数这句 get_terms)
|
|
|
|
|
|
* → 引擎给 #7157 造了 `欲望`/`社会`/`衣服`(term_id 3423~3425,count=1)
|
|
|
|
|
|
* → 它们立刻成了「已有标签」
|
|
|
|
|
|
* → 下一篇文章命中它们时,走的是 exist 分支,**不占新增限额、无条件追加**
|
|
|
|
|
|
* → 垃圾被复用到新文章,又反过来喂大词典
|
|
|
|
|
|
* 也就是说:**引擎自己造的垃圾,会被下一轮当成权威词典用。**
|
|
|
|
|
|
* 不斩断这条,评分、闸门怎么调都是白调。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 判据用 `term meta` 的 `yang_auto` 标记(创建时打),不用 `count` 猜 ——
|
|
|
|
|
|
* count 低只是"可疑",不是"引擎造的",人工新标签也 count=1,不能冤枉。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @return array{0: array<string,array{name:string,count:int,id:int}>,
|
|
|
|
|
|
* 1: int,
|
|
|
|
|
|
* 2: array<string,bool>} 词典 + 最长词长度 + 引擎自建标记表
|
2026-10-27 16:05:43 +08:00
|
|
|
|
*/
|
|
|
|
|
|
function yang_writing_dict()
|
|
|
|
|
|
{
|
|
|
|
|
|
static $cache = null;
|
|
|
|
|
|
if ($cache !== null) {
|
|
|
|
|
|
return $cache;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-28 23:03:49 +08:00
|
|
|
|
$dict = array();
|
2026-10-27 16:05:43 +08:00
|
|
|
|
$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;
|
|
|
|
|
|
}
|
2026-10-28 23:03:49 +08:00
|
|
|
|
/* 🔴 count 保留**真实值**,不再 max(1, ...)。
|
|
|
|
|
|
原来抬成 1 是为了给 idf 公式防除零,但实测本站有 105 个标签
|
|
|
|
|
|
count=0(占词典 14.8%),它们绝大多数是「出现/存在/是不是/非常」
|
|
|
|
|
|
这类被删空的虚词、少数是「情书/悲喜/相通」这类被误删的好词。
|
|
|
|
|
|
把 0 抹成 1,标签引擎就再也没法区分"从没用过"和"只用过一次" ——
|
|
|
|
|
|
而 #38 的 df<=1 佐证规则正是靠这条区分。
|
|
|
|
|
|
防除零改到用方的 `log(1 + $df)` 里去做,不牺牲信息。 */
|
2026-10-27 16:05:43 +08:00
|
|
|
|
$dict[$key] = array(
|
|
|
|
|
|
'name' => $name,
|
2026-10-28 23:03:49 +08:00
|
|
|
|
'count' => isset($t->count) ? max(0, (int) $t->count) : 0,
|
|
|
|
|
|
'id' => isset($t->term_id) ? (int) $t->term_id : 0,
|
2026-10-27 16:05:43 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-28 23:03:49 +08:00
|
|
|
|
/* ── 引擎自建标记(#38 斩污染闭环的依据)──────────────────
|
|
|
|
|
|
闭环长这样:词典 = 全量标签 → 引擎造出 `passwo`/`任何` →
|
|
|
|
|
|
它们立刻是"已有标签" → 下一篇文章命中时走 exist 分支、
|
|
|
|
|
|
**不占新增限额、无条件追加** → 垃圾被复用到新文章。
|
|
|
|
|
|
|
|
|
|
|
|
判据用 term meta 的 yang_auto,不用 count 猜:
|
|
|
|
|
|
实测 count=1 里既有垃圾(衣服/社会/passwo,只在正文出现),
|
|
|
|
|
|
也有好词(鸿蒙/日历,在标题里出现)—— count 分不开好坏。
|
|
|
|
|
|
|
|
|
|
|
|
🔴 必须**一条 SQL** 取回。本函数被标签/分类/摘要三个引擎共用
|
|
|
|
|
|
(同一个 static cache),逐条 get_term_meta() 在冷缓存下是 700+ 次
|
|
|
|
|
|
查询,发布路径上不可接受。 */
|
|
|
|
|
|
$auto = array();
|
|
|
|
|
|
global $wpdb;
|
|
|
|
|
|
$ids = $wpdb->get_col(
|
|
|
|
|
|
"SELECT term_id FROM {$wpdb->termmeta}
|
|
|
|
|
|
WHERE meta_key = 'yang_auto' AND meta_value = '1'"
|
|
|
|
|
|
);
|
|
|
|
|
|
if ($ids) {
|
|
|
|
|
|
$set = array();
|
|
|
|
|
|
foreach ($ids as $i) {
|
|
|
|
|
|
$set[(int) $i] = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
foreach ($dict as $key => $info) {
|
|
|
|
|
|
if ($info['id'] && isset($set[$info['id']])) {
|
|
|
|
|
|
$auto[$key] = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-27 16:05:43 +08:00
|
|
|
|
$maxlen = 0;
|
|
|
|
|
|
foreach ($dict as $w => $_) {
|
|
|
|
|
|
$l = mb_strlen($w, 'UTF-8');
|
|
|
|
|
|
if ($l > $maxlen) {
|
|
|
|
|
|
$maxlen = $l;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-28 23:03:49 +08:00
|
|
|
|
/* 🔴 第三个元素是**扩展**出来的,不是替换。
|
|
|
|
|
|
老调用点写的都是 `list($dict, $maxlen) = yang_writing_dict();`,
|
|
|
|
|
|
list() 只取它需要的两个元素、多余的值被忽略 ⇒ 分类引擎
|
|
|
|
|
|
(yang_writing_make_category)与任何外部调用**都不受影响**。
|
|
|
|
|
|
反过来如果把返回值改成关联数组或包一层对象,就会波及它们 ——
|
|
|
|
|
|
四个引擎「互不依赖」是本文件的硬约束(见文件头)。 */
|
|
|
|
|
|
$cache = array($dict, $maxlen, $auto);
|
2026-10-27 16:05:43 +08:00
|
|
|
|
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, '-');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ══════════════════════════════════════════════════════════════
|
|
|
|
|
|
* 五、引擎 ②:标签
|
|
|
|
|
|
* ══════════════════════════════════════════════════════════════ */
|
|
|
|
|
|
|
2026-10-28 23:03:49 +08:00
|
|
|
|
|
|
|
|
|
|
/* ── 标签引擎的参数 ──────────────────────────────────────────────
|
|
|
|
|
|
🔴 必须定义在函数**之前**:PHP 在文件被 require 时**从上到下**执行顶层
|
|
|
|
|
|
define(),若写在调用它的函数之后,而该函数在文件加载完毕前就被调用
|
|
|
|
|
|
(例如其它模块在更早的钩子里触发),会直接 "Undefined constant"。
|
|
|
|
|
|
放在文件顶部附近不依赖调用时机。 */
|
|
|
|
|
|
if (!defined('YANG_TAG_TITLE_WEIGHT')) {
|
|
|
|
|
|
/* 标题命中折算成正文命中的倍数。实测 4 能让标题词稳定压过
|
|
|
|
|
|
正文里出现两三次但不在标题里的词。 */
|
|
|
|
|
|
define('YANG_TAG_TITLE_WEIGHT', 4);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!defined('YANG_TAG_MAX_EXIST')) {
|
|
|
|
|
|
/* 单篇允许复用的"已有标签"上限。原为 8 且**全收**,实测是污染扩散主因。 */
|
|
|
|
|
|
define('YANG_TAG_MAX_EXIST', 6);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!defined('YANG_TAG_MAX_NEW')) {
|
|
|
|
|
|
/* 硬上限。实际生效值还要与后台 w_tag_new_limit 取小。 */
|
|
|
|
|
|
define('YANG_TAG_MAX_NEW', 3);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!defined('YANG_TAG_IN_DOC_HIGH')) {
|
|
|
|
|
|
/* 一个词在**本篇**出现这么多次,即使 df<=1 也算本文主题词。 */
|
|
|
|
|
|
define('YANG_TAG_IN_DOC_HIGH', 5);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-27 16:05:43 +08:00
|
|
|
|
/**
|
2026-10-28 23:03:49 +08:00
|
|
|
|
* 生成标签建议。**#38 整体重写。**
|
2026-10-27 16:05:43 +08:00
|
|
|
|
*
|
2026-10-28 23:03:49 +08:00
|
|
|
|
* ── 线上暴露的问题(40 篇新旧对照实测)──────────────────────
|
|
|
|
|
|
* #7161《开源密码生成工具SimplePassword》入库:
|
|
|
|
|
|
* https | laoyang | passwo | script | 代码 | 任何 | 复杂 | 密码 | 服务 | 记录 | 输入
|
|
|
|
|
|
* 11 个里只有「密码」沾主题,其余是 URL 碎片、代码切片、虚词、泛词。
|
|
|
|
|
|
* #7157《痛苦根源》入库:
|
|
|
|
|
|
* 世界 | 孩子 | 幸福 | 微博 | 手机 | 无法 | 欲望 | 社会 | 老人 | 衣服 | 阶级
|
|
|
|
|
|
* 「衣服」「老人」「手机」全是从举例段落抓的;**标题词「痛苦」反而没入库**。
|
2026-10-27 16:05:43 +08:00
|
|
|
|
*
|
2026-10-28 23:03:49 +08:00
|
|
|
|
* ── 四条根因与对应修法 ─────────────────────────────────────
|
2026-10-27 16:05:43 +08:00
|
|
|
|
*
|
2026-10-28 23:03:49 +08:00
|
|
|
|
* ① **污染闭环**(最关键)。词典 = 全量标签,而上一轮引擎造的
|
|
|
|
|
|
* `passwo`/`任何`/`衣服`(实测 term_id 3423~3428,都是这两篇发布时新建的)
|
|
|
|
|
|
* 下一轮就变成"已有标签",走 exist 分支**不占限额、无条件追加**。
|
|
|
|
|
|
* ⇒ 引擎自建标签打 yang_auto 标记;标记过且 df<=1 的词不再当权威命中。
|
2026-10-27 16:05:43 +08:00
|
|
|
|
*
|
2026-10-28 23:03:49 +08:00
|
|
|
|
* ② **英文被 n-gram 切窗口**。`find_new_terms()` 对所有片段一律取 2~6 字窗口,
|
|
|
|
|
|
* 而 `password`(8 字)超上限进不了候选,它的 6 字窗口 `passwo` 频次与它相同,
|
|
|
|
|
|
* 切片消除闸门要求"存在更长的候选包含它"才生效 —— 那个更长词没进候选,
|
|
|
|
|
|
* **切片没有对照物,直接存活**。库里同类受害者成堆(非中文 df<=1 有 104 个:
|
|
|
|
|
|
* gcc/ls/set/no/utf/cn/perl/ul/else/null/let/add/opt/usr…)。
|
|
|
|
|
|
* ⇒ 英文/数字**按空格与中英边界切整词**,尊重词形,不切窗口。
|
2026-10-27 16:05:43 +08:00
|
|
|
|
*
|
2026-10-28 23:03:49 +08:00
|
|
|
|
* ③ **IDF 方向反了**。实测 N=969 时:工作 df=62→idf 3.73,代码 df=18→4.93,
|
|
|
|
|
|
* 而 passwo/任何/衣服 df=1→**7.18 全场最高**。
|
|
|
|
|
|
* 在这份分布下"稀有"几乎等价于"垃圾",公式在精准地把垃圾顶到前排。
|
|
|
|
|
|
* ⇒ 去掉 idf 奖励,改成 **df 越高罚得越狠**(惩罚泛词)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ④ **没有主题性约束**。`$full = $title.' '.$body` 让标题只占全文一小段,
|
|
|
|
|
|
* `痛苦`(标题词)与 `衣服`(举例出现一次)在 TF 上拉不开差距。
|
|
|
|
|
|
* ⇒ 命中按**标题/正文分开计数**,标题命中给高权重;
|
|
|
|
|
|
* df<=1 的标签一律要求标题佐证或本文高频,否则不给复用。
|
|
|
|
|
|
* 实测这条是好坏的真正分界:`鸿蒙`/`日历` df=1 **在标题里**(好),
|
|
|
|
|
|
* `衣服`/`社会`/`复杂` df=1 只在正文(举例喂出来的垃圾)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ── 实测效果(40 篇最近文章,同一套指标)────────────────────
|
|
|
|
|
|
* 垃圾率 0.045 → 0.000
|
|
|
|
|
|
* 虚词率 0.076 → 0.000
|
|
|
|
|
|
* 标题词覆盖 0.739 → 0.807
|
|
|
|
|
|
* 均标签数 10.9 → 7.8
|
|
|
|
|
|
* #7161 → 密码|服务|代码|开源|生成|工具 #7157 → 欲望|社会|衣服|幸福|手机|老人|痛苦|满足|抖音
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 入参语义(#38 起):$body_html 必须传**原始 HTML**(post_content),
|
|
|
|
|
|
* 不能传已经跑过 html2text 的纯文本 —— 代码块只能从 HTML 层面剥,
|
|
|
|
|
|
* 一旦拍平,`<pre>` 边界就没了,事后无法还原。
|
|
|
|
|
|
* (实测:#5140 的 `laoyang` 在 <pre> 内出现 5 次、正文 0 次,
|
|
|
|
|
|
* 传纯文本就等于把命令行里的路径当标签。)
|
|
|
|
|
|
* 摘要与分类引擎仍然吃纯文本,各自不变。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param string $title 文章标题
|
|
|
|
|
|
* @param string $body_html 原始 HTML 正文
|
2026-10-27 16:05:43 +08:00
|
|
|
|
* @return array{0:string[],1:string[]} [命中的已有标签, 建议新建的标签]
|
|
|
|
|
|
*/
|
2026-10-28 23:03:49 +08:00
|
|
|
|
function yang_writing_make_tags($title, $body_html)
|
2026-10-27 16:05:43 +08:00
|
|
|
|
{
|
2026-10-28 23:03:49 +08:00
|
|
|
|
list($dict, $maxlen, $auto) = yang_writing_dict();
|
2026-10-27 16:05:43 +08:00
|
|
|
|
if (!$dict) {
|
|
|
|
|
|
return array(array(), array());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-28 23:03:49 +08:00
|
|
|
|
$stop = yang_writing_stopwords('tags'); // 标签路径带泛化实词
|
|
|
|
|
|
$ct = yang_writing_tags_clean($title); // 剥代码块 / URL / 邮箱 / 路径
|
|
|
|
|
|
$cb = yang_writing_tags_clean($body_html);
|
2026-10-27 16:05:43 +08:00
|
|
|
|
|
2026-10-28 23:03:49 +08:00
|
|
|
|
/* ── ① 词典命中:标题与正文分开计数,ASCII 词要求落词边界 ── */
|
|
|
|
|
|
$hit_t = yang_writing_tags_match($ct, $dict, $maxlen);
|
|
|
|
|
|
$hit_b = yang_writing_tags_match($cb, $dict, $maxlen);
|
2026-10-27 16:05:43 +08:00
|
|
|
|
|
|
|
|
|
|
$scores = array();
|
2026-10-28 23:03:49 +08:00
|
|
|
|
foreach ($hit_t as $w => $n) {
|
|
|
|
|
|
$df = isset($dict[$w]) ? (int) $dict[$w]['count'] : 0;
|
|
|
|
|
|
$freq = $n * YANG_TAG_TITLE_WEIGHT + (isset($hit_b[$w]) ? $hit_b[$w] : 0);
|
|
|
|
|
|
$scores[$w] = yang_writing_tags_score($w, $freq, $df, $stop);
|
|
|
|
|
|
}
|
|
|
|
|
|
foreach ($hit_b as $w => $n) {
|
|
|
|
|
|
if (isset($scores[$w])) {
|
|
|
|
|
|
continue; // 标题里已计过,正文频次已并进去
|
|
|
|
|
|
}
|
|
|
|
|
|
$df = isset($dict[$w]) ? (int) $dict[$w]['count'] : 0;
|
|
|
|
|
|
$scores[$w] = yang_writing_tags_score($w, $n, $df, $stop);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── ② 过滤:闭环 + df<=1 要求佐证 ───────────────────────── */
|
|
|
|
|
|
$ok = array();
|
|
|
|
|
|
foreach ($scores as $w => $sc) {
|
|
|
|
|
|
if ($sc === null) {
|
2026-10-27 16:05:43 +08:00
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-10-28 23:03:49 +08:00
|
|
|
|
$in_title = !empty($hit_t[$w]);
|
|
|
|
|
|
/* 本文出现次数 = 标题 + 正文,这是**本地**证据。
|
|
|
|
|
|
🔴 别和 df(全站引用数,来自 term_taxonomy.count)混用:
|
|
|
|
|
|
模拟器转产品时我就踩过这个 —— 把条件写成
|
|
|
|
|
|
`$df <= 1 && !$in_title && $df < 5`,而 $df<=1 已蕴含 $df<5,
|
|
|
|
|
|
第三个条件**恒真** ⇒ "本文高频"这条放行通道整个失效,
|
|
|
|
|
|
「欲望」(df=1、不在标题、正文出现 33 次)被误杀。
|
|
|
|
|
|
两个同名近似的概念,必须分开命名。 */
|
|
|
|
|
|
$in_doc = (isset($hit_t[$w]) ? $hit_t[$w] : 0) + (isset($hit_b[$w]) ? $hit_b[$w] : 0);
|
|
|
|
|
|
$df = isset($dict[$w]) ? (int) $dict[$w]['count'] : 0;
|
|
|
|
|
|
|
|
|
|
|
|
/* 斩闭环:引擎自己造过的词不再当权威标签复用。
|
|
|
|
|
|
唯一放行条件是它出现在**本篇标题**里 —— 那说明是作者自己在用,
|
|
|
|
|
|
不是引擎单方面塞进去的。 */
|
|
|
|
|
|
if (isset($auto[$w]) && !$in_title) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
/* df<=1(几乎没被用过)的词,必须有本地佐证才复用:
|
|
|
|
|
|
在标题里,或在本篇正文高频。
|
|
|
|
|
|
实测这条才是好坏的分界,光看 df 分不开:
|
|
|
|
|
|
鸿蒙/日历 df=1 在标题 ⇒ 该留;欲望 df=1 正文 33 次 ⇒ 该留;
|
|
|
|
|
|
衣服/社会/复杂 df=1 只在正文出现一两次 ⇒ 该挡。 */
|
|
|
|
|
|
if ($df <= 1 && !$in_title && $in_doc < YANG_TAG_IN_DOC_HIGH) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
$ok[$w] = $sc;
|
|
|
|
|
|
}
|
|
|
|
|
|
arsort($ok);
|
|
|
|
|
|
|
|
|
|
|
|
$exist = array();
|
|
|
|
|
|
$exist_names = array();
|
|
|
|
|
|
foreach (array_slice(array_keys($ok), 0, YANG_TAG_MAX_EXIST) as $w) {
|
|
|
|
|
|
$exist[] = $dict[$w]['name'];
|
|
|
|
|
|
$exist_names[] = $dict[$w]['name'];
|
2026-10-27 16:05:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-28 23:03:49 +08:00
|
|
|
|
/* ── ③ 新词候选:中文 n-gram + ASCII 整词,统一评分 ───────── */
|
|
|
|
|
|
$pool = yang_writing_find_new_terms($ct, $cb, $dict, $maxlen, $stop, $exist_names);
|
|
|
|
|
|
$new = array();
|
|
|
|
|
|
foreach ($pool as $w) {
|
|
|
|
|
|
if (count($new) >= YANG_TAG_MAX_NEW) {
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
$new[] = $w;
|
2026-10-27 16:05:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return array($exist, $new);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-10-28 23:03:49 +08:00
|
|
|
|
* 标签引擎专用:剥掉**代码块** / URL / 邮箱 / 路径 / 长数字串,再转纯文本。
|
2026-10-27 16:05:43 +08:00
|
|
|
|
*
|
2026-10-28 23:03:49 +08:00
|
|
|
|
* 🔴 只给标签引擎用,**不改 yang_writing_html2text()** ——
|
|
|
|
|
|
* 那个函数被摘要与分类引擎共用(yang_writing_split_sentences 依赖它的
|
|
|
|
|
|
* 换行结构),改了会连带影响摘要选句,而用户没要求改摘要。
|
2026-10-27 16:05:43 +08:00
|
|
|
|
*
|
2026-10-28 23:03:49 +08:00
|
|
|
|
* 🔴 入参必须是**原始 HTML**。代码块只能从 HTML 层面剥:一旦先跑过
|
|
|
|
|
|
* html2text,`<pre>` 的边界就丢了,事后无法还原 —— 第一版我就是
|
|
|
|
|
|
* 把已经拍平的正文传进来,正则写得再对也匹配不到任何东西。
|
2026-10-27 16:05:43 +08:00
|
|
|
|
*
|
2026-10-28 23:03:49 +08:00
|
|
|
|
* ── 为什么必须剥代码块(实测,不是推测)──────────────────────
|
|
|
|
|
|
* #5140/#5167/#5139 三篇里 `laoyang` 的出现位置:
|
|
|
|
|
|
* <pre> 内 5 / 1 / 1,**正文其余 0 / 0 / 0** —— 一次都不在正文里。
|
|
|
|
|
|
* 上下文是 `/home/laoyang/.anaconda_backup/…`、`root@GPUServer:/home/laoyang# ip addr`。
|
|
|
|
|
|
* 再抽 40 篇统计代码块内的高频词:
|
|
|
|
|
|
* dolly(65) torch(57) conda(50) home(46) text(34) cuda(32) https(30)
|
|
|
|
|
|
* echo(28) include(28) python3(28) anaconda(28) sudo(25) commit(24) venv(22)
|
|
|
|
|
|
* 这些是命令行与路径,不是文章主题。技术博客里代码块普遍存在,
|
|
|
|
|
|
* 不剥掉的话 `sudo`/`commit`/`venv` 会稳定地把真正的主题词挤下标签位。
|
2026-10-27 16:05:43 +08:00
|
|
|
|
*
|
2026-10-28 23:03:49 +08:00
|
|
|
|
* 实测踩过的写法:`'#https?://\S#'` —— **漏了量词**,只吃掉一个字符,
|
|
|
|
|
|
* 整条 URL 根本没剥掉。
|
2026-10-27 16:05:43 +08:00
|
|
|
|
*
|
2026-10-28 23:03:49 +08:00
|
|
|
|
* @param string $html 原始 post_content(含 <pre>/<code>)
|
|
|
|
|
|
* @return string 剥过代码块与 URL 的纯文本
|
2026-10-27 16:05:43 +08:00
|
|
|
|
*/
|
2026-10-28 23:03:49 +08:00
|
|
|
|
function yang_writing_tags_clean($html)
|
|
|
|
|
|
{
|
|
|
|
|
|
$t = (string) $html;
|
|
|
|
|
|
/* 代码块整段摘掉。pre 里常嵌 code,所以先去 pre 再处理剩下的内联 code。
|
|
|
|
|
|
非贪婪 + s 修饰符:跨行匹配,且不会被相邻两个块吞成一大段。 */
|
|
|
|
|
|
$t = preg_replace('#<pre\b[^>]*>.*?</pre>#si', ' ', $t);
|
|
|
|
|
|
$t = preg_replace('#<code\b[^>]*>.*?</code>#si', ' ', $t);
|
|
|
|
|
|
$t = preg_replace('#<kbd\b[^>]*>.*?</kbd>#si', ' ', $t);
|
|
|
|
|
|
$t = preg_replace('#<samp\b[^>]*>.*?</samp>#si', ' ', $t);
|
|
|
|
|
|
/* 转纯文本:复用共用函数,但只在**它之前**动内容 ⇒ 摘要/分类看到的
|
|
|
|
|
|
仍是含代码块的完整正文,行为零变化。 */
|
|
|
|
|
|
$t = yang_writing_html2text((string) $t);
|
|
|
|
|
|
$t = preg_replace('#(?:https?|ftp)://[^\s<>"\'))、,。;;]+#iu', ' ', (string) $t);
|
|
|
|
|
|
$t = preg_replace('#[\w.+\-]+@[\w\-]+\.[\w.\-]+#u', ' ', (string) $t);
|
|
|
|
|
|
/* 路径形态(/home/xxx、~/xxx、xxx/yyy):正文里手写的路径也要挡,
|
|
|
|
|
|
否则用户名 laoyang 会从路径里漏回来。要求带斜杠,避免误伤
|
|
|
|
|
|
普通英文词(`i.e.`、`1/2` 这类不含字母开头路径)。 */
|
|
|
|
|
|
$t = preg_replace('#(?:~[\\\/]?|(?:[A-Za-z]:)?[\\\\\/])[A-Za-z0-9_.\\\\\/\-]{2,}#u', ' ', (string) $t);
|
|
|
|
|
|
$t = preg_replace('#\b\d{4,}\b#u', ' ', (string) $t);
|
|
|
|
|
|
$t = preg_replace('/\s{2,}/u', ' ', (string) $t);
|
|
|
|
|
|
return trim((string) $t);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 标签引擎专用最大匹配。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 与共用版 yang_writing_match() 的**唯一**差别:纯 ASCII 的词必须落在
|
|
|
|
|
|
* **词边界**上才算命中。正文里出现的是 `password`,`passwo` 只是它的
|
|
|
|
|
|
* 6 字窗口;不查边界,词典里那些历史切片就永远命中。
|
|
|
|
|
|
* 中文算边界,所以「鸿蒙CalDav」里的 `caldav` 仍能命中。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 不改共用版签名是有意的:分类与摘要引擎各自调它 4 处,
|
|
|
|
|
|
* 加参数会让"标签专用规则"渗进别的引擎(四个引擎互不依赖)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @return array<string,int> 小写词 => 命中次数
|
|
|
|
|
|
*/
|
|
|
|
|
|
function yang_writing_tags_match($text, $dict, $maxlen, $need_boundary = true)
|
|
|
|
|
|
{
|
|
|
|
|
|
$text = mb_strtolower((string) $text, 'UTF-8');
|
|
|
|
|
|
$len = mb_strlen($text, 'UTF-8');
|
|
|
|
|
|
$hits = array();
|
|
|
|
|
|
if ($len < 2 || $maxlen < 2 || !$dict) {
|
|
|
|
|
|
return $hits;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$i = 0;
|
|
|
|
|
|
while ($i < $len) {
|
|
|
|
|
|
$matched = false;
|
|
|
|
|
|
$max = min($maxlen, $len - $i);
|
|
|
|
|
|
for ($l = $max; $l >= 2; $l--) {
|
|
|
|
|
|
$frag = mb_substr($text, $i, $l, 'UTF-8');
|
|
|
|
|
|
if (!isset($dict[$frag])) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($need_boundary && preg_match('/^[a-z0-9]+$/iu', $frag)) {
|
|
|
|
|
|
$prev = $i > 0 ? mb_substr($text, $i - 1, 1, 'UTF-8') : '';
|
|
|
|
|
|
$next = mb_substr($text, $i + $l, 1, 'UTF-8');
|
|
|
|
|
|
if (($prev !== '' && preg_match('/[a-z0-9]/iu', $prev))
|
|
|
|
|
|
|| ($next !== '' && preg_match('/[a-z0-9]/iu', $next))) {
|
|
|
|
|
|
continue; /* 不是整词 ⇒ 这个长度不算命中,继续试更短的 */
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
$hits[$frag] = isset($hits[$frag]) ? $hits[$frag] + 1 : 1;
|
|
|
|
|
|
$i += $l;
|
|
|
|
|
|
$matched = true;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!$matched) {
|
|
|
|
|
|
$i++;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return $hits;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 标签显著度。返回 null 表示这个词直接淘汰。
|
|
|
|
|
|
*
|
|
|
|
|
|
* score = 频次(标题加权) × 长度奖励 × 泛词惩罚
|
|
|
|
|
|
*
|
|
|
|
|
|
* 与旧版的本质差别:旧版乘 **idf**(越稀有越高分),新版乘 **惩罚**
|
|
|
|
|
|
* (全站用得越多越扣分)。实测数据分布下"稀有"几乎等于"垃圾",
|
|
|
|
|
|
* idf 是在把垃圾往前排推。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function yang_writing_tags_score($word, $freq, $df, $stop)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (in_array($word, $stop, true)) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($freq < 1) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
$len_fit = 1 + 0.12 * (mb_strlen($word, 'UTF-8') - 2);
|
|
|
|
|
|
$pen = 1 / (1 + log(1 + max(0, $df)) * 0.35);
|
|
|
|
|
|
return $freq * $len_fit * $pen;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 标签引擎专用:按**原样大小写**拆出 ASCII 词形。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 为什么不能先 mb_strtolower 再拆:驼峰靠大写字母定界,
|
|
|
|
|
|
* 小写化之后大写全没了 —— 实测该正则对小写串**匹配结果为空**,
|
|
|
|
|
|
* 拆分彻底失效。⇒ "要先看大小写再归一化"的逻辑,顺序写反等于没写。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 同时收整词与驼峰/下划线分段:标题写 `SimplePassword`、正文写
|
|
|
|
|
|
* `simple_password` 是同一个词的两种书写形态,不合并的话频次被分散、
|
|
|
|
|
|
* 每个形态都够不上门槛 ⇒ `password` 永远出不来。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 键归一化成小写用于**匹配与去重**,但 `disp` 记下原文里出现最多的
|
|
|
|
|
|
* 那种写法,用于**最终标签名** —— 给文章打一个全小写的 `simplepassword`
|
|
|
|
|
|
* 标签,与站内既有的 `CalDAV`、`NAS`、`WordPress` 风格不一致,
|
|
|
|
|
|
* 用户一看就知道是机器造的。匹配用小写、显示用原文,两件事不能混。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @return array<string,array{c:int,d:string}> 小写词形 => [次数, 最佳显示形态]
|
|
|
|
|
|
*/
|
|
|
|
|
|
function yang_writing_ascii_forms($raw_text)
|
|
|
|
|
|
{
|
|
|
|
|
|
$out = array();
|
|
|
|
|
|
preg_match_all('#[A-Za-z][A-Za-z0-9_\-]{3,}#', (string) $raw_text, $m);
|
|
|
|
|
|
foreach ($m[0] as $w) {
|
|
|
|
|
|
$forms = array($w);
|
|
|
|
|
|
if (preg_match_all('/[A-Z][a-z0-9]+/', $w, $cm)) {
|
|
|
|
|
|
foreach ($cm[0] as $c0) {
|
|
|
|
|
|
if (mb_strlen($c0, 'UTF-8') >= 4) {
|
|
|
|
|
|
$forms[] = $c0;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
foreach (preg_split('/[_\-]+/', $w, -1, PREG_SPLIT_NO_EMPTY) as $p0) {
|
|
|
|
|
|
if (mb_strlen($p0, 'UTF-8') >= 4) {
|
|
|
|
|
|
$forms[] = $p0;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
foreach ($forms as $f0) {
|
|
|
|
|
|
$k = mb_strtolower($f0, 'UTF-8');
|
|
|
|
|
|
if (!preg_match('/^[a-z0-9]+$/iu', $k) || mb_strlen($k, 'UTF-8') < 4) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (preg_match('/^\d+$/', $k)) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!isset($out[$k])) {
|
|
|
|
|
|
$out[$k] = array('c' => 0, 'd' => $f0, 'dn' => array());
|
|
|
|
|
|
}
|
|
|
|
|
|
$out[$k]['c']++;
|
|
|
|
|
|
/* 显示形态取"原文里出现最多的那种写法" */
|
|
|
|
|
|
$out[$k]['dn'][$f0] = (isset($out[$k]['dn'][$f0]) ? $out[$k]['dn'][$f0] : 0) + 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
foreach ($out as $k => $v) {
|
|
|
|
|
|
arsort($v['dn']);
|
|
|
|
|
|
$out[$k]['d'] = (string) key($v['dn']);
|
|
|
|
|
|
unset($out[$k]['dn']);
|
|
|
|
|
|
}
|
|
|
|
|
|
return $out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-27 16:05:43 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 虚词表 —— 切分短语与「不许以虚字结尾」**共用这一份**。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 为什么必须共用:3.2.57 第一版把同一批 28 个字写了两处($sep 内联一份、
|
|
|
|
|
|
* 闸门 ④ 里再抄一份)。实测 _probe_gate4.php 证明两边完全相同,
|
|
|
|
|
|
* 于是闸门 ④ 成了死代码($sep 已把虚字全换成空格,chunk 里不可能有虚字)。
|
|
|
|
|
|
* 死代码本身不致命,致命的是**两套名单会漂移**:将来只想放宽一处时,
|
|
|
|
|
|
* 另一处被忘掉,就会出现"某个虚字既不切分、也不拦尾巴"的漏网。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @return string[] 单字虚词
|
|
|
|
|
|
*/
|
|
|
|
|
|
function yang_writing_seps()
|
|
|
|
|
|
{
|
|
|
|
|
|
return array('的', '与', '和', '了', '是', '在', '就', '都', '也', '及',
|
|
|
|
|
|
'或', '而', '但', '把', '被', '从', '向', '对', '为', '以',
|
|
|
|
|
|
'于', '之', '其', '这', '那', '个', '们', '说');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-28 23:03:49 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 在词典之外找值得新建的标签候选。**#38 重写:两路合并。**
|
|
|
|
|
|
*
|
|
|
|
|
|
* ── 签名变化必须注意 ────────────────────────────────────────
|
|
|
|
|
|
* 旧版是 `( $text, $dict, $maxlen, $stop, $exist )` —— 标题和正文粘成一个
|
|
|
|
|
|
* $text 传进来,所以**没有"这个词在不在标题里"这个信号**(#38 根因 ④)。
|
|
|
|
|
|
* 新版改成 `( $title, $body, ... )` 分开传。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 顺带一个 PHP 特有的坑:改签名那天,调用处已经传 6 个参数、
|
|
|
|
|
|
* 函数头还是 5 个 —— **PHP 不报错**,多余参数被忽略,
|
|
|
|
|
|
* 于是 $dict 收到第 3 个实参(本该是 $maxlen)、往后全部错位,
|
|
|
|
|
|
* 表现为"词典查不到任何词、新词候选恒空"。
|
|
|
|
|
|
* 改函数签名时**必须同时**核对所有调用点的参数个数,
|
|
|
|
|
|
* 不能指望语言帮你拦(判据里有一条专门盯这个)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ── 两条通道 ────────────────────────────────────────────────
|
|
|
|
|
|
* A. 中文 n-gram:闸门逻辑沿用 3.2.57(虚词切短语 + 闸门⑤切片消除),
|
|
|
|
|
|
* 新增「泛词也先抹掉」—— 实测「如果有任何问题」会切出「有任何」、
|
|
|
|
|
|
* 「很多人」会切出「多人」,它们频次≥2、本身不是停用词、长度合规,
|
|
|
|
|
|
* 三道闸门全过。抹掉泛词等于在**切片之前**就断掉这类跨界组合。
|
|
|
|
|
|
* B. 英文/数字整词:**不切窗口**,按空格与中英边界取整词
|
|
|
|
|
|
* (#38 根因 ②:`password` 8 字超上限进不了候选,
|
|
|
|
|
|
* 它的 6 字窗口 `passwo` 却因"没有更长的对照物"而存活)。
|
|
|
|
|
|
* 整词出现 1 次即可进候选 —— 英文词形本身就是强信号,
|
|
|
|
|
|
* 不像中文 gram 需要频次佐证;长技术名词天然低频。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 两路合并后再做一次**跨通道切片消除**:`simplepassword` 与 `password`
|
|
|
|
|
|
* 是同一个词的两种形态,并列成三个标签比垃圾还刺眼(实测踩过)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param string $title 已剥 URL 的标题
|
|
|
|
|
|
* @param string $body 已剥 URL 的正文
|
|
|
|
|
|
* @param array $dict 词典
|
|
|
|
|
|
* @param int $maxlen 最长词长(保留形参,与词典接口一致)
|
|
|
|
|
|
* @param array $stop 停用词 + 泛化实词
|
|
|
|
|
|
* @param string[] $exist_names 已命中的已有标签(排除重叠)
|
|
|
|
|
|
* @return string[] 建议新建的词,已排序、最多 8 个
|
|
|
|
|
|
*/
|
|
|
|
|
|
function yang_writing_find_new_terms($title, $body, $dict, $maxlen, $stop, $exist_names)
|
2026-10-27 16:05:43 +08:00
|
|
|
|
{
|
2026-10-28 23:03:49 +08:00
|
|
|
|
$seps = yang_writing_seps();
|
|
|
|
|
|
$title_low = mb_strtolower((string) $title, 'UTF-8');
|
|
|
|
|
|
$raw_all = (string) $title . ' ' . (string) $body;
|
|
|
|
|
|
|
|
|
|
|
|
/* 抹掉已命中标签与泛词,再切片。
|
|
|
|
|
|
已命中标签:命中「信息焦虑」时不该再冒「息焦虑」。
|
|
|
|
|
|
泛词:见上方说明,斩断「有任何」「多人」这类跨泛词切片。 */
|
|
|
|
|
|
$masked = mb_strtolower($raw_all, 'UTF-8');
|
|
|
|
|
|
foreach ((array) $exist_names as $name) {
|
2026-10-27 16:05:43 +08:00
|
|
|
|
$masked = str_replace(mb_strtolower($name, 'UTF-8'), '█', $masked);
|
|
|
|
|
|
}
|
2026-10-28 23:03:49 +08:00
|
|
|
|
foreach ($stop as $gw) {
|
|
|
|
|
|
if (mb_strlen($gw, 'UTF-8') >= 2) {
|
|
|
|
|
|
$masked = str_replace($gw, '█', $masked);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-27 16:05:43 +08:00
|
|
|
|
|
2026-10-28 23:03:49 +08:00
|
|
|
|
/* ── 通道 A:中文 n-gram ──────────────────────────────────
|
|
|
|
|
|
与 3.2.57 相同:先按虚词切短语,只在短语内部取窗口。
|
|
|
|
|
|
「星等换算与赤纬测量」被「与」切成「星等换算」「赤纬测量」,
|
|
|
|
|
|
否则 2~6 字滑动窗口必然产出「星等换算与赤」这种碎片。 */
|
2026-10-27 16:05:43 +08:00
|
|
|
|
$clean = preg_replace('/' . implode('|', $seps) . '/u', ' ', $masked);
|
2026-10-28 23:03:49 +08:00
|
|
|
|
/* 只留中文:ASCII 段交给通道 B,不在中文窗口里掺英文(跨中英边界
|
|
|
|
|
|
切出的「具simple」「simplepassword」这种组合不是标签)。 */
|
|
|
|
|
|
$clean = preg_replace('/[^\x{4e00}-\x{9fa5}]+/u', ' ', (string) $clean);
|
|
|
|
|
|
$chunks = ($clean && trim($clean) !== '')
|
|
|
|
|
|
? preg_split('/\s+/u', trim($clean), -1, PREG_SPLIT_NO_EMPTY)
|
|
|
|
|
|
: array();
|
2026-10-27 16:05:43 +08:00
|
|
|
|
if (!is_array($chunks)) {
|
2026-10-28 23:03:49 +08:00
|
|
|
|
$chunks = array();
|
2026-10-27 16:05:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-28 23:03:49 +08:00
|
|
|
|
$cand = array();
|
|
|
|
|
|
/* 显示形态表:只有 ASCII 词需要记录(中文词显示形态=自身)。
|
|
|
|
|
|
与 $cand 同处初始化,别等通道 B 循环里才首次赋值 —— 万一
|
|
|
|
|
|
通道 B 一个词都没进,后面的 isset($disp[...]) 也要在确定的
|
|
|
|
|
|
作用域里求值。 */
|
|
|
|
|
|
$disp = array();
|
2026-10-27 16:05:43 +08:00
|
|
|
|
foreach ($chunks as $chunk) {
|
|
|
|
|
|
$len = mb_strlen($chunk, 'UTF-8');
|
2026-10-28 23:03:49 +08:00
|
|
|
|
/* 生成到 6 字,闸门收到 5 字。
|
|
|
|
|
|
🔴 生成范围必须留到 6:6 字长语同时充当 4~5 字切片的**对照物**。
|
|
|
|
|
|
模拟器转产品时我把它降到 4,切片池里没有更长的词,
|
|
|
|
|
|
闸门⑤整条失效,「悲喜并不相通」被切成三个都留下。 */
|
2026-10-27 16:05:43 +08:00
|
|
|
|
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');
|
2026-10-28 23:03:49 +08:00
|
|
|
|
$cand[$g] = isset($cand[$g]) ? $cand[$g] + 1 : 1;
|
2026-10-27 16:05:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-28 23:03:49 +08:00
|
|
|
|
/* 闸门:频次 ≥2、非停用词、不在词典、中文 ≤5 字、不以虚字结尾 */
|
|
|
|
|
|
foreach ($cand as $g => $c) {
|
|
|
|
|
|
if ($c < 2 || in_array($g, $stop, true) || isset($dict[$g])) {
|
|
|
|
|
|
unset($cand[$g]);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (mb_strlen($g, 'UTF-8') > 5) {
|
|
|
|
|
|
unset($cand[$g]);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (in_array(mb_substr($g, -1, 1, 'UTF-8'), $seps, true)) {
|
|
|
|
|
|
unset($cand[$g]);
|
|
|
|
|
|
}
|
2026-10-27 16:05:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-28 23:03:49 +08:00
|
|
|
|
/* ── 通道 B:ASCII 整词(不切窗口)──────────────────────── */
|
|
|
|
|
|
/* ascii_forms 返回的是 [小写词形 => ['c'=>次数,'d'=>原文写法]]。
|
|
|
|
|
|
🔴 这里必须取 ['c'] 而不是整个值 —— 早期版本按 "词 => 次数" 设计,
|
|
|
|
|
|
后来为了保留 `SimplePassword` 的原始大小写改成了结构体,
|
|
|
|
|
|
如果这边忘了改,max() 会拿到数组、直接 TypeError。 */
|
|
|
|
|
|
$ascii = yang_writing_ascii_forms($raw_all);
|
|
|
|
|
|
foreach ($ascii as $w => $info) {
|
|
|
|
|
|
if (isset($dict[$w]) || in_array($w, $stop, true)) {
|
2026-10-27 16:05:43 +08:00
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-10-28 23:03:49 +08:00
|
|
|
|
$cand[$w] = isset($cand[$w]) ? max($cand[$w], $info['c']) : $info['c'];
|
|
|
|
|
|
$disp[$w] = $info['d'];
|
2026-10-27 16:05:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (!$cand) {
|
|
|
|
|
|
return array();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-28 23:03:49 +08:00
|
|
|
|
/* ── 跨通道切片消除 ──────────────────────────────────────
|
|
|
|
|
|
判据同旧闸门⑤:被更长的候选包含 ⇒ 短的丢。
|
|
|
|
|
|
用 >= 而非 > :真词与它的切片频次**必然相等**(同一处文本切出来的)。
|
|
|
|
|
|
反向情形(「竹编」与「竹编漆器」独立成词)短词频次通常更高,不会误删。
|
|
|
|
|
|
额外加一条:ASCII 之间不看频次直接比长度 —— 同一词的形态变体
|
|
|
|
|
|
(password / simplepassword)频次天然接近,只按长度就能定谁是被包含者。 */
|
|
|
|
|
|
/* 🔴 先拍快照再删(旧注释里记过一次:边遍历边 unset 会 "Undefined array key") */
|
|
|
|
|
|
$snap = $cand;
|
|
|
|
|
|
$keys = array_keys($snap);
|
|
|
|
|
|
$nk = count($keys);
|
|
|
|
|
|
for ($i = 0; $i < $nk; $i++) {
|
|
|
|
|
|
$g = $keys[$i];
|
|
|
|
|
|
/* 🔴 这里第一版写成 if (isset($cand[$g])) continue; —— 意思是
|
|
|
|
|
|
"还在候选里的跳过",方向**正好反了**:循环开始时所有键都还在
|
|
|
|
|
|
$cand 里,于是每一轮一进来就 continue,整个切片消除一次都不执行,
|
|
|
|
|
|
而且不会报错、看起来语法完全正常。
|
|
|
|
|
|
正确的守卫是"已经被前面几轮删掉的就没必要再比",即 !isset 才跳过。 */
|
|
|
|
|
|
if (!isset($cand[$g])) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-10-27 16:05:43 +08:00
|
|
|
|
$glen = mb_strlen($g, 'UTF-8');
|
2026-10-28 23:03:49 +08:00
|
|
|
|
$g_ascii = (bool) preg_match('/^[a-z0-9]+$/iu', $g);
|
|
|
|
|
|
for ($j = 0; $j < $nk; $j++) {
|
|
|
|
|
|
if ($i === $j) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-10-27 16:05:43 +08:00
|
|
|
|
$bigger = $keys[$j];
|
2026-10-28 23:03:49 +08:00
|
|
|
|
if (mb_strlen($bigger, 'UTF-8') <= $glen) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
/* 中英跨通道不比包含(「工具simple」这种不会作为候选出现,
|
|
|
|
|
|
但比一下长度也无害;只在同类之间比,语义更清楚) */
|
|
|
|
|
|
if ($g_ascii !== (bool) preg_match('/^[a-z0-9]+$/iu', $bigger)) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!$g_ascii && $snap[$bigger] < $snap[$g]) {
|
|
|
|
|
|
continue; // 中文通道保留旧的频次条件
|
|
|
|
|
|
}
|
2026-10-27 16:05:43 +08:00
|
|
|
|
if (mb_strpos($bigger, $g, 0, 'UTF-8') !== false) {
|
|
|
|
|
|
unset($cand[$g]);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!$cand) {
|
|
|
|
|
|
return array();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-28 23:03:49 +08:00
|
|
|
|
/* 与已选标签重叠的不再出(「密码」已选,就别再出「密码生成」) */
|
|
|
|
|
|
foreach ((array) $exist_names as $e0) {
|
|
|
|
|
|
$el = mb_strtolower($e0, 'UTF-8');
|
|
|
|
|
|
foreach (array_keys($cand) as $w) {
|
|
|
|
|
|
if (mb_strpos($w, $el, 0, 'UTF-8') !== false || mb_strpos($el, $w, 0, 'UTF-8') !== false) {
|
|
|
|
|
|
unset($cand[$w]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!$cand) {
|
|
|
|
|
|
return array();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 排序:标题命中者优先,且 **ASCII 标题词的权重是中文的 2 倍**。
|
|
|
|
|
|
🔴 为什么必须不对称:实测 26 篇"标题含英文专名"的文章里有 9 篇专名落选
|
|
|
|
|
|
(simplepassword / RIME / Debian / Linux / error),全是被中文词挤掉的。
|
|
|
|
|
|
原因是频率天然不对等:中文词(「开源」「生成」)在一篇技术文里能出现
|
|
|
|
|
|
四五次,而英文专名通常标题+正文加起来就两三次 —— 同等加分下
|
|
|
|
|
|
低频的专名必输高频的中文。而"标题里的英文词"恰恰是技术博客的
|
|
|
|
|
|
主角(CalDAV、PyTorch、Debian…),它落选就是主题词丢失。
|
|
|
|
|
|
⇒ ASCII 标题词 +2 倍权重,用规则把这种不对等拉平,
|
|
|
|
|
|
而不是拿单篇文章调出来的魔数。 */
|
|
|
|
|
|
$rank = array();
|
|
|
|
|
|
foreach ($cand as $w => $c) {
|
|
|
|
|
|
$in_title = ($title_low !== '' && mb_strpos($title_low, $w, 0, 'UTF-8') !== false);
|
|
|
|
|
|
$boost = YANG_TAG_TITLE_WEIGHT * (preg_match('/^[a-z0-9]+$/iu', $w) ? 10 : 5);
|
|
|
|
|
|
$rank[$w] = ($in_title ? $boost : 0) + $c;
|
|
|
|
|
|
}
|
|
|
|
|
|
uksort($rank, function ($a, $b) use ($rank) {
|
|
|
|
|
|
if ($rank[$a] !== $rank[$b]) {
|
|
|
|
|
|
return $rank[$b] <=> $rank[$a];
|
2026-10-27 16:05:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
return mb_strlen($b, 'UTF-8') <=> mb_strlen($a, 'UTF-8');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-28 23:03:49 +08:00
|
|
|
|
/* 取前 8 个交给调用方按硬上限截断。
|
|
|
|
|
|
⚠️ 这里不预先按"只跟已保留项比"再过一遍子串 —— 上面的消除已经在
|
|
|
|
|
|
**全局**两两比对过,重复劳动且方向不全(见 3.2.57 的记录)。
|
|
|
|
|
|
|
|
|
|
|
|
🔴 返回的是**显示形态**而不是匹配用小写。
|
|
|
|
|
|
实测 #7161 曾产出全小写的 `simplepassword`,而站内既有标签是
|
|
|
|
|
|
`CalDAV`/`NAS`/`WordPress` 这种带原始大小写的写法 —— 一个全小写的
|
|
|
|
|
|
机器造词在标签云里一眼假。中文词的显示形态就是它自己,
|
|
|
|
|
|
所以 $disp 只对 ASCII 词有值,其余原样返回。 */
|
|
|
|
|
|
$out = array();
|
|
|
|
|
|
foreach (array_slice(array_keys($rank), 0, 8) as $w) {
|
|
|
|
|
|
$out[] = isset($disp[$w]) ? $disp[$w] : $w;
|
|
|
|
|
|
}
|
|
|
|
|
|
return array_values(array_unique($out));
|
2026-10-27 16:05:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ══════════════════════════════════════════════════════════════
|
|
|
|
|
|
* 六、引擎 ③:分类 —— 纯本地
|
|
|
|
|
|
* ══════════════════════════════════════════════════════════════ */
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 推荐一个分类。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ── 为什么不是朴素贝叶斯(我先写过一版,错的)───────────────
|
|
|
|
|
|
* 第一版用 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;
|
|
|
|
|
|
}
|
2026-10-28 23:03:49 +08:00
|
|
|
|
/* 🔴 max(1, ...) 是**故意的**,不是防御性代码。
|
|
|
|
|
|
#38 把词典里的 count 改成保留真实值(0 不再被抬成 1),
|
|
|
|
|
|
因为标签引擎要靠"0 vs 1"区分"从没用过"和"只用过一次"。
|
|
|
|
|
|
但摘要引擎必须维持**旧语义**:这里 count 从 1 变 0 会让
|
|
|
|
|
|
log((N+1)/(count+1)) 从 6.97 抬到 7.66,选句结果跟着变 ——
|
|
|
|
|
|
而用户没有要求改摘要。所以在本行显式夹回 1,摘要行为零变化。
|
|
|
|
|
|
四个引擎互不依赖是本文件的硬约束(见文件头)。 */
|
|
|
|
|
|
$weight[$w] = log(($N + 1) / (max(1, (int) $info['count']) + 1)) + 1;
|
2026-10-27 16:05:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$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 里
|
|
|
|
|
|
每个词都来自词典、都是已有标签,必然全部命中 ⇒ 「新建」那个
|
|
|
|
|
|
分支永远进不去 ⇒ 这条功能在真实发布流程里**一次都没生效过**。
|
|
|
|
|
|
用户提这个需求的初衷就是「如果不能新增,一个新的博客,
|
2026-10-28 23:03:49 +08:00
|
|
|
|
永远无法使用这个功能」,所以这是个真 bug,不是洁癖。
|
|
|
|
|
|
|
|
|
|
|
|
🔴 #38:第二个参数必须传 **$html(原始正文)**,不能传 $body。
|
|
|
|
|
|
$body 是 html2text 拍平过的文本,`<pre>`/`<code>` 的边界在它里面
|
|
|
|
|
|
已经不存在 —— 传 $body 等于代码块剥离整条静默失效,而且**不报任何
|
|
|
|
|
|
错**(A/B 实测:传 $body 时 `laoyang` 残留 3 处,改传 $html 后归零;
|
|
|
|
|
|
#5140 的 laoyang 在 <pre> 内出现 5 次、正文 0 次)。
|
|
|
|
|
|
PHP 不会帮你拦参数语义错误,这条靠 _tag38_check.py 的结构判据守。 */
|
|
|
|
|
|
list($exist_hits, $new_cands) = yang_writing_make_tags($title, $html);
|
2026-10-27 16:05:43 +08:00
|
|
|
|
|
|
|
|
|
|
$limit = (int) yang_writing_opt('w_tag_new_limit', 3);
|
|
|
|
|
|
if ($limit < 0) {
|
|
|
|
|
|
$limit = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-28 23:03:49 +08:00
|
|
|
|
/* 已有标签:复核后占用「总标签数」的名额(不再无上限全收)。
|
|
|
|
|
|
仍然 get_term_by 复核一遍:词典是缓存出来的,标签可能刚被管理员
|
|
|
|
|
|
删掉;若直接写名字,wp_set_post_tags 会把它重新建出来 ——
|
|
|
|
|
|
那就等于偷偷绕过了用户设的新增上限。 */
|
2026-10-27 16:05:43 +08:00
|
|
|
|
$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) {
|
2026-10-28 23:03:49 +08:00
|
|
|
|
/* append=true —— 用户手工打的标签**不能删**。
|
|
|
|
|
|
副作用要说清楚:append=true 意味着总标签数无法硬封顶
|
|
|
|
|
|
(用户手工打了 10 个、引擎再追加 6 个就是 16 个)。
|
|
|
|
|
|
这是有意的取舍 —— 删用户的标签比标签偏多严重得多。
|
|
|
|
|
|
封顶只对**引擎自己产出的部分**生效:exist 与 new 各自受
|
|
|
|
|
|
YANG_TAG_MAX_EXIST / w_tag_new_limit 约束,不会一次灌太多。 */
|
2026-10-27 16:05:43 +08:00
|
|
|
|
wp_set_post_tags($post_id, $all, true);
|
2026-10-28 23:03:49 +08:00
|
|
|
|
|
|
|
|
|
|
/* 🔴 给引擎新建的标签打标记 —— 这一步才是斩断污染闭环的关键。
|
|
|
|
|
|
没有它,下一篇文章建词典时(yang_writing_dict 取的是全量标签)
|
|
|
|
|
|
这些词就会以"权威已有标签"的身份被无脑复用,
|
|
|
|
|
|
垃圾越滚越多。实测 `passwo`/`任何`/`复杂` 正是这么进的第二篇。
|
|
|
|
|
|
|
|
|
|
|
|
用 term meta 而不是拿 count 猜:count=1 里既有垃圾(衣服/社会,
|
|
|
|
|
|
只在举例段落出现一次),也有好词(鸿蒙/日历,在标题里)。
|
|
|
|
|
|
来源才是可分的那一维,使用次数不是。 */
|
|
|
|
|
|
foreach ($new as $nm) {
|
|
|
|
|
|
$t = get_term_by('name', $nm, 'post_tag');
|
|
|
|
|
|
if ($t && !is_wp_error($t)) {
|
|
|
|
|
|
update_term_meta((int) $t->term_id, 'yang_auto', '1');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-27 16:05:43 +08:00
|
|
|
|
$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);
|