@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
|
@@ -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);
|
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,11 +17,13 @@ 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";
|
|
25
28
|
import { wxDrafts } from "./commands/wx-drafts";
|
|
26
29
|
import { wxDraftDelete } from "./commands/wx-draft-delete";
|
|
@@ -64,7 +67,8 @@ export function getCommandPlugins(): CommandPlugin[] {
|
|
|
64
67
|
{
|
|
65
68
|
name: "ops",
|
|
66
69
|
commands: [
|
|
67
|
-
{ 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 },
|
|
68
72
|
{ name: "imgx", summary: "Run bundled imgx renderer subcommands", plugin: "ops", handler: imgxCommand },
|
|
69
73
|
{ name: "wechat-export", summary: "Render markdown to WeChat HTML with bundled preview styles", plugin: "ops", handler: wechatExport },
|
|
70
74
|
{ name: "cos-upload", summary: "Upload a local image to configured COS CDN", plugin: "ops", handler: cosUpload },
|
|
@@ -73,6 +77,8 @@ export function getCommandPlugins(): CommandPlugin[] {
|
|
|
73
77
|
{ name: "hermes-metrics", summary: "Show Hermes execution metrics per task", plugin: "ops", handler: hermesMetrics },
|
|
74
78
|
{ name: "wx-drafts", summary: "List or get drafts from WeChat draft box", plugin: "ops", handler: wxDrafts },
|
|
75
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 },
|
|
76
82
|
],
|
|
77
83
|
},
|
|
78
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,16 +72,17 @@ 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,
|
|
@@ -83,11 +90,12 @@ async function publishWechatArticleRoute({
|
|
|
83
90
|
existingDraftMediaId: state.intent.existing_draft_media_id,
|
|
84
91
|
noteId: state.intent.note_id,
|
|
85
92
|
nezusBaseUrl: process.env.ZZHUB_WX_BASE_URL || config.wx.baseUrl,
|
|
86
|
-
nezusPat: process.env.ZZCLUB_PAT || config.wx.accounts[
|
|
93
|
+
nezusPat: process.env.ZZCLUB_PAT || config.wx.accounts[account]?.pat || config.wx.accounts[config.wx.defaultAccount]?.pat,
|
|
87
94
|
});
|
|
88
95
|
} catch (error) {
|
|
89
96
|
return {
|
|
90
97
|
route: "wechat-article",
|
|
98
|
+
account,
|
|
91
99
|
status: "failed",
|
|
92
100
|
detail: error instanceof Error ? error.message : String(error),
|
|
93
101
|
published_at: null,
|
|
@@ -98,6 +106,7 @@ async function publishWechatArticleRoute({
|
|
|
98
106
|
|
|
99
107
|
return {
|
|
100
108
|
route: "wechat-article",
|
|
109
|
+
account,
|
|
101
110
|
status: "success",
|
|
102
111
|
detail: null,
|
|
103
112
|
published_at: new Date().toISOString(),
|
|
@@ -110,6 +119,7 @@ async function publishWechatNewspicRoute({
|
|
|
110
119
|
state,
|
|
111
120
|
dryRun,
|
|
112
121
|
config,
|
|
122
|
+
accountOverride,
|
|
113
123
|
}: PublishRouteContext): Promise<PublishResult> {
|
|
114
124
|
const postPath = join(state.asset_path, "post.md");
|
|
115
125
|
const postContent = await readFile(postPath, "utf-8");
|
|
@@ -129,11 +139,14 @@ async function publishWechatNewspicRoute({
|
|
|
129
139
|
const renderPhotos = assets.map((asset) => asset.path);
|
|
130
140
|
const photos = mergePhotoLists(renderPhotos, bodyImageUrls);
|
|
131
141
|
|
|
142
|
+
const account = accountOverride || state.route.account;
|
|
143
|
+
|
|
132
144
|
if (dryRun) {
|
|
133
145
|
console.error(`[dry-run] Wechat newspic: ${state.metadata.title}`);
|
|
134
146
|
console.error(`[dry-run] Content file: ${cleanPath}`);
|
|
135
147
|
return {
|
|
136
148
|
route: "wechat-newspic",
|
|
149
|
+
account,
|
|
137
150
|
status: "skipped",
|
|
138
151
|
detail: "dry-run",
|
|
139
152
|
published_at: null,
|
|
@@ -144,7 +157,7 @@ async function publishWechatNewspicRoute({
|
|
|
144
157
|
|
|
145
158
|
try {
|
|
146
159
|
await createWechatNewspic({
|
|
147
|
-
account
|
|
160
|
+
account,
|
|
148
161
|
title: state.metadata.title,
|
|
149
162
|
content: cleanContent,
|
|
150
163
|
photos,
|
|
@@ -152,11 +165,12 @@ async function publishWechatNewspicRoute({
|
|
|
152
165
|
existingDraftMediaId: state.intent.existing_draft_media_id,
|
|
153
166
|
noteId: state.intent.note_id,
|
|
154
167
|
nezusBaseUrl: process.env.ZZHUB_WX_BASE_URL || config.wx.baseUrl,
|
|
155
|
-
nezusPat: process.env.ZZCLUB_PAT || config.wx.accounts[
|
|
168
|
+
nezusPat: process.env.ZZCLUB_PAT || config.wx.accounts[account]?.pat || config.wx.accounts[config.wx.defaultAccount]?.pat,
|
|
156
169
|
});
|
|
157
170
|
} catch (error) {
|
|
158
171
|
return {
|
|
159
172
|
route: "wechat-newspic",
|
|
173
|
+
account,
|
|
160
174
|
status: "failed",
|
|
161
175
|
detail: error instanceof Error ? error.message : String(error),
|
|
162
176
|
published_at: null,
|
|
@@ -167,6 +181,7 @@ async function publishWechatNewspicRoute({
|
|
|
167
181
|
|
|
168
182
|
return {
|
|
169
183
|
route: "wechat-newspic",
|
|
184
|
+
account,
|
|
170
185
|
status: "success",
|
|
171
186
|
detail: null,
|
|
172
187
|
published_at: new Date().toISOString(),
|
|
@@ -175,9 +190,10 @@ async function publishWechatNewspicRoute({
|
|
|
175
190
|
};
|
|
176
191
|
}
|
|
177
192
|
|
|
178
|
-
const PUBLISH_PROVIDERS:
|
|
193
|
+
const PUBLISH_PROVIDERS: Record<RoutePrimary, PublishProvider> = {
|
|
179
194
|
"wechat-article": publishWechatArticleRoute,
|
|
180
195
|
"wechat-newspic": publishWechatNewspicRoute,
|
|
196
|
+
"blog": publishBlogRoute,
|
|
181
197
|
};
|
|
182
198
|
|
|
183
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
|
+
}
|
package/src/providers/wechat.ts
CHANGED
|
@@ -121,7 +121,8 @@ function fillWxAccountConfig(source?: WxAccountConfig): WxAccountConfig {
|
|
|
121
121
|
appId: source?.appId ?? "",
|
|
122
122
|
appSecret: source?.appSecret ?? "",
|
|
123
123
|
customCss: source?.customCss ?? null,
|
|
124
|
-
|
|
124
|
+
theme: source?.theme ?? { editorVars: {}, exportTheme: {} },
|
|
125
|
+
} as WxAccountConfig;
|
|
125
126
|
}
|
|
126
127
|
|
|
127
128
|
function trimTrailingSlash(value: string): string {
|
|
@@ -187,12 +188,17 @@ export function mergePhotoLists(...lists: string[][]): string[] {
|
|
|
187
188
|
|
|
188
189
|
export function extractImageUrls(input: string): string[] {
|
|
189
190
|
const urls = new Set<string>();
|
|
190
|
-
|
|
191
|
+
// Handle both standard  and angle-bracket  syntax.
|
|
192
|
+
// Angle brackets allow URLs with spaces per CommonMark spec.
|
|
193
|
+
// Match markdown images:  and 
|
|
194
|
+
// Second alternative uses lazy match so titles like  still work.
|
|
195
|
+
const markdownImageRegex = /!\[[^\]]*\]\((?:<([^>]+)>|([^)]+?))(?:\s+"[^"]*")?\)/g;
|
|
191
196
|
const htmlImageRegex = /<img[^>]+src=["']([^"']+)["'][^>]*>/g;
|
|
192
197
|
let match = markdownImageRegex.exec(input);
|
|
193
198
|
while (match) {
|
|
194
|
-
|
|
195
|
-
|
|
199
|
+
const url = match[1] || match[2];
|
|
200
|
+
if (url) {
|
|
201
|
+
urls.add(url);
|
|
196
202
|
}
|
|
197
203
|
match = markdownImageRegex.exec(input);
|
|
198
204
|
}
|
|
@@ -203,7 +209,12 @@ export function extractImageUrls(input: string): string[] {
|
|
|
203
209
|
}
|
|
204
210
|
match = htmlImageRegex.exec(input);
|
|
205
211
|
}
|
|
206
|
-
|
|
212
|
+
// Filter out URLs that look like HTML fragments (unescaped code block content
|
|
213
|
+
// that was incorrectly matched by the image regex patterns).
|
|
214
|
+
return [...urls].filter((url) => {
|
|
215
|
+
if (url.startsWith('<') || url.includes('/>')) return false;
|
|
216
|
+
return true;
|
|
217
|
+
});
|
|
207
218
|
}
|
|
208
219
|
|
|
209
220
|
export function replaceImageUrls(html: string, imageUrlMap: Record<string, string>): string {
|
|
@@ -335,7 +346,8 @@ async function resolvePhotoPayload(
|
|
|
335
346
|
}
|
|
336
347
|
|
|
337
348
|
if (photoUrl.startsWith("http://") || photoUrl.startsWith("https://")) {
|
|
338
|
-
const
|
|
349
|
+
const safeUrl = photoUrl.includes(" ") ? photoUrl.replace(/ /g, "%20") : photoUrl;
|
|
350
|
+
const response = await fetch(safeUrl);
|
|
339
351
|
if (!response.ok) {
|
|
340
352
|
throw new Error(`Failed to download image: HTTP ${response.status}`);
|
|
341
353
|
}
|
|
@@ -348,8 +360,9 @@ async function resolvePhotoPayload(
|
|
|
348
360
|
}
|
|
349
361
|
|
|
350
362
|
if (photoUrl.startsWith("file://")) {
|
|
351
|
-
const
|
|
352
|
-
|
|
363
|
+
const safeUrl = photoUrl.replace(/ /g, "%20");
|
|
364
|
+
const url = new URL(safeUrl);
|
|
365
|
+
return resolveFilePayload(decodeURIComponent(url.pathname), index);
|
|
353
366
|
}
|
|
354
367
|
|
|
355
368
|
return resolveFilePayload(photoUrl, index);
|
|
@@ -359,6 +372,14 @@ function resolveFilePayload(
|
|
|
359
372
|
filePath: string,
|
|
360
373
|
index: number,
|
|
361
374
|
): { blob: Blob; filename: string } {
|
|
375
|
+
// Detect HTML fragments incorrectly extracted as image URLs (defense-in-depth)
|
|
376
|
+
if (filePath.startsWith("<") && (filePath.includes("style=") || filePath.includes("</") || filePath.includes("/>"))) {
|
|
377
|
+
throw new Error(
|
|
378
|
+
`Image URL appears to be HTML content, not a valid file path or URL. ` +
|
|
379
|
+
`This usually means extractImageUrls matched unescaped HTML from a code block. ` +
|
|
380
|
+
`Got: ${filePath.slice(0, 120)}`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
362
383
|
if (!existsSync(filePath)) {
|
|
363
384
|
throw new Error(`File not found: ${filePath}`);
|
|
364
385
|
}
|
|
@@ -549,7 +570,8 @@ export async function createWechatNewspic(input: WechatNewspicInput): Promise<Re
|
|
|
549
570
|
const uploadResult = await uploadPhotos(runtime, accessToken, finalPhotos, uploadTimeout);
|
|
550
571
|
|
|
551
572
|
const imageInfo = {
|
|
552
|
-
image_list: uploadResult.uploadedMedia
|
|
573
|
+
image_list: uploadResult.uploadedMedia
|
|
574
|
+
.map((item) => ({ image_media_id: item.mediaId })),
|
|
553
575
|
};
|
|
554
576
|
|
|
555
577
|
const isUpdate = !!input.existingDraftMediaId;
|
package/src/routes.ts
CHANGED