fix: 正文图片上传使用 media/uploadimg 接口

- 区分正文图片和封面图片的上传接口
- 正文图片使用 media/uploadimg (返回 URL)
- 封面图片使用 material/add_material (返回 media_id)
- 添加 uploadType 参数支持两种上传方式
- 优化错误提示,告知用户 news 类型需要封面图
This commit is contained in:
浪不能停 2026-03-18 17:23:48 +08:00
parent ea84f21439
commit e79a42fd94
1 changed files with 51 additions and 34 deletions

View File

@ -52,7 +52,8 @@ interface ArticleOptions {
} }
const TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token"; const TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token";
const UPLOAD_URL = "https://api.weixin.qq.com/cgi-bin/material/add_material"; 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"; const DRAFT_URL = "https://api.weixin.qq.com/cgi-bin/draft/add";
@ -75,7 +76,8 @@ async function fetchAccessToken(appId: string, appSecret: string): Promise<strin
async function uploadImage( async function uploadImage(
imagePath: string, imagePath: string,
accessToken: string, accessToken: string,
baseDir?: string baseDir?: string,
uploadType: "body" | "material" = "body"
): Promise<UploadResponse> { ): Promise<UploadResponse> {
let fileBuffer: Buffer; let fileBuffer: Buffer;
let filename: string; let filename: string;
@ -133,7 +135,9 @@ async function uploadImage(
const footerBuffer = Buffer.from(footer, "utf-8"); const footerBuffer = Buffer.from(footer, "utf-8");
const body = Buffer.concat([headerBuffer, fileBuffer, footerBuffer]); const body = Buffer.concat([headerBuffer, fileBuffer, footerBuffer]);
const url = `${UPLOAD_URL}?access_token=${accessToken}&type=image`; // 根据上传类型选择不同的接口
const uploadUrl = uploadType === "body" ? UPLOAD_BODY_IMG_URL : UPLOAD_MATERIAL_URL;
const url = `${uploadUrl}?type=image&access_token=${accessToken}`;
const res = await fetch(url, { const res = await fetch(url, {
method: "POST", method: "POST",
headers: { headers: {
@ -147,27 +151,39 @@ async function uploadImage(
throw new Error(`Upload failed ${data.errcode}: ${data.errmsg}`); 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://")) { if (data.url?.startsWith("http://")) {
data.url = data.url.replace(/^http:\/\//i, "https://"); data.url = data.url.replace(/^http:\/\//i, "https://");
} }
return data; return data;
} }
}
async function uploadImagesInHtml( async function uploadImagesInHtml(
html: string, html: string,
accessToken: string, accessToken: string,
baseDir: string, baseDir: string,
contentImages: ImageInfo[] = [], contentImages: ImageInfo[] = [],
): Promise<{ html: string; firstMediaId: string; allMediaIds: string[] }> { ): Promise<{ html: string; firstImageUrl: string; allMediaIds: string[] }> {
const imgRegex = /<img[^>]*\ssrc=["']([^"']+)["'][^>]*>/gi; const imgRegex = /<img[^>]*\ssrc=["']([^"']+)["'][^>]*>/gi;
const matches = [...html.matchAll(imgRegex)]; const matches = [...html.matchAll(imgRegex)];
if (matches.length === 0 && contentImages.length === 0) { if (matches.length === 0 && contentImages.length === 0) {
return { html, firstMediaId: "", allMediaIds: [] }; return { html, firstImageUrl: "", allMediaIds: [] };
} }
let firstMediaId = ""; let firstImageUrl = "";
let updatedHtml = html; let updatedHtml = html;
const allMediaIds: string[] = []; const allMediaIds: string[] = [];
const uploadedBySource = new Map<string, UploadResponse>(); const uploadedBySource = new Map<string, UploadResponse>();
@ -177,8 +193,8 @@ async function uploadImagesInHtml(
if (!src) continue; if (!src) continue;
if (src.startsWith("https://mmbiz.qpic.cn")) { if (src.startsWith("https://mmbiz.qpic.cn")) {
if (!firstMediaId) { if (!firstImageUrl) {
firstMediaId = src; firstImageUrl = src;
} }
continue; continue;
} }
@ -186,20 +202,20 @@ async function uploadImagesInHtml(
const localPathMatch = fullTag.match(/data-local-path=["']([^"']+)["']/); const localPathMatch = fullTag.match(/data-local-path=["']([^"']+)["']/);
const imagePath = localPathMatch ? localPathMatch[1]! : src; const imagePath = localPathMatch ? localPathMatch[1]! : src;
console.error(`[wechat-api] Uploading image: ${imagePath}`); console.error(`[wechat-api] Uploading body image: ${imagePath}`);
try { try {
let resp = uploadedBySource.get(imagePath); let resp = uploadedBySource.get(imagePath);
if (!resp) { if (!resp) {
resp = await uploadImage(imagePath, accessToken, baseDir); // 正文图片使用 media/uploadimg 接口
resp = await uploadImage(imagePath, accessToken, baseDir, "body");
uploadedBySource.set(imagePath, resp); uploadedBySource.set(imagePath, resp);
} }
const newTag = fullTag const newTag = fullTag
.replace(/\ssrc=["'][^"']+["']/, ` src="${resp.url}"`) .replace(/\ssrc=["'][^"']+["']/, ` src="${resp.url}"`)
.replace(/\sdata-local-path=["'][^"']+["']/, ""); .replace(/\sdata-local-path=["'][^"']+["']/, "");
updatedHtml = updatedHtml.replace(fullTag, newTag); updatedHtml = updatedHtml.replace(fullTag, newTag);
allMediaIds.push(resp.media_id); if (!firstImageUrl) {
if (!firstMediaId) { firstImageUrl = resp.url;
firstMediaId = resp.media_id;
} }
} catch (err) { } catch (err) {
console.error(`[wechat-api] Failed to upload ${imagePath}:`, err); console.error(`[wechat-api] Failed to upload ${imagePath}:`, err);
@ -210,27 +226,27 @@ async function uploadImagesInHtml(
if (!updatedHtml.includes(image.placeholder)) continue; if (!updatedHtml.includes(image.placeholder)) continue;
const imagePath = image.localPath || image.originalPath; const imagePath = image.localPath || image.originalPath;
console.error(`[wechat-api] Uploading placeholder image: ${imagePath}`); console.error(`[wechat-api] Uploading body image: ${imagePath}`);
try { try {
let resp = uploadedBySource.get(imagePath); let resp = uploadedBySource.get(imagePath);
if (!resp) { if (!resp) {
resp = await uploadImage(imagePath, accessToken, baseDir); // 正文图片使用 media/uploadimg 接口
resp = await uploadImage(imagePath, accessToken, baseDir, "body");
uploadedBySource.set(imagePath, resp); uploadedBySource.set(imagePath, resp);
} }
const replacementTag = `<img src="${resp.url}" style="display: block; width: 100%; margin: 1.5em auto;">`; const replacementTag = `<img src="${resp.url}" style="display: block; width: 100%; margin: 1.5em auto;">`;
updatedHtml = replaceAllPlaceholders(updatedHtml, image.placeholder, replacementTag); updatedHtml = replaceAllPlaceholders(updatedHtml, image.placeholder, replacementTag);
allMediaIds.push(resp.media_id); if (!firstImageUrl) {
if (!firstMediaId) { firstImageUrl = resp.url;
firstMediaId = resp.media_id;
} }
} catch (err) { } catch (err) {
console.error(`[wechat-api] Failed to upload placeholder ${image.placeholder}:`, err); console.error(`[wechat-api] Failed to upload placeholder ${image.placeholder}:`, err);
} }
} }
return { html: updatedHtml, firstMediaId, allMediaIds }; return { html: updatedHtml, firstImageUrl, allMediaIds };
} }
async function publishToDraft( async function publishToDraft(
@ -592,8 +608,8 @@ async function main(): Promise<void> {
console.error("[wechat-api] Fetching access token..."); console.error("[wechat-api] Fetching access token...");
const accessToken = await fetchAccessToken(creds.appId, creds.appSecret); const accessToken = await fetchAccessToken(creds.appId, creds.appSecret);
console.error("[wechat-api] Uploading images..."); console.error("[wechat-api] Uploading body images...");
const { html: processedHtml, firstMediaId, allMediaIds } = await uploadImagesInHtml( const { html: processedHtml, firstImageUrl, allMediaIds } = await uploadImagesInHtml(
htmlContent, htmlContent,
accessToken, accessToken,
baseDir, baseDir,
@ -613,16 +629,17 @@ async function main(): Promise<void> {
if (coverPath) { if (coverPath) {
console.error(`[wechat-api] Uploading cover: ${coverPath}`); console.error(`[wechat-api] Uploading cover: ${coverPath}`);
const coverResp = await uploadImage(coverPath, accessToken, baseDir); // 封面图片使用 material/add_material 接口
const coverResp = await uploadImage(coverPath, accessToken, baseDir, "material");
thumbMediaId = coverResp.media_id; thumbMediaId = coverResp.media_id;
} else if (firstMediaId) { console.error(`[wechat-api] Cover uploaded successfully, media_id: ${thumbMediaId}`);
if (firstMediaId.startsWith("https://")) { } else if (firstImageUrl && args.articleType === "news") {
console.error(`[wechat-api] Uploading first image as cover: ${firstMediaId}`); // news 类型需要 thumb_media_id,正文图片的 URL 无法使用
const coverResp = await uploadImage(firstMediaId, accessToken, baseDir); console.error(`[wechat-api] Warning: No cover image provided for news article.`);
thumbMediaId = coverResp.media_id; console.error(`[wechat-api] The first body image URL is: ${firstImageUrl}`);
} else { console.error(`[wechat-api] However, news articles require thumb_media_id (not URL).`);
thumbMediaId = firstMediaId; console.error(`[wechat-api] Please provide --cover parameter or set coverImage in frontmatter.`);
} process.exit(1);
} }
if (args.articleType === "news" && !thumbMediaId) { if (args.articleType === "news" && !thumbMediaId) {