diff --git a/CHANGELOG.md b/CHANGELOG.md
index aeaf935..4917a77 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,12 @@
## [Unreleased]
+### Added
+
+- **发布证据持久化**:草稿 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
+
### Fixed
- **Pipeline 自洽修补**:显式串联 render/preflight/orchestrator 的封面图和 `--skip-image-check` 参数,避免 `pre_image_missing` 隐式破坏默认入口
diff --git a/README.md b/README.md
index 61d8c09..1ffffce 100644
--- a/README.md
+++ b/README.md
@@ -264,6 +264,19 @@ node scripts/orchestrator.mjs \
它会自动完成:创建远程目录 → SCP 上传 bundle(`.env` 单独传输)→ SSH 远程执行 `create_wechat_draft.mjs`。手动分步命令详见 SKILL.md Step 3。
+验证通过后,Orchestrator 必须把两份证据带回文章的同一
+`publish/vN/`:
+
+- `audit.log`:`draft/get` 回检后的可读审计
+- `push-result.json`:`media_id`、`thumb_media_id`、`errcode`、账号、
+ 推送时间、源稿路径和 verification 状态
+
+如果输入稿所在仓存在可唯一发现的 `CATALOG.md`,Orchestrator 会按
+文章目录推导 slug 并回写当前 `media_id`、`pushed-draft` 状态和 audit
+指针。可用 `--catalog` / `--catalog-slug` 显式指定;仓库确实没有 CATALOG
+契约时用 `--no-catalog`。微信已收稿但证据返回或 CATALOG 回写失败时,
+命令返回非零并保留可取得的 `push-result.json`,不能把它误报为完整成功。
+
### Step 4:回检验证
推送后 `create_wechat_draft.mjs` **自动从微信 API 拉回草稿** 输出 Audit Log。关键检查项:
@@ -279,6 +292,22 @@ node scripts/orchestrator.mjs \
> **必须逐行核验**:拿到 Audit Log 后,打开回检表格逐项比对。`errcode: 0` ≠ 完成。
+周期对账必须走 relay(本机出口 IP 未必在微信白名单):
+
+```bash
+node scripts/reconcile_wechat_drafts.mjs \
+ --catalog /absolute/article-repo/CATALOG.md \
+ --account YOUR_ACCOUNT \
+ --env /absolute/md2wechat/.env \
+ --date YYYY-MM-DD \
+ --report /absolute/reconcile-report.json
+```
+
+默认只读。仅在确认唯一标题匹配的替换 id 后,`--write` 才会修复
+CATALOG;真正从草稿箱消失的记录始终标为 `published-or-deleted`,不会因
+`freepublish` 返回 `48001` 或草稿消失而自动断言已发布。封面为空的草稿
+会在 `cover_missing` 中单列。
+
### Step 5:完成前核查(skill-compliance-harness)
**管线最后一道门。** 在所有步骤执行完毕、告知用户"完成"之前,先加载合规核查 skill:
diff --git a/SKILL.md b/SKILL.md
index ccc04e2..8d31711 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -81,20 +81,30 @@ 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 '标题' --account \
+ --audit-out audit.log --push-result-out push-result.json"
```
-⚠️ 裁剪参数必须从 preflight 输出复制。标题 ≤ 21 中文字,digest ≤ 120 字。
-Gotchas:`references/gotchas.md` #G18–#G21。
+⚠️ 正式推送优先用 Orchestrator:它会把 relay 的 `audit.log` 和
+`push-result.json` 带回文章 `publish/vN/`,并在可唯一定位时回写 CATALOG。
+证据或 Backlink 不完整时即使微信已收稿也必须报非零。裁剪参数必须从
+preflight 输出复制。标题 ≤ 21 中文字,digest ≤ 120 字。
+Gotchas:`references/gotchas.md` #G18–#G21、#G31–#G33。
---
## Step 4:回检
-推送成功后 Audit Log 自动输出。逐行核对 `references/audit-log.md` 中的检查项。
+推送成功后 Audit Log 自动落盘并可输出。逐行核对
+`references/audit-log.md` 中的检查项;结构化结果见同目录
+`push-result.json`。
```bash
node ${PIPELINE_HOME}/scripts/preflight-check.mjs --file --require-render-qa
+
+node ${PIPELINE_HOME}/scripts/reconcile_wechat_drafts.mjs \
+ --catalog --account --env ${PIPELINE_HOME}/.env
```
-Gotchas:`references/gotchas.md` #G22–#G24。
+对账默认只读且经 relay;草稿消失只标 `published-or-deleted`,封面空值单列。
+Gotchas:`references/gotchas.md` #G22–#G24、#G31–#G33。
---
diff --git a/examples/sample-push-result.json b/examples/sample-push-result.json
new file mode 100644
index 0000000..4b3a2b8
--- /dev/null
+++ b/examples/sample-push-result.json
@@ -0,0 +1,41 @@
+{
+ "schema_version": "md2wechat-push-result/v1",
+ "media_id": "sample-current-media-id",
+ "thumb_media_id": "sample-cover-media-id",
+ "errcode": 0,
+ "errmsg": "ok",
+ "account": "YOUR_ACCOUNT",
+ "pushed_at": "2026-07-17T08:00:00.000Z",
+ "source_path": "/absolute/article/path/article.md",
+ "html_path": "/relay/workdir/article.html",
+ "title": "示例文章",
+ "operation": "draft/add",
+ "completion_status": "verified",
+ "verification": {
+ "status": "passed",
+ "checked_at": "2026-07-17T08:00:02.000Z",
+ "metrics": {
+ "imgCount": 3,
+ "cdnCount": 3,
+ "h2Count": 5,
+ "cardCount": 1,
+ "darkCardCount": 1,
+ "lightCardCount": 0,
+ "quoteCount": 1,
+ "styleCount": 98,
+ "positionCount": 0,
+ "filterCount": 0
+ }
+ },
+ "evidence": {
+ "audit_path": "/absolute/article/path/publish/v3/audit.log",
+ "push_result_path": "/absolute/article/path/publish/v3/push-result.json"
+ },
+ "backlink": {
+ "status": "updated",
+ "catalog_path": "/absolute/article-repo/CATALOG.md",
+ "catalog_slug": "article-slug",
+ "old_media_id": "sample-superseded-media-id",
+ "audit_pointer": "publish/v3/audit.log"
+ }
+}
diff --git a/harness/test-orchestrator-command-contract.mjs b/harness/test-orchestrator-command-contract.mjs
index e50af00..ff2acfc 100644
--- a/harness/test-orchestrator-command-contract.mjs
+++ b/harness/test-orchestrator-command-contract.mjs
@@ -17,6 +17,10 @@ const result = buildManualRelayCommand({
slug: "slug",
author: "公众号作者",
openComment: "1",
+ sourcePath: "/tmp/article source.md",
+ archiveDir: "/tmp/article-publish/v1",
+ catalogPath: "/tmp/article repo/CATALOG.md",
+ catalogSlug: "enterprise-ai-carrier",
thumbImage: "/tmp/cover.png",
cropSpec: "0_0.0035_1_0.9965",
envInBundle: true,
@@ -29,6 +33,12 @@ 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, /--audit-out 'audit\.log'/);
+assert.match(result.remoteDraftCmd, /--push-result-out 'push-result\.json'/);
+assert.match(result.remoteDraftCmd, /--source-path '\/tmp\/article source\.md'/);
+assert.match(result.command, /push-result\.json/);
+assert.match(result.command, /audit\.log/);
+assert.match(result.command, /update_wechat_catalog\.mjs/);
assert.doesNotMatch(result.command, /\\scp|\\ssh|\\ node/);
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "md2wechat-orchestrator-"));
@@ -43,6 +53,8 @@ try {
assert.equal(first.outDir, path.join(articleDir, "publish", "v1", "bundle"));
assert.equal(first.renderOut, path.join(articleDir, "publish", "v1", "enterprise-ai-carrier.html"));
assert.equal(first.lintOut, path.join(articleDir, "publish", "v1", "enterprise-ai-carrier-lint.json"));
+ assert.equal(first.auditOut, path.join(articleDir, "publish", "v1", "audit.log"));
+ assert.equal(first.pushResultOut, path.join(articleDir, "publish", "v1", "push-result.json"));
fs.mkdirSync(path.join(articleDir, "publish", "v1"), { recursive: true });
const second = resolvePipelinePaths({ inputPath: articlePath });
diff --git a/harness/test-publish-evidence-contract.mjs b/harness/test-publish-evidence-contract.mjs
new file mode 100644
index 0000000..11ad08b
--- /dev/null
+++ b/harness/test-publish-evidence-contract.mjs
@@ -0,0 +1,101 @@
+#!/usr/bin/env node
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import {
+ deriveCatalogSlug,
+ discoverCatalogPath,
+ finalizePushResultBacklink,
+ inspectCatalogTarget,
+ updateCatalogAfterPush,
+} from "../scripts/lib/publish-evidence.mjs";
+import { persistVerifiedDraftEvidence } from "../scripts/create_wechat_draft.mjs";
+
+const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "md2wechat-evidence-"));
+try {
+ const articleDir = path.join(tmpRoot, "articles", "2026", "2026-07-17-contract-test");
+ const publishDir = path.join(articleDir, "publish", "v2");
+ const sourcePath = path.join(articleDir, "article.md");
+ const auditPath = path.join(publishDir, "audit.log");
+ const pushResultPath = path.join(publishDir, "push-result.json");
+ const catalogPath = path.join(tmpRoot, "CATALOG.md");
+ fs.mkdirSync(publishDir, { recursive: true });
+ fs.writeFileSync(sourcePath, "# Contract test\n", "utf8");
+ const persisted = persistVerifiedDraftEvidence({
+ audit: {
+ timestamp: "2026-07-17T08:00:00.000Z",
+ source: { mdPath: sourcePath, htmlPath: path.join(publishDir, "article.html"), title: "Contract Test", author: "Author", account: "XINZHE" },
+ assets: [],
+ lintReport: null,
+ push: { status: "ok", mediaId: "new-media-id-12345678901234567890", thumbMediaId: "thumb-id" },
+ verify: { imgCount: 1, cdnCount: 1, h2Count: 1, cardCount: 0, darkCardCount: 0, lightCardCount: 0, quoteCount: 0, styleCount: 60, positionCount: 0, filterCount: 0 },
+ },
+ pushResult: {
+ schema_version: "md2wechat-push-result/v1",
+ media_id: "new-media-id-12345678901234567890",
+ thumb_media_id: "thumb-id",
+ errcode: 0,
+ completion_status: "verified",
+ verification: { status: "passed" },
+ },
+ auditOutPath: auditPath,
+ pushResultOutPath: pushResultPath,
+ });
+ assert.equal(persisted.auditPath, auditPath);
+ assert.equal(persisted.pushResultPath, pushResultPath);
+ assert.match(fs.readFileSync(auditPath, "utf8"), /media_id: new-media-id-12345678901234567890/);
+ fs.writeFileSync(catalogPath, [
+ "# Article Catalog",
+ "",
+ "| Date | Slug | Title | Status | Draftbox | WeChat media_id | Notes |",
+ "| --- | --- | --- | --- | --- | --- | --- |",
+ "| 2026-07-17 | `contract-test` | Contract Test | drafting | 未推送草稿箱 | `old-media-id-12345678901234567890` | 初始备注 |",
+ "",
+ ].join("\n"), "utf8");
+
+ assert.equal(discoverCatalogPath(sourcePath), catalogPath);
+ assert.equal(deriveCatalogSlug(sourcePath), "contract-test");
+ assert.equal(inspectCatalogTarget({ catalogPath, slug: "contract-test" }).row.status, "drafting");
+
+ const update = updateCatalogAfterPush({
+ catalogPath,
+ slug: "contract-test",
+ mediaId: "new-media-id-12345678901234567890",
+ account: "XINZHE",
+ sourcePath,
+ auditPath,
+ });
+ assert.equal(update.changed, true);
+ assert.equal(update.old_media_id, "old-media-id-12345678901234567890");
+
+ const catalog = fs.readFileSync(catalogPath, "utf8");
+ assert.match(catalog, /\| pushed-draft \|/);
+ assert.match(catalog, /`new-media-id-12345678901234567890`/);
+ assert.match(catalog, /回执见 `publish\/v2\/audit\.log`/);
+ assert.match(catalog, /原 media_id `old-media-id-12345678901234567890` 已失效并更新/);
+
+ const finalResult = finalizePushResultBacklink({ pushResultPath, auditPath, catalogUpdate: update });
+ assert.equal(finalResult.evidence.audit_path, auditPath);
+ assert.equal(finalResult.evidence.push_result_path, pushResultPath);
+ assert.equal(finalResult.backlink.catalog_slug, "contract-test");
+
+ const second = updateCatalogAfterPush({
+ catalogPath,
+ slug: "contract-test",
+ mediaId: "new-media-id-12345678901234567890",
+ account: "XINZHE",
+ sourcePath,
+ auditPath,
+ });
+ assert.equal(second.changed, false);
+
+ assert.throws(
+ () => inspectCatalogTarget({ catalogPath, slug: "missing" }),
+ /matched 0 rows/,
+ );
+} finally {
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
+}
+
+console.log("Publish evidence contract passed.");
diff --git a/harness/test-reconcile-wechat-drafts.mjs b/harness/test-reconcile-wechat-drafts.mjs
new file mode 100644
index 0000000..3933f4b
--- /dev/null
+++ b/harness/test-reconcile-wechat-drafts.mjs
@@ -0,0 +1,111 @@
+#!/usr/bin/env node
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import {
+ applyReconciliationRepairs,
+ buildReconciliationReport,
+} from "../scripts/reconcile_wechat_drafts.mjs";
+
+const workerSource = fs.readFileSync(
+ new URL("../scripts/lib/wechat-reconcile-worker.mjs", import.meta.url),
+ "utf8",
+);
+assert.match(workerSource, /await fetch\(/);
+assert.doesNotMatch(workerSource, /execFileSync|WECHAT_.*APP_SECRET.*console/);
+
+const catalog = [
+ "# Article Catalog",
+ "",
+ "| Date | Slug | Title | Status | Draftbox | WeChat media_id | Notes |",
+ "| --- | --- | --- | --- | --- | --- | --- |",
+ "| 2026-07-16 | `symbiotic-writing` | 花了一天跟 AI 讨论代码,我们碰撞出了灵感 | pushed-draft | 已推送新褶草稿箱 | `id-symbiotic` | audit |",
+ "| 2026-07-16 | `weknora-agent-programming` | 当 LLM 开始写代码——Agent 编程的 10 条安全底线 | pushed-draft | 已推送新褶草稿箱 | `id-agent` | audit |",
+ "| 2026-07-16 | `weknora-taste` | WeKnora 代码里的四种品味 | pushed-draft | 已推送新褶草稿箱 | `id-taste` | audit |",
+ "| 2026-07-16 | `weknora-rag-deepdive` | 手把手拆一个生产级 RAG 系统 | pushed-draft | 已推送新褶草稿箱 | `id-rag` | audit |",
+ "| 2026-07-07 | `older-cataloged` | Older Cataloged Draft | pushed-draft | 已推送草稿箱 | `id-older` | audit |",
+ "| 2026-07-16 | `already-published` | Published Draft | published | 已发布 | `id-published` | manual |",
+ "",
+].join("\n");
+
+const snapshot = {
+ account: "XINZHE",
+ queried_at: "2026-07-17T12:00:00.000Z",
+ draft_total: 7,
+ freepublish: { available: false, errcode: 48001 },
+ drafts: [
+ { media_id: "id-symbiotic", title: "花了一天跟AI讨论代码,我们碰撞出了灵感", thumb_media_id: "thumb-ok" },
+ { media_id: "id-agent", title: "当LLM开始写代码——Agent编程的10条安全底线", thumb_media_id: "" },
+ { media_id: "id-taste", title: "WeKnora代码里的四种品味", thumb_media_id: "" },
+ { media_id: "id-rag", title: "手把手拆一个生产级RAG系统", thumb_media_id: "" },
+ { media_id: "id-older", title: "Older Cataloged Draft", thumb_media_id: "thumb-old" },
+ { media_id: "external-1", title: "External One", thumb_media_id: "thumb-e1" },
+ { media_id: "external-2", title: "External Two", thumb_media_id: "thumb-e2" },
+ ],
+};
+
+const report = buildReconciliationReport({
+ catalogMarkdown: catalog,
+ catalogPath: "/tmp/CATALOG.md",
+ snapshot,
+ account: "XINZHE",
+ date: "2026-07-16",
+});
+
+assert.equal(report.summary.matched, 4);
+assert.equal(report.summary.stale_ids, 0);
+assert.equal(report.summary.invalid_ids, 0);
+assert.equal(report.summary.absent_from_draftbox, 0);
+assert.equal(report.summary.uncataloged_drafts, 2);
+assert.equal(report.summary.cover_missing_selected, 3);
+assert.equal(report.publication_inference.allowed, false);
+assert.equal(report.live_snapshot.freepublish.errcode, 48001);
+
+const driftedCatalog = catalog.replace("`id-taste`", "`old-id-taste`");
+const staleReport = buildReconciliationReport({
+ catalogMarkdown: driftedCatalog,
+ snapshot,
+ account: "XINZHE",
+ slugs: ["weknora-taste", "already-published"],
+});
+assert.equal(staleReport.summary.stale_ids, 1);
+assert.equal(staleReport.stale_ids[0].replacement_media_id, "id-taste");
+assert.equal(staleReport.absent_from_draftbox.length, 0);
+
+const absentReport = buildReconciliationReport({
+ catalogMarkdown: catalog.replace("| published |", "| pushed-draft |"),
+ snapshot,
+ account: "XINZHE",
+ slugs: ["already-published"],
+});
+assert.equal(absentReport.summary.absent_from_draftbox, 1);
+assert.equal(absentReport.absent_from_draftbox[0].state, "published-or-deleted");
+assert.equal(absentReport.absent_from_draftbox[0].publication_assertion, "forbidden");
+
+const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "md2wechat-reconcile-write-"));
+try {
+ const catalogPath = path.join(tmpRoot, "CATALOG.md");
+ const articleDir = path.join(tmpRoot, "articles", "2026", "2026-07-16-weknora-taste");
+ const auditPath = path.join(articleDir, "publish", "v1", "audit.log");
+ fs.mkdirSync(path.dirname(auditPath), { recursive: true });
+ fs.writeFileSync(catalogPath, driftedCatalog, "utf8");
+ fs.writeFileSync(auditPath, "media_id: id-taste\n", "utf8");
+ const writeReport = buildReconciliationReport({
+ catalogMarkdown: driftedCatalog,
+ catalogPath,
+ snapshot,
+ account: "XINZHE",
+ slugs: ["weknora-taste"],
+ });
+ const repairs = applyReconciliationRepairs({ report: writeReport, catalogPath, account: "XINZHE" });
+ assert.equal(repairs.length, 1);
+ const repairedCatalog = fs.readFileSync(catalogPath, "utf8");
+ assert.match(repairedCatalog, /`id-taste`/);
+ assert.match(repairedCatalog, /原 media_id `old-id-taste` 已失效并更新/);
+ assert.match(repairedCatalog, /回执见 `publish\/v1\/audit\.log`/);
+} finally {
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
+}
+
+console.log("WeChat draft reconciliation contract passed.");
diff --git a/md2wechat.spec.md b/md2wechat.spec.md
index 303055f..37afdd9 100644
--- a/md2wechat.spec.md
+++ b/md2wechat.spec.md
@@ -82,10 +82,16 @@ md2wechat/
- [ ] 裁剪参数必须从 preflight 输出中复制,不能猜测
- [ ] 标题 ≤ 21 个中文字(微信 64 字节限制)
- [ ] digest(summary)≤ 120 字(微信 digest 限制)
+- [ ] `publish/vN/audit.log` 与 `push-result.json` 必须落盘;stdout 不算持久证据
+- [ ] relay 推送必须把两份证据带回本地再报完整成功
+- [ ] CATALOG 可唯一定位时自动回写当前 id/status/audit;旧 id 留在 Notes
### Step 4(回检)
- [ ] `errcode: 0` ≠ 完成,必须逐行核对 Audit Log
- [ ] 缺少【渲染质检】区块 = lint report 没生成,回到 Step 1 补
+- [ ] 对账 API 必须经 relay;默认只读,`--write` 只修复唯一 replacement candidate
+- [ ] 草稿消失标 `published-or-deleted`,不得自动断言 published
+- [ ] `thumb_media_id` 为空在 cover-missing 清单单列
### Step 5(完成前核查)
- [ ] skill-compliance-harness 不得跳过
@@ -183,6 +189,8 @@ description: >
### 功能验收
- [ ] 用一篇测试文章完整走完 Step 0–5,所有步骤正常
- [ ] Audit Log 解析脚本输出与手动核对结果一致
+- [ ] push-result schema、CATALOG 回写与 rebuild 旧 id 备注 fixture 通过
+- [ ] reconcile fixture 覆盖 matched/stale/absent/uncataloged/cover-missing 和 48001 二义性
- [ ] skill-compliance-harness 能正确加载并核验
### 回归验收
diff --git a/package.json b/package.json
index 48ed609..a822c89 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,7 @@
"scripts": {
"render": "node scripts/render_wechat_editorial.mjs",
"publish": "node scripts/create_wechat_draft.mjs",
- "check": "find scripts harness -name '*.mjs' -print | sort | xargs -n1 node --check && node harness/test-code-generator-contract.mjs && node harness/test-preflight-fixtures.mjs && node harness/test-orchestrator-command-contract.mjs && node harness/test-self-report-no-write.mjs && node harness/run-generated-check-tests.mjs"
+ "check": "find scripts harness -name '*.mjs' -print | sort | xargs -n1 node --check && node harness/test-code-generator-contract.mjs && node harness/test-preflight-fixtures.mjs && node harness/test-orchestrator-command-contract.mjs && node harness/test-publish-evidence-contract.mjs && node harness/test-reconcile-wechat-drafts.mjs && node harness/test-self-report-no-write.mjs && node harness/run-generated-check-tests.mjs"
},
"engines": {
"node": ">=18.0.0"
diff --git a/references/audit-log.md b/references/audit-log.md
index 2adf671..6cce059 100644
--- a/references/audit-log.md
+++ b/references/audit-log.md
@@ -45,6 +45,18 @@ HTML:
> ⚠️ 缺少【渲染质检】区块 = `--lint-report` 没传或 lint.json 没生成 → 回到 Step 1 补。
+同一次推送还必须存在同目录 `push-result.json`。标准字段见
+`examples/sample-push-result.json`。只有以下条件同时满足才是完整成功:
+
+- `errcode = 0`
+- `verification.status = passed`
+- `completion_status = verified`
+- `audit.log` 与 `push-result.json` 已回到本地文章 `publish/vN/`
+- 配置了 CATALOG 时,Backlink 已写入当前 id / status / audit 指针
+
+如果微信已创建草稿但上述任一步失败,状态是
+`draft-created-evidence-incomplete`,不是成功,也不是自动回滚。
+
---
## 回检必须通过的检查项
@@ -91,3 +103,12 @@ HTML:
| `position: N`(N > 0) | HTML 含 `position:` 属性,会被微信过滤 | 回到 Step 1,检查 lint 报告,移除 position |
| `filter: N`(N > 0) | HTML 含 `filter:` 属性,兼容性不稳定 | 回到 Step 1,移除 filter |
| 缺少【渲染质检】区块 | `--lint-report` 没传或 lint.json 没生成 | 回到 Step 1,指定 `--lint-report-out`,重新渲染 |
+
+## 草稿箱对账语义
+
+- API 查询经 `WECHAT_RELAY_*` 指向的 relay 执行
+- `CATALOG.md` 是投影,草稿箱快照是 live truth
+- 草稿仍在但 id 被替换:`stale_ids`,可在唯一标题匹配时用 `--write` 修复
+- 草稿不在:`published-or-deleted`,禁止自动断言 published
+- 草稿无 `thumb_media_id`:单列为 cover gap
+- 草稿在箱但无任何 CATALOG id/title:单列为 uncataloged draft
diff --git a/references/gotchas.md b/references/gotchas.md
index e981894..b29d394 100644
--- a/references/gotchas.md
+++ b/references/gotchas.md
@@ -105,6 +105,12 @@
**Gotcha #G30**: frontmatter `author:` 会泄漏到微信推送 — create_wechat_draft.mjs 读取 author 字段写入微信草稿。写个人名字会暴露身份,写公众号名称不符合微信规范。**强制规则**:`author:` 必须为空、`公众号名称`、或与账号 ID 一致——不允许写个人名字。
+**Gotcha #G31**: stdout 不是发布证据 — 成功 push 必须在文章 `publish/vN/` 同时留下 `audit.log` 与 `push-result.json`;relay 推送必须把两份文件带回本地再报完整成功。
+
+**Gotcha #G32**: 草稿消失不等于已发布 — `freepublish` 对无权限账号可返回 `48001`,因此对账只能标 `published-or-deleted`,等待人工或权威证据确认。
+
+**Gotcha #G33**: 对账不得从未入白名单的本机直连微信 API — 使用 `reconcile_wechat_drafts.mjs` 经 `WECHAT_RELAY_*` 执行,`--write` 只修复唯一标题匹配的 stale id,封面空值单列。
+
## 新增 Gotcha 记录模板
```markdown
diff --git a/scripts/create_wechat_draft.mjs b/scripts/create_wechat_draft.mjs
index 7116902..cddbd0d 100755
--- a/scripts/create_wechat_draft.mjs
+++ b/scripts/create_wechat_draft.mjs
@@ -277,13 +277,17 @@ function getStableAccessToken({ appId, appSecret }) {
// ── Audit Log: 推送后自动回检 ──
-function fetchDraftContent(accessToken, mediaId) {
+function fetchDraftRecord(accessToken, mediaId) {
const url = `https://api.weixin.qq.com/cgi-bin/draft/get?access_token=${encodeURIComponent(accessToken)}`;
const result = postJson(url, { media_id: mediaId });
if (result.errcode && result.errcode !== 0) {
throw new Error(`draft/get failed: ${JSON.stringify(result)}`);
}
- return result.news_item?.[0]?.content || "";
+ const record = result.news_item?.[0];
+ if (!record) {
+ throw new Error(`draft/get returned no news_item for media_id ${mediaId}`);
+ }
+ return record;
}
function analyzeDraftContent(content) {
@@ -386,7 +390,7 @@ function formatLintSection(report) {
return lines;
}
-function formatAuditLog(audit) {
+export function formatAuditLog(audit) {
const lines = [
"",
"━━━ md2wechat Audit Log ━━━",
@@ -435,6 +439,45 @@ function formatAuditLog(audit) {
return lines.join("\n");
}
+function sanitizeEvidenceError(error) {
+ return String(error?.message || error || "unknown error")
+ .replace(/access_token=[^&\s]+/gi, "access_token=")
+ .slice(0, 500);
+}
+
+function writeAtomicText(targetPath, content) {
+ const absolute = path.resolve(targetPath);
+ fs.mkdirSync(path.dirname(absolute), { recursive: true });
+ const tempPath = `${absolute}.${process.pid}.${Date.now()}.tmp`;
+ fs.writeFileSync(tempPath, content, "utf8");
+ fs.renameSync(tempPath, absolute);
+ return absolute;
+}
+
+function writeAtomicJson(targetPath, value) {
+ return writeAtomicText(targetPath, `${JSON.stringify(value, null, 2)}\n`);
+}
+
+export function persistVerifiedDraftEvidence({
+ audit,
+ pushResult,
+ auditOutPath,
+ pushResultOutPath,
+}) {
+ if (pushResult?.verification?.status !== "passed" || pushResult?.completion_status !== "verified") {
+ throw new Error("refusing to persist verified evidence for an unverified push result");
+ }
+ const auditText = `${formatAuditLog(audit).trimStart()}\n`;
+ writeAtomicText(auditOutPath, auditText);
+ writeAtomicJson(pushResultOutPath, pushResult);
+ return {
+ auditPath: path.resolve(auditOutPath),
+ pushResultPath: path.resolve(pushResultOutPath),
+ auditText,
+ pushResult,
+ };
+}
+
function guessMimeType(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === ".png") {
@@ -1335,15 +1378,36 @@ export function resolveDraftPlan(argv = process.argv.slice(2)) {
keepInlineStyles: Boolean(args["keep-inline-styles"]),
});
+ const evidenceDir = args["evidence-dir"]
+ ? path.resolve(process.cwd(), String(args["evidence-dir"]))
+ : absoluteHtmlPath
+ ? path.dirname(absoluteHtmlPath)
+ : process.cwd();
+ const auditOutPath = args["audit-out"]
+ ? path.resolve(process.cwd(), String(args["audit-out"]))
+ : path.join(evidenceDir, "audit.log");
+ const pushResultOutPath = args["push-result-out"]
+ ? path.resolve(process.cwd(), String(args["push-result-out"]))
+ : path.join(evidenceDir, "push-result.json");
+
return {
help: false,
dryRun: Boolean(args["dry-run"]),
+ jsonOutput: Boolean(args.json),
preflight: Boolean(args.preflight),
isPictureDraft,
account: args.account ? String(args.account).trim() : "",
envPath: args.env ? path.resolve(process.cwd(), args.env) : defaultEnvPath,
htmlPath: absoluteHtmlPath,
sourceMdPath: args["source-md"] ? path.resolve(process.cwd(), args["source-md"]) : "",
+ sourcePath: args["source-path"]
+ ? String(args["source-path"]).trim()
+ : args["source-md"]
+ ? path.resolve(process.cwd(), args["source-md"])
+ : absoluteHtmlPath,
+ evidenceDir,
+ auditOutPath,
+ pushResultOutPath,
thumbImagePath,
pictureImagePaths: imagePaths.map((imagePath) => path.resolve(process.cwd(), imagePath)),
lintReportPath: args["lint-report"] ? path.resolve(process.cwd(), String(args["lint-report"])) : "",
@@ -1375,6 +1439,7 @@ export async function createWechatDraft(argv = process.argv.slice(2)) {
"Optional metadata:",
" --title Article title; overrides --source-md",
" --source-md Read first H1 from Markdown as title",
+ " --source-path Original source path recorded in push-result.json",
" --author Article author",
" --digest Article digest; defaults to正文前54字",
" --description Picture draft caption text; falls back to --content or digest",
@@ -1390,6 +1455,10 @@ export async function createWechatDraft(argv = process.argv.slice(2)) {
" --env Override .env path",
" --keep-inline-styles Keep existing style= attrs instead of compacting HTML",
" --lint-report Structured lint report JSON from render_wechat_editorial.mjs",
+ " --evidence-dir Evidence directory (default: HTML directory)",
+ " --audit-out Durable audit log path (default: /audit.log)",
+ " --push-result-out Structured push result path (default: /push-result.json)",
+ " --json Emit only structured result JSON on stdout",
" --preflight Run publish checks and print a structured report",
" --dry-run Print resolved payload without calling WeChat",
" --help Show help",
@@ -1415,6 +1484,11 @@ export async function createWechatDraft(argv = process.argv.slice(2)) {
envPath: plan.envPath,
htmlPath: plan.htmlPath,
sourceMdPath: plan.sourceMdPath || undefined,
+ sourcePath: plan.sourcePath || undefined,
+ evidence: {
+ auditPath: plan.auditOutPath,
+ pushResultPath: plan.pushResultOutPath,
+ },
thumbImagePath: plan.thumbImagePath || undefined,
preflight: preflightReport,
payload: plan.payload,
@@ -1471,31 +1545,49 @@ export async function createWechatDraft(argv = process.argv.slice(2)) {
throw new Error(`WeChat ${action} failed: ${JSON.stringify(result)}`);
}
- console.log(
- JSON.stringify(
- {
- media_id: result.media_id || "",
- errcode: result.errcode || 0,
- errmsg: result.errmsg || "ok",
- title: plan.payload.articles[0].title,
- account: credentials.account,
- thumb_media_id: plan.payload.articles[0].thumb_media_id,
- htmlPath: plan.htmlPath,
- },
- null,
- 2,
- ),
- );
+ const effectiveMediaId = result.media_id || plan.updateMediaId || "";
+ const pushedAt = new Date().toISOString();
+ const pushResult = {
+ schema_version: "md2wechat-push-result/v1",
+ media_id: effectiveMediaId,
+ thumb_media_id: plan.payload.articles[0].thumb_media_id || "",
+ errcode: result.errcode || 0,
+ errmsg: result.errmsg || "ok",
+ account: credentials.account,
+ pushed_at: pushedAt,
+ source_path: plan.sourcePath || "",
+ html_path: plan.htmlPath || "",
+ title: plan.payload.articles[0].title,
+ operation: isUpdate ? "draft/update" : "draft/add",
+ completion_status: "draft-created-evidence-pending",
+ verification: {
+ status: "pending",
+ checked_at: "",
+ },
+ evidence: {
+ audit_path: plan.auditOutPath,
+ push_result_path: plan.pushResultOutPath,
+ },
+ };
+
+ try {
+ writeAtomicJson(plan.pushResultOutPath, pushResult);
+ } catch {
+ // Continue to read back the draft; the final evidence write remains authoritative.
+ }
- // ── 输出 Audit Log(推送后自动回检) ──
+ // ── 输出并持久化 Audit Log(推送后自动回检) ──
try {
- const content = fetchDraftContent(accessToken, result.media_id);
- const verify = analyzeDraftContent(content);
+ const draftRecord = fetchDraftRecord(accessToken, effectiveMediaId);
+ const verify = analyzeDraftContent(draftRecord.content || "");
const assets = getAssetList(plan.htmlPath);
+ const verifiedThumbMediaId = Object.hasOwn(draftRecord, "thumb_media_id")
+ ? draftRecord.thumb_media_id || ""
+ : plan.payload.articles[0].thumb_media_id || "";
const audit = {
- timestamp: new Date().toISOString(),
+ timestamp: pushedAt,
source: {
- mdPath: plan.sourceMdPath,
+ mdPath: plan.sourcePath,
htmlPath: plan.htmlPath,
title: plan.payload.articles[0].title,
author: plan.payload.articles[0].author,
@@ -1505,14 +1597,43 @@ export async function createWechatDraft(argv = process.argv.slice(2)) {
lintReport: readLintReport(plan.lintReportPath),
push: {
status: result.errmsg || "ok",
- mediaId: result.media_id,
- thumbMediaId: plan.payload.articles[0].thumb_media_id,
+ mediaId: effectiveMediaId,
+ thumbMediaId: verifiedThumbMediaId,
},
verify,
};
- console.log(formatAuditLog(audit));
+ pushResult.thumb_media_id = verifiedThumbMediaId;
+ pushResult.completion_status = "verified";
+ pushResult.verification = {
+ status: "passed",
+ checked_at: new Date().toISOString(),
+ metrics: verify,
+ };
+ const persisted = persistVerifiedDraftEvidence({
+ audit,
+ pushResult,
+ auditOutPath: plan.auditOutPath,
+ pushResultOutPath: plan.pushResultOutPath,
+ });
+
+ console.log(JSON.stringify(pushResult, null, 2));
+ if (!plan.jsonOutput) console.log(persisted.auditText.trimEnd());
} catch (auditErr) {
- console.warn("⚠️ Audit log 生成失败:", auditErr.message);
+ pushResult.completion_status = "draft-created-evidence-incomplete";
+ pushResult.verification = {
+ status: "failed",
+ checked_at: new Date().toISOString(),
+ error: sanitizeEvidenceError(auditErr),
+ };
+ try {
+ writeAtomicJson(plan.pushResultOutPath, pushResult);
+ } catch {
+ // The original persistence error is reported below without exposing credentials.
+ }
+ if (plan.jsonOutput) console.log(JSON.stringify(pushResult, null, 2));
+ throw new Error(
+ `WeChat draft ${effectiveMediaId || ""} was created, but durable verification evidence is incomplete: ${sanitizeEvidenceError(auditErr)}`,
+ );
}
return 0;
diff --git a/scripts/lib/publish-evidence.mjs b/scripts/lib/publish-evidence.mjs
new file mode 100644
index 0000000..6585424
--- /dev/null
+++ b/scripts/lib/publish-evidence.mjs
@@ -0,0 +1,318 @@
+import fs from "node:fs";
+import path from "node:path";
+
+const REQUIRED_CATALOG_COLUMNS = [
+ "Date",
+ "Slug",
+ "Title",
+ "Status",
+ "Draftbox",
+ "WeChat media_id",
+ "Notes",
+];
+
+function splitMarkdownRow(line) {
+ const trimmed = String(line || "").trim();
+ if (!trimmed.startsWith("|") || !trimmed.endsWith("|")) return null;
+
+ const cells = [];
+ let current = "";
+ let escaped = false;
+ for (const char of trimmed.slice(1, -1)) {
+ if (escaped) {
+ current += char;
+ escaped = false;
+ continue;
+ }
+ if (char === "\\") {
+ current += char;
+ escaped = true;
+ continue;
+ }
+ if (char === "|") {
+ cells.push(current.trim());
+ current = "";
+ continue;
+ }
+ current += char;
+ }
+ cells.push(current.trim());
+ return cells;
+}
+
+function stripMarkdownValue(value = "") {
+ const trimmed = String(value || "").trim();
+ if (trimmed.startsWith("`") && trimmed.endsWith("`") && trimmed.length >= 2) {
+ return trimmed.slice(1, -1).trim();
+ }
+ return trimmed;
+}
+
+function isSeparatorRow(cells) {
+ return Array.isArray(cells) && cells.every((cell) => /^:?-{3,}:?$/.test(cell));
+}
+
+export function atomicWriteText(targetPath, content) {
+ const absolute = path.resolve(targetPath);
+ fs.mkdirSync(path.dirname(absolute), { recursive: true });
+ const tempPath = `${absolute}.${process.pid}.${Date.now()}.tmp`;
+ fs.writeFileSync(tempPath, content, "utf8");
+ fs.renameSync(tempPath, absolute);
+ return absolute;
+}
+
+export function atomicWriteJson(targetPath, value) {
+ return atomicWriteText(targetPath, `${JSON.stringify(value, null, 2)}\n`);
+}
+
+export function normalizeCatalogTitle(value = "") {
+ return stripMarkdownValue(value)
+ .normalize("NFKC")
+ .replace(/ /gi, "")
+ .replace(/\s+/g, "")
+ .toLowerCase();
+}
+
+export function normalizeCatalogMediaId(value = "") {
+ const normalized = stripMarkdownValue(value);
+ return ["", "无", "none", "n/a", "null"].includes(normalized.toLowerCase()) ? "" : normalized;
+}
+
+export function parseCatalogMarkdown(markdown = "") {
+ const lines = String(markdown || "").replace(/\r\n/g, "\n").split("\n");
+ let headerIndex = -1;
+ let headers = [];
+
+ for (let index = 0; index < lines.length; index += 1) {
+ const cells = splitMarkdownRow(lines[index]);
+ if (!cells) continue;
+ const normalized = cells.map(stripMarkdownValue);
+ if (REQUIRED_CATALOG_COLUMNS.every((column) => normalized.includes(column))) {
+ headerIndex = index;
+ headers = normalized;
+ break;
+ }
+ }
+
+ if (headerIndex === -1) {
+ throw new Error(`CATALOG table missing required columns: ${REQUIRED_CATALOG_COLUMNS.join(", ")}`);
+ }
+
+ const column = Object.fromEntries(headers.map((header, index) => [header, index]));
+ const rows = [];
+ for (let index = headerIndex + 1; index < lines.length; index += 1) {
+ const cells = splitMarkdownRow(lines[index]);
+ if (!cells) {
+ if (rows.length > 0) break;
+ continue;
+ }
+ if (isSeparatorRow(cells)) continue;
+ if (cells.length !== headers.length) {
+ throw new Error(`CATALOG row ${index + 1} has ${cells.length} cells; expected ${headers.length}`);
+ }
+ rows.push({
+ lineIndex: index,
+ cells,
+ date: stripMarkdownValue(cells[column.Date]),
+ slug: stripMarkdownValue(cells[column.Slug]),
+ title: stripMarkdownValue(cells[column.Title]),
+ status: stripMarkdownValue(cells[column.Status]),
+ draftbox: stripMarkdownValue(cells[column.Draftbox]),
+ mediaId: normalizeCatalogMediaId(cells[column["WeChat media_id"]]),
+ notes: stripMarkdownValue(cells[column.Notes]),
+ });
+ }
+
+ return { lines, headers, column, rows };
+}
+
+export function readCatalog(catalogPath) {
+ const absolute = path.resolve(catalogPath);
+ return {
+ path: absolute,
+ markdown: fs.readFileSync(absolute, "utf8"),
+ ...parseCatalogMarkdown(fs.readFileSync(absolute, "utf8")),
+ };
+}
+
+export function discoverCatalogPath(sourcePath, { maxDepth = 10 } = {}) {
+ if (!sourcePath) return "";
+ let current = path.resolve(fs.existsSync(sourcePath) && fs.statSync(sourcePath).isDirectory()
+ ? sourcePath
+ : path.dirname(sourcePath));
+
+ for (let depth = 0; depth <= maxDepth; depth += 1) {
+ const candidate = path.join(current, "CATALOG.md");
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate;
+ const parent = path.dirname(current);
+ if (parent === current) break;
+ current = parent;
+ }
+ return "";
+}
+
+export function deriveCatalogSlug(sourcePath) {
+ if (!sourcePath) return "";
+ const absolute = path.resolve(sourcePath);
+ const sourceStem = path.basename(absolute, path.extname(absolute));
+ if (!/^(?:article|index|draft)$/i.test(sourceStem)) return sourceStem;
+ return path.basename(path.dirname(absolute)).replace(/^\d{4}-\d{2}(?:-\d{2})?-/, "");
+}
+
+export function inspectCatalogTarget({ catalogPath, slug }) {
+ if (!catalogPath) throw new Error("catalogPath is required");
+ if (!slug) throw new Error("catalog slug is required");
+ const catalog = readCatalog(catalogPath);
+ const matches = catalog.rows.filter((row) => row.slug === slug);
+ if (matches.length !== 1) {
+ throw new Error(`catalog slug ${slug} matched ${matches.length} rows in ${catalog.path}; expected exactly 1`);
+ }
+ return { catalog, row: matches[0] };
+}
+
+function normalizedPointer(value = "") {
+ return String(value || "").replaceAll(path.sep, "/").replace(/^\.\//, "");
+}
+
+function appendNote(notes, fragment) {
+ const trimmed = String(notes || "").trim().replace(/[;;]\s*$/, "");
+ if (!fragment || trimmed.includes(fragment)) return trimmed;
+ return trimmed ? `${trimmed};${fragment}` : fragment;
+}
+
+export function updateCatalogAfterPush({
+ catalogPath,
+ slug,
+ mediaId,
+ account = "",
+ sourcePath = "",
+ auditPath = "",
+ auditPointer = "",
+ checkOnly = false,
+} = {}) {
+ if (!mediaId && !checkOnly) throw new Error("mediaId is required for CATALOG update");
+ const { catalog, row } = inspectCatalogTarget({ catalogPath, slug });
+ if (checkOnly) {
+ return {
+ status: "admitted",
+ changed: false,
+ catalog_path: catalog.path,
+ slug,
+ current_media_id: row.mediaId,
+ };
+ }
+
+ const cells = [...row.cells];
+ const oldMediaId = row.mediaId;
+ cells[catalog.column.Status] = "pushed-draft";
+ if (!row.draftbox || /^(?:无|未推送|未推送草稿箱)$/i.test(row.draftbox)) {
+ cells[catalog.column.Draftbox] = account ? `已推送草稿箱(${account})` : "已推送草稿箱";
+ }
+ cells[catalog.column["WeChat media_id"]] = `\`${mediaId}\``;
+
+ let notes = row.notes;
+ let pointer = normalizedPointer(auditPointer);
+ if (!pointer && auditPath) {
+ pointer = sourcePath
+ ? normalizedPointer(path.relative(path.dirname(path.resolve(sourcePath)), path.resolve(auditPath)))
+ : normalizedPointer(auditPath);
+ }
+ if (pointer) notes = appendNote(notes, `回执见 \`${pointer}\``);
+ if (oldMediaId && oldMediaId !== mediaId) {
+ notes = appendNote(notes, `原 media_id \`${oldMediaId}\` 已失效并更新`);
+ }
+ cells[catalog.column.Notes] = notes;
+
+ const nextLine = `| ${cells.join(" | ")} |`;
+ const changed = nextLine !== catalog.lines[row.lineIndex];
+ if (changed) {
+ catalog.lines[row.lineIndex] = nextLine;
+ atomicWriteText(catalog.path, catalog.lines.join("\n"));
+ }
+
+ return {
+ status: "updated",
+ changed,
+ catalog_path: catalog.path,
+ slug,
+ old_media_id: oldMediaId,
+ media_id: mediaId,
+ audit_pointer: pointer,
+ };
+}
+
+export function finalizePushResultBacklink({ pushResultPath, auditPath, catalogUpdate = null } = {}) {
+ const absolute = path.resolve(pushResultPath);
+ const result = JSON.parse(fs.readFileSync(absolute, "utf8"));
+ result.evidence = {
+ ...(result.evidence || {}),
+ audit_path: auditPath ? path.resolve(auditPath) : result.evidence?.audit_path || "",
+ push_result_path: absolute,
+ };
+ if (catalogUpdate) {
+ result.backlink = {
+ status: catalogUpdate.status || "updated",
+ catalog_path: catalogUpdate.catalog_path || "",
+ catalog_slug: catalogUpdate.slug || "",
+ old_media_id: catalogUpdate.old_media_id || "",
+ audit_pointer: catalogUpdate.audit_pointer || "",
+ };
+ }
+ atomicWriteJson(absolute, result);
+ return result;
+}
+
+function walkDirectories(rootPath, visit) {
+ if (!rootPath || !fs.existsSync(rootPath)) return;
+ const stack = [rootPath];
+ while (stack.length > 0) {
+ const current = stack.pop();
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
+ if (!entry.isDirectory()) continue;
+ const fullPath = path.join(current, entry.name);
+ if (visit(fullPath, entry.name) === false) continue;
+ stack.push(fullPath);
+ }
+ }
+}
+
+export function findArticleDirectory({ catalogPath, slug }) {
+ const articlesRoot = path.join(path.dirname(path.resolve(catalogPath)), "articles");
+ const matches = [];
+ walkDirectories(articlesRoot, (fullPath, name) => {
+ if (name === slug || name.endsWith(`-${slug}`)) matches.push(fullPath);
+ return !name.startsWith("publish");
+ });
+ return matches.length === 1 ? matches[0] : "";
+}
+
+export function findAuditPointerForSlug({ catalogPath, slug, mediaId = "" }) {
+ const articleDir = findArticleDirectory({ catalogPath, slug });
+ if (!articleDir) return "";
+ const publishRoot = path.join(articleDir, "publish");
+ if (!fs.existsSync(publishRoot)) return "";
+ const versionDirs = fs.readdirSync(publishRoot, { withFileTypes: true })
+ .filter((entry) => entry.isDirectory() && /^v\d+$/.test(entry.name))
+ .sort((a, b) => Number(b.name.slice(1)) - Number(a.name.slice(1)));
+
+ for (const version of versionDirs) {
+ const versionDir = path.join(publishRoot, version.name);
+ const auditPath = path.join(versionDir, "audit.log");
+ const resultPath = path.join(versionDir, "push-result.json");
+ let matches = !mediaId;
+ if (mediaId && fs.existsSync(resultPath)) {
+ try {
+ matches = JSON.parse(fs.readFileSync(resultPath, "utf8")).media_id === mediaId;
+ } catch {
+ matches = false;
+ }
+ }
+ if (mediaId && !matches && fs.existsSync(auditPath)) {
+ matches = fs.readFileSync(auditPath, "utf8").includes(mediaId);
+ }
+ if (matches && fs.existsSync(auditPath)) {
+ return normalizedPointer(path.relative(articleDir, auditPath));
+ }
+ }
+ return "";
+}
diff --git a/scripts/lib/wechat-reconcile-worker.mjs b/scripts/lib/wechat-reconcile-worker.mjs
new file mode 100644
index 0000000..5a495d2
--- /dev/null
+++ b/scripts/lib/wechat-reconcile-worker.mjs
@@ -0,0 +1,136 @@
+import fs from "node:fs";
+
+function parseArgs(argv) {
+ const args = {};
+ for (let index = 0; index < argv.length; index += 1) {
+ const token = argv[index];
+ if (!token.startsWith("--")) continue;
+ const key = token.slice(2);
+ const next = argv[index + 1];
+ if (next && !next.startsWith("--")) {
+ args[key] = next;
+ index += 1;
+ } else {
+ args[key] = true;
+ }
+ }
+ return args;
+}
+
+function readEnvFile(envPath) {
+ const env = {};
+ for (const line of fs.readFileSync(envPath, "utf8").split(/\r?\n/)) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) continue;
+ const separator = trimmed.indexOf("=");
+ const key = trimmed.slice(0, separator).trim();
+ let value = trimmed.slice(separator + 1).trim();
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
+ value = value.slice(1, -1);
+ }
+ env[key] = value;
+ }
+ return env;
+}
+
+function normalizeAccount(value = "") {
+ return String(value || "")
+ .trim()
+ .replace(/[^a-zA-Z0-9]+/g, "_")
+ .replace(/^_+|_+$/g, "")
+ .toUpperCase();
+}
+
+async function postJson(url, payload) {
+ const response = await fetch(url, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(payload),
+ signal: AbortSignal.timeout(30_000),
+ });
+ const text = await response.text();
+ if (!response.ok) throw new Error(`WeChat HTTP ${response.status}`);
+ return text ? JSON.parse(text) : {};
+}
+
+function credentialKeys(account) {
+ const normalized = normalizeAccount(account);
+ if (!normalized || normalized === "DEFAULT" || normalized === "PRIMARY") {
+ return ["WECHAT_MP_APP_ID", "WECHAT_MP_APP_SECRET"];
+ }
+ return [`WECHAT_${normalized}_APP_ID`, `WECHAT_${normalized}_APP_SECRET`];
+}
+
+function firstNewsItem(item) {
+ return item?.content?.news_item?.[0] || {};
+}
+
+async function main() {
+ const args = parseArgs(process.argv.slice(2));
+ const envPath = String(args.env || "");
+ const account = normalizeAccount(args.account || "");
+ if (!envPath || !account) throw new Error("--env and --account are required");
+ const env = readEnvFile(envPath);
+ const [appIdKey, appSecretKey] = credentialKeys(account);
+ const appId = env[appIdKey] || "";
+ const appSecret = env[appSecretKey] || "";
+ if (!appId || !appSecret) throw new Error(`relay env missing ${appIdKey} or ${appSecretKey}`);
+
+ const tokenResult = await postJson("https://api.weixin.qq.com/cgi-bin/stable_token", {
+ grant_type: "client_credential",
+ appid: appId,
+ secret: appSecret,
+ force_refresh: false,
+ });
+ const token = tokenResult.access_token;
+ if (!token) throw new Error(`stable_token failed with errcode ${tokenResult.errcode ?? "unknown"}`);
+
+ const drafts = [];
+ let offset = 0;
+ const count = 20;
+ let total = 0;
+ while (true) {
+ const page = await postJson(
+ `https://api.weixin.qq.com/cgi-bin/draft/batchget?access_token=${encodeURIComponent(token)}`,
+ { offset, count, no_content: 1 },
+ );
+ if (page.errcode && page.errcode !== 0) {
+ throw new Error(`draft/batchget failed with errcode ${page.errcode}`);
+ }
+ total = Number(page.total_count || 0);
+ const items = Array.isArray(page.item) ? page.item : [];
+ for (const item of items) {
+ const news = firstNewsItem(item);
+ drafts.push({
+ media_id: item.media_id || "",
+ title: news.title || "",
+ thumb_media_id: news.thumb_media_id || "",
+ create_time: news.create_time || item.create_time || null,
+ update_time: news.update_time || item.update_time || null,
+ });
+ }
+ offset += items.length;
+ if (items.length === 0 || offset >= total) break;
+ }
+
+ const publishProbe = await postJson(
+ `https://api.weixin.qq.com/cgi-bin/freepublish/batchget?access_token=${encodeURIComponent(token)}`,
+ { offset: 0, count: 1, no_content: 1 },
+ );
+
+ process.stdout.write(`${JSON.stringify({
+ schema_version: "md2wechat-relay-draft-snapshot/v1",
+ account,
+ queried_at: new Date().toISOString(),
+ draft_total: total,
+ drafts,
+ freepublish: publishProbe.errcode && publishProbe.errcode !== 0
+ ? { available: false, errcode: publishProbe.errcode }
+ : { available: true, errcode: 0, total_count: Number(publishProbe.total_count || 0) },
+ }, null, 2)}\n`);
+}
+
+await main().catch((error) => {
+ process.stderr.write(`${error.message || String(error)}\n`);
+ process.exitCode = 1;
+});
diff --git a/scripts/orchestrator.mjs b/scripts/orchestrator.mjs
index 1da97c3..6a03f75 100644
--- a/scripts/orchestrator.mjs
+++ b/scripts/orchestrator.mjs
@@ -2,6 +2,13 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
+import {
+ deriveCatalogSlug,
+ discoverCatalogPath,
+ finalizePushResultBacklink,
+ inspectCatalogTarget,
+ updateCatalogAfterPush,
+} from "./lib/publish-evidence.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -43,6 +50,9 @@ Optional:
--author Article author (default: WECHAT_DEFAULT_AUTHOR or \"公众号作者\")
--env .env file path (default: ./.env)
--out-dir Bundle output directory (default: /publish/vN/bundle)
+ --catalog Article-repo CATALOG.md (auto-discovered from input ancestors)
+ --catalog-slug Exact CATALOG row slug (derived from article directory when possible)
+ --no-catalog Explicitly skip CATALOG backwrite when the repo has no catalog contract
--thumb-image Cover image path (for WeChat draft)
--qr QR code image path
--dry-run Run full pipeline but skip WeChat API push
@@ -147,6 +157,8 @@ export function resolvePipelinePaths({ inputPath, outDirArg = "" }) {
logPath: path.join(workDir, ".md2wechat-pipeline.jsonl"),
renderOut: path.join(archiveDir, `${slug}.html`),
lintOut: path.join(archiveDir, `${slug}-lint.json`),
+ auditOut: path.join(archiveDir, "audit.log"),
+ pushResultOut: path.join(archiveDir, "push-result.json"),
};
}
@@ -214,6 +226,10 @@ export function buildManualRelayCommand({
slug,
author,
openComment,
+ sourcePath = "",
+ archiveDir = "",
+ catalogPath = "",
+ catalogSlug = "",
thumbImage = null,
cropSpec = null,
envInBundle = false,
@@ -222,6 +238,9 @@ export function buildManualRelayCommand({
const manualTitle = title || slug;
const scriptsDir = (envPath ? readEnvVar(envPath, "WECHAT_RELAY_SCRIPTS_DIR") : null) || `${relayRoot}/${account}/shared/scripts`;
const manualScript = `${scriptsDir}/create_wechat_draft.mjs`;
+ const localArchiveDir = archiveDir || path.dirname(outDir);
+ const auditOut = path.join(localArchiveDir, "audit.log");
+ const pushResultOut = path.join(localArchiveDir, "push-result.json");
const remoteDraftCmd = [
`cd ${shellQuote(remoteDir)} && node ${shellQuote(manualScript)}`,
`--html ${shellQuote(path.basename(renderOut))}`,
@@ -230,6 +249,10 @@ export function buildManualRelayCommand({
`--author ${shellQuote(author)}`,
`--account ${shellQuote(account)}`,
`--open-comment ${shellQuote(openComment)}`,
+ `--source-path ${shellQuote(sourcePath)}`,
+ "--audit-out 'audit.log'",
+ "--push-result-out 'push-result.json'",
+ "--json",
...(thumbImage ? [`--thumb-image ${shellQuote(path.basename(thumbImage))}`] : []),
...(cropSpec ? [`--crop-235-1 ${shellQuote(cropSpec)}`] : []),
].join(" ");
@@ -239,6 +262,22 @@ export function buildManualRelayCommand({
`scp ${shellQuote(outDir)}/* ${shellQuote(`${relayHost}:${remoteDir}/`)}`,
...(envInBundle ? [`scp ${shellQuote(path.join(outDir, ".env"))} ${shellQuote(`${relayHost}:${remoteDir}/.env`)}`] : []),
`ssh ${shellQuote(relayHost)} ${shellQuote(remoteDraftCmd)}`,
+ `scp ${shellQuote(`${relayHost}:${remoteDir}/push-result.json`)} ${shellQuote(pushResultOut)}`,
+ `scp ${shellQuote(`${relayHost}:${remoteDir}/audit.log`)} ${shellQuote(auditOut)}`,
+ ...(catalogPath && catalogSlug
+ ? [
+ [
+ `node ${shellQuote(path.join(PIPELINE_HOME, "scripts", "update_wechat_catalog.mjs"))}`,
+ `--catalog ${shellQuote(catalogPath)}`,
+ `--slug ${shellQuote(catalogSlug)}`,
+ `--source ${shellQuote(sourcePath)}`,
+ `--push-result ${shellQuote(pushResultOut)}`,
+ `--audit ${shellQuote(auditOut)}`,
+ `--account ${shellQuote(account)}`,
+ "--json",
+ ].join(" "),
+ ]
+ : []),
];
return {
@@ -248,6 +287,32 @@ export function buildManualRelayCommand({
};
}
+function collectRelayEvidence({ relayHost, remoteDir, auditOut, pushResultOut }) {
+ fs.mkdirSync(path.dirname(auditOut), { recursive: true });
+ const pushResultCopy = spawnSync(
+ "scp",
+ [`${relayHost}:${remoteDir}/push-result.json`, pushResultOut],
+ { encoding: "utf8", stdio: "pipe" },
+ );
+ const auditCopy = spawnSync(
+ "scp",
+ [`${relayHost}:${remoteDir}/audit.log`, auditOut],
+ { encoding: "utf8", stdio: "pipe" },
+ );
+ return {
+ pushResult: {
+ ok: pushResultCopy.status === 0 && fs.existsSync(pushResultOut),
+ status: pushResultCopy.status,
+ stderr: pushResultCopy.stderr || "",
+ },
+ audit: {
+ ok: auditCopy.status === 0 && fs.existsSync(auditOut),
+ status: auditCopy.status,
+ stderr: auditCopy.stderr || "",
+ },
+ };
+}
+
// ── JSONL 日志 ──
class PipelineLogger {
constructor(logPath) {
@@ -574,13 +639,35 @@ function main() {
// 工作目录和日志
const paths = resolvePipelinePaths({ inputPath, outDirArg: args["out-dir"] || "" });
- const { workDir, slug, archiveDir, outDir, logPath, renderOut, lintOut } = paths;
+ const { workDir, slug, archiveDir, outDir, logPath, renderOut, lintOut, auditOut, pushResultOut } = paths;
fs.mkdirSync(archiveDir, { recursive: true });
// 清空旧日志,避免累积
if (fs.existsSync(logPath)) {
fs.writeFileSync(logPath, "", "utf8");
}
const logger = new PipelineLogger(logPath);
+ const catalogPath = args["no-catalog"]
+ ? ""
+ : args.catalog
+ ? path.resolve(args.catalog)
+ : discoverCatalogPath(inputPath);
+ const catalogSlug = catalogPath
+ ? String(args["catalog-slug"] || deriveCatalogSlug(inputPath)).trim()
+ : "";
+ if (catalogPath) {
+ try {
+ const admission = inspectCatalogTarget({ catalogPath, slug: catalogSlug });
+ logger.record("catalog_admission", "success", {
+ catalog_path: admission.catalog.path,
+ slug: catalogSlug,
+ current_media_id: admission.row.mediaId,
+ });
+ } catch (error) {
+ err(error.message);
+ logger.record("catalog_admission", "failed", { reason: error.message });
+ return 1;
+ }
+ }
console.log(`\n${C.bold}${C.cyan}╔══════════════════════════════════════════════════════════════╗${C.reset}`);
console.log(`${C.bold}${C.cyan}║ md2wechat Autopoietic Orchestrator v3.0 ║${C.reset}`);
@@ -826,7 +913,7 @@ function main() {
const remoteCropArg = cropSpec ? ` --crop-235-1 ${shellQuote(cropSpec)}` : "";
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)} --source-path ${shellQuote(inputPath)} --audit-out ${shellQuote("audit.log")} --push-result-out ${shellQuote("push-result.json")} --json${remoteCropArg}`,
];
const pushResult = spawnSync("ssh", remoteCmd, { encoding: "utf8", stdio: "pipe", maxBuffer: 1024 * 1024 });
@@ -841,30 +928,72 @@ function main() {
}
}
+ const collected = collectRelayEvidence({ relayHost, remoteDir, auditOut, pushResultOut });
let pushJson = null;
- try {
- // 从 stdout 提取完整 JSON(支持多行格式化)
- const jsonMatch = pushStdout.match(/\{[\s\S]*\}/);
- if (jsonMatch) {
- try { pushJson = JSON.parse(jsonMatch[0]); } catch {}
- }
- // 兜底:尝试按行解析(单行 JSON)
- if (!pushJson) {
- const jsonLines = pushStdout.split("\n").filter((l) => l.trim().startsWith("{") || l.trim().startsWith("["));
- for (const jl of jsonLines.reverse()) {
- try { pushJson = JSON.parse(jl); break; } catch {}
- }
+ if (collected.pushResult.ok) {
+ try {
+ pushJson = JSON.parse(fs.readFileSync(pushResultOut, "utf8"));
+ } catch {
+ pushJson = null;
}
- } catch {}
+ }
- if (pushResult.status !== 0 || !pushJson || pushJson.errcode !== 0) {
- err(`Remote push failed (exit ${pushResult.status}). Check relay logs.`);
- logger.record("push", "failed", { reason: "remote_push_failed", stdout_preview: pushStdout.slice(0, 500), stderr_preview: pushStderr.slice(0, 500) });
+ if (pushResult.status !== 0 || !pushJson || pushJson.errcode !== 0 || pushJson.verification?.status !== "passed") {
+ err(`Remote draft may have been created, but verified push evidence is incomplete (exit ${pushResult.status}).`);
+ logger.record("push", "failed", {
+ reason: "remote_push_or_verification_failed",
+ completion_status: pushJson?.completion_status || "unknown",
+ push_result_collected: collected.pushResult.ok,
+ audit_collected: collected.audit.ok,
+ stdout_preview: pushStdout.slice(0, 500),
+ stderr_preview: pushStderr.slice(0, 500),
+ });
return 6;
}
+ if (!collected.audit.ok) {
+ err("WeChat accepted the draft, but audit.log was not returned from relay.");
+ logger.record("push", "failed", {
+ reason: "relay_audit_collection_failed",
+ media_id: pushJson.media_id,
+ stderr_preview: collected.audit.stderr.slice(0, 500),
+ });
+ return 7;
+ }
- ok(`Push succeeded. media_id: ${pushJson.media_id || "(unknown)"}`);
- logger.record("push", "success", { media_id: pushJson.media_id, thumb_media_id: pushJson.thumb_media_id });
+ let catalogUpdate = null;
+ try {
+ if (catalogPath) {
+ catalogUpdate = updateCatalogAfterPush({
+ catalogPath,
+ slug: catalogSlug,
+ mediaId: pushJson.media_id,
+ account,
+ sourcePath: inputPath,
+ auditPath: auditOut,
+ });
+ }
+ pushJson = finalizePushResultBacklink({ pushResultPath: pushResultOut, auditPath: auditOut, catalogUpdate });
+ } catch (error) {
+ err(`Draft created, but CATALOG/Backlink persistence failed: ${error.message}`);
+ logger.record("push", "failed", {
+ reason: "draft_created_backlink_incomplete",
+ media_id: pushJson.media_id,
+ error: error.message,
+ push_result_path: pushResultOut,
+ audit_path: auditOut,
+ });
+ return 7;
+ }
+
+ ok(`Push and evidence persistence succeeded. media_id: ${pushJson.media_id || "(unknown)"}`);
+ logger.record("push", "success", {
+ media_id: pushJson.media_id,
+ thumb_media_id: pushJson.thumb_media_id,
+ audit_path: auditOut,
+ push_result_path: pushResultOut,
+ catalog_path: catalogUpdate?.catalog_path || "",
+ catalog_slug: catalogUpdate?.slug || "",
+ });
} else {
step(3, "Push to WeChat Draft (MANUAL)");
@@ -883,6 +1012,10 @@ function main() {
slug,
author,
openComment,
+ sourcePath: inputPath,
+ archiveDir,
+ catalogPath,
+ catalogSlug,
thumbImage,
cropSpec,
envInBundle,
diff --git a/scripts/reconcile_wechat_drafts.mjs b/scripts/reconcile_wechat_drafts.mjs
new file mode 100644
index 0000000..53ca076
--- /dev/null
+++ b/scripts/reconcile_wechat_drafts.mjs
@@ -0,0 +1,279 @@
+#!/usr/bin/env node
+import fs from "node:fs";
+import path from "node:path";
+import { spawnSync } from "node:child_process";
+import { fileURLToPath } from "node:url";
+import {
+ atomicWriteJson,
+ findAuditPointerForSlug,
+ normalizeCatalogMediaId,
+ normalizeCatalogTitle,
+ parseCatalogMarkdown,
+ updateCatalogAfterPush,
+} from "./lib/publish-evidence.mjs";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+function parseArgs(argv) {
+ const args = { slug: [] };
+ for (let index = 0; index < argv.length; index += 1) {
+ const token = argv[index];
+ if (!token.startsWith("--")) continue;
+ const key = token.slice(2);
+ const next = argv[index + 1];
+ const value = next && !next.startsWith("--") ? next : true;
+ if (key === "slug") args.slug.push(String(value));
+ else args[key] = value;
+ if (value !== true) index += 1;
+ }
+ return args;
+}
+
+function readEnvFile(envPath) {
+ const pairs = {};
+ for (const line of fs.readFileSync(envPath, "utf8").split(/\r?\n/)) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) continue;
+ const separator = trimmed.indexOf("=");
+ const key = trimmed.slice(0, separator).trim();
+ let value = trimmed.slice(separator + 1).trim();
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
+ value = value.slice(1, -1);
+ }
+ pairs[key] = value;
+ }
+ return pairs;
+}
+
+function shellQuote(value = "") {
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
+}
+
+export function fetchRelaySnapshot({ envPath, account, relayEnv = "" }) {
+ const env = readEnvFile(envPath);
+ const relayHost = env.WECHAT_RELAY_HOST || "";
+ if (!relayHost) throw new Error(`WECHAT_RELAY_HOST missing from ${envPath}`);
+ const remoteEnv = relayEnv
+ || env.WECHAT_RELAY_RECONCILE_ENV
+ || (env.WECHAT_RELAY_SHARED_DIR ? `${env.WECHAT_RELAY_SHARED_DIR}/.env` : "")
+ || (env.WECHAT_RELAY_PUBLISH_ROOT ? `${env.WECHAT_RELAY_PUBLISH_ROOT}/.env` : "");
+ if (!remoteEnv) {
+ throw new Error("relay credential path missing; configure WECHAT_RELAY_RECONCILE_ENV or WECHAT_RELAY_SHARED_DIR");
+ }
+
+ const workerPath = path.join(__dirname, "lib", "wechat-reconcile-worker.mjs");
+ const workerSource = fs.readFileSync(workerPath, "utf8");
+ const remoteCommand = `node --input-type=module - --env ${shellQuote(remoteEnv)} --account ${shellQuote(account)}`;
+ const result = spawnSync("ssh", [relayHost, remoteCommand], {
+ encoding: "utf8",
+ input: workerSource,
+ maxBuffer: 10 * 1024 * 1024,
+ });
+ if (result.error) throw result.error;
+ if (result.status !== 0) {
+ throw new Error((result.stderr || result.stdout || `relay snapshot failed with status ${result.status}`).trim());
+ }
+ return JSON.parse(result.stdout);
+}
+
+function publicDraftRecord(draft) {
+ return {
+ media_id: draft.media_id || "",
+ title: draft.title || "",
+ thumb_media_id: draft.thumb_media_id || "",
+ create_time: draft.create_time ?? null,
+ update_time: draft.update_time ?? null,
+ };
+}
+
+export function buildReconciliationReport({
+ catalogMarkdown,
+ catalogPath = "",
+ snapshot,
+ account,
+ slugs = [],
+ date = "",
+} = {}) {
+ const catalog = parseCatalogMarkdown(catalogMarkdown);
+ const allRows = catalog.rows;
+ const requestedSlugs = new Set((slugs || []).filter(Boolean));
+ let selectedRows = allRows.filter((row) => row.status === "pushed-draft");
+ if (requestedSlugs.size > 0) selectedRows = selectedRows.filter((row) => requestedSlugs.has(row.slug));
+ if (date) selectedRows = selectedRows.filter((row) => row.date === date);
+
+ const drafts = Array.isArray(snapshot?.drafts) ? snapshot.drafts.map(publicDraftRecord) : [];
+ const byId = new Map(drafts.filter((draft) => draft.media_id).map((draft) => [draft.media_id, draft]));
+ const byTitle = new Map();
+ for (const draft of drafts) {
+ const key = normalizeCatalogTitle(draft.title);
+ if (!byTitle.has(key)) byTitle.set(key, []);
+ byTitle.get(key).push(draft);
+ }
+
+ const matched = [];
+ const staleIds = [];
+ const absent = [];
+ const selectedLiveIds = new Set();
+ for (const row of selectedRows) {
+ const mediaId = normalizeCatalogMediaId(row.mediaId);
+ const direct = mediaId ? byId.get(mediaId) : null;
+ if (direct) {
+ selectedLiveIds.add(direct.media_id);
+ matched.push({ slug: row.slug, title: row.title, media_id: mediaId, thumb_media_id: direct.thumb_media_id });
+ continue;
+ }
+
+ const titleMatches = byTitle.get(normalizeCatalogTitle(row.title)) || [];
+ if (titleMatches.length === 1) {
+ const replacement = titleMatches[0];
+ selectedLiveIds.add(replacement.media_id);
+ staleIds.push({
+ slug: row.slug,
+ title: row.title,
+ old_media_id: mediaId,
+ replacement_media_id: replacement.media_id,
+ thumb_media_id: replacement.thumb_media_id,
+ state: "replacement-candidate",
+ });
+ continue;
+ }
+
+ absent.push({
+ slug: row.slug,
+ title: row.title,
+ media_id: mediaId,
+ state: "published-or-deleted",
+ publication_assertion: "forbidden",
+ title_match_count: titleMatches.length,
+ });
+ }
+
+ const allCatalogIds = new Set(allRows.map((row) => normalizeCatalogMediaId(row.mediaId)).filter(Boolean));
+ const allCatalogTitles = new Set(allRows.map((row) => normalizeCatalogTitle(row.title)).filter(Boolean));
+ const uncatalogedDrafts = drafts.filter((draft) =>
+ !allCatalogIds.has(draft.media_id) && !allCatalogTitles.has(normalizeCatalogTitle(draft.title))
+ );
+ const coverMissingAll = drafts.filter((draft) => !draft.thumb_media_id);
+ const coverMissingSelected = drafts.filter((draft) => selectedLiveIds.has(draft.media_id) && !draft.thumb_media_id);
+ const invalidIds = [
+ ...staleIds.map((item) => ({ slug: item.slug, title: item.title, media_id: item.old_media_id, reason: "superseded" })),
+ ...absent.map((item) => ({ slug: item.slug, title: item.title, media_id: item.media_id, reason: "absent-from-draftbox" })),
+ ];
+
+ return {
+ schema_version: "md2wechat-draft-reconcile/v1",
+ account: account || snapshot?.account || "",
+ queried_at: snapshot?.queried_at || new Date().toISOString(),
+ catalog_path: catalogPath ? path.resolve(catalogPath) : "",
+ scope: {
+ status: "pushed-draft",
+ date: date || "",
+ slugs: [...requestedSlugs],
+ selected_count: selectedRows.length,
+ },
+ live_snapshot: {
+ draft_total: Number(snapshot?.draft_total ?? drafts.length),
+ freepublish: snapshot?.freepublish || { available: false, errcode: null, reason: "not-probed" },
+ },
+ summary: {
+ matched: matched.length,
+ stale_ids: staleIds.length,
+ invalid_ids: invalidIds.length,
+ absent_from_draftbox: absent.length,
+ uncataloged_drafts: uncatalogedDrafts.length,
+ cover_missing_selected: coverMissingSelected.length,
+ cover_missing_all_live: coverMissingAll.length,
+ },
+ matched,
+ stale_ids: staleIds,
+ invalid_ids: invalidIds,
+ absent_from_draftbox: absent,
+ uncataloged_drafts: uncatalogedDrafts,
+ cover_missing: {
+ selected: coverMissingSelected,
+ all_live: coverMissingAll,
+ },
+ publication_inference: {
+ allowed: false,
+ absent_state: "published-or-deleted",
+ reason: "draft disappearance alone is not publication evidence",
+ },
+ };
+}
+
+export function applyReconciliationRepairs({ report, catalogPath, account }) {
+ const repairs = [];
+ for (const item of report.stale_ids || []) {
+ const auditPointer = findAuditPointerForSlug({
+ catalogPath,
+ slug: item.slug,
+ mediaId: item.replacement_media_id,
+ });
+ const result = updateCatalogAfterPush({
+ catalogPath,
+ slug: item.slug,
+ mediaId: item.replacement_media_id,
+ account,
+ auditPointer,
+ });
+ repairs.push(result);
+ }
+ return repairs;
+}
+
+export function reconcileWechatDrafts(argv = process.argv.slice(2)) {
+ const args = parseArgs(argv);
+ if (args.help) {
+ process.stdout.write([
+ "Usage: node scripts/reconcile_wechat_drafts.mjs --catalog --account [options]",
+ " --env Local md2wechat .env containing WECHAT_RELAY_* (default: ./.env)",
+ " --relay-env Credential .env path on relay",
+ " --slug Repeat to limit catalog rows",
+ " --date Limit selected pushed-draft rows by date",
+ " --snapshot Offline fixture; skips relay (tests only)",
+ " --report Persist the reconciliation report locally",
+ " --write Repair only unique title-matched stale ids in CATALOG",
+ "",
+ ].join("\n"));
+ return { status: "help" };
+ }
+
+ const catalogPath = path.resolve(String(args.catalog || "CATALOG.md"));
+ const account = String(args.account || "").trim();
+ if (!account) throw new Error("--account is required");
+ const catalogMarkdown = fs.readFileSync(catalogPath, "utf8");
+ const snapshot = args.snapshot
+ ? JSON.parse(fs.readFileSync(path.resolve(args.snapshot), "utf8"))
+ : fetchRelaySnapshot({
+ envPath: path.resolve(String(args.env || ".env")),
+ account,
+ relayEnv: args["relay-env"] ? String(args["relay-env"]) : "",
+ });
+ const report = buildReconciliationReport({
+ catalogMarkdown,
+ catalogPath,
+ snapshot,
+ account,
+ slugs: args.slug,
+ date: args.date ? String(args.date) : "",
+ });
+ if (args.write) {
+ report.write_result = {
+ repairs: applyReconciliationRepairs({ report, catalogPath, account }),
+ absent_rows_unchanged: report.absent_from_draftbox.length,
+ };
+ }
+ if (args.report) atomicWriteJson(path.resolve(args.report), report);
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
+ return report;
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) === __filename) {
+ try {
+ reconcileWechatDrafts();
+ } catch (error) {
+ process.stderr.write(`${error.message || String(error)}\n`);
+ process.exitCode = 1;
+ }
+}
diff --git a/scripts/update_wechat_catalog.mjs b/scripts/update_wechat_catalog.mjs
new file mode 100644
index 0000000..21df985
--- /dev/null
+++ b/scripts/update_wechat_catalog.mjs
@@ -0,0 +1,106 @@
+#!/usr/bin/env node
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import {
+ deriveCatalogSlug,
+ discoverCatalogPath,
+ finalizePushResultBacklink,
+ inspectCatalogTarget,
+ updateCatalogAfterPush,
+} from "./lib/publish-evidence.mjs";
+
+function parseArgs(argv) {
+ const args = {};
+ for (let index = 0; index < argv.length; index += 1) {
+ const token = argv[index];
+ if (!token.startsWith("--")) continue;
+ const key = token.slice(2);
+ const next = argv[index + 1];
+ if (next && !next.startsWith("--")) {
+ args[key] = next;
+ index += 1;
+ } else {
+ args[key] = true;
+ }
+ }
+ return args;
+}
+
+export function updateWechatCatalogFromArgs(argv = process.argv.slice(2)) {
+ const args = parseArgs(argv);
+ if (args.help) {
+ process.stdout.write([
+ "Usage: node scripts/update_wechat_catalog.mjs --source [options]",
+ " --catalog Explicit catalog; otherwise discover from source ancestors",
+ " --no-catalog Finalize local evidence paths without a CATALOG projection",
+ " --slug Explicit row slug; otherwise derive from source path",
+ " --push-result Required unless --check",
+ " --audit Durable audit.log path",
+ " --account Account label for a newly transitioned Draftbox cell",
+ " --check Validate exact row admission without writing",
+ " --json Emit JSON",
+ "",
+ ].join("\n"));
+ return { status: "help" };
+ }
+
+ const sourcePath = args.source ? path.resolve(args.source) : "";
+ const catalogPath = args.catalog
+ ? path.resolve(args.catalog)
+ : discoverCatalogPath(sourcePath);
+ const slug = String(args.slug || deriveCatalogSlug(sourcePath)).trim();
+ if (!args["no-catalog"] && !catalogPath) throw new Error("CATALOG.md not found; pass --catalog explicitly or --no-catalog");
+ if (!args["no-catalog"] && !slug) throw new Error("catalog slug could not be derived; pass --slug explicitly");
+
+ if (args.check) {
+ if (args["no-catalog"]) throw new Error("--check cannot be combined with --no-catalog");
+ const { catalog, row } = inspectCatalogTarget({ catalogPath, slug });
+ const result = {
+ status: "admitted",
+ catalog_path: catalog.path,
+ slug,
+ current_media_id: row.mediaId,
+ };
+ if (args.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
+ return result;
+ }
+
+ if (!args["push-result"]) throw new Error("--push-result is required unless --check is used");
+ const pushResultPath = path.resolve(args["push-result"]);
+ const pushResult = JSON.parse(fs.readFileSync(pushResultPath, "utf8"));
+ if (!pushResult.media_id || pushResult.errcode !== 0 || pushResult.verification?.status !== "passed") {
+ throw new Error("push-result is not a verified successful draft result");
+ }
+
+ const catalogUpdate = args["no-catalog"]
+ ? null
+ : updateCatalogAfterPush({
+ catalogPath,
+ slug,
+ mediaId: pushResult.media_id,
+ account: args.account || pushResult.account || "",
+ sourcePath: sourcePath || pushResult.source_path || "",
+ auditPath: args.audit ? path.resolve(args.audit) : "",
+ });
+ const finalized = finalizePushResultBacklink({
+ pushResultPath,
+ auditPath: args.audit ? path.resolve(args.audit) : "",
+ catalogUpdate,
+ });
+ const result = catalogUpdate
+ ? { ...catalogUpdate, push_result: finalized }
+ : { status: "evidence-finalized", changed: true, push_result: finalized };
+ if (args.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
+ return result;
+}
+
+const thisFile = fileURLToPath(import.meta.url);
+if (process.argv[1] && path.resolve(process.argv[1]) === thisFile) {
+ try {
+ updateWechatCatalogFromArgs();
+ } catch (error) {
+ process.stderr.write(`${error.message || String(error)}\n`);
+ process.exitCode = 1;
+ }
+}