@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 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` 组,12 个命令:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zzclub/pipeline",
3
- "version": "0.10.2",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "zzp": "./src/cli.ts",
@@ -5,13 +5,17 @@ import {
5
5
  configSummary,
6
6
  getConfigValue,
7
7
  loadConfig,
8
- PipelineConfigSchema,
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: existing values win, imported fills gaps
54
- const importedObj = (typeof imported === "object" && imported !== null ? imported : {}) as Record<string, unknown>;
55
- const mergedWx = {
56
- ...config.wx,
57
- ...(importedObj.wx as Record<string, unknown> ?? {}),
58
- accounts: {
59
- ...config.wx.accounts,
60
- ...((importedObj.wx as Record<string, unknown>)?.accounts as Record<string, unknown> ?? {}),
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: { ...config.services, ...(importedObj.services ?? {}) },
66
- commands: { ...config.commands, ...(importedObj.commands ?? {}) },
67
- wx: mergedWx,
68
- cos: { ...config.cos, ...(importedObj.cos ?? {}) },
69
- plugins: { ...config.plugins, ...(importedObj.plugins ?? {}) },
70
- imgx: { ...config.imgx, ...(importedObj.imgx ?? {}) },
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
- // Validate through Zod schema strips unknown fields, applies defaults
73
- const merged = PipelineConfigSchema.parse(raw);
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
- mergedAccounts[accountName] = { ...legacyAccount, ...sourceAccount };
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
  ];
@@ -161,8 +161,10 @@ export function normalizeAccountName(value: string): string {
161
161
  return account;
162
162
  }
163
163
 
164
- function fillWxAccountConfig(source?: WxAccountConfig): WxAccountConfig {
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
+ });
@@ -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
- .record(z.string(), WxAccountConfigSchema)
79
- .default({ default: { pat: "", appId: "", appSecret: "", customCss: null, theme: { editorVars: {}, exportTheme: {} } } }),
83
+ accounts: z.record(z.string(), WxAccountConfigSchema).default(() => ({
84
+ default: WxAccountConfigSchema.parse({}),
85
+ })),
80
86
  }),
81
87
  );
82
88
 
@@ -1437,6 +1437,7 @@ describe("config", () => {
1437
1437
  defaultAccount: "default",
1438
1438
  accounts: {
1439
1439
  default: {
1440
+ name: "",
1440
1441
  pat: "",
1441
1442
  appId: "",
1442
1443
  appSecret: "",
@@ -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
- });