@zzclub/pipeline 0.5.1 → 0.9.1

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 (47) 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/prepare.ts +1 -1
  10. package/src/commands/publish.ts +44 -67
  11. package/src/commands/republish.ts +146 -0
  12. package/src/commands/sync-blog.ts +19 -3
  13. package/src/commands/topic.test.ts +146 -0
  14. package/src/commands/topic.ts +717 -0
  15. package/src/commands/wechat-export.ts +2 -0
  16. package/src/config.ts +0 -0
  17. package/src/db.test.ts +47 -0
  18. package/src/db.ts +95 -0
  19. package/src/imgx/assets/icons/ancientone-logo.png +0 -0
  20. package/src/imgx/assets/templates/wechat-cover-split.html +1 -1
  21. package/src/imgx/render-card.ts +1 -5
  22. package/src/plugins.ts +7 -1
  23. package/src/providers/blog.ts +9 -3
  24. package/src/providers/index.test.ts +1 -1
  25. package/src/providers/index.ts +23 -7
  26. package/src/providers/publish-core.ts +124 -0
  27. package/src/providers/wechat.ts +31 -9
  28. package/src/routes.ts +1 -1
  29. package/src/schema/analytics.test.ts +54 -0
  30. package/src/schema/analytics.ts +19 -0
  31. package/src/schema/config.ts +25 -1
  32. package/src/schema/state.ts +8 -0
  33. package/src/schema/topic.test.ts +69 -0
  34. package/src/schema/topic.ts +55 -0
  35. package/src/state.ts +1 -0
  36. package/src/text.test.ts +11 -6
  37. package/src/text.ts +27 -9
  38. package/src/wechat-preview/assets/browser-dist/.vite/manifest.json +2 -7
  39. package/src/wechat-preview/assets/browser-dist/assets/{style-BYNXnUvc.css → style-vhHYSP8T.css} +1 -1
  40. package/src/wechat-preview/assets/browser-dist/editor-export.js +27 -27
  41. package/src/wechat-preview/browser/editor-export.css +248 -159
  42. package/src/wechat-preview/index.ts +28 -11
  43. package/src/wechat-preview/themes.ts +70 -24
  44. package/src/wechat-preview/wechat-formatter.test.ts +0 -1
  45. package/src/wechat-preview/wechat-formatter.ts +48 -23
  46. package/src/workflow.test.ts +294 -1
  47. package/src/wechat-preview/assets/browser-dist/assets/SweiCurveLegCJKsc-Regular-B3Ut5bwH.woff +0 -0
package/README.md CHANGED
@@ -43,7 +43,7 @@ bun install --global .
43
43
  - `wechat-article` — 公众号文章草稿(HTML)
44
44
  - `wechat-newspic` — 公众号图片消息(PNG 卡片/图集)
45
45
 
46
- `blog` 只做事后同步(`sync-blog`),不参与主工作流分支。
46
+ `blog` 只做事后同步(`sync-blog`),不参与主工作流分支,但同步结果会写回 `workflow-state.json` 的 `publish.results`。
47
47
 
48
48
  状态文件分两类:
49
49
 
@@ -409,11 +409,11 @@ src/
409
409
  | `review` | Update content review status |
410
410
  | `abandon` | Mark one or more tasks as abandoned |
411
411
 
412
- `ops` 组,9 个命令:
412
+ `ops` 组,11 个命令:
413
413
 
414
414
  | 命令 | 说明 |
415
415
  | --- | --- |
416
- | `sync-blog` | Copy canonical markdown to the blog repo and publish there |
416
+ | `sync-blog` | Copy post.md to blog repo, publish, and record result in state JSON |
417
417
  | `imgx` | Run bundled imgx renderer subcommands |
418
418
  | `wechat-export` | Render markdown to WeChat HTML with bundled preview styles |
419
419
  | `cos-upload` | Upload a local image to configured COS CDN |
@@ -422,6 +422,8 @@ src/
422
422
  | `hermes-metrics` | Show Hermes execution metrics per task |
423
423
  | `wx-drafts` | List or get drafts from WeChat draft box |
424
424
  | `wx-draft-delete` | Delete a draft from WeChat draft box |
425
+ | `topic` | 选题管理(添加、列表、更新、排期、复盘、放弃) |
426
+ | `analytics` | 发布数据录入和分析 |
425
427
 
426
428
  ## 常用命令
427
429
 
@@ -516,6 +518,75 @@ bun run src/cli.ts reset --state {state_path} --mode full # 完全放弃
516
518
 
517
519
  每种模式均会设置 `redo_hint`,确保 Agent 恢复时知道从哪个子步骤开始。
518
520
 
521
+ ### 选题管理
522
+
523
+ 使用 `topic` 命令管理内容选题的完整生命周期:
524
+
525
+ ```bash
526
+ # 添加选题
527
+ bun run src/cli.ts topic add \
528
+ --workspace {workspace} \
529
+ --title "AI 工具推荐" \
530
+ --priority high \
531
+ --tags "AI,工具"
532
+
533
+ # 列出选题
534
+ bun run src/cli.ts topic list --workspace {workspace}
535
+ bun run src/cli.ts topic list --workspace {workspace} --status backlog --priority high
536
+
537
+ # 更新选题(AI 评估)
538
+ bun run src/cli.ts topic update \
539
+ --workspace {workspace} \
540
+ --topic {topic_id} \
541
+ --ai-score 85 \
542
+ --ai-reason "基于热点趋势和受众匹配度"
543
+
544
+ # 排期
545
+ bun run src/cli.ts topic schedule \
546
+ --workspace {workspace} \
547
+ --topic {topic_id} \
548
+ --scheduled-date 2026-06-15 \
549
+ --target-account default
550
+
551
+ # 复盘(发布后)
552
+ bun run src/cli.ts topic retro \
553
+ --workspace {workspace} \
554
+ --topic {topic_id} \
555
+ --performance good \
556
+ --lessons "标题悬念效果好" \
557
+ --metrics-snapshot '{"reads": 1500, "likes": 45}'
558
+
559
+ # 放弃选题
560
+ bun run src/cli.ts topic abandon \
561
+ --workspace {workspace} \
562
+ --topic {topic_id} \
563
+ --reason "话题过时"
564
+ ```
565
+
566
+ 选题状态流转:`backlog` → `evaluating` → `scheduled` → `in_progress` → `published`(或 `abandoned`)
567
+
568
+ ### 数据分析
569
+
570
+ 使用 `analytics` 命令录入和分析发布后的数据:
571
+
572
+ ```bash
573
+ # 录入发布数据
574
+ bun run src/cli.ts analytics record \
575
+ --state {workspace}/posts/{date-slug}/workflow-state.json \
576
+ --reads 1500 \
577
+ --likes 45 \
578
+ --favorites 23 \
579
+ --shares 12 \
580
+ --comments 8 \
581
+ --notes "标题效果好,转化率高"
582
+
583
+ # 列出历史数据
584
+ bun run src/cli.ts analytics list --workspace {workspace}
585
+ bun run src/cli.ts analytics list --workspace {workspace} --days 30 --sort reads
586
+ ```
587
+
588
+ 数据存储在 `{workspace}/zzhub.db`(SQLite 数据库),支持复杂查询和统计分析。
589
+
519
590
  ### Blog 同步
520
591
 
521
592
  ```bash
@@ -527,6 +598,7 @@ bun run src/cli.ts sync-blog --state {workspace}/posts/{date-slug}/workflow-stat
527
598
  - 读取 canonical `post.md`
528
599
  - 复制到博客仓库 `content/posts/<slug>.md`
529
600
  - 执行博客发布命令
601
+ - 将 blog publish result 写回 `workflow-state.json`,方便筛查是否遗漏同步
530
602
 
531
603
  ### 配置管理
532
604
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zzclub/pipeline",
3
- "version": "0.5.1",
3
+ "version": "0.9.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "zzp": "./src/cli.ts",
@@ -137,6 +137,12 @@ export interface MarkdownRenderInput {
137
137
 
138
138
  /** Workflow state for context */
139
139
  state?: WorkflowState;
140
+
141
+ /** Per-account theme overrides from config. Deep-merged onto hardcoded defaults. */
142
+ themeOverrides?: {
143
+ editorVars?: Record<string, string>;
144
+ exportTheme?: Record<string, string>;
145
+ };
140
146
  }
141
147
 
142
148
  /**
@@ -144,6 +144,12 @@ async function renderCover(
144
144
 
145
145
  if (template === "wechat-cover-split") {
146
146
  const cmdParts = ["--template", "wechat-cover-split", "--text", title];
147
+ if (visualParams) {
148
+ cmdParts.push("--highlight", visualParams.highlight);
149
+ cmdParts.push("--bg", visualParams.bg);
150
+ cmdParts.push("--footer", visualParams.footer);
151
+ cmdParts.push("--fallback-icon", visualParams.fallback_icon);
152
+ }
147
153
  cmdParts.push("--highlight-words", highlightWords.join(","));
148
154
  cmdParts.push("--out", coverOut);
149
155
  runRenderCardCli(cmdParts);
@@ -303,7 +309,7 @@ export const builtinImageRenderer: ImageRenderPlugin = {
303
309
 
304
310
  if (route === "wechat-article") {
305
311
  // Article: cover only (wechat-cover-split)
306
- const cover = await renderCover("wechat-cover-split", title, outputDir, null, highlightWords);
312
+ const cover = await renderCover("wechat-cover-split", title, outputDir, vp, highlightWords);
307
313
  return { assets: [cover], pageCount: 1, pages: [{ page: 1, imageCount: 0, imageSources: [] }] };
308
314
  }
309
315
 
@@ -50,6 +50,7 @@ export const builtinMarkdownRenderer: MarkdownRenderPlugin = {
50
50
  title: input.title,
51
51
  previewShellOutPath: input.previewShellOutPath,
52
52
  customCss: input.customCss,
53
+ themeOverrides: input.themeOverrides,
53
54
  });
54
55
 
55
56
  return {
@@ -0,0 +1,155 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { mkdtemp, rm, writeFile } from "fs/promises";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+ import { recordAnalytics, listAnalytics } from "./analytics";
6
+
7
+ describe("analytics commands", () => {
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
+ describe("recordAnalytics", () => {
19
+ test("records analytics from state file", async () => {
20
+ const statePath = join(workspace, "state.json");
21
+ await writeFile(statePath, JSON.stringify({
22
+ run_id: "run_001",
23
+ metadata: {
24
+ title: "Test Article",
25
+ date: "2026-06-11",
26
+ },
27
+ route: {
28
+ primary: "wechat-article",
29
+ },
30
+ publish: {
31
+ results: {
32
+ wechat: {
33
+ status: "success",
34
+ published_at: "2026-06-11T10:00:00Z",
35
+ },
36
+ },
37
+ },
38
+ }));
39
+
40
+ const result = await recordAnalytics({
41
+ statePath,
42
+ reads: 1500,
43
+ likes: 45,
44
+ favorites: 23,
45
+ shares: 12,
46
+ comments: 8,
47
+ notes: "Good performance",
48
+ });
49
+
50
+ expect(result.run_id).toBe("run_001");
51
+ expect(result.reads).toBe(1500);
52
+ expect(result.likes).toBe(45);
53
+ expect(result.favorites).toBe(23);
54
+ expect(result.shares).toBe(12);
55
+ expect(result.comments).toBe(8);
56
+ expect(result.notes).toBe("Good performance");
57
+ expect(result.title).toBe("Test Article");
58
+ expect(result.publish_date).toBe("2026-06-11");
59
+ });
60
+
61
+ test("defaults missing metrics to 0", async () => {
62
+ const statePath = join(workspace, "state.json");
63
+ await writeFile(statePath, JSON.stringify({
64
+ run_id: "run_002",
65
+ metadata: { title: "Minimal", date: "2026-06-11" },
66
+ route: { primary: "wechat-article" },
67
+ publish: { results: { wechat: { status: "success", published_at: "2026-06-11T10:00:00Z" } } },
68
+ }));
69
+
70
+ const result = await recordAnalytics({ statePath });
71
+
72
+ expect(result.run_id).toBe("run_002");
73
+ expect(result.reads).toBe(0);
74
+ expect(result.likes).toBe(0);
75
+ expect(result.favorites).toBe(0);
76
+ expect(result.shares).toBe(0);
77
+ expect(result.comments).toBe(0);
78
+ });
79
+
80
+ test("re-recording replaces previous entry", async () => {
81
+ const statePath = join(workspace, "state.json");
82
+ await writeFile(statePath, JSON.stringify({
83
+ run_id: "run_003",
84
+ metadata: { title: "Update Me", date: "2026-06-11" },
85
+ route: { primary: "wechat-article" },
86
+ publish: { results: { wechat: { status: "success", published_at: "2026-06-11T10:00:00Z" } } },
87
+ }));
88
+
89
+ await recordAnalytics({ statePath, reads: 100 });
90
+ const updated = await recordAnalytics({ statePath, reads: 200 });
91
+
92
+ expect(updated.reads).toBe(200);
93
+
94
+ const all = await listAnalytics(workspace, {});
95
+ expect(all.filter(a => a.run_id === "run_003").length).toBe(1);
96
+ });
97
+ });
98
+
99
+ describe("listAnalytics", () => {
100
+ test("lists all analytics", async () => {
101
+ const statePath1 = join(workspace, "state1.json");
102
+ await writeFile(statePath1, JSON.stringify({
103
+ run_id: "run_001",
104
+ metadata: { title: "Article 1", date: "2026-06-11" },
105
+ route: { primary: "wechat-article" },
106
+ publish: { results: { wechat: { status: "success", published_at: "2026-06-11T10:00:00Z" } } },
107
+ }));
108
+
109
+ const statePath2 = join(workspace, "state2.json");
110
+ await writeFile(statePath2, JSON.stringify({
111
+ run_id: "run_002",
112
+ metadata: { title: "Article 2", date: "2026-06-12" },
113
+ route: { primary: "wechat-article" },
114
+ publish: { results: { wechat: { status: "success", published_at: "2026-06-12T10:00:00Z" } } },
115
+ }));
116
+
117
+ await recordAnalytics({ statePath: statePath1, reads: 1000 });
118
+ await recordAnalytics({ statePath: statePath2, reads: 2000 });
119
+
120
+ const analytics = await listAnalytics(workspace, {});
121
+ expect(analytics.length).toBe(2);
122
+ });
123
+
124
+ test("filters by days", async () => {
125
+ const statePath = join(workspace, "state.json");
126
+ await writeFile(statePath, JSON.stringify({
127
+ run_id: "run_001",
128
+ metadata: { title: "Article", date: "2026-06-11" },
129
+ route: { primary: "wechat-article" },
130
+ publish: { results: { wechat: { status: "success", published_at: "2026-06-11T10:00:00Z" } } },
131
+ }));
132
+
133
+ await recordAnalytics({ statePath, reads: 1000 });
134
+
135
+ const analytics = await listAnalytics(workspace, { days: 30 });
136
+ expect(analytics.length).toBe(1);
137
+ });
138
+
139
+ test("limits results", async () => {
140
+ for (let i = 0; i < 5; i++) {
141
+ const statePath = join(workspace, `state${i}.json`);
142
+ await writeFile(statePath, JSON.stringify({
143
+ run_id: `run_${String(i).padStart(3, "0")}`,
144
+ metadata: { title: `Article ${i}`, date: "2026-06-11" },
145
+ route: { primary: "wechat-article" },
146
+ publish: { results: { wechat: { status: "success", published_at: "2026-06-11T10:00:00Z" } } },
147
+ }));
148
+ await recordAnalytics({ statePath, reads: 100 * (i + 1) });
149
+ }
150
+
151
+ const analytics = await listAnalytics(workspace, { limit: 3 });
152
+ expect(analytics.length).toBe(3);
153
+ });
154
+ });
155
+ });
@@ -0,0 +1,223 @@
1
+ import { readFile } from "fs/promises";
2
+ import { dirname } from "path";
3
+ import { ensureDb } from "../db";
4
+ import { AnalyticsSchema, type Analytics } from "../schema/analytics";
5
+ import { parseArgs, requireArg, optionalArg } from "../args";
6
+ import { printResult } from "../output";
7
+
8
+ export interface RecordAnalyticsInput {
9
+ statePath: string;
10
+ reads?: number;
11
+ likes?: number;
12
+ favorites?: number;
13
+ shares?: number;
14
+ comments?: number;
15
+ notes?: string;
16
+ }
17
+
18
+ function resolveWorkspace(statePath: string): string {
19
+ const postsIdx = statePath.lastIndexOf("/posts/");
20
+ if (postsIdx !== -1) {
21
+ return statePath.substring(0, postsIdx);
22
+ }
23
+ return dirname(statePath);
24
+ }
25
+
26
+ export async function recordAnalytics(
27
+ input: RecordAnalyticsInput,
28
+ ): Promise<Analytics> {
29
+ const stateContent = await readFile(input.statePath, "utf-8");
30
+ const state = JSON.parse(stateContent);
31
+
32
+ const workspace = resolveWorkspace(input.statePath);
33
+ const db = ensureDb(workspace);
34
+
35
+ try {
36
+ const now = new Date().toISOString();
37
+ const publishDate = state.metadata?.date || now.split("T")[0];
38
+ const title = state.metadata?.title || "Untitled";
39
+ const topicId = state.topic_id || null;
40
+
41
+ db.prepare(
42
+ `INSERT OR REPLACE INTO analytics (
43
+ run_id, topic_id, title, publish_date,
44
+ reads, likes, favorites, shares, comments,
45
+ notes, recorded_at
46
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
47
+ ).run(
48
+ state.run_id,
49
+ topicId,
50
+ title,
51
+ publishDate,
52
+ input.reads ?? 0,
53
+ input.likes ?? 0,
54
+ input.favorites ?? 0,
55
+ input.shares ?? 0,
56
+ input.comments ?? 0,
57
+ input.notes ?? null,
58
+ now,
59
+ );
60
+
61
+ const row = db
62
+ .prepare("SELECT * FROM analytics WHERE run_id = ?")
63
+ .get(state.run_id) as Record<string, unknown>;
64
+ return AnalyticsSchema.parse(row);
65
+ } finally {
66
+ db.close();
67
+ }
68
+ }
69
+
70
+ export interface ListAnalyticsFilter {
71
+ days?: number;
72
+ sort?: string;
73
+ limit?: number;
74
+ }
75
+
76
+ const ALLOWED_SORT_FIELDS = new Set([
77
+ "publish_date",
78
+ "reads",
79
+ "likes",
80
+ "favorites",
81
+ "shares",
82
+ "comments",
83
+ "recorded_at",
84
+ "title",
85
+ ]);
86
+
87
+ export async function listAnalytics(
88
+ workspace: string,
89
+ filter: ListAnalyticsFilter,
90
+ ): Promise<Analytics[]> {
91
+ const db = ensureDb(workspace);
92
+
93
+ try {
94
+ const conditions: string[] = [];
95
+ const params: (string | number)[] = [];
96
+
97
+ if (filter.days) {
98
+ conditions.push("publish_date >= date('now', ?)");
99
+ params.push(`-${filter.days} days`);
100
+ }
101
+
102
+ const where =
103
+ conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
104
+
105
+ const sortField = filter.sort || "publish_date";
106
+ if (!ALLOWED_SORT_FIELDS.has(sortField)) {
107
+ throw new Error(
108
+ `Invalid sort field: ${sortField}. Allowed: ${[...ALLOWED_SORT_FIELDS].join(", ")}`,
109
+ );
110
+ }
111
+
112
+ const limit = filter.limit ?? 50;
113
+
114
+ const sql = `SELECT * FROM analytics ${where} ORDER BY ${sortField} DESC LIMIT ?`;
115
+ params.push(limit);
116
+
117
+ const rows = db
118
+ .prepare(sql)
119
+ .all(...params) as Record<string, unknown>[];
120
+ return rows.map((row) => AnalyticsSchema.parse(row));
121
+ } finally {
122
+ db.close();
123
+ }
124
+ }
125
+
126
+ // CLI handler
127
+ export async function analytics(args: string[]): Promise<void> {
128
+ const parsed = parseArgs(args);
129
+
130
+ if (parsed.help || args.length === 0) {
131
+ console.log(`
132
+ Usage: zzhub-pipeline analytics <subcommand> [options]
133
+
134
+ Subcommands:
135
+ record Record post-publish metrics
136
+ list List analytics history
137
+
138
+ Run 'zzhub-pipeline analytics <subcommand> --help' for details.
139
+ `.trim());
140
+ return;
141
+ }
142
+
143
+ const subcommand = args[0];
144
+ const subArgs = args.slice(1);
145
+
146
+ switch (subcommand) {
147
+ case "record":
148
+ return analyticsRecord(subArgs);
149
+ case "list":
150
+ return analyticsList(subArgs);
151
+ default:
152
+ throw new Error(`Unknown analytics subcommand: ${subcommand}`);
153
+ }
154
+ }
155
+
156
+ async function analyticsRecord(args: string[]): Promise<void> {
157
+ const parsed = parseArgs(args);
158
+
159
+ if (parsed.help) {
160
+ console.log(`
161
+ Usage: zzhub-pipeline analytics record [options]
162
+
163
+ Options:
164
+ --state State file path (required)
165
+ --reads Read count
166
+ --likes Like count
167
+ --favorites Favorite count
168
+ --shares Share count
169
+ --comments Comment count
170
+ --notes Notes
171
+ `.trim());
172
+ return;
173
+ }
174
+
175
+ const statePath = requireArg(parsed, "state", "State file path");
176
+ const readsStr = optionalArg(parsed, "reads");
177
+ const likesStr = optionalArg(parsed, "likes");
178
+ const favoritesStr = optionalArg(parsed, "favorites");
179
+ const sharesStr = optionalArg(parsed, "shares");
180
+ const commentsStr = optionalArg(parsed, "comments");
181
+ const notes = optionalArg(parsed, "notes");
182
+
183
+ const result = await recordAnalytics({
184
+ statePath,
185
+ reads: readsStr ? parseInt(readsStr, 10) : undefined,
186
+ likes: likesStr ? parseInt(likesStr, 10) : undefined,
187
+ favorites: favoritesStr ? parseInt(favoritesStr, 10) : undefined,
188
+ shares: sharesStr ? parseInt(sharesStr, 10) : undefined,
189
+ comments: commentsStr ? parseInt(commentsStr, 10) : undefined,
190
+ notes,
191
+ });
192
+
193
+ printResult(result);
194
+ }
195
+
196
+ async function analyticsList(args: string[]): Promise<void> {
197
+ const parsed = parseArgs(args);
198
+
199
+ if (parsed.help) {
200
+ console.log(`
201
+ Usage: zzhub-pipeline analytics list [options]
202
+
203
+ Options:
204
+ --workspace Workspace path (required)
205
+ --days Show last N days (default: all)
206
+ --sort Sort field: publish_date/reads/likes (default: publish_date)
207
+ --limit Max results (default: 50)
208
+ --view Output format: json/markdown/agent (default: json)
209
+ `.trim());
210
+ return;
211
+ }
212
+
213
+ const workspace = requireArg(parsed, "workspace", "Workspace path");
214
+ const daysStr = optionalArg(parsed, "days");
215
+ const days = daysStr ? parseInt(daysStr, 10) : undefined;
216
+ const sort = optionalArg(parsed, "sort");
217
+ const limitStr = optionalArg(parsed, "limit");
218
+ const limit = limitStr ? parseInt(limitStr, 10) : undefined;
219
+
220
+ const result = await listAnalytics(workspace, { days, sort, limit });
221
+
222
+ printResult(result);
223
+ }
@@ -36,6 +36,7 @@ import {
36
36
  writeState,
37
37
  type ContentForm,
38
38
  type ContentOrigin,
39
+ type PublishTarget,
39
40
  type Target,
40
41
  type TaskKind,
41
42
  } from "../state";
@@ -81,6 +82,30 @@ Options:
81
82
 
82
83
  const targets = targetsRaw.split(",").map((t) => t.trim()) as Target[];
83
84
 
85
+ // Parse multi-target format: "route@account,route@account,..."
86
+ // Also supports RoutePrimary values (wechat-article, wechat-newspic, blog)
87
+ // which need to be mapped to Target enum (wechat, blog) for intent.targets.
88
+ const publishTargets: PublishTarget[] = [];
89
+ const targetParts = targetsRaw.split(",").map((t) => t.trim());
90
+ const defaultAccount = accountOverride || "default";
91
+ const mappedTargets: Target[] = [];
92
+
93
+ for (const part of targetParts) {
94
+ const atIdx = part.indexOf("@");
95
+ const route = atIdx !== -1 ? part.slice(0, atIdx).trim() : part;
96
+ const account = atIdx !== -1 ? part.slice(atIdx + 1).trim() : defaultAccount;
97
+ publishTargets.push({ route: route as any, account });
98
+ // Map RoutePrimary to Target enum: wechat-* → "wechat", blog → "blog"
99
+ mappedTargets.push(route.startsWith("wechat") ? "wechat" : route as Target);
100
+ }
101
+
102
+ // Single target: leave publish_targets empty (backward compat)
103
+ // Multi-target: populate publish_targets
104
+ const isMultiTarget = publishTargets.length > 1 || targetsRaw.includes("@");
105
+
106
+ // Use mapped targets (Target enum) for intent, not raw RoutePrimary values
107
+ const intentTargets = mappedTargets.length > 0 ? mappedTargets : targets;
108
+
84
109
  const runId = generateRunId();
85
110
  const statePath = getRunStatePath(workspace, runId);
86
111
  let newspicRender = null;
@@ -98,13 +123,21 @@ Options:
98
123
  state.route = resolveFullRoute(intentText, {
99
124
  account: accountOverride,
100
125
  contentForm,
101
- targets,
126
+ targets: intentTargets,
102
127
  });
103
128
 
129
+ // Set publish_targets if multi-target
130
+ if (isMultiTarget && publishTargets.length > 0) {
131
+ state.publish_targets = publishTargets;
132
+ // First target becomes primary route
133
+ state.route.primary = publishTargets[0].route as any;
134
+ state.route.account = publishTargets[0].account;
135
+ }
136
+
104
137
  state.intent = {
105
138
  task_kind: taskKind,
106
139
  content_form: contentForm,
107
- targets,
140
+ targets: intentTargets,
108
141
  content_origin: contentOrigin,
109
142
  intent_text: intentText || null,
110
143
  explicit_constraints: [],
@@ -151,7 +151,7 @@ Options:
151
151
  : resolveFullRoute(intentText, {
152
152
  primary: routeOverride,
153
153
  extras,
154
- account: accountOverride,
154
+ account: accountOverride ?? state.route?.account || undefined,
155
155
  contentForm: state.intent.content_form,
156
156
  targets: state.intent.targets,
157
157
  });