// /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 { 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 = [ /]+rel=["'](?:shortcut )?icon["'][^>]+href=["']([^"']+)["']/i, /]+href=["']([^"']+)["'][^>]+rel=["'](?:shortcut )?icon["']/i, /]+rel=["']apple-touch-icon["'][^>]+href=["']([^"']+)["']/i, /]+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 { 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 { 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 = `` + `` + `${letter}`; 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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: `

构建产物 ${hash} 已同步到又拍云并刷新多吉云 CDN,国内线路已生效。

` + `

时间:${new Date().toISOString()} · artalk-cf deploy monitor

`, }); return respond({ ok: okMail }); }