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

410 lines
17 KiB
TypeScript
Raw Normal View History

// /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;
}
}
}
/**
* favicon 图片响应的统一缓存头。
*
* ★ 为什么要显式加 s-maxage / CDN-Cache-Control:
* `Cache-Control: max-age=86400` 只让**浏览器**缓存,Cloudflare 边缘默认
* **不缓存 Worker 的响应**(实测响应里没有 cf-cache-status)。于是每个访客、
* 每次清缓存、每换设备,都会真正进 Worker → 第一条语句就是 KV.get → KV 读量被放大。
*
* 加上 s-maxage(共享缓存)后,CF 边缘可缓存 24h:边缘命中就完全不进 Worker,
* KV 读取 ≈ 0,同时首包更快。浏览器侧仍保留 max-age。
* 只对**成功拿到图片**的响应使用;失败/兜底响应不缓存(避免把错误固化一天)。
*/
function imageCacheHeaders(contentType: string): Record<string, string> {
return {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=86400, s-maxage=86400',
'CDN-Cache-Control': 'max-age=86400',
'Cloudflare-CDN-Cache-Control': 'max-age=86400',
'Access-Control-Allow-Origin': '*',
};
}
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: imageCacheHeaders(meta) });
}
// 失败短缓存:抓取失败的站点 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: imageCacheHeaders(contentType) });
}
} 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: imageCacheHeaders(contentType) });
}
} 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: {
// 兜底图也缓存(否则每个访客都要重跑一遍抓取失败流程 = 3 次 KV 读 + 1 次写)
'Content-Type': 'image/svg+xml',
'Cache-Control': 'public, max-age=86400, s-maxage=86400',
'CDN-Cache-Control': 'max-age=86400',
'Cloudflare-CDN-Cache-Control': '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 鉴权)。
* 历史用途:与广州中转机 `sync.sh` 配套 —— 构建通知告诉你"构建好了",
* 这封邮件告诉你"国内线路真正生效了"。
* (2026-10-04 起国内线路改由 CNB 国内节点直传又拍云,该轮询链路已停用;
* 端点保留以兼容旧调用方。)
*/
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 });
}