归档 artalk-cf 评论后端 + rss-robot 到 blog-admin(含技术选型/模块分布 README)

This commit is contained in:
zqlit committed 2026-10-04 08:45:40 +08:00
1 parent e1323bd260
commit 299ab57e4d
98 files changed
+15657

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
// /api/feeds —— 订阅源 CRUD
// 迁自 edgeone/functions/api/feeds.js
import type { Env } from '../../types';
import { respond, corsOptions, requireAuth, kvGetJson, kvPutJson } from '../../lib/rss/util';
export async function onRequest(request: Request, env: Env): Promise<Response> {
if (request.method === 'OPTIONS') return corsOptions();
const url = new URL(request.url);
switch (request.method) {
case 'GET': {
const data = await kvGetJson<{ feeds: unknown[] }>(env, 'feeds_config', { feeds: [] });
return respond(data);
}
case 'POST': {
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
let body: any;
try {
body = await request.json();
} catch {
return respond({ error: 'invalid json' }, 400);
}
const data = await kvGetJson<{ feeds: any[] }>(env, 'feeds_config', { feeds: [] });
const incoming = body.feeds && Array.isArray(body.feeds) ? body.feeds : [body];
const duplicates: string[] = [];
for (const feed of incoming) {
if (!feed.url) continue;
const idx = data.feeds.findIndex((f) => f.url === feed.url);
if (idx >= 0 && feed.url !== feed.oldUrl) duplicates.push(feed.url);
}
if (duplicates.length > 0) {
return respond({ error: '链接已存在,不允许重复添加', duplicates }, 409);
}
for (const feed of incoming) {
if (!feed.url) continue;
if (feed.oldUrl) {
const idx = data.feeds.findIndex((f) => f.url === feed.oldUrl);
if (idx >= 0) {
data.feeds[idx] = {
...data.feeds[idx],
url: feed.url,
feedTitle: feed.feedTitle || data.feeds[idx].feedTitle,
};
} else {
data.feeds.push({ url: feed.url, feedTitle: feed.feedTitle || '' });
}
} else {
data.feeds.push({ url: feed.url, feedTitle: feed.feedTitle || '' });
}
}
await kvPutJson(env, 'feeds_config', data);
return respond({ ok: true, total: data.feeds.length });
}
case 'DELETE': {
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
const targetUrl = url.searchParams.get('url');
if (!targetUrl) return respond({ error: 'url parameter required' }, 400);
const data = await kvGetJson<{ feeds: any[] }>(env, 'feeds_config', { feeds: [] });
const before = data.feeds.length;
data.feeds = data.feeds.filter((f) => f.url !== targetUrl);
const removed = before - data.feeds.length;
await kvPutJson(env, 'feeds_config', data);
return respond({ ok: true, removed, total: data.feeds.length });
}
default:
return respond({ error: 'method not allowed' }, 405);
}
}
+151
View File
@@ -0,0 +1,151 @@
// /api/ai/greeting —— 随机问候语 + DeepSeek 词库扩充
// 迁自 edgeone/functions/api/ai/greeting.js
import type { Env } from '../../types';
import { respond, corsOptions, requireAuth } from '../../lib/rss/util';
import { defaultPool, type Greeting } from '../../lib/rss/greetings';
function getTodayMMDD(): string {
const now = new Date();
return String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0');
}
function getTimeSlot(): string {
const h = new Date().getHours();
const d = new Date().getDay();
if (d === 0 || d === 6) {
if (h < 22) return 'weekend';
return 'night';
}
if (h >= 6 && h < 9) return 'weekday-morning';
if (h >= 9 && h < 12) return 'morning';
if (h >= 12 && h < 14) return 'noon';
if (h >= 14 && h < 18) return 'afternoon';
if (h >= 18 && h < 22) return 'evening';
return 'night';
}
function shuffle<T>(arr: T[]): T[] {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
async function getPool(env: Env): Promise<Greeting[]> {
const raw = await env.RSS_KV.get('greetings_pool');
return raw ? (JSON.parse(raw) as Greeting[]) : defaultPool();
}
async function callDeepSeek(apiKey: string, count: number): Promise<Greeting[]> {
const today = getTodayMMDD();
const slot = getTimeSlot();
const slotNames: Record<string, string> = {
'weekday-morning': '工作日早晨', morning: '上午', noon: '中午',
afternoon: '下午', evening: '傍晚', night: '深夜', weekend: '周末',
};
const prompt = `你是"优世界"博客的AI助手。请生成${count}条新的博客问候语,每条15字以内,语气轻松温暖幽默。
当前场景:${slotNames[slot] || slot},日期 ${today}。
返回纯JSON数组:[{"text":"问候语","time":"morning","holiday":null}]`;
const res = await fetch('https://api.deepseek.com/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({
model: 'deepseek-chat',
messages: [{ role: 'user', content: prompt }],
max_tokens: 2048,
temperature: 0.9,
}),
});
const json = (await res.json()) as any;
if (json.error) throw new Error(`DeepSeek API: ${json.error.message}`);
const text = json.choices?.[0]?.message?.content || '';
const match = text.match(/\[[\s\S]*\]/);
if (!match) throw new Error('AI 返回格式异常');
const generated = JSON.parse(match[0]);
if (!Array.isArray(generated)) throw new Error('AI 返回的不是数组');
return generated.filter((g: Greeting) => g.text && g.text.length <= 20);
}
export async function onRequest(request: Request, env: Env): Promise<Response> {
if (request.method === 'OPTIONS') return corsOptions();
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
const url = new URL(request.url);
if (request.method === 'GET') {
const count = Math.min(Math.max(parseInt(url.searchParams.get('count') || '10'), 1), 50);
const pool = await getPool(env);
const today = getTodayMMDD();
const slot = getTimeSlot();
const holidayMatches = pool.filter((g) => g.holiday === today);
const slotMatches = pool.filter((g) => !g.holiday && g.time === slot);
const genericMatches = pool.filter((g) => !g.holiday && !g.time);
let result = shuffle(holidayMatches).slice(0, Math.ceil(count * 0.3));
result = result.concat(shuffle(slotMatches).slice(0, count - result.length));
result = result.concat(shuffle(genericMatches).slice(0, count - result.length));
return respond({ greetings: shuffle(result).slice(0, count) });
}
if (request.method === 'POST') {
let body: any;
try {
body = await request.json();
} catch {
return respond({ error: 'invalid json' }, 400);
}
if (body.action === 'config') {
if (!body.deepseek_key) return respond({ error: '请提供 deepseek_key' }, 400);
await env.RSS_KV.put('ai_config', JSON.stringify({ deepseek_key: body.deepseek_key }));
return respond({ ok: true, message: 'DeepSeek API Key 已保存' });
}
if (body.action === 'generate') {
const count = Math.min(body.count || 10, 30);
let apiKey = body.deepseek_key;
if (!apiKey) {
const configRaw = await env.RSS_KV.get('ai_config');
if (configRaw) {
try {
apiKey = JSON.parse(configRaw).deepseek_key;
} catch {
// 忽略
}
}
}
if (!apiKey) return respond({ error: '请先配置 DeepSeek API Key' }, 400);
try {
const generated = await callDeepSeek(apiKey, count);
const pool = await getPool(env);
const existing = new Set(pool.map((g) => g.text));
let added = 0;
for (const g of generated) {
if (!existing.has(g.text)) {
pool.push({ text: g.text, time: g.time || null, holiday: g.holiday || null });
existing.add(g.text);
added++;
}
}
await env.RSS_KV.put('greetings_pool', JSON.stringify(pool));
return respond({ ok: true, generated: generated.length, added, total: pool.length });
} catch (err) {
return respond({ error: (err as Error).message }, 500);
}
}
const pool = body.pool && Array.isArray(body.pool) ? body.pool : defaultPool();
await env.RSS_KV.put('greetings_pool', JSON.stringify(pool));
return respond({ ok: true, total: pool.length, message: '词库已更新' });
}
return respond({ error: 'method not allowed' }, 405);
}
+262
View File
@@ -0,0 +1,262 @@
// /api/links —— 友链 CRUD
// 迁自 edgeone/functions/api/links.js
import type { Env } from '../../types';
import { respond, corsOptions, requireAuth, kvGetJson, kvPutJson } from '../../lib/rss/util';
interface Link {
name: string;
url: string;
image?: string;
description?: string;
rss?: string;
hidden?: boolean;
addedAt?: string;
avatar?: string;
}
export async function onRequest(request: Request, env: Env): Promise<Response> {
if (request.method === 'OPTIONS') return corsOptions();
const url = new URL(request.url);
const method = request.method.toUpperCase();
if (method === 'GET') {
const data = await kvGetJson<{ links: Link[] }>(env, 'friend_links', { links: [] });
const showAll = url.searchParams.get('all') === '1';
// 历史数据里 /api/favicon 存的是相对地址 → 读取时补成绝对(博客域名下相对路径会 404)
const apiBase = env.PUBLIC_API_BASE || 'https://api.200181.xyz';
let links = data.links.map((l) => {
const img = l.image || l.avatar || '';
return { ...l, image: img.startsWith('/') ? apiBase + img : img };
});
if (!showAll) links = links.filter((l) => !l.hidden);
return respond({ links });
}
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
if (method === 'DELETE') {
const targetUrl = url.searchParams.get('url');
if (!targetUrl) return respond({ error: 'missing url param' }, 400);
const data = await kvGetJson<{ links: Link[] }>(env, 'friend_links', { links: [] });
data.links = data.links.filter((l) => l.url !== targetUrl);
await kvPutJson(env, 'friend_links', data);
return respond({ ok: true, links: data.links });
}
if (method === 'POST') {
let body: any;
try {
body = await request.json();
} catch {
return respond({ error: 'invalid json' }, 400);
}
const data = await kvGetJson<{ links: Link[] }>(env, 'friend_links', { links: [] });
const incoming = body.links || (body.url ? [body] : []);
if (incoming.length === 0) return respond({ error: 'empty body' }, 400);
const duplicates: string[] = [];
for (const link of incoming) {
if (!link.url) continue;
const idx = data.links.findIndex((l) => l.url === link.url);
if (idx >= 0 && link.url !== link.oldUrl) duplicates.push(link.url);
}
if (duplicates.length > 0) {
return respond({ error: '链接已存在,不允许重复添加', duplicates }, 409);
}
for (const link of incoming) {
if (!link.url) continue;
const entry: Link = {
name: link.name || link.title || '',
url: link.url,
image:
link.image || link.avatar || `/api/favicon?url=${encodeURIComponent(link.url)}`,
description: link.description || '',
rss: link.rss || '',
hidden: link.hidden || false,
addedAt: new Date().toISOString(),
};
if (link.oldUrl) {
const idx = data.links.findIndex((l) => l.url === link.oldUrl);
if (idx >= 0) {
entry.addedAt = data.links[idx].addedAt;
data.links[idx] = entry;
} else {
data.links.push(entry);
}
} else {
data.links.push(entry);
}
}
await kvPutJson(env, 'friend_links', data);
return respond({ ok: true, links: data.links });
}
return respond({ error: 'method not allowed' }, 405);
}
// ============================================================================
// 友链自助申请
// 公开提交 → KV link_applications(pending)→ 邮件提醒管理员;
// 管理面板审核:approve → 写入 friend_links + 邮件告知申请人;
// reject → 记录反馈意见 + 邮件告知申请人。
// ============================================================================
interface LinkApplication {
id: string;
name: string;
url: string;
feed: string;
email: string;
/** 申请人自填的图标地址;为空时审核通过会自动用 /api/favicon 抓站点图标 */
image?: string;
description?: string;
status: 'pending' | 'approved' | 'rejected';
feedback?: string;
appliedAt: string;
reviewedAt?: string;
}
function validHttpUrl(s: string): boolean {
try {
const u = new URL(s);
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
}
function normUrl(s: string): string {
return s.replace(/\/+$/, '');
}
export async function linkApply(request: Request, env: Env): Promise<Response> {
if (request.method === 'OPTIONS') return corsOptions();
if (request.method !== 'POST') return respond({ error: 'method not allowed' }, 405);
let body: any;
try {
body = await request.json();
} catch {
return respond({ error: 'invalid json' }, 400);
}
const name = String(body.name || '').trim().slice(0, 60);
const url = String(body.url || '').trim().slice(0, 300);
const feed = String(body.feed || '').trim().slice(0, 300);
const email = String(body.email || '').trim().slice(0, 120);
const image = String(body.image || '').trim().slice(0, 300);
const description = String(body.description || '').trim().slice(0, 200);
if (!name || !url || !feed || !email) return respond({ error: '名称、链接、订阅地址、邮箱均为必填' }, 400);
if (!validHttpUrl(url)) return respond({ error: '站点链接格式不正确(需 http/https 开头)' }, 400);
if (!validHttpUrl(feed)) return respond({ error: '订阅地址格式不正确(需 http/https 开头)' }, 400);
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return respond({ error: '邮箱格式不正确' }, 400);
if (image && !validHttpUrl(image)) return respond({ error: '图标链接格式不正确(需 http/https 开头,或留空自动获取)' }, 400);
const st = await kvGetJson<{ apps: LinkApplication[] }>(env, 'link_applications', { apps: [] });
const links = await kvGetJson<{ links: Link[] }>(env, 'friend_links', { links: [] });
if (links.links.some((l) => normUrl(l.url) === normUrl(url)))
return respond({ error: '该站点已经是友链啦,无需重复申请' }, 409);
if (st.apps.some((a) => normUrl(a.url) === normUrl(url) && a.status === 'pending'))
return respond({ error: '该站点已有待审申请,请耐心等待审核结果' }, 409);
const app: LinkApplication = {
id: 'la' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
name, url, feed, email, description, image,
status: 'pending',
appliedAt: new Date().toISOString(),
};
st.apps.unshift(app);
st.apps = st.apps.slice(0, 200);
await kvPutJson(env, 'link_applications', st);
// 邮件提醒管理员
const { sendMail } = await import('../../lib/mail');
await sendMail(env, {
to: 'imql@qq.com',
subject: `🔗 新的友链申请:${name}`,
html:
`<p><b>${name}</b> 提交了友链申请:</p>` +
`<ul><li>链接:<a href="${url}">${url}</a></li>` +
(image ? `<li>图标:<a href="${image}">${image}</a></li>` : `<li>图标:未填写(通过后自动抓取站点图标)</li>`) +
`<li>订阅:${feed}</li><li>邮箱:${email}</li>` +
(description ? `<li>描述:${description}</li>` : '') + `</ul>` +
`<p><a href="https://api.200181.xyz/admin/">前往管理面板审核</a></p>`,
});
return respond({ ok: true, message: '申请已提交,审核结果将邮件通知您' });
}
export async function linkApplyList(request: Request, env: Env): Promise<Response> {
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
const st = await kvGetJson<{ apps: LinkApplication[] }>(env, 'link_applications', { apps: [] });
return respond({ apps: st.apps || [] });
}
export async function linkApplyReview(request: Request, env: Env): Promise<Response> {
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
let body: { id?: string; action?: string; feedback?: string };
try {
body = (await request.json()) as { id?: string; action?: string; feedback?: string };
} catch {
return respond({ error: 'invalid json' }, 400);
}
const id = String(body.id || '');
const action = body.action === 'approve' ? 'approve' : body.action === 'reject' ? 'reject' : '';
if (!id || !action) return respond({ error: 'id and action required' }, 400);
const st = await kvGetJson<{ apps: LinkApplication[] }>(env, 'link_applications', { apps: [] });
const app = st.apps.find((a) => a.id === id);
if (!app) return respond({ error: '申请不存在' }, 404);
if (app.status !== 'pending') return respond({ error: '该申请已处理过' }, 409);
app.status = action === 'approve' ? 'approved' : 'rejected';
app.feedback = String(body.feedback || '').slice(0, 300);
app.reviewedAt = new Date().toISOString();
await kvPutJson(env, 'link_applications', st);
let mailOk = false;
if (action === 'approve') {
// 写入友链
const links = await kvGetJson<{ links: Link[] }>(env, 'friend_links', { links: [] });
links.links.push({
name: app.name,
url: app.url,
// 申请人没填图标 → 用站内 favicon 服务自动抓(页面/友圈直接用这个地址)
image: app.image || `/api/favicon?url=${encodeURIComponent(app.url)}`,
description: app.description || '',
rss: app.feed,
addedAt: new Date().toISOString(),
});
await kvPutJson(env, 'friend_links', links);
mailOk = await sendMailSafe(env, app.email,
`✅ 你的友链申请已通过:${app.name}`,
`<p>你的友链申请(<a href="${app.url}">${app.url}</a>)已审核通过,现已加进友链列表,感谢支持!</p>` +
(app.feedback ? `<p>站长留言:${app.feedback}</p>` : ''));
} else {
mailOk = await sendMailSafe(env, app.email,
`关于你的友链申请:${app.name}`,
`<p>很抱歉,你的友链申请(<a href="${app.url}">${app.url}</a>)本次未能通过。</p>` +
`<p>站长反馈:${app.feedback || '(未填写)'}</p>` +
`<p>欢迎完善站点内容后再次申请。</p>`);
}
return respond({ ok: true, mailOk });
}
async function sendMailSafe(env: Env, to: string, subject: string, html: string): Promise<boolean> {
try {
const { sendMail } = await import('../../lib/mail');
return await sendMail(env, { to, subject, html });
} catch {
return false;
}
}
+170
View File
@@ -0,0 +1,170 @@
// /api/random-image —— 随机图
// /api/artalk/commenter —— 代理 Artalk 查用户最新评论(已修 login 路径 bug)
// 迁自 edgeone/functions/api/{random-image.js, artalk/commenter.js}
import type { Env } from '../../types';
import { respond, corsOptions, requireAuth } from '../../lib/rss/util';
const FOLDER_PREFIX = 'img_folder:';
const DATA_PREFIX = 'img_data:';
function getContentType(filename: string): string {
const ext = (filename || '').split('.').pop()?.toLowerCase();
const map: Record<string, string> = {
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
webp: 'image/webp', bmp: 'image/bmp', svg: 'image/svg+xml', ico: 'image/x-icon',
};
return map[ext || ''] || 'image/jpeg';
}
export async function randomImage(request: Request, env: Env): Promise<Response> {
if (request.method === 'OPTIONS') return corsOptions();
const url = new URL(request.url);
if (request.method === 'GET') {
const folder = url.searchParams.get('folder') || 'jiege';
const meta = url.searchParams.get('meta');
const list = url.searchParams.get('list');
if (list === '1') {
const raw = await env.RSS_KV.get('img_folders');
const folders = raw ? JSON.parse(raw) : [];
return respond({ folders });
}
const folderRaw = await env.RSS_KV.get(FOLDER_PREFIX + folder);
const images: { name?: string; type?: string; url?: string }[] = folderRaw ? JSON.parse(folderRaw) : [];
if (images.length === 0) return respond({ error: `文件夹 "${folder}" 为空或不存在` }, 404);
const idx = Math.floor(Math.random() * images.length);
const img = images[idx];
if (meta === '1') {
return respond({ name: img.name, type: img.type, url: img.url || null, folder, total: images.length, index: idx });
}
if (img.url && !img.name) {
return new Response(null, { status: 302, headers: { Location: img.url } });
}
const dataKey = DATA_PREFIX + folder + '/' + img.name;
const base64 = await env.RSS_KV.get(dataKey);
if (!base64) return respond({ error: `图片数据不存在: ${img.name}` }, 404);
const binary = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
return new Response(binary, {
status: 200,
headers: {
'Content-Type': img.type || getContentType(img.name || ''),
'Cache-Control': 'public, max-age=86400',
'Content-Disposition': `inline; filename="${img.name}"`,
},
});
}
if (request.method === 'POST') {
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
const folder = url.searchParams.get('folder') || 'jiege';
let body: any;
try {
body = await request.json();
} catch {
return respond({ error: 'invalid json' }, 400);
}
const folderKey = FOLDER_PREFIX + folder;
const folderRaw = await env.RSS_KV.get(folderKey);
const images: any[] = folderRaw ? JSON.parse(folderRaw) : [];
let added = 0;
if (body.files && Array.isArray(body.files)) {
for (const f of body.files) {
if (!f.name || !f.data) continue;
if (images.find((i) => i.name === f.name)) continue;
const type = f.type || getContentType(f.name);
await env.RSS_KV.put(DATA_PREFIX + folder + '/' + f.name, f.data);
images.push({ name: f.name, type });
added++;
}
}
if (body.urls && Array.isArray(body.urls)) {
for (const item of body.urls) {
const u = typeof item === 'string' ? item : item.url;
if (!u) continue;
if (!images.find((i) => (i.url || i.name) === u)) {
images.push(typeof item === 'string' ? { url: u } : item);
added++;
}
}
}
if (added === 0) return respond({ error: '没有可添加的图片' }, 400);
await env.RSS_KV.put(folderKey, JSON.stringify(images));
const foldersRaw = await env.RSS_KV.get('img_folders');
const folders: string[] = foldersRaw ? JSON.parse(foldersRaw) : [];
if (!folders.includes(folder)) {
folders.push(folder);
await env.RSS_KV.put('img_folders', JSON.stringify(folders));
}
return respond({ ok: true, folder, added, total: images.length });
}
return respond({ error: 'method not allowed' }, 405);
}
export async function artalkCommenter(request: Request, env: Env): Promise<Response> {
if (request.method === 'OPTIONS') return corsOptions();
if (request.method === 'POST') {
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
let body: any;
try {
body = await request.json();
} catch {
return respond({ error: 'invalid json' }, 400);
}
const { name, email, password, site_name, api_url } = body;
if (!name || !email || !password) return respond({ error: 'name, email, password 必填' }, 400);
await env.RSS_KV.put(
'artalk_config',
JSON.stringify({ name, email, password, site_name: site_name || '优世界', api_url: api_url || 'https://artalk.usj.cc' }),
);
return respond({ ok: true, message: 'Artalk 配置已保存' });
}
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
const url = new URL(request.url);
const targetEmail = url.searchParams.get('email');
if (!targetEmail) return respond({ error: 'email 参数必填' }, 400);
const configRaw = await env.RSS_KV.get('artalk_config');
if (!configRaw) return respond({ error: 'Artalk 未配置,请先 POST 保存 admin 账号' }, 400);
const config = JSON.parse(configRaw);
const { name, email, password, site_name, api_url } = config;
try {
// 登录(★ 修正:Artalk 登录接口是 /user/access_token,不是 /login)
const loginRes = await fetch(`${api_url}/api/v2/user/access_token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, password }),
});
const loginJson = (await loginRes.json()) as { token?: string; msg?: string };
if (!loginJson.token) {
return respond({ error: 'Artalk 登录失败: ' + (loginJson.msg || 'unknown') }, 502);
}
const adminToken = loginJson.token;
const searchUrl = `${api_url}/api/v2/comments?scope=site&site_name=${encodeURIComponent(site_name)}&search=${encodeURIComponent(targetEmail)}&limit=1`;
const commentRes = await fetch(searchUrl, { headers: { Authorization: `Bearer ${adminToken}` } });
const commentJson = (await commentRes.json()) as { comments?: any[]; data?: any };
const comments = commentJson.comments || commentJson.data?.comments || commentJson.data || [];
if (!Array.isArray(comments) || comments.length === 0) return respond({ nick: '', link: '' });
const last = comments[0];
return respond({ nick: last.nick || '', link: last.link || '' });
} catch (err) {
return respond({ error: (err as Error).message }, 500);
}
}
+90
View File
@@ -0,0 +1,90 @@
// /api/auth —— 验证 / 修改口令
// /api/health —— 站点存活检测
// 迁自 edgeone/functions/api/auth.js + health.js
import type { Env } from '../../types';
import { respond, corsOptions, requireAuth, kvGetJson } from '../../lib/rss/util';
export async function auth(request: Request, env: Env): Promise<Response> {
if (request.method === 'OPTIONS') return corsOptions();
if (request.method === 'POST') {
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
let body: any;
try {
body = await request.json();
} catch {
return respond({ error: 'invalid json' }, 400);
}
const { oldPassword, newPassword } = body;
if (oldPassword) {
const current = (await env.RSS_KV.get('site_password')) || '';
if (current && oldPassword !== current) return respond({ error: '当前口令错误' }, 403);
}
if (newPassword) {
await env.RSS_KV.put('site_password', newPassword);
} else if (newPassword === '') {
await env.RSS_KV.delete('site_password');
}
return respond({ ok: true });
}
const authed = await requireAuth(request, env);
return respond({ ok: authed }, authed ? 200 : 401);
}
async function checkSite(url: string): Promise<Record<string, unknown>> {
const start = Date.now();
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 10000);
const res = await fetch(url, {
method: 'HEAD',
signal: ctrl.signal,
redirect: 'follow',
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; RSSBot/1.0)' },
});
clearTimeout(t);
return { url, status: res.status, ok: res.ok, latency: Date.now() - start };
} catch (err) {
return {
url,
status: 0,
ok: false,
latency: Date.now() - start,
error: (err as Error).message,
};
}
}
export async function health(request: Request, env: Env): Promise<Response> {
const reqUrl = new URL(request.url);
const targetUrl = reqUrl.searchParams.get('url');
if (targetUrl) {
return respond(await checkSite(targetUrl));
}
const data = await kvGetJson<{ feeds: { url: string }[] }>(env, 'feeds_config', { feeds: [] });
const feeds = data.feeds || [];
if (feeds.length === 0) return respond({ results: [], total: 0 });
const results: Record<string, unknown>[] = [];
const concurrency = 5;
for (let i = 0; i < feeds.length; i += concurrency) {
const batch = feeds.slice(i, i + concurrency);
const batchResults = await Promise.all(batch.map((f) => checkSite(f.url)));
results.push(...batchResults);
}
const alive = results.filter((r) => r.ok).length;
const dead = results.filter((r) => !r.ok).length;
return respond({
timestamp: new Date().toISOString(),
total: results.length,
alive,
dead,
results,
});
}
+63
View File
@@ -0,0 +1,63 @@
// /api/results —— 最新 RSS 聚合 JSON(按博客分组)
// /api/articles —— 按时间排序的文章列表
// 迁自 edgeone/functions/api/results.js + articles.js
import type { Env } from '../../types';
import { respond, kvGetJson } from '../../lib/rss/util';
interface FeedData {
name: string;
siteUrl: string;
favicon: string;
articles: unknown[];
}
interface Latest {
timestamp: string | null;
total: number;
feeds: FeedData[];
}
export async function results(request: Request, env: Env): Promise<Response> {
const data = await kvGetJson<Latest>(env, 'latest', { timestamp: null, total: 0, feeds: [] });
const url = new URL(request.url);
const feedFilter = url.searchParams.get('feed');
const limit = parseInt(url.searchParams.get('limit') || '0');
if (feedFilter) data.feeds = data.feeds.filter((f) => f.name === feedFilter);
if (limit > 0) data.feeds = data.feeds.map((f) => ({ ...f, articles: f.articles.slice(0, limit) }));
return respond(data, 200);
}
export async function articles(request: Request, env: Env): Promise<Response> {
const data = await kvGetJson<Latest>(env, 'latest', { timestamp: null, total: 0, feeds: [] });
const url = new URL(request.url);
const limit = parseInt(url.searchParams.get('limit') || '50');
const flat: any[] = [];
for (const feed of data.feeds || []) {
for (const article of feed.articles || []) {
const a = article as any;
flat.push({
title: a.title,
link: a.link,
pubDate: a.pubDate,
author: a.author || feed.name,
feedName: feed.name,
siteUrl: feed.siteUrl,
// 源里没抓到图标时,直接用站内 favicon 服务(会返回真实图标或字母 SVG,绝不是灰头像)。
// 用 request 的 origin 拼绝对地址 —— 友圈页在 usj.cc 上,相对路径 /api/* 会 404。
favicon: (() => {
const raw = feed.favicon || '';
// 历史数据里存的是相对路径 → 补成绝对(否则在博客域名下会 404)
if (raw) return raw.startsWith('/') ? url.origin + raw : raw;
return feed.siteUrl ? `${url.origin}/api/favicon?url=${encodeURIComponent(feed.siteUrl)}` : '';
})(),
});
}
}
flat.sort((a, b) => new Date(b.pubDate).getTime() - new Date(a.pubDate).getTime());
return respond({ timestamp: data.timestamp, total: flat.length, articles: flat.slice(0, limit) }, 200);
}
+404
View File
@@ -0,0 +1,404 @@
// /api/favicon —— 自动发现并返回站点 favicon
// /api/update —— 接收抓取结果写入 KV(scheduled 已内置,此端点保留兼容外部上报)
// /api/proxy —— RSS 抓取代理(EdgeOne 版迁过来;注意本 Worker 在境外,等价于原 EdgeOne)
// 迁自 edgeone/functions/api/{favicon,update,proxy}.js
import type { Env } from '../../types';
import { respond, requireAuth } from '../../lib/rss/util';
import { notifyAdmin } from '../../lib/admin-notify';
import { checkProxyHealth, notifyProxyDown } from '../../lib/rss/proxy-health';
import { kvGetJson, kvPutJson } from '../../lib/rss/util';
const CACHE_TTL = 604800;
function resolveUrl(href: string, baseUrl: string): string | null {
if (href.startsWith('http://') || href.startsWith('https://')) return href;
try {
return new URL(href, baseUrl).href;
} catch {
return null;
}
}
async function discoverFaviconUrl(siteUrl: string): Promise<string | null> {
if (!/^https?:\/\//i.test(siteUrl)) siteUrl = 'https://' + siteUrl;
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 12000);
const res = await fetch(siteUrl, {
signal: ctrl.signal,
redirect: 'follow',
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; RSSBot/1.0)',
Accept: 'text/html,application/xhtml+xml,*/*',
},
});
clearTimeout(t);
const html = await res.text();
const iconPatterns = [
/<link[^>]+rel=["'](?:shortcut )?icon["'][^>]+href=["']([^"']+)["']/i,
/<link[^>]+href=["']([^"']+)["'][^>]+rel=["'](?:shortcut )?icon["']/i,
/<link[^>]+rel=["']apple-touch-icon["'][^>]+href=["']([^"']+)["']/i,
/<link[^>]+href=["']([^"']+)["'][^>]+rel=["']apple-touch-icon["']/i,
];
for (const pattern of iconPatterns) {
const match = html.match(pattern);
if (match) return resolveUrl(match[1].trim(), siteUrl);
}
const urlObj = new URL(siteUrl);
const defaultFavicon = `${urlObj.protocol}//${urlObj.host}/favicon.ico`;
try {
const ctrl2 = new AbortController();
const t2 = setTimeout(() => ctrl2.abort(), 8000);
const check = await fetch(defaultFavicon, { method: 'HEAD', signal: ctrl2.signal, redirect: 'follow' });
clearTimeout(t2);
const ct = (check.headers.get('content-type') || '').toLowerCase();
// 必须确认返回的真是图片:Typecho/WordPress 等会把 404 页面以 200 + text/html 返回
if (check.ok && (ct.startsWith('image/') || ct.includes('octet-stream'))) return defaultFavicon;
} catch {
/* 继续走兜底 */
}
// favicon.ico 不通或返回的不是图片 → 兜底第三方 favicon 服务(国内可达),
// 避免友圈卡片大面积落回灰色默认头像
return `https://api.iowen.cn/favicon/${urlObj.host}.png`;
} catch {
try {
const urlObj = new URL(siteUrl);
// 同上:抓取失败兜底第三方服务
return `https://api.iowen.cn/favicon/${urlObj.host}.png`;
} catch {
return null;
}
}
}
export async function favicon(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const siteUrl = url.searchParams.get('url');
if (!siteUrl) return respond({ error: 'url parameter required' }, 400);
const bodyKey = 'favicon:body:v2:' + siteUrl;
// 一级缓存:图片本体直接存 KV(base64)——命中后零外站回源,响应最快
const cached = await env.RSS_KV.get(bodyKey);
if (cached) {
const [meta, b64] = cached.split('|');
const body = Uint8Array.from(atob(b64), (ch) => ch.charCodeAt(0));
return new Response(body, {
status: 200,
headers: {
'Content-Type': meta,
'Cache-Control': 'public, max-age=86400',
'Access-Control-Allow-Origin': '*',
},
});
}
// 失败短缓存:抓取失败的站点 1 小时内不再反复打外站
const missKey = 'favicon:miss:' + siteUrl;
if (await env.RSS_KV.get(missKey)) {
return letterSvg(siteUrl);
}
if (url.searchParams.get('meta')) {
const u = await discoverFaviconUrl(siteUrl);
return respond({ url: u });
}
// 发现 + 下载图片本体(兼容读旧版 URL 字符串缓存:存量友链的发现结果都在旧 key 里)
let faviconUrl: string | null = await env.RSS_KV.get('favicon:' + siteUrl).catch(() => null);
if (!faviconUrl) {
try {
faviconUrl = await discoverFaviconUrl(siteUrl);
if (faviconUrl) await env.RSS_KV.put('favicon:' + siteUrl, faviconUrl, { expirationTtl: CACHE_TTL });
} catch {
faviconUrl = null;
}
}
if (faviconUrl) {
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 10000);
const imgRes = await fetch(faviconUrl, {
signal: ctrl.signal,
redirect: 'follow',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
Accept: 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
},
});
clearTimeout(t);
if (imgRes.ok) {
const rawCt = (imgRes.headers.get('content-type') || '').toLowerCase();
// 只接受真正的图片(含 octet-stream 的 .ico);HTML/文本一律视为抓取失败
if (!rawCt.startsWith('image/') && !rawCt.includes('octet-stream')) {
throw new Error('favicon not an image: ' + rawCt);
}
const contentType = rawCt || 'image/x-icon';
const buf = await imgRes.arrayBuffer();
// 只缓存小于 300KB 的图(KV 单值上限 25MB,异常大图不占空间)
if (buf.byteLength <= 307200) {
let bin = '';
const bytes = new Uint8Array(buf);
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
bin += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + chunk)) as unknown as number[]);
}
await env.RSS_KV.put(bodyKey, `${contentType}|${btoa(bin)}`, { expirationTtl: CACHE_TTL });
}
return new Response(buf, {
status: 200,
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=86400',
'Access-Control-Allow-Origin': '*',
},
});
}
} catch {
/* 落到第三方兜底 */
}
}
// 兜底链:第三方 favicon 服务代理返回(仍尝试写缓存,下次直接命中)
let host = siteUrl.replace(/^https?:\/\//, '').split('/')[0];
const fallback = `https://api.iowen.cn/favicon/${host}.png`;
try {
const ctrl3 = new AbortController();
const t3 = setTimeout(() => ctrl3.abort(), 10000);
const fRes = await fetch(fallback, { signal: ctrl3.signal, redirect: 'follow' });
clearTimeout(t3);
if (fRes.ok) {
const contentType = fRes.headers.get('content-type') || 'image/png';
const buf = await fRes.arrayBuffer();
let bin = '';
const bytes = new Uint8Array(buf);
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
bin += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + chunk)) as unknown as number[]);
}
await env.RSS_KV.put(bodyKey, `${contentType}|${btoa(bin)}`, { expirationTtl: CACHE_TTL });
return new Response(buf, {
status: 200,
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=86400',
'Access-Control-Allow-Origin': '*',
},
});
}
} catch {
/* 彻底失败 */
}
// 记一次失败,1 小时内不再打外站;返回自绘字母 SVG 兜底
// (零依赖永远可用,比第三方服务和灰色默认头像都体面)
await env.RSS_KV.put(missKey, '1', { expirationTtl: 3600 });
return letterSvg(siteUrl);
}
/** 站点首字母占位图(永远 200 的最后兜底) */
function letterSvg(siteUrl: string): Response {
let host = siteUrl.replace(/^https?:\/\//, '').split('/')[0].replace(/^www\./, '');
const letter = (host[0] || '?').toUpperCase();
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64">` +
`<rect width="64" height="64" rx="12" fill="#2f9e63"/>` +
`<text x="32" y="43" font-family="Arial,sans-serif" font-size="32" font-weight="600" fill="#ffffff" text-anchor="middle">${letter}</text></svg>`;
return new Response(svg, {
status: 200,
headers: {
'Content-Type': 'image/svg+xml',
'Cache-Control': 'public, max-age=86400',
'Access-Control-Allow-Origin': '*',
},
});
}
export async function update(request: Request, env: Env): Promise<Response> {
if (request.method !== 'POST') return respond({ error: 'POST only' }, 405);
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
try {
const data = await request.json();
await env.RSS_KV.put('latest', JSON.stringify(data), { expirationTtl: 604800 });
return respond({ ok: true });
} catch (err) {
return respond({ error: (err as Error).message }, 500);
}
}
/**
* POST /api/notify-mail —— 通用提醒邮件(给自己发一封)
* body: { subject, text, dedupeKey?, minGapHours? }
* 鉴权:requireAuth;收件人固定 MAIL_ADMIN,不能指定别人。
*/
export async function notifyMail(request: Request, env: Env): Promise<Response> {
if (request.method !== 'POST') return respond({ error: 'POST only' }, 405);
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
let body: any;
try {
body = await request.json();
} catch {
return respond({ error: 'invalid json' }, 400);
}
const result = await notifyAdmin(env, {
subject: String(body?.subject || '提醒'),
text: String(body?.text || ''),
dedupeKey: body?.dedupeKey,
minGapHours: Number(body?.minGapHours) || 0,
});
if (result.error) return respond({ error: result.error }, 400);
return respond(result);
}
/**
* GET /api/proxy-health —— 国内代理(scfapi.usj.cc)体检
* ?notify=1 时若不健康还会给站长发一封提醒邮件(48h 节流)。
*/
export async function proxyHealth(request: Request, env: Env): Promise<Response> {
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
const health = await checkProxyHealth(env);
let mailed: unknown = null;
if (!health.ok && new URL(request.url).searchParams.get('notify') === '1') {
mailed = await notifyProxyDown(env, health);
}
return respond({ ...health, mailed });
}
export async function proxy(request: Request, env: Env): Promise<Response> {
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type' } });
}
if (request.method !== 'POST') return respond({ error: 'POST only' }, 405);
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
let body: any;
try {
body = await request.json();
} catch {
return respond({ error: 'invalid json' }, 400);
}
const { url, timeout = 15000 } = body;
if (!url) return respond({ error: 'url required' }, 400);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const targetRes = await fetch(url, {
signal: controller.signal,
redirect: 'follow',
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; RSSBot/1.0)',
Accept: 'text/html,application/xhtml+xml,application/xml,application/json;q=0.9,*/*;q=0.8',
},
});
clearTimeout(timer);
const text = await targetRes.text();
const ct = targetRes.headers.get('content-type') || '';
return respond({ ok: true, status: targetRes.status, contentType: ct, body: text });
} catch (err) {
return respond({ ok: false, error: (err as Error).message }, 502);
}
}
/**
* POST /api/cron/run?offset=0&limit=35 —— 手动触发一批抓取(token 鉴权)。
* 免费版 subrequest 限制下用于补跑/首灌数据。
*/
export async function cronRun(request: Request, env: Env): Promise<Response> {
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
const url = new URL(request.url);
const offset = Math.max(parseInt(url.searchParams.get('offset') || '0', 10) || 0, 0);
const limit = Math.max(parseInt(url.searchParams.get('limit') || '35', 10) || 0, 0);
// dry=1:只跑抓取、不写 KV、不发通知 —— 用于安全地测耗时/成功率(不会污染线上数据)
const dry = url.searchParams.get('dry') === '1';
// rotate=1:从 KV 游标继续抓(模拟定时任务的行为)
const rotate = url.searchParams.get('rotate') === '1';
// 抓取主要是 IO 等待(不计 CPU),HTTP 触发也没有时长上限,35 个源串行能跑完
const { runCron } = await import('../../lib/rss/cron');
const stats = await runCron(env, { offset, limit, dry, rotate });
return respond({ ok: true, offset, limit, stats });
}
// ============================================================================
// 部署状态(国内中转机同步监控)
// CI 的 build job 在 COS 上传后上报 phase=built;中转机 sync.sh 同步完成后
// 上报 phase=synced。GET 公开可查,对比两者就知道国内线路部署完了没。
// ============================================================================
interface DeployPhase {
hash: string;
phase: string;
at: string;
}
export async function deployStatusGet(_request: Request, env: Env): Promise<Response> {
const st = await kvGetJson<{ builds: DeployPhase[] }>(env, 'deploy_status', { builds: [] });
const builds = (st.builds || []).slice(0, 5);
const built = builds.find((b) => b.phase === 'built');
const synced = builds.find((b) => b.phase === 'synced');
let state = 'unknown';
if (built && synced && synced.hash === built.hash) state = 'synced';
else if (built && !synced) state = 'syncing';
else if (synced && !built) state = 'synced'; // 未配置 CI 上报时,以中转机记录为准
else if (built && synced && synced.hash !== built.hash) state = 'syncing';
return respond({
state, // synced = 国内已生效;syncing = 构建完成等待中转机同步
latest_build: built || null,
latest_sync: synced || null,
builds,
});
}
export async function deployStatusPost(request: Request, env: Env): Promise<Response> {
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
let body: { hash?: string; phase?: string } = {};
try {
body = (await request.json()) as { hash?: string; phase?: string };
} catch {
return respond({ error: 'invalid json' }, 400);
}
const hash = String(body.hash || '').slice(0, 64);
const phase = String(body.phase || '').slice(0, 20);
if (!hash || !phase) return respond({ error: 'hash and phase required' }, 400);
const st = await kvGetJson<{ builds: DeployPhase[] }>(env, 'deploy_status', { builds: [] });
st.builds = (st.builds || []).filter((b) => !(b.phase === phase && b.hash === hash));
st.builds.unshift({ hash, phase, at: new Date().toISOString() });
st.builds = st.builds.slice(0, 20);
await kvPutJson(env, 'deploy_status', st);
return respond({ ok: true });
}
/**
* POST /api/deploy-notify —— 中转机同步完成后发部署完成邮件(token 鉴权)。
* 与 Gitea Actions 的构建通知配套:构建通知告诉你"构建好了",
* 这封邮件告诉你"国内线路真正生效了"。
*/
export async function deployNotify(request: Request, env: Env): Promise<Response> {
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
let body: { hash?: string } = {};
try {
body = (await request.json()) as { hash?: string };
} catch {
return respond({ error: 'invalid json' }, 400);
}
const hash = String(body.hash || '').slice(0, 12) || '未知';
const { sendMail } = await import('../../lib/mail');
const okMail = await sendMail(env, {
to: 'imql@qq.com',
subject: `✅ 博客国内线路部署完成 ${hash}`,
text: `构建产物 ${hash} 已同步到又拍云并刷新多吉云 CDN,国内线路已生效。\n时间:${new Date().toISOString()}`,
html:
`<p>构建产物 <code>${hash}</code> 已同步到又拍云并刷新多吉云 CDN,<b>国内线路已生效</b>。</p>` +
`<p style="color:#8a8e98">时间:${new Date().toISOString()} · artalk-cf deploy monitor</p>`,
});
return respond({ ok: okMail });
}
+196
View File
@@ -0,0 +1,196 @@
// /api/wechat-material —— 同步文章到微信公众号草稿箱
// 迁自 edgeone/functions/api/wechat-material.js(319 行,最复杂的端点)
// 调用链:token → material/add_material(封面) → media/uploadimg(文内图) → draft/add
import type { Env } from '../../types';
import { respond, corsOptions, requireAuth } from '../../lib/rss/util';
const WECHAT_CONFIG_KEY = 'wechat_config';
const WECHAT_TOKEN_KEY = 'wechat_token';
async function getAccessToken(env: Env, appId: string, appSecret: string): Promise<string> {
const cached = await env.RSS_KV.get(WECHAT_TOKEN_KEY);
if (cached) {
try {
const data = JSON.parse(cached);
if (data.expiresAt > Date.now()) return data.token;
} catch {
// 忽略,重新获取
}
}
const res = await fetch(
`https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appId}&secret=${appSecret}`,
);
const json = (await res.json()) as { errcode?: number; errmsg?: string; access_token?: string; expires_in?: number };
if (json.errcode) throw new Error(`获取 access_token 失败: ${json.errmsg} (code=${json.errcode})`);
const ttl = Math.max((json.expires_in || 7200) - 300, 60);
await env.RSS_KV.put(
WECHAT_TOKEN_KEY,
JSON.stringify({ token: json.access_token, expiresAt: Date.now() + ttl * 1000 }),
);
return json.access_token!;
}
function buildMultipartBody(fieldName: string, filename: string, data: ArrayBuffer, contentType: string): { body: Uint8Array; boundary: string } {
const boundary = '----WechatMaterial' + Date.now();
const encoder = new TextEncoder();
const parts: Uint8Array[] = [];
parts.push(encoder.encode(`--${boundary}\r\n`));
parts.push(encoder.encode(`Content-Disposition: form-data; name="${fieldName}"; filename="${filename}"\r\n`));
parts.push(encoder.encode(`Content-Type: ${contentType}\r\n\r\n`));
parts.push(new Uint8Array(data));
parts.push(encoder.encode('\r\n'));
parts.push(encoder.encode(`--${boundary}--\r\n`));
const totalLen = parts.reduce((acc, p) => acc + p.length, 0);
const body = new Uint8Array(totalLen);
let offset = 0;
for (const p of parts) {
body.set(p, offset);
offset += p.length;
}
return { body, boundary };
}
async function uploadThumbImage(accessToken: string, imageUrl: string): Promise<string> {
const downloadRes = await fetch(imageUrl);
if (!downloadRes.ok) throw new Error(`下载封面图失败: HTTP ${downloadRes.status} — ${imageUrl}`);
const buffer = await downloadRes.arrayBuffer();
const contentType = downloadRes.headers.get('content-type') || 'image/jpeg';
const ext = contentType.split('/')[1] || 'jpg';
const { body, boundary } = buildMultipartBody('media', `cover.${ext}`, buffer, contentType);
const res = await fetch(
`https://api.weixin.qq.com/cgi-bin/material/add_material?access_token=${accessToken}&type=image`,
{ method: 'POST', headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` }, body },
);
const json = (await res.json()) as { errcode?: number; errmsg?: string; media_id?: string };
if (json.errcode) throw new Error(`上传封面图失败: ${json.errmsg} (code=${json.errcode})`);
return json.media_id!;
}
async function uploadContentImage(accessToken: string, imageUrl: string): Promise<string> {
const downloadRes = await fetch(imageUrl);
if (!downloadRes.ok) throw new Error(`下载文内图片失败: HTTP ${downloadRes.status} — ${imageUrl}`);
const buffer = await downloadRes.arrayBuffer();
const contentType = downloadRes.headers.get('content-type') || 'image/jpeg';
const ext = contentType.split('/')[1] || 'jpg';
const { body, boundary } = buildMultipartBody('media', `content.${ext}`, buffer, contentType);
const res = await fetch(
`https://api.weixin.qq.com/cgi-bin/media/uploadimg?access_token=${accessToken}`,
{ method: 'POST', headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` }, body },
);
const json = (await res.json()) as { errcode?: number; errmsg?: string; url?: string };
if (json.errcode) throw new Error(`上传文内图片失败: ${json.errmsg} (code=${json.errcode})`);
return json.url!;
}
async function addDraft(accessToken: string, articles: unknown[]): Promise<string> {
const res = await fetch(`https://api.weixin.qq.com/cgi-bin/draft/add?access_token=${accessToken}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ articles }),
});
const json = (await res.json()) as { errcode?: number; errmsg?: string; media_id?: string };
if (json.errcode) throw new Error(`新增草稿失败: ${json.errmsg} (code=${json.errcode})`);
return json.media_id!;
}
export async function onRequest(request: Request, env: Env): Promise<Response> {
if (request.method === 'OPTIONS') return corsOptions();
if (request.method === 'GET') {
const configRaw = await env.RSS_KV.get(WECHAT_CONFIG_KEY);
if (!configRaw) return respond({ configured: false, message: '尚未配置微信公众号凭证' });
try {
const config = JSON.parse(configRaw);
return respond({ configured: true, appId: config.appId, hasSecret: !!config.appSecret });
} catch {
return respond({ configured: false, message: '配置解析失败' });
}
}
if (request.method === 'POST') {
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
let body: any;
try {
body = await request.json();
} catch {
return respond({ error: 'invalid json' }, 400);
}
const { appId, appSecret, articles } = body;
if (!articles || !Array.isArray(articles) || articles.length === 0) {
return respond({ error: 'articles 数组不能为空' }, 400);
}
let wechatAppId = appId;
let wechatAppSecret = appSecret;
if ((!wechatAppId || !wechatAppSecret)) {
const configRaw = await env.RSS_KV.get(WECHAT_CONFIG_KEY);
if (configRaw) {
try {
const config = JSON.parse(configRaw);
if (!wechatAppId) wechatAppId = config.appId;
if (!wechatAppSecret) wechatAppSecret = config.appSecret;
} catch {
// 忽略
}
}
}
if (!wechatAppId || !wechatAppSecret) {
return respond({ error: '请提供微信 appId 和 appSecret' }, 400);
}
try {
if (appId && appSecret) {
await env.RSS_KV.put(WECHAT_CONFIG_KEY, JSON.stringify({ appId, appSecret }));
}
const accessToken = await getAccessToken(env, wechatAppId, wechatAppSecret);
const processed: Record<string, unknown>[] = [];
for (const article of articles) {
const item = { ...article };
if (!item.title) throw new Error('文章 title 不能为空');
if (item.thumb_media_url && !item.thumb_media_id) {
item.thumb_media_id = await uploadThumbImage(accessToken, item.thumb_media_url);
}
delete item.thumb_media_url;
if (!item.thumb_media_id) throw new Error(`文章「${item.title}」缺少 thumb_media_id 或 thumb_media_url`);
if (item.content_image_urls && Array.isArray(item.content_image_urls)) {
for (const img of item.content_image_urls) {
const wxUrl = await uploadContentImage(accessToken, img.original_url);
item.content = item.content.split(img.original_url).join(wxUrl);
}
}
delete item.content_image_urls;
const allowed = [
'title', 'author', 'digest', 'content', 'content_source_url',
'thumb_media_id', 'show_cover_pic', 'need_open_comment',
'only_fans_can_comment', 'article_type',
];
const draft: Record<string, unknown> = {};
for (const key of allowed) {
if (item[key] !== undefined) draft[key] = item[key];
}
if (draft.show_cover_pic == null) draft.show_cover_pic = 1;
if (!draft.content) draft.content = '';
processed.push(draft);
}
const mediaId = await addDraft(accessToken, processed);
return respond({ ok: true, media_id: mediaId, message: `已同步 ${processed.length} 篇文章到公众号草稿箱` });
} catch (err) {
return respond({ error: (err as Error).message }, 500);
}
}
return respond({ error: 'method not allowed' }, 405);
}