@zzclub/pipeline 0.4.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +102 -3
  2. package/package.json +1 -1
  3. package/src/adapter-types.ts +6 -0
  4. package/src/adapters/builtin-image-renderer.ts +7 -1
  5. package/src/adapters/builtin-markdown-renderer.ts +1 -0
  6. package/src/commands/analytics.test.ts +155 -0
  7. package/src/commands/analytics.ts +223 -0
  8. package/src/commands/init.ts +42 -3
  9. package/src/commands/publish.ts +44 -67
  10. package/src/commands/republish.ts +146 -0
  11. package/src/commands/sync-blog.ts +19 -3
  12. package/src/commands/topic.test.ts +146 -0
  13. package/src/commands/topic.ts +717 -0
  14. package/src/commands/wechat-export.ts +2 -0
  15. package/src/commands/wx-draft-delete.ts +36 -0
  16. package/src/commands/wx-drafts.ts +47 -0
  17. package/src/config.ts +0 -0
  18. package/src/db.test.ts +47 -0
  19. package/src/db.ts +95 -0
  20. package/src/imgx/assets/icons/ancientone-logo.png +0 -0
  21. package/src/imgx/assets/templates/wechat-cover-split.html +1 -1
  22. package/src/imgx/render-card.ts +1 -5
  23. package/src/plugins.ts +11 -1
  24. package/src/providers/blog.ts +9 -3
  25. package/src/providers/index.test.ts +1 -1
  26. package/src/providers/index.ts +29 -5
  27. package/src/providers/publish-core.ts +124 -0
  28. package/src/providers/wechat.ts +260 -28
  29. package/src/routes.ts +1 -1
  30. package/src/schema/analytics.test.ts +54 -0
  31. package/src/schema/analytics.ts +19 -0
  32. package/src/schema/config.ts +25 -1
  33. package/src/schema/state.ts +10 -0
  34. package/src/schema/topic.test.ts +69 -0
  35. package/src/schema/topic.ts +55 -0
  36. package/src/state.ts +1 -0
  37. package/src/text.test.ts +11 -6
  38. package/src/text.ts +27 -9
  39. package/src/wechat-preview/assets/browser-dist/.vite/manifest.json +2 -7
  40. package/src/wechat-preview/assets/browser-dist/assets/{style-BYNXnUvc.css → style-vhHYSP8T.css} +1 -1
  41. package/src/wechat-preview/assets/browser-dist/editor-export.js +27 -27
  42. package/src/wechat-preview/browser/editor-export.css +248 -159
  43. package/src/wechat-preview/index.ts +28 -11
  44. package/src/wechat-preview/themes.ts +70 -24
  45. package/src/wechat-preview/wechat-formatter.test.ts +0 -1
  46. package/src/wechat-preview/wechat-formatter.ts +48 -23
  47. package/src/workflow.test.ts +294 -1
  48. package/src/wechat-preview/assets/browser-dist/assets/SweiCurveLegCJKsc-Regular-B3Ut5bwH.woff +0 -0
@@ -54,6 +54,7 @@ ${CSS_DEMO}
54
54
  const customCss = optionalArg(parsed, "custom-css");
55
55
 
56
56
  const config = loadConfig();
57
+ const wxAccount = config.wx.accounts[account] ?? config.wx.accounts[config.wx.defaultAccount];
57
58
  const markdownRenderer = await resolveMarkdownRenderer(config);
58
59
  const result = await markdownRenderer.render({
59
60
  markdownPath,
@@ -62,6 +63,7 @@ ${CSS_DEMO}
62
63
  title,
63
64
  previewShellOutPath,
64
65
  customCss,
66
+ themeOverrides: wxAccount?.theme,
65
67
  });
66
68
 
67
69
  printResult(result, renderWechatExport);
@@ -0,0 +1,36 @@
1
+ /**
2
+ * wx-draft-delete — Delete a draft from WeChat draft box.
3
+ *
4
+ * Usage:
5
+ * zzhub-pipeline wx-draft-delete [--account <account>] --media-id <media_id>
6
+ */
7
+
8
+ import { optionalArg, parseArgs, requireArg } from "../args";
9
+ import { loadConfig } from "../config";
10
+ import { printHelp, printResult } from "../output";
11
+ import { deleteWxDraft } from "../providers/wechat";
12
+
13
+ export async function wxDraftDelete(args: string[]): Promise<void> {
14
+ const parsed = parseArgs(args);
15
+
16
+ if (parsed.help) {
17
+ printHelp(`
18
+ Usage: zzhub-pipeline wx-draft-delete [options]
19
+
20
+ Options:
21
+ --account WeChat account name (defaults to wx.defaultAccount from config)
22
+ --media-id Draft media_id to delete (required)
23
+
24
+ Examples:
25
+ zzhub-pipeline wx-draft-delete --media-id MEDIA_ID
26
+ `.trim());
27
+ return;
28
+ }
29
+
30
+ const config = loadConfig();
31
+ const account = optionalArg(parsed, "account") || config.wx.defaultAccount;
32
+ const mediaId = requireArg(parsed, "media-id", "draft media_id to delete");
33
+
34
+ const result = await deleteWxDraft({ account, mediaId, config });
35
+ printResult(result);
36
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * wx-drafts — List or get WeChat draft box drafts.
3
+ *
4
+ * Usage:
5
+ * zzhub-pipeline wx-drafts [--account <account>] [--limit 20] [--offset 0]
6
+ * zzhub-pipeline wx-drafts [--account <account>] --media-id <media_id>
7
+ */
8
+
9
+ import { optionalArg, parseArgs } from "../args";
10
+ import { loadConfig } from "../config";
11
+ import { printHelp, printResult } from "../output";
12
+ import { getWxDraft, getWxDraftList } from "../providers/wechat";
13
+
14
+ export async function wxDrafts(args: string[]): Promise<void> {
15
+ const parsed = parseArgs(args);
16
+
17
+ if (parsed.help) {
18
+ printHelp(`
19
+ Usage: zzhub-pipeline wx-drafts [options]
20
+
21
+ Options:
22
+ --account WeChat account name (defaults to wx.defaultAccount from config)
23
+ --media-id Draft media_id to fetch a single draft (optional)
24
+ --limit Number of drafts to list, default 20, max 20 (optional)
25
+ --offset Pagination offset, default 0 (optional)
26
+
27
+ Examples:
28
+ zzhub-pipeline wx-drafts --limit 10
29
+ zzhub-pipeline wx-drafts --media-id MEDIA_ID
30
+ `.trim());
31
+ return;
32
+ }
33
+
34
+ const config = loadConfig();
35
+ const account = optionalArg(parsed, "account") || config.wx.defaultAccount;
36
+ const mediaId = optionalArg(parsed, "media-id") ?? null;
37
+ const limit = optionalArg(parsed, "limit") ? Number.parseInt(optionalArg(parsed, "limit")!, 10) : undefined;
38
+ const offset = optionalArg(parsed, "offset") ? Number.parseInt(optionalArg(parsed, "offset")!, 10) : undefined;
39
+
40
+ if (mediaId) {
41
+ const result = await getWxDraft({ account, mediaId, config });
42
+ printResult(result);
43
+ } else {
44
+ const result = await getWxDraftList({ account, limit, offset, config });
45
+ printResult(result);
46
+ }
47
+ }
package/src/config.ts CHANGED
Binary file
package/src/db.test.ts ADDED
@@ -0,0 +1,47 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { mkdtemp, rm, exists } from "fs/promises";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+ import { getDb, initDb } from "./db";
6
+
7
+ describe("db", () => {
8
+ let workspace: string;
9
+
10
+ beforeEach(async () => {
11
+ workspace = await mkdtemp(join(tmpdir(), "zzhub-test-"));
12
+ });
13
+
14
+ afterEach(async () => {
15
+ await rm(workspace, { recursive: true, force: true });
16
+ });
17
+
18
+ test("getDb creates database file", () => {
19
+ const db = getDb(workspace);
20
+ db.close();
21
+ expect(exists(join(workspace, "zzhub.db"))).resolves.toBe(true);
22
+ });
23
+
24
+ test("initDb creates topics table", () => {
25
+ const db = getDb(workspace);
26
+ initDb(db);
27
+
28
+ const tables = db.prepare(
29
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='topics'"
30
+ ).all();
31
+
32
+ expect(tables.length).toBe(1);
33
+ db.close();
34
+ });
35
+
36
+ test("initDb creates analytics table", () => {
37
+ const db = getDb(workspace);
38
+ initDb(db);
39
+
40
+ const tables = db.prepare(
41
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='analytics'"
42
+ ).all();
43
+
44
+ expect(tables.length).toBe(1);
45
+ db.close();
46
+ });
47
+ });
package/src/db.ts ADDED
@@ -0,0 +1,95 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { join } from "path";
3
+ import { existsSync } from "fs";
4
+ import { mkdirSync } from "fs";
5
+
6
+ const DB_FILE = "zzhub.db";
7
+
8
+ /**
9
+ * Get or create SQLite database for workspace.
10
+ */
11
+ export function getDb(workspace: string): Database {
12
+ if (!existsSync(workspace)) {
13
+ mkdirSync(workspace, { recursive: true });
14
+ }
15
+
16
+ const dbPath = join(workspace, DB_FILE);
17
+ const db = new Database(dbPath);
18
+
19
+ // Enable WAL mode for better concurrent performance
20
+ db.exec("PRAGMA journal_mode = WAL;");
21
+
22
+ return db;
23
+ }
24
+
25
+ /**
26
+ * Initialize database schema (idempotent).
27
+ */
28
+ export function initDb(db: Database): void {
29
+ db.exec(`
30
+ CREATE TABLE IF NOT EXISTS topics (
31
+ topic_id TEXT PRIMARY KEY,
32
+ title TEXT NOT NULL,
33
+ description TEXT,
34
+ priority TEXT CHECK(priority IN ('high', 'medium', 'low')) DEFAULT 'medium',
35
+ tags TEXT,
36
+ notes TEXT,
37
+ status TEXT CHECK(status IN (
38
+ 'backlog', 'evaluating', 'scheduled', 'in_progress', 'published', 'abandoned'
39
+ )) DEFAULT 'backlog',
40
+
41
+ ai_score INTEGER,
42
+ ai_reason TEXT,
43
+
44
+ scheduled_date TEXT,
45
+ target_account TEXT,
46
+
47
+ run_id TEXT,
48
+
49
+ retro_performance TEXT CHECK(retro_performance IN (
50
+ 'excellent', 'good', 'average', 'poor', null
51
+ )),
52
+ retro_lessons TEXT,
53
+ retro_metrics_snapshot TEXT,
54
+
55
+ created_at TEXT NOT NULL,
56
+ updated_at TEXT NOT NULL
57
+ );
58
+
59
+ CREATE INDEX IF NOT EXISTS idx_topics_status ON topics(status);
60
+ CREATE INDEX IF NOT EXISTS idx_topics_priority ON topics(priority);
61
+ CREATE INDEX IF NOT EXISTS idx_topics_scheduled ON topics(scheduled_date);
62
+ CREATE INDEX IF NOT EXISTS idx_topics_created ON topics(created_at);
63
+
64
+ CREATE TABLE IF NOT EXISTS analytics (
65
+ run_id TEXT PRIMARY KEY,
66
+ topic_id TEXT,
67
+ title TEXT NOT NULL,
68
+ publish_date TEXT NOT NULL,
69
+
70
+ reads INTEGER DEFAULT 0,
71
+ likes INTEGER DEFAULT 0,
72
+ favorites INTEGER DEFAULT 0,
73
+ shares INTEGER DEFAULT 0,
74
+ comments INTEGER DEFAULT 0,
75
+
76
+ notes TEXT,
77
+ recorded_at TEXT NOT NULL,
78
+
79
+ FOREIGN KEY (topic_id) REFERENCES topics(topic_id)
80
+ );
81
+
82
+ CREATE INDEX IF NOT EXISTS idx_analytics_publish_date ON analytics(publish_date);
83
+ CREATE INDEX IF NOT EXISTS idx_analytics_topic ON analytics(topic_id);
84
+ CREATE INDEX IF NOT EXISTS idx_analytics_reads ON analytics(reads);
85
+ `);
86
+ }
87
+
88
+ /**
89
+ * Ensure database is initialized for workspace.
90
+ */
91
+ export function ensureDb(workspace: string): Database {
92
+ const db = getDb(workspace);
93
+ initDb(db);
94
+ return db;
95
+ }
@@ -136,7 +136,7 @@
136
136
  <body>
137
137
  <div class="canvas">
138
138
  <section class="left">
139
- <div class="eyebrow">公众号·早早集市</div>
139
+ <div class="eyebrow">{{FOOTER_TEXT}}</div>
140
140
  <div class="text-block">
141
141
  <div class="pretext-lines" id="cover-lines" aria-label="cover text"></div>
142
142
  </div>
@@ -30,11 +30,7 @@ const DEFAULT_SIZE = { width: 900, height: 1200 } as const;
30
30
  const WECHAT_SPLIT_WINDOW_EXTRA_HEIGHT = 87;
31
31
  const PRETEXT_SCREENSHOT_VIRTUAL_TIME_BUDGET_MS = 6_000;
32
32
 
33
- function detectIcon(template: string, _text: string, _line1: string, _line2: string, _line3: string, fallbackIcon: string): string {
34
- if (template === "wechat-cover-split") {
35
- return join(ICONS_DIR, "logo.png");
36
- }
37
-
33
+ function detectIcon(_template: string, _text: string, _line1: string, _line2: string, _line3: string, fallbackIcon: string): string {
38
34
  if (fallbackIcon.length > 0) return fallbackIcon;
39
35
  return join(ICONS_DIR, "logo.png");
40
36
  }
package/src/plugins.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { abandon } from "./commands/abandon";
2
+ import { analytics } from "./commands/analytics";
2
3
  import { attachBody } from "./commands/attach-body";
3
4
  import { attachBodyImages } from "./commands/attach-body-images";
4
5
  import { attachNewspicSpec } from "./commands/attach-newspic-spec";
@@ -16,12 +17,16 @@ import { prepare } from "./commands/prepare";
16
17
  import { publish } from "./commands/publish";
17
18
  import { reconcile } from "./commands/reconcile";
18
19
  import { render } from "./commands/render";
20
+ import { republish } from "./commands/republish";
19
21
  import { reset } from "./commands/reset";
20
22
  import { review } from "./commands/review";
21
23
  import { status } from "./commands/status";
22
24
  import { syncBlog } from "./commands/sync-blog";
23
25
  import { tasks } from "./commands/tasks";
26
+ import { topic } from "./commands/topic";
24
27
  import { wechatExport } from "./commands/wechat-export";
28
+ import { wxDrafts } from "./commands/wx-drafts";
29
+ import { wxDraftDelete } from "./commands/wx-draft-delete";
25
30
 
26
31
  export interface CommandDefinition {
27
32
  name: string;
@@ -62,13 +67,18 @@ export function getCommandPlugins(): CommandPlugin[] {
62
67
  {
63
68
  name: "ops",
64
69
  commands: [
65
- { name: "sync-blog", summary: "Copy canonical markdown to the blog repo and publish there", plugin: "ops", handler: syncBlog },
70
+ { name: "sync-blog", summary: "Copy post.md to blog repo, publish, and record result in state JSON", plugin: "ops", handler: syncBlog },
71
+ { name: "republish", summary: "Publish completed task to additional accounts/platforms", plugin: "ops", handler: republish },
66
72
  { name: "imgx", summary: "Run bundled imgx renderer subcommands", plugin: "ops", handler: imgxCommand },
67
73
  { name: "wechat-export", summary: "Render markdown to WeChat HTML with bundled preview styles", plugin: "ops", handler: wechatExport },
68
74
  { name: "cos-upload", summary: "Upload a local image to configured COS CDN", plugin: "ops", handler: cosUpload },
69
75
  { name: "config", summary: "Read or update pipeline config", plugin: "ops", handler: configCommand },
70
76
  { name: "doctor", summary: "Inspect resolved paths and provider health", plugin: "ops", handler: doctor },
71
77
  { name: "hermes-metrics", summary: "Show Hermes execution metrics per task", plugin: "ops", handler: hermesMetrics },
78
+ { name: "wx-drafts", summary: "List or get drafts from WeChat draft box", plugin: "ops", handler: wxDrafts },
79
+ { name: "wx-draft-delete", summary: "Delete a draft from WeChat draft box", plugin: "ops", handler: wxDraftDelete },
80
+ { name: "topic", summary: "Manage topics (add, list, update, schedule, retro, abandon)", plugin: "ops", handler: topic },
81
+ { name: "analytics", summary: "Record and analyze post-publish metrics", plugin: "ops", handler: analytics },
72
82
  ],
73
83
  },
74
84
  ];
@@ -84,9 +84,12 @@ export async function publishBlogRoute({
84
84
  }: BlogPublishContext): Promise<PublishResult> {
85
85
  const postPath = join(state.asset_path, "post.md");
86
86
  const postContent = await readFile(postPath, "utf-8");
87
- const category = "posts";
87
+ const category = "nezus";
88
88
  const blogRoot = workspacePaths.blogRoot;
89
- const blogPostPath = join(blogRoot, "content", category, `${state.metadata.slug}.md`);
89
+
90
+ const [year, month] = state.metadata.date.split("-");
91
+ const blogPostDir = join(blogRoot, "content", category, year, month);
92
+ const blogPostPath = join(blogPostDir, `${state.metadata.slug}.md`);
90
93
 
91
94
  const lines = ["---"];
92
95
  lines.push(`title: "${state.metadata.title.replace(/"/g, '\\"')}"`);
@@ -120,6 +123,7 @@ export async function publishBlogRoute({
120
123
  console.error(`[dry-run] Blog: cd ${blogRoot} && ${config.commands.blogPublish.join(" ")}`);
121
124
  return {
122
125
  route: "blog",
126
+ account: "default",
123
127
  status: "skipped",
124
128
  detail: "dry-run",
125
129
  published_at: null,
@@ -128,13 +132,14 @@ export async function publishBlogRoute({
128
132
  };
129
133
  }
130
134
 
131
- await mkdir(join(blogRoot, "content", category), { recursive: true });
135
+ await mkdir(blogPostDir, { recursive: true });
132
136
  await writeFile(blogPostPath, blogContent, "utf-8");
133
137
  const result = spawnSync(config.commands.blogPublish, { cwd: blogRoot });
134
138
 
135
139
  if (result.exitCode !== 0) {
136
140
  return {
137
141
  route: "blog",
142
+ account: "default",
138
143
  status: "failed",
139
144
  detail: `blog publish command failed: ${config.commands.blogPublish.join(" ")}`,
140
145
  published_at: null,
@@ -145,6 +150,7 @@ export async function publishBlogRoute({
145
150
 
146
151
  return {
147
152
  route: "blog",
153
+ account: "default",
148
154
  status: "success",
149
155
  detail: null,
150
156
  published_at: new Date().toISOString(),
@@ -21,6 +21,6 @@ describe("getPublishProvider", () => {
21
21
  });
22
22
 
23
23
  test("throws for unregistered route", () => {
24
- expect(() => getPublishProvider("blog" as never)).toThrow();
24
+ expect(() => getPublishProvider("nonexistent" as never)).toThrow();
25
25
  });
26
26
  });
@@ -7,6 +7,7 @@ import {
7
7
  prepareBodyForNewspic,
8
8
  } from "../text";
9
9
  import { resolveMarkdownRenderer } from "../adapter-loader";
10
+ import { publishBlogRoute } from "./blog";
10
11
  import { createWechatDraft, createWechatNewspic, extractImageUrls, mergePhotoLists } from "./wechat";
11
12
 
12
13
  export interface PublishRouteContext {
@@ -14,6 +15,7 @@ export interface PublishRouteContext {
14
15
  dryRun: boolean;
15
16
  config: PipelineConfig;
16
17
  workspacePaths: ResolvedWorkspacePaths;
18
+ accountOverride?: string;
17
19
  }
18
20
 
19
21
  export type PublishProvider = (ctx: PublishRouteContext) => Promise<PublishResult>;
@@ -23,6 +25,7 @@ async function publishWechatArticleRoute({
23
25
  dryRun,
24
26
  config,
25
27
  workspacePaths,
28
+ accountOverride,
26
29
  }: PublishRouteContext): Promise<PublishResult> {
27
30
  const postPath = join(state.asset_path, "post.md");
28
31
  let exportPostPath = postPath;
@@ -51,11 +54,14 @@ async function publishWechatArticleRoute({
51
54
  }
52
55
  }
53
56
 
57
+ const account = accountOverride || state.route.account;
58
+
54
59
  if (dryRun) {
55
60
  console.error(`[dry-run] Zotepad export: ${exportPostPath}`);
56
61
  console.error(`[dry-run] Wechat draft: ${state.metadata.title}`);
57
62
  return {
58
63
  route: "wechat-article",
64
+ account,
59
65
  status: "skipped",
60
66
  detail: "dry-run",
61
67
  published_at: null,
@@ -66,24 +72,30 @@ async function publishWechatArticleRoute({
66
72
 
67
73
  try {
68
74
  const markdownRenderer = await resolveMarkdownRenderer(config);
69
- const wxAccount = config.wx.accounts[state.route.account] ?? config.wx.accounts[config.wx.defaultAccount];
75
+ const wxAccount = config.wx.accounts[account] ?? config.wx.accounts[config.wx.defaultAccount];
70
76
  const { html } = await markdownRenderer.render({
71
77
  markdownPath: exportPostPath,
72
78
  outPath: workspacePaths.zotepadExportHtml,
73
- account: state.route.account,
79
+ account,
74
80
  title: state.metadata.title,
75
81
  customCss: wxAccount?.customCss ?? null,
82
+ themeOverrides: wxAccount?.theme,
76
83
  });
77
84
  await createWechatDraft({
78
- account: state.route.account,
85
+ account,
79
86
  title: state.metadata.title,
80
87
  html,
81
88
  photos,
82
89
  config,
90
+ existingDraftMediaId: state.intent.existing_draft_media_id,
91
+ noteId: state.intent.note_id,
92
+ nezusBaseUrl: process.env.ZZHUB_WX_BASE_URL || config.wx.baseUrl,
93
+ nezusPat: process.env.ZZCLUB_PAT || config.wx.accounts[account]?.pat || config.wx.accounts[config.wx.defaultAccount]?.pat,
83
94
  });
84
95
  } catch (error) {
85
96
  return {
86
97
  route: "wechat-article",
98
+ account,
87
99
  status: "failed",
88
100
  detail: error instanceof Error ? error.message : String(error),
89
101
  published_at: null,
@@ -94,6 +106,7 @@ async function publishWechatArticleRoute({
94
106
 
95
107
  return {
96
108
  route: "wechat-article",
109
+ account,
97
110
  status: "success",
98
111
  detail: null,
99
112
  published_at: new Date().toISOString(),
@@ -106,6 +119,7 @@ async function publishWechatNewspicRoute({
106
119
  state,
107
120
  dryRun,
108
121
  config,
122
+ accountOverride,
109
123
  }: PublishRouteContext): Promise<PublishResult> {
110
124
  const postPath = join(state.asset_path, "post.md");
111
125
  const postContent = await readFile(postPath, "utf-8");
@@ -125,11 +139,14 @@ async function publishWechatNewspicRoute({
125
139
  const renderPhotos = assets.map((asset) => asset.path);
126
140
  const photos = mergePhotoLists(renderPhotos, bodyImageUrls);
127
141
 
142
+ const account = accountOverride || state.route.account;
143
+
128
144
  if (dryRun) {
129
145
  console.error(`[dry-run] Wechat newspic: ${state.metadata.title}`);
130
146
  console.error(`[dry-run] Content file: ${cleanPath}`);
131
147
  return {
132
148
  route: "wechat-newspic",
149
+ account,
133
150
  status: "skipped",
134
151
  detail: "dry-run",
135
152
  published_at: null,
@@ -140,15 +157,20 @@ async function publishWechatNewspicRoute({
140
157
 
141
158
  try {
142
159
  await createWechatNewspic({
143
- account: state.route.account,
160
+ account,
144
161
  title: state.metadata.title,
145
162
  content: cleanContent,
146
163
  photos,
147
164
  config,
165
+ existingDraftMediaId: state.intent.existing_draft_media_id,
166
+ noteId: state.intent.note_id,
167
+ nezusBaseUrl: process.env.ZZHUB_WX_BASE_URL || config.wx.baseUrl,
168
+ nezusPat: process.env.ZZCLUB_PAT || config.wx.accounts[account]?.pat || config.wx.accounts[config.wx.defaultAccount]?.pat,
148
169
  });
149
170
  } catch (error) {
150
171
  return {
151
172
  route: "wechat-newspic",
173
+ account,
152
174
  status: "failed",
153
175
  detail: error instanceof Error ? error.message : String(error),
154
176
  published_at: null,
@@ -159,6 +181,7 @@ async function publishWechatNewspicRoute({
159
181
 
160
182
  return {
161
183
  route: "wechat-newspic",
184
+ account,
162
185
  status: "success",
163
186
  detail: null,
164
187
  published_at: new Date().toISOString(),
@@ -167,9 +190,10 @@ async function publishWechatNewspicRoute({
167
190
  };
168
191
  }
169
192
 
170
- const PUBLISH_PROVIDERS: Partial<Record<RoutePrimary, PublishProvider>> = {
193
+ const PUBLISH_PROVIDERS: Record<RoutePrimary, PublishProvider> = {
171
194
  "wechat-article": publishWechatArticleRoute,
172
195
  "wechat-newspic": publishWechatNewspicRoute,
196
+ "blog": publishBlogRoute,
173
197
  };
174
198
 
175
199
  export function getPublishProvider(route: RoutePrimary): PublishProvider {
@@ -0,0 +1,124 @@
1
+ /**
2
+ * publish-core.ts — Shared publish execution logic.
3
+ *
4
+ * Used by both `publish` (normal flow) and `republish` (add-on).
5
+ */
6
+
7
+ import type { PipelineConfig, ResolvedWorkspacePaths } from "../config";
8
+ import type { PublishResult, PublishTarget, WorkflowState } from "../state";
9
+ import { getPublishProvider, type PublishRouteContext } from "./index";
10
+
11
+ export interface PublishTargetError {
12
+ route: string;
13
+ account: string;
14
+ error: string;
15
+ }
16
+
17
+ export interface ExecutePublishTargetsParams {
18
+ state: WorkflowState;
19
+ targets: PublishTarget[];
20
+ dryRun: boolean;
21
+ config: PipelineConfig;
22
+ workspacePaths: ResolvedWorkspacePaths;
23
+ }
24
+
25
+ export interface ExecutePublishTargetsResult {
26
+ results: PublishResult[];
27
+ errors: PublishTargetError[];
28
+ }
29
+
30
+ /**
31
+ * Deduplicate targets by route+account.
32
+ */
33
+ export function dedupeTargets(targets: PublishTarget[]): PublishTarget[] {
34
+ const seen = new Set<string>();
35
+ const result: PublishTarget[] = [];
36
+ for (const target of targets) {
37
+ const key = `${target.route}@${target.account}`;
38
+ if (!seen.has(key)) {
39
+ seen.add(key);
40
+ result.push(target);
41
+ }
42
+ }
43
+ return result;
44
+ }
45
+
46
+ /**
47
+ * Filter out targets that already have a successful publish result
48
+ * at the current content_version + render_version.
49
+ */
50
+ export function filterIdempotent(
51
+ targets: PublishTarget[],
52
+ existingResults: PublishResult[],
53
+ contentVersion: number,
54
+ renderVersion: number,
55
+ ): PublishTarget[] {
56
+ return targets.filter((target) => {
57
+ const existing = existingResults.find(
58
+ (r) =>
59
+ r.route === target.route &&
60
+ r.account === target.account &&
61
+ r.status === "success" &&
62
+ r.content_version === contentVersion &&
63
+ r.render_version === renderVersion,
64
+ );
65
+ return !existing;
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Execute publish for a list of targets in parallel.
71
+ */
72
+ export async function executePublishTargets(
73
+ params: ExecutePublishTargetsParams,
74
+ ): Promise<ExecutePublishTargetsResult> {
75
+ const { state, targets, dryRun, config, workspacePaths } = params;
76
+
77
+ const deduped = dedupeTargets(targets);
78
+ const filtered = filterIdempotent(
79
+ deduped,
80
+ state.publish.results,
81
+ state.artifacts.content_version,
82
+ state.artifacts.render_version,
83
+ );
84
+
85
+ const results: PublishResult[] = [];
86
+ const errors: PublishTargetError[] = [];
87
+
88
+ // Execute in parallel
89
+ const promises = filtered.map(async (target) => {
90
+ const provider = getPublishProvider(target.route);
91
+ const ctx: PublishRouteContext = {
92
+ state,
93
+ dryRun,
94
+ config,
95
+ workspacePaths,
96
+ accountOverride: target.account,
97
+ };
98
+
99
+ try {
100
+ const result = await provider(ctx);
101
+ return { result, error: null };
102
+ } catch (err) {
103
+ const error: PublishTargetError = {
104
+ route: target.route,
105
+ account: target.account,
106
+ error: err instanceof Error ? err.message : String(err),
107
+ };
108
+ return { result: null, error };
109
+ }
110
+ });
111
+
112
+ const outcomes = await Promise.all(promises);
113
+
114
+ for (const { result, error } of outcomes) {
115
+ if (result) {
116
+ results.push(result);
117
+ }
118
+ if (error) {
119
+ errors.push(error);
120
+ }
121
+ }
122
+
123
+ return { results, errors };
124
+ }