JimLiu-baoyu-skills/skills/baoyu-post-to-wechat/scripts/wechat-api.ts

712 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { loadWechatExtendConfig, resolveAccount, loadCredentials } from "./wechat-extend-config.ts";
interface AccessTokenResponse {
access_token?: string;
errcode?: number;
errmsg?: string;
}
interface UploadResponse {
media_id: string;
url: string;
errcode?: number;
errmsg?: string;
}
interface PublishResponse {
media_id?: string;
errcode?: number;
errmsg?: string;
}
interface ImageInfo {
placeholder: string;
localPath: string;
originalPath: string;
}
interface MarkdownRenderResult {
title: string;
author: string;
summary: string;
htmlPath: string;
contentImages: ImageInfo[];
}
type ArticleType = "news" | "newspic";
interface ArticleOptions {
title: string;
author?: string;
digest?: string;
content: string;
thumbMediaId: string;
articleType: ArticleType;
imageMediaIds?: string[];
needOpenComment?: number;
onlyFansCanComment?: number;
}
const TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token";
const UPLOAD_BODY_IMG_URL = "https://api.weixin.qq.com/cgi-bin/media/uploadimg";
const UPLOAD_MATERIAL_URL = "https://api.weixin.qq.com/cgi-bin/material/add_material";
const DRAFT_URL = "https://api.weixin.qq.com/cgi-bin/draft/add";
async function fetchAccessToken(appId: string, appSecret: string): Promise<string> {
const url = `${TOKEN_URL}?grant_type=client_credential&appid=${appId}&secret=${appSecret}`;
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to fetch access token: ${res.status}`);
}
const data = await res.json() as AccessTokenResponse;
if (data.errcode) {
throw new Error(`Access token error ${data.errcode}: ${data.errmsg}`);
}
if (!data.access_token) {
throw new Error("No access_token in response");
}
return data.access_token;
}
async function uploadImage(
imagePath: string,
accessToken: string,
baseDir?: string,
uploadType: "body" | "material" = "body"
): Promise<UploadResponse> {
let fileBuffer: Buffer;
let filename: string;
let contentType: string;
if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
const response = await fetch(imagePath);
if (!response.ok) {
throw new Error(`Failed to download image: ${imagePath}`);
}
const buffer = await response.arrayBuffer();
if (buffer.byteLength === 0) {
throw new Error(`Remote image is empty: ${imagePath}`);
}
fileBuffer = Buffer.from(buffer);
const urlPath = imagePath.split("?")[0];
filename = path.basename(urlPath) || "image.jpg";
contentType = response.headers.get("content-type") || "image/jpeg";
} else {
const resolvedPath = path.isAbsolute(imagePath)
? imagePath
: path.resolve(baseDir || process.cwd(), imagePath);
if (!fs.existsSync(resolvedPath)) {
throw new Error(`Image not found: ${resolvedPath}`);
}
const stats = fs.statSync(resolvedPath);
if (stats.size === 0) {
throw new Error(`Local image is empty: ${resolvedPath}`);
}
fileBuffer = fs.readFileSync(resolvedPath);
filename = path.basename(resolvedPath);
const ext = path.extname(filename).toLowerCase();
const mimeTypes: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
};
contentType = mimeTypes[ext] || "image/jpeg";
}
const boundary = `----WebKitFormBoundary${Date.now().toString(16)}`;
const header = [
`--${boundary}`,
`Content-Disposition: form-data; name="media"; filename="${filename}"`,
`Content-Type: ${contentType}`,
"",
"",
].join("\r\n");
const footer = `\r\n--${boundary}--\r\n`;
const headerBuffer = Buffer.from(header, "utf-8");
const footerBuffer = Buffer.from(footer, "utf-8");
const body = Buffer.concat([headerBuffer, fileBuffer, footerBuffer]);
// 根据上传类型选择不同的接口
const uploadUrl = uploadType === "body" ? UPLOAD_BODY_IMG_URL : UPLOAD_MATERIAL_URL;
const url = `${uploadUrl}?type=image&access_token=${accessToken}`;
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": `multipart/form-data; boundary=${boundary}`,
},
body,
});
const data = await res.json() as UploadResponse;
if (data.errcode && data.errcode !== 0) {
throw new Error(`Upload failed ${data.errcode}: ${data.errmsg}`);
}
// media/uploadimg 接口只返回 URLmaterial/add_material 返回 media_id
if (uploadType === "body") {
// 正文图片上传,返回 URL
if (data.url?.startsWith("http://")) {
data.url = data.url.replace(/^http:\/\//i, "https://");
}
return {
url: data.url,
media_id: "",
} as UploadResponse;
} else {
// 封面图片上传,返回 media_id
if (data.url?.startsWith("http://")) {
data.url = data.url.replace(/^http:\/\//i, "https://");
}
return data;
}
}
async function uploadImagesInHtml(
html: string,
accessToken: string,
baseDir: string,
contentImages: ImageInfo[] = [],
articleType: ArticleType = "news",
): Promise<{ html: string; firstImageUrl: string; firstImageSource: string; imageMediaIds: string[] }> {
const imgRegex = /<img[^>]*\ssrc=["']([^"']+)["'][^>]*>/gi;
const matches = [...html.matchAll(imgRegex)];
if (matches.length === 0 && contentImages.length === 0) {
return { html, firstImageUrl: "", firstImageSource: "", imageMediaIds: [] };
}
let firstImageUrl = "";
let firstImageSource = "";
let updatedHtml = html;
const imageMediaIds: string[] = [];
const uploadedBySource = new Map<string, UploadResponse>();
for (const match of matches) {
const [fullTag, src] = match;
if (!src) continue;
if (src.startsWith("https://mmbiz.qpic.cn")) {
if (!firstImageUrl) {
firstImageUrl = src;
}
continue;
}
const localPathMatch = fullTag.match(/data-local-path=["']([^"']+)["']/);
const imagePath = localPathMatch ? localPathMatch[1]! : src;
console.error(`[wechat-api] Uploading body image: ${imagePath}`);
try {
let resp = uploadedBySource.get(imagePath);
if (!resp) {
// 正文图片使用 media/uploadimg 接口获取 URL
resp = await uploadImage(imagePath, accessToken, baseDir, "body");
uploadedBySource.set(imagePath, resp);
}
const newTag = fullTag
.replace(/\ssrc=["'][^"']+["']/, ` src="${resp.url}"`)
.replace(/\sdata-local-path=["'][^"']+["']/, "");
updatedHtml = updatedHtml.replace(fullTag, newTag);
if (!firstImageUrl) {
firstImageUrl = resp.url;
}
// 如果是 newspic 类型,额外调用 material 接口收集 media_id
if (articleType === "newspic") {
let materialResp = uploadedBySource.get(`${imagePath}:material`);
if (!materialResp) {
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material");
uploadedBySource.set(`${imagePath}:material`, materialResp);
}
if (materialResp.media_id) {
imageMediaIds.push(materialResp.media_id);
if (!firstImageSource) {
firstImageSource = materialResp.media_id;
}
}
}
} catch (err) {
console.error(`[wechat-api] Failed to upload ${imagePath}:`, err);
}
}
for (const image of contentImages) {
if (!updatedHtml.includes(image.placeholder)) continue;
const imagePath = image.localPath || image.originalPath;
console.error(`[wechat-api] Uploading body image: ${imagePath}`);
try {
let resp = uploadedBySource.get(imagePath);
if (!resp) {
// 正文图片使用 media/uploadimg 接口获取 URL
resp = await uploadImage(imagePath, accessToken, baseDir, "body");
uploadedBySource.set(imagePath, resp);
}
const replacementTag = `<img src="${resp.url}" style="display: block; width: 100%; margin: 1.5em auto;">`;
updatedHtml = replaceAllPlaceholders(updatedHtml, image.placeholder, replacementTag);
if (!firstImageUrl) {
firstImageUrl = resp.url;
}
// 如果是 newspic 类型,额外调用 material 接口收集 media_id
if (articleType === "newspic") {
let materialResp = uploadedBySource.get(`${imagePath}:material`);
if (!materialResp) {
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material");
uploadedBySource.set(`${imagePath}:material`, materialResp);
}
if (materialResp.media_id) {
imageMediaIds.push(materialResp.media_id);
if (!firstImageSource) {
firstImageSource = materialResp.media_id;
}
}
}
} catch (err) {
console.error(`[wechat-api] Failed to upload placeholder ${image.placeholder}:`, err);
}
}
return { html: updatedHtml, firstImageUrl, firstImageSource, imageMediaIds };
}
async function publishToDraft(
options: ArticleOptions,
accessToken: string
): Promise<PublishResponse> {
const url = `${DRAFT_URL}?access_token=${accessToken}`;
let article: Record<string, unknown>;
const noc = options.needOpenComment ?? 1;
const ofcc = options.onlyFansCanComment ?? 0;
if (options.articleType === "newspic") {
if (!options.imageMediaIds || options.imageMediaIds.length === 0) {
throw new Error("newspic requires at least one image");
}
article = {
article_type: "newspic",
title: options.title,
content: options.content,
need_open_comment: noc,
only_fans_can_comment: ofcc,
image_info: {
image_list: options.imageMediaIds.map(id => ({ image_media_id: id })),
},
};
if (options.author) article.author = options.author;
} else {
article = {
article_type: "news",
title: options.title,
content: options.content,
thumb_media_id: options.thumbMediaId,
need_open_comment: noc,
only_fans_can_comment: ofcc,
};
if (options.author) article.author = options.author;
if (options.digest) article.digest = options.digest;
}
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ articles: [article] }),
});
const data = await res.json() as PublishResponse;
if (data.errcode && data.errcode !== 0) {
throw new Error(`Publish failed ${data.errcode}: ${data.errmsg}`);
}
return data;
}
function parseFrontmatter(content: string): { frontmatter: Record<string, string>; body: string } {
const match = content.match(/^\s*---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!match) return { frontmatter: {}, body: content };
const frontmatter: Record<string, string> = {};
const lines = match[1]!.split("\n");
for (const line of lines) {
const colonIdx = line.indexOf(":");
if (colonIdx > 0) {
const key = line.slice(0, colonIdx).trim();
let value = line.slice(colonIdx + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
frontmatter[key] = value;
}
}
return { frontmatter, body: match[2]! };
}
function renderMarkdownWithPlaceholders(
markdownPath: string,
theme: string = "default",
color?: string,
citeStatus: boolean = true,
title?: string,
): MarkdownRenderResult {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const mdToWechatScript = path.join(__dirname, "md-to-wechat.ts");
const baseDir = path.dirname(markdownPath);
const args = ["-y", "bun", mdToWechatScript, markdownPath];
if (title) args.push("--title", title);
if (theme) args.push("--theme", theme);
if (color) args.push("--color", color);
if (!citeStatus) args.push("--no-cite");
console.error(`[wechat-api] Rendering markdown with placeholders via md-to-wechat: ${theme}${color ? `, color: ${color}` : ""}, citeStatus: ${citeStatus}`);
const result = spawnSync("npx", args, {
stdio: ["inherit", "pipe", "pipe"],
cwd: baseDir,
});
if (result.status !== 0) {
const stderr = result.stderr?.toString() || "";
throw new Error(`Markdown placeholder render failed: ${stderr}`);
}
const stdout = result.stdout?.toString() || "";
return JSON.parse(stdout) as MarkdownRenderResult;
}
function replaceAllPlaceholders(html: string, placeholder: string, replacement: string): string {
const escapedPlaceholder = placeholder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return html.replace(new RegExp(escapedPlaceholder, "g"), replacement);
}
function extractHtmlContent(htmlPath: string): string {
const html = fs.readFileSync(htmlPath, "utf-8");
const match = html.match(/<div id="output">([\s\S]*?)<\/div>\s*<\/body>/);
if (match) {
return match[1]!.trim();
}
const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
return bodyMatch ? bodyMatch[1]!.trim() : html;
}
function printUsage(): never {
console.log(`Publish article to WeChat Official Account draft using API
Usage:
npx -y bun wechat-api.ts <file> [options]
Arguments:
file Markdown (.md) or HTML (.html) file
Options:
--type <type> Article type: news (文章, default) or newspic (图文)
--title <title> Override title
--author <name> Author name (max 16 chars)
--summary <text> Article summary/digest (max 128 chars)
--theme <name> Theme name for markdown (default, grace, simple, modern). Default: default
--color <name|hex> Primary color (blue, green, vermilion, etc. or hex)
--cover <path> Cover image path (local or URL)
--account <alias> Select account by alias (for multi-account setups)
--no-cite Disable bottom citations for ordinary external links in markdown mode
--dry-run Parse and render only, don't publish
--help Show this help
Frontmatter Fields (markdown):
title Article title
author Author name
digest/summary Article summary
coverImage/featureImage/cover/image Cover image path
Comments:
Comments are enabled by default, open to all users.
Environment Variables:
WECHAT_APP_ID WeChat App ID
WECHAT_APP_SECRET WeChat App Secret
Config File Locations (in priority order):
1. Environment variables
2. <cwd>/.baoyu-skills/.env
3. ~/.baoyu-skills/.env
Example:
npx -y bun wechat-api.ts article.md
npx -y bun wechat-api.ts article.md --theme grace --cover cover.png
npx -y bun wechat-api.ts article.md --author "Author Name" --summary "Brief intro"
npx -y bun wechat-api.ts article.html --title "My Article"
npx -y bun wechat-api.ts images/ --type newspic --title "Photo Album"
npx -y bun wechat-api.ts article.md --dry-run
npx -y bun wechat-api.ts article.md --no-cite
`);
process.exit(0);
}
interface CliArgs {
filePath: string;
isHtml: boolean;
articleType: ArticleType;
title?: string;
author?: string;
summary?: string;
theme: string;
color?: string;
cover?: string;
account?: string;
citeStatus: boolean;
dryRun: boolean;
}
function parseArgs(argv: string[]): CliArgs {
if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) {
printUsage();
}
const args: CliArgs = {
filePath: "",
isHtml: false,
articleType: "news",
theme: "default",
citeStatus: true,
dryRun: false,
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]!;
if (arg === "--type" && argv[i + 1]) {
const t = argv[++i]!.toLowerCase();
if (t === "news" || t === "newspic") {
args.articleType = t;
}
} else if (arg === "--title" && argv[i + 1]) {
args.title = argv[++i];
} else if (arg === "--author" && argv[i + 1]) {
args.author = argv[++i];
} else if (arg === "--summary" && argv[i + 1]) {
args.summary = argv[++i];
} else if (arg === "--theme" && argv[i + 1]) {
args.theme = argv[++i]!;
} else if (arg === "--color" && argv[i + 1]) {
args.color = argv[++i];
} else if (arg === "--cover" && argv[i + 1]) {
args.cover = argv[++i];
} else if (arg === "--account" && argv[i + 1]) {
args.account = argv[++i];
} else if (arg === "--cite") {
args.citeStatus = true;
} else if (arg === "--no-cite") {
args.citeStatus = false;
} else if (arg === "--dry-run") {
args.dryRun = true;
} else if (arg.startsWith("--") && argv[i + 1] && !argv[i + 1]!.startsWith("-")) {
i++;
} else if (!arg.startsWith("-")) {
args.filePath = arg;
}
}
if (!args.filePath) {
console.error("Error: File path required");
process.exit(1);
}
args.isHtml = args.filePath.toLowerCase().endsWith(".html");
return args;
}
function extractHtmlTitle(html: string): string {
const titleMatch = html.match(/<title>([^<]+)<\/title>/i);
if (titleMatch) return titleMatch[1]!;
const h1Match = html.match(/<h1[^>]*>([^<]+)<\/h1>/i);
if (h1Match) return h1Match[1]!.replace(/<[^>]+>/g, "").trim();
return "";
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const filePath = path.resolve(args.filePath);
if (!fs.existsSync(filePath)) {
console.error(`Error: File not found: ${filePath}`);
process.exit(1);
}
const baseDir = path.dirname(filePath);
let title = args.title || "";
let author = args.author || "";
let digest = args.summary || "";
let htmlPath: string;
let htmlContent: string;
let frontmatter: Record<string, string> = {};
let contentImages: ImageInfo[] = [];
if (args.isHtml) {
htmlPath = filePath;
htmlContent = extractHtmlContent(htmlPath);
const mdPath = filePath.replace(/\.html$/i, ".md");
if (fs.existsSync(mdPath)) {
const mdContent = fs.readFileSync(mdPath, "utf-8");
const parsed = parseFrontmatter(mdContent);
frontmatter = parsed.frontmatter;
if (!title && frontmatter.title) title = frontmatter.title;
if (!author) author = frontmatter.author || "";
if (!digest) digest = frontmatter.digest || frontmatter.summary || frontmatter.description || "";
}
if (!title) {
title = extractHtmlTitle(fs.readFileSync(htmlPath, "utf-8"));
}
console.error(`[wechat-api] Using HTML file: ${htmlPath}`);
} else {
const content = fs.readFileSync(filePath, "utf-8");
const parsed = parseFrontmatter(content);
frontmatter = parsed.frontmatter;
const body = parsed.body;
title = title || frontmatter.title || "";
if (!title) {
const h1Match = body.match(/^#\s+(.+)$/m);
if (h1Match) title = h1Match[1]!;
}
if (!author) author = frontmatter.author || "";
if (!digest) digest = frontmatter.digest || frontmatter.summary || frontmatter.description || "";
console.error(`[wechat-api] Theme: ${args.theme}${args.color ? `, color: ${args.color}` : ""}, citeStatus: ${args.citeStatus}`);
const rendered = renderMarkdownWithPlaceholders(filePath, args.theme, args.color, args.citeStatus, args.title);
htmlPath = rendered.htmlPath;
contentImages = rendered.contentImages;
if (!title) title = rendered.title;
if (!author) author = rendered.author;
if (!digest) digest = rendered.summary;
console.error(`[wechat-api] HTML generated: ${htmlPath}`);
console.error(`[wechat-api] Placeholder images: ${contentImages.length}`);
htmlContent = extractHtmlContent(htmlPath);
}
if (!title) {
console.error("Error: No title found. Provide via --title, frontmatter, or <title> tag.");
process.exit(1);
}
if (digest && digest.length > 120) {
const truncated = digest.slice(0, 117);
const lastPunct = Math.max(truncated.lastIndexOf("。"), truncated.lastIndexOf(""), truncated.lastIndexOf(""), truncated.lastIndexOf("、"));
digest = lastPunct > 80 ? truncated.slice(0, lastPunct + 1) : truncated + "...";
console.error(`[wechat-api] Digest truncated to ${digest.length} chars`);
}
console.error(`[wechat-api] Title: ${title}`);
if (author) console.error(`[wechat-api] Author: ${author}`);
if (digest) console.error(`[wechat-api] Digest: ${digest.slice(0, 50)}...`);
console.error(`[wechat-api] Type: ${args.articleType}`);
const extConfig = loadWechatExtendConfig();
const resolved = resolveAccount(extConfig, args.account);
if (resolved.name) console.error(`[wechat-api] Account: ${resolved.name} (${resolved.alias})`);
if (!author && resolved.default_author) author = resolved.default_author;
if (args.dryRun) {
console.log(JSON.stringify({
articleType: args.articleType,
title,
author: author || undefined,
digest: digest || undefined,
htmlPath,
contentLength: htmlContent.length,
placeholderImageCount: contentImages.length || undefined,
account: resolved.alias || undefined,
}, null, 2));
return;
}
const creds = loadCredentials(resolved);
console.error("[wechat-api] Fetching access token...");
const accessToken = await fetchAccessToken(creds.appId, creds.appSecret);
console.error("[wechat-api] Uploading body images...");
const { html: processedHtml, firstImageUrl, firstImageSource, imageMediaIds } = await uploadImagesInHtml(
htmlContent,
accessToken,
baseDir,
contentImages,
args.articleType,
);
htmlContent = processedHtml;
let thumbMediaId = "";
const rawCoverPath = args.cover ||
frontmatter.coverImage ||
frontmatter.featureImage ||
frontmatter.cover ||
frontmatter.image;
const coverPath = rawCoverPath && !path.isAbsolute(rawCoverPath) && args.cover
? path.resolve(process.cwd(), rawCoverPath)
: rawCoverPath;
if (coverPath) {
console.error(`[wechat-api] Uploading cover: ${coverPath}`);
// 封面图片使用 material/add_material 接口
const coverResp = await uploadImage(coverPath, accessToken, baseDir, "material");
thumbMediaId = coverResp.media_id;
console.error(`[wechat-api] Cover uploaded successfully, media_id: ${thumbMediaId}`);
} else if (firstImageSource && args.articleType === "news") {
// news 类型没有封面时,使用第一张正文图的 media_id 作为封面(兜底逻辑)
thumbMediaId = firstImageSource;
console.error(`[wechat-api] Using first body image as cover (fallback), media_id: ${thumbMediaId}`);
}
if (args.articleType === "news" && !thumbMediaId) {
console.error("Error: No cover image. Provide via --cover, frontmatter.coverImage, or include an image in content.");
process.exit(1);
}
if (args.articleType === "newspic" && imageMediaIds.length === 0) {
console.error("Error: newspic requires at least one image in content.");
process.exit(1);
}
console.error("[wechat-api] Publishing to draft...");
const result = await publishToDraft({
title,
author: author || undefined,
digest: digest || undefined,
content: htmlContent,
thumbMediaId,
articleType: args.articleType,
imageMediaIds: args.articleType === "newspic" ? imageMediaIds : undefined,
needOpenComment: resolved.need_open_comment,
onlyFansCanComment: resolved.only_fans_can_comment,
}, accessToken);
console.log(JSON.stringify({
success: true,
media_id: result.media_id,
title,
articleType: args.articleType,
}, null, 2));
console.error(`[wechat-api] Published successfully! media_id: ${result.media_id}`);
}
await main().catch((err) => {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});