@zzclub/pipeline 0.3.0 → 0.4.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 +366 -170
- package/package.json +4 -7
- package/src/adapter-loader.ts +138 -0
- package/src/adapter-types.ts +172 -0
- package/src/adapters/builtin-image-renderer.ts +353 -0
- package/src/adapters/builtin-markdown-renderer.ts +63 -0
- package/src/adapters/index.ts +2 -0
- package/src/args.test.ts +118 -0
- package/src/args.ts +90 -0
- package/src/cli.ts +51 -0
- package/src/commands/abandon.test.ts +104 -0
- package/src/commands/abandon.ts +202 -0
- package/src/commands/attach-body-images.ts +88 -0
- package/src/commands/attach-body.ts +86 -0
- package/src/commands/attach-newspic-spec.ts +41 -0
- package/src/commands/checkpoint.test.ts +91 -0
- package/src/commands/checkpoint.ts +66 -0
- package/src/commands/config.ts +106 -0
- package/src/commands/cos-upload.ts +62 -0
- package/src/commands/doctor.ts +66 -0
- package/src/commands/find-run.ts +55 -0
- package/src/commands/hermes-metrics.ts +226 -0
- package/src/commands/imgx.ts +60 -0
- package/src/commands/ingest-handoff.ts +509 -0
- package/src/commands/init.ts +126 -0
- package/src/commands/prepare-finalize.ts +348 -0
- package/src/commands/prepare.ts +310 -0
- package/src/commands/publish.ts +147 -0
- package/src/commands/reconcile.ts +36 -0
- package/src/commands/render.ts +244 -0
- package/src/commands/reset.test.ts +151 -0
- package/src/commands/reset.ts +159 -0
- package/src/commands/review.ts +77 -0
- package/src/commands/status.ts +68 -0
- package/src/commands/sync-blog.ts +39 -0
- package/src/commands/tasks.ts +50 -0
- package/src/commands/wechat-export.ts +68 -0
- package/src/config.ts +0 -0
- package/src/imgx/ascii-portrait.ts +77 -0
- package/src/imgx/assets/fonts/AlimamaShuHeiTi-Bold.ttf +0 -0
- package/src/imgx/assets/fonts/LXGWNeoZhiSongPlus.ttf +0 -0
- package/src/imgx/assets/fonts/LXGWWenKai-Regular.ttf +0 -0
- package/src/imgx/assets/icons/logo.png +0 -0
- package/src/imgx/assets/icons/logo.svg +8 -0
- package/{dist/assets → src}/imgx/assets/templates/longform-3-4.html +1 -1
- package/{dist/assets → src}/imgx/assets/templates/poster-3-4.html +69 -30
- package/{dist/assets → src}/imgx/assets/templates/wechat-cover-split.html +58 -27
- package/src/imgx/cli.ts +57 -0
- package/src/imgx/geometry.ts +68 -0
- package/src/imgx/index.ts +5 -0
- package/src/imgx/longform-theme.ts +243 -0
- package/src/imgx/poster-recipe.ts +81 -0
- package/src/imgx/pretext-adapter.ts +14 -0
- package/src/imgx/pretext-runtime.ts +61 -0
- package/src/imgx/references/ascii-portrait-3-4.md +59 -0
- package/src/imgx/references/longform-3-4.md +173 -0
- package/src/imgx/references/poster-3-4.md +87 -0
- package/src/imgx/references/tips-3-4.md +69 -0
- package/src/imgx/references/wechat-cover-split.md +89 -0
- package/src/imgx/references/x-like-posts.md +139 -0
- package/src/imgx/render-article.test.ts +53 -0
- package/src/imgx/render-article.ts +1364 -0
- package/src/imgx/render-ascii-portrait.ts +53 -0
- package/src/imgx/render-card.ts +232 -0
- package/src/imgx/render-x-like-posts.ts +235 -0
- package/src/imgx/runtime.ts +403 -0
- package/src/imgx/typographic-scale.test.ts +56 -0
- package/src/imgx/typographic-scale.ts +44 -0
- package/src/output.ts +632 -0
- package/src/plugins.ts +101 -0
- package/src/profiles.ts +149 -0
- package/src/providers/blog.test.ts +58 -0
- package/src/providers/blog.ts +154 -0
- package/src/providers/cos.ts +183 -0
- package/src/providers/index.test.ts +26 -0
- package/src/providers/index.ts +185 -0
- package/src/providers/wechat.test.ts +215 -0
- package/src/providers/wechat.ts +548 -0
- package/src/routes.ts +287 -0
- package/src/runtime-paths.ts +179 -0
- package/src/schema/config.ts +133 -0
- package/src/schema/state.ts +376 -0
- package/src/spawn.ts +103 -0
- package/src/state.ts +265 -0
- package/src/task-manager.ts +657 -0
- package/src/task-views.ts +222 -0
- package/src/text.test.ts +689 -0
- package/src/text.ts +683 -0
- package/src/wechat-preview/assets/fonts/SweiCurveLegCJKsc-Regular.woff +0 -0
- package/src/wechat-preview/browser/editor-export.css +241 -0
- package/src/wechat-preview/browser/editor-export.ts +103 -0
- package/src/wechat-preview/frontmatter-handler.ts +73 -0
- package/src/wechat-preview/index.test.ts +154 -0
- package/src/wechat-preview/index.ts +294 -0
- package/src/wechat-preview/themes.ts +141 -0
- package/src/wechat-preview/wechat-formatter.test.ts +288 -0
- package/src/wechat-preview/wechat-formatter.ts +1506 -0
- package/src/wechat-preview.test.ts +73 -0
- package/src/workflow-materials.test.ts +225 -0
- package/src/workflow-materials.ts +217 -0
- package/src/workflow.test.ts +2087 -0
- package/dist/assets/imgx/assets/icons/avatar_jinx_cartoon.jpg +0 -0
- package/dist/assets/imgx/assets/icons/fishbone-logo-square.png +0 -0
- package/dist/assets/imgx/assets/icons/fishbone-logo.jpg +0 -0
- package/dist/assets/imgx/assets/icons/fishbone-logo.png +0 -0
- package/dist/assets/imgx/assets/icons/openclaw-logo.svg +0 -22
- package/dist/assets/imgx/assets/icons/zzclub-logo-black.jpg +0 -0
- package/dist/assets/imgx/assets/icons/zzclub-logo-gray.svg +0 -25
- package/dist/cli.js +0 -431
- package/dist/node_modules/@chenglou/pretext/dist/analysis.d.ts +0 -33
- package/dist/node_modules/@chenglou/pretext/dist/analysis.js +0 -1063
- package/dist/node_modules/@chenglou/pretext/dist/bidi.d.ts +0 -1
- package/dist/node_modules/@chenglou/pretext/dist/bidi.js +0 -175
- package/dist/node_modules/@chenglou/pretext/dist/generated/bidi-data.d.ts +0 -4
- package/dist/node_modules/@chenglou/pretext/dist/generated/bidi-data.js +0 -979
- package/dist/node_modules/@chenglou/pretext/dist/layout.d.ts +0 -70
- package/dist/node_modules/@chenglou/pretext/dist/layout.js +0 -496
- package/dist/node_modules/@chenglou/pretext/dist/line-break.d.ts +0 -36
- package/dist/node_modules/@chenglou/pretext/dist/line-break.js +0 -820
- package/dist/node_modules/@chenglou/pretext/dist/measurement.d.ts +0 -28
- package/dist/node_modules/@chenglou/pretext/dist/measurement.js +0 -219
- package/dist/node_modules/@chenglou/pretext/dist/rich-inline.d.ts +0 -51
- package/dist/node_modules/@chenglou/pretext/dist/rich-inline.js +0 -401
- /package/{dist/assets → src}/imgx/assets/browser/obstacle-flow.d.ts +0 -0
- /package/{dist/assets → src}/imgx/assets/browser/obstacle-flow.js +0 -0
- /package/{dist/assets → src}/imgx/assets/icons/clover.svg +0 -0
- /package/{dist/assets → src}/imgx/assets/templates/ascii-portrait-3-4.html +0 -0
- /package/{dist/assets → src}/imgx/assets/templates/ascii-portrait-tile.html +0 -0
- /package/{dist/assets → src}/imgx/assets/templates/tips-3-4.html +0 -0
- /package/{dist/assets → src}/imgx/assets/templates/x-like-posts.html +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/.vite/manifest.json +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/SweiCurveLegCJKsc-Regular-B3Ut5bwH.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/style-BYNXnUvc.css +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/editor-export.js +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/templates/export-shell.html +0 -0
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "fs";
|
|
2
|
+
import { basename, extname } from "path";
|
|
3
|
+
import { PipelineConfig, WxAccountConfig } from "../config";
|
|
4
|
+
|
|
5
|
+
const TOKEN_PATH = "/api/v1/wx/cgi-bin/token";
|
|
6
|
+
const MATERIAL_PATH = "/api/v1/wx/cgi-bin/material/add_material";
|
|
7
|
+
const DRAFT_PATH = "/api/v1/wx/cgi-bin/draft/add";
|
|
8
|
+
const TOKEN_TIMEOUT = 10000;
|
|
9
|
+
const UPLOAD_TIMEOUT = 60000;
|
|
10
|
+
const DRAFT_TIMEOUT = 30000;
|
|
11
|
+
const TOKEN_FETCH_RETRY_MAX_ATTEMPTS = 3;
|
|
12
|
+
const TOKEN_FETCH_RETRY_DELAY_MS = 500;
|
|
13
|
+
|
|
14
|
+
const IMAGE_EXTENSIONS = new Map<string, string>([
|
|
15
|
+
["image/jpeg", "jpg"],
|
|
16
|
+
["image/jpg", "jpg"],
|
|
17
|
+
["image/png", "png"],
|
|
18
|
+
["image/gif", "gif"],
|
|
19
|
+
["image/webp", "webp"],
|
|
20
|
+
["image/bmp", "bmp"],
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const EXTENSION_CONTENT_TYPES = new Map<string, string>([
|
|
24
|
+
[".jpg", "image/jpeg"],
|
|
25
|
+
[".jpeg", "image/jpeg"],
|
|
26
|
+
[".png", "image/png"],
|
|
27
|
+
[".gif", "image/gif"],
|
|
28
|
+
[".webp", "image/webp"],
|
|
29
|
+
[".bmp", "image/bmp"],
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
interface TokenResponse {
|
|
33
|
+
code?: number;
|
|
34
|
+
data?: {
|
|
35
|
+
accessToken?: string;
|
|
36
|
+
expiresIn?: number;
|
|
37
|
+
};
|
|
38
|
+
accessToken?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface UploadApiResponse {
|
|
42
|
+
code?: number;
|
|
43
|
+
message?: string;
|
|
44
|
+
data?: { media_id?: string; url?: string };
|
|
45
|
+
media_id?: string;
|
|
46
|
+
url?: string;
|
|
47
|
+
errcode?: number;
|
|
48
|
+
errmsg?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface UploadedMedia {
|
|
52
|
+
originalUrl: string;
|
|
53
|
+
mediaId: string;
|
|
54
|
+
wxUrl: string;
|
|
55
|
+
index: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface UploadResult {
|
|
59
|
+
uploadedMedia: UploadedMedia[];
|
|
60
|
+
imageUrlMap: Record<string, string>;
|
|
61
|
+
coverMediaId: string;
|
|
62
|
+
totalUploaded: number;
|
|
63
|
+
photos: string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface WxRuntimeConfig {
|
|
67
|
+
accountName: string;
|
|
68
|
+
baseUrl: string;
|
|
69
|
+
timeout: number;
|
|
70
|
+
pat: string;
|
|
71
|
+
appId: string;
|
|
72
|
+
appSecret: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface BaseWechatPublishInput {
|
|
76
|
+
account?: string;
|
|
77
|
+
title: string;
|
|
78
|
+
photos?: string[];
|
|
79
|
+
timeout?: number;
|
|
80
|
+
config: PipelineConfig;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface WechatDraftInput extends BaseWechatPublishInput {
|
|
84
|
+
html: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface WechatNewspicInput extends BaseWechatPublishInput {
|
|
88
|
+
content: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function requireValue(name: string, value: string | undefined): string {
|
|
92
|
+
if (!value) {
|
|
93
|
+
throw new Error(`Missing required value: ${name}`);
|
|
94
|
+
}
|
|
95
|
+
return value;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function normalizeAccountName(value: string): string {
|
|
99
|
+
const account = value.trim();
|
|
100
|
+
if (!account) {
|
|
101
|
+
throw new Error("wx account cannot be empty");
|
|
102
|
+
}
|
|
103
|
+
if (!/^[a-zA-Z0-9_.-]+$/.test(account)) {
|
|
104
|
+
throw new Error(`invalid wx account: ${value}`);
|
|
105
|
+
}
|
|
106
|
+
return account;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function fillWxAccountConfig(source?: WxAccountConfig): WxAccountConfig {
|
|
110
|
+
return {
|
|
111
|
+
pat: source?.pat ?? "",
|
|
112
|
+
appId: source?.appId ?? "",
|
|
113
|
+
appSecret: source?.appSecret ?? "",
|
|
114
|
+
customCss: source?.customCss ?? null,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function trimTrailingSlash(value: string): string {
|
|
119
|
+
return value.endsWith("/") ? value.slice(0, -1) : value;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getWxRuntimeConfig(
|
|
123
|
+
config: PipelineConfig,
|
|
124
|
+
accountOverride?: string,
|
|
125
|
+
): WxRuntimeConfig {
|
|
126
|
+
const accountName = normalizeAccountName(
|
|
127
|
+
accountOverride || config.wx.defaultAccount,
|
|
128
|
+
);
|
|
129
|
+
const accountConfig = fillWxAccountConfig(config.wx.accounts[accountName]);
|
|
130
|
+
return {
|
|
131
|
+
accountName,
|
|
132
|
+
baseUrl: trimTrailingSlash(process.env.ZZHUB_WX_BASE_URL || config.wx.baseUrl),
|
|
133
|
+
timeout: config.wx.timeout,
|
|
134
|
+
pat: requireValue(
|
|
135
|
+
"ZZCLUB_PAT",
|
|
136
|
+
process.env.ZZCLUB_PAT || accountConfig.pat,
|
|
137
|
+
),
|
|
138
|
+
appId: requireValue(
|
|
139
|
+
"WX_APPID",
|
|
140
|
+
process.env.WX_APPID || accountConfig.appId,
|
|
141
|
+
),
|
|
142
|
+
appSecret: requireValue(
|
|
143
|
+
"WX_APPSECRET",
|
|
144
|
+
process.env.WX_APPSECRET || accountConfig.appSecret,
|
|
145
|
+
),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function resolveTimeout(defaultTimeout: number, configTimeout: number, override?: number): number {
|
|
150
|
+
if (override && Number.isFinite(override) && override > 0) {
|
|
151
|
+
return override;
|
|
152
|
+
}
|
|
153
|
+
if (configTimeout && Number.isFinite(configTimeout) && configTimeout > 0) {
|
|
154
|
+
return configTimeout;
|
|
155
|
+
}
|
|
156
|
+
return defaultTimeout;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function parsePhotos(photos?: string[]): string[] {
|
|
160
|
+
return (photos ?? []).map((item) => item.trim()).filter(Boolean);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function mergePhotoLists(...lists: string[][]): string[] {
|
|
164
|
+
const seen = new Set<string>();
|
|
165
|
+
const merged: string[] = [];
|
|
166
|
+
for (const list of lists) {
|
|
167
|
+
for (const item of list) {
|
|
168
|
+
const normalized = item.trim();
|
|
169
|
+
if (!normalized || seen.has(normalized)) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
seen.add(normalized);
|
|
173
|
+
merged.push(normalized);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return merged;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function extractImageUrls(input: string): string[] {
|
|
180
|
+
const urls = new Set<string>();
|
|
181
|
+
const markdownImageRegex = /!\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
|
|
182
|
+
const htmlImageRegex = /<img[^>]+src=["']([^"']+)["'][^>]*>/g;
|
|
183
|
+
let match = markdownImageRegex.exec(input);
|
|
184
|
+
while (match) {
|
|
185
|
+
if (match[1]) {
|
|
186
|
+
urls.add(match[1]);
|
|
187
|
+
}
|
|
188
|
+
match = markdownImageRegex.exec(input);
|
|
189
|
+
}
|
|
190
|
+
match = htmlImageRegex.exec(input);
|
|
191
|
+
while (match) {
|
|
192
|
+
if (match[1]) {
|
|
193
|
+
urls.add(match[1]);
|
|
194
|
+
}
|
|
195
|
+
match = htmlImageRegex.exec(input);
|
|
196
|
+
}
|
|
197
|
+
return [...urls];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function replaceImageUrls(html: string, imageUrlMap: Record<string, string>): string {
|
|
201
|
+
let replaced = html;
|
|
202
|
+
for (const [orig, wx] of Object.entries(imageUrlMap)) {
|
|
203
|
+
if (!orig || !wx) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const escaped = orig.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&");
|
|
207
|
+
replaced = replaced.replace(new RegExp(escaped, "g"), wx);
|
|
208
|
+
}
|
|
209
|
+
return replaced;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function requestJson(
|
|
213
|
+
url: string,
|
|
214
|
+
options: { method: string; headers?: Record<string, string>; body?: unknown },
|
|
215
|
+
timeout: number,
|
|
216
|
+
): Promise<unknown> {
|
|
217
|
+
const controller = new AbortController();
|
|
218
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
219
|
+
try {
|
|
220
|
+
const response = await fetch(url, {
|
|
221
|
+
method: options.method,
|
|
222
|
+
headers: options.headers,
|
|
223
|
+
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
224
|
+
signal: controller.signal,
|
|
225
|
+
});
|
|
226
|
+
const responseText = await response.text();
|
|
227
|
+
if (!response.ok) {
|
|
228
|
+
throw new Error(`API request failed with status ${response.status}: ${responseText}`);
|
|
229
|
+
}
|
|
230
|
+
return responseText ? JSON.parse(responseText) : {};
|
|
231
|
+
} finally {
|
|
232
|
+
clearTimeout(timeoutId);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function requestFormData(
|
|
237
|
+
url: string,
|
|
238
|
+
options: { method: string; headers?: Record<string, string>; body: FormData },
|
|
239
|
+
timeout: number,
|
|
240
|
+
): Promise<unknown> {
|
|
241
|
+
const controller = new AbortController();
|
|
242
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
243
|
+
try {
|
|
244
|
+
const response = await fetch(url, {
|
|
245
|
+
method: options.method,
|
|
246
|
+
headers: options.headers,
|
|
247
|
+
body: options.body,
|
|
248
|
+
signal: controller.signal,
|
|
249
|
+
});
|
|
250
|
+
const responseText = await response.text();
|
|
251
|
+
if (!response.ok) {
|
|
252
|
+
throw new Error(`API request failed with status ${response.status}: ${responseText}`);
|
|
253
|
+
}
|
|
254
|
+
return responseText ? JSON.parse(responseText) : {};
|
|
255
|
+
} finally {
|
|
256
|
+
clearTimeout(timeoutId);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function sleep(ms: number): Promise<void> {
|
|
261
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function isRetryableTokenFetchError(error: unknown): boolean {
|
|
265
|
+
return error instanceof Error && error.message.toLowerCase().includes("fetch failed");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function requestToken(
|
|
269
|
+
runtime: WxRuntimeConfig,
|
|
270
|
+
timeout: number,
|
|
271
|
+
): Promise<TokenResponse> {
|
|
272
|
+
let lastError: unknown;
|
|
273
|
+
|
|
274
|
+
for (let attempt = 1; attempt <= TOKEN_FETCH_RETRY_MAX_ATTEMPTS; attempt += 1) {
|
|
275
|
+
try {
|
|
276
|
+
const response = await requestJson(
|
|
277
|
+
`${runtime.baseUrl}${TOKEN_PATH}`,
|
|
278
|
+
{
|
|
279
|
+
method: "POST",
|
|
280
|
+
headers: {
|
|
281
|
+
"Content-Type": "application/json",
|
|
282
|
+
Authorization: `Bearer ${runtime.pat}`,
|
|
283
|
+
},
|
|
284
|
+
body: { appId: runtime.appId, appSecret: runtime.appSecret },
|
|
285
|
+
},
|
|
286
|
+
timeout,
|
|
287
|
+
);
|
|
288
|
+
return response as TokenResponse;
|
|
289
|
+
} catch (error) {
|
|
290
|
+
lastError = error;
|
|
291
|
+
if (!isRetryableTokenFetchError(error) || attempt >= TOKEN_FETCH_RETRY_MAX_ATTEMPTS) {
|
|
292
|
+
throw error;
|
|
293
|
+
}
|
|
294
|
+
await sleep(TOKEN_FETCH_RETRY_DELAY_MS * attempt);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
throw lastError instanceof Error ? lastError : new Error("failed to fetch access_token");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function fetchAccessToken(runtime: WxRuntimeConfig, timeout: number): Promise<string> {
|
|
302
|
+
const response = await requestToken(runtime, timeout);
|
|
303
|
+
const accessToken = response.data?.accessToken || response.accessToken;
|
|
304
|
+
if (!accessToken) {
|
|
305
|
+
throw new Error("Access token not found in wx token response");
|
|
306
|
+
}
|
|
307
|
+
return accessToken;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function resolvePhotoPayload(
|
|
311
|
+
photoUrl: string,
|
|
312
|
+
index: number,
|
|
313
|
+
): Promise<{ blob: Blob; filename: string }> {
|
|
314
|
+
if (photoUrl.startsWith("data:")) {
|
|
315
|
+
const matches = photoUrl.match(/^data:([^;]+);base64,(.+)$/);
|
|
316
|
+
if (!matches) {
|
|
317
|
+
throw new Error("Invalid data URL format");
|
|
318
|
+
}
|
|
319
|
+
const contentType = matches[1] || "image/png";
|
|
320
|
+
const buffer = Buffer.from(matches[2] || "", "base64");
|
|
321
|
+
const extension = IMAGE_EXTENSIONS.get(contentType.toLowerCase()) || "png";
|
|
322
|
+
return {
|
|
323
|
+
blob: new Blob([buffer], { type: contentType }),
|
|
324
|
+
filename: `image_${index + 1}.${extension}`,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (photoUrl.startsWith("http://") || photoUrl.startsWith("https://")) {
|
|
329
|
+
const response = await fetch(photoUrl);
|
|
330
|
+
if (!response.ok) {
|
|
331
|
+
throw new Error(`Failed to download image: HTTP ${response.status}`);
|
|
332
|
+
}
|
|
333
|
+
const contentType = response.headers.get("content-type") || "image/jpeg";
|
|
334
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
335
|
+
return {
|
|
336
|
+
blob: new Blob([buffer], { type: contentType }),
|
|
337
|
+
filename: resolveFilenameFromUrl(photoUrl, contentType, index),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (photoUrl.startsWith("file://")) {
|
|
342
|
+
const url = new URL(photoUrl);
|
|
343
|
+
return resolveFilePayload(url.pathname, index);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return resolveFilePayload(photoUrl, index);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function resolveFilePayload(
|
|
350
|
+
filePath: string,
|
|
351
|
+
index: number,
|
|
352
|
+
): { blob: Blob; filename: string } {
|
|
353
|
+
if (!existsSync(filePath)) {
|
|
354
|
+
throw new Error(`File not found: ${filePath}`);
|
|
355
|
+
}
|
|
356
|
+
const buffer = readFileSync(filePath);
|
|
357
|
+
const ext = extname(filePath).toLowerCase();
|
|
358
|
+
const contentType = EXTENSION_CONTENT_TYPES.get(ext) || "image/jpeg";
|
|
359
|
+
const extension = IMAGE_EXTENSIONS.get(contentType.toLowerCase()) || ext.replace(".", "") || "jpg";
|
|
360
|
+
const baseName = basename(filePath, ext) || `image_${index + 1}`;
|
|
361
|
+
return {
|
|
362
|
+
blob: new Blob([buffer], { type: contentType }),
|
|
363
|
+
filename: ext ? `${baseName}${ext}` : `${baseName}.${extension}`,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function resolveFilenameFromUrl(url: string, contentType: string, index: number): string {
|
|
368
|
+
const urlParts = url.split("/");
|
|
369
|
+
let filename = urlParts[urlParts.length - 1] || `image_${index + 1}`;
|
|
370
|
+
filename = filename.split("?")[0] || filename;
|
|
371
|
+
const expectedExt = IMAGE_EXTENSIONS.get(contentType.toLowerCase()) || "jpg";
|
|
372
|
+
const hasExtension = /\.(jpg|jpeg|png|gif|webp|bmp)$/i.test(filename);
|
|
373
|
+
|
|
374
|
+
if (!hasExtension) {
|
|
375
|
+
return `${filename}.${expectedExt}`;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const urlExt = filename.split(".").pop()?.toLowerCase();
|
|
379
|
+
if (urlExt && urlExt !== expectedExt) {
|
|
380
|
+
return filename.replace(/\.[^.]+$/, `.${expectedExt}`);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return filename;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function uploadPhotos(
|
|
387
|
+
runtime: WxRuntimeConfig,
|
|
388
|
+
accessToken: string,
|
|
389
|
+
photos: string[],
|
|
390
|
+
timeout: number,
|
|
391
|
+
): Promise<UploadResult> {
|
|
392
|
+
const uploadedMedia: UploadedMedia[] = [];
|
|
393
|
+
const imageUrlMap: Record<string, string> = {};
|
|
394
|
+
|
|
395
|
+
for (let index = 0; index < photos.length; index += 1) {
|
|
396
|
+
const photoUrl = photos[index];
|
|
397
|
+
if (!photoUrl) {
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const payload = await resolvePhotoPayload(photoUrl, index);
|
|
402
|
+
const formData = new FormData();
|
|
403
|
+
formData.append("access_token", accessToken);
|
|
404
|
+
formData.append("type", "image");
|
|
405
|
+
formData.append("media", payload.blob, payload.filename);
|
|
406
|
+
|
|
407
|
+
const response = await requestFormData(
|
|
408
|
+
`${runtime.baseUrl}${MATERIAL_PATH}`,
|
|
409
|
+
{
|
|
410
|
+
method: "POST",
|
|
411
|
+
headers: { Authorization: `Bearer ${runtime.pat}` },
|
|
412
|
+
body: formData,
|
|
413
|
+
},
|
|
414
|
+
timeout,
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
const result = response as UploadApiResponse;
|
|
418
|
+
if (result.errcode && result.errcode !== 0) {
|
|
419
|
+
throw new Error(`WeChat API error: ${result.errcode} - ${result.errmsg || "Unknown"}`);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const mediaId = result.data?.media_id || result.media_id;
|
|
423
|
+
const wxUrl = result.data?.url || result.url || "";
|
|
424
|
+
if (!mediaId) {
|
|
425
|
+
throw new Error(`Upload API error: No media_id in response. Raw: ${JSON.stringify(result)}`);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
uploadedMedia.push({
|
|
429
|
+
originalUrl: photoUrl,
|
|
430
|
+
mediaId,
|
|
431
|
+
wxUrl,
|
|
432
|
+
index,
|
|
433
|
+
});
|
|
434
|
+
imageUrlMap[photoUrl] = wxUrl || photoUrl;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return {
|
|
438
|
+
uploadedMedia,
|
|
439
|
+
imageUrlMap,
|
|
440
|
+
coverMediaId: uploadedMedia[0]?.mediaId || "",
|
|
441
|
+
totalUploaded: uploadedMedia.length,
|
|
442
|
+
photos: photos.map((url) => imageUrlMap[url] || url),
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export async function createWechatDraft(input: WechatDraftInput): Promise<Record<string, unknown>> {
|
|
447
|
+
const runtime = getWxRuntimeConfig(input.config, input.account);
|
|
448
|
+
const fallbackPhotos = extractImageUrls(input.html);
|
|
449
|
+
const photos = parsePhotos(input.photos);
|
|
450
|
+
const finalPhotos = mergePhotoLists(photos, fallbackPhotos);
|
|
451
|
+
if (finalPhotos.length === 0) {
|
|
452
|
+
throw new Error("No photos available for wx draft upload");
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const tokenTimeout = resolveTimeout(TOKEN_TIMEOUT, runtime.timeout, input.timeout);
|
|
456
|
+
const uploadTimeout = resolveTimeout(UPLOAD_TIMEOUT, runtime.timeout, input.timeout);
|
|
457
|
+
const draftTimeout = resolveTimeout(DRAFT_TIMEOUT, runtime.timeout, input.timeout);
|
|
458
|
+
const accessToken = await fetchAccessToken(runtime, tokenTimeout);
|
|
459
|
+
const uploadResult = await uploadPhotos(runtime, accessToken, finalPhotos, uploadTimeout);
|
|
460
|
+
const replacedHtml = replaceImageUrls(input.html, uploadResult.imageUrlMap);
|
|
461
|
+
|
|
462
|
+
const response = await requestJson(
|
|
463
|
+
`${runtime.baseUrl}${DRAFT_PATH}`,
|
|
464
|
+
{
|
|
465
|
+
method: "POST",
|
|
466
|
+
headers: {
|
|
467
|
+
"Content-Type": "application/json",
|
|
468
|
+
Authorization: `Bearer ${runtime.pat}`,
|
|
469
|
+
},
|
|
470
|
+
body: {
|
|
471
|
+
access_token: accessToken,
|
|
472
|
+
articles: [
|
|
473
|
+
{
|
|
474
|
+
article_type: "news",
|
|
475
|
+
title: input.title,
|
|
476
|
+
content: replacedHtml,
|
|
477
|
+
thumb_media_id: uploadResult.coverMediaId,
|
|
478
|
+
},
|
|
479
|
+
],
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
draftTimeout,
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
return {
|
|
486
|
+
account: runtime.accountName,
|
|
487
|
+
articleType: "news",
|
|
488
|
+
title: input.title,
|
|
489
|
+
photosCount: finalPhotos.length,
|
|
490
|
+
totalUploaded: uploadResult.totalUploaded,
|
|
491
|
+
coverMediaId: uploadResult.coverMediaId,
|
|
492
|
+
response,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export async function createWechatNewspic(input: WechatNewspicInput): Promise<Record<string, unknown>> {
|
|
497
|
+
const runtime = getWxRuntimeConfig(input.config, input.account);
|
|
498
|
+
const fallbackPhotos = extractImageUrls(input.content);
|
|
499
|
+
const photos = parsePhotos(input.photos);
|
|
500
|
+
const finalPhotos = mergePhotoLists(photos, fallbackPhotos);
|
|
501
|
+
if (finalPhotos.length === 0) {
|
|
502
|
+
throw new Error("No photos available for wx newspic upload");
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const tokenTimeout = resolveTimeout(TOKEN_TIMEOUT, runtime.timeout, input.timeout);
|
|
506
|
+
const uploadTimeout = resolveTimeout(UPLOAD_TIMEOUT, runtime.timeout, input.timeout);
|
|
507
|
+
const draftTimeout = resolveTimeout(DRAFT_TIMEOUT, runtime.timeout, input.timeout);
|
|
508
|
+
const accessToken = await fetchAccessToken(runtime, tokenTimeout);
|
|
509
|
+
const uploadResult = await uploadPhotos(runtime, accessToken, finalPhotos, uploadTimeout);
|
|
510
|
+
|
|
511
|
+
const imageInfo = {
|
|
512
|
+
image_list: uploadResult.uploadedMedia.map((item) => ({ image_media_id: item.mediaId })),
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
const response = await requestJson(
|
|
516
|
+
`${runtime.baseUrl}${DRAFT_PATH}`,
|
|
517
|
+
{
|
|
518
|
+
method: "POST",
|
|
519
|
+
headers: {
|
|
520
|
+
"Content-Type": "application/json",
|
|
521
|
+
Authorization: `Bearer ${runtime.pat}`,
|
|
522
|
+
},
|
|
523
|
+
body: {
|
|
524
|
+
access_token: accessToken,
|
|
525
|
+
articles: [
|
|
526
|
+
{
|
|
527
|
+
article_type: "newspic",
|
|
528
|
+
title: input.title,
|
|
529
|
+
content: input.content,
|
|
530
|
+
thumb_media_id: uploadResult.coverMediaId,
|
|
531
|
+
image_info: imageInfo,
|
|
532
|
+
},
|
|
533
|
+
],
|
|
534
|
+
},
|
|
535
|
+
},
|
|
536
|
+
draftTimeout,
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
return {
|
|
540
|
+
account: runtime.accountName,
|
|
541
|
+
articleType: "newspic",
|
|
542
|
+
title: input.title,
|
|
543
|
+
photosCount: finalPhotos.length,
|
|
544
|
+
totalUploaded: uploadResult.totalUploaded,
|
|
545
|
+
coverMediaId: uploadResult.coverMediaId,
|
|
546
|
+
response,
|
|
547
|
+
};
|
|
548
|
+
}
|