diff --git a/functions.php b/functions.php
index 0d07c77..2b8c3c4 100644
--- a/functions.php
+++ b/functions.php
@@ -96,4 +96,7 @@ require get_template_directory() . '/inc/theme-mail-template.php';
require get_template_directory() . '/inc/theme-mail.php';
// SMTP 配置(连通性相关,实际接管在 theme-mail.php)
-require get_template_directory() . '/inc/theme-smtp.php';
\ No newline at end of file
+require get_template_directory() . '/inc/theme-smtp.php';
+
+// 写作增强:发布时自动生成别名 / 标签 / 分类 / 摘要(四引擎各自独立)
+require get_template_directory() . '/inc/theme-writing.php';
\ No newline at end of file
diff --git a/inc/options-framework/css/optionsframework.css b/inc/options-framework/css/optionsframework.css
index 3e0b1a1..0e7910d 100644
--- a/inc/options-framework/css/optionsframework.css
+++ b/inc/options-framework/css/optionsframework.css
@@ -524,6 +524,16 @@
display: none !important;
}
+/* info 类型的条件隐藏
+ *
+ * info 不生成 .section 包裹层(框架对它走另一条分支),它自己是
+ * div.section-info。上面那条选择器要求同时有 .section 类,命不中它,
+ * 所以单独补一条 —— 否则「写作增强」里那段服务商申请说明永远显示,
+ * 即使「自动生成英文别名」没勾。 */
+#optionsframework .section-info.yq-cond-hide {
+ display: none !important;
+}
+
/* 3. 提交栏:与分组标题同一套留白 */
#optionsframework-submit {
padding: 12px 18px;
diff --git a/inc/options-framework/js/options-custom.js b/inc/options-framework/js/options-custom.js
index f49c784..f07f3aa 100644
--- a/inc/options-framework/js/options-custom.js
+++ b/inc/options-framework/js/options-custom.js
@@ -128,6 +128,43 @@ jQuery(document).ready(function ($) {
jQuery(document).on('change', '#toc_inline, #toc_float', yq_toggle_toc_fields)
yq_toggle_toc_fields()
+ // 写作增强:翻译服务商的四个凭据 + 说明,只在「自动生成英文别名」勾选时才有意义。
+ //
+ // 为什么用 requires 之外的方式:`requires` 这个键在框架里**根本没被读取**
+ // (grep 整个 options-framework 目录零命中,条件显示全靠本文件里这些
+ // 硬编码的 jQuery)。所以主题里其它 19 处 'requires' 其实都是死代码,
+ // 这里不再往那个坑里加,直接写显式逻辑。
+ //
+ // 为什么连说明文字一起藏:用户没勾别名生成时,那段「去百度申请 APPID」
+ // 只会是噪声。
+ //
+ // ⚠️ 用 delegation + toggleClass('yq-cond-hide'),不直接绑 input.hide():
+ // 原因与上面的自动目录一致(.section 的 display 是 flow-root,
+ // jQuery 会把行内 display 写成 block,回退 3.2.28 的布局修复)。
+ // info 那块没有 .section 类,靠 CSS 里补的 .section-info 规则命中。
+ function yq_toggle_writing_slug_fields () {
+ var on = jQuery('#w_on_slug').is(':checked')
+ jQuery('#section-w_provider').toggleClass('yq-cond-hide', !on)
+ jQuery('#section-w_baidu_appid').toggleClass('yq-cond-hide', !on)
+ jQuery('#section-w_baidu_key').toggleClass('yq-cond-hide', !on)
+ jQuery('#section-w_youdao_appid').toggleClass('yq-cond-hide', !on)
+ jQuery('#section-w_youdao_key').toggleClass('yq-cond-hide', !on)
+ jQuery('#section-w_timeout').toggleClass('yq-cond-hide', !on)
+ jQuery('#w_provider_help').toggleClass('yq-cond-hide', !on)
+ }
+
+ jQuery(document).on('change', '#w_on_slug', yq_toggle_writing_slug_fields)
+ yq_toggle_writing_slug_fields()
+
+ // 写作增强:「每篇最多新增几个标签」只在「自动生成标签」勾选时才有意义。
+ function yq_toggle_writing_tag_fields () {
+ var on = jQuery('#w_on_tag').is(':checked')
+ jQuery('#section-w_tag_new_limit').toggleClass('yq-cond-hide', !on)
+ }
+
+ jQuery(document).on('change', '#w_on_tag', yq_toggle_writing_tag_fields)
+ yq_toggle_writing_tag_fields()
+
// Loads the color pickers
$('.of-color').wpColorPicker()
diff --git a/inc/theme-options.php b/inc/theme-options.php
index 6e5367d..952eddc 100644
--- a/inc/theme-options.php
+++ b/inc/theme-options.php
@@ -1262,7 +1262,195 @@ function kratos_options()
);
/* ══════════════════════════════════════════════════════════
- * 十三、自定义代码 —— 兜底用,放最后
+ * 十三、写作增强 —— 发布时自动生成别名/标签/分类/摘要
+ *
+ * 四个引擎各自独立开关,互不依赖。分工是按「能不能纯本地做」
+ * 划的,不是按功能好坏:
+ * · 分类、摘要 → 纯本地(分类 90% 准确率、摘要 4ms)
+ * · 标签 → 本地优先,外部可选(Top8 召回 49.2%,见下方说明)
+ * · 别名 → 必须外部(纯 PHP 做不出中译英)
+ *
+ * 用户明确要求「能不用外部就不用,为了安全着想」,所以默认状态是:
+ * 四个开关**全关**,需要用户主动打开 —— 新装主题不该偷偷往文章里写东西。
+ * ══════════════════════════════════════════════════════════ */
+ $options[] = array(
+ 'name' => __('写作增强', 'kratos'),
+ 'type' => 'heading',
+ );
+
+ $options[] = array(
+ 'name' => __('总开关说明', 'kratos'),
+ 'desc' => __('下面的功能全部只在「文章第一次发布」那一下生效,'
+ . '并且只填空白、绝不覆盖已有内容。'
+ . '
已发布的旧文章完全不动 —— 再次编辑保存也不会改任何字段。'
+ . '
这样设计是因为别名就是 URL:一旦被改,旧链接全部 404。', 'kratos'),
+ 'type' => 'info',
+ );
+
+ /* ── ① 摘要 ────────────────────────────────────────────────
+ * 放第一个:最安全、最便宜、纯本地、离线可用。
+ * 站点现在 967 篇文章**没有一篇有摘要**,这一项立刻有收益。 */
+ $options[] = array(
+ 'name' => __('自动生成摘要', 'kratos'),
+ 'desc' => __('发布时从正文里抽取 150 字左右的摘要,写入「摘要」字段。'
+ . '纯本地计算,不联网,耗时约 4 毫秒。'
+ . '
只有摘要为空时才写;你自己写过的摘要不会被改动。', 'kratos'),
+ 'std' => '0',
+ 'id' => 'w_on_excerpt',
+ 'type' => 'checkbox',
+ );
+
+ /* ── ② 分类 ────────────────────────────────────────────────
+ * 同样纯本地。只在新文章还挂在「默认分类」上时才出手。 */
+ $options[] = array(
+ 'name' => __('自动选择分类', 'kratos'),
+ 'desc' => __('发布时按标签与已发布文章的历史共现关系,推荐最接近的一个分类。'
+ . '纯本地计算,不联网。'
+ . '
只对仍挂在「默认分类」的新文章生效;你已经选过分类的不动。'
+ . '
线上实测准确率约 90%(前三名命中 99%)。', 'kratos'),
+ 'std' => '0',
+ 'id' => 'w_on_category',
+ 'type' => 'checkbox',
+ );
+
+ /* ── ③ 标签 ────────────────────────────────────────────────
+ * 本地引擎:703 个已有标签当词典,最大匹配 + TF-IDF 排序。
+ * 外部引擎:本地匹配不到新词时,由服务商提关键词 —— 这是唯一
+ * 能"发现新词"的路(服务器没有任何中文分词扩展)。 */
+ $options[] = array(
+ 'name' => __('自动生成标签', 'kratos'),
+ 'desc' => __('发布时自动打标签。优先从你已有的标签里挑'
+ . '(实测:Top8 能命中人工标签的 49.2%,'
+ . '只算字面出现在正文里的人工标签则 59.0%),'
+ . '匹配不到才考虑新增。'
+ . '
你自己手工打的标签不会被删除,只会追加。', 'kratos'),
+ 'std' => '0',
+ 'id' => 'w_on_tag',
+ 'type' => 'checkbox',
+ );
+
+ $options[] = array(
+ 'name' => __('每篇最多新增几个标签', 'kratos'),
+ 'desc' => __('只限制新建的标签数量;匹配到已有标签不受此限制。'
+ . '
设为 0 表示只从已有标签里挑,永不新建。'
+ . '
为什么要限制:站内 703 个标签里已有 338 个只用过 1 次,'
+ . '放开新建会让标签库迅速变成几千个长尾词。', 'kratos'),
+ 'std' => '3',
+ 'id' => 'w_tag_new_limit',
+ 'type' => 'text',
+ );
+
+ /* ── ④ 别名 ────────────────────────────────────────────────
+ * 唯一必须联网的一项。放最后:万一接口超时,前面三项已经落库。 */
+ $options[] = array(
+ 'name' => __('自动生成英文别名', 'kratos'),
+ 'desc' => __('发布时把中文标题翻译成英文,作为 URL 别名(slug)。'
+ . '这一项必须联网调用翻译服务 —— 纯 PHP 做不出中译英,'
+ . '拼音方案(xin-xi-jiao-lu…)又长又不可读,所以没有本地引擎。'
+ . '
只有别名为空、或仍是 %e4%bf%a1 这种乱码时才写;'
+ . '你手工填过的别名绝不动。'
+ . '
翻译失败(超时、限额用尽)时安静跳过,不影响发布。', 'kratos'),
+ 'std' => '0',
+ 'id' => 'w_on_slug',
+ 'type' => 'checkbox',
+ );
+
+ /* ── 翻译服务商 ────────────────────────────────────────────
+ * 只用服务器实测可达的三家。Google 翻译返回 000、LibreTranslate 超时,
+ * 已排除 —— 列出来只会让用户白填密钥。 */
+ $providers = function_exists('yang_writing_providers') ? yang_writing_providers() : array();
+ $prov_sel = array();
+ foreach ($providers as $k => $v) {
+ $prov_sel[$k] = $v['label'];
+ }
+
+ $options[] = array(
+ 'name' => __('翻译服务商', 'kratos'),
+ 'desc' => __('别名生成用的翻译服务。MyMemory 无需密钥,开箱可用,'
+ . '但它是国外服务、有每日字符限额;被限流时换成百度即可。', 'kratos'),
+ 'std' => 'mymemory',
+ 'id' => 'w_provider',
+ 'type' => 'select',
+ 'options' => $prov_sel,
+ );
+
+ $options[] = array(
+ 'name' => __('服务商账号填写说明', 'kratos'),
+ /* 给 info 一个 id:它不生成 section- 包裹层(框架对 info 特殊处理),
+ 所以只能靠这个 id 让 JS 能把它一起折叠。 */
+ 'id' => 'w_provider_help',
+ 'desc' => __('MyMemory:不用填任何东西。
'
+ . '百度翻译:访问 fanyi-api.baidu.com '
+ . '→ 注册登录 → 进入「管理控制台」→ 在「开发者信息」里可看到 APPID 与 密钥。'
+ . '免费版每月 5 万字符。
'
+ . '有道翻译:访问 ai.youdao.com '
+ . '→ 注册登录 → 「创建应用」选「文本翻译」→ 在「应用管理」里可看到 应用 ID 与 应用密钥。', 'kratos'),
+ 'type' => 'info',
+ );
+
+ /* 密钥字段用 password 类型。
+ *
+ * ⚠️ 注意:框架会把真实值写进 HTML 的 value 属性(浏览器里 F12
+ * 就能看到)。SMTP 密码字段现在也是这样。这里保持与之一致,
+ * 不单独另做一套脱敏 —— 否则同一个页面上两种行为,更容易混淆。
+ * 想彻底避免,只能改成「不回显、留空即不修改」,那会牵动整个
+ * 框架的保存语义,不在本次范围。 */
+ $options[] = array(
+ 'name' => __('百度 APPID', 'kratos'),
+ 'desc' => __('选「百度翻译」时填写。', 'kratos'),
+ 'id' => 'w_baidu_appid',
+ 'type' => 'text',
+ );
+
+ /* 密钥字段的特殊处理(只对这两项生效)。
+ *
+ * 框架的 password 类型会把**真实值**写进 HTML 的 value 属性,页面上看着
+ * 是圆点,F12 一看就是明文(主题里的 SMTP 密码现在就是这样,那是既有
+ * 问题,不在本次范围)。这个新功能没有照抄一遍的理由。
+ *
+ * 做法:submit 前先用 '' 覆盖掉 std,页面上永远不出现真实密钥;
+ * 真实值存进独立选项 yang_writing_secrets(见 theme-writing.php 的
+ * yang_writing_capture_secret)。已有值时在下方提示末 4 位,
+ * 留空提交 = 不修改。 */
+ $yang_baidu_hint = function_exists('yang_writing_secret_hint') ? yang_writing_secret_hint('w_baidu_key') : '';
+ $yang_youdao_hint = function_exists('yang_writing_secret_hint') ? yang_writing_secret_hint('w_youdao_key') : '';
+
+ $options[] = array(
+ 'name' => __('百度密钥', 'kratos'),
+ 'desc' => __('选「百度翻译」时填写。留空表示不修改已保存的密钥。', 'kratos')
+ . ($yang_baidu_hint !== '' ? '
当前已保存:' . esc_html($yang_baidu_hint) . '' : ''),
+ 'id' => 'w_baidu_key',
+ 'type' => 'password',
+ 'std' => '',
+ );
+
+ $options[] = array(
+ 'name' => __('有道应用 ID', 'kratos'),
+ 'desc' => __('选「有道翻译」时填写。', 'kratos'),
+ 'id' => 'w_youdao_appid',
+ 'type' => 'text',
+ );
+
+ $options[] = array(
+ 'name' => __('有道应用密钥', 'kratos'),
+ 'desc' => __('选「有道翻译」时填写。留空表示不修改已保存的密钥。', 'kratos')
+ . ($yang_youdao_hint !== '' ? '
当前已保存:' . esc_html($yang_youdao_hint) . '' : ''),
+ 'id' => 'w_youdao_key',
+ 'type' => 'password',
+ 'std' => '',
+ );
+
+ $options[] = array(
+ 'name' => __('接口超时(秒)', 'kratos'),
+ 'desc' => __('调用翻译接口的最长等待时间,建议 8 秒。'
+ . '设太大时接口挂了会拖慢发布;设太小则容易误判超时。', 'kratos'),
+ 'std' => '8',
+ 'id' => 'w_timeout',
+ 'type' => 'text',
+ );
+
+ /* ══════════════════════════════════════════════════════════
+ * 十四、自定义代码 —— 兜底用,放最后
*
* 3.2.29 起这两项默认都为空:站点自己的通用样式
* (段落缩进、h2 蓝条、列表缩进)已并入 assets/css/site.css,
diff --git a/inc/theme-writing.php b/inc/theme-writing.php
new file mode 100644
index 0000000..5fee92d
--- /dev/null
+++ b/inc/theme-writing.php
@@ -0,0 +1,1412 @@
+
+ * @license MIT License
+ */
+
+/* ══════════════════════════════════════════════════════════════
+ * 一、设置读取
+ * ══════════════════════════════════════════════════════════════ */
+
+/**
+ * 读一项写作增强设置。
+ *
+ * 统一走 kratos_option(),与主题其它模块一致;不做任何类型转换,
+ * 由调用方自己决定(复选框要布尔,上限要整数)。
+ *
+ * @param string $key 设置键名(不含前缀,本模块统一 w_ 开头)
+ * @param mixed $default 库里没这个键时的默认值
+ * @return mixed
+ */
+function yang_writing_opt($key, $default = '')
+{
+ return kratos_option($key, $default);
+}
+
+/**
+ * 某个引擎是否启用。
+ *
+ * ⚠️ 复选框未勾选时不会出现在 POST 里,所以库里存的是 false;
+ * 但**键不存在**(用户从没保存过设置)时也应当视为关闭 ——
+ * 这是新装主题的正确语义:不能默认就往用户的文章里写东西。
+ */
+function yang_writing_on($key)
+{
+ $v = yang_writing_opt($key, '0');
+ return $v === true || $v === '1' || $v === 1 || $v === 'on';
+}
+
+/* ══════════════════════════════════════════════════════════════
+ * 二、文本处理(纯本地,四个引擎共用)
+ * ══════════════════════════════════════════════════════════════ */
+
+/**
+ * HTML 正文 → 纯文本。
+ *
+ * 顺序有讲究:先摘掉 script/style 整块(strip_tags 只剥标签、留内容,
+ * 会把 JS 源码当成正文喂进分词),再补换行给块级元素(否则段落会粘成
+ * 一坨,分句全错),最后才 strip_tags。
+ */
+function yang_writing_html2text($html)
+{
+ $t = (string) $html;
+ $t = preg_replace('##si', ' ', $t);
+ $t = preg_replace('##si', ' ', $t);
+ $t = preg_replace('##s', ' ', $t);
+ /* 块级元素结尾补换行:分句依赖它,不然「…结束了。下一段…」可能
+ 被当成一句,摘要里就会出现跨段拼接。 */
+ $t = preg_replace('#<(br|/p|/div|/h[1-6]|/li|/blockquote|/pre)\b[^>]*>#si', "\n", $t);
+ $t = strip_tags($t);
+ $t = html_entity_decode($t, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+ $t = preg_replace('/[ \t\x{3000}\x{00a0}]+/u', ' ', $t);
+ $t = preg_replace('/\n{2,}/u', "\n", $t);
+ return trim($t);
+}
+
+/**
+ * 中文停用词。
+ *
+ * 为什么必须有:抽取式摘要/标签排序里,如果「我们」「这个」「可以」
+ * 拿到高权重,摘要选出来的每一句都会是废话,标签会全是虚词。
+ * 这份表是**手工挑的高频虚词与泛化名词**,不是通用停用词表 ——
+ * 通用表里那些「的、了、在」是单字,本来就被「长度 ≥ 2」挡掉了。
+ */
+function yang_writing_stopwords()
+{
+ static $stop = null;
+ if ($stop !== null) {
+ return $stop;
+ }
+ $stop = array(
+ '我们', '你们', '他们', '她们', '这个', '那个', '什么', '可以', '就是',
+ '没有', '因为', '所以', '但是', '如果', '这样', '那样', '一个', '自己',
+ '现在', '已经', '还是', '或者', '而且', '虽然', '然后', '时候', '问题',
+ '东西', '知道', '觉得', '应该', '需要', '可能', '真的', '这么', '那么',
+ '一下', '一些', '这些', '那些', '不是', '其实', '大家', '每个', '这种',
+ '很多', '不能', '不会', '还有', '只是', '一样', '别人', '有人', '之前',
+ '之后', '起来', '出来', '一点', '有点', '非常', '特别', '事情', '地方',
+ '时间', '感觉', '开始', '发现', '看到', '听到', '告诉', '只要', '然而',
+ '于是', '不过', '当然', '确实', '似乎', '好像', '比较', '更加', '一直',
+ '每天', '今年', '今天', '昨天', '明天', '现在', '后来', '当时',
+ );
+ return $stop;
+}
+
+/**
+ * 中文分句。
+ *
+ * 只按句末标点与换行切,且丢掉过短(< 12 字,通常是标题/列表项签)
+ * 与过长(> 200 字,通常是没有标点的口语句)的片段。
+ * 这两个边界是实测调出来的:不设下限会选出「一、」这种碎片,
+ * 不设上限会有一整段当摘要的情况。
+ */
+function yang_writing_split_sentences($text)
+{
+ $parts = preg_split('/(?<=[。!?;!?;…])|\n+/u', (string) $text, -1, PREG_SPLIT_NO_EMPTY);
+ $out = array();
+ if (!is_array($parts)) {
+ return $out;
+ }
+ foreach ($parts as $s) {
+ $s = trim(preg_replace('/\s+/u', ' ', $s));
+ $len = mb_strlen($s, 'UTF-8');
+ if ($len >= 12 && $len <= 200) {
+ $out[] = $s;
+ }
+ }
+ return $out;
+}
+
+/* ══════════════════════════════════════════════════════════════
+ * 三、词典(标签池)—— 标签引擎与摘要引擎共用
+ * ══════════════════════════════════════════════════════════════ */
+
+/**
+ * 把现有标签建成词典。
+ *
+ * ── 为什么拿标签本身当词典 ─────────────────────────────────
+ * 这是整个标签方案的支点。没有分词扩展,从零切词不可行;但**用户已经
+ * 手工沉淀了 703 个标签**,那是一份高质量的领域词典 —— 写「Git」「鸿蒙」
+ * 「失业」这些词的人,本来就会把它们当标签用。所以「在已有标签里挑最
+ * 相关的」这件事,用最大匹配 + TF-IDF 就能做得不错。
+ *
+ * ── 实测数字(3.2.57,可复现,别照抄旧说法)──────────────────
+ * 早期注释里写过「60 篇 / 243 个人工标签 / Top8 召回 74.9%」,本轮
+ * 用 _quality_tags.php 复测,同一批 60 篇(120 个人工标签,平均 2.00/篇):
+ * Top8 命中 59 个 → 召回 49.2%
+ * 只算「字面出现在正文里」的人工标签 → 59.0%
+ * 全未命中 18 篇
+ * 三个口径都到不了 74.9%,且 A/B 测试(_ab_exist.php,60 篇 0 差异)
+ * 已证明本轮改动完全没碰这条路径 ⇒ 那个 74.9% 是**未经复核的历史数字**。
+ * 按红线 #418 换成上面这组实测值。
+ *
+ * 49.2% 不惊艳,但要看清结构:本站 967 篇文章里人工标签平均只有 2 个,
+ * 而且大量是「生活」「工作」「情感」这种泛化标签 —— 一篇讲吃饭的文章,
+ * 人工打「生活」,引擎给「习惯、免费、推荐、幻想、吃饭」,按"标签质量"
+ * 判引擎其实更准,但按"猜中作者手笔"判就是没中。所以这个指标对
+ * **标签自动生成**这个用途偏悲观,README 里是按"人工抽检是否通顺"表述的。
+ *
+ * ── 为什么键要小写 ────────────────────────────────────────
+ * `Git` 与 `git` 在 WordPress 里可以是两个标签,但匹配时应当一视同仁。
+ * 小写只用于**匹配键**,返回时仍用标签自己的原始大小写。
+ *
+ * @return array{0: array, 1: int}
+ * 词典 + 最长词长度
+ */
+function yang_writing_dict()
+{
+ static $cache = null;
+ if ($cache !== null) {
+ return $cache;
+ }
+
+ $dict = array();
+ $terms = get_terms(array(
+ 'taxonomy' => 'post_tag',
+ 'hide_empty' => false,
+ 'number' => 0,
+ ));
+
+ if (!is_wp_error($terms) && is_array($terms)) {
+ foreach ($terms as $t) {
+ $name = isset($t->name) ? html_entity_decode($t->name, ENT_QUOTES, 'UTF-8') : '';
+ $key = trim(mb_strtolower($name, 'UTF-8'));
+ if (mb_strlen($key, 'UTF-8') < 2) {
+ continue;
+ }
+ $dict[$key] = array(
+ 'name' => $name,
+ 'count' => isset($t->count) ? max(1, (int) $t->count) : 1,
+ );
+ }
+ }
+
+ $maxlen = 0;
+ foreach ($dict as $w => $_) {
+ $l = mb_strlen($w, 'UTF-8');
+ if ($l > $maxlen) {
+ $maxlen = $l;
+ }
+ }
+
+ $cache = array($dict, $maxlen);
+ return $cache;
+}
+
+/**
+ * 词典正向最大匹配。
+ *
+ * 从当前位置尽量取最长的词,命中就整段跳过 —— 这样「同步日历」不会被
+ * 拆成「同步」+「日历」两个标签,也不会出现「日历」「同步日历」同时
+ * 命中(重叠匹配会让同一个概念占两个标签位)。
+ *
+ * @return array 小写词 => 出现次数
+ */
+function yang_writing_match($text, $dict, $maxlen)
+{
+ $text = mb_strtolower((string) $text, 'UTF-8');
+ $len = mb_strlen($text, 'UTF-8');
+ $hits = array();
+ if ($len < 2 || $maxlen < 2 || !$dict) {
+ return $hits;
+ }
+
+ $i = 0;
+ while ($i < $len) {
+ $matched = false;
+ $max = min($maxlen, $len - $i);
+ for ($l = $max; $l >= 2; $l--) {
+ $frag = mb_substr($text, $i, $l, 'UTF-8');
+ if (isset($dict[$frag])) {
+ $hits[$frag] = isset($hits[$frag]) ? $hits[$frag] + 1 : 1;
+ $i += $l;
+ $matched = true;
+ break;
+ }
+ }
+ if (!$matched) {
+ $i++;
+ }
+ }
+ return $hits;
+}
+
+/**
+ * 全站已发布文章数 —— IDF 的 N。
+ *
+ * 用 wp_count_posts 而不是查 posts 表:它带对象缓存,且能正确排除
+ * 修订版本 / 自动草稿。IDF 公式加了 1 兜底,所以 N=0 时也不会除零。
+ */
+function yang_writing_doc_count()
+{
+ $c = wp_count_posts('post');
+ return isset($c->publish) ? max(1, (int) $c->publish) : 1;
+}
+
+/* ══════════════════════════════════════════════════════════════
+ * 四、引擎 ①:别名(slug)—— 需外部翻译
+ * ══════════════════════════════════════════════════════════════ */
+
+/**
+ * 生成英文别名。
+ *
+ * 只在 slug 为空时调用(由 yang_writing_apply 保证)。翻译失败返回空串,
+ * 由调用方跳过 —— 绝不用拼音兜底,因为拼音 slug 与站点现有 966 篇手工
+ * 英文 slug 风格不一致,混在一起比留空更难看。
+ *
+ * @return string 干净的 slug,失败返回 ''
+ */
+function yang_writing_make_slug($title)
+{
+ $title = trim((string) $title);
+ if ($title === '') {
+ return '';
+ }
+
+ $en = yang_writing_translate($title);
+ if ($en === '') {
+ return '';
+ }
+
+ /* sanitize_title 会把空格与标点折成连字符、转小写、去掉中文残留。
+ 译文里常有 `Don't`,sanitize_title 会处理成 `don-t`,可接受;
+ 它也会去掉中文,所以万一服务商返回原样中文,结果为空 ⇒ 上层跳过。 */
+ $slug = sanitize_title($en);
+
+ /* 过长的 slug 影响可读性与复制粘贴。按连字符截断到 60 字符内,
+ 尽量不切在词中间(从末尾往前找最后一个连字符)。 */
+ if (strlen($slug) > 60) {
+ $cut = substr($slug, 0, 60);
+ $pos = strrpos($cut, '-');
+ $slug = $pos !== false && $pos > 20 ? substr($cut, 0, $pos) : $cut;
+ }
+
+ return trim($slug, '-');
+}
+
+/* ══════════════════════════════════════════════════════════════
+ * 五、引擎 ②:标签
+ * ══════════════════════════════════════════════════════════════ */
+
+/**
+ * 生成标签建议。
+ *
+ * ── 算法 ────────────────────────────────────────────────────
+ * 在 703 个已有标签里做最大匹配,然后用 TF-IDF 排序:
+ * tf_norm = 出现次数 / (正文字数 / 1000) —— 长文短文的词频可比
+ * idf = log((N+1)/(df+1)) + 1 —— 全站少见的词更值钱
+ * score = tf_norm * idf
+ * 这套在线上实测(3.2.57 复测,60 篇 / 120 个人工标签):
+ * Top8 命中 59 个 → 召回 49.2%;只算字面出现在正文的 → 59.0%;
+ * 全未命中 18 篇;单篇耗时 18.5ms,发布时无感。
+ * (旧注释写的 74.9% 已复核为不可复现,详见 yang_writing_dict() 头注释。)
+ *
+ * ── 返回值为什么是两段 ──────────────────────────────────────
+ * [0] 命中的**已有**标签 —— 直接用,不占新增额度
+ * [1] 建议**新建**的标签 —— 受「每篇最多新增 N 个」限制
+ *
+ * ── 新词从哪来(3.2.57 修正)───────────────────────────────
+ * 第一版只返回了 [0],[1] 永远是空 —— 因为输入就是"词典命中结果",
+ * 它当然全部已在词典里。那等于「新建」这条路根本没实现,与用户
+ * 「如果不能新增,一个新博客永远无法使用这个功能」的要求直接冲突。
+ *
+ * 现在的做法:从**标题与正文的 n-gram** 里找词典没覆盖的候选。纯 n-gram
+ * 单独用效果很差(实测 Top50 只有 4~7 个真标签),所以这里不靠它排序,
+ * 只用它做**受限补充**,三道闸门过滤:
+ * ① 长度 2~6 字(超过 6 字几乎不可能是标签)
+ * ② 在全文出现 ≥ 2 次(只出现一次的多半是偶然搭配)
+ * ③ 只由中文/字母数字构成,且不是停用词
+ * 再要求它在 n-gram 频次里排进前列,且**不与已命中词重叠**(重叠说明
+ * 只是长词的一部分,不该单独成标签)。
+ *
+ * @return array{0:string[],1:string[]} [命中的已有标签, 建议新建的标签]
+ */
+function yang_writing_make_tags($title, $body)
+{
+ list($dict, $maxlen) = yang_writing_dict();
+ if (!$dict) {
+ return array(array(), array());
+ }
+
+ $N = yang_writing_doc_count();
+ $stop = yang_writing_stopwords();
+ $full = $title . ' ' . $body;
+
+ $hits = yang_writing_match($full, $dict, $maxlen);
+
+ /* 词长(按字数)作为额外权重:两字标签(「工作」)天然比四字标签
+ (「信息焦虑」)更容易出现,不惩罚一下的话 Top 里全是两字词。 */
+ $body_len = max(1, mb_strlen($body, 'UTF-8'));
+
+ $scores = array();
+ foreach ($hits as $w => $tf) {
+ if (in_array($w, $stop, true)) {
+ continue;
+ }
+ $df = $dict[$w]['count'];
+ $idf = log(($N + 1) / ($df + 1)) + 1;
+ $tf_norm = $tf / ($body_len / 1000);
+ $len_fit = 1 + 0.12 * (mb_strlen($w, 'UTF-8') - 2);
+ $scores[$w] = $tf_norm * $idf * $len_fit;
+ }
+
+ arsort($scores);
+
+ /* 取前 8 个作为候选。
+ 为什么是 8:再往上加,多出来的基本都是「的利」「的信」之外的低分噪声,
+ 而 WordPress 后台标签框一次列太多反而要用户删。8 个是"够挑"与"不烦"
+ 的折中,没有更强的依据支持别的数字。 */
+ $exist = array();
+ foreach (array_slice(array_keys($scores), 0, 8) as $w) {
+ $exist[] = $dict[$w]['name'];
+ }
+
+ /* ── 新词候选(受限补充)── */
+ $new = yang_writing_find_new_terms($full, $dict, $maxlen, $stop, $exist);
+
+ return array($exist, $new);
+}
+
+/**
+ * 在词典之外找值得新建的标签候选。
+ *
+ * ── 为什么这么保守 ──────────────────────────────────────────
+ * 站内已有 703 个标签,其中 338 个只用过 1 次 —— 说明"随手建标签"
+ * 的行为模式会把标签库迅速变成几千个长尾词,标签云和归档页都会废掉。
+ * 用户也明确要求「每篇文章有新增数量限制」,所以这里宁可少找、
+ * 不找错。找不到就返回空,功能降级为"只用已有标签",无害。
+ *
+ * ── 3.2.57 第二轮:朴素 n-gram 的碎片问题(实测不过关,已修)──
+ * 第一版只有三道闸门(长度 2~6 / 出现 ≥2 次 / 非停用词非词典词),
+ * 拿「新博客」场景实测,输出是这样的:
+ * 天文标题「星等换算与赤纬测量」
+ * → 星等换算与赤、等换算与赤纬、换算与赤纬测、算与赤纬测量
+ * 「锔瓷工艺」→ 套结的
+ * 全是**跨词边界的机械切片**,一个都不是标签。这正是当初排除纯
+ * n-gram 方案的同一个毛病,第一版等于把它又请回来了。
+ *
+ * 修法不是调阈值,而是**先把句子切成短语再切 n-gram**:
+ * ① 在虚词/标点处断开,只在短语内部取 n-gram
+ * ——「星等换算与赤纬测量」被「与」切成「星等换算」「赤纬测量」
+ * ② 中文候选最多 4 字
+ * —— 回查 703 个真实标签:中文 6 字以上只有 3 个
+ * (「网上办公系统」「我在大学那几年」「基于Java的B/S系统构建」),
+ * 都是人工写的完整短语,n-gram 永远造不出来;≤4 字覆盖 590/703
+ * (84%)
+ * ③ 不许以虚字结尾(的/与/和/了/是/在/就/都/也/说/把/被)
+ * —— 回查真实标签,以此结尾的 7 个里 6 个 count=0 是垃圾,
+ * 唯一存活的「小说」count=69 是个完整的二字词,不是碎片
+ *
+ * 实测对照(同一批用例):
+ * A 传统手艺 → 竹编、漆器、锔瓷、竹编漆器、手艺、讲究、篾丝、髹涂
+ * B 天文观测 → 星等、星等换算、赤纬测量、子午仪读、蒙气差、做不准、消光、修正
+ * C 生造词 → 套结、珂玛珞套结、工艺、收口(切片「珂玛珞套」「玛珞套结」已消灭)
+ *
+ * ── 残留缺陷与为什么不继续修(诚实记录)────────────────────────
+ * B 段仍有「子午仪读」(真词是「子午仪」)和「做不准」。
+ * 根因是闸门 ③(5 字上限)与闸门 ⑤(切片消除)互相打架:
+ * 原文「子午仪读取地平高度」里真正的长单位是「子午仪读取」(6 字),
+ * 它被长度闸门砍掉 ⇒ 它的 4 字窗口「子午仪读」成了最长候选
+ * ⇒ 反过来把真词「子午仪」按"切片"删掉了。
+ *
+ * 想彻底解决需要"成词度"(互信息 / 左右邻字熵)判断,但**单篇文章
+ * 的样本量不够**:一篇文章里「读」出现 2 次就算高频,算出的熵与互信息
+ * 噪声极大、不可靠(业界共识是要大语料)。服务器上也没有 scws/jieba
+ * 任何中文分词扩展(实测,见项目记忆),无法做词典外分词。
+ *
+ * 所以这里的取舍是:**不假装完美,靠"只取前 N 个"兜底**。
+ * 用户设的上限默认 3,而按频次降序时,A/B/C 三段的前 3 个恰好全部
+ * 合格(竹编/漆器/锔瓷;星等/星等换算/赤纬测量;套结/珂玛珞套结/工艺)。
+ * 排序靠后的噪声只在用户把上限调大时才会冒出来 —— 这是用户自己
+ * 承担的可调项,设置页里已注明"建议保持 3"。
+ *
+ * @param string $text 标题+正文
+ * @param array $dict 词典
+ * @param int $maxlen 最长词长
+ * @param array $stop 停用词
+ * @param string[] $exist 已命中的已有标签(用于排除重叠)
+ * @return string[] 建议新建的词(已去重、按频次降序)
+ */
+/**
+ * 虚词表 —— 切分短语与「不许以虚字结尾」**共用这一份**。
+ *
+ * 为什么必须共用:3.2.57 第一版把同一批 28 个字写了两处($sep 内联一份、
+ * 闸门 ④ 里再抄一份)。实测 _probe_gate4.php 证明两边完全相同,
+ * 于是闸门 ④ 成了死代码($sep 已把虚字全换成空格,chunk 里不可能有虚字)。
+ * 死代码本身不致命,致命的是**两套名单会漂移**:将来只想放宽一处时,
+ * 另一处被忘掉,就会出现"某个虚字既不切分、也不拦尾巴"的漏网。
+ *
+ * @return string[] 单字虚词
+ */
+function yang_writing_seps()
+{
+ return array('的', '与', '和', '了', '是', '在', '就', '都', '也', '及',
+ '或', '而', '但', '把', '被', '从', '向', '对', '为', '以',
+ '于', '之', '其', '这', '那', '个', '们', '说');
+}
+
+function yang_writing_find_new_terms($text, $dict, $maxlen, $stop, $exist)
+{
+ /* 先把已命中的词从文本里"抹掉",避免它们的长词片段被当成新词。
+ 比如已命中「信息焦虑」时,不该再冒出「息焦虑」这种切片。 */
+ $masked = mb_strtolower($text, 'UTF-8');
+ foreach ($exist as $name) {
+ $masked = str_replace(mb_strtolower($name, 'UTF-8'), '█', $masked);
+ }
+
+ /* 🔴 关键一步:先按虚词与标点把文本切成**短语**,只在短语内部取 n-gram。
+ *
+ * 不做这一步,n-gram 会横跨词组边界。「星等换算与赤纬测量」切成
+ * 2~6 字的滑动窗口,必然产出「星等换算与赤」「换算与赤纬测」这种
+ * 既不是词、也读不通的碎片 —— 而它们在字面上"出现 ≥2 次",
+ * 通过所有频次闸门。这是 n-gram 方案的固有缺陷,只能靠切分规避。
+ *
+ * 用虚词当隔离符是有依据的:中文里「与/和/的/了」几乎总在词组分界处,
+ * 真正的标签很少横跨它们(703 个真实标签里含虚字的只有 9 个,
+ * 且都是「乐队的夏天」「我在大学那几年」这类人工写的完整标题)。
+ *
+ * ── 3.2.57 第三轮:这张表是**唯一来源** ─────────────────────
+ * 第一版把同一批虚字写了两遍:这里一份给 $sep 做切分,闸门 ④
+ * 又抄了一份做「不许以虚字结尾」判断。实测(_probe_gate4.php):
+ * 两张表 28 个字完全相同,差集两边都是空;
+ * 30 篇真实语料、239 个候选,以虚字结尾的 **0 个**;
+ * 把闸门 ④ 整段短路,仍然 0 个。
+ * 结论:**闸门 ④ 是死代码** —— $sep 已经把每个虚字换成空格,
+ * 切出来的 chunk 里根本不含虚字,它永远不可能命中。
+ *
+ * 保留它不是为了好看:切分规则将来若放宽(比如只切「的/了」两个),
+ * 闸门 ④ 立刻变成有效防线。但两张表必须共用一个来源,
+ * 否则放宽一处、忘记另一处,就会重演「两套名单各说各话」。
+ * $sep 的正则形态由 yang_writing_seps() 统一生成。 */
+ $seps = yang_writing_seps();
+ $clean = preg_replace('/' . implode('|', $seps) . '/u', ' ', $masked);
+ /* 再只保留中文与字母数字,其它一律当分隔符 —— 标点、空格、HTML 残留
+ 都会让 n-gram 切出无意义片段。 */
+ $clean = preg_replace('/[^\x{4e00}-\x{9fa5}a-zA-Z0-9]+/u', ' ', (string) $clean);
+ if (!is_string($clean) || trim($clean) === '') {
+ return array();
+ }
+
+ $chunks = preg_split('/\s+/u', trim($clean), -1, PREG_SPLIT_NO_EMPTY);
+ if (!is_array($chunks)) {
+ return array();
+ }
+
+ $freq = array();
+ foreach ($chunks as $chunk) {
+ $len = mb_strlen($chunk, 'UTF-8');
+ /* 2~6 字。低于 2 无意义;高于 6 基本不可能是标签。
+ 注意:这里放宽到 6 是为了让长短语先进入候选,
+ 真正的中文上限在闸门 ③ 收紧到 4。 */
+ for ($l = 2; $l <= 6; $l++) {
+ if ($l > $len) {
+ break;
+ }
+ for ($i = 0; $i + $l <= $len; $i++) {
+ $g = mb_substr($chunk, $i, $l, 'UTF-8');
+ /* 纯数字不要(「360」「2026」这类当标签没意义)。 */
+ if (preg_match('/^\d+$/', $g)) {
+ continue;
+ }
+ $freq[$g] = isset($freq[$g]) ? $freq[$g] + 1 : 1;
+ }
+ }
+ }
+
+ if (!$freq) {
+ return array();
+ }
+
+ /* 闸门 ①:出现 ≥ 2 次。
+ 闸门 ②:不是停用词、不在词典里。
+ 这两条一起把候选量从上千压到个位数。 */
+ $cand = array();
+ foreach ($freq as $g => $c) {
+ if ($c < 2) {
+ continue;
+ }
+ if (in_array($g, $stop, true)) {
+ continue;
+ }
+ if (isset($dict[$g])) {
+ continue;
+ }
+ /* 闸门 ③:中文最多 5 字(依据:703 个真实标签里含中文的 525 个,
+ ≤4 字占 98.3%、≤5 字占 99.2%;5 字的那 5 个是「中国共产党」
+ 「自然辩证法」「计算机领域」这类完整短语,不是碎片)。
+ 纯 ASCII 候选不受此限 —— 英文技术词本来就可能很长
+ (「kubernetes」10 字仍在用)。 */
+ if (preg_match('/[\x{4e00}-\x{9fa5}]/u', $g) && mb_strlen($g, 'UTF-8') > 5) {
+ continue;
+ }
+ /* 闸门 ④:不许以虚字结尾。
+ 与 $sep 共用 yang_writing_seps() 那一份名单(见上方长注释)
+ —— 当前它不可达,保留是为了「切分规则将来放宽时」仍有防线。 */
+ $tail = mb_substr($g, -1, 1, 'UTF-8');
+ if (in_array($tail, yang_writing_seps(), true)) {
+ continue;
+ }
+ $cand[$g] = $c;
+ }
+ if (!$cand) {
+ return array();
+ }
+
+ /* 闸门 ⑤:消除「长词的滑动窗口切片」。
+ *
+ * 只做长度限制是不够的。若正文里反复出现「珂玛珞套结」(5 字真词),
+ * 4 字窗口会同时产出「珂玛珞套」「玛珞套结」—— 它们与真词**频次完全相同**
+ * (同一处文本切出来的),靠频次排序分不开,最后拿到的是一堆切片。
+ *
+ * 判据:候选 $g 若被另一个更长的候选 $bigger 包含,且
+ * count($bigger) >= count($g),说明 $g 只是 $bigger 的切片 ⇒ 丢掉。
+ *
+ * 为什么用「>=」而不是「>」:真词与它的切片频次**必然相等**(同一处
+ * 文本产生),所以必须用 >= 才能抓到。反过来,两个真正独立的词
+ * (「竹编」与「竹编漆器」)里,短的那个频次通常**更高**(单独出现
+ * 的次数多于连写),此时 count($bigger) < count($g),不会被误删。
+ */
+ /* 🔴 先拍一份频次快照再删。
+ 第一版直接在 $cand 上边遍历边 unset,外层 $keys 是循环开始前算的
+ —— 某个键被删掉之后,后面几轮还在读 $cand[$bigger],于是满屏
+ "Undefined array key"。判据本身没错,是遍历方式错了:
+ **边遍历边修改的数组,索引必须提前固定成快照**。 */
+ $freq_snap = $cand;
+ $keys = array_keys($freq_snap);
+ $nkeys = count($keys);
+ for ($i = 0; $i < $nkeys; $i++) {
+ $g = $keys[$i];
+ $glen = mb_strlen($g, 'UTF-8');
+ for ($j = 0; $j < $nkeys; $j++) {
+ if ($i === $j) { continue; }
+ $bigger = $keys[$j];
+ if (mb_strlen($bigger, 'UTF-8') <= $glen) { continue; }
+ if ($freq_snap[$bigger] < $freq_snap[$g]) { continue; }
+ if (mb_strpos($bigger, $g, 0, 'UTF-8') !== false) {
+ unset($cand[$g]);
+ break;
+ }
+ }
+ }
+ if (!$cand) {
+ return array();
+ }
+
+ /* 按频次降序;频次相同按长度降序(长词信息量更大)。 */
+ uksort($cand, function ($a, $b) use ($cand) {
+ if ($cand[$a] !== $cand[$b]) {
+ return $cand[$b] <=> $cand[$a];
+ }
+ return mb_strlen($b, 'UTF-8') <=> mb_strlen($a, 'UTF-8');
+ });
+
+ /* 取前 8 个。
+ *
+ * ⚠️ 3.2.57 第一版这里还有一段「按已保留项过滤子串」的循环,
+ * 现在删掉了 —— 闸门 ⑤ 已经在**全局**做过切片消除(对所有候选
+ * 两两比对,而不是只跟已保留的那几个比),再来一遍是重复劳动,
+ * 而且这段是先排序后过滤,会漏掉「排序靠后的长词包含前面短词」
+ * 这种方向,判据在两种实现下结果不同,留着容易误判。
+ * 现在只做截断。 */
+ return array_slice(array_keys($cand), 0, 8);
+}
+
+/* ══════════════════════════════════════════════════════════════
+ * 六、引擎 ③:分类 —— 纯本地
+ * ══════════════════════════════════════════════════════════════ */
+
+/**
+ * 推荐一个分类。
+ *
+ * ── 为什么不是朴素贝叶斯(我先写过一版,错的)───────────────
+ * 第一版用 P(标签|分类) 连乘,线上实测**只有 60%**,而且疯狂把文章判成
+ * 只有 6 篇的「病毒相关」。根因是分母:
+ * p(tag|cat) = (共现数 + α) / (该分类标签总数 + α·词表大小)
+ * 小分类分母小 ⇒ 对没见过的标签给了几十倍的偏高概率 ⇒ 小分类通吃。
+ *
+ * ── 现在用的:P(分类|标签) 加权投票 ────────────────────────
+ * score(cat) = Σ_tags P(cat|tag) × idf(tag)
+ * P(cat|tag) = 带该标签的文章里落在 cat 的比例
+ * 这个量天然在分类间归一化(Σ_cat P(cat|tag) = 1),小分类不会因为
+ * 分母小被抬高。再乘 idf,让「鸿蒙」这种稀有标签的投票比「生活」值钱。
+ *
+ * 线上实测:**Top1 90.0% / Top3 99.0%**(290 篇单分类文章)。
+ * 按用户要求不排除小样本分类,直接把最接近的一个给他。
+ *
+ * @return int 分类 ID,无法判断返回 0
+ */
+function yang_writing_make_category($title, $body)
+{
+ global $wpdb;
+
+ list($dict, $maxlen) = yang_writing_dict();
+ if (!$dict) {
+ return 0;
+ }
+
+ /* 一次查出全部「标签×分类」共现(线上 4070 行,毫秒级)。
+ 不缓存进 transient:发布是低频动作,一次 4070 行的分组查询比
+ 维护缓存失效逻辑省心得多(也避免 save_post 里清缓存的递归风险)。 */
+ $rows = $wpdb->get_results(
+ "SELECT t.name AS tag, c.term_id AS cat_id
+ FROM {$wpdb->posts} p
+ JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
+ JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
+ AND tt.taxonomy = 'post_tag'
+ JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
+ JOIN {$wpdb->term_relationships} tr2 ON tr2.object_id = p.ID
+ JOIN {$wpdb->term_taxonomy} tt2 ON tt2.term_taxonomy_id = tr2.term_taxonomy_id
+ AND tt2.taxonomy = 'category'
+ JOIN {$wpdb->terms} c ON c.term_id = tt2.term_id
+ WHERE p.post_type = 'post' AND p.post_status = 'publish'"
+ );
+ if (empty($rows)) {
+ return 0;
+ }
+
+ $tag_cat = array(); // 标签 => [分类 => 共现数]
+ $tag_total = array(); // 标签 => 总文章数
+ foreach ($rows as $r) {
+ $tg = trim(mb_strtolower($r->tag, 'UTF-8'));
+ $cid = (int) $r->cat_id;
+ $tag_cat[$tg][$cid] = isset($tag_cat[$tg][$cid]) ? $tag_cat[$tg][$cid] + 1 : 1;
+ $tag_total[$tg] = isset($tag_total[$tg]) ? $tag_total[$tg] + 1 : 1;
+ }
+
+ /* 用本文标题+正文命中的标签来投票。这里**不排序、不设上限**:
+ 投票是求和,噪声标签的贡献接近 0,不需要先筛。 */
+ $hits = yang_writing_match($title . ' ' . $body, $dict, $maxlen);
+ if (!$hits) {
+ return 0;
+ }
+
+ $N = yang_writing_doc_count();
+ $scores = array();
+ foreach ($hits as $tag => $times) {
+ if (!isset($tag_cat[$tag])) {
+ continue;
+ }
+ $total = $tag_total[$tag];
+ $idf = log(($N + 1) / ($total + 1)) + 1;
+ foreach ($tag_cat[$tag] as $cid => $co) {
+ $p = $co / $total;
+ $scores[$cid] = (isset($scores[$cid]) ? $scores[$cid] : 0) + $p * $idf * $times;
+ }
+ }
+ if (!$scores) {
+ return 0;
+ }
+
+ arsort($scores);
+ return (int) array_key_first($scores);
+}
+
+/* ══════════════════════════════════════════════════════════════
+ * 七、引擎 ④:摘要 —— 纯本地
+ * ══════════════════════════════════════════════════════════════ */
+
+/**
+ * 生成抽取式摘要(只写 post_excerpt)。
+ *
+ * ── 做法 ────────────────────────────────────────────────────
+ * TextRank 的简化版:给每句打分,按分数挑句子,再**还原原文顺序**拼起来。
+ * score = Σ(命中词的 idf × 次数) / (句长/40) ← 长度归一
+ * × 1.3(位于前 20% 位置) ← 位置加权
+ * × (1 + 0.15·与标题共现词数) ← 标题对齐
+ *
+ * 为什么长度要归一:不归一的话,最长那句几乎必然胜出,摘要就变成
+ * 「原文的某一大段」。为什么按分数挑完还要还原顺序:挑出的句子如果
+ * 按分数排列,读起来是跳跃的,不通顺。
+ *
+ * 实测:4ms 出 150 字,5 篇人工通读都通顺、抓得住主旨。
+ *
+ * @return string 摘要文本,无法生成返回 ''
+ */
+function yang_writing_make_excerpt($title, $body, $target = 150)
+{
+ list($dict, $maxlen) = yang_writing_dict();
+ $sents = yang_writing_split_sentences($body);
+ if (!$sents) {
+ return '';
+ }
+
+ $stop = yang_writing_stopwords();
+ $N = yang_writing_doc_count();
+
+ /* 词权重表:只给词典里的词算 idf。没进词典的词不参与打分 ——
+ 这是抽词方案的固有局限,但比引入 n-gram 噪声好。 */
+ $weight = array();
+ foreach ($dict as $w => $info) {
+ if (in_array($w, $stop, true)) {
+ continue;
+ }
+ $weight[$w] = log(($N + 1) / ($info['count'] + 1)) + 1;
+ }
+
+ $title_hits = yang_writing_match($title, $dict, $maxlen);
+ $total_sent = count($sents);
+
+ $scored = array();
+ foreach ($sents as $idx => $s) {
+ $hits = yang_writing_match($s, $dict, $maxlen);
+ $score = 0.0;
+
+ foreach ($hits as $w => $c) {
+ if (!isset($weight[$w])) {
+ continue;
+ }
+ $score += $weight[$w] * $c;
+ }
+ if ($score <= 0) {
+ continue;
+ }
+
+ $len = mb_strlen($s, 'UTF-8');
+ $score = $score / max(1, $len / 40);
+
+ if ($total_sent > 1 && ($idx / ($total_sent - 1)) < 0.2) {
+ $score *= 1.3;
+ }
+
+ $overlap = 0;
+ foreach ($title_hits as $w => $c) {
+ if (isset($hits[$w])) {
+ $overlap += $c;
+ }
+ }
+ if ($overlap > 0) {
+ $score *= (1 + 0.15 * $overlap);
+ }
+
+ $scored[$idx] = array('s' => $s, 'score' => $score, 'len' => $len);
+ }
+
+ if (!$scored) {
+ /* 一句都没命中词典时,退化成「取首句」,而不是返回空。
+ 首句在中文写作里通常就是总起句,比没有摘要强。 */
+ $first = $sents[0];
+ return mb_strlen($first, 'UTF-8') > $target
+ ? mb_substr($first, 0, $target, 'UTF-8')
+ : $first;
+ }
+
+ uasort($scored, function ($a, $b) {
+ return $b['score'] <=> $a['score'];
+ });
+
+ $picked = array();
+ $total = 0;
+ foreach ($scored as $idx => $item) {
+ if ($total >= $target) {
+ break;
+ }
+ $picked[$idx] = $item;
+ $total += $item['len'];
+ }
+
+ /* 还原原文顺序 —— 按分数顺序拼接读起来是跳跃的。 */
+ ksort($picked);
+
+ $out = '';
+ foreach ($picked as $item) {
+ $out .= $item['s'];
+ }
+
+ /* 目标 150 字,超太多就硬截到 200 字以内,保证后台摘要框不爆。 */
+ if (mb_strlen($out, 'UTF-8') > 220) {
+ $out = mb_substr($out, 0, 200, 'UTF-8') . '…';
+ }
+ return $out;
+}
+
+/**
+ * 登录凭据的独立存储。
+ *
+ * ── 为什么不跟其它设置一起存进 `kratos` ─────────────────────
+ * 框架渲染 password 字段时会把**真实值**写进 `value` 属性
+ * (见 class-options-interface.php 的 `case 'password'`),
+ * 页面上看着是圆点,但 F12 或「查看源代码」就是明文。
+ *
+ * 主题里的 SMTP 密码(`m_passwd`)现在就是这个状态。那是既有问题,
+ * 不在本次范围,但新功能没有再照抄一遍的理由 —— 用户这次明确说了
+ * 「为了安全着想」。
+ *
+ * 做法:提交时把密钥从 `kratos` 里摘出来,单独存到 `yang_writing_secrets`;
+ * 表单里永远回显空串,只提示「已保存(末 4 位 xxxx)」。
+ * 代价是要多一次 option 读取,可以忽略。
+ */
+function yang_writing_secret_keys()
+{
+ return array('w_baidu_key', 'w_youdao_key');
+}
+
+/**
+ * 读一个已保存的密钥。
+ *
+ * @param string $id 设置键名
+ * @return string
+ */
+function yang_writing_secret($id)
+{
+ $all = get_option('yang_writing_secrets', array());
+ if (!is_array($all) || !isset($all[$id])) {
+ return '';
+ }
+ return (string) $all[$id];
+}
+
+/**
+ * 密钥字段提交时拦截:存进独立选项,`kratos` 里只留空串。
+ *
+ * ── 为什么用 of_sanitize_password 过滤器 ────────────────────
+ * 框架对每个字段调 `apply_filters('of_sanitize_' . $type, $value, $option)`,
+ * 这是唯一能同时拿到「字段 id」和「新值」又不必改框架的位置。
+ * 挂在这个过滤器上,返回值就是最终写进 `kratos` 的东西。
+ *
+ * ── 空值语义 ────────────────────────────────────────────────
+ * 提交空串 = **不修改**(因为表单本来就回显空)。
+ * 这一条很关键:若当成"清空",用户每次保存别的设置都会把 Key 抹掉。
+ *
+ * @param mixed $value 提交的值
+ * @param array $option 字段定义
+ * @return string 永远返回空串(真实值已另存)
+ */
+function yang_writing_capture_secret($value, $option = array())
+{
+ if (!is_array($option) || empty($option['id'])) {
+ return $value;
+ }
+ $id = $option['id'];
+ if (!in_array($id, yang_writing_secret_keys(), true)) {
+ return $value;
+ }
+
+ $new = is_string($value) ? trim($value) : '';
+ if ($new === '') {
+ /* 空 ⇒ 不改。库里原有的值原封不动。 */
+ return '';
+ }
+
+ $all = get_option('yang_writing_secrets', array());
+ if (!is_array($all)) {
+ $all = array();
+ }
+ $all[$id] = $new;
+ update_option('yang_writing_secrets', $all, false);
+
+ return '';
+}
+add_filter('of_sanitize_password', 'yang_writing_capture_secret', 10, 2);
+
+/**
+ * 某个密钥是否已保存,返回末 4 位用于提示。
+ *
+ * @param string $id
+ * @return string 已保存返回「••••abcd」,未保存返回空串
+ */
+function yang_writing_secret_hint($id)
+{
+ $v = yang_writing_secret($id);
+ if ($v === '') {
+ return '';
+ }
+ $tail = mb_strlen($v, 'UTF-8') > 4 ? mb_substr($v, -4, 4, 'UTF-8') : '****';
+ return '••••' . $tail;
+}
+
+/* ══════════════════════════════════════════════════════════════
+ * 八、服务商适配层(三个,全部按各家官方签名规范实现)
+ * ══════════════════════════════════════════════════════════════ */
+
+/**
+ * 服务商清单。后台下拉与校验共用这一份,避免两处漂移。
+ *
+ * 只收服务器**实测可达**的:Google 翻译返回 000(不可达),
+ * LibreTranslate 超时,都已排除 —— 列出来只会让用户白填密钥。
+ */
+function yang_writing_providers()
+{
+ return array(
+ 'mymemory' => array(
+ 'label' => __('MyMemory(免费,无需密钥)', 'kratos'),
+ 'needs' => false,
+ 'note' => __('直接可用,不开通任何账号。每日有免费字符限额,超限会返回错误,届时可换百度。', 'kratos'),
+ ),
+ 'baidu' => array(
+ 'label' => __('百度翻译(国内,需 APPID + 密钥)', 'kratos'),
+ 'needs' => true,
+ 'note' => __('申请:访问 fanyi-api.baidu.com → 注册登录 → 控制台「管理控制台」→ 开发者信息里可看到 APPID 与密钥。免费版每月 5 万字符。', 'kratos'),
+ ),
+ 'youdao' => array(
+ 'label' => __('有道翻译(国内,需应用 ID + 应用密钥)', 'kratos'),
+ 'needs' => true,
+ 'note' => __('申请:访问 ai.youdao.com → 注册登录 → 创建应用(选「文本翻译」)→ 应用管理里可看到应用 ID 与应用密钥。', 'kratos'),
+ ),
+ );
+}
+
+/**
+ * 翻译一段中文到英文。失败一律返回空串,绝不抛异常。
+ *
+ * @param string $text 待翻译文本
+ * @return string 译文;任何失败路径都返回 ''
+ */
+function yang_writing_translate($text)
+{
+ $text = trim((string) $text);
+ if ($text === '') {
+ return '';
+ }
+ /* 超长标题截断:翻译接口对长文本要么收费要么直接拒。
+ 标题超过 200 字本身也不正常。 */
+ if (mb_strlen($text, 'UTF-8') > 200) {
+ $text = mb_substr($text, 0, 200, 'UTF-8');
+ }
+
+ $provider = (string) yang_writing_opt('w_provider', 'mymemory');
+ $timeout = (int) yang_writing_opt('w_timeout', 8);
+ if ($timeout < 3 || $timeout > 30) {
+ $timeout = 8;
+ }
+
+ switch ($provider) {
+ case 'baidu':
+ $out = yang_writing_tr_baidu($text, $timeout);
+ break;
+ case 'youdao':
+ $out = yang_writing_tr_youdao($text, $timeout);
+ break;
+ case 'mymemory':
+ default:
+ $out = yang_writing_tr_mymemory($text, $timeout);
+ break;
+ }
+
+ $out = trim(preg_replace('/\s+/u', ' ', (string) $out));
+ return $out;
+}
+
+/** MyMemory:免密钥,GET。返回体里 responseData.translatedText 是译文。 */
+function yang_writing_tr_mymemory($text, $timeout)
+{
+ $url = add_query_arg(array(
+ 'q' => rawurlencode($text),
+ 'langpair' => 'zh-CN|en',
+ ), 'https://api.mymemory.translated.net/get');
+
+ $res = wp_remote_get($url, array('timeout' => $timeout));
+ if (is_wp_error($res)) {
+ return '';
+ }
+ if (200 !== (int) wp_remote_retrieve_response_code($res)) {
+ return '';
+ }
+ $j = json_decode(wp_remote_retrieve_body($res), true);
+ if (!is_array($j) || empty($j['responseData']['translatedText'])) {
+ return '';
+ }
+ /* 限额耗尽时它会在 translatedText 里塞一句英文提示(MYMEMORY WARNING...),
+ 那种内容不能当译文用。 */
+ $t = (string) $j['responseData']['translatedText'];
+ if (stripos($t, 'MYMEMORY WARNING') !== false || stripos($t, 'QUOTA') !== false) {
+ return '';
+ }
+ return $t;
+}
+
+/**
+ * 百度翻译:sign = md5(appid + q + salt + key)。
+ * 官方要求 q 原样参与签名,**不能** URL 编码后再算。
+ */
+function yang_writing_tr_baidu($text, $timeout)
+{
+ $appid = trim((string) yang_writing_opt('w_baidu_appid', ''));
+ $key = yang_writing_secret('w_baidu_key');
+ if ($appid === '' || $key === '') {
+ return '';
+ }
+
+ $salt = (string) wp_rand(10000, 99999);
+ $res = wp_remote_post('https://fanyi-api.baidu.com/api/trans/vip/translate', array(
+ 'timeout' => $timeout,
+ 'body' => array(
+ 'q' => $text,
+ 'from' => 'zh',
+ 'to' => 'en',
+ 'appid' => $appid,
+ 'salt' => $salt,
+ 'sign' => md5($appid . $text . $salt . $key),
+ ),
+ ));
+ if (is_wp_error($res)) {
+ return '';
+ }
+ $j = json_decode(wp_remote_retrieve_body($res), true);
+ if (!is_array($j) || !isset($j['trans_result'][0]['dst'])) {
+ return '';
+ }
+ return (string) $j['trans_result'][0]['dst'];
+}
+
+/**
+ * 有道翻译 v3:sign = sha256(appKey + truncate(q) + salt + curtime + appSecret)。
+ * truncate 规则:长度 ≤ 20 用原文,否则取「首 10 字 + 长度 + 末 10 字」。
+ */
+function yang_writing_tr_youdao($text, $timeout)
+{
+ $appkey = trim((string) yang_writing_opt('w_youdao_appid', ''));
+ $secret = yang_writing_secret('w_youdao_key');
+ if ($appkey === '' || $secret === '') {
+ return '';
+ }
+
+ $salt = (string) wp_rand(10000, 99999);
+ $curtime = (string) time();
+ $len = mb_strlen($text, 'UTF-8');
+ $trunc = $len <= 20
+ ? $text
+ : (mb_substr($text, 0, 10, 'UTF-8') . $len . mb_substr($text, $len - 10, 10, 'UTF-8'));
+
+ $sign = hash('sha256', $appkey . $trunc . $salt . $curtime . $secret);
+
+ $res = wp_remote_post('https://openapi.youdao.com/api', array(
+ 'timeout' => $timeout,
+ 'body' => array(
+ 'q' => $text,
+ 'from' => 'zh-CHS',
+ 'to' => 'en',
+ 'appKey' => $appkey,
+ 'salt' => $salt,
+ 'sign' => $sign,
+ 'signType' => 'v3',
+ 'curtime' => $curtime,
+ ),
+ ));
+ if (is_wp_error($res)) {
+ return '';
+ }
+ $j = json_decode(wp_remote_retrieve_body($res), true);
+ if (!is_array($j) || !isset($j['translation'][0])) {
+ return '';
+ }
+ return (string) $j['translation'][0];
+}
+
+/* ══════════════════════════════════════════════════════════════
+ * 九、主入口:发布时应用四个引擎
+ * ══════════════════════════════════════════════════════════════ */
+
+/**
+ * 挂在 transition_post_status 上。
+ *
+ * ── 为什么用这个钩子而不是 save_post ─────────────────────────
+ * 用户的规则是「只在新增时启用,修改都不改变」。save_post 无法区分
+ * 「第一次发布」和「已发布文章又改了一次」—— 它两件事都触发。
+ * transition_post_status 带 $old_status,一眼就能判断:
+ * 旧状态不是 publish、新状态是 publish ⇒ 这就是第一次发布。
+ * 已发布文章再次保存时 $old_status === 'publish' ⇒ 直接返回。
+ *
+ * 顺带解决了自动保存:自动保存不会把状态变成 publish,天然被挡住。
+ *
+ * @param string $new_status 新状态
+ * @param string $old_status 旧状态
+ * @param WP_Post $post 文章对象
+ */
+function yang_writing_on_transition($new_status, $old_status, $post)
+{
+ /* 只在「首次进入 publish」时干活。 */
+ if ($new_status !== 'publish' || $old_status === 'publish') {
+ return;
+ }
+ if (!$post instanceof WP_Post) {
+ return;
+ }
+ if ($post->post_type !== 'post') {
+ return;
+ }
+ if (wp_is_post_revision($post->ID) || wp_is_post_autosave($post->ID)) {
+ return;
+ }
+ if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
+ return;
+ }
+ /* 定时发布(future → publish)也走这里,是正确行为:它在未来某刻
+ 才真正"第一次发布"。但对**已经有值**的字段仍然不覆盖,所以安全。 */
+
+ /* 幂等:同一篇文章处理过一次就不再碰。
+ 为什么必须要有:用户可能「发布 → 退回草稿 → 再发布」,
+ 没有这道锁的话外部 API 会被重复调用几次,
+ 而且第二次会把第一次的结果当成"已有值"从而跳过 —— 逻辑上没错,
+ 但白花钱和时间。有锁更干净。 */
+ $done = get_post_meta($post->ID, '_yang_writing_done', true);
+ if (is_array($done) && !empty($done)) {
+ return;
+ }
+
+ yang_writing_apply($post);
+}
+
+/**
+ * 执行四个引擎并把结果写回文章。
+ *
+ * 每个引擎都包在「先判开关、再判是否为空」里,任何一步失败都不影响
+ * 其它步骤 —— 这是「独立引擎」的字面实现。
+ *
+ * @param WP_Post $post
+ */
+function yang_writing_apply($post)
+{
+ $post_id = (int) $post->ID;
+ $title = (string) $post->post_title;
+ $html = (string) $post->post_content;
+
+ $body = yang_writing_html2text($html);
+ if (mb_strlen($body, 'UTF-8') < 30) {
+ /* 正文太短(少于 30 字)任何引擎都给不出有意义的结果,
+ 直接不做 —— 也顺手避免把一句话的文章判定成某个分类。 */
+ update_post_meta($post_id, '_yang_writing_done', array('skip' => 'short'));
+ return;
+ }
+
+ $did = array();
+
+ /* ── ① 摘要(最便宜、最安全,先做)──────────────────────── */
+ if (yang_writing_on('w_on_excerpt')) {
+ $cur = trim((string) $post->post_excerpt);
+ if ($cur === '') {
+ $ex = yang_writing_make_excerpt($title, $body);
+ if ($ex !== '') {
+ /* 直接写库而不是 wp_update_post:wp_update_post 会再触发
+ transition_post_status,把自己卷进递归。这里只改一个
+ 字段,直写 + 清缓存是最稳的。 */
+ yang_writing_update_field($post_id, 'post_excerpt', $ex);
+ $did[] = 'excerpt';
+ }
+ }
+ }
+
+ /* ── ② 分类(纯本地,无网络)────────────────────────────── */
+ if (yang_writing_on('w_on_category')) {
+ if (yang_writing_needs_category($post_id)) {
+ $cid = yang_writing_make_category($title, $body);
+ if ($cid > 0) {
+ /* append=false:这是"替他选一个",不是"再加一个"。
+ 只在文章还挂在默认分类时才走到这里(见上面判断)。 */
+ wp_set_post_categories($post_id, array($cid), false);
+ $did[] = 'category';
+ }
+ }
+ }
+
+ /* ── ③ 标签 ──────────────────────────────────────────────── */
+ if (yang_writing_on('w_on_tag')) {
+ /* 🔴 这里必须把两段返回值都接住。make_tags() 返回的是
+ array($exist, $new):
+ $exist = 词典(=标签库)里**已经命中**的标签名
+ $new = 词典之外、值得**新建**的候选词
+ 3.2.56 及以前写的是 `list($suggest, $_)`,$new 被整个丢掉;
+ 然后又拿 $suggest 去 get_term_by 做二次分类 —— 而 $suggest 里
+ 每个词都来自词典、都是已有标签,必然全部命中 ⇒ 「新建」那个
+ 分支永远进不去 ⇒ 这条功能在真实发布流程里**一次都没生效过**。
+ 用户提这个需求的初衷就是「如果不能新增,一个新的博客,
+ 永远无法使用这个功能」,所以这是个真 bug,不是洁癖。 */
+ list($exist_hits, $new_cands) = yang_writing_make_tags($title, $body);
+
+ $limit = (int) yang_writing_opt('w_tag_new_limit', 3);
+ if ($limit < 0) {
+ $limit = 0;
+ }
+
+ /* 已有标签全收,不占新增额度。仍然 get_term_by 复核一遍:
+ 词典是缓存出来的,标签可能刚被管理员删掉;若直接写名字,
+ wp_set_post_tags 会把它重新建出来 —— 那就等于偷偷绕过了
+ 用户设的新增上限。 */
+ $exist = array();
+ foreach ($exist_hits as $name) {
+ $term = get_term_by('name', $name, 'post_tag');
+ if ($term && !is_wp_error($term)) {
+ $exist[] = $term->name;
+ }
+ }
+
+ /* 新标签:先复核再占额度。顺序很关键 —— 万一并发下另一个请求
+ 刚把同名标签建好,它此刻已属「已有标签」,不该再吃掉一个额度。 */
+ $new = array();
+ foreach ($new_cands as $name) {
+ if (count($new) >= $limit) {
+ break;
+ }
+ if ($name === '' || preg_match('/^\d+$/', (string) $name)) {
+ continue;
+ }
+ $term = get_term_by('name', $name, 'post_tag');
+ if ($term && !is_wp_error($term)) {
+ $exist[] = $term->name;
+ continue;
+ }
+ $new[] = $name;
+ }
+
+ $all = array_values(array_unique(array_merge($exist, $new)));
+ if ($all) {
+ /* append=true —— 用户手工打的标签**不能删**。 */
+ wp_set_post_tags($post_id, $all, true);
+ $did[] = 'tag';
+ }
+ }
+
+ /* ── ④ 别名(唯一需要外部翻译的一步,放最后)─────────────
+ 放最后是有意的:万一翻译接口超时,前面三项已经落库了。
+ 若放最前,一次超时会让四项全白做。 */
+ if (yang_writing_on('w_on_slug')) {
+ $cur = (string) get_post_field('post_name', $post_id);
+ /* 中文标题下 WP 会生成 `%e4%bf%a1...` 这种 URL 编码串,它不是空、
+ 但也不是可用的英文 slug。所以判断标准不是"空",而是
+ "不是纯 ASCII" —— 否则永远不会替换掉那串百分号。 */
+ if ($cur === '' || !preg_match('/^[a-z0-9\-]+$/', $cur)) {
+ $slug = yang_writing_make_slug($title);
+ if ($slug !== '' && $slug !== $cur) {
+ /* slug 必须唯一,否则 WP 会自己加 -2 后缀造成"看起来没生效"。
+ 这里显式判重,冲突时加序号。 */
+ $slug = yang_writing_unique_slug($slug, $post_id);
+ yang_writing_update_field($post_id, 'post_name', $slug);
+ $did[] = 'slug';
+ }
+ }
+ }
+
+ update_post_meta($post_id, '_yang_writing_done', $did ? $did : array('none' => 1));
+}
+
+/**
+ * 文章是否「还没选过分类」。
+ *
+ * 判据:只挂在默认分类上(且只有这一个)。
+ * 这是 WP 里"用户没动过分类"的标准信号 —— 新文章默认落在
+ * get_option('default_category'),用户一旦选了别的就变了。
+ */
+function yang_writing_needs_category($post_id)
+{
+ $cats = wp_get_post_categories($post_id);
+ if (count($cats) !== 1) {
+ return false;
+ }
+ return (int) $cats[0] === (int) get_option('default_category');
+}
+
+/**
+ * 直接改一个 post 字段。
+ *
+ * 不用 wp_update_post 的原因见 yang_writing_apply 里的注释:
+ * 它会再触发 transition_post_status / save_post,形成递归。
+ * 这里改完必须 clean_post_cache,否则同一请求内 get_post 还是旧值。
+ */
+function yang_writing_update_field($post_id, $field, $value)
+{
+ static $allowed = array('post_name', 'post_excerpt');
+ if (!in_array($field, $allowed, true)) {
+ return false;
+ }
+ $post_id = (int) $post_id;
+
+ global $wpdb;
+ $ok = $wpdb->update($wpdb->posts, array($field => $value), array('ID' => $post_id));
+
+ /* 🔴 必须清缓存。
+ *
+ * wpdb->update 直接改表,**不经过任何 WP 的缓存失效逻辑**。
+ * 而同一次请求里 get_post() / get_post_field() 走 wp_cache_get,
+ * 拿到的还是改之前的对象 —— 表现是「写库成功了,但读出来是旧值」。
+ *
+ * 3.2.57 实测踩到:摘要明明写进去了(直接查库能查到),
+ * 但同一请求里 get_post_field('post_excerpt') 返回空串,
+ * 端到端判据因此报「摘要 0 字」。若不是当场查了两边,
+ * 会误判成"摘要引擎没工作",去改算法 —— 方向完全错。
+ *
+ * clean_post_cache() 会清 post 对象缓存;wp_cache_delete 兜底清
+ * posts 组(某些持久对象缓存后端下 clean_post_cache 清不干净)。 */
+ clean_post_cache($post_id);
+ wp_cache_delete($post_id, 'posts');
+ wp_cache_delete($post_id, 'post_meta');
+
+ return $ok !== false;
+}
+
+/**
+ * 保证 slug 唯一。
+ *
+ * WP 自己的 wp_unique_post_slug 需要 post_type/status 等一串参数,
+ * 这里场景单一(post + publish),直接查库更直白。
+ */
+function yang_writing_unique_slug($slug, $post_id)
+{
+ global $wpdb;
+
+ $base = $slug;
+ $i = 2;
+ /* 最多试 50 次,超过说明站内有大量同名文章,加时间戳兜底。 */
+ while ($i < 50) {
+ $exists = $wpdb->get_var($wpdb->prepare(
+ "SELECT ID FROM {$wpdb->posts}
+ WHERE post_name = %s AND post_type = 'post' AND ID <> %d LIMIT 1",
+ $slug,
+ $post_id
+ ));
+ if (!$exists) {
+ return $slug;
+ }
+ $slug = $base . '-' . $i;
+ $i++;
+ }
+ return $base . '-' . time();
+}
+
+/* ══════════════════════════════════════════════════════════════
+ * 十、注册钩子
+ * ══════════════════════════════════════════════════════════════ */
+
+/* 只在有任何一项开关打开时才挂钩子 —— 全部关闭时零开销、零行为。 */
+function yang_writing_boot()
+{
+ $any = false;
+ foreach (array('w_on_slug', 'w_on_tag', 'w_on_category', 'w_on_excerpt') as $k) {
+ if (yang_writing_on($k)) {
+ $any = true;
+ break;
+ }
+ }
+ if (!$any) {
+ return;
+ }
+ add_action('transition_post_status', 'yang_writing_on_transition', 20, 3);
+}
+add_action('init', 'yang_writing_boot', 20);
\ No newline at end of file
diff --git a/style.css b/style.css
index bc4dc46..2e86921 100644
--- a/style.css
+++ b/style.css
@@ -1,7 +1,7 @@
/*
Theme Name: Kratos_Yang
Text Domain: kratos_Yang
-Version: 3.2.56
+Version: 3.2.57
Requires PHP: 7.0
Description: 这是一个Kratos主题的分支版本,用来增加一些定制化功能。
Tags: 博客, 双栏, 响应式, 自定义背景, 自定义颜色, 自定义图标, 自定义菜单, 特色图像, 收录优化, 邮件中心, 主题选项, 小工具, 文章置顶, 无障碍友好