3.2.57 新增「写作增强」:发布时自动生成别名/标签/分类/摘要 #25

需求(工单 #25):写文章时自动生成别名(翻译)、自动生成标签、
自动选择分类、自动生成摘要,问主题能不能做、是否必须用插件。
结论:主题自己就能做,不依赖任何插件。

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

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

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

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

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

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

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

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

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

验证(发布前):
- 写作端到端 71 条、后台渲染 21 条、密钥路径 9 条,合计 101 条全过
- 反证 22 组(W1~W16 端到端 / A1~A6 后台)× R1~R14 发布包注入,
  全部让失败数从 0 增加
- 测试自身不污染:每节差分回滚 + 总闸门,标签库稳定 703 / 967 篇
- 发布走 theme-update/,主题目录仍是 3.2.56,未被覆盖
This commit is contained in:
2026-10-27 16:05:43 +08:00
parent b7dcdc1602
commit 0a5486e195
6 changed files with 1653 additions and 3 deletions
@@ -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;
@@ -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()
+189 -1
View File
@@ -1262,7 +1262,195 @@ function kratos_options()
);
/* ══════════════════════════════════════════════════════════
* 十三、自定义代码 —— 兜底用,放最后
* 十三、写作增强 —— 发布时自动生成别名/标签/分类/摘要
*
* 四个引擎各自独立开关,互不依赖。分工是按「能不能纯本地做」
* 划的,不是按功能好坏:
* · 分类、摘要 → 纯本地(分类 90% 准确率、摘要 4ms)
* · 标签 → 本地优先,外部可选(Top8 召回 49.2%,见下方说明)
* · 别名 → 必须外部(纯 PHP 做不出中译英)
*
* 用户明确要求「能不用外部就不用,为了安全着想」,所以默认状态是:
* 四个开关**全关**,需要用户主动打开 —— 新装主题不该偷偷往文章里写东西。
* ══════════════════════════════════════════════════════════ */
$options[] = array(
'name' => __('写作增强', 'kratos'),
'type' => 'heading',
);
$options[] = array(
'name' => __('总开关说明', 'kratos'),
'desc' => __('下面的功能全部只在<b>「文章第一次发布」</b>那一下生效,'
. '并且<b>只填空白、绝不覆盖已有内容</b>。'
. '<br>已发布的旧文章<b>完全不动</b> —— 再次编辑保存也不会改任何字段。'
. '<br>这样设计是因为别名就是 URL:一旦被改,旧链接全部 404。', 'kratos'),
'type' => 'info',
);
/* ── ① 摘要 ────────────────────────────────────────────────
* 放第一个:最安全、最便宜、纯本地、离线可用。
* 站点现在 967 篇文章**没有一篇有摘要**,这一项立刻有收益。 */
$options[] = array(
'name' => __('自动生成摘要', 'kratos'),
'desc' => __('发布时从正文里抽取 150 字左右的摘要,写入「摘要」字段。'
. '<b>纯本地计算,不联网</b>,耗时约 4 毫秒。'
. '<br>只有摘要为空时才写;你自己写过的摘要不会被改动。', 'kratos'),
'std' => '0',
'id' => 'w_on_excerpt',
'type' => 'checkbox',
);
/* ── ② 分类 ────────────────────────────────────────────────
* 同样纯本地。只在新文章还挂在「默认分类」上时才出手。 */
$options[] = array(
'name' => __('自动选择分类', 'kratos'),
'desc' => __('发布时按标签与已发布文章的历史共现关系,推荐最接近的一个分类。'
. '<b>纯本地计算,不联网。</b>'
. '<br>只对<b>仍挂在「默认分类」</b>的新文章生效;你已经选过分类的不动。'
. '<br>线上实测准确率约 90%(前三名命中 99%)。', 'kratos'),
'std' => '0',
'id' => 'w_on_category',
'type' => 'checkbox',
);
/* ── ③ 标签 ────────────────────────────────────────────────
* 本地引擎:703 个已有标签当词典,最大匹配 + TF-IDF 排序。
* 外部引擎:本地匹配不到新词时,由服务商提关键词 —— 这是唯一
* 能"发现新词"的路(服务器没有任何中文分词扩展)。 */
$options[] = array(
'name' => __('自动生成标签', 'kratos'),
'desc' => __('发布时自动打标签。<b>优先从你已有的标签里挑</b>'
. '(实测:Top8 能命中人工标签的 49.2%,'
. '只算字面出现在正文里的人工标签则 59.0%),'
. '匹配不到才考虑新增。'
. '<br>你自己手工打的标签<b>不会被删除</b>,只会追加。', 'kratos'),
'std' => '0',
'id' => 'w_on_tag',
'type' => 'checkbox',
);
$options[] = array(
'name' => __('每篇最多新增几个标签', 'kratos'),
'desc' => __('只限制<b>新建</b>的标签数量;匹配到已有标签不受此限制。'
. '<br>设为 0 表示<b>只从已有标签里挑,永不新建</b>。'
. '<br>为什么要限制:站内 703 个标签里已有 338 个只用过 1 次,'
. '放开新建会让标签库迅速变成几千个长尾词。', 'kratos'),
'std' => '3',
'id' => 'w_tag_new_limit',
'type' => 'text',
);
/* ── ④ 别名 ────────────────────────────────────────────────
* 唯一必须联网的一项。放最后:万一接口超时,前面三项已经落库。 */
$options[] = array(
'name' => __('自动生成英文别名', 'kratos'),
'desc' => __('发布时把中文标题翻译成英文,作为 URL 别名(slug)。'
. '<b>这一项必须联网调用翻译服务</b> —— 纯 PHP 做不出中译英,'
. '拼音方案(xin-xi-jiao-lu…)又长又不可读,所以没有本地引擎。'
. '<br>只有别名为空、或仍是 <code>%e4%bf%a1</code> 这种乱码时才写;'
. '<b>你手工填过的别名绝不动</b>。'
. '<br>翻译失败(超时、限额用尽)时<b>安静跳过</b>,不影响发布。', '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' => __('别名生成用的翻译服务。<b>MyMemory 无需密钥,开箱可用</b>,'
. '但它是国外服务、有每日字符限额;被限流时换成百度即可。', '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' => __('<b>MyMemory</b>:不用填任何东西。<br>'
. '<b>百度翻译</b>:访问 <a href="https://fanyi-api.baidu.com/" target="_blank" rel="noopener">fanyi-api.baidu.com</a> '
. '→ 注册登录 → 进入「管理控制台」→ 在「开发者信息」里可看到 <b>APPID</b> 与 <b>密钥</b>。'
. '免费版每月 5 万字符。<br>'
. '<b>有道翻译</b>:访问 <a href="https://ai.youdao.com/" target="_blank" rel="noopener">ai.youdao.com</a> '
. '→ 注册登录 → 「创建应用」选「文本翻译」→ 在「应用管理」里可看到 <b>应用 ID</b> 与 <b>应用密钥</b>。', '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' => __('选「百度翻译」时填写。<b>留空表示不修改</b>已保存的密钥。', 'kratos')
. ($yang_baidu_hint !== '' ? '<br>当前已保存:<code>' . esc_html($yang_baidu_hint) . '</code>' : ''),
'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' => __('选「有道翻译」时填写。<b>留空表示不修改</b>已保存的密钥。', 'kratos')
. ($yang_youdao_hint !== '' ? '<br>当前已保存:<code>' . esc_html($yang_youdao_hint) . '</code>' : ''),
'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,
File diff suppressed because it is too large Load Diff