168 lines
5.8 KiB
JavaScript
168 lines
5.8 KiB
JavaScript
/**
|
||
* 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': '*',
|
||
},
|
||
}
|
||
)
|
||
}
|
||
}
|