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; + } +}