Files
cf-favicon/functions/api/favicon.js
2026-03-11 20:08:29 +08:00

168 lines
5.8 KiB
JavaScript
Raw Permalink 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.
/**
* Favicon 代理 API
* 通过 Google、DuckDuckGo、直连 /favicon.ico 与解析页面 <link rel="icon"> 多路竞速获取 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 中解析 <link rel="icon"> 的 href支持 png/jpg/webp/svg 等 */
function parseFaviconFromHtml(html, baseUrl) {
const linkRegex = /<link[^>]+>/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、解析页面 <link rel="icon">(支持 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解析 <link rel="icon" href="...">,支持 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': '*',
},
}
)
}
}