@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.
- package/README.md +102 -3
- package/package.json +1 -1
- package/src/adapter-types.ts +6 -0
- package/src/adapters/builtin-image-renderer.ts +7 -1
- package/src/adapters/builtin-markdown-renderer.ts +1 -0
- package/src/commands/analytics.test.ts +155 -0
- package/src/commands/analytics.ts +223 -0
- package/src/commands/init.ts +42 -3
- package/src/commands/publish.ts +44 -67
- package/src/commands/republish.ts +146 -0
- package/src/commands/sync-blog.ts +19 -3
- package/src/commands/topic.test.ts +146 -0
- package/src/commands/topic.ts +717 -0
- package/src/commands/wechat-export.ts +2 -0
- package/src/commands/wx-draft-delete.ts +36 -0
- package/src/commands/wx-drafts.ts +47 -0
- package/src/config.ts +0 -0
- package/src/db.test.ts +47 -0
- package/src/db.ts +95 -0
- package/src/imgx/assets/icons/ancientone-logo.png +0 -0
- package/src/imgx/assets/templates/wechat-cover-split.html +1 -1
- package/src/imgx/render-card.ts +1 -5
- package/src/plugins.ts +11 -1
- package/src/providers/blog.ts +9 -3
- package/src/providers/index.test.ts +1 -1
- package/src/providers/index.ts +29 -5
- package/src/providers/publish-core.ts +124 -0
- package/src/providers/wechat.ts +260 -28
- package/src/routes.ts +1 -1
- package/src/schema/analytics.test.ts +54 -0
- package/src/schema/analytics.ts +19 -0
- package/src/schema/config.ts +25 -1
- package/src/schema/state.ts +10 -0
- package/src/schema/topic.test.ts +69 -0
- package/src/schema/topic.ts +55 -0
- package/src/state.ts +1 -0
- package/src/text.test.ts +11 -6
- package/src/text.ts +27 -9
- package/src/wechat-preview/assets/browser-dist/.vite/manifest.json +2 -7
- package/src/wechat-preview/assets/browser-dist/assets/{style-BYNXnUvc.css → style-vhHYSP8T.css} +1 -1
- package/src/wechat-preview/assets/browser-dist/editor-export.js +27 -27
- package/src/wechat-preview/browser/editor-export.css +248 -159
- package/src/wechat-preview/index.ts +28 -11
- package/src/wechat-preview/themes.ts +70 -24
- package/src/wechat-preview/wechat-formatter.test.ts +0 -1
- package/src/wechat-preview/wechat-formatter.ts +48 -23
- package/src/workflow.test.ts +294 -1
- 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` 组,
|
|
412
|
+
`ops` 组,11 个命令:
|
|
413
413
|
|
|
414
414
|
| 命令 | 说明 |
|
|
415
415
|
| --- | --- |
|
|
416
|
-
| `sync-blog` | Copy
|
|
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
package/src/adapter-types.ts
CHANGED
|
@@ -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,
|
|
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
|
|
|
@@ -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
|
+
}
|
package/src/commands/init.ts
CHANGED
|
@@ -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);
|