From e4b5811450a5416608960e4b502211717fb0589a Mon Sep 17 00:00:00 2001 From: leether Date: Fri, 17 Jul 2026 13:53:22 +0800 Subject: [PATCH] feat: checkpoint digest relay contract Preserves the exact six-file dirty checkout before integration with PR #13. Change-ID: CHANGE-20260717-MD2WECHAT-DIGEST-RELAY-DIRTY-GOVERNANCE --- CHANGELOG.md | 5 +++ SKILL.md | 12 ++++-- .../test-orchestrator-command-contract.mjs | 17 +++++++- md2wechat.spec.md | 5 +++ references/gotchas.md | 12 +++++- scripts/orchestrator.mjs | 42 ++++++++++++++++++- 6 files changed, 86 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aeaf935..24a096f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,16 @@ ## [Unreleased] +### Added + +- **Digest/relay 契约**:`orchestrator` 新增 `--digest`,默认读取 frontmatter `summary`,并在自动/手动 relay 推送命令中显式传给 `create_wechat_draft.mjs` + ### Fixed - **Pipeline 自洽修补**:显式串联 render/preflight/orchestrator 的封面图和 `--skip-image-check` 参数,避免 `pre_image_missing` 隐式破坏默认入口 - **AutoHeal 阻断逻辑**:preflight JSON 改为完整解析,存在不可自动修复的 L1/Agent 失败时不再继续进入 bundle - **Bundle/relay 契约**:封面图纳入 bundle,relay 目录日期由本地生成,远程推送命令正确传递标题、作者、封面和 `--crop-235-1` +- **发布摩擦沉淀**:补充 footer QR 绝对路径、参考资料 bullet 空行、手动 render 图片路径和正式推送默认 relay 的 gotchas - **Preflight 误报收敛**:Markdown 卡片/表格计数跳过代码块和 inline code,source verification 只扫描可见文本数字 - **CI 覆盖**:`npm run check` 与 GitHub Lint workflow 覆盖 `harness/**/*.mjs` diff --git a/SKILL.md b/SKILL.md index ccc04e2..057ee99 100644 --- a/SKILL.md +++ b/SKILL.md @@ -37,10 +37,13 @@ metadata: ```bash node ${PIPELINE_HOME}/scripts/orchestrator.mjs \ --input article.md --account YOUR_ACCOUNT \ - --title "标题" --author "公众号作者" \ + --title "标题" --digest "摘要" --author "公众号作者" \ + --qr /abs/path/to/footer-qr.png \ --auto-fix --auto-push ``` +⚠️ 发布优先用 Orchestrator,不要手动拆 render/bundle/push。`--digest` 不传时会读取 frontmatter `summary` 并传到 relay;`--qr` 必须传绝对路径,避免 footer QR 在 render/preflight 间被拼成错误相对路径。 + --- ## Step 1:渲染 @@ -49,9 +52,10 @@ node ${PIPELINE_HOME}/scripts/orchestrator.mjs \ node ${PIPELINE_HOME}/scripts/render_wechat_editorial.mjs \ --input --output \ --env ${PIPELINE_HOME}/.env \ + --footer-qr /abs/path/to/footer-qr.png \ --lint-report-out ``` -⚠️ `--env` 和 `--lint-report-out` 必须指定。禁止绕路用 inline import 替换 CLI。 +⚠️ `--env` 和 `--lint-report-out` 必须指定。`--footer-qr` 一律使用绝对路径。禁止绕路用 inline import 替换 CLI。 Gotchas:`references/gotchas.md` #G06–#G10。 --- @@ -81,9 +85,9 @@ Gotchas:`references/gotchas.md` #G15–#G17。 ```bash ssh relay "cd && node create_wechat_draft.mjs \ --html article.html --thumb-image cover.png \ - --lint-report lint.json --title '标题' --account " + --lint-report lint.json --title '标题' --digest '摘要' --account " ``` -⚠️ 裁剪参数必须从 preflight 输出复制。标题 ≤ 21 中文字,digest ≤ 120 字。 +⚠️ 正式推送默认走 relay;本机直推只有在确认当前 IP 已进微信白名单时才可用。裁剪参数必须从 preflight 输出复制。标题 ≤ 21 中文字,digest ≤ 120 字。 Gotchas:`references/gotchas.md` #G18–#G21。 --- diff --git a/harness/test-orchestrator-command-contract.mjs b/harness/test-orchestrator-command-contract.mjs index e50af00..fa45093 100644 --- a/harness/test-orchestrator-command-contract.mjs +++ b/harness/test-orchestrator-command-contract.mjs @@ -3,7 +3,11 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { buildManualRelayCommand, resolvePipelinePaths } from "../scripts/orchestrator.mjs"; +import { + buildManualRelayCommand, + extractSummaryFromMarkdown, + resolvePipelinePaths, +} from "../scripts/orchestrator.mjs"; const result = buildManualRelayCommand({ relayHost: "relay-host", @@ -17,6 +21,7 @@ const result = buildManualRelayCommand({ slug: "slug", author: "公众号作者", openComment: "1", + digest: "短摘要:必须原样传到 relay。", thumbImage: "/tmp/cover.png", cropSpec: "0_0.0035_1_0.9965", envInBundle: true, @@ -29,8 +34,18 @@ assert.match(result.command, /scp '\/tmp\/wechat bundle\/\.env'/); assert.match(result.remoteDraftCmd, /--thumb-image 'cover\.png'/); assert.match(result.remoteDraftCmd, /--crop-235-1 '0_0\.0035_1_0\.9965'/); assert.match(result.remoteDraftCmd, /--title 'Title With Spaces'/); +assert.match(result.remoteDraftCmd, /--digest '短摘要:必须原样传到 relay。'/); assert.doesNotMatch(result.command, /\\scp|\\ssh|\\ node/); +assert.equal( + extractSummaryFromMarkdown("---\ntitle: Test\nsummary: \"frontmatter 摘要\"\n---\n# H1\n"), + "frontmatter 摘要", +); +assert.equal( + extractSummaryFromMarkdown("summary: legacy 摘要\n\n# H1\n"), + "legacy 摘要", +); + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "md2wechat-orchestrator-")); try { const articleDir = path.join(tmpRoot, "article"); diff --git a/md2wechat.spec.md b/md2wechat.spec.md index 303055f..7ee857b 100644 --- a/md2wechat.spec.md +++ b/md2wechat.spec.md @@ -63,11 +63,14 @@ md2wechat/ - [ ] 人格覆盖:替换「数字生命卡兹克」为用户的公众号身份 - [ ] 破折号 `——` 和中文双引号 `""` 必须在 Step 0 替换,不能等 lint 拦 - [ ] 插图检查:正文 > 800 字至少 1 张图,> 1500 字至少 2 张 +- [ ] 参考资料连续 bullet 每条之间留空行,避免被段落长度检查合并误伤 ### Step 1(渲染) - [ ] 禁止绕路:不能用 inline import 替换 CLI 渲染 - [ ] `--env` 必须显式指定,否则 footer 不注入 - [ ] `--lint-report-out` 必须指定,否则 Audit Log 缺【渲染质检】区块 +- [ ] `--footer-qr` 必须传绝对路径 +- [ ] 手动 render 时,正文图片必须能从 HTML 输出目录解析;发布优先走 orchestrator ### Step 2(图片) - [ ] 封面图必须 ≤ 2MB,超了用 `sips -Z` 压缩,覆盖原文件名 @@ -82,6 +85,8 @@ md2wechat/ - [ ] 裁剪参数必须从 preflight 输出中复制,不能猜测 - [ ] 标题 ≤ 21 个中文字(微信 64 字节限制) - [ ] digest(summary)≤ 120 字(微信 digest 限制) +- [ ] 正式推送默认走 relay,除非确认本机出口 IP 已在微信白名单 +- [ ] relay 远端命令必须显式传 `--digest`;orchestrator 应从 `--digest` 或 frontmatter `summary` 取值 ### Step 4(回检) - [ ] `errcode: 0` ≠ 完成,必须逐行核对 Audit Log diff --git a/references/gotchas.md b/references/gotchas.md index e981894..faa45cc 100644 --- a/references/gotchas.md +++ b/references/gotchas.md @@ -17,6 +17,8 @@ **Gotcha #G05**: `summary:` 是 GEO 第一依据 — 必须写成「AI 可直接引用的答案片段」(含核心结论 + 具体论据),不能只概括大意;≤ 120 字。 +**Gotcha #G31**: 连续参考资料 bullet 会被段落长度检查合并 — 「参考资料」这类连续 `- link` 列表,每条之间留一个空行;否则可能被 L1 超长段落误伤。 + --- ## Step 1:渲染 HTML @@ -31,6 +33,10 @@ **Gotcha #G10**: 卡片内部不支持表格 — `:::wechat-card` 里的 `|...|` 表格语法不会被解析,会被当成普通文本。表格必须放在卡片外部。 +**Gotcha #G32**: `--footer-qr` 必须传绝对路径 — 相对路径会以 HTML 输出目录再次拼接,可能生成双重相对路径,导致 footer QR 在 preflight 或 relay 上找不到。 + +**Gotcha #G33**: 手动 render 时图片必须能从 HTML 输出目录解析 — 如果 HTML 输出到 `publish/v1/article.html`,正文 `assets/*.png` 需要在 `publish/v1/assets/` 存在;更推荐直接用 orchestrator,让 bundle 处理路径替换。 + --- ## Step 2:准备图片 @@ -65,6 +71,10 @@ **Gotcha #G21**: `orchestrator --auto-push` 会自动处理版本号递增 — 手动推送时才需要查已有最大版本号,`v{N+1}` 递增,永不覆盖已有版本。 +**Gotcha #G34**: 正式推送默认走 relay — 本机 IP 未进入微信白名单时会触发 `40164 invalid ip`;除非确认当前出口 IP 已加入白名单,否则不要本机直推。 + +**Gotcha #G35**: relay 推送必须显式传 `--digest` — 不传时低层脚本会退回正文前 54 字,可能覆盖精心写好的摘要。Orchestrator 会从 `--digest` 或 frontmatter `summary` 读取并传到 relay。 + --- ## Step 4:回检验证 @@ -91,7 +101,7 @@ > `python3 -c "t='标题';print(len(t.encode('utf-8')))"` 预先算好再写 `--title`。 -**Gotcha #G28**: digest > 128 字节触发微信 API 45004 错误 — frontmatter `summary:` 会被脚本作为 digest 传给微信。≤ 128 字节(约 42 个中文字)。**最安全的做法**:始终显式传 `--digest`,不要依赖 frontmatter summary。 +**Gotcha #G28**: digest > 128 字节触发微信 API 45004 错误 — frontmatter `summary:` 会被脚本作为 digest 传给微信。≤ 128 字节(约 42 个中文字)。**最安全的做法**:让 Orchestrator 从 `--digest` 或 frontmatter `summary` 读取后显式传给 relay,不要依赖低层脚本自动取正文前 54 字。 > 实在想自动截断可在 Step 0 末尾用 `node -e "const s='...';console.log(s.slice(0,40))"` 预检。 diff --git a/scripts/orchestrator.mjs b/scripts/orchestrator.mjs index 1da97c3..7496b54 100644 --- a/scripts/orchestrator.mjs +++ b/scripts/orchestrator.mjs @@ -40,6 +40,7 @@ Required: Optional: --title Article title (overrides H1 in MD) + --digest Article digest; defaults to frontmatter summary --author Article author (default: WECHAT_DEFAULT_AUTHOR or \"公众号作者\") --env .env file path (default: ./.env) --out-dir Bundle output directory (default: /publish/vN/bundle) @@ -113,6 +114,39 @@ export function shellQuote(value = "") { return `'${String(value).replace(/'/g, "'\\''")}'`; } +function cleanYamlScalar(value = "") { + const trimmed = String(value || "").trim(); + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1).trim(); + } + return trimmed; +} + +export function extractSummaryFromMarkdown(markdown = "") { + const text = String(markdown || ""); + if (text.startsWith("---\n") || text.startsWith("---\r\n")) { + const lines = text.split(/\r?\n/); + for (let i = 1; i < lines.length; i++) { + if (lines[i].trim() === "---") break; + const match = lines[i].match(/^summary\s*:\s*(.+)$/i); + if (match) return cleanYamlScalar(match[1]); + } + } + const legacy = text.match(/^summary\s*:\s*(.+)$/im); + return legacy ? cleanYamlScalar(legacy[1]) : ""; +} + +export function extractSummaryFromFile(mdPath) { + try { + return extractSummaryFromMarkdown(fs.readFileSync(mdPath, "utf8")); + } catch { + return ""; + } +} + function formatLocalDateStamp(date = new Date()) { const yyyy = String(date.getFullYear()); const mm = String(date.getMonth() + 1).padStart(2, "0"); @@ -214,6 +248,7 @@ export function buildManualRelayCommand({ slug, author, openComment, + digest = "", thumbImage = null, cropSpec = null, envInBundle = false, @@ -230,6 +265,7 @@ export function buildManualRelayCommand({ `--author ${shellQuote(author)}`, `--account ${shellQuote(account)}`, `--open-comment ${shellQuote(openComment)}`, + ...(digest ? [`--digest ${shellQuote(digest)}`] : []), ...(thumbImage ? [`--thumb-image ${shellQuote(path.basename(thumbImage))}`] : []), ...(cropSpec ? [`--crop-235-1 ${shellQuote(cropSpec)}`] : []), ].join(" "); @@ -551,6 +587,7 @@ function main() { const title = args.title; const envPath = path.resolve(args.env || path.join(process.cwd(), ".env")); const author = args.author || readEnvVar(envPath, "WECHAT_DEFAULT_AUTHOR") || "公众号作者"; + const digest = args.digest ? String(args.digest).trim() : extractSummaryFromFile(inputPath); const dryRun = args["dry-run"] || false; const autoFix = args["auto-fix"] || false; const thumbImage = args["thumb-image"] ? path.resolve(args["thumb-image"]) : null; @@ -588,6 +625,7 @@ function main() { console.log(`${C.dim}Input: ${inputPath}${C.reset}`); console.log(`${C.dim}Account: ${account}${C.reset}`); console.log(`${C.dim}Author: ${author}${C.reset}`); + console.log(`${C.dim}Digest: ${digest ? `${digest.length} chars` : "(fallback to正文前54字)"}${C.reset}`); console.log(`${C.dim}Dry-run: ${dryRun}${C.reset}`); console.log(`${C.dim}Auto-fix: ${autoFix}${C.reset}`); console.log(`${C.dim}Archive: ${archiveDir}${C.reset}`); @@ -824,9 +862,10 @@ function main() { const remoteTitle = title || slug; const remoteThumbArg = thumbImage ? ` --thumb-image ${shellQuote(path.basename(thumbImage))}` : ""; const remoteCropArg = cropSpec ? ` --crop-235-1 ${shellQuote(cropSpec)}` : ""; + const remoteDigestArg = digest ? ` --digest ${shellQuote(digest)}` : ""; const remoteCmd = [ relayHost, - `cd ${shellQuote(remoteDir)} && node ${shellQuote(`${scriptsDir}/create_wechat_draft.mjs`)} --html ${shellQuote(path.basename(renderOut))}${remoteThumbArg} --lint-report ${shellQuote(path.basename(lintOut))} --title ${shellQuote(remoteTitle)} --author ${shellQuote(author)} --account ${shellQuote(account)} --open-comment ${shellQuote(openComment)}${remoteCropArg}`, + `cd ${shellQuote(remoteDir)} && node ${shellQuote(`${scriptsDir}/create_wechat_draft.mjs`)} --html ${shellQuote(path.basename(renderOut))}${remoteThumbArg} --lint-report ${shellQuote(path.basename(lintOut))} --title ${shellQuote(remoteTitle)} --author ${shellQuote(author)} --account ${shellQuote(account)} --open-comment ${shellQuote(openComment)}${remoteDigestArg}${remoteCropArg}`, ]; const pushResult = spawnSync("ssh", remoteCmd, { encoding: "utf8", stdio: "pipe", maxBuffer: 1024 * 1024 }); @@ -883,6 +922,7 @@ function main() { slug, author, openComment, + digest, thumbImage, cropSpec, envInBundle,