commit f0d79638c910c7a4d24f38b428779ad5b7c6281a Author: shumengya Date: Wed Mar 11 20:08:29 2026 +0800 shumengya diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c0b6c30 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.wrangler/ +.claude/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..a13593a --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# Favicon 代理 API(Cloudflare Pages) + +通过 HTTP API **代理**获取任意网站的 favicon,解决国内无法直连部分站点的问题。 +使用 **Google**、**DuckDuckGo** 与 **直连目标站** 三路同时请求,**谁先成功返回就用谁**。 + +> 说明:Bing 没有公开的 favicon 接口,因此第二源使用 DuckDuckGo;直连目标站(如 `https://域名/favicon.ico`)作为第三路,真正起到“代理访问被墙站”的作用。 + +## 接口说明 + +- **方法**: `GET` +- **路径**: `/api/favicon` +- **参数**: + - `url` / `domain` / `u`(必填):目标网站完整 URL 或域名,如 `https://twitter.com` 或 `github.com` + - `size`(可选):图标尺寸 16–256,默认 128(仅对 Google 源生效) + +## 示例 + +```bash +# 使用 url 参数 +curl -o favicon.ico "https://你的 Pages 域名/api/favicon?url=https://twitter.com" + +# 使用 domain 参数 +curl -o favicon.ico "https://你的 Pages 域名/api/favicon?domain=github.com&size=64" +``` + +前端使用: + +```html +favicon +``` + +## 部署到 Cloudflare Pages + +### 方式一:通过 Wrangler CLI + +1. 安装 [Wrangler](https://developers.cloudflare.com/workers/wrangler/install-and-update/): + ```bash + npm install -g wrangler + ``` + +2. 登录 Cloudflare: + ```bash + wrangler login + ``` + +3. 在项目根目录部署: + ```bash + wrangler pages deploy . --project-name=cf-favicon + ``` + 首次会提示创建项目,之后会得到 `https://cf-favicon.pages.dev` 这类地址。 + +### 方式二:通过 Git 连接(推荐) + +1. 将本仓库推送到 GitHub/GitLab。 +2. 在 [Cloudflare Dashboard](https://dash.cloudflare.com) → **Workers & Pages** → **Create** → **Pages** → **Connect to Git**。 +3. 选择仓库,构建配置: + - **Build command**: 留空(无构建步骤) + - **Build output directory**: `/` 或 `.`(根目录即为静态资源) +4. 保存并部署。 + 部署完成后 API 地址为:`https://你的项目名.pages.dev/api/favicon?...` + +## 项目结构 + +``` +cf-favicon/ +├── functions/ +│ └── api/ +│ └── favicon.js # Favicon 代理 API 实现 +├── public/ +│ └── favicon.ico # 可选:默认图标,当所有外部源都失败时返回此文件 +├── index.html # 简单说明页(可选) +├── wrangler.toml # 可选,用于 wrangler 部署 +└── README.md +``` + +## 行为说明 + +- 请求会同时发往 **Google**、**DuckDuckGo**、**目标站 /favicon.ico** 以及 **目标站首页 HTML 中的 \**(支持 ico/png/jpg/webp/svg 等),采用“先成功先返回”,减少单点失败。 +- **回退**:若三路均失败,会尝试返回 `public/favicon.ico`;若该文件存在则返回 200 并带响应头 `X-Favicon-Fallback: true`,否则返回 502。 +- 运行在 Cloudflare 边缘,请求从 CF 出去,相当于代理访问,适合在国内环境调用以获取国外站点的 favicon。 +- 成功时返回图片二进制和 `Cache-Control: public, max-age=86400`;失败时返回 400/502 及 JSON 错误信息。 + +## License + +MIT diff --git a/functions/api/favicon.js b/functions/api/favicon.js new file mode 100644 index 0000000..c277ac8 --- /dev/null +++ b/functions/api/favicon.js @@ -0,0 +1,167 @@ +/** + * 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': '*', + }, + } + ) + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..371a00b --- /dev/null +++ b/index.html @@ -0,0 +1,42 @@ + + + + + + Favicon 代理 API + + + +

Favicon 代理 API

+

通过 HTTP API 代理获取任意网站的 favicon,使用 GoogleDuckDuckGo直连目标站三路竞速,谁先返回就用谁,适合在国内无法直连的站点。(Bing 无公开 favicon API,故用 DuckDuckGo 作为第二源。)

+ +

用法

+

GET 请求,支持参数:

+ + +

示例

+
+ /api/favicon?url=https://twitter.com
+ twitter +
+
+ /api/favicon?domain=github.com&size=64
+ github +
+ +

直接调用

+
GET https://你的 Pages 域名/api/favicon?url=https://example.com
+

返回为图片二进制(或 400/502 JSON 错误)。

+ + diff --git a/public/.gitkeep b/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/README.md b/public/README.md new file mode 100644 index 0000000..57a4bee --- /dev/null +++ b/public/README.md @@ -0,0 +1,7 @@ +# 默认 Favicon + +将你的默认 `favicon.ico` 放在此目录下。 + +当 Google、DuckDuckGo 与直连目标站都获取失败时,API 会返回本目录下的 `favicon.ico` 作为回退;若未放置该文件,则返回 502。 + +响应头会带上 `X-Favicon-Fallback: true` 表示本次返回的是默认图标。 diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..4c8c425 Binary files /dev/null and b/public/favicon.ico differ diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..30705a4 --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,3 @@ +name = "cf-favicon" +compatibility_date = "2024-01-01" +pages_build_output_dir = "."