/** * Favicon 代理 API * 通过 Google、DuckDuckGo、直连 /favicon.ico 与解析页面 多路竞速获取 favicon(支持 ico/png/jpg/webp) * 注:Bing 无公开 favicon API,故使用 DuckDuckGo 作为第二源 */ function parseDomain(input) { if (!input || typeof input !== 'string') return null const trimmed = input.trim() if (!trimmed) return null try { let url = trimmed if (!/^https?:\/\//i.test(trimmed)) url = `https://${trimmed}` const u = new URL(url) return u.hostname } catch { return null } } /** 从 HTML 中解析 的 href,支持 png/jpg/webp/svg 等 */ function parseFaviconFromHtml(html, baseUrl) { const linkRegex = /]+>/gi const links = html.match(linkRegex) || [] for (const tag of links) { const relMatch = tag.match(/\brel\s*=\s*["']([^"']+)["']/i) if (!relMatch) continue const rel = relMatch[1].toLowerCase() if (!rel.includes('icon')) continue const hrefMatch = tag.match(/\bhref\s*=\s*["']([^"']+)["']/i) if (!hrefMatch) continue const href = hrefMatch[1].trim() if (!href || href.startsWith('data:')) continue try { return new URL(href, baseUrl).href } catch { continue } } return null } function firstSuccess(promises) { return new Promise((resolve, reject) => { let rejectedCount = 0 const total = promises.length const onFulfill = (value) => { if (!resolve.done) { resolve.done = true resolve(value) } } const onReject = () => { if (++rejectedCount === total && !resolve.done) reject(new Error('All sources failed')) } promises.forEach((p) => p.then(onFulfill, onReject)) }) } export async function onRequestGet(context) { const { request } = context const url = new URL(request.url) const domainParam = url.searchParams.get('domain') ?? url.searchParams.get('url') ?? url.searchParams.get('u') const domain = parseDomain(domainParam) if (!domain) { return new Response( JSON.stringify({ error: 'missing_domain', message: '请提供 domain、url 或 u 参数,例如: ?url=https://twitter.com 或 ?domain=github.com', }), { status: 400, headers: { 'Content-Type': 'application/json; charset=utf-8', 'Access-Control-Allow-Origin': '*', }, } ) } const size = Math.min(256, Math.max(16, parseInt(url.searchParams.get('size') || '128', 10) || 128)) const fetchOpts = { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; FaviconProxy/1.0)', }, } // 多路:Google、DuckDuckGo、直连 /favicon.ico、解析页面 (支持 png/jpg/webp) const googleUrl = `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=${size}` const ddgUrl = `https://icons.duckduckgo.com/ip3/${encodeURIComponent(domain)}.ico` const directUrl = `https://${domain}/favicon.ico` // 四路竞速:任一路成功即返回,都不参与 fallback const googlePromise = fetch(googleUrl, fetchOpts).then((r) => r.ok ? r : Promise.reject(new Error('Google non-OK')) ) const ddgPromise = fetch(ddgUrl, fetchOpts).then((r) => r.ok ? r : Promise.reject(new Error('DDG non-OK')) ) const directPromise = fetch(directUrl, fetchOpts).then((r) => r.ok ? r : Promise.reject(new Error('Direct non-OK')) ) // 抓取首页 HTML,解析 ,支持 png/jpg/webp/svg 等 const fromHtmlPromise = (async () => { const pageRes = await fetch(`https://${domain}/`, { ...fetchOpts, redirect: 'follow' }) if (!pageRes.ok) throw new Error('Page non-OK') const html = await pageRes.text() const baseUrl = pageRes.url || `https://${domain}/` const iconUrl = parseFaviconFromHtml(html, baseUrl) if (!iconUrl) throw new Error('No icon in HTML') const iconRes = await fetch(iconUrl, fetchOpts) if (!iconRes.ok) throw new Error('Icon fetch non-OK') return iconRes })() // 仅当以上四路全部失败时,才尝试 public/favicon.ico(最后才用,不参与竞速) try { const response = await firstSuccess([googlePromise, ddgPromise, directPromise, fromHtmlPromise]) const contentType = response.headers.get('Content-Type') || 'image/x-icon' const body = await response.arrayBuffer() return new Response(body, { status: 200, headers: { 'Content-Type': contentType, 'Cache-Control': 'public, max-age=86400', 'Access-Control-Allow-Origin': '*', 'X-Favicon-Domain': domain, }, }) } catch (e) { // 仅当四路(Google、DDG、直连 ico、页面解析)全部失败后,才用 public/favicon.ico const fallbackUrl = `${url.origin}/public/favicon.ico` const fallbackRes = await fetch(fallbackUrl, fetchOpts) if (fallbackRes.ok) { const contentType = fallbackRes.headers.get('Content-Type') || 'image/x-icon' const body = await fallbackRes.arrayBuffer() return new Response(body, { status: 200, headers: { 'Content-Type': contentType, 'Cache-Control': 'public, max-age=86400', 'Access-Control-Allow-Origin': '*', 'X-Favicon-Domain': domain, 'X-Favicon-Fallback': 'true', }, }) } return new Response( JSON.stringify({ error: 'fetch_failed', message: '无法从多路源获取该域名的 favicon,且未找到 public/favicon.ico 作为回退', domain, }), { status: 502, headers: { 'Content-Type': 'application/json; charset=utf-8', 'Access-Control-Allow-Origin': '*', }, } ) } }