diff --git a/CHANGELOG.md b/CHANGELOG.md index 4917a77..726f537 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Added +- **Digest/relay 契约**:`orchestrator` 新增 `--digest`,默认读取 frontmatter `summary`,并在自动/手动 relay 推送命令中显式传给 `create_wechat_draft.mjs` - **发布证据持久化**:草稿 create/update + `draft/get` 后自动写入 `audit.log` 与 `push-result.json`;relay 路径把证据带回文章 `publish/vN/` - **CATALOG Backlink**:Orchestrator 可发现或显式接收 CATALOG/slug,成功后回写当前 id、`pushed-draft`、audit 指针,并保留被替换的旧 id - **Relay 对账**:新增 `reconcile_wechat_drafts.mjs`,区分 matched、stale、`published-or-deleted`、uncataloged 和 cover-missing;`--write` 只修复唯一 replacement candidate @@ -18,6 +19,7 @@ - **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 8d31711..9e6d9e1 100644 --- a/SKILL.md +++ b/SKILL.md @@ -28,7 +28,7 @@ metadata: `Skill: "khazix-writer"`。 ⚠️ 删尾部模板「投稿或爆料…」整行,替换身份。正文 > 800 字至少 1 张图。 -详细规则:`references/gotchas.md` #G01–#G05 + `references/writing-rules.md`。 +详细规则:`references/gotchas.md` #G01–#G05、#G34 + `references/writing-rules.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,10 +52,11 @@ 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。 -Gotchas:`references/gotchas.md` #G06–#G10。 +⚠️ `--env` 和 `--lint-report-out` 必须指定。`--footer-qr` 一律使用绝对路径。禁止绕路用 inline import 替换 CLI。 +Gotchas:`references/gotchas.md` #G06–#G10、#G35–#G36。 --- @@ -81,14 +85,15 @@ 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 \ --audit-out audit.log --push-result-out push-result.json" ``` -⚠️ 正式推送优先用 Orchestrator:它会把 relay 的 `audit.log` 和 -`push-result.json` 带回文章 `publish/vN/`,并在可唯一定位时回写 CATALOG。 +⚠️ 正式推送默认走 relay,并优先用 Orchestrator:它会显式传递 digest,把 +relay 的 `audit.log` 和 `push-result.json` 带回文章 `publish/vN/`,并在可 +唯一定位时回写 CATALOG。本机直推只有在确认当前 IP 已进微信白名单时才可用。 证据或 Backlink 不完整时即使微信已收稿也必须报非零。裁剪参数必须从 preflight 输出复制。标题 ≤ 21 中文字,digest ≤ 120 字。 -Gotchas:`references/gotchas.md` #G18–#G21、#G31–#G33。 +Gotchas:`references/gotchas.md` #G18–#G21、#G31–#G33、#G37–#G38。 --- diff --git a/harness/test-orchestrator-command-contract.mjs b/harness/test-orchestrator-command-contract.mjs index ff2acfc..a52166b 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。", sourcePath: "/tmp/article source.md", archiveDir: "/tmp/article-publish/v1", catalogPath: "/tmp/article repo/CATALOG.md", @@ -33,6 +38,7 @@ 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.match(result.remoteDraftCmd, /--audit-out 'audit\.log'/); assert.match(result.remoteDraftCmd, /--push-result-out 'push-result\.json'/); assert.match(result.remoteDraftCmd, /--source-path '\/tmp\/article source\.md'/); @@ -41,6 +47,15 @@ assert.match(result.command, /audit\.log/); assert.match(result.command, /update_wechat_catalog\.mjs/); 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 37afdd9..40ee74b 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` 取值 - [ ] `publish/vN/audit.log` 与 `push-result.json` 必须落盘;stdout 不算持久证据 - [ ] relay 推送必须把两份证据带回本地再报完整成功 - [ ] CATALOG 可唯一定位时自动回写当前 id/status/audit;旧 id 留在 Notes diff --git a/references/gotchas.md b/references/gotchas.md index b29d394..d1c0191 100644 --- a/references/gotchas.md +++ b/references/gotchas.md @@ -17,6 +17,8 @@ **Gotcha #G05**: `summary:` 是 GEO 第一依据 — 必须写成「AI 可直接引用的答案片段」(含核心结论 + 具体论据),不能只概括大意;≤ 120 字。 +**Gotcha #G34**: 连续参考资料 bullet 会被段落长度检查合并 — 「参考资料」这类连续 `- link` 列表,每条之间留一个空行;否则可能被 L1 超长段落误伤。 + --- ## Step 1:渲染 HTML @@ -31,6 +33,10 @@ **Gotcha #G10**: 卡片内部不支持表格 — `:::wechat-card` 里的 `|...|` 表格语法不会被解析,会被当成普通文本。表格必须放在卡片外部。 +**Gotcha #G35**: `--footer-qr` 必须传绝对路径 — 相对路径会以 HTML 输出目录再次拼接,可能生成双重相对路径,导致 footer QR 在 preflight 或 relay 上找不到。 + +**Gotcha #G36**: 手动 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 #G37**: 正式推送默认走 relay — 本机 IP 未进入微信白名单时会触发 `40164 invalid ip`;除非确认当前出口 IP 已加入白名单,否则不要本机直推。 + +**Gotcha #G38**: 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 6a03f75..e8efa9d 100644 --- a/scripts/orchestrator.mjs +++ b/scripts/orchestrator.mjs @@ -47,6 +47,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) @@ -123,6 +124,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"); @@ -226,6 +260,7 @@ export function buildManualRelayCommand({ slug, author, openComment, + digest = "", sourcePath = "", archiveDir = "", catalogPath = "", @@ -253,6 +288,7 @@ export function buildManualRelayCommand({ "--audit-out 'audit.log'", "--push-result-out 'push-result.json'", "--json", + ...(digest ? [`--digest ${shellQuote(digest)}`] : []), ...(thumbImage ? [`--thumb-image ${shellQuote(path.basename(thumbImage))}`] : []), ...(cropSpec ? [`--crop-235-1 ${shellQuote(cropSpec)}`] : []), ].join(" "); @@ -616,6 +652,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; @@ -675,6 +712,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}`); @@ -911,9 +949,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)} --source-path ${shellQuote(inputPath)} --audit-out ${shellQuote("audit.log")} --push-result-out ${shellQuote("push-result.json")} --json${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)} --source-path ${shellQuote(inputPath)} --audit-out ${shellQuote("audit.log")} --push-result-out ${shellQuote("push-result.json")} --json${remoteDigestArg}${remoteCropArg}`, ]; const pushResult = spawnSync("ssh", remoteCmd, { encoding: "utf8", stdio: "pipe", maxBuffer: 1024 * 1024 }); @@ -1012,6 +1051,7 @@ function main() { slug, author, openComment, + digest, sourcePath: inputPath, archiveDir, catalogPath,