Files
blog/write-server/src/lib/wechat.ts
T
2026-06-22 13:01:37 +08:00

216 lines
6.7 KiB
TypeScript

import { WECHAT_APP_ID, WECHAT_APP_SECRET } from "./config";
// ---- Token 管理 ----
interface TokenCache {
token: string;
expiresAt: number;
}
let tokenCache: TokenCache | null = null;
async function getAccessToken(): Promise<string> {
if (tokenCache && Date.now() < tokenCache.expiresAt) {
return tokenCache.token;
}
if (!WECHAT_APP_ID || !WECHAT_APP_SECRET) {
throw new Error("微信 AppID 或 AppSecret 未配置");
}
const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${WECHAT_APP_ID}&secret=${WECHAT_APP_SECRET}`;
const res = await fetch(url);
const data = await res.json() as { access_token?: string; expires_in?: number; errcode?: number; errmsg?: string };
if (data.errcode) {
throw new Error(`获取 access_token 失败: ${data.errmsg} (${data.errcode})`);
}
if (!data.access_token) {
throw new Error("获取 access_token 失败: 返回为空");
}
// 提前 5 分钟过期
tokenCache = {
token: data.access_token,
expiresAt: Date.now() + (data.expires_in! - 300) * 1000,
};
return tokenCache.token;
}
// ---- 状态检查 ----
export async function getWechatStatus(): Promise<{ configured: boolean; appId?: string }> {
return {
configured: !!(WECHAT_APP_ID && WECHAT_APP_SECRET),
appId: WECHAT_APP_ID ? WECHAT_APP_ID.slice(0, 6) + "****" : undefined,
};
}
// ---- 上传封面图片 ----
export async function uploadImage(imageUrl: string): Promise<string> {
const token = await getAccessToken();
// 下载图片
const imgRes = await fetch(imageUrl);
if (!imgRes.ok) throw new Error(`下载图片失败: ${imgRes.status}`);
const imgBuffer = await imgRes.arrayBuffer();
// 从 URL 推断文件名
const urlParts = imageUrl.split("/");
const filename = urlParts[urlParts.length - 1] || "cover.jpg";
// 构造 multipart/form-data
const formData = new FormData();
const blob = new Blob([imgBuffer], { type: "image/jpeg" });
formData.append("media", blob, filename);
const uploadUrl = `https://api.weixin.qq.com/cgi-bin/material/add_material?access_token=${token}&type=image`;
const uploadRes = await fetch(uploadUrl, { method: "POST", body: formData });
const uploadData = await uploadRes.json() as { media_id?: string; url?: string; errcode?: number; errmsg?: string };
if (uploadData.errcode) {
throw new Error(`上传图片失败: ${uploadData.errmsg} (${uploadData.errcode})`);
}
return uploadData.media_id!;
}
// ---- 上传文章内图片(用于正文中引用的图片) ----
export async function uploadContentImage(imageUrl: string): Promise<string> {
const token = await getAccessToken();
const imgRes = await fetch(imageUrl);
if (!imgRes.ok) throw new Error(`下载图片失败: ${imgRes.status}`);
const imgBuffer = await imgRes.arrayBuffer();
const urlParts = imageUrl.split("/");
const filename = urlParts[urlParts.length - 1] || "image.jpg";
const formData = new FormData();
const blob = new Blob([imgBuffer], { type: "image/jpeg" });
formData.append("media", blob, filename);
const uploadUrl = `https://api.weixin.qq.com/cgi-bin/media/uploadimg?access_token=${token}`;
const uploadRes = await fetch(uploadUrl, { method: "POST", body: formData });
const uploadData = await uploadRes.json() as { url?: string; errcode?: number; errmsg?: string };
if (uploadData.errcode) {
throw new Error(`上传内容图片失败: ${uploadData.errmsg} (${uploadData.errcode})`);
}
return uploadData.url!;
}
// ---- 替换文章中的图片为微信 URL ----
async function replaceContentImages(html: string, imageUrls: string[]): Promise<string> {
let result = html;
for (const url of imageUrls) {
try {
const wxUrl = await uploadContentImage(url);
result = result.replaceAll(url, wxUrl);
} catch (e) {
console.warn(`[wechat] 跳过图片 ${url}:`, e instanceof Error ? e.message : String(e));
}
}
return result;
}
// ---- 创建草稿 ----
interface Article {
title: string;
author?: string;
content: string;
content_source_url?: string;
thumb_media_id?: string;
thumb_media_url?: string;
show_cover_pic?: number;
need_open_comment?: number;
content_image_urls?: { original_url: string }[];
}
interface DraftResult {
ok: boolean;
media_id?: string;
message?: string;
}
export async function syncWechat(articles: Article[]): Promise<DraftResult> {
const token = await getAccessToken();
const processedArticles: Record<string, unknown>[] = [];
for (const article of articles) {
let content = article.content;
// 替换内容中的图片为微信 URL
if (article.content_image_urls && article.content_image_urls.length > 0) {
const urls = article.content_image_urls.map(u => u.original_url);
content = await replaceContentImages(content, urls);
}
const processed: Record<string, unknown> = {
title: article.title,
author: article.author || "",
content: content,
content_source_url: article.content_source_url || "",
show_cover_pic: article.show_cover_pic ?? 1,
need_open_comment: article.need_open_comment ?? 0,
};
// 上传封面图
if (article.thumb_media_url) {
try {
const mediaId = await uploadImage(article.thumb_media_url);
processed.thumb_media_id = mediaId;
} catch (e) {
console.warn("[wechat] 封面图上传失败:", e instanceof Error ? e.message : String(e));
}
} else if (article.thumb_media_id) {
processed.thumb_media_id = article.thumb_media_id;
}
processedArticles.push(processed);
}
// 创建草稿
const draftUrl = `https://api.weixin.qq.com/cgi-bin/draft/add?access_token=${token}`;
const draftRes = await fetch(draftUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ articles: processedArticles }),
});
const draftData = await draftRes.json() as { media_id?: string; errcode?: number; errmsg?: string };
if (draftData.errcode) {
return { ok: false, message: `创建草稿失败: ${draftData.errmsg} (${draftData.errcode})` };
}
return { ok: true, media_id: draftData.media_id };
}
// ---- 发布草稿 ----
export async function publishDraft(mediaId: string): Promise<DraftResult> {
const token = await getAccessToken();
const url = `https://api.weixin.qq.com/cgi-bin/freepublish/submit?access_token=${token}`;
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ media_id: mediaId }),
});
const data = await res.json() as { publish_id?: string; errcode?: number; errmsg?: string };
if (data.errcode) {
return { ok: false, message: `发布失败: ${data.errmsg} (${data.errcode})` };
}
return { ok: true, media_id: data.publish_id };
}