@akairos/pipeline 0.21.1 → 0.23.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/article-snapshot.ts +2 -0
- package/package.json +5 -3
- package/src/article-snapshot.test.ts +39 -0
- package/src/article-snapshot.ts +121 -0
- package/src/commands/article-export.ts +17 -0
- package/src/commands/prepare-finalize.ts +8 -2
- package/src/logger.test.ts +15 -0
- package/src/logger.ts +21 -5
- package/src/monitor/recorder.ts +22 -3
- package/src/monitor.test.ts +17 -1
- package/src/plugins.ts +2 -0
- package/src/providers/article-export.ts +68 -0
- package/src/providers/index.ts +11 -61
- package/src/providers/wechat.ts +3 -1
- package/src/wechat-preview/index.ts +3 -0
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { createArticleSnapshot, validateArticleSnapshot, createArtifactManifest, hashArtifactManifest, validateArtifactManifest, rewriteArticleImages } from "./src/article-snapshot";
|
|
2
|
+
export type { ArticleSnapshot, ArticleSnapshotInput, ArticleSnapshotContent, BodyAssetIdentity, ArtifactManifest } from "./src/article-snapshot";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akairos/pipeline",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"src/",
|
|
15
15
|
"article-style.ts",
|
|
16
16
|
"cover-snapshot.ts",
|
|
17
|
+
"article-snapshot.ts",
|
|
17
18
|
"ARTICLE-THEMES.md",
|
|
18
19
|
"THIRD-PARTY-NOTICES.md"
|
|
19
20
|
],
|
|
@@ -30,11 +31,12 @@
|
|
|
30
31
|
},
|
|
31
32
|
"dependencies": {
|
|
32
33
|
"@akairos/milkdown-article-plugins": "^0.1.6",
|
|
33
|
-
"@akairos/myno": "0.
|
|
34
|
-
"@akairos/typeset-studio": "0.
|
|
34
|
+
"@akairos/myno": "0.4.1",
|
|
35
|
+
"@akairos/typeset-studio": "0.4.0",
|
|
35
36
|
"@chenglou/pretext": "0.0.8",
|
|
36
37
|
"image-size": "^2.0.2",
|
|
37
38
|
"pngjs": "^7.0.0",
|
|
39
|
+
"remark": "^15.0.1",
|
|
38
40
|
"zod": "^4.4.2"
|
|
39
41
|
},
|
|
40
42
|
"optionalDependencies": {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { createArticleSnapshot, validateArticleSnapshot, rewriteArticleImages, createArtifactManifest, hashArtifactManifest, validateArtifactManifest } from "../article-snapshot";
|
|
3
|
+
|
|
4
|
+
const asset = { id: "31161ca7-af98-4a84-adc7-c60885a3bd0f", sha256: "a".repeat(64), contentType: "image/png" as const, byteLength: 100, width: 10, height: 10 };
|
|
5
|
+
const input = { documentId: "521b9c37-1d74-4f23-a0ad-1801ad2f3669", documentRevision: 1, title: "预览", account: "fixed-account", body: ``, bodyAssets: [asset] };
|
|
6
|
+
|
|
7
|
+
test("完整文章输入 hash 绑定修订、正文/账号及素材,拒绝未托管/过量图片", () => {
|
|
8
|
+
const snapshot = createArticleSnapshot(input);
|
|
9
|
+
expect(validateArticleSnapshot(snapshot, input)).toEqual(snapshot);
|
|
10
|
+
expect(() => validateArticleSnapshot(snapshot, { ...input, title: "变化" })).toThrow("input hash");
|
|
11
|
+
expect(createArticleSnapshot({ ...input, documentRevision: 2 }).inputHash).not.toBe(snapshot.inputHash);
|
|
12
|
+
for (const body of ["", "<img src='asset:bad'>", Array(21).fill(input.body).join("\n")]) expect(() => createArticleSnapshot({ ...input, body })).toThrow();
|
|
13
|
+
expect(() => createArticleSnapshot({ ...input, bodyAssets: [asset, asset] })).toThrow("Duplicate");
|
|
14
|
+
expect(() => validateArticleSnapshot({ ...snapshot, schemaVersion: 2 }, input)).toThrow();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("图片引用转换保定义图注、描述与代码/普通链接/共享definition", () => {
|
|
18
|
+
const source = '[普通][shared]\n\n![描述][shared]\n\n[shared]: /api/assets/body/image "定义图注"\n\n```md\n\n```';
|
|
19
|
+
const rewritten = rewriteArticleImages(source, () => `asset:${asset.id}`);
|
|
20
|
+
expect(rewritten).toContain(``);
|
|
21
|
+
expect(rewritten).toContain('[shared]: /api/assets/body/image "定义图注"');
|
|
22
|
+
expect(rewritten).toContain('[普通][shared]');
|
|
23
|
+
expect(rewritten).toContain('');
|
|
24
|
+
const brackets = rewriteArticleImages('![左\\[右\\]尾][shared]\n\n[shared]: /image "图注"', () => `asset:${asset.id}`);
|
|
25
|
+
expect(brackets).toContain(`![左\\[右\\]尾](asset:${asset.id} "图注")`);
|
|
26
|
+
expect(createArticleSnapshot({ ...input, body: brackets }).inputHash).toBeTruthy();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("产物 manifest 严格固定key/type和双hash身份", () => {
|
|
30
|
+
const manifest = createArtifactManifest({ snapshotHash: "b".repeat(64), runtime: { pipeline: "0.22.0", myno: "0.4.1", typeset: "0.4.0" }, artifacts: [
|
|
31
|
+
{ key: "cover", kind: "cover", contentType: "image/png", byteLength: 1, sha256: "c".repeat(64) },
|
|
32
|
+
{ key: "article", kind: "html", contentType: "text/html", byteLength: 2, sha256: "d".repeat(64) },
|
|
33
|
+
{ key: `media_${asset.id}`, kind: "media", contentType: "image/png", byteLength: 100, sha256: asset.sha256 },
|
|
34
|
+
] });
|
|
35
|
+
expect(validateArtifactManifest(manifest, hashArtifactManifest(manifest))).toEqual(manifest);
|
|
36
|
+
expect(() => validateArtifactManifest(manifest, "0".repeat(64))).toThrow();
|
|
37
|
+
expect(() => validateArtifactManifest({ ...manifest, path: "/private/html" })).toThrow();
|
|
38
|
+
expect(() => createArtifactManifest({ ...manifest, artifacts: [...manifest.artifacts, manifest.artifacts[0]!] })).toThrow();
|
|
39
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { remark } from "remark";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { validateArticleStyleSnapshot, type ArticleStyleSnapshot } from "./article-style";
|
|
5
|
+
import { validateCoverSnapshot, type CoverSnapshot } from "./cover-snapshot";
|
|
6
|
+
|
|
7
|
+
const hash = z.string().regex(/^[a-f\d]{64}$/);
|
|
8
|
+
const uuid = z.string().uuid().transform(value => value.toLowerCase());
|
|
9
|
+
export const BodyAssetIdentitySchema = z.object({
|
|
10
|
+
id: uuid, sha256: hash, contentType: z.enum(["image/png", "image/jpeg", "image/webp"]),
|
|
11
|
+
byteLength: z.number().int().positive().max(5 * 1024 * 1024),
|
|
12
|
+
width: z.number().int().positive(), height: z.number().int().positive(),
|
|
13
|
+
}).strict().refine(value => value.width * value.height <= 32_000_000, "Body image exceeds 32 megapixels");
|
|
14
|
+
export type BodyAssetIdentity = z.infer<typeof BodyAssetIdentitySchema>;
|
|
15
|
+
|
|
16
|
+
export interface ArticleSnapshotContent {
|
|
17
|
+
title: string; body: string; account: string;
|
|
18
|
+
articleStyle?: ArticleStyleSnapshot; coverSnapshot?: CoverSnapshot;
|
|
19
|
+
}
|
|
20
|
+
export interface ArticleSnapshotInput extends ArticleSnapshotContent {
|
|
21
|
+
documentId: string; documentRevision: number; bodyAssets: BodyAssetIdentity[];
|
|
22
|
+
}
|
|
23
|
+
const ArticleSnapshotSchema = z.object({
|
|
24
|
+
schemaVersion: z.literal(1), documentId: uuid, documentRevision: z.number().int().positive(),
|
|
25
|
+
bodyAssets: z.array(BodyAssetIdentitySchema).max(20), inputHash: hash,
|
|
26
|
+
}).strict();
|
|
27
|
+
export type ArticleSnapshot = z.infer<typeof ArticleSnapshotSchema>;
|
|
28
|
+
|
|
29
|
+
interface ImageNode { type: string; url?: string; identifier?: string; alt?: string; title?: string | null; value?: string; position?: { start: { offset?: number }; end: { offset?: number } }; children?: ImageNode[] }
|
|
30
|
+
|
|
31
|
+
/** 只遍历 Markdown 图片节点,代码和普通链接不参与资产绑定。 */
|
|
32
|
+
export function rewriteArticleImages(body: string, resolveImage: (source: string) => string): string {
|
|
33
|
+
const tree = remark().parse(body) as ImageNode;
|
|
34
|
+
const definitions = new Map<string, ImageNode>();
|
|
35
|
+
const collect = (node: ImageNode) => { if (node.type === "definition") definitions.set(node.identifier!, node); node.children?.forEach(collect); };
|
|
36
|
+
collect(tree);
|
|
37
|
+
const edits: Array<{ start: number; end: number; text: string }> = [];
|
|
38
|
+
let count = 0;
|
|
39
|
+
const visit = (node: ImageNode) => {
|
|
40
|
+
if (node.type === "html" && /<img\b/i.test(node.value || "")) throw new Error("Raw HTML images must be converted to managed Markdown images");
|
|
41
|
+
if (node.type === "image" || node.type === "imageReference") {
|
|
42
|
+
if (++count > 20) throw new Error("Article supports at most 20 visible images");
|
|
43
|
+
const definition = definitions.get(node.identifier!);
|
|
44
|
+
const source = node.url ?? definition?.url;
|
|
45
|
+
if (!source) throw new Error("Unresolved image reference");
|
|
46
|
+
const mapped = resolveImage(source);
|
|
47
|
+
if (mapped !== source) {
|
|
48
|
+
const start = node.position?.start.offset, end = node.position?.end.offset;
|
|
49
|
+
if (start === undefined || end === undefined) throw new Error("Image source position is unavailable");
|
|
50
|
+
const escape = (text: string) => text.replace(/\\/g, "\\\\").replace(/\[/g, "\\[").replace(/\]/g, "\\]");
|
|
51
|
+
const originalTitle = node.title ?? definition?.title;
|
|
52
|
+
const title = originalTitle ? ` "${originalTitle.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : "";
|
|
53
|
+
edits.push({ start, end, text: `` });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
node.children?.forEach(visit);
|
|
57
|
+
};
|
|
58
|
+
visit(tree);
|
|
59
|
+
for (const edit of edits.sort((a, b) => b.start - a.start)) body = body.slice(0, edit.start) + edit.text + body.slice(edit.end);
|
|
60
|
+
return body;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function canonicalHash(value: unknown): string {
|
|
64
|
+
const normalize = (item: any): any => Array.isArray(item) ? item.map(normalize) : item && typeof item === "object"
|
|
65
|
+
? Object.fromEntries(Object.keys(item).sort().filter(key => item[key] !== undefined).map(key => [key, normalize(item[key])])) : item;
|
|
66
|
+
return createHash("sha256").update(JSON.stringify(normalize(value))).digest("hex");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 输入 hash 包含文章、账号固定身份、配置内容与素材清单,不重复保存大字段。 */
|
|
70
|
+
export function createArticleSnapshot(value: ArticleSnapshotInput): ArticleSnapshot {
|
|
71
|
+
const content = z.object({ title: z.string().trim().min(1).max(255), body: z.string().min(1), account: z.string().regex(/^[a-zA-Z0-9_.-]+$/) }).parse(value);
|
|
72
|
+
if (Buffer.byteLength(content.body) > 2_097_152) throw new Error("Article body exceeds 2 MiB");
|
|
73
|
+
const header = ArticleSnapshotSchema.omit({ inputHash: true }).parse({ schemaVersion: 1, documentId: value.documentId, documentRevision: value.documentRevision, bodyAssets: value.bodyAssets });
|
|
74
|
+
header.bodyAssets.sort((a, b) => a.id.localeCompare(b.id));
|
|
75
|
+
const identities = new Map(header.bodyAssets.map(asset => [asset.id, asset]));
|
|
76
|
+
if (identities.size !== header.bodyAssets.length) throw new Error("Duplicate body asset id");
|
|
77
|
+
const uniqueBytes = new Map(header.bodyAssets.map(asset => [asset.sha256, asset.byteLength]));
|
|
78
|
+
if ([...uniqueBytes.values()].reduce((sum, bytes) => sum + bytes, 0) > 40 * 1024 * 1024) throw new Error("Body assets exceed 40 MiB");
|
|
79
|
+
const used = new Set<string>();
|
|
80
|
+
rewriteArticleImages(content.body, source => {
|
|
81
|
+
const id = /^asset:([a-f\d-]+)$/.exec(source)?.[1];
|
|
82
|
+
if (!id || !identities.has(id)) throw new Error("Article contains an unmanaged image");
|
|
83
|
+
used.add(id); return source;
|
|
84
|
+
});
|
|
85
|
+
if (used.size !== identities.size) throw new Error("Body asset is not referenced by the article");
|
|
86
|
+
const articleStyle = value.articleStyle ? validateArticleStyleSnapshot(value.articleStyle) : undefined;
|
|
87
|
+
const coverSnapshot = value.coverSnapshot ? validateCoverSnapshot(value.coverSnapshot) : undefined;
|
|
88
|
+
return { ...header, inputHash: canonicalHash({ ...header, ...content, articleStyle, coverSnapshot }) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function validateArticleSnapshot(value: unknown, content: ArticleSnapshotContent): ArticleSnapshot {
|
|
92
|
+
const input = ArticleSnapshotSchema.parse(value);
|
|
93
|
+
const snapshot = createArticleSnapshot({ ...content, ...input });
|
|
94
|
+
if (snapshot.inputHash !== input.inputHash) throw new Error("Article input hash mismatch");
|
|
95
|
+
return snapshot;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const ArtifactSchema = z.object({
|
|
99
|
+
key: z.string().regex(/^(article|cover|media_[a-f\d-]{36})$/), kind: z.enum(["html", "cover", "media"]),
|
|
100
|
+
contentType: z.enum(["text/html", "image/png", "image/jpeg", "image/webp", "image/gif"]), byteLength: z.number().int().positive().max(50 * 1024 * 1024), sha256: hash,
|
|
101
|
+
}).strict();
|
|
102
|
+
const ArtifactManifestSchema = z.object({
|
|
103
|
+
schemaVersion: z.literal(1), snapshotHash: hash, artifacts: z.array(ArtifactSchema).min(2).max(42),
|
|
104
|
+
runtime: z.object({ pipeline: z.string().min(1), myno: z.string().min(1), typeset: z.string().min(1) }).strict(),
|
|
105
|
+
}).strict().superRefine((value, ctx) => {
|
|
106
|
+
const keys = value.artifacts.map(item => item.key);
|
|
107
|
+
if (new Set(keys).size !== keys.length || !keys.includes("article") || !keys.includes("cover")) ctx.addIssue({ code: "custom", message: "Artifact keys must be unique and contain article/cover" });
|
|
108
|
+
for (const item of value.artifacts) if (item.key === "article" ? item.kind !== "html" || item.contentType !== "text/html" : item.key === "cover" ? item.kind !== "cover" || item.contentType !== "image/png" : item.kind !== "media" || item.contentType === "text/html") ctx.addIssue({ code: "custom", message: "Artifact key and type mismatch" });
|
|
109
|
+
});
|
|
110
|
+
export type ArtifactManifest = z.infer<typeof ArtifactManifestSchema>;
|
|
111
|
+
export function createArtifactManifest(value: Omit<ArtifactManifest, "schemaVersion">): ArtifactManifest {
|
|
112
|
+
const manifest = ArtifactManifestSchema.parse({ schemaVersion: 1, ...value });
|
|
113
|
+
manifest.artifacts.sort((a, b) => a.key.localeCompare(b.key));
|
|
114
|
+
return manifest;
|
|
115
|
+
}
|
|
116
|
+
export function hashArtifactManifest(value: ArtifactManifest): string { return canonicalHash(ArtifactManifestSchema.parse(value)); }
|
|
117
|
+
export function validateArtifactManifest(value: unknown, expectedHash?: string): ArtifactManifest {
|
|
118
|
+
const manifest = ArtifactManifestSchema.parse(value);
|
|
119
|
+
if (expectedHash && hashArtifactManifest(manifest) !== expectedHash) throw new Error("Artifact manifest hash mismatch");
|
|
120
|
+
return manifest;
|
|
121
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { parseArgs, requireArg } from "../args";
|
|
2
|
+
import { loadTaskState } from "../task-manager";
|
|
3
|
+
import { loadConfig, resolveWorkspacePaths } from "../config";
|
|
4
|
+
import { readArticleTheme } from "../article-theme";
|
|
5
|
+
import { exportWechatArticle } from "../providers/article-export";
|
|
6
|
+
import { printResult } from "../output";
|
|
7
|
+
|
|
8
|
+
/** 在发布检查点只导出本地 HTML,预览绝不调用发布 provider。 */
|
|
9
|
+
export async function articleExport(args: string[]): Promise<void> {
|
|
10
|
+
const parsed = parseArgs(args);
|
|
11
|
+
const { state } = await loadTaskState(requireArg(parsed, "state", "state JSON path"));
|
|
12
|
+
if (state.route.primary !== "wechat-article" || state.phase.render.status !== "done" || !state.asset_path) throw new Error("Article preview requires a rendered article run");
|
|
13
|
+
const config = loadConfig();
|
|
14
|
+
const articleTheme = state.intent.article_theme ? await readArticleTheme(state.intent.article_theme) : undefined;
|
|
15
|
+
const result = await exportWechatArticle({ state, config, workspacePaths: resolveWorkspacePaths(state.workspace_root, config), account: state.route.account, articleTheme });
|
|
16
|
+
printResult({ htmlPath: result.rendered.htmlPath, coverPath: result.photos[0], assetPath: state.asset_path });
|
|
17
|
+
}
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
stripLeadingTitleHeading,
|
|
41
41
|
removePageMarkers,
|
|
42
42
|
} from "../text";
|
|
43
|
+
import { rewriteArticleImages } from "../article-snapshot";
|
|
43
44
|
|
|
44
45
|
function isExternalAssetRef(value: string): boolean {
|
|
45
46
|
return /^(https?:|data:|blob:|\/\/)/i.test(value);
|
|
@@ -133,7 +134,12 @@ async function materializeInlineAssets(options: {
|
|
|
133
134
|
return targetRelativePath;
|
|
134
135
|
};
|
|
135
136
|
|
|
136
|
-
let rewritten =
|
|
137
|
+
let rewritten = process.env.ZZHUB_PIPELINE_ARTICLE_SNAPSHOT === "1"
|
|
138
|
+
? rewriteArticleImages(options.body, source => {
|
|
139
|
+
const target = registerAsset(source);
|
|
140
|
+
return target ? formatRelativeAssetRef(target) : source;
|
|
141
|
+
})
|
|
142
|
+
: options.body.replace(
|
|
137
143
|
/!\[([^\]]*)\]\((?:<([^>]+)>|([^\s)]+))(\s+(?:"[^"]*"|'[^']*'))?\)/g,
|
|
138
144
|
(match, alt, angleSrc, bareSrc, titleSuffix) => {
|
|
139
145
|
const targetRelativePath = registerAsset(String(angleSrc ?? bareSrc ?? ""));
|
|
@@ -147,7 +153,7 @@ async function materializeInlineAssets(options: {
|
|
|
147
153
|
},
|
|
148
154
|
);
|
|
149
155
|
|
|
150
|
-
rewritten = rewritten.replace(
|
|
156
|
+
if (process.env.ZZHUB_PIPELINE_ARTICLE_SNAPSHOT !== "1") rewritten = rewritten.replace(
|
|
151
157
|
/(<img\b[^>]*\bsrc=["'])([^"']+)(["'][^>]*>)/gi,
|
|
152
158
|
(match, prefix, src, suffix) => {
|
|
153
159
|
const targetRelativePath = registerAsset(String(src ?? ""));
|
package/src/logger.test.ts
CHANGED
|
@@ -7,10 +7,13 @@ import { getDailyLogPath, getLogDir, runWithCommandLog } from "./logger";
|
|
|
7
7
|
describe("pipeline daily logger", () => {
|
|
8
8
|
const previousLogDir = process.env.ZZHUB_PIPELINE_LOG_DIR;
|
|
9
9
|
const previousMonitor = process.env.ZZHUB_PIPELINE_MONITOR;
|
|
10
|
+
const previousBudget = process.env.ZZHUB_PIPELINE_WORKER_BUDGET;
|
|
10
11
|
beforeEach(() => { process.env.ZZHUB_PIPELINE_MONITOR = "0"; });
|
|
11
12
|
let tempDir: string | null = null;
|
|
12
13
|
|
|
13
14
|
afterEach(() => {
|
|
15
|
+
if (previousBudget === undefined) delete process.env.ZZHUB_PIPELINE_WORKER_BUDGET;
|
|
16
|
+
else process.env.ZZHUB_PIPELINE_WORKER_BUDGET = previousBudget;
|
|
14
17
|
if (previousMonitor === undefined) delete process.env.ZZHUB_PIPELINE_MONITOR;
|
|
15
18
|
else process.env.ZZHUB_PIPELINE_MONITOR = previousMonitor;
|
|
16
19
|
if (previousLogDir === undefined) {
|
|
@@ -80,4 +83,16 @@ describe("pipeline daily logger", () => {
|
|
|
80
83
|
expect(body).toContain("Error: boom-upload");
|
|
81
84
|
expect(body).toContain("boom-upload");
|
|
82
85
|
});
|
|
86
|
+
|
|
87
|
+
test("trusted Worker truncates diagnostic lines without changing the original exception", async () => {
|
|
88
|
+
tempDir = mkdtempSync(join(tmpdir(), "zzhub-pipeline-log-"));
|
|
89
|
+
process.env.ZZHUB_PIPELINE_LOG_DIR = tempDir;
|
|
90
|
+
process.env.ZZHUB_PIPELINE_WORKER_BUDGET = "1";
|
|
91
|
+
const message = `original-error-${"x".repeat(20_000)}`;
|
|
92
|
+
await expect(runWithCommandLog("publish", [], async () => { throw new Error(message); })).rejects.toThrow(message);
|
|
93
|
+
const body = readFileSync(getDailyLogPath(), "utf8");
|
|
94
|
+
expect(body).toContain("[truncated]");
|
|
95
|
+
expect(body).toContain("=== FAIL command=publish");
|
|
96
|
+
expect(Buffer.byteLength(body)).toBeLessThan(6000);
|
|
97
|
+
});
|
|
83
98
|
});
|
package/src/logger.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { homedir, platform } from "node:os";
|
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { outcomeExitCode, type CommandOutcome } from "./command-outcome";
|
|
16
16
|
import { observeCommand, reportLog } from "./monitor/recorder";
|
|
17
|
+
import { LOG_LIMIT, redact } from "./monitor/runtime";
|
|
17
18
|
|
|
18
19
|
const APP_DIR = "zzhub-pipeline";
|
|
19
20
|
const LOG_DIR_NAME = "logs";
|
|
@@ -158,7 +159,20 @@ export async function runWithCommandLog(
|
|
|
158
159
|
const startedAt = new Date();
|
|
159
160
|
const startedMs = Date.now();
|
|
160
161
|
const logPath = getDailyLogPath(startedAt);
|
|
161
|
-
|
|
162
|
+
let logBytes = 0;
|
|
163
|
+
let truncated = false;
|
|
164
|
+
const write = (message: string, terminal = false) => {
|
|
165
|
+
if (process.env.ZZHUB_PIPELINE_WORKER_BUDGET === "1") {
|
|
166
|
+
if (truncated && !terminal) return;
|
|
167
|
+
const oversized = message.length > 4096;
|
|
168
|
+
message = redact(message);
|
|
169
|
+
if (oversized) message = `${message} [truncated]`;
|
|
170
|
+
if (logBytes + Buffer.byteLength(message) > LOG_LIMIT && !terminal) {
|
|
171
|
+
truncated = true;
|
|
172
|
+
message = "[truncated] Worker diagnostic log limit reached; command outcome remains available.";
|
|
173
|
+
}
|
|
174
|
+
logBytes += Buffer.byteLength(message) + 30;
|
|
175
|
+
}
|
|
162
176
|
appendLogLine(`[${new Date().toISOString()}] ${message}`, startedAt);
|
|
163
177
|
};
|
|
164
178
|
|
|
@@ -177,12 +191,14 @@ export async function runWithCommandLog(
|
|
|
177
191
|
outcome = { status: "failed", errors: [{ code: "PROCESS_EXIT", message: `进程退出:${process.exitCode}` }] };
|
|
178
192
|
}
|
|
179
193
|
const label = outcomeExitCode(outcome) ? "FAIL" : outcome.status === "success" ? "OK" : outcome.status.toUpperCase();
|
|
180
|
-
write(`=== ${label} command=${command} duration_ms=${Date.now() - startedMs}
|
|
181
|
-
|
|
194
|
+
write(`=== ${label} command=${command} duration_ms=${Date.now() - startedMs} ===`, true);
|
|
195
|
+
const errors = process.env.ZZHUB_PIPELINE_WORKER_BUDGET === "1" ? outcome.errors?.slice(0, 3) : outcome.errors;
|
|
196
|
+
for (const error of errors ?? []) write(`${error.code}: ${error.message}${error.cause ? `\n${error.cause}` : ""}`, true);
|
|
197
|
+
if (errors && outcome.errors!.length > errors.length) write("[truncated] Additional errors remain in the command result.", true);
|
|
182
198
|
return outcome;
|
|
183
199
|
} catch (err) {
|
|
184
|
-
write(`=== FAIL command=${command} duration_ms=${Date.now() - startedMs}
|
|
185
|
-
write(formatError(err));
|
|
200
|
+
write(`=== FAIL command=${command} duration_ms=${Date.now() - startedMs} ===`, true);
|
|
201
|
+
write(formatError(err), true);
|
|
186
202
|
throw err;
|
|
187
203
|
} finally {
|
|
188
204
|
restoreConsole();
|
package/src/monitor/recorder.ts
CHANGED
|
@@ -19,6 +19,8 @@ interface Recording {
|
|
|
19
19
|
progressAccount?: string;
|
|
20
20
|
ended?: boolean;
|
|
21
21
|
tasks: Set<string>;
|
|
22
|
+
eventBytes: number;
|
|
23
|
+
eventTruncated: boolean;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
/** 调用上下文自动穿过内置异步流程,不要求业务函数传监控对象。 */
|
|
@@ -29,8 +31,25 @@ function emit(type: MonitorEvent["type"], data: Record<string, unknown>): void {
|
|
|
29
31
|
const current = recording.getStore();
|
|
30
32
|
if (!current || current.disabled || current.ended) return;
|
|
31
33
|
try {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
let event: MonitorEvent = { version: 1, execution_id: current.id, seq: ++current.seq, at: new Date().toISOString(), type, data };
|
|
35
|
+
if (process.env.ZZHUB_PIPELINE_WORKER_BUDGET === "1") {
|
|
36
|
+
// 仅截断观测内容,终态与调用方原异常、退出码和业务 receipt 不受影响。
|
|
37
|
+
event = JSON.parse(JSON.stringify(event, (_key, value) => typeof value === "string" && value.length > 4096 ? `${redact(value.slice(0, 4096))} [truncated]` : value)) as MonitorEvent;
|
|
38
|
+
if (Buffer.byteLength(JSON.stringify(event)) > 64 * 1024) {
|
|
39
|
+
const outcome = event.data.outcome as CommandOutcome | undefined;
|
|
40
|
+
event.data = type === "finished" ? { exit_code: event.data.exit_code, outcome: { status: outcome?.status, errors: outcome?.errors?.slice(0, 3).map(error => ({ code: error.code, message: error.message })) }, truncated: true }
|
|
41
|
+
: { level: "warn", message: "Worker 观测事件超过单条上限,内容已截断。", truncated: true };
|
|
42
|
+
if (type !== "finished") event.type = "log";
|
|
43
|
+
}
|
|
44
|
+
if (current.eventTruncated && type !== "finished") return;
|
|
45
|
+
if (current.eventBytes + Buffer.byteLength(JSON.stringify(event)) > LOG_LIMIT && type !== "finished") {
|
|
46
|
+
current.eventTruncated = true;
|
|
47
|
+
event.type = "log"; event.data = { level: "warn", message: "Worker 观测事件达到上限,后续事件截断;业务错误和终态保留。", truncated: true };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const line = `${JSON.stringify(event)}\n`;
|
|
51
|
+
appendFileSync(current.file, line, { encoding: "utf8", mode: 0o600 });
|
|
52
|
+
current.eventBytes += Buffer.byteLength(line);
|
|
34
53
|
} catch {
|
|
35
54
|
// 只停用本次记录,不重试、不递归写日志,也不影响业务结果。
|
|
36
55
|
current.disabled = true;
|
|
@@ -115,7 +134,7 @@ export async function observeCommand(command: string, handler: () => Promise<Com
|
|
|
115
134
|
const dir = join(monitorDir(), "events");
|
|
116
135
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
117
136
|
const id = crypto.randomUUID();
|
|
118
|
-
context = { id, file: join(dir, `${id}.jsonl`), seq: 0, disabled: false, logBytes: 0, truncated: false, lastProgressAt: 0, progressKey: "", tasks: new Set() };
|
|
137
|
+
context = { id, file: join(dir, `${id}.jsonl`), seq: 0, disabled: false, logBytes: 0, truncated: false, lastProgressAt: 0, progressKey: "", tasks: new Set(), eventBytes: 0, eventTruncated: false };
|
|
119
138
|
} catch { return handler(); }
|
|
120
139
|
|
|
121
140
|
return recording.run(context, async () => {
|
package/src/monitor.test.ts
CHANGED
|
@@ -28,7 +28,7 @@ beforeEach(async () => {
|
|
|
28
28
|
afterEach(async () => {
|
|
29
29
|
stop?.();
|
|
30
30
|
stop = undefined;
|
|
31
|
-
for (const key of ["ZZHUB_PIPELINE_MONITOR_DIR", "ZZHUB_PIPELINE_LOG_DIR", "ZZHUB_PIPELINE_CONFIG", "ZZHUB_PIPELINE_MONITOR", "NO_COLOR"]) {
|
|
31
|
+
for (const key of ["ZZHUB_PIPELINE_MONITOR_DIR", "ZZHUB_PIPELINE_LOG_DIR", "ZZHUB_PIPELINE_CONFIG", "ZZHUB_PIPELINE_MONITOR", "ZZHUB_PIPELINE_WORKER_BUDGET", "NO_COLOR"]) {
|
|
32
32
|
if (previousEnv[key] === undefined) delete process.env[key];
|
|
33
33
|
else process.env[key] = previousEnv[key];
|
|
34
34
|
}
|
|
@@ -178,6 +178,22 @@ test("incomplete final records resume on the next read and state aliases keep on
|
|
|
178
178
|
expect(store.snapshot().tasks[0]?.state_path).toContain("posts/test/workflow-state.json");
|
|
179
179
|
});
|
|
180
180
|
|
|
181
|
+
test("trusted Worker caps individual and cumulative events while returning the original failure", async () => {
|
|
182
|
+
process.env.ZZHUB_PIPELINE_WORKER_BUDGET = "1";
|
|
183
|
+
const message = "字".repeat(5000);
|
|
184
|
+
const outcome = await runWithCommandLog("render", [], async () => {
|
|
185
|
+
for (let index = 0; index < 1100; index++) reportProgress({ stage: `stage-${index}`, message });
|
|
186
|
+
return { status: "failed", errors: [{ code: "RENDER_FAILED", message: "原始最终错误" }] };
|
|
187
|
+
});
|
|
188
|
+
expect(outcome.errors?.[0]?.message).toBe("原始最终错误");
|
|
189
|
+
const directory = join(process.env.ZZHUB_PIPELINE_MONITOR_DIR!, "events");
|
|
190
|
+
const raw = await readFile(join(directory, (await readdir(directory))[0]!), "utf8");
|
|
191
|
+
const events = raw.trim().split("\n").map(line => JSON.parse(line));
|
|
192
|
+
expect(raw).toContain("truncated");
|
|
193
|
+
expect(Buffer.byteLength(raw)).toBeLessThan(LOG_LIMIT + 64 * 1024);
|
|
194
|
+
expect(events.at(-1)).toMatchObject({ type: "finished", data: { exit_code: 1, outcome: { errors: [{ message: "原始最终错误" }] } } });
|
|
195
|
+
});
|
|
196
|
+
|
|
181
197
|
test("HTTP/SSE support auth, shared subscriptions, cursor replay, resync and server restart", async () => {
|
|
182
198
|
const server = await serveMonitor();
|
|
183
199
|
expect(server).not.toBeNull();
|
package/src/plugins.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { wxDraftDelete } from "./commands/wx-draft-delete";
|
|
|
30
30
|
import type { CommandOutcome } from "./command-outcome";
|
|
31
31
|
import { monitor } from "./commands/monitor";
|
|
32
32
|
import { typesetAssetsSync } from "./commands/typeset-assets-sync";
|
|
33
|
+
import { articleExport } from "./commands/article-export";
|
|
33
34
|
|
|
34
35
|
export interface CommandDefinition {
|
|
35
36
|
name: string;
|
|
@@ -76,6 +77,7 @@ export function getCommandPlugins(): CommandPlugin[] {
|
|
|
76
77
|
{ name: "imgx", summary: "Run bundled imgx renderer subcommands", plugin: "ops", handler: imgxCommand },
|
|
77
78
|
{ name: "article-theme", summary: "Inspect or install a local article theme", plugin: "ops", handler: articleTheme },
|
|
78
79
|
{ name: "wechat-export", summary: "Render markdown to WeChat HTML with bundled preview styles", plugin: "ops", handler: wechatExport },
|
|
80
|
+
{ name: "article-export", summary: "Export a rendered article locally without publishing", plugin: "ops", handler: articleExport },
|
|
79
81
|
{ name: "wechat-preview", summary: "Local singleton server to preview exported WeChat HTML", plugin: "ops", handler: wechatPreview },
|
|
80
82
|
{ name: "cos-upload", summary: "Upload a local image to configured COS CDN", plugin: "ops", handler: cosUpload },
|
|
81
83
|
{ name: "typeset-assets-sync", summary: "Upload Studio template assets and build a runtime config package", plugin: "ops", handler: typesetAssetsSync },
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { resolveMarkdownRenderer } from "../adapter-loader";
|
|
5
|
+
import { resolveConfigRelativePath, type PipelineConfig, type ResolvedWorkspacePaths } from "../config";
|
|
6
|
+
import type { ArticleThemePackage } from "../article-theme";
|
|
7
|
+
import type { WorkflowState } from "../state";
|
|
8
|
+
import { injectIllustrationImages } from "../text";
|
|
9
|
+
import { reportProgress } from "../monitor/recorder";
|
|
10
|
+
import { validateArtifactManifest, type ArtifactManifest } from "../article-snapshot";
|
|
11
|
+
import type { MarkdownRenderOutput } from "../adapter-types";
|
|
12
|
+
|
|
13
|
+
/** 文章导出只做本地排版,预览与直接发布复用;没有微信上传操作。 */
|
|
14
|
+
export async function exportWechatArticle({ state, config, workspacePaths, account, articleTheme }: {
|
|
15
|
+
state: WorkflowState; config: PipelineConfig; workspacePaths: ResolvedWorkspacePaths; account: string; articleTheme?: ArticleThemePackage;
|
|
16
|
+
}): Promise<{ html: string; photos: string[]; rendered: MarkdownRenderOutput; exportPostPath: string }> {
|
|
17
|
+
let exportPostPath = join(state.asset_path, "post.md");
|
|
18
|
+
const cover = state.images.render_assets.find(asset => asset.route === "wechat-article" && asset.kind === "cover");
|
|
19
|
+
const photos = cover ? [cover.path] : [];
|
|
20
|
+
if (state.images.body_inputs.scope === "article" && state.images.body_inputs.status === "ready") {
|
|
21
|
+
const body = injectIllustrationImages(await readFile(exportPostPath, "utf8"), state.images.body_inputs.received);
|
|
22
|
+
exportPostPath = join(state.asset_path, "post-wechat-draft.md");
|
|
23
|
+
await writeFile(exportPostPath, body);
|
|
24
|
+
photos.push(...state.images.body_inputs.received.map(image => image.path));
|
|
25
|
+
}
|
|
26
|
+
const renderer = await resolveMarkdownRenderer(config);
|
|
27
|
+
const wxAccount = config.wx.accounts[account] ?? config.wx.accounts[config.wx.defaultAccount];
|
|
28
|
+
const standalone = articleTheme?.manifest.css !== undefined;
|
|
29
|
+
const rendered = await renderer.render({ onProgress: reportProgress, markdownPath: exportPostPath,
|
|
30
|
+
outPath: workspacePaths.zotepadExportHtml, account, title: state.metadata.title,
|
|
31
|
+
customCss: standalone ? null : resolveConfigRelativePath(wxAccount?.customCss),
|
|
32
|
+
articleThemePath: articleTheme?.path, themeOverrides: standalone ? undefined : wxAccount?.theme });
|
|
33
|
+
return { html: rendered.html, photos, rendered, exportPostPath };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface FrozenArticleArtifacts {
|
|
37
|
+
manifest: ArtifactManifest;
|
|
38
|
+
manifestHash: string;
|
|
39
|
+
files: Record<string, string>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 私有 manifest 是唯一文件白名单;确认发布只读已验证产物,不再导出或渲染。 */
|
|
43
|
+
export async function readFrozenArticleArtifacts(manifestPath: string): Promise<{ html: string; photos: string[]; rendered?: undefined; exportPostPath: string }> {
|
|
44
|
+
const stored = JSON.parse(await readFile(manifestPath, "utf8")) as FrozenArticleArtifacts;
|
|
45
|
+
if (stored.manifestHash !== process.env.ZZHUB_PIPELINE_ARTIFACT_MANIFEST_HASH || stored.manifest.snapshotHash !== process.env.ZZHUB_PIPELINE_ARTICLE_SNAPSHOT_HASH) throw new Error("Frozen artifact identity does not match the confirmed job");
|
|
46
|
+
const manifest = validateArtifactManifest(stored.manifest, stored.manifestHash);
|
|
47
|
+
const root = dirname(resolve(manifestPath));
|
|
48
|
+
const bytes = new Map<string, Buffer>();
|
|
49
|
+
const paths = new Map<string, string>();
|
|
50
|
+
for (const artifact of manifest.artifacts) {
|
|
51
|
+
const name = stored.files[artifact.key];
|
|
52
|
+
if (!name || name.includes("/") || name.includes("\\") || name === "." || name === "..") throw new Error("Invalid private artifact file");
|
|
53
|
+
const path = join(root, name);
|
|
54
|
+
const data = await readFile(path);
|
|
55
|
+
if (data.byteLength !== artifact.byteLength || createHash("sha256").update(data).digest("hex") !== artifact.sha256) throw new Error("Frozen article artifact hash mismatch");
|
|
56
|
+
bytes.set(artifact.key, data); paths.set(artifact.key, path);
|
|
57
|
+
}
|
|
58
|
+
let html = bytes.get("article")!.toString("utf8");
|
|
59
|
+
html = await new HTMLRewriter().on("img", { element(element) {
|
|
60
|
+
const source = element.getAttribute("src") || "";
|
|
61
|
+
const id = /^asset:([a-f\d-]{36})$/.exec(source)?.[1];
|
|
62
|
+
const path = id ? paths.get(`media_${id}`) : undefined;
|
|
63
|
+
if (!path) throw new Error("Frozen HTML contains an unmanaged image");
|
|
64
|
+
element.setAttribute("src", path);
|
|
65
|
+
if (element.hasAttribute("data-src")) element.setAttribute("data-src", path);
|
|
66
|
+
} }).transform(new Response(html)).text();
|
|
67
|
+
return { html, photos: [paths.get("cover")!, ...manifest.artifacts.filter(artifact => artifact.kind === "media").map(artifact => paths.get(artifact.key)!)], exportPostPath: paths.get("article")! };
|
|
68
|
+
}
|
package/src/providers/index.ts
CHANGED
|
@@ -3,17 +3,14 @@ import { createHash } from "node:crypto";
|
|
|
3
3
|
import { readFile, writeFile } from "fs/promises";
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
import {
|
|
6
|
-
resolveConfigRelativePath,
|
|
7
6
|
type PipelineConfig,
|
|
8
7
|
type ResolvedWorkspacePaths,
|
|
9
8
|
} from "../config";
|
|
10
9
|
import { PublishResult, RenderAsset, RoutePrimary, WorkflowState } from "../state";
|
|
11
10
|
import {
|
|
12
|
-
injectIllustrationImages,
|
|
13
11
|
prepareBodyForNewspic,
|
|
14
12
|
} from "../text";
|
|
15
|
-
import {
|
|
16
|
-
import { reportProgress } from "../monitor/recorder";
|
|
13
|
+
import { exportWechatArticle, readFrozenArticleArtifacts } from "./article-export";
|
|
17
14
|
import { publishBlogRoute } from "./blog";
|
|
18
15
|
import { createWechatDraft, createWechatNewspic, extractImageUrls, mergePhotoLists } from "./wechat";
|
|
19
16
|
|
|
@@ -36,70 +33,22 @@ async function publishWechatArticleRoute({
|
|
|
36
33
|
accountOverride,
|
|
37
34
|
articleTheme,
|
|
38
35
|
}: PublishRouteContext): Promise<PublishResult> {
|
|
39
|
-
const postPath = join(state.asset_path, "post.md");
|
|
40
|
-
let exportPostPath = postPath;
|
|
41
|
-
const cover = (state.images.render_assets as RenderAsset[]).find(
|
|
42
|
-
(asset) => asset.route === "wechat-article" && asset.kind === "cover",
|
|
43
|
-
);
|
|
44
|
-
|
|
45
|
-
const photos: string[] = [];
|
|
46
|
-
if (cover) {
|
|
47
|
-
photos.push(cover.path);
|
|
48
|
-
}
|
|
49
|
-
if (
|
|
50
|
-
state.images.body_inputs.scope === "article" &&
|
|
51
|
-
state.images.body_inputs.status === "ready"
|
|
52
|
-
) {
|
|
53
|
-
const postContent = await readFile(postPath, "utf-8");
|
|
54
|
-
const draftContent = injectIllustrationImages(
|
|
55
|
-
postContent,
|
|
56
|
-
state.images.body_inputs.received,
|
|
57
|
-
);
|
|
58
|
-
exportPostPath = join(state.asset_path, "post-wechat-draft.md");
|
|
59
|
-
if (!dryRun) {
|
|
60
|
-
await writeFile(exportPostPath, draftContent, "utf-8");
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
for (const img of state.images.body_inputs.received) {
|
|
64
|
-
photos.push(img.path);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
36
|
const account = accountOverride || state.route.account;
|
|
69
|
-
|
|
70
37
|
if (dryRun) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return {
|
|
74
|
-
route: "wechat-article",
|
|
75
|
-
account,
|
|
76
|
-
status: "skipped",
|
|
77
|
-
detail: "dry-run",
|
|
78
|
-
published_at: null,
|
|
79
|
-
content_version: state.artifacts.content_version,
|
|
80
|
-
render_version: state.artifacts.render_version,
|
|
81
|
-
};
|
|
38
|
+
return { route: "wechat-article", account, status: "skipped", detail: "dry-run", published_at: null,
|
|
39
|
+
content_version: state.artifacts.content_version, render_version: state.artifacts.render_version };
|
|
82
40
|
}
|
|
83
41
|
|
|
84
42
|
try {
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
markdownPath: exportPostPath,
|
|
92
|
-
outPath: workspacePaths.zotepadExportHtml,
|
|
93
|
-
account,
|
|
94
|
-
title: state.metadata.title,
|
|
95
|
-
customCss: standalone ? null : resolveConfigRelativePath(wxAccount?.customCss),
|
|
96
|
-
articleThemePath: articleTheme?.path,
|
|
97
|
-
themeOverrides: standalone ? undefined : wxAccount?.theme,
|
|
98
|
-
});
|
|
99
|
-
const { html } = rendered;
|
|
43
|
+
if (process.env.ZZHUB_PIPELINE_PREVIEW_ONLY === "1") throw new Error("Preview operation cannot publish or upload WeChat images");
|
|
44
|
+
const frozenPath = process.env.ZZHUB_PIPELINE_ARTICLE_ARTIFACTS;
|
|
45
|
+
const prepared = frozenPath
|
|
46
|
+
? await readFrozenArticleArtifacts(frozenPath)
|
|
47
|
+
: await exportWechatArticle({ state, config, workspacePaths, account, articleTheme });
|
|
48
|
+
const { html, photos, rendered, exportPostPath } = prepared;
|
|
100
49
|
|
|
101
50
|
// Register with local preview server unless explicitly disabled.
|
|
102
|
-
if (process.env.ZZHUB_WECHAT_PREVIEW_ON_PUBLISH !== "0") {
|
|
51
|
+
if (rendered && process.env.ZZHUB_WECHAT_PREVIEW_ON_PUBLISH !== "0") {
|
|
103
52
|
try {
|
|
104
53
|
const { registerPreviewEntry } = await import("../wechat-preview/server");
|
|
105
54
|
const registration = await registerPreviewEntry(
|
|
@@ -131,6 +80,7 @@ async function publishWechatArticleRoute({
|
|
|
131
80
|
title: state.metadata.title,
|
|
132
81
|
html,
|
|
133
82
|
photos,
|
|
83
|
+
...(frozenPath ? { explicitPhotos: true } : {}),
|
|
134
84
|
idempotencyKey: createHash("sha256").update(JSON.stringify([state.run_id, state.publish.attempt ?? 0, account, state.artifacts.content_version, state.artifacts.render_version, articleTheme?.hash, state.intent.existing_draft_media_id])).digest("hex"),
|
|
135
85
|
config,
|
|
136
86
|
existingDraftMediaId: state.intent.existing_draft_media_id,
|
package/src/providers/wechat.ts
CHANGED
|
@@ -139,6 +139,8 @@ interface BaseWechatPublishInput {
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
export interface WechatDraftInput extends BaseWechatPublishInput {
|
|
142
|
+
/** 冻结产物已给出真实图片清单,禁止从代码文本再猜图片来源。 */
|
|
143
|
+
explicitPhotos?: boolean;
|
|
142
144
|
html: string;
|
|
143
145
|
existingDraftMediaId?: string | null;
|
|
144
146
|
}
|
|
@@ -642,7 +644,7 @@ export function calculatePicCrop(width: number, height: number): { pic_crop_235_
|
|
|
642
644
|
|
|
643
645
|
export async function createWechatDraft(input: WechatDraftInput): Promise<Record<string, unknown>> {
|
|
644
646
|
const runtime = getWxRuntimeConfig(input.config, input.account);
|
|
645
|
-
const fallbackPhotos = extractImageUrls(input.html);
|
|
647
|
+
const fallbackPhotos = input.explicitPhotos ? [] : extractImageUrls(input.html);
|
|
646
648
|
const photos = parsePhotos(input.photos);
|
|
647
649
|
const finalPhotos = mergePhotoLists(photos, fallbackPhotos);
|
|
648
650
|
if (finalPhotos.length === 0) {
|
|
@@ -548,6 +548,9 @@ export async function exportMarkdownToWechatHtml(
|
|
|
548
548
|
}
|
|
549
549
|
|
|
550
550
|
const html = result.html;
|
|
551
|
+
if (process.env.ZZHUB_PIPELINE_WORKER_BUDGET === "1" && Buffer.byteLength(html, "utf8") > 50 * 1024 * 1024) {
|
|
552
|
+
throw makeError("render_error", "Worker HTML export exceeds 50 MiB");
|
|
553
|
+
}
|
|
551
554
|
const semanticHtml =
|
|
552
555
|
"semanticHtml" in result && typeof result.semanticHtml === "string"
|
|
553
556
|
? result.semanticHtml
|