From 0a5486e1951af6f1d32981a1e2a26bb57e60ceb4 Mon Sep 17 00:00:00 2001 From: laoyang Date: Tue, 27 Oct 2026 16:05:43 +0800 Subject: [PATCH] =?UTF-8?q?3.2.57=20=E6=96=B0=E5=A2=9E=E3=80=8C=E5=86=99?= =?UTF-8?q?=E4=BD=9C=E5=A2=9E=E5=BC=BA=E3=80=8D=EF=BC=9A=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E6=97=B6=E8=87=AA=E5=8A=A8=E7=94=9F=E6=88=90=E5=88=AB=E5=90=8D?= =?UTF-8?q?/=E6=A0=87=E7=AD=BE/=E5=88=86=E7=B1=BB/=E6=91=98=E8=A6=81=20#25?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求(工单 #25):写文章时自动生成别名(翻译)、自动生成标签、 自动选择分类、自动生成摘要,问主题能不能做、是否必须用插件。 结论:主题自己就能做,不依赖任何插件。 新增 inc/theme-writing.php(四个独立引擎 + 设置读写 + 密钥处理): - 触发点:transition_post_status,且要求 new_status === publish && old_status !== publish ⇒ 只在「第一次点发布」时动手,自动保存不触发,已发布的文章一概不碰。 - 幂等锁 _yang_writing_done:同一篇只做一次,避免反复覆写。 四道「不覆盖」守卫(用户逐条确认的决策): - 摘要:只在 post_excerpt 为空时才写。 - 分类:只在文章还挂默认分类(未分类)时才动。 - 别名:只在「空」或「不是纯 ASCII」时才生成。判据不是"空"而是 "不是纯 ASCII" —— 中文标题下 WP 生成的是 %e4%bf%a1... 这种编码串, 它非空但不可用。已有英文别名的 966 篇一概不动(别名=URL,改了 就是成批 404)。 - 标签:wp_set_post_tags(..., true) 追加,绝不删除手工标签。 修一个真实产品 bug:3.2.56 及以前 apply() 里写的是 list($suggest, $_) = yang_writing_make_tags(...),$new 被整个丢掉; 接着又拿 $suggest 去 get_term_by 二次分类 —— 而 $suggest 里每个词 都来自词典、必然全部命中 ⇒「新建」分支永远进不去 ⇒ 这条功能在真实 发布流程里一次都没生效过。改为 list($exist_hits, $new_cands), 两段分别复核后合并。 标签两级策略(新博客也能用):先匹配已有标签池,匹配不到才新建, 每篇新增数量受 w_tag_new_limit 约束(默认 3,设 0 = 永不新建)。 中文分词(服务器无 scws/jieba):先按虚词与标点切短语,再在短语 内部取 2~6 字 n-gram。不切句直接取窗口会产出「星等换算与赤」 这类跨词碎片,且字面上"出现 ≥2 次"能通过所有频次闸门 —— 这是 n-gram 的固有缺陷,只能靠切分规避,调阈值无效。 虚词表统一由 yang_writing_seps() 提供(闸门④ 复用同一函数)。 翻译:默认 MyMemory(免注册),可选百度/有道。任何失败都返回空串 跳过该项,绝不阻塞发布。 密钥:存独立选项 yang_writing_secrets,不进 kratos;password 类型 + std 空串;页面只显示末 4 位;留空提交 = 不修改。 设置页:新增「写作增强」独立 heading 标签页,四个开关默认全关 (升级不改变现有行为)。framework 里 'requires' 这个键根本没被读取, 条件显示改用 options-custom.js 显式逻辑;info 类型不生成 .section 包裹层,补一条 .section-info 规则才能藏住服务商申请说明。 验证(发布前): - 写作端到端 71 条、后台渲染 21 条、密钥路径 9 条,合计 101 条全过 - 反证 22 组(W1~W16 端到端 / A1~A6 后台)× R1~R14 发布包注入, 全部让失败数从 0 增加 - 测试自身不污染:每节差分回滚 + 总闸门,标签库稳定 703 / 967 篇 - 发布走 theme-update/,主题目录仍是 3.2.56,未被覆盖 --- functions.php | 5 +- .../css/optionsframework.css | 10 + inc/options-framework/js/options-custom.js | 37 + inc/theme-options.php | 190 ++- inc/theme-writing.php | 1412 +++++++++++++++++ style.css | 2 +- 6 files changed, 1653 insertions(+), 3 deletions(-) create mode 100644 inc/theme-writing.php 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: 博客, 双栏, 响应式, 自定义背景, 自定义颜色, 自定义图标, 自定义菜单, 特色图像, 收录优化, 邮件中心, 主题选项, 小工具, 文章置顶, 无障碍友好