@zzclub/pipeline 0.5.1 → 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 (46) hide show
  1. package/README.md +75 -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 +35 -2
  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/config.ts +0 -0
  16. package/src/db.test.ts +47 -0
  17. package/src/db.ts +95 -0
  18. package/src/imgx/assets/icons/ancientone-logo.png +0 -0
  19. package/src/imgx/assets/templates/wechat-cover-split.html +1 -1
  20. package/src/imgx/render-card.ts +1 -5
  21. package/src/plugins.ts +7 -1
  22. package/src/providers/blog.ts +9 -3
  23. package/src/providers/index.test.ts +1 -1
  24. package/src/providers/index.ts +23 -7
  25. package/src/providers/publish-core.ts +124 -0
  26. package/src/providers/wechat.ts +31 -9
  27. package/src/routes.ts +1 -1
  28. package/src/schema/analytics.test.ts +54 -0
  29. package/src/schema/analytics.ts +19 -0
  30. package/src/schema/config.ts +25 -1
  31. package/src/schema/state.ts +8 -0
  32. package/src/schema/topic.test.ts +69 -0
  33. package/src/schema/topic.ts +55 -0
  34. package/src/state.ts +1 -0
  35. package/src/text.test.ts +11 -6
  36. package/src/text.ts +27 -9
  37. package/src/wechat-preview/assets/browser-dist/.vite/manifest.json +2 -7
  38. package/src/wechat-preview/assets/browser-dist/assets/{style-BYNXnUvc.css → style-vhHYSP8T.css} +1 -1
  39. package/src/wechat-preview/assets/browser-dist/editor-export.js +27 -27
  40. package/src/wechat-preview/browser/editor-export.css +248 -159
  41. package/src/wechat-preview/index.ts +28 -11
  42. package/src/wechat-preview/themes.ts +70 -24
  43. package/src/wechat-preview/wechat-formatter.test.ts +0 -1
  44. package/src/wechat-preview/wechat-formatter.ts +48 -23
  45. package/src/workflow.test.ts +294 -1
  46. 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);
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,11 +17,13 @@ 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";
25
28
  import { wxDrafts } from "./commands/wx-drafts";
26
29
  import { wxDraftDelete } from "./commands/wx-draft-delete";
@@ -64,7 +67,8 @@ export function getCommandPlugins(): CommandPlugin[] {
64
67
  {
65
68
  name: "ops",
66
69
  commands: [
67
- { 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 },
68
72
  { name: "imgx", summary: "Run bundled imgx renderer subcommands", plugin: "ops", handler: imgxCommand },
69
73
  { name: "wechat-export", summary: "Render markdown to WeChat HTML with bundled preview styles", plugin: "ops", handler: wechatExport },
70
74
  { name: "cos-upload", summary: "Upload a local image to configured COS CDN", plugin: "ops", handler: cosUpload },
@@ -73,6 +77,8 @@ export function getCommandPlugins(): CommandPlugin[] {
73
77
  { name: "hermes-metrics", summary: "Show Hermes execution metrics per task", plugin: "ops", handler: hermesMetrics },
74
78
  { name: "wx-drafts", summary: "List or get drafts from WeChat draft box", plugin: "ops", handler: wxDrafts },
75
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 },
76
82
  ],
77
83
  },
78
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,16 +72,17 @@ 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,
@@ -83,11 +90,12 @@ async function publishWechatArticleRoute({
83
90
  existingDraftMediaId: state.intent.existing_draft_media_id,
84
91
  noteId: state.intent.note_id,
85
92
  nezusBaseUrl: process.env.ZZHUB_WX_BASE_URL || config.wx.baseUrl,
86
- nezusPat: process.env.ZZCLUB_PAT || config.wx.accounts[state.route.account]?.pat || config.wx.accounts[config.wx.defaultAccount]?.pat,
93
+ nezusPat: process.env.ZZCLUB_PAT || config.wx.accounts[account]?.pat || config.wx.accounts[config.wx.defaultAccount]?.pat,
87
94
  });
88
95
  } catch (error) {
89
96
  return {
90
97
  route: "wechat-article",
98
+ account,
91
99
  status: "failed",
92
100
  detail: error instanceof Error ? error.message : String(error),
93
101
  published_at: null,
@@ -98,6 +106,7 @@ async function publishWechatArticleRoute({
98
106
 
99
107
  return {
100
108
  route: "wechat-article",
109
+ account,
101
110
  status: "success",
102
111
  detail: null,
103
112
  published_at: new Date().toISOString(),
@@ -110,6 +119,7 @@ async function publishWechatNewspicRoute({
110
119
  state,
111
120
  dryRun,
112
121
  config,
122
+ accountOverride,
113
123
  }: PublishRouteContext): Promise<PublishResult> {
114
124
  const postPath = join(state.asset_path, "post.md");
115
125
  const postContent = await readFile(postPath, "utf-8");
@@ -129,11 +139,14 @@ async function publishWechatNewspicRoute({
129
139
  const renderPhotos = assets.map((asset) => asset.path);
130
140
  const photos = mergePhotoLists(renderPhotos, bodyImageUrls);
131
141
 
142
+ const account = accountOverride || state.route.account;
143
+
132
144
  if (dryRun) {
133
145
  console.error(`[dry-run] Wechat newspic: ${state.metadata.title}`);
134
146
  console.error(`[dry-run] Content file: ${cleanPath}`);
135
147
  return {
136
148
  route: "wechat-newspic",
149
+ account,
137
150
  status: "skipped",
138
151
  detail: "dry-run",
139
152
  published_at: null,
@@ -144,7 +157,7 @@ async function publishWechatNewspicRoute({
144
157
 
145
158
  try {
146
159
  await createWechatNewspic({
147
- account: state.route.account,
160
+ account,
148
161
  title: state.metadata.title,
149
162
  content: cleanContent,
150
163
  photos,
@@ -152,11 +165,12 @@ async function publishWechatNewspicRoute({
152
165
  existingDraftMediaId: state.intent.existing_draft_media_id,
153
166
  noteId: state.intent.note_id,
154
167
  nezusBaseUrl: process.env.ZZHUB_WX_BASE_URL || config.wx.baseUrl,
155
- nezusPat: process.env.ZZCLUB_PAT || config.wx.accounts[state.route.account]?.pat || config.wx.accounts[config.wx.defaultAccount]?.pat,
168
+ nezusPat: process.env.ZZCLUB_PAT || config.wx.accounts[account]?.pat || config.wx.accounts[config.wx.defaultAccount]?.pat,
156
169
  });
157
170
  } catch (error) {
158
171
  return {
159
172
  route: "wechat-newspic",
173
+ account,
160
174
  status: "failed",
161
175
  detail: error instanceof Error ? error.message : String(error),
162
176
  published_at: null,
@@ -167,6 +181,7 @@ async function publishWechatNewspicRoute({
167
181
 
168
182
  return {
169
183
  route: "wechat-newspic",
184
+ account,
170
185
  status: "success",
171
186
  detail: null,
172
187
  published_at: new Date().toISOString(),
@@ -175,9 +190,10 @@ async function publishWechatNewspicRoute({
175
190
  };
176
191
  }
177
192
 
178
- const PUBLISH_PROVIDERS: Partial<Record<RoutePrimary, PublishProvider>> = {
193
+ const PUBLISH_PROVIDERS: Record<RoutePrimary, PublishProvider> = {
179
194
  "wechat-article": publishWechatArticleRoute,
180
195
  "wechat-newspic": publishWechatNewspicRoute,
196
+ "blog": publishBlogRoute,
181
197
  };
182
198
 
183
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
+ }
@@ -121,7 +121,8 @@ function fillWxAccountConfig(source?: WxAccountConfig): WxAccountConfig {
121
121
  appId: source?.appId ?? "",
122
122
  appSecret: source?.appSecret ?? "",
123
123
  customCss: source?.customCss ?? null,
124
- };
124
+ theme: source?.theme ?? { editorVars: {}, exportTheme: {} },
125
+ } as WxAccountConfig;
125
126
  }
126
127
 
127
128
  function trimTrailingSlash(value: string): string {
@@ -187,12 +188,17 @@ export function mergePhotoLists(...lists: string[][]): string[] {
187
188
 
188
189
  export function extractImageUrls(input: string): string[] {
189
190
  const urls = new Set<string>();
190
- const markdownImageRegex = /!\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
191
+ // Handle both standard ![alt](url) and angle-bracket ![alt](<url>) syntax.
192
+ // Angle brackets allow URLs with spaces per CommonMark spec.
193
+ // Match markdown images: ![alt](url) and ![alt](<url with spaces>)
194
+ // Second alternative uses lazy match so titles like ![alt](url "title") still work.
195
+ const markdownImageRegex = /!\[[^\]]*\]\((?:<([^>]+)>|([^)]+?))(?:\s+"[^"]*")?\)/g;
191
196
  const htmlImageRegex = /<img[^>]+src=["']([^"']+)["'][^>]*>/g;
192
197
  let match = markdownImageRegex.exec(input);
193
198
  while (match) {
194
- if (match[1]) {
195
- urls.add(match[1]);
199
+ const url = match[1] || match[2];
200
+ if (url) {
201
+ urls.add(url);
196
202
  }
197
203
  match = markdownImageRegex.exec(input);
198
204
  }
@@ -203,7 +209,12 @@ export function extractImageUrls(input: string): string[] {
203
209
  }
204
210
  match = htmlImageRegex.exec(input);
205
211
  }
206
- return [...urls];
212
+ // Filter out URLs that look like HTML fragments (unescaped code block content
213
+ // that was incorrectly matched by the image regex patterns).
214
+ return [...urls].filter((url) => {
215
+ if (url.startsWith('<') || url.includes('/>')) return false;
216
+ return true;
217
+ });
207
218
  }
208
219
 
209
220
  export function replaceImageUrls(html: string, imageUrlMap: Record<string, string>): string {
@@ -335,7 +346,8 @@ async function resolvePhotoPayload(
335
346
  }
336
347
 
337
348
  if (photoUrl.startsWith("http://") || photoUrl.startsWith("https://")) {
338
- const response = await fetch(photoUrl);
349
+ const safeUrl = photoUrl.includes(" ") ? photoUrl.replace(/ /g, "%20") : photoUrl;
350
+ const response = await fetch(safeUrl);
339
351
  if (!response.ok) {
340
352
  throw new Error(`Failed to download image: HTTP ${response.status}`);
341
353
  }
@@ -348,8 +360,9 @@ async function resolvePhotoPayload(
348
360
  }
349
361
 
350
362
  if (photoUrl.startsWith("file://")) {
351
- const url = new URL(photoUrl);
352
- return resolveFilePayload(url.pathname, index);
363
+ const safeUrl = photoUrl.replace(/ /g, "%20");
364
+ const url = new URL(safeUrl);
365
+ return resolveFilePayload(decodeURIComponent(url.pathname), index);
353
366
  }
354
367
 
355
368
  return resolveFilePayload(photoUrl, index);
@@ -359,6 +372,14 @@ function resolveFilePayload(
359
372
  filePath: string,
360
373
  index: number,
361
374
  ): { blob: Blob; filename: string } {
375
+ // Detect HTML fragments incorrectly extracted as image URLs (defense-in-depth)
376
+ if (filePath.startsWith("<") && (filePath.includes("style=") || filePath.includes("</") || filePath.includes("/>"))) {
377
+ throw new Error(
378
+ `Image URL appears to be HTML content, not a valid file path or URL. ` +
379
+ `This usually means extractImageUrls matched unescaped HTML from a code block. ` +
380
+ `Got: ${filePath.slice(0, 120)}`,
381
+ );
382
+ }
362
383
  if (!existsSync(filePath)) {
363
384
  throw new Error(`File not found: ${filePath}`);
364
385
  }
@@ -549,7 +570,8 @@ export async function createWechatNewspic(input: WechatNewspicInput): Promise<Re
549
570
  const uploadResult = await uploadPhotos(runtime, accessToken, finalPhotos, uploadTimeout);
550
571
 
551
572
  const imageInfo = {
552
- image_list: uploadResult.uploadedMedia.map((item) => ({ image_media_id: item.mediaId })),
573
+ image_list: uploadResult.uploadedMedia
574
+ .map((item) => ({ image_media_id: item.mediaId })),
553
575
  };
554
576
 
555
577
  const isUpdate = !!input.existingDraftMediaId;
package/src/routes.ts CHANGED
@@ -148,7 +148,7 @@ const VISUAL_PARAMS: Record<string, AccountVisualParams> = {
148
148
  footer: "公众号 · 古一软件",
149
149
  bg: "#faf5f8",
150
150
  highlight: "#ca6093",
151
- fallback_icon: "assets/icons/logo.png",
151
+ fallback_icon: "assets/icons/ancientone-logo.png",
152
152
  },
153
153
  };
154
154