归档 artalk-cf 评论后端 + rss-robot 到 blog-admin(含技术选型/模块分布 README)
This commit is contained in:
1 parent
e1323bd260
commit
299ab57e4d
98 files changed
+15657
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user