@zzclub/pipeline 0.10.2 → 0.11.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 +1 -72
- package/package.json +1 -1
- package/src/commands/config.ts +47 -20
- package/src/config.ts +21 -2
- package/src/plugins.ts +0 -4
- package/src/providers/wechat.ts +3 -1
- package/src/schema/config.account-name.test.ts +79 -0
- package/src/schema/config.ts +9 -3
- package/src/workflow.test.ts +1 -0
- package/src/commands/analytics.test.ts +0 -156
- package/src/commands/analytics.ts +0 -240
- package/src/commands/topic.test.ts +0 -146
- package/src/commands/topic.ts +0 -717
- package/src/db.test.ts +0 -47
- package/src/db.ts +0 -95
- package/src/schema/analytics.test.ts +0 -54
- package/src/schema/analytics.ts +0 -19
- package/src/schema/topic.test.ts +0 -69
- package/src/schema/topic.ts +0 -55
package/README.md
CHANGED
|
@@ -410,7 +410,7 @@ src/
|
|
|
410
410
|
| `review` | Update content review status |
|
|
411
411
|
| `abandon` | Mark one or more tasks as abandoned |
|
|
412
412
|
|
|
413
|
-
`ops` 组,
|
|
413
|
+
`ops` 组,11 个命令:
|
|
414
414
|
|
|
415
415
|
| 命令 | 说明 |
|
|
416
416
|
| --- | --- |
|
|
@@ -425,8 +425,6 @@ src/
|
|
|
425
425
|
| `hermes-metrics` | Show Hermes execution metrics per task |
|
|
426
426
|
| `wx-drafts` | List or get drafts from WeChat draft box |
|
|
427
427
|
| `wx-draft-delete` | Delete a draft from WeChat draft box |
|
|
428
|
-
| `topic` | 选题管理(添加、列表、更新、排期、复盘、放弃) |
|
|
429
|
-
| `analytics` | 发布数据录入和分析 |
|
|
430
428
|
|
|
431
429
|
## 常用命令
|
|
432
430
|
|
|
@@ -521,75 +519,6 @@ bun run src/cli.ts reset --state {state_path} --mode full # 完全放弃
|
|
|
521
519
|
|
|
522
520
|
每种模式均会设置 `redo_hint`,确保 Agent 恢复时知道从哪个子步骤开始。
|
|
523
521
|
|
|
524
|
-
### 选题管理
|
|
525
|
-
|
|
526
|
-
使用 `topic` 命令管理内容选题的完整生命周期:
|
|
527
|
-
|
|
528
|
-
```bash
|
|
529
|
-
# 添加选题
|
|
530
|
-
bun run src/cli.ts topic add \
|
|
531
|
-
--workspace {workspace} \
|
|
532
|
-
--title "AI 工具推荐" \
|
|
533
|
-
--priority high \
|
|
534
|
-
--tags "AI,工具"
|
|
535
|
-
|
|
536
|
-
# 列出选题
|
|
537
|
-
bun run src/cli.ts topic list --workspace {workspace}
|
|
538
|
-
bun run src/cli.ts topic list --workspace {workspace} --status backlog --priority high
|
|
539
|
-
|
|
540
|
-
# 更新选题(AI 评估)
|
|
541
|
-
bun run src/cli.ts topic update \
|
|
542
|
-
--workspace {workspace} \
|
|
543
|
-
--topic {topic_id} \
|
|
544
|
-
--ai-score 85 \
|
|
545
|
-
--ai-reason "基于热点趋势和受众匹配度"
|
|
546
|
-
|
|
547
|
-
# 排期
|
|
548
|
-
bun run src/cli.ts topic schedule \
|
|
549
|
-
--workspace {workspace} \
|
|
550
|
-
--topic {topic_id} \
|
|
551
|
-
--scheduled-date 2026-06-15 \
|
|
552
|
-
--target-account default
|
|
553
|
-
|
|
554
|
-
# 复盘(发布后)
|
|
555
|
-
bun run src/cli.ts topic retro \
|
|
556
|
-
--workspace {workspace} \
|
|
557
|
-
--topic {topic_id} \
|
|
558
|
-
--performance good \
|
|
559
|
-
--lessons "标题悬念效果好" \
|
|
560
|
-
--metrics-snapshot '{"reads": 1500, "likes": 45}'
|
|
561
|
-
|
|
562
|
-
# 放弃选题
|
|
563
|
-
bun run src/cli.ts topic abandon \
|
|
564
|
-
--workspace {workspace} \
|
|
565
|
-
--topic {topic_id} \
|
|
566
|
-
--reason "话题过时"
|
|
567
|
-
```
|
|
568
|
-
|
|
569
|
-
选题状态流转:`backlog` → `evaluating` → `scheduled` → `in_progress` → `published`(或 `abandoned`)
|
|
570
|
-
|
|
571
|
-
### 数据分析
|
|
572
|
-
|
|
573
|
-
使用 `analytics` 命令录入和分析发布后的数据:
|
|
574
|
-
|
|
575
|
-
```bash
|
|
576
|
-
# 录入发布数据
|
|
577
|
-
bun run src/cli.ts analytics record \
|
|
578
|
-
--state {workspace}/posts/{date-slug}/workflow-state.json \
|
|
579
|
-
--reads 1500 \
|
|
580
|
-
--likes 45 \
|
|
581
|
-
--favorites 23 \
|
|
582
|
-
--shares 12 \
|
|
583
|
-
--comments 8 \
|
|
584
|
-
--notes "标题效果好,转化率高"
|
|
585
|
-
|
|
586
|
-
# 列出历史数据
|
|
587
|
-
bun run src/cli.ts analytics list --workspace {workspace}
|
|
588
|
-
bun run src/cli.ts analytics list --workspace {workspace} --days 30 --sort reads
|
|
589
|
-
```
|
|
590
|
-
|
|
591
|
-
数据存储在 `{workspace}/zzhub.db`(SQLite 数据库),支持复杂查询和统计分析。
|
|
592
|
-
|
|
593
522
|
### Blog 同步
|
|
594
523
|
|
|
595
524
|
```bash
|
package/package.json
CHANGED
package/src/commands/config.ts
CHANGED
|
@@ -5,13 +5,17 @@ import {
|
|
|
5
5
|
configSummary,
|
|
6
6
|
getConfigValue,
|
|
7
7
|
loadConfig,
|
|
8
|
-
|
|
8
|
+
normalizeConfig,
|
|
9
9
|
redactConfig,
|
|
10
10
|
redactConfigValue,
|
|
11
11
|
saveConfig,
|
|
12
12
|
setConfigValue,
|
|
13
13
|
} from "../config";
|
|
14
14
|
|
|
15
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
16
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
15
19
|
export async function configCommand(args: string[]): Promise<void> {
|
|
16
20
|
const parsed = parseArgs(args);
|
|
17
21
|
|
|
@@ -26,6 +30,11 @@ Options:
|
|
|
26
30
|
--export Print full config as JSON (secrets redacted; use --raw to show all)
|
|
27
31
|
--import Path to a JSON file to merge into current config
|
|
28
32
|
--raw Show secrets unredacted (use with --export)
|
|
33
|
+
|
|
34
|
+
Examples:
|
|
35
|
+
zzhub-pipeline config --key wx.defaultAccount
|
|
36
|
+
zzhub-pipeline config --key wx.accounts.default.name --value "大号(早早集市)"
|
|
37
|
+
zzhub-pipeline config --export
|
|
29
38
|
`.trim());
|
|
30
39
|
return;
|
|
31
40
|
}
|
|
@@ -50,27 +59,45 @@ Options:
|
|
|
50
59
|
throw new Error(`Failed to read import file: ${err instanceof Error ? err.message : String(err)}`);
|
|
51
60
|
}
|
|
52
61
|
|
|
53
|
-
// Merge:
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
// Merge: top-level shallow; wx.accounts deep-merge per key so partial imports
|
|
63
|
+
// keep existing fields (e.g. display `name`) unless overridden.
|
|
64
|
+
const importedObj = isPlainObject(imported) ? imported : {};
|
|
65
|
+
const importedWx = isPlainObject(importedObj.wx) ? importedObj.wx : {};
|
|
66
|
+
const importedAccounts = isPlainObject(importedWx.accounts) ? importedWx.accounts : {};
|
|
67
|
+
const mergedAccounts: Record<string, unknown> = { ...config.wx.accounts };
|
|
68
|
+
for (const [key, value] of Object.entries(importedAccounts)) {
|
|
69
|
+
const accountKey = key.trim();
|
|
70
|
+
if (!accountKey) continue;
|
|
71
|
+
const existing = isPlainObject(mergedAccounts[accountKey])
|
|
72
|
+
? mergedAccounts[accountKey]
|
|
73
|
+
: {};
|
|
74
|
+
const incoming = isPlainObject(value) ? value : {};
|
|
75
|
+
mergedAccounts[accountKey] = { ...existing, ...incoming };
|
|
76
|
+
}
|
|
63
77
|
const raw = {
|
|
64
|
-
paths: { ...config.paths, ...(importedObj.paths
|
|
65
|
-
services: {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
78
|
+
paths: { ...config.paths, ...(isPlainObject(importedObj.paths) ? importedObj.paths : {}) },
|
|
79
|
+
services: {
|
|
80
|
+
...config.services,
|
|
81
|
+
...(isPlainObject(importedObj.services) ? importedObj.services : {}),
|
|
82
|
+
},
|
|
83
|
+
commands: {
|
|
84
|
+
...config.commands,
|
|
85
|
+
...(isPlainObject(importedObj.commands) ? importedObj.commands : {}),
|
|
86
|
+
},
|
|
87
|
+
wx: {
|
|
88
|
+
...config.wx,
|
|
89
|
+
...importedWx,
|
|
90
|
+
accounts: mergedAccounts,
|
|
91
|
+
},
|
|
92
|
+
cos: { ...config.cos, ...(isPlainObject(importedObj.cos) ? importedObj.cos : {}) },
|
|
93
|
+
plugins: {
|
|
94
|
+
...config.plugins,
|
|
95
|
+
...(isPlainObject(importedObj.plugins) ? importedObj.plugins : {}),
|
|
96
|
+
},
|
|
97
|
+
imgx: { ...config.imgx, ...(isPlainObject(importedObj.imgx) ? importedObj.imgx : {}) },
|
|
71
98
|
};
|
|
72
|
-
//
|
|
73
|
-
const merged =
|
|
99
|
+
// Soft-fill known account display names + Zod defaults / strip unknowns
|
|
100
|
+
const merged = normalizeConfig(raw);
|
|
74
101
|
|
|
75
102
|
saveConfig(merged);
|
|
76
103
|
printResult(configSummary(merged), renderConfig);
|
package/src/config.ts
CHANGED
|
@@ -43,6 +43,15 @@ export interface ResolvedWorkspacePaths {
|
|
|
43
43
|
|
|
44
44
|
const DEFAULT_WX_ACCOUNT_NAME = "default";
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Soft display names for known account keys when `name` is missing/empty.
|
|
48
|
+
* Never overrides a user-set name.
|
|
49
|
+
*/
|
|
50
|
+
export const SUGGESTED_ACCOUNT_NAMES: Record<string, string> = {
|
|
51
|
+
default: "大号(早早集市)",
|
|
52
|
+
ancientone: "小号(古一)",
|
|
53
|
+
};
|
|
54
|
+
|
|
46
55
|
export const DEFAULT_CONFIG: PipelineConfig = PipelineConfigSchema.parse({});
|
|
47
56
|
|
|
48
57
|
const PIPELINE_CONFIG_DIR = "zzhub-pipeline";
|
|
@@ -104,8 +113,9 @@ export function getLegacyZCliConfigPath(): string {
|
|
|
104
113
|
/**
|
|
105
114
|
* Merge source config with legacy config, then normalize through Zod schema.
|
|
106
115
|
* Legacy values fill gaps where source has nothing.
|
|
116
|
+
* Soft-fills known account display `name` only when missing/empty.
|
|
107
117
|
*/
|
|
108
|
-
function normalizeConfig(value: unknown, legacyValue?: unknown): PipelineConfig {
|
|
118
|
+
export function normalizeConfig(value: unknown, legacyValue?: unknown): PipelineConfig {
|
|
109
119
|
const source = isPlainObject(value) ? value : {};
|
|
110
120
|
const legacy = isPlainObject(legacyValue) ? legacyValue : {};
|
|
111
121
|
|
|
@@ -122,7 +132,16 @@ function normalizeConfig(value: unknown, legacyValue?: unknown): PipelineConfig
|
|
|
122
132
|
if (!accountName) continue;
|
|
123
133
|
const legacyAccount = isPlainObject(legacyAccounts[accountName]) ? legacyAccounts[accountName] : {};
|
|
124
134
|
const sourceAccount = isPlainObject(sourceAccounts[accountName]) ? sourceAccounts[accountName] : {};
|
|
125
|
-
|
|
135
|
+
const merged = { ...legacyAccount, ...sourceAccount };
|
|
136
|
+
// Trim + soft-fill display name for known keys only when missing/empty (never override user-set name).
|
|
137
|
+
if (typeof merged.name === "string") {
|
|
138
|
+
merged.name = merged.name.trim();
|
|
139
|
+
}
|
|
140
|
+
const existingName = typeof merged.name === "string" ? merged.name : "";
|
|
141
|
+
if (!existingName && SUGGESTED_ACCOUNT_NAMES[accountName]) {
|
|
142
|
+
merged.name = SUGGESTED_ACCOUNT_NAMES[accountName];
|
|
143
|
+
}
|
|
144
|
+
mergedAccounts[accountName] = merged;
|
|
126
145
|
}
|
|
127
146
|
|
|
128
147
|
// Merge wx config: source wins, legacy fills gaps
|
package/src/plugins.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { abandon } from "./commands/abandon";
|
|
2
|
-
import { analytics } from "./commands/analytics";
|
|
3
2
|
import { attachBody } from "./commands/attach-body";
|
|
4
3
|
import { attachBodyImages } from "./commands/attach-body-images";
|
|
5
4
|
import { attachNewspicSpec } from "./commands/attach-newspic-spec";
|
|
@@ -23,7 +22,6 @@ import { review } from "./commands/review";
|
|
|
23
22
|
import { status } from "./commands/status";
|
|
24
23
|
import { syncBlog } from "./commands/sync-blog";
|
|
25
24
|
import { tasks } from "./commands/tasks";
|
|
26
|
-
import { topic } from "./commands/topic";
|
|
27
25
|
import { wechatExport } from "./commands/wechat-export";
|
|
28
26
|
import { wechatPreview } from "./commands/wechat-preview";
|
|
29
27
|
import { wxDrafts } from "./commands/wx-drafts";
|
|
@@ -79,8 +77,6 @@ export function getCommandPlugins(): CommandPlugin[] {
|
|
|
79
77
|
{ name: "hermes-metrics", summary: "Show Hermes execution metrics per task", plugin: "ops", handler: hermesMetrics },
|
|
80
78
|
{ name: "wx-drafts", summary: "List or get drafts from WeChat draft box", plugin: "ops", handler: wxDrafts },
|
|
81
79
|
{ name: "wx-draft-delete", summary: "Delete a draft from WeChat draft box", plugin: "ops", handler: wxDraftDelete },
|
|
82
|
-
{ name: "topic", summary: "Manage topics (add, list, update, schedule, retro, abandon)", plugin: "ops", handler: topic },
|
|
83
|
-
{ name: "analytics", summary: "Record and analyze post-publish metrics", plugin: "ops", handler: analytics },
|
|
84
80
|
],
|
|
85
81
|
},
|
|
86
82
|
];
|
package/src/providers/wechat.ts
CHANGED
|
@@ -161,8 +161,10 @@ export function normalizeAccountName(value: string): string {
|
|
|
161
161
|
return account;
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
|
|
164
|
+
/** Fill missing wx account fields with defaults. Preserves optional display `name`. */
|
|
165
|
+
export function fillWxAccountConfig(source?: WxAccountConfig): WxAccountConfig {
|
|
165
166
|
return {
|
|
167
|
+
name: source?.name ?? "",
|
|
166
168
|
pat: source?.pat ?? "",
|
|
167
169
|
appId: source?.appId ?? "",
|
|
168
170
|
appSecret: source?.appSecret ?? "",
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { normalizeConfig, SUGGESTED_ACCOUNT_NAMES } from "../config";
|
|
4
|
+
import { fillWxAccountConfig } from "../providers/wechat";
|
|
5
|
+
import { PipelineConfigSchema } from "./config";
|
|
6
|
+
|
|
7
|
+
describe("wx account display name", () => {
|
|
8
|
+
test("schema keeps name when provided", () => {
|
|
9
|
+
const parsed = PipelineConfigSchema.parse({
|
|
10
|
+
wx: {
|
|
11
|
+
accounts: {
|
|
12
|
+
default: {
|
|
13
|
+
name: "早早集市",
|
|
14
|
+
appId: "wx123",
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
expect(parsed.wx.accounts.default?.name).toBe("早早集市");
|
|
20
|
+
expect(parsed.wx.accounts.default?.appId).toBe("wx123");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("schema defaults name to empty string when missing", () => {
|
|
24
|
+
const parsed = PipelineConfigSchema.parse({
|
|
25
|
+
wx: {
|
|
26
|
+
accounts: {
|
|
27
|
+
custom: {
|
|
28
|
+
appId: "wx999",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
// custom key has no soft-suggest unless normalizeConfig is used
|
|
34
|
+
expect(parsed.wx.accounts.custom?.name).toBe("");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("normalizeConfig soft-fills known keys only when name empty", () => {
|
|
38
|
+
const normalized = normalizeConfig({
|
|
39
|
+
wx: {
|
|
40
|
+
accounts: {
|
|
41
|
+
default: { appId: "wx1" },
|
|
42
|
+
ancientone: { name: "我自定义的小号", appId: "wx2" },
|
|
43
|
+
other: { appId: "wx3" },
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
expect(normalized.wx.accounts.default?.name).toBe(SUGGESTED_ACCOUNT_NAMES.default);
|
|
48
|
+
expect(normalized.wx.accounts.ancientone?.name).toBe("我自定义的小号");
|
|
49
|
+
expect(normalized.wx.accounts.other?.name).toBe("");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("normalizeConfig does not override explicit empty-then-set name after trim", () => {
|
|
53
|
+
const normalized = normalizeConfig({
|
|
54
|
+
wx: {
|
|
55
|
+
accounts: {
|
|
56
|
+
default: { name: " 大号定制 ", appId: "wx1" },
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
expect(normalized.wx.accounts.default?.name).toBe("大号定制");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("fillWxAccountConfig preserves name", () => {
|
|
64
|
+
const filled = fillWxAccountConfig({
|
|
65
|
+
name: "大号",
|
|
66
|
+
pat: "p",
|
|
67
|
+
appId: "id",
|
|
68
|
+
appSecret: "s",
|
|
69
|
+
customCss: null,
|
|
70
|
+
theme: { editorVars: {}, exportTheme: {} },
|
|
71
|
+
});
|
|
72
|
+
expect(filled.name).toBe("大号");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("fillWxAccountConfig defaults name to empty", () => {
|
|
76
|
+
const filled = fillWxAccountConfig(undefined);
|
|
77
|
+
expect(filled.name).toBe("");
|
|
78
|
+
});
|
|
79
|
+
});
|
package/src/schema/config.ts
CHANGED
|
@@ -56,6 +56,12 @@ const WechatThemeOverridesSchema = withObjectDefault(
|
|
|
56
56
|
);
|
|
57
57
|
|
|
58
58
|
const WxAccountConfigSchema = z.object({
|
|
59
|
+
/**
|
|
60
|
+
* Optional Chinese display name for UIs (e.g. 大号(早早集市)).
|
|
61
|
+
* Config map key remains the stable account id (default / ancientone).
|
|
62
|
+
* Whitespace is trimmed in normalizeConfig when soft-filling / merging.
|
|
63
|
+
*/
|
|
64
|
+
name: z.string().default(""),
|
|
59
65
|
pat: z.string().default(""),
|
|
60
66
|
appId: z.string().default(""),
|
|
61
67
|
appSecret: z.string().default(""),
|
|
@@ -74,9 +80,9 @@ const WxConfigSchema = withObjectDefault(
|
|
|
74
80
|
z.number().default(30000),
|
|
75
81
|
),
|
|
76
82
|
defaultAccount: trimmedNonEmptyString("default"),
|
|
77
|
-
accounts: z
|
|
78
|
-
.
|
|
79
|
-
|
|
83
|
+
accounts: z.record(z.string(), WxAccountConfigSchema).default(() => ({
|
|
84
|
+
default: WxAccountConfigSchema.parse({}),
|
|
85
|
+
})),
|
|
80
86
|
}),
|
|
81
87
|
);
|
|
82
88
|
|
package/src/workflow.test.ts
CHANGED
|
@@ -1,156 +0,0 @@
|
|
|
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
|
-
const today = new Date().toISOString().slice(0, 10);
|
|
127
|
-
await writeFile(statePath, JSON.stringify({
|
|
128
|
-
run_id: "run_001",
|
|
129
|
-
metadata: { title: "Article", date: today },
|
|
130
|
-
route: { primary: "wechat-article" },
|
|
131
|
-
publish: { results: { wechat: { status: "success", published_at: `${today}T10:00:00Z` } } },
|
|
132
|
-
}));
|
|
133
|
-
|
|
134
|
-
await recordAnalytics({ statePath, reads: 1000 });
|
|
135
|
-
|
|
136
|
-
const analytics = await listAnalytics(workspace, { days: 30 });
|
|
137
|
-
expect(analytics.length).toBe(1);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
test("limits results", async () => {
|
|
141
|
-
for (let i = 0; i < 5; i++) {
|
|
142
|
-
const statePath = join(workspace, `state${i}.json`);
|
|
143
|
-
await writeFile(statePath, JSON.stringify({
|
|
144
|
-
run_id: `run_${String(i).padStart(3, "0")}`,
|
|
145
|
-
metadata: { title: `Article ${i}`, date: "2026-06-11" },
|
|
146
|
-
route: { primary: "wechat-article" },
|
|
147
|
-
publish: { results: { wechat: { status: "success", published_at: "2026-06-11T10:00:00Z" } } },
|
|
148
|
-
}));
|
|
149
|
-
await recordAnalytics({ statePath, reads: 100 * (i + 1) });
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const analytics = await listAnalytics(workspace, { limit: 3 });
|
|
153
|
-
expect(analytics.length).toBe(3);
|
|
154
|
-
});
|
|
155
|
-
});
|
|
156
|
-
});
|