@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
|
@@ -54,6 +54,7 @@ ${CSS_DEMO}
|
|
|
54
54
|
const customCss = optionalArg(parsed, "custom-css");
|
|
55
55
|
|
|
56
56
|
const config = loadConfig();
|
|
57
|
+
const wxAccount = config.wx.accounts[account] ?? config.wx.accounts[config.wx.defaultAccount];
|
|
57
58
|
const markdownRenderer = await resolveMarkdownRenderer(config);
|
|
58
59
|
const result = await markdownRenderer.render({
|
|
59
60
|
markdownPath,
|
|
@@ -62,6 +63,7 @@ ${CSS_DEMO}
|
|
|
62
63
|
title,
|
|
63
64
|
previewShellOutPath,
|
|
64
65
|
customCss,
|
|
66
|
+
themeOverrides: wxAccount?.theme,
|
|
65
67
|
});
|
|
66
68
|
|
|
67
69
|
printResult(result, renderWechatExport);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wx-draft-delete — Delete a draft from WeChat draft box.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* zzhub-pipeline wx-draft-delete [--account <account>] --media-id <media_id>
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { optionalArg, parseArgs, requireArg } from "../args";
|
|
9
|
+
import { loadConfig } from "../config";
|
|
10
|
+
import { printHelp, printResult } from "../output";
|
|
11
|
+
import { deleteWxDraft } from "../providers/wechat";
|
|
12
|
+
|
|
13
|
+
export async function wxDraftDelete(args: string[]): Promise<void> {
|
|
14
|
+
const parsed = parseArgs(args);
|
|
15
|
+
|
|
16
|
+
if (parsed.help) {
|
|
17
|
+
printHelp(`
|
|
18
|
+
Usage: zzhub-pipeline wx-draft-delete [options]
|
|
19
|
+
|
|
20
|
+
Options:
|
|
21
|
+
--account WeChat account name (defaults to wx.defaultAccount from config)
|
|
22
|
+
--media-id Draft media_id to delete (required)
|
|
23
|
+
|
|
24
|
+
Examples:
|
|
25
|
+
zzhub-pipeline wx-draft-delete --media-id MEDIA_ID
|
|
26
|
+
`.trim());
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const config = loadConfig();
|
|
31
|
+
const account = optionalArg(parsed, "account") || config.wx.defaultAccount;
|
|
32
|
+
const mediaId = requireArg(parsed, "media-id", "draft media_id to delete");
|
|
33
|
+
|
|
34
|
+
const result = await deleteWxDraft({ account, mediaId, config });
|
|
35
|
+
printResult(result);
|
|
36
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wx-drafts — List or get WeChat draft box drafts.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* zzhub-pipeline wx-drafts [--account <account>] [--limit 20] [--offset 0]
|
|
6
|
+
* zzhub-pipeline wx-drafts [--account <account>] --media-id <media_id>
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { optionalArg, parseArgs } from "../args";
|
|
10
|
+
import { loadConfig } from "../config";
|
|
11
|
+
import { printHelp, printResult } from "../output";
|
|
12
|
+
import { getWxDraft, getWxDraftList } from "../providers/wechat";
|
|
13
|
+
|
|
14
|
+
export async function wxDrafts(args: string[]): Promise<void> {
|
|
15
|
+
const parsed = parseArgs(args);
|
|
16
|
+
|
|
17
|
+
if (parsed.help) {
|
|
18
|
+
printHelp(`
|
|
19
|
+
Usage: zzhub-pipeline wx-drafts [options]
|
|
20
|
+
|
|
21
|
+
Options:
|
|
22
|
+
--account WeChat account name (defaults to wx.defaultAccount from config)
|
|
23
|
+
--media-id Draft media_id to fetch a single draft (optional)
|
|
24
|
+
--limit Number of drafts to list, default 20, max 20 (optional)
|
|
25
|
+
--offset Pagination offset, default 0 (optional)
|
|
26
|
+
|
|
27
|
+
Examples:
|
|
28
|
+
zzhub-pipeline wx-drafts --limit 10
|
|
29
|
+
zzhub-pipeline wx-drafts --media-id MEDIA_ID
|
|
30
|
+
`.trim());
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const config = loadConfig();
|
|
35
|
+
const account = optionalArg(parsed, "account") || config.wx.defaultAccount;
|
|
36
|
+
const mediaId = optionalArg(parsed, "media-id") ?? null;
|
|
37
|
+
const limit = optionalArg(parsed, "limit") ? Number.parseInt(optionalArg(parsed, "limit")!, 10) : undefined;
|
|
38
|
+
const offset = optionalArg(parsed, "offset") ? Number.parseInt(optionalArg(parsed, "offset")!, 10) : undefined;
|
|
39
|
+
|
|
40
|
+
if (mediaId) {
|
|
41
|
+
const result = await getWxDraft({ account, mediaId, config });
|
|
42
|
+
printResult(result);
|
|
43
|
+
} else {
|
|
44
|
+
const result = await getWxDraftList({ account, limit, offset, config });
|
|
45
|
+
printResult(result);
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/config.ts
CHANGED
|
Binary file
|
package/src/db.test.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm, exists } from "fs/promises";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import { getDb, initDb } from "./db";
|
|
6
|
+
|
|
7
|
+
describe("db", () => {
|
|
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
|
+
test("getDb creates database file", () => {
|
|
19
|
+
const db = getDb(workspace);
|
|
20
|
+
db.close();
|
|
21
|
+
expect(exists(join(workspace, "zzhub.db"))).resolves.toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("initDb creates topics table", () => {
|
|
25
|
+
const db = getDb(workspace);
|
|
26
|
+
initDb(db);
|
|
27
|
+
|
|
28
|
+
const tables = db.prepare(
|
|
29
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='topics'"
|
|
30
|
+
).all();
|
|
31
|
+
|
|
32
|
+
expect(tables.length).toBe(1);
|
|
33
|
+
db.close();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("initDb creates analytics table", () => {
|
|
37
|
+
const db = getDb(workspace);
|
|
38
|
+
initDb(db);
|
|
39
|
+
|
|
40
|
+
const tables = db.prepare(
|
|
41
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='analytics'"
|
|
42
|
+
).all();
|
|
43
|
+
|
|
44
|
+
expect(tables.length).toBe(1);
|
|
45
|
+
db.close();
|
|
46
|
+
});
|
|
47
|
+
});
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { existsSync } from "fs";
|
|
4
|
+
import { mkdirSync } from "fs";
|
|
5
|
+
|
|
6
|
+
const DB_FILE = "zzhub.db";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Get or create SQLite database for workspace.
|
|
10
|
+
*/
|
|
11
|
+
export function getDb(workspace: string): Database {
|
|
12
|
+
if (!existsSync(workspace)) {
|
|
13
|
+
mkdirSync(workspace, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const dbPath = join(workspace, DB_FILE);
|
|
17
|
+
const db = new Database(dbPath);
|
|
18
|
+
|
|
19
|
+
// Enable WAL mode for better concurrent performance
|
|
20
|
+
db.exec("PRAGMA journal_mode = WAL;");
|
|
21
|
+
|
|
22
|
+
return db;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Initialize database schema (idempotent).
|
|
27
|
+
*/
|
|
28
|
+
export function initDb(db: Database): void {
|
|
29
|
+
db.exec(`
|
|
30
|
+
CREATE TABLE IF NOT EXISTS topics (
|
|
31
|
+
topic_id TEXT PRIMARY KEY,
|
|
32
|
+
title TEXT NOT NULL,
|
|
33
|
+
description TEXT,
|
|
34
|
+
priority TEXT CHECK(priority IN ('high', 'medium', 'low')) DEFAULT 'medium',
|
|
35
|
+
tags TEXT,
|
|
36
|
+
notes TEXT,
|
|
37
|
+
status TEXT CHECK(status IN (
|
|
38
|
+
'backlog', 'evaluating', 'scheduled', 'in_progress', 'published', 'abandoned'
|
|
39
|
+
)) DEFAULT 'backlog',
|
|
40
|
+
|
|
41
|
+
ai_score INTEGER,
|
|
42
|
+
ai_reason TEXT,
|
|
43
|
+
|
|
44
|
+
scheduled_date TEXT,
|
|
45
|
+
target_account TEXT,
|
|
46
|
+
|
|
47
|
+
run_id TEXT,
|
|
48
|
+
|
|
49
|
+
retro_performance TEXT CHECK(retro_performance IN (
|
|
50
|
+
'excellent', 'good', 'average', 'poor', null
|
|
51
|
+
)),
|
|
52
|
+
retro_lessons TEXT,
|
|
53
|
+
retro_metrics_snapshot TEXT,
|
|
54
|
+
|
|
55
|
+
created_at TEXT NOT NULL,
|
|
56
|
+
updated_at TEXT NOT NULL
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
CREATE INDEX IF NOT EXISTS idx_topics_status ON topics(status);
|
|
60
|
+
CREATE INDEX IF NOT EXISTS idx_topics_priority ON topics(priority);
|
|
61
|
+
CREATE INDEX IF NOT EXISTS idx_topics_scheduled ON topics(scheduled_date);
|
|
62
|
+
CREATE INDEX IF NOT EXISTS idx_topics_created ON topics(created_at);
|
|
63
|
+
|
|
64
|
+
CREATE TABLE IF NOT EXISTS analytics (
|
|
65
|
+
run_id TEXT PRIMARY KEY,
|
|
66
|
+
topic_id TEXT,
|
|
67
|
+
title TEXT NOT NULL,
|
|
68
|
+
publish_date TEXT NOT NULL,
|
|
69
|
+
|
|
70
|
+
reads INTEGER DEFAULT 0,
|
|
71
|
+
likes INTEGER DEFAULT 0,
|
|
72
|
+
favorites INTEGER DEFAULT 0,
|
|
73
|
+
shares INTEGER DEFAULT 0,
|
|
74
|
+
comments INTEGER DEFAULT 0,
|
|
75
|
+
|
|
76
|
+
notes TEXT,
|
|
77
|
+
recorded_at TEXT NOT NULL,
|
|
78
|
+
|
|
79
|
+
FOREIGN KEY (topic_id) REFERENCES topics(topic_id)
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
CREATE INDEX IF NOT EXISTS idx_analytics_publish_date ON analytics(publish_date);
|
|
83
|
+
CREATE INDEX IF NOT EXISTS idx_analytics_topic ON analytics(topic_id);
|
|
84
|
+
CREATE INDEX IF NOT EXISTS idx_analytics_reads ON analytics(reads);
|
|
85
|
+
`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Ensure database is initialized for workspace.
|
|
90
|
+
*/
|
|
91
|
+
export function ensureDb(workspace: string): Database {
|
|
92
|
+
const db = getDb(workspace);
|
|
93
|
+
initDb(db);
|
|
94
|
+
return db;
|
|
95
|
+
}
|
|
Binary file
|
|
@@ -136,7 +136,7 @@
|
|
|
136
136
|
<body>
|
|
137
137
|
<div class="canvas">
|
|
138
138
|
<section class="left">
|
|
139
|
-
<div class="eyebrow"
|
|
139
|
+
<div class="eyebrow">{{FOOTER_TEXT}}</div>
|
|
140
140
|
<div class="text-block">
|
|
141
141
|
<div class="pretext-lines" id="cover-lines" aria-label="cover text"></div>
|
|
142
142
|
</div>
|
package/src/imgx/render-card.ts
CHANGED
|
@@ -30,11 +30,7 @@ const DEFAULT_SIZE = { width: 900, height: 1200 } as const;
|
|
|
30
30
|
const WECHAT_SPLIT_WINDOW_EXTRA_HEIGHT = 87;
|
|
31
31
|
const PRETEXT_SCREENSHOT_VIRTUAL_TIME_BUDGET_MS = 6_000;
|
|
32
32
|
|
|
33
|
-
function detectIcon(
|
|
34
|
-
if (template === "wechat-cover-split") {
|
|
35
|
-
return join(ICONS_DIR, "logo.png");
|
|
36
|
-
}
|
|
37
|
-
|
|
33
|
+
function detectIcon(_template: string, _text: string, _line1: string, _line2: string, _line3: string, fallbackIcon: string): string {
|
|
38
34
|
if (fallbackIcon.length > 0) return fallbackIcon;
|
|
39
35
|
return join(ICONS_DIR, "logo.png");
|
|
40
36
|
}
|
package/src/plugins.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { abandon } from "./commands/abandon";
|
|
2
|
+
import { analytics } from "./commands/analytics";
|
|
2
3
|
import { attachBody } from "./commands/attach-body";
|
|
3
4
|
import { attachBodyImages } from "./commands/attach-body-images";
|
|
4
5
|
import { attachNewspicSpec } from "./commands/attach-newspic-spec";
|
|
@@ -16,12 +17,16 @@ import { prepare } from "./commands/prepare";
|
|
|
16
17
|
import { publish } from "./commands/publish";
|
|
17
18
|
import { reconcile } from "./commands/reconcile";
|
|
18
19
|
import { render } from "./commands/render";
|
|
20
|
+
import { republish } from "./commands/republish";
|
|
19
21
|
import { reset } from "./commands/reset";
|
|
20
22
|
import { review } from "./commands/review";
|
|
21
23
|
import { status } from "./commands/status";
|
|
22
24
|
import { syncBlog } from "./commands/sync-blog";
|
|
23
25
|
import { tasks } from "./commands/tasks";
|
|
26
|
+
import { topic } from "./commands/topic";
|
|
24
27
|
import { wechatExport } from "./commands/wechat-export";
|
|
28
|
+
import { wxDrafts } from "./commands/wx-drafts";
|
|
29
|
+
import { wxDraftDelete } from "./commands/wx-draft-delete";
|
|
25
30
|
|
|
26
31
|
export interface CommandDefinition {
|
|
27
32
|
name: string;
|
|
@@ -62,13 +67,18 @@ export function getCommandPlugins(): CommandPlugin[] {
|
|
|
62
67
|
{
|
|
63
68
|
name: "ops",
|
|
64
69
|
commands: [
|
|
65
|
-
{ name: "sync-blog", summary: "Copy
|
|
70
|
+
{ name: "sync-blog", summary: "Copy post.md to blog repo, publish, and record result in state JSON", plugin: "ops", handler: syncBlog },
|
|
71
|
+
{ name: "republish", summary: "Publish completed task to additional accounts/platforms", plugin: "ops", handler: republish },
|
|
66
72
|
{ name: "imgx", summary: "Run bundled imgx renderer subcommands", plugin: "ops", handler: imgxCommand },
|
|
67
73
|
{ name: "wechat-export", summary: "Render markdown to WeChat HTML with bundled preview styles", plugin: "ops", handler: wechatExport },
|
|
68
74
|
{ name: "cos-upload", summary: "Upload a local image to configured COS CDN", plugin: "ops", handler: cosUpload },
|
|
69
75
|
{ name: "config", summary: "Read or update pipeline config", plugin: "ops", handler: configCommand },
|
|
70
76
|
{ name: "doctor", summary: "Inspect resolved paths and provider health", plugin: "ops", handler: doctor },
|
|
71
77
|
{ name: "hermes-metrics", summary: "Show Hermes execution metrics per task", plugin: "ops", handler: hermesMetrics },
|
|
78
|
+
{ name: "wx-drafts", summary: "List or get drafts from WeChat draft box", plugin: "ops", handler: wxDrafts },
|
|
79
|
+
{ name: "wx-draft-delete", summary: "Delete a draft from WeChat draft box", plugin: "ops", handler: wxDraftDelete },
|
|
80
|
+
{ name: "topic", summary: "Manage topics (add, list, update, schedule, retro, abandon)", plugin: "ops", handler: topic },
|
|
81
|
+
{ name: "analytics", summary: "Record and analyze post-publish metrics", plugin: "ops", handler: analytics },
|
|
72
82
|
],
|
|
73
83
|
},
|
|
74
84
|
];
|
package/src/providers/blog.ts
CHANGED
|
@@ -84,9 +84,12 @@ export async function publishBlogRoute({
|
|
|
84
84
|
}: BlogPublishContext): Promise<PublishResult> {
|
|
85
85
|
const postPath = join(state.asset_path, "post.md");
|
|
86
86
|
const postContent = await readFile(postPath, "utf-8");
|
|
87
|
-
const category = "
|
|
87
|
+
const category = "nezus";
|
|
88
88
|
const blogRoot = workspacePaths.blogRoot;
|
|
89
|
-
|
|
89
|
+
|
|
90
|
+
const [year, month] = state.metadata.date.split("-");
|
|
91
|
+
const blogPostDir = join(blogRoot, "content", category, year, month);
|
|
92
|
+
const blogPostPath = join(blogPostDir, `${state.metadata.slug}.md`);
|
|
90
93
|
|
|
91
94
|
const lines = ["---"];
|
|
92
95
|
lines.push(`title: "${state.metadata.title.replace(/"/g, '\\"')}"`);
|
|
@@ -120,6 +123,7 @@ export async function publishBlogRoute({
|
|
|
120
123
|
console.error(`[dry-run] Blog: cd ${blogRoot} && ${config.commands.blogPublish.join(" ")}`);
|
|
121
124
|
return {
|
|
122
125
|
route: "blog",
|
|
126
|
+
account: "default",
|
|
123
127
|
status: "skipped",
|
|
124
128
|
detail: "dry-run",
|
|
125
129
|
published_at: null,
|
|
@@ -128,13 +132,14 @@ export async function publishBlogRoute({
|
|
|
128
132
|
};
|
|
129
133
|
}
|
|
130
134
|
|
|
131
|
-
await mkdir(
|
|
135
|
+
await mkdir(blogPostDir, { recursive: true });
|
|
132
136
|
await writeFile(blogPostPath, blogContent, "utf-8");
|
|
133
137
|
const result = spawnSync(config.commands.blogPublish, { cwd: blogRoot });
|
|
134
138
|
|
|
135
139
|
if (result.exitCode !== 0) {
|
|
136
140
|
return {
|
|
137
141
|
route: "blog",
|
|
142
|
+
account: "default",
|
|
138
143
|
status: "failed",
|
|
139
144
|
detail: `blog publish command failed: ${config.commands.blogPublish.join(" ")}`,
|
|
140
145
|
published_at: null,
|
|
@@ -145,6 +150,7 @@ export async function publishBlogRoute({
|
|
|
145
150
|
|
|
146
151
|
return {
|
|
147
152
|
route: "blog",
|
|
153
|
+
account: "default",
|
|
148
154
|
status: "success",
|
|
149
155
|
detail: null,
|
|
150
156
|
published_at: new Date().toISOString(),
|
package/src/providers/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
prepareBodyForNewspic,
|
|
8
8
|
} from "../text";
|
|
9
9
|
import { resolveMarkdownRenderer } from "../adapter-loader";
|
|
10
|
+
import { publishBlogRoute } from "./blog";
|
|
10
11
|
import { createWechatDraft, createWechatNewspic, extractImageUrls, mergePhotoLists } from "./wechat";
|
|
11
12
|
|
|
12
13
|
export interface PublishRouteContext {
|
|
@@ -14,6 +15,7 @@ export interface PublishRouteContext {
|
|
|
14
15
|
dryRun: boolean;
|
|
15
16
|
config: PipelineConfig;
|
|
16
17
|
workspacePaths: ResolvedWorkspacePaths;
|
|
18
|
+
accountOverride?: string;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export type PublishProvider = (ctx: PublishRouteContext) => Promise<PublishResult>;
|
|
@@ -23,6 +25,7 @@ async function publishWechatArticleRoute({
|
|
|
23
25
|
dryRun,
|
|
24
26
|
config,
|
|
25
27
|
workspacePaths,
|
|
28
|
+
accountOverride,
|
|
26
29
|
}: PublishRouteContext): Promise<PublishResult> {
|
|
27
30
|
const postPath = join(state.asset_path, "post.md");
|
|
28
31
|
let exportPostPath = postPath;
|
|
@@ -51,11 +54,14 @@ async function publishWechatArticleRoute({
|
|
|
51
54
|
}
|
|
52
55
|
}
|
|
53
56
|
|
|
57
|
+
const account = accountOverride || state.route.account;
|
|
58
|
+
|
|
54
59
|
if (dryRun) {
|
|
55
60
|
console.error(`[dry-run] Zotepad export: ${exportPostPath}`);
|
|
56
61
|
console.error(`[dry-run] Wechat draft: ${state.metadata.title}`);
|
|
57
62
|
return {
|
|
58
63
|
route: "wechat-article",
|
|
64
|
+
account,
|
|
59
65
|
status: "skipped",
|
|
60
66
|
detail: "dry-run",
|
|
61
67
|
published_at: null,
|
|
@@ -66,24 +72,30 @@ async function publishWechatArticleRoute({
|
|
|
66
72
|
|
|
67
73
|
try {
|
|
68
74
|
const markdownRenderer = await resolveMarkdownRenderer(config);
|
|
69
|
-
const wxAccount = config.wx.accounts[
|
|
75
|
+
const wxAccount = config.wx.accounts[account] ?? config.wx.accounts[config.wx.defaultAccount];
|
|
70
76
|
const { html } = await markdownRenderer.render({
|
|
71
77
|
markdownPath: exportPostPath,
|
|
72
78
|
outPath: workspacePaths.zotepadExportHtml,
|
|
73
|
-
account
|
|
79
|
+
account,
|
|
74
80
|
title: state.metadata.title,
|
|
75
81
|
customCss: wxAccount?.customCss ?? null,
|
|
82
|
+
themeOverrides: wxAccount?.theme,
|
|
76
83
|
});
|
|
77
84
|
await createWechatDraft({
|
|
78
|
-
account
|
|
85
|
+
account,
|
|
79
86
|
title: state.metadata.title,
|
|
80
87
|
html,
|
|
81
88
|
photos,
|
|
82
89
|
config,
|
|
90
|
+
existingDraftMediaId: state.intent.existing_draft_media_id,
|
|
91
|
+
noteId: state.intent.note_id,
|
|
92
|
+
nezusBaseUrl: process.env.ZZHUB_WX_BASE_URL || config.wx.baseUrl,
|
|
93
|
+
nezusPat: process.env.ZZCLUB_PAT || config.wx.accounts[account]?.pat || config.wx.accounts[config.wx.defaultAccount]?.pat,
|
|
83
94
|
});
|
|
84
95
|
} catch (error) {
|
|
85
96
|
return {
|
|
86
97
|
route: "wechat-article",
|
|
98
|
+
account,
|
|
87
99
|
status: "failed",
|
|
88
100
|
detail: error instanceof Error ? error.message : String(error),
|
|
89
101
|
published_at: null,
|
|
@@ -94,6 +106,7 @@ async function publishWechatArticleRoute({
|
|
|
94
106
|
|
|
95
107
|
return {
|
|
96
108
|
route: "wechat-article",
|
|
109
|
+
account,
|
|
97
110
|
status: "success",
|
|
98
111
|
detail: null,
|
|
99
112
|
published_at: new Date().toISOString(),
|
|
@@ -106,6 +119,7 @@ async function publishWechatNewspicRoute({
|
|
|
106
119
|
state,
|
|
107
120
|
dryRun,
|
|
108
121
|
config,
|
|
122
|
+
accountOverride,
|
|
109
123
|
}: PublishRouteContext): Promise<PublishResult> {
|
|
110
124
|
const postPath = join(state.asset_path, "post.md");
|
|
111
125
|
const postContent = await readFile(postPath, "utf-8");
|
|
@@ -125,11 +139,14 @@ async function publishWechatNewspicRoute({
|
|
|
125
139
|
const renderPhotos = assets.map((asset) => asset.path);
|
|
126
140
|
const photos = mergePhotoLists(renderPhotos, bodyImageUrls);
|
|
127
141
|
|
|
142
|
+
const account = accountOverride || state.route.account;
|
|
143
|
+
|
|
128
144
|
if (dryRun) {
|
|
129
145
|
console.error(`[dry-run] Wechat newspic: ${state.metadata.title}`);
|
|
130
146
|
console.error(`[dry-run] Content file: ${cleanPath}`);
|
|
131
147
|
return {
|
|
132
148
|
route: "wechat-newspic",
|
|
149
|
+
account,
|
|
133
150
|
status: "skipped",
|
|
134
151
|
detail: "dry-run",
|
|
135
152
|
published_at: null,
|
|
@@ -140,15 +157,20 @@ async function publishWechatNewspicRoute({
|
|
|
140
157
|
|
|
141
158
|
try {
|
|
142
159
|
await createWechatNewspic({
|
|
143
|
-
account
|
|
160
|
+
account,
|
|
144
161
|
title: state.metadata.title,
|
|
145
162
|
content: cleanContent,
|
|
146
163
|
photos,
|
|
147
164
|
config,
|
|
165
|
+
existingDraftMediaId: state.intent.existing_draft_media_id,
|
|
166
|
+
noteId: state.intent.note_id,
|
|
167
|
+
nezusBaseUrl: process.env.ZZHUB_WX_BASE_URL || config.wx.baseUrl,
|
|
168
|
+
nezusPat: process.env.ZZCLUB_PAT || config.wx.accounts[account]?.pat || config.wx.accounts[config.wx.defaultAccount]?.pat,
|
|
148
169
|
});
|
|
149
170
|
} catch (error) {
|
|
150
171
|
return {
|
|
151
172
|
route: "wechat-newspic",
|
|
173
|
+
account,
|
|
152
174
|
status: "failed",
|
|
153
175
|
detail: error instanceof Error ? error.message : String(error),
|
|
154
176
|
published_at: null,
|
|
@@ -159,6 +181,7 @@ async function publishWechatNewspicRoute({
|
|
|
159
181
|
|
|
160
182
|
return {
|
|
161
183
|
route: "wechat-newspic",
|
|
184
|
+
account,
|
|
162
185
|
status: "success",
|
|
163
186
|
detail: null,
|
|
164
187
|
published_at: new Date().toISOString(),
|
|
@@ -167,9 +190,10 @@ async function publishWechatNewspicRoute({
|
|
|
167
190
|
};
|
|
168
191
|
}
|
|
169
192
|
|
|
170
|
-
const PUBLISH_PROVIDERS:
|
|
193
|
+
const PUBLISH_PROVIDERS: Record<RoutePrimary, PublishProvider> = {
|
|
171
194
|
"wechat-article": publishWechatArticleRoute,
|
|
172
195
|
"wechat-newspic": publishWechatNewspicRoute,
|
|
196
|
+
"blog": publishBlogRoute,
|
|
173
197
|
};
|
|
174
198
|
|
|
175
199
|
export function getPublishProvider(route: RoutePrimary): PublishProvider {
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* publish-core.ts — Shared publish execution logic.
|
|
3
|
+
*
|
|
4
|
+
* Used by both `publish` (normal flow) and `republish` (add-on).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { PipelineConfig, ResolvedWorkspacePaths } from "../config";
|
|
8
|
+
import type { PublishResult, PublishTarget, WorkflowState } from "../state";
|
|
9
|
+
import { getPublishProvider, type PublishRouteContext } from "./index";
|
|
10
|
+
|
|
11
|
+
export interface PublishTargetError {
|
|
12
|
+
route: string;
|
|
13
|
+
account: string;
|
|
14
|
+
error: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ExecutePublishTargetsParams {
|
|
18
|
+
state: WorkflowState;
|
|
19
|
+
targets: PublishTarget[];
|
|
20
|
+
dryRun: boolean;
|
|
21
|
+
config: PipelineConfig;
|
|
22
|
+
workspacePaths: ResolvedWorkspacePaths;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ExecutePublishTargetsResult {
|
|
26
|
+
results: PublishResult[];
|
|
27
|
+
errors: PublishTargetError[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Deduplicate targets by route+account.
|
|
32
|
+
*/
|
|
33
|
+
export function dedupeTargets(targets: PublishTarget[]): PublishTarget[] {
|
|
34
|
+
const seen = new Set<string>();
|
|
35
|
+
const result: PublishTarget[] = [];
|
|
36
|
+
for (const target of targets) {
|
|
37
|
+
const key = `${target.route}@${target.account}`;
|
|
38
|
+
if (!seen.has(key)) {
|
|
39
|
+
seen.add(key);
|
|
40
|
+
result.push(target);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Filter out targets that already have a successful publish result
|
|
48
|
+
* at the current content_version + render_version.
|
|
49
|
+
*/
|
|
50
|
+
export function filterIdempotent(
|
|
51
|
+
targets: PublishTarget[],
|
|
52
|
+
existingResults: PublishResult[],
|
|
53
|
+
contentVersion: number,
|
|
54
|
+
renderVersion: number,
|
|
55
|
+
): PublishTarget[] {
|
|
56
|
+
return targets.filter((target) => {
|
|
57
|
+
const existing = existingResults.find(
|
|
58
|
+
(r) =>
|
|
59
|
+
r.route === target.route &&
|
|
60
|
+
r.account === target.account &&
|
|
61
|
+
r.status === "success" &&
|
|
62
|
+
r.content_version === contentVersion &&
|
|
63
|
+
r.render_version === renderVersion,
|
|
64
|
+
);
|
|
65
|
+
return !existing;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Execute publish for a list of targets in parallel.
|
|
71
|
+
*/
|
|
72
|
+
export async function executePublishTargets(
|
|
73
|
+
params: ExecutePublishTargetsParams,
|
|
74
|
+
): Promise<ExecutePublishTargetsResult> {
|
|
75
|
+
const { state, targets, dryRun, config, workspacePaths } = params;
|
|
76
|
+
|
|
77
|
+
const deduped = dedupeTargets(targets);
|
|
78
|
+
const filtered = filterIdempotent(
|
|
79
|
+
deduped,
|
|
80
|
+
state.publish.results,
|
|
81
|
+
state.artifacts.content_version,
|
|
82
|
+
state.artifacts.render_version,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const results: PublishResult[] = [];
|
|
86
|
+
const errors: PublishTargetError[] = [];
|
|
87
|
+
|
|
88
|
+
// Execute in parallel
|
|
89
|
+
const promises = filtered.map(async (target) => {
|
|
90
|
+
const provider = getPublishProvider(target.route);
|
|
91
|
+
const ctx: PublishRouteContext = {
|
|
92
|
+
state,
|
|
93
|
+
dryRun,
|
|
94
|
+
config,
|
|
95
|
+
workspacePaths,
|
|
96
|
+
accountOverride: target.account,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const result = await provider(ctx);
|
|
101
|
+
return { result, error: null };
|
|
102
|
+
} catch (err) {
|
|
103
|
+
const error: PublishTargetError = {
|
|
104
|
+
route: target.route,
|
|
105
|
+
account: target.account,
|
|
106
|
+
error: err instanceof Error ? err.message : String(err),
|
|
107
|
+
};
|
|
108
|
+
return { result: null, error };
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const outcomes = await Promise.all(promises);
|
|
113
|
+
|
|
114
|
+
for (const { result, error } of outcomes) {
|
|
115
|
+
if (result) {
|
|
116
|
+
results.push(result);
|
|
117
|
+
}
|
|
118
|
+
if (error) {
|
|
119
|
+
errors.push(error);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { results, errors };
|
|
124
|
+
}
|