@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.
- package/README.md +75 -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 +35 -2
- 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/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 +7 -1
- package/src/providers/blog.ts +9 -3
- package/src/providers/index.test.ts +1 -1
- package/src/providers/index.ts +23 -7
- package/src/providers/publish-core.ts +124 -0
- package/src/providers/wechat.ts +31 -9
- 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 +8 -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,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` 组,
|
|
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 |
|
|
@@ -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
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
|
@@ -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: [],
|