216 lines
6.7 KiB
TypeScript
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 };
|
|
}
|