@zzclub/pipeline 0.9.0 → 0.10.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/package.json +1 -1
- package/src/adapter-loader.ts +6 -1
- package/src/commands/analytics.test.ts +3 -2
- package/src/commands/analytics.ts +21 -4
- package/src/commands/attach-body-images.ts +23 -3
- package/src/commands/attach-body.ts +12 -3
- package/src/commands/attach-newspic-spec.ts +14 -3
- package/src/commands/config.ts +1 -0
- package/src/commands/ingest-handoff.ts +12 -6
- package/src/commands/init.ts +20 -36
- package/src/commands/prepare-finalize.ts +75 -43
- package/src/commands/prepare.ts +44 -17
- package/src/commands/publish.ts +63 -29
- package/src/commands/reconcile.ts +5 -3
- package/src/commands/render.ts +50 -9
- package/src/commands/republish.ts +73 -30
- package/src/commands/reset.test.ts +43 -0
- package/src/commands/reset.ts +25 -32
- package/src/commands/review.ts +5 -3
- package/src/commands/sync-blog.ts +20 -3
- package/src/config.ts +0 -0
- package/src/providers/blog.test.ts +37 -1
- package/src/providers/blog.ts +51 -13
- package/src/providers/cos.ts +23 -19
- package/src/providers/index.ts +28 -22
- package/src/providers/publish-core.ts +47 -26
- package/src/providers/wechat.test.ts +55 -0
- package/src/providers/wechat.ts +126 -14
- package/src/publish-targets.ts +143 -0
- package/src/schema/state.ts +1 -0
- package/src/state.test.ts +151 -0
- package/src/state.ts +267 -11
- package/src/task-manager.ts +62 -29
- package/src/workflow-materials.test.ts +46 -4
- package/src/workflow-materials.ts +41 -7
- package/src/workflow.test.ts +295 -23
package/src/commands/reset.ts
CHANGED
|
@@ -21,7 +21,14 @@
|
|
|
21
21
|
|
|
22
22
|
import { parseArgs, requireArg } from "../args";
|
|
23
23
|
import { printResult, renderReset } from "../output";
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
defaultContentReview,
|
|
26
|
+
readResolvedState,
|
|
27
|
+
reenterPrepare,
|
|
28
|
+
reenterPublish,
|
|
29
|
+
reenterRender,
|
|
30
|
+
writeState,
|
|
31
|
+
} from "../state";
|
|
25
32
|
|
|
26
33
|
type ResetMode =
|
|
27
34
|
| "content"
|
|
@@ -68,7 +75,7 @@ Modes:
|
|
|
68
75
|
return;
|
|
69
76
|
}
|
|
70
77
|
|
|
71
|
-
const
|
|
78
|
+
const requestedStatePath = requireArg(parsed, "state", "state JSON path");
|
|
72
79
|
const mode = requireArg(parsed, "mode", "reset mode") as ResetMode;
|
|
73
80
|
|
|
74
81
|
if (!VALID_MODES.includes(mode)) {
|
|
@@ -77,7 +84,9 @@ Modes:
|
|
|
77
84
|
);
|
|
78
85
|
}
|
|
79
86
|
|
|
80
|
-
const
|
|
87
|
+
const resolved = await readResolvedState(requestedStatePath);
|
|
88
|
+
const statePath = resolved.path;
|
|
89
|
+
const state = resolved.state;
|
|
81
90
|
|
|
82
91
|
switch (mode) {
|
|
83
92
|
case "content":
|
|
@@ -85,14 +94,10 @@ Modes:
|
|
|
85
94
|
case "redo.format":
|
|
86
95
|
case "redo.metadata":
|
|
87
96
|
case "redo.route":
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
state.phase.current = "prepare";
|
|
93
|
-
state.mode = "active";
|
|
94
|
-
// Persist redo hint so orchestrator can recover start step even after context loss
|
|
95
|
-
state.redo_hint =
|
|
97
|
+
reenterPrepare(state, {
|
|
98
|
+
clearFormattedBody: mode === "content",
|
|
99
|
+
resetReview: mode === "content" || mode === "redo.style",
|
|
100
|
+
redoHint:
|
|
96
101
|
mode === "redo.style"
|
|
97
102
|
? "style"
|
|
98
103
|
: mode === "redo.format"
|
|
@@ -101,33 +106,21 @@ Modes:
|
|
|
101
106
|
? "asset-meta"
|
|
102
107
|
: mode === "redo.route"
|
|
103
108
|
? "channel-route"
|
|
104
|
-
: "writer"
|
|
105
|
-
|
|
106
|
-
if (mode === "content") {
|
|
107
|
-
state.content_review = defaultContentReview();
|
|
108
|
-
}
|
|
109
|
+
: "writer",
|
|
110
|
+
});
|
|
109
111
|
break;
|
|
110
112
|
|
|
111
113
|
case "render":
|
|
112
|
-
|
|
113
|
-
state.phase.render = { status: "pending", error: null };
|
|
114
|
-
state.phase.publish = { status: "pending", error: null };
|
|
115
|
-
state.phase.current = "render";
|
|
116
|
-
state.mode = "active";
|
|
117
|
-
state.redo_hint = null;
|
|
118
|
-
// Reset image plan status
|
|
119
|
-
if (state.images.plan.needed) {
|
|
120
|
-
state.images.plan.status = "planned";
|
|
121
|
-
}
|
|
114
|
+
reenterRender(state);
|
|
122
115
|
break;
|
|
123
116
|
|
|
124
117
|
case "publish":
|
|
125
|
-
|
|
126
|
-
state.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
118
|
+
reenterPublish(state);
|
|
119
|
+
state.publish.results = state.publish.results.filter(
|
|
120
|
+
(result) =>
|
|
121
|
+
result.content_version !== state.artifacts.content_version ||
|
|
122
|
+
result.render_version !== state.artifacts.render_version,
|
|
123
|
+
);
|
|
131
124
|
break;
|
|
132
125
|
|
|
133
126
|
case "full":
|
package/src/commands/review.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { parseArgs, requireArg, optionalArg } from "../args";
|
|
19
19
|
import { printResult, renderReview } from "../output";
|
|
20
|
-
import {
|
|
20
|
+
import { readResolvedState, writeState, type ContentReviewStatus } from "../state";
|
|
21
21
|
|
|
22
22
|
const VALID_STATUSES: ContentReviewStatus[] = [
|
|
23
23
|
"passed",
|
|
@@ -43,7 +43,7 @@ Statuses:
|
|
|
43
43
|
return;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
const
|
|
46
|
+
const requestedStatePath = requireArg(parsed, "state", "state JSON path");
|
|
47
47
|
const status = requireArg(parsed, "status", "review status") as ContentReviewStatus;
|
|
48
48
|
const feedback = optionalArg(parsed, "feedback") ?? null;
|
|
49
49
|
|
|
@@ -59,7 +59,9 @@ Statuses:
|
|
|
59
59
|
);
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
const
|
|
62
|
+
const resolved = await readResolvedState(requestedStatePath);
|
|
63
|
+
const statePath = resolved.path;
|
|
64
|
+
const state = resolved.state;
|
|
63
65
|
|
|
64
66
|
state.content_review = {
|
|
65
67
|
status,
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { parseArgs, requireArg, flagArg } from "../args";
|
|
2
2
|
import { printResult, renderSyncBlog } from "../output";
|
|
3
3
|
import { loadConfig, resolveWorkspacePaths } from "../config";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
acquireStateOperationLock,
|
|
6
|
+
readResolvedState,
|
|
7
|
+
updateState,
|
|
8
|
+
} from "../state";
|
|
5
9
|
import { publishBlogRoute } from "../providers/blog";
|
|
6
10
|
|
|
7
11
|
export async function syncBlog(args: string[]): Promise<void> {
|
|
@@ -18,13 +22,18 @@ Options:
|
|
|
18
22
|
return;
|
|
19
23
|
}
|
|
20
24
|
|
|
21
|
-
const
|
|
25
|
+
const requestedStatePath = requireArg(parsed, "state", "state JSON path");
|
|
22
26
|
const dryRun = flagArg(parsed, "dry-run");
|
|
23
27
|
|
|
24
28
|
const config = loadConfig();
|
|
25
29
|
|
|
26
30
|
// Run the actual publish (may have network side effects)
|
|
27
|
-
const
|
|
31
|
+
const initialResolved = await readResolvedState(requestedStatePath);
|
|
32
|
+
const releaseOperationLock = await acquireStateOperationLock(initialResolved.path);
|
|
33
|
+
try {
|
|
34
|
+
const resolved = await readResolvedState(initialResolved.path);
|
|
35
|
+
const statePath = resolved.path;
|
|
36
|
+
const state = resolved.state;
|
|
28
37
|
|
|
29
38
|
if (!state.asset_path) {
|
|
30
39
|
throw new Error("asset_path not set. Run prepare-finalize first.");
|
|
@@ -38,6 +47,11 @@ Options:
|
|
|
38
47
|
workspacePaths,
|
|
39
48
|
});
|
|
40
49
|
|
|
50
|
+
if (dryRun) {
|
|
51
|
+
printResult({ ...result, mode: state.mode }, renderSyncBlog);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
41
55
|
// Write result back to state
|
|
42
56
|
const finalState = await updateState(statePath, (s) => {
|
|
43
57
|
const idx = s.publish.results.findIndex((r) => r.route === "blog");
|
|
@@ -52,4 +66,7 @@ Options:
|
|
|
52
66
|
...result,
|
|
53
67
|
mode: finalState.mode,
|
|
54
68
|
}, renderSyncBlog);
|
|
69
|
+
} finally {
|
|
70
|
+
await releaseOperationLock();
|
|
71
|
+
}
|
|
55
72
|
}
|
package/src/config.ts
CHANGED
|
Binary file
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { join } from "path";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
extractLocalImagePaths,
|
|
5
|
+
replaceLocalImagePaths,
|
|
6
|
+
resolveBlogPostPath,
|
|
7
|
+
} from "./blog";
|
|
4
8
|
|
|
5
9
|
describe("extractLocalImagePaths", () => {
|
|
6
10
|
test("extracts local image paths from markdown", () => {
|
|
@@ -17,6 +21,22 @@ describe("extractLocalImagePaths", () => {
|
|
|
17
21
|
expect(pathMap.has("./local.png")).toBe(true);
|
|
18
22
|
});
|
|
19
23
|
|
|
24
|
+
test("skips non-file URI schemes", () => {
|
|
25
|
+
const content = "\n";
|
|
26
|
+
expect(extractLocalImagePaths(content, "/workspace").size).toBe(0);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("rejects paths that escape the canonical asset directory", () => {
|
|
30
|
+
expect(() => extractLocalImagePaths("", "/workspace/post"))
|
|
31
|
+
.toThrow("escapes asset directory");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("handles angle-bracket paths and markdown titles", () => {
|
|
35
|
+
const content = '';
|
|
36
|
+
const pathMap = extractLocalImagePaths(content, "/workspace");
|
|
37
|
+
expect(pathMap.get("images/my cover.png")).toBe("/workspace/images/my cover.png");
|
|
38
|
+
});
|
|
39
|
+
|
|
20
40
|
test("returns empty map for no images", () => {
|
|
21
41
|
expect(extractLocalImagePaths("just text", "/workspace").size).toBe(0);
|
|
22
42
|
});
|
|
@@ -56,3 +76,19 @@ describe("replaceLocalImagePaths", () => {
|
|
|
56
76
|
expect(result).toBe("");
|
|
57
77
|
});
|
|
58
78
|
});
|
|
79
|
+
|
|
80
|
+
describe("resolveBlogPostPath", () => {
|
|
81
|
+
test("builds the canonical dated blog path", () => {
|
|
82
|
+
expect(resolveBlogPostPath("/blog", "2026-07-12", "safe-slug")).toEqual({
|
|
83
|
+
directory: join("/blog", "content", "nezus", "2026", "07"),
|
|
84
|
+
path: join("/blog", "content", "nezus", "2026", "07", "safe-slug.md"),
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("rejects traversal in date or slug fields", () => {
|
|
89
|
+
expect(() => resolveBlogPostPath("/blog", "../../tmp", "safe"))
|
|
90
|
+
.toThrow("Invalid blog publish date");
|
|
91
|
+
expect(() => resolveBlogPostPath("/blog", "2026-07-12", "../../secret"))
|
|
92
|
+
.toThrow("Invalid blog publish slug");
|
|
93
|
+
});
|
|
94
|
+
});
|
package/src/providers/blog.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
2
|
-
import { join, resolve } from "path";
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve } from "path";
|
|
3
3
|
import { PipelineConfig, ResolvedWorkspacePaths } from "../config";
|
|
4
4
|
import { spawnSync } from "../spawn";
|
|
5
5
|
import { PublishResult, WorkflowState } from "../state";
|
|
@@ -13,25 +13,63 @@ interface BlogPublishContext {
|
|
|
13
13
|
workspacePaths: ResolvedWorkspacePaths;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
const LOCAL_IMAGE_RE = /!\[([^\]]*)\]\((?!https?:\/\/)([^)]+)\)/g;
|
|
16
|
+
const LOCAL_IMAGE_RE = /!\[([^\]]*)\]\((?![a-zA-Z][a-zA-Z0-9+.-]*:|\/\/)(?:<([^>]+)>|([^\s)]+))(?:\s+"[^"]*")?\)/g;
|
|
18
17
|
|
|
19
18
|
export function extractLocalImagePaths(content: string, assetPath: string): Map<string, string> {
|
|
20
19
|
const pathMap = new Map<string, string>();
|
|
21
20
|
for (const match of content.matchAll(LOCAL_IMAGE_RE)) {
|
|
22
|
-
const rawPath = match[2].trim();
|
|
23
|
-
|
|
21
|
+
const rawPath = (match[2] ?? match[3] ?? "").trim();
|
|
22
|
+
const assetRoot = resolve(assetPath);
|
|
23
|
+
const absolutePath = resolve(assetRoot, rawPath);
|
|
24
|
+
const relativePath = relative(assetRoot, absolutePath);
|
|
25
|
+
if (
|
|
26
|
+
relativePath === ".." ||
|
|
27
|
+
relativePath.startsWith(`..${/\\/.test(relativePath) ? "\\" : "/"}`) ||
|
|
28
|
+
isAbsolute(relativePath)
|
|
29
|
+
) {
|
|
30
|
+
throw new Error(`Local blog image escapes asset directory: ${rawPath}`);
|
|
31
|
+
}
|
|
32
|
+
pathMap.set(rawPath, absolutePath);
|
|
24
33
|
}
|
|
25
34
|
return pathMap;
|
|
26
35
|
}
|
|
27
36
|
|
|
28
37
|
export function replaceLocalImagePaths(content: string, urlMap: Map<string, string>): string {
|
|
29
|
-
return content.replace(LOCAL_IMAGE_RE, (original, alt: string,
|
|
30
|
-
const
|
|
38
|
+
return content.replace(LOCAL_IMAGE_RE, (original, alt: string, anglePath: string, barePath: string) => {
|
|
39
|
+
const rawPath = (anglePath ?? barePath ?? "").trim();
|
|
40
|
+
const url = urlMap.get(rawPath);
|
|
31
41
|
return url ? `` : original;
|
|
32
42
|
});
|
|
33
43
|
}
|
|
34
44
|
|
|
45
|
+
export function resolveBlogPostPath(
|
|
46
|
+
blogRoot: string,
|
|
47
|
+
date: string,
|
|
48
|
+
slug: string,
|
|
49
|
+
): { directory: string; path: string } {
|
|
50
|
+
const dateMatch = date.match(/^(\d{4})-(\d{2})-\d{2}$/);
|
|
51
|
+
if (!dateMatch) {
|
|
52
|
+
throw new Error(`Invalid blog publish date: ${date}`);
|
|
53
|
+
}
|
|
54
|
+
if (!slug || /[\\/]/.test(slug) || slug === "." || slug === "..") {
|
|
55
|
+
throw new Error(`Invalid blog publish slug: ${slug}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const contentRoot = resolve(blogRoot, "content", "nezus");
|
|
59
|
+
const directory = resolve(contentRoot, dateMatch[1], dateMatch[2]);
|
|
60
|
+
const path = resolve(directory, `${slug}.md`);
|
|
61
|
+
const relativePath = relative(contentRoot, path);
|
|
62
|
+
if (
|
|
63
|
+
dirname(path) !== directory ||
|
|
64
|
+
relativePath === ".." ||
|
|
65
|
+
relativePath.startsWith(`..${/\\/.test(relativePath) ? "\\" : "/"}`) ||
|
|
66
|
+
isAbsolute(relativePath)
|
|
67
|
+
) {
|
|
68
|
+
throw new Error(`Blog post path escapes content directory: ${date}/${slug}`);
|
|
69
|
+
}
|
|
70
|
+
return { directory, path };
|
|
71
|
+
}
|
|
72
|
+
|
|
35
73
|
async function uploadBlogImages(
|
|
36
74
|
content: string,
|
|
37
75
|
assetPath: string,
|
|
@@ -45,7 +83,7 @@ async function uploadBlogImages(
|
|
|
45
83
|
}
|
|
46
84
|
|
|
47
85
|
const cosPat = config.cos.pat;
|
|
48
|
-
if (!cosPat) {
|
|
86
|
+
if (!cosPat && !dryRun) {
|
|
49
87
|
throw new Error("No COS PAT configured. Set cos.pat in config.");
|
|
50
88
|
}
|
|
51
89
|
|
|
@@ -84,12 +122,12 @@ export async function publishBlogRoute({
|
|
|
84
122
|
}: BlogPublishContext): Promise<PublishResult> {
|
|
85
123
|
const postPath = join(state.asset_path, "post.md");
|
|
86
124
|
const postContent = await readFile(postPath, "utf-8");
|
|
87
|
-
const category = "nezus";
|
|
88
125
|
const blogRoot = workspacePaths.blogRoot;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
126
|
+
const { directory: blogPostDir, path: blogPostPath } = resolveBlogPostPath(
|
|
127
|
+
blogRoot,
|
|
128
|
+
state.metadata.date,
|
|
129
|
+
state.metadata.slug,
|
|
130
|
+
);
|
|
93
131
|
|
|
94
132
|
const lines = ["---"];
|
|
95
133
|
lines.push(`title: "${state.metadata.title.replace(/"/g, '\\"')}"`);
|
package/src/providers/cos.ts
CHANGED
|
@@ -90,28 +90,32 @@ async function requestSts(
|
|
|
90
90
|
)) as StsApiResponse;
|
|
91
91
|
|
|
92
92
|
const d = response?.data;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
d
|
|
101
|
-
d
|
|
102
|
-
)
|
|
103
|
-
|
|
93
|
+
const missing = [
|
|
94
|
+
["TmpSecretId", d?.TmpSecretId],
|
|
95
|
+
["TmpSecretKey", d?.TmpSecretKey],
|
|
96
|
+
["SessionToken", d?.SessionToken],
|
|
97
|
+
["Bucket", d?.Bucket],
|
|
98
|
+
["Region", d?.Region],
|
|
99
|
+
["Key", d?.Key],
|
|
100
|
+
["StartTime", d?.StartTime],
|
|
101
|
+
["ExpiredTime", d?.ExpiredTime],
|
|
102
|
+
].filter(([, value]) => value === undefined || value === null || value === "");
|
|
103
|
+
if (missing.length > 0) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`COS STS response incomplete; missing: ${missing.map(([field]) => field).join(", ")}`,
|
|
106
|
+
);
|
|
104
107
|
}
|
|
108
|
+
const data = d as Required<NonNullable<StsApiResponse["data"]>>;
|
|
105
109
|
|
|
106
110
|
return {
|
|
107
|
-
TmpSecretId:
|
|
108
|
-
TmpSecretKey:
|
|
109
|
-
SessionToken:
|
|
110
|
-
StartTime:
|
|
111
|
-
ExpiredTime:
|
|
112
|
-
Bucket:
|
|
113
|
-
Region:
|
|
114
|
-
Key:
|
|
111
|
+
TmpSecretId: data.TmpSecretId,
|
|
112
|
+
TmpSecretKey: data.TmpSecretKey,
|
|
113
|
+
SessionToken: data.SessionToken,
|
|
114
|
+
StartTime: data.StartTime,
|
|
115
|
+
ExpiredTime: data.ExpiredTime,
|
|
116
|
+
Bucket: data.Bucket,
|
|
117
|
+
Region: data.Region,
|
|
118
|
+
Key: data.Key,
|
|
115
119
|
};
|
|
116
120
|
}
|
|
117
121
|
|
package/src/providers/index.ts
CHANGED
|
@@ -47,7 +47,9 @@ async function publishWechatArticleRoute({
|
|
|
47
47
|
state.images.body_inputs.received,
|
|
48
48
|
);
|
|
49
49
|
exportPostPath = join(state.asset_path, "post-wechat-draft.md");
|
|
50
|
-
|
|
50
|
+
if (!dryRun) {
|
|
51
|
+
await writeFile(exportPostPath, draftContent, "utf-8");
|
|
52
|
+
}
|
|
51
53
|
|
|
52
54
|
for (const img of state.images.body_inputs.received) {
|
|
53
55
|
photos.push(img.path);
|
|
@@ -81,7 +83,7 @@ async function publishWechatArticleRoute({
|
|
|
81
83
|
customCss: wxAccount?.customCss ?? null,
|
|
82
84
|
themeOverrides: wxAccount?.theme,
|
|
83
85
|
});
|
|
84
|
-
await createWechatDraft({
|
|
86
|
+
const response = await createWechatDraft({
|
|
85
87
|
account,
|
|
86
88
|
title: state.metadata.title,
|
|
87
89
|
html,
|
|
@@ -92,6 +94,16 @@ async function publishWechatArticleRoute({
|
|
|
92
94
|
nezusBaseUrl: process.env.ZZHUB_WX_BASE_URL || config.wx.baseUrl,
|
|
93
95
|
nezusPat: process.env.ZZCLUB_PAT || config.wx.accounts[account]?.pat || config.wx.accounts[config.wx.defaultAccount]?.pat,
|
|
94
96
|
});
|
|
97
|
+
return {
|
|
98
|
+
route: "wechat-article",
|
|
99
|
+
account,
|
|
100
|
+
status: "success",
|
|
101
|
+
detail: null,
|
|
102
|
+
external_id: typeof response.draftMediaId === "string" ? response.draftMediaId : null,
|
|
103
|
+
published_at: new Date().toISOString(),
|
|
104
|
+
content_version: state.artifacts.content_version,
|
|
105
|
+
render_version: state.artifacts.render_version,
|
|
106
|
+
};
|
|
95
107
|
} catch (error) {
|
|
96
108
|
return {
|
|
97
109
|
route: "wechat-article",
|
|
@@ -104,15 +116,6 @@ async function publishWechatArticleRoute({
|
|
|
104
116
|
};
|
|
105
117
|
}
|
|
106
118
|
|
|
107
|
-
return {
|
|
108
|
-
route: "wechat-article",
|
|
109
|
-
account,
|
|
110
|
-
status: "success",
|
|
111
|
-
detail: null,
|
|
112
|
-
published_at: new Date().toISOString(),
|
|
113
|
-
content_version: state.artifacts.content_version,
|
|
114
|
-
render_version: state.artifacts.render_version,
|
|
115
|
-
};
|
|
116
119
|
}
|
|
117
120
|
|
|
118
121
|
async function publishWechatNewspicRoute({
|
|
@@ -126,7 +129,9 @@ async function publishWechatNewspicRoute({
|
|
|
126
129
|
const bodyImageUrls = extractImageUrls(postContent);
|
|
127
130
|
const cleanContent = prepareBodyForNewspic(postContent);
|
|
128
131
|
const cleanPath = join(state.asset_path, "post-clean.md");
|
|
129
|
-
|
|
132
|
+
if (!dryRun) {
|
|
133
|
+
await writeFile(cleanPath, cleanContent, "utf-8");
|
|
134
|
+
}
|
|
130
135
|
|
|
131
136
|
const assets = (state.images.render_assets as RenderAsset[])
|
|
132
137
|
.filter((asset) => asset.route === "wechat-newspic")
|
|
@@ -156,7 +161,7 @@ async function publishWechatNewspicRoute({
|
|
|
156
161
|
}
|
|
157
162
|
|
|
158
163
|
try {
|
|
159
|
-
await createWechatNewspic({
|
|
164
|
+
const response = await createWechatNewspic({
|
|
160
165
|
account,
|
|
161
166
|
title: state.metadata.title,
|
|
162
167
|
content: cleanContent,
|
|
@@ -167,6 +172,16 @@ async function publishWechatNewspicRoute({
|
|
|
167
172
|
nezusBaseUrl: process.env.ZZHUB_WX_BASE_URL || config.wx.baseUrl,
|
|
168
173
|
nezusPat: process.env.ZZCLUB_PAT || config.wx.accounts[account]?.pat || config.wx.accounts[config.wx.defaultAccount]?.pat,
|
|
169
174
|
});
|
|
175
|
+
return {
|
|
176
|
+
route: "wechat-newspic",
|
|
177
|
+
account,
|
|
178
|
+
status: "success",
|
|
179
|
+
detail: null,
|
|
180
|
+
external_id: typeof response.draftMediaId === "string" ? response.draftMediaId : null,
|
|
181
|
+
published_at: new Date().toISOString(),
|
|
182
|
+
content_version: state.artifacts.content_version,
|
|
183
|
+
render_version: state.artifacts.render_version,
|
|
184
|
+
};
|
|
170
185
|
} catch (error) {
|
|
171
186
|
return {
|
|
172
187
|
route: "wechat-newspic",
|
|
@@ -179,15 +194,6 @@ async function publishWechatNewspicRoute({
|
|
|
179
194
|
};
|
|
180
195
|
}
|
|
181
196
|
|
|
182
|
-
return {
|
|
183
|
-
route: "wechat-newspic",
|
|
184
|
-
account,
|
|
185
|
-
status: "success",
|
|
186
|
-
detail: null,
|
|
187
|
-
published_at: new Date().toISOString(),
|
|
188
|
-
content_version: state.artifacts.content_version,
|
|
189
|
-
render_version: state.artifacts.render_version,
|
|
190
|
-
};
|
|
191
197
|
}
|
|
192
198
|
|
|
193
199
|
const PUBLISH_PROVIDERS: Record<RoutePrimary, PublishProvider> = {
|
|
@@ -20,6 +20,7 @@ export interface ExecutePublishTargetsParams {
|
|
|
20
20
|
dryRun: boolean;
|
|
21
21
|
config: PipelineConfig;
|
|
22
22
|
workspacePaths: ResolvedWorkspacePaths;
|
|
23
|
+
onResult?: (result: PublishResult) => Promise<void>;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
export interface ExecutePublishTargetsResult {
|
|
@@ -66,13 +67,28 @@ export function filterIdempotent(
|
|
|
66
67
|
});
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
export function upsertPublishResult(
|
|
71
|
+
state: WorkflowState,
|
|
72
|
+
result: PublishResult,
|
|
73
|
+
): void {
|
|
74
|
+
const index = state.publish.results.findIndex(
|
|
75
|
+
(item) => item.route === result.route && item.account === result.account,
|
|
76
|
+
);
|
|
77
|
+
if (index >= 0) {
|
|
78
|
+
state.publish.results[index] = result;
|
|
79
|
+
} else {
|
|
80
|
+
state.publish.results.push(result);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
69
84
|
/**
|
|
70
|
-
* Execute publish
|
|
85
|
+
* Execute publish targets sequentially so callers can persist each outcome
|
|
86
|
+
* before the next external side effect begins.
|
|
71
87
|
*/
|
|
72
88
|
export async function executePublishTargets(
|
|
73
89
|
params: ExecutePublishTargetsParams,
|
|
74
90
|
): Promise<ExecutePublishTargetsResult> {
|
|
75
|
-
const { state, targets, dryRun, config, workspacePaths } = params;
|
|
91
|
+
const { state, targets, dryRun, config, workspacePaths, onResult } = params;
|
|
76
92
|
|
|
77
93
|
const deduped = dedupeTargets(targets);
|
|
78
94
|
const filtered = filterIdempotent(
|
|
@@ -85,39 +101,44 @@ export async function executePublishTargets(
|
|
|
85
101
|
const results: PublishResult[] = [];
|
|
86
102
|
const errors: PublishTargetError[] = [];
|
|
87
103
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const provider = getPublishProvider(target.route);
|
|
91
|
-
const ctx: PublishRouteContext = {
|
|
92
|
-
state,
|
|
93
|
-
dryRun,
|
|
94
|
-
config,
|
|
95
|
-
workspacePaths,
|
|
96
|
-
accountOverride: target.account,
|
|
97
|
-
};
|
|
98
|
-
|
|
104
|
+
for (const target of filtered) {
|
|
105
|
+
let result: PublishResult;
|
|
99
106
|
try {
|
|
100
|
-
const
|
|
101
|
-
|
|
107
|
+
const provider = getPublishProvider(target.route);
|
|
108
|
+
const ctx: PublishRouteContext = {
|
|
109
|
+
state,
|
|
110
|
+
dryRun,
|
|
111
|
+
config,
|
|
112
|
+
workspacePaths,
|
|
113
|
+
accountOverride: target.account,
|
|
114
|
+
};
|
|
115
|
+
const providerResult = await provider(ctx);
|
|
116
|
+
result = {
|
|
117
|
+
...providerResult,
|
|
118
|
+
route: target.route,
|
|
119
|
+
account: target.account,
|
|
120
|
+
content_version: state.artifacts.content_version,
|
|
121
|
+
render_version: state.artifacts.render_version,
|
|
122
|
+
};
|
|
102
123
|
} catch (err) {
|
|
103
124
|
const error: PublishTargetError = {
|
|
104
125
|
route: target.route,
|
|
105
126
|
account: target.account,
|
|
106
127
|
error: err instanceof Error ? err.message : String(err),
|
|
107
128
|
};
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
if (error) {
|
|
129
|
+
result = {
|
|
130
|
+
route: target.route,
|
|
131
|
+
account: target.account,
|
|
132
|
+
status: "failed",
|
|
133
|
+
detail: error.error,
|
|
134
|
+
published_at: null,
|
|
135
|
+
content_version: state.artifacts.content_version,
|
|
136
|
+
render_version: state.artifacts.render_version,
|
|
137
|
+
};
|
|
119
138
|
errors.push(error);
|
|
120
139
|
}
|
|
140
|
+
results.push(result);
|
|
141
|
+
await onResult?.(result);
|
|
121
142
|
}
|
|
122
143
|
|
|
123
144
|
return { results, errors };
|
|
@@ -8,8 +8,63 @@ import {
|
|
|
8
8
|
replaceImageUrls,
|
|
9
9
|
isRetryableTokenFetchError,
|
|
10
10
|
resolveFilenameFromUrl,
|
|
11
|
+
resolveDraftMediaId,
|
|
12
|
+
readResponseBodyWithLimit,
|
|
11
13
|
} from "./wechat";
|
|
12
14
|
|
|
15
|
+
describe("resolveDraftMediaId", () => {
|
|
16
|
+
test("extracts media_id from a successful draft response", () => {
|
|
17
|
+
expect(resolveDraftMediaId({ code: 0, data: { media_id: "media-1" } }))
|
|
18
|
+
.toBe("media-1");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("rejects business errors returned with HTTP 200", () => {
|
|
22
|
+
expect(() => resolveDraftMediaId({ errcode: 40001, errmsg: "invalid credential" }))
|
|
23
|
+
.toThrow("WeChat draft API error: 40001");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("rejects string business error codes for draft updates", () => {
|
|
27
|
+
expect(() => resolveDraftMediaId(
|
|
28
|
+
{ errcode: "40001", errmsg: "invalid credential" },
|
|
29
|
+
"existing-media",
|
|
30
|
+
)).toThrow("WeChat draft API error: 40001");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("requires media_id for a newly created draft", () => {
|
|
34
|
+
expect(() => resolveDraftMediaId({ code: 0, data: {} }))
|
|
35
|
+
.toThrow("did not include media_id");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("uses the existing media id for a successful update", () => {
|
|
39
|
+
expect(resolveDraftMediaId({ errcode: 0 }, "existing-media"))
|
|
40
|
+
.toBe("existing-media");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("readResponseBodyWithLimit", () => {
|
|
45
|
+
test("accepts a streamed response at the byte limit", async () => {
|
|
46
|
+
const response = new Response(new Uint8Array([1, 2, 3]));
|
|
47
|
+
const body = await readResponseBodyWithLimit(response, 3);
|
|
48
|
+
expect(new Uint8Array(body)).toEqual(new Uint8Array([1, 2, 3]));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("rejects a streamed response as soon as it exceeds the byte limit", async () => {
|
|
52
|
+
const response = new Response(new Uint8Array([1, 2, 3, 4]));
|
|
53
|
+
await expect(readResponseBodyWithLimit(response, 3)).rejects.toThrow(
|
|
54
|
+
"Image exceeds 3 byte limit",
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("rejects an oversized content-length before reading the body", async () => {
|
|
59
|
+
const response = new Response(new Uint8Array([1]), {
|
|
60
|
+
headers: { "content-length": "4" },
|
|
61
|
+
});
|
|
62
|
+
await expect(readResponseBodyWithLimit(response, 3)).rejects.toThrow(
|
|
63
|
+
"Image exceeds 3 byte limit",
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
13
68
|
describe("normalizeAccountName", () => {
|
|
14
69
|
test("accepts valid account names", () => {
|
|
15
70
|
expect(normalizeAccountName("default")).toBe("default");
|