Files
blog/blog-admin/src/routes/rss/tools.ts
T

405 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// /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 });
}