@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
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,17 +409,21 @@ src/
409
409
  | `review` | Update content review status |
410
410
  | `abandon` | Mark one or more tasks as abandoned |
411
411
 
412
- `ops` 组,7 个命令:
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 |
420
420
  | `config` | Read or update pipeline config |
421
421
  | `doctor` | Inspect resolved paths and provider health |
422
422
  | `hermes-metrics` | Show Hermes execution metrics per task |
423
+ | `wx-drafts` | List or get drafts from WeChat draft box |
424
+ | `wx-draft-delete` | Delete a draft from WeChat draft box |
425
+ | `topic` | 选题管理(添加、列表、更新、排期、复盘、放弃) |
426
+ | `analytics` | 发布数据录入和分析 |
423
427
 
424
428
  ## 常用命令
425
429
 
@@ -514,6 +518,75 @@ bun run src/cli.ts reset --state {state_path} --mode full # 完全放弃
514
518
 
515
519
  每种模式均会设置 `redo_hint`,确保 Agent 恢复时知道从哪个子步骤开始。
516
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
+
517
590
  ### Blog 同步
518
591
 
519
592
  ```bash
@@ -525,6 +598,7 @@ bun run src/cli.ts sync-blog --state {workspace}/posts/{date-slug}/workflow-stat
525
598
  - 读取 canonical `post.md`
526
599
  - 复制到博客仓库 `content/posts/<slug>.md`
527
600
  - 执行博客发布命令
601
+ - 将 blog publish result 写回 `workflow-state.json`,方便筛查是否遗漏同步
528
602
 
529
603
  ### 配置管理
530
604
 
@@ -553,6 +627,31 @@ bun run src/cli.ts wechat-export --body /abs/path/body.md --account default
553
627
  bun run src/cli.ts cos-upload --file /abs/path/image.png --folder notes/note-id --alt image
554
628
  ```
555
629
 
630
+ ### 草稿箱管理
631
+
632
+ ```bash
633
+ # 列出草稿(账号不传时使用 wx.defaultAccount)
634
+ bun run src/cli.ts wx-drafts --limit 10
635
+
636
+ # 获取某篇草稿的完整内容(含 HTML)
637
+ bun run src/cli.ts wx-drafts --media-id MEDIA_ID
638
+
639
+ # 删除一篇草稿
640
+ bun run src/cli.ts wx-draft-delete --media-id MEDIA_ID
641
+ ```
642
+
643
+ `--account` 参数可选,默认使用配置中的 `wx.defaultAccount`。
644
+
645
+ ### init 可选参数
646
+
647
+ ```bash
648
+ # 更新已有草稿而非新建(配合 --existing-draft-media-id)
649
+ bun run src/cli.ts init ... --existing-draft-media-id MEDIA_ID
650
+
651
+ # 关联 Nezus note 用于发布结果回调
652
+ bun run src/cli.ts init ... --note-id NOTE_ID
653
+ ```
654
+
556
655
  ## 状态文件
557
656
 
558
657
  最重要的字段是:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zzclub/pipeline",
3
- "version": "0.4.0",
3
+ "version": "0.9.0",
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
+ }
@@ -16,7 +16,8 @@
16
16
  * [--requires-research] \
17
17
  * [--requires-style] \
18
18
  * [--requires-render] \
19
- * [--requires-publish]
19
+ * [--requires-publish] \
20
+ * [--existing-draft-media-id MEDIA_ID]
20
21
  *
21
22
  * Output: JSON state written to {workspace}/.zzhub-media/runs/{run_id}.json
22
23
  * Prints the state path to stdout.
@@ -35,6 +36,7 @@ import {
35
36
  writeState,
36
37
  type ContentForm,
37
38
  type ContentOrigin,
39
+ type PublishTarget,
38
40
  type Target,
39
41
  type TaskKind,
40
42
  } from "../state";
@@ -60,6 +62,7 @@ Options:
60
62
  --requires-style Flag
61
63
  --requires-render Flag
62
64
  --requires-publish Flag
65
+ --existing-draft-media-id Update existing WeChat draft instead of creating new one (optional)
63
66
  `.trim());
64
67
  return;
65
68
  }
@@ -72,11 +75,37 @@ Options:
72
75
  const accountOverride = optionalArg(parsed, "account");
73
76
  const styleHint = optionalArg(parsed, "style-hint") ?? null;
74
77
  const newspicRenderSpecFile = optionalArg(parsed, "newspic-render-spec-file");
78
+ const existingDraftMediaId = optionalArg(parsed, "existing-draft-media-id") ?? null;
79
+ const noteId = optionalArg(parsed, "note-id") ?? null;
75
80
  const config = loadConfig();
76
81
  const workspace = resolveWorkspaceRoot(optionalArg(parsed, "workspace"), config);
77
82
 
78
83
  const targets = targetsRaw.split(",").map((t) => t.trim()) as Target[];
79
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
+
80
109
  const runId = generateRunId();
81
110
  const statePath = getRunStatePath(workspace, runId);
82
111
  let newspicRender = null;
@@ -94,13 +123,21 @@ Options:
94
123
  state.route = resolveFullRoute(intentText, {
95
124
  account: accountOverride,
96
125
  contentForm,
97
- targets,
126
+ targets: intentTargets,
98
127
  });
99
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
+
100
137
  state.intent = {
101
138
  task_kind: taskKind,
102
139
  content_form: contentForm,
103
- targets,
140
+ targets: intentTargets,
104
141
  content_origin: contentOrigin,
105
142
  intent_text: intentText || null,
106
143
  explicit_constraints: [],
@@ -112,6 +149,8 @@ Options:
112
149
  render: flagArg(parsed, "requires-render"),
113
150
  publish: flagArg(parsed, "requires-publish"),
114
151
  },
152
+ existing_draft_media_id: existingDraftMediaId,
153
+ note_id: noteId,
115
154
  };
116
155
 
117
156
  await writeState(statePath, state);