@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
|
@@ -1,240 +0,0 @@
|
|
|
1
|
-
import { readFile } from "fs/promises";
|
|
2
|
-
import { dirname, isAbsolute, resolve } 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 requestedPath = resolve(input.statePath);
|
|
30
|
-
let statePath = requestedPath;
|
|
31
|
-
let state = JSON.parse(await readFile(statePath, "utf-8"));
|
|
32
|
-
if (typeof state.state_path === "string" && state.state_path.trim()) {
|
|
33
|
-
const candidatePath = isAbsolute(state.state_path)
|
|
34
|
-
? resolve(state.state_path)
|
|
35
|
-
: resolve(dirname(statePath), state.state_path);
|
|
36
|
-
if (candidatePath !== statePath) {
|
|
37
|
-
try {
|
|
38
|
-
state = JSON.parse(await readFile(candidatePath, "utf-8"));
|
|
39
|
-
statePath = candidatePath;
|
|
40
|
-
} catch (error) {
|
|
41
|
-
const code = error instanceof Error && "code" in error
|
|
42
|
-
? String((error as NodeJS.ErrnoException).code)
|
|
43
|
-
: "";
|
|
44
|
-
if (code !== "ENOENT") throw error;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const workspace = state.workspace_root || resolveWorkspace(statePath);
|
|
50
|
-
const db = ensureDb(workspace);
|
|
51
|
-
|
|
52
|
-
try {
|
|
53
|
-
const now = new Date().toISOString();
|
|
54
|
-
const publishDate = state.metadata?.date || now.split("T")[0];
|
|
55
|
-
const title = state.metadata?.title || "Untitled";
|
|
56
|
-
const topicId = state.topic_id || null;
|
|
57
|
-
|
|
58
|
-
db.prepare(
|
|
59
|
-
`INSERT OR REPLACE INTO analytics (
|
|
60
|
-
run_id, topic_id, title, publish_date,
|
|
61
|
-
reads, likes, favorites, shares, comments,
|
|
62
|
-
notes, recorded_at
|
|
63
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
64
|
-
).run(
|
|
65
|
-
state.run_id,
|
|
66
|
-
topicId,
|
|
67
|
-
title,
|
|
68
|
-
publishDate,
|
|
69
|
-
input.reads ?? 0,
|
|
70
|
-
input.likes ?? 0,
|
|
71
|
-
input.favorites ?? 0,
|
|
72
|
-
input.shares ?? 0,
|
|
73
|
-
input.comments ?? 0,
|
|
74
|
-
input.notes ?? null,
|
|
75
|
-
now,
|
|
76
|
-
);
|
|
77
|
-
|
|
78
|
-
const row = db
|
|
79
|
-
.prepare("SELECT * FROM analytics WHERE run_id = ?")
|
|
80
|
-
.get(state.run_id) as Record<string, unknown>;
|
|
81
|
-
return AnalyticsSchema.parse(row);
|
|
82
|
-
} finally {
|
|
83
|
-
db.close();
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export interface ListAnalyticsFilter {
|
|
88
|
-
days?: number;
|
|
89
|
-
sort?: string;
|
|
90
|
-
limit?: number;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const ALLOWED_SORT_FIELDS = new Set([
|
|
94
|
-
"publish_date",
|
|
95
|
-
"reads",
|
|
96
|
-
"likes",
|
|
97
|
-
"favorites",
|
|
98
|
-
"shares",
|
|
99
|
-
"comments",
|
|
100
|
-
"recorded_at",
|
|
101
|
-
"title",
|
|
102
|
-
]);
|
|
103
|
-
|
|
104
|
-
export async function listAnalytics(
|
|
105
|
-
workspace: string,
|
|
106
|
-
filter: ListAnalyticsFilter,
|
|
107
|
-
): Promise<Analytics[]> {
|
|
108
|
-
const db = ensureDb(workspace);
|
|
109
|
-
|
|
110
|
-
try {
|
|
111
|
-
const conditions: string[] = [];
|
|
112
|
-
const params: (string | number)[] = [];
|
|
113
|
-
|
|
114
|
-
if (filter.days) {
|
|
115
|
-
conditions.push("publish_date >= date('now', ?)");
|
|
116
|
-
params.push(`-${filter.days} days`);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const where =
|
|
120
|
-
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
121
|
-
|
|
122
|
-
const sortField = filter.sort || "publish_date";
|
|
123
|
-
if (!ALLOWED_SORT_FIELDS.has(sortField)) {
|
|
124
|
-
throw new Error(
|
|
125
|
-
`Invalid sort field: ${sortField}. Allowed: ${[...ALLOWED_SORT_FIELDS].join(", ")}`,
|
|
126
|
-
);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const limit = filter.limit ?? 50;
|
|
130
|
-
|
|
131
|
-
const sql = `SELECT * FROM analytics ${where} ORDER BY ${sortField} DESC LIMIT ?`;
|
|
132
|
-
params.push(limit);
|
|
133
|
-
|
|
134
|
-
const rows = db
|
|
135
|
-
.prepare(sql)
|
|
136
|
-
.all(...params) as Record<string, unknown>[];
|
|
137
|
-
return rows.map((row) => AnalyticsSchema.parse(row));
|
|
138
|
-
} finally {
|
|
139
|
-
db.close();
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// CLI handler
|
|
144
|
-
export async function analytics(args: string[]): Promise<void> {
|
|
145
|
-
const parsed = parseArgs(args);
|
|
146
|
-
|
|
147
|
-
if (parsed.help || args.length === 0) {
|
|
148
|
-
console.log(`
|
|
149
|
-
Usage: zzhub-pipeline analytics <subcommand> [options]
|
|
150
|
-
|
|
151
|
-
Subcommands:
|
|
152
|
-
record Record post-publish metrics
|
|
153
|
-
list List analytics history
|
|
154
|
-
|
|
155
|
-
Run 'zzhub-pipeline analytics <subcommand> --help' for details.
|
|
156
|
-
`.trim());
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const subcommand = args[0];
|
|
161
|
-
const subArgs = args.slice(1);
|
|
162
|
-
|
|
163
|
-
switch (subcommand) {
|
|
164
|
-
case "record":
|
|
165
|
-
return analyticsRecord(subArgs);
|
|
166
|
-
case "list":
|
|
167
|
-
return analyticsList(subArgs);
|
|
168
|
-
default:
|
|
169
|
-
throw new Error(`Unknown analytics subcommand: ${subcommand}`);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
async function analyticsRecord(args: string[]): Promise<void> {
|
|
174
|
-
const parsed = parseArgs(args);
|
|
175
|
-
|
|
176
|
-
if (parsed.help) {
|
|
177
|
-
console.log(`
|
|
178
|
-
Usage: zzhub-pipeline analytics record [options]
|
|
179
|
-
|
|
180
|
-
Options:
|
|
181
|
-
--state State file path (required)
|
|
182
|
-
--reads Read count
|
|
183
|
-
--likes Like count
|
|
184
|
-
--favorites Favorite count
|
|
185
|
-
--shares Share count
|
|
186
|
-
--comments Comment count
|
|
187
|
-
--notes Notes
|
|
188
|
-
`.trim());
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
const statePath = requireArg(parsed, "state", "State file path");
|
|
193
|
-
const readsStr = optionalArg(parsed, "reads");
|
|
194
|
-
const likesStr = optionalArg(parsed, "likes");
|
|
195
|
-
const favoritesStr = optionalArg(parsed, "favorites");
|
|
196
|
-
const sharesStr = optionalArg(parsed, "shares");
|
|
197
|
-
const commentsStr = optionalArg(parsed, "comments");
|
|
198
|
-
const notes = optionalArg(parsed, "notes");
|
|
199
|
-
|
|
200
|
-
const result = await recordAnalytics({
|
|
201
|
-
statePath,
|
|
202
|
-
reads: readsStr ? parseInt(readsStr, 10) : undefined,
|
|
203
|
-
likes: likesStr ? parseInt(likesStr, 10) : undefined,
|
|
204
|
-
favorites: favoritesStr ? parseInt(favoritesStr, 10) : undefined,
|
|
205
|
-
shares: sharesStr ? parseInt(sharesStr, 10) : undefined,
|
|
206
|
-
comments: commentsStr ? parseInt(commentsStr, 10) : undefined,
|
|
207
|
-
notes,
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
printResult(result);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
async function analyticsList(args: string[]): Promise<void> {
|
|
214
|
-
const parsed = parseArgs(args);
|
|
215
|
-
|
|
216
|
-
if (parsed.help) {
|
|
217
|
-
console.log(`
|
|
218
|
-
Usage: zzhub-pipeline analytics list [options]
|
|
219
|
-
|
|
220
|
-
Options:
|
|
221
|
-
--workspace Workspace path (required)
|
|
222
|
-
--days Show last N days (default: all)
|
|
223
|
-
--sort Sort field: publish_date/reads/likes (default: publish_date)
|
|
224
|
-
--limit Max results (default: 50)
|
|
225
|
-
--view Output format: json/markdown/agent (default: json)
|
|
226
|
-
`.trim());
|
|
227
|
-
return;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const workspace = requireArg(parsed, "workspace", "Workspace path");
|
|
231
|
-
const daysStr = optionalArg(parsed, "days");
|
|
232
|
-
const days = daysStr ? parseInt(daysStr, 10) : undefined;
|
|
233
|
-
const sort = optionalArg(parsed, "sort");
|
|
234
|
-
const limitStr = optionalArg(parsed, "limit");
|
|
235
|
-
const limit = limitStr ? parseInt(limitStr, 10) : undefined;
|
|
236
|
-
|
|
237
|
-
const result = await listAnalytics(workspace, { days, sort, limit });
|
|
238
|
-
|
|
239
|
-
printResult(result);
|
|
240
|
-
}
|
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
-
import { mkdtemp, rm } from "fs/promises";
|
|
3
|
-
import { join } from "path";
|
|
4
|
-
import { tmpdir } from "os";
|
|
5
|
-
import { addTopic, listTopics, updateTopic, scheduleTopic, retroTopic, abandonTopic } from "./topic";
|
|
6
|
-
|
|
7
|
-
describe("topic 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("addTopic", () => {
|
|
19
|
-
test("creates a new topic with defaults", async () => {
|
|
20
|
-
const result = await addTopic(workspace, {
|
|
21
|
-
title: "Test Topic",
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
expect(result.topic_id).toMatch(/^topic_/);
|
|
25
|
-
expect(result.title).toBe("Test Topic");
|
|
26
|
-
expect(result.status).toBe("backlog");
|
|
27
|
-
expect(result.priority).toBe("medium");
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
test("creates topic with all fields", async () => {
|
|
31
|
-
const result = await addTopic(workspace, {
|
|
32
|
-
title: "AI Tools",
|
|
33
|
-
description: "About AI",
|
|
34
|
-
priority: "high",
|
|
35
|
-
tags: ["AI", "tools"],
|
|
36
|
-
notes: "Some notes",
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
expect(result.priority).toBe("high");
|
|
40
|
-
expect(result.tags).toEqual(["AI", "tools"]);
|
|
41
|
-
expect(result.notes).toBe("Some notes");
|
|
42
|
-
});
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
describe("listTopics", () => {
|
|
46
|
-
test("lists all topics", async () => {
|
|
47
|
-
await addTopic(workspace, { title: "Topic 1" });
|
|
48
|
-
await addTopic(workspace, { title: "Topic 2" });
|
|
49
|
-
|
|
50
|
-
const topics = await listTopics(workspace, {});
|
|
51
|
-
expect(topics.length).toBe(2);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test("filters by status", async () => {
|
|
55
|
-
await addTopic(workspace, { title: "Topic 1", status: "backlog" });
|
|
56
|
-
await addTopic(workspace, { title: "Topic 2", status: "scheduled" });
|
|
57
|
-
|
|
58
|
-
const topics = await listTopics(workspace, { status: "backlog" });
|
|
59
|
-
expect(topics.length).toBe(1);
|
|
60
|
-
expect(topics[0].title).toBe("Topic 1");
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
test("filters by priority", async () => {
|
|
64
|
-
await addTopic(workspace, { title: "Topic 1", priority: "high" });
|
|
65
|
-
await addTopic(workspace, { title: "Topic 2", priority: "low" });
|
|
66
|
-
|
|
67
|
-
const topics = await listTopics(workspace, { priority: "high" });
|
|
68
|
-
expect(topics.length).toBe(1);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test("limits results", async () => {
|
|
72
|
-
await addTopic(workspace, { title: "Topic 1" });
|
|
73
|
-
await addTopic(workspace, { title: "Topic 2" });
|
|
74
|
-
await addTopic(workspace, { title: "Topic 3" });
|
|
75
|
-
|
|
76
|
-
const topics = await listTopics(workspace, { limit: 2 });
|
|
77
|
-
expect(topics.length).toBe(2);
|
|
78
|
-
});
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
describe("updateTopic", () => {
|
|
82
|
-
test("updates topic fields", async () => {
|
|
83
|
-
const topic = await addTopic(workspace, { title: "Original" });
|
|
84
|
-
|
|
85
|
-
const updated = await updateTopic(workspace, topic.topic_id, {
|
|
86
|
-
status: "evaluating",
|
|
87
|
-
ai_score: 85,
|
|
88
|
-
ai_reason: "Good topic",
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
expect(updated.status).toBe("evaluating");
|
|
92
|
-
expect(updated.ai_score).toBe(85);
|
|
93
|
-
expect(updated.ai_reason).toBe("Good topic");
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
test("throws if topic not found", async () => {
|
|
97
|
-
expect(
|
|
98
|
-
updateTopic(workspace, "nonexistent", { status: "scheduled" })
|
|
99
|
-
).rejects.toThrow("Topic not found");
|
|
100
|
-
});
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
describe("scheduleTopic", () => {
|
|
104
|
-
test("schedules topic with date", async () => {
|
|
105
|
-
const topic = await addTopic(workspace, { title: "Test" });
|
|
106
|
-
|
|
107
|
-
const scheduled = await scheduleTopic(workspace, topic.topic_id, {
|
|
108
|
-
scheduled_date: "2026-06-15",
|
|
109
|
-
target_account: "default",
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
expect(scheduled.status).toBe("scheduled");
|
|
113
|
-
expect(scheduled.scheduled_date).toBe("2026-06-15");
|
|
114
|
-
expect(scheduled.target_account).toBe("default");
|
|
115
|
-
});
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
describe("retroTopic", () => {
|
|
119
|
-
test("adds retro and marks published", async () => {
|
|
120
|
-
const topic = await addTopic(workspace, { title: "Test" });
|
|
121
|
-
|
|
122
|
-
const retroed = await retroTopic(workspace, topic.topic_id, {
|
|
123
|
-
performance: "good",
|
|
124
|
-
lessons: "Learned something",
|
|
125
|
-
metrics_snapshot: { reads: 1500 },
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
expect(retroed.status).toBe("published");
|
|
129
|
-
expect(retroed.retro_performance).toBe("good");
|
|
130
|
-
expect(retroed.retro_lessons).toBe("Learned something");
|
|
131
|
-
});
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
describe("abandonTopic", () => {
|
|
135
|
-
test("marks topic as abandoned", async () => {
|
|
136
|
-
const topic = await addTopic(workspace, { title: "Test" });
|
|
137
|
-
|
|
138
|
-
const abandoned = await abandonTopic(workspace, topic.topic_id, {
|
|
139
|
-
reason: "Topic outdated",
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
expect(abandoned.status).toBe("abandoned");
|
|
143
|
-
expect(abandoned.notes).toContain("Topic outdated");
|
|
144
|
-
});
|
|
145
|
-
});
|
|
146
|
-
});
|