wp-publish
WordPress 文章发布
Publish an article (Markdown or DingTalk doc) to a WordPress blog via the REST API + Application Passwords, including downloading source images, re-hosting them in the WP media library, converting Markdown to HTML, and creating a draft post. Also supports an optional block-level redaction of the source DingTalk doc before publishing. Use when the user asks to upload/publish/发布 an article or 文章 to their WordPress blog (e.g. yushuoxin.top), migrate a DingTalk doc to WordPress, re-host doc images onto a blog, or redact/脱敏 a DingTalk doc block by block without overwriting manual edits.
WordPress 文章发布 (wp-publish)
把一篇文章(本地 Markdown 或钉钉文档)发布到个人 WordPress 博客。核心难点已在本 Skill 固化:内网域名拦截绕行、图片重托管、朴素固定链接的 REST 路由、嵌套代码围栏的 Markdown 转换。
前置:读取配置
敏感凭证不写在本文件里,统一放在项目根的 0-wp.env(见文末格式)。使用前先读取其中的 WP_SITE_URL / WP_USERNAME / WP_APP_PASSWORD。
鉴权用 Application Password(后台 用户→个人资料→应用程序密码 生成),Basic Auth:
btoa("用户名:应用密码"),密码含空格也照原样带上。
关键环境事实(决定方案)
- 朴素固定链接:站点若
/wp-json/报 404,说明固定链接是"朴素"模式,REST 必须走/?rest_route=/wp/v2/...形式。用GET /?rest_route=/返回 JSON 即可确认。 - 本机可能无法直连站点:若命令行
curl被企业网络策略/代理拦截(常见于 307 跳转到管控页),则所有对站点的 API 调用都走browser-use的页面内fetch(),而不是 curl。
完整流程
复制此清单跟踪进度:
- [ ] 1. 读取 0-wp.env 凭证
- [ ] 2. 取文章内容(钉钉 dws doc read / 本地 md)
- [ ] 3. 下载文章内所有图片到 /tmp/wp_imgs/
- [ ] 4. 浏览器放行域名 + 验证 REST 鉴权 (users/me)
- [ ] 5. 逐张上传图片到媒体库,按顺序收集 source_url
- [ ] 6. 构建 HTML:替换图片地址 + Markdown→HTML
- [ ] 7. 创建 draft 文章 + 设置分类
- [ ] 8. 校验草稿 + 清理临时媒体/本地文件
1-3. 取内容与下载图片(命令行,可直连钉钉/OSS)
- 钉钉文档:在
~/.qoder/skills/dws目录dws doc read --node <id> --format json,取.markdown。 - 用正则
![[^]]*](([^)]+))抽出图片 URL(注意去掉 markdown 空标题产生的尾部" ":url.split(' ')[0])。 - 按文档出现顺序下载为
img_01.png … img_NN.png(顺序即后续定位依据)。钉钉签名图 URL 会过期,尽快下载。
4. 放行域名 + 验证鉴权(browser-use)
navigate_page到${WP_SITE_URL}/?rest_route=/。若被企业网络策略拦到放行页,先在浏览器里完成放行,回到本步重试。navigate_page到${WP_SITE_URL}/(HTML 页,便于注入 DOM)。evaluate_script验证鉴权:
async () => {
const auth = 'Basic ' + btoa(USER + ':' + PW);
const r = await fetch('/?rest_route=/wp/v2/users/me&context=edit', { headers: { Authorization: auth } });
return { status: r.status, body: (await r.text()).slice(0,200) };
}
期望 200 且含 "roles":["administrator"]。
5. 上传图片(browser-use,见 reference.md 详解)
思路:页面内注入一个 <input type=file id=qup>,用 upload_file 把本地图片塞进去,再 evaluate_script 读 files[0] 走 fetch() POST 到 /?rest_route=/wp/v2/media。
- 注入后先精简页面(
document.body.innerHTML=''只留#qup),让后续take_snapshot极小、省 token。 - 严格配对:每次
upload_file后必须紧跟一次上传fetch(),否则下一次upload_file会覆盖文件导致漏传。 - 每张记录
source_url(存到window.__up便于最后汇总)。上传返回大图可能带-scaled后缀,以返回的source_url为准。
6. 构建 HTML(命令行)
钉钉文档一律走 JSONML,不要用 markdown。 用 scripts/jsonml_to_html.py(无第三方依赖):
# 先抽块结构(code 是显式节点,自带 syntax 与完整 code 文本)
dws doc read --node "<URL>" --content-format jsonml --output doc.jsonml.json --format json
python3 scripts/jsonml_to_html.py doc.jsonml.json wp_urls.txt out.html
为何必须用 JSONML:
dws doc read默认导出的 markdown 里,代码块用三反引号表示。 钉钉常见写法是把一整段 AI 输出放进`markdown块,内部又含`bash和#标题—— 三反引号无法表达嵌套,任何解析器到这里都只能猜,极易把代码块内的标题渲染成文章标题。 JSONML 里code是显式节点,边界不存在歧义,还能拿到语言标注。 实测对比(post 2437):JSONML 取得 21 标题 / 7 代码块 / 8 表格;markdown 路径跑出 111 标题 / 53 代码块 / 30 表格。
校验:脚本会打印 headings/pre/tables/imgs/lists/callouts 计数,逐项对照 JSONML 中同名节点数应完全相等;
图片数与 URL 数不一致会告警。建议再做一次文本完整性对比(去空白后源文本字符数 == HTML 文本字符数)。
退路:本地 Markdown 文件
源头就是 .md 文件(没有块结构可抽)时才用 scripts/build_post.py(依赖 pip3 install markdown --break-system-packages):
python3 scripts/build_post.py <doc.md> <wp_urls.txt> <out.html>
它做四件事:① 剥离首个 H1 作为标题(并把 + 还原为 +);② 按顺序把第 N 张图片 URL 替换为 wp_urls 第 N 行;③ 转 HTML(extensions=['tables','fenced_code','sane_lists']);④ 标题泄漏校验。
嵌套围栏陷阱(已修,务必看):钉钉文档常把一整段 AI 输出放进
`markdown代码块,内部又含`bash。 三反引号无法表达嵌套(CommonMark 下外层会在第一个 ``处提前结束),所以脚本默认把外层围栏升级为 4 个反引号, 让整段(含内部``bash与#标题)完整保留在一个代码块里。早期版本的做法是反的——直接删掉外层围栏(
--unwrap-outer-md)让内部表格能渲染,后果是代码块里的#/##变成了文章一级/二级标题, 且旧的围栏配对逻辑(带语言=开、裸`=闭)遇到无语言代码块会整体错位,导致<pre>与标题交替错乱。 旧行为已降为可选开关,除非文档里那段`markdown本来就是想当正文渲染的,否则不要用。
标题泄漏校验:转完会对比「HTML 里的
<h*>数」与「原文围栏外的标题数」,前者更多就报错退出(exit 2)并列出可疑标题。 确认无误时才加--allow-heading-leak放行。发布前看一眼这行统计:headings=实际/预期。
校验:<img>/wp-content/uploads 计数应等于图片数,alidocs 计数应为 0,headings= 不应超出预期值。
7. 创建草稿(browser-use)
大段 HTML 不要直接塞进 evaluate_script(易踩转义)。用中转文件:把 HTML 存成 .txt(WP 允许 text/plain),用图片相同方式上传到媒体库拿到 URL,页面内 fetch() 取回文本作为正文,再 POST 创建文章:
const body = { title, content, status: 'draft', categories: [CAT_ID], comment_status: 'closed' };
await fetch('/?rest_route=/wp/v2/posts', { method:'POST',
headers:{ Authorization: auth, 'Content-Type':'application/json' }, body: JSON.stringify(body) });
分类 ID 先 GET /?rest_route=/wp/v2/categories&per_page=100 查询。(yushuoxin.top 现有:技术博客=8、学习笔记=2、生活随记=18、未分类=1。)
8. 校验与清理
GET /?rest_route=/wp/v2/posts/<id>&context=edit确认status=draft、content.raw里<img>/<table>数量正确、alidocs为 0。- 删除中转
.txt媒体:DELETE 常被 405 拦,用POST+ 头X-HTTP-Method-Override: DELETE到/?rest_route=/wp/v2/media/<id>&force=true。 - 删除本地
/tmp/wp_imgs、临时脚本产物。 - 给用户后台编辑链接:
${WP_SITE_URL}/wp-admin/post.php?post=<id>&action=edit(用户已登录后台,可预览后手动点「发布」)。
可选:发布前给钉钉原文脱敏(块级编辑)
若源文是钉钉文档、且线上版本已被用户手动插入截图/改动,不能整体覆盖(会丢改动)。用块级精准脱敏只改命中敏感信息的块。适用场景:账号 ID、AK/SK、余额/授信额度、真实消费金额、安全组 ID、手机号等。
用 scripts/redact_doc.py(需 dws 可执行,建议 cd ~/.qoder/skills/dws):
# 1) 先试运行,只看命中哪些块,不改动
python3 scripts/redact_doc.py --node <文档节点ID> --rules rules.json --dry-run
# 2) 确认无误后正式脱敏
python3 scripts/redact_doc.py --node <文档节点ID> --rules rules.json
rules.json 是「正则→替换」数组(按序应用,replace 支持 1 反向引用):
[
{"pattern": "(余额|授信额度)[:: ]*¥[\d,\.]+", "replace": "\1:¥**,***.**"},
{"pattern": "¥\s*[\d,]+\.\d{2}", "replace": "¥**,***.**"},
{"pattern": "\b\d{16}\b", "replace": "<ACCOUNT_ID>"},
{"pattern": "LTAI[A-Za-z0-9]+", "replace": "<YOUR_ACCESS_KEY_ID>"}
]
关键坑(务必知道):文本/代码内容的真正源头在子节点 leaf(["span",{"data-type":"leaf"},"文字"]),不是元素属性。代码块虽有 code 属性,但 --fix-jsonml 会从 leaf 重新生成 code——只改属性无效。脚本已对 leaf 和 code 属性同时改写。更多见 reference.md。
脱敏后务必回读校验:dws doc read --node <id> --format json,确认真实数值已变为掩码、且截图/编号未受影响。
安全须知
- 永远从
0-wp.env读凭证,不要把应用密码写进文章、日志、Skill 或提交物。 - 发布前确认文章已脱敏(账号 ID、余额、密钥等)。可用上面的块级脱敏处理钉钉原文,本地 Markdown 直接改文件即可。
更多细节
- 逐图上传的 browser-use 调用范式、常见报错处理见 reference.md。