From f0d79638c910c7a4d24f38b428779ad5b7c6281a Mon Sep 17 00:00:00 2001 From: shumengya Date: Wed, 11 Mar 2026 20:08:29 +0800 Subject: [PATCH] shumengya --- .gitignore | 2 + README.md | 85 ++++++++++++++++++++ functions/api/favicon.js | 167 +++++++++++++++++++++++++++++++++++++++ index.html | 42 ++++++++++ public/.gitkeep | 0 public/README.md | 7 ++ public/favicon.ico | Bin 0 -> 67646 bytes wrangler.toml | 3 + 8 files changed, 306 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 functions/api/favicon.js create mode 100644 index.html create mode 100644 public/.gitkeep create mode 100644 public/README.md create mode 100644 public/favicon.ico create mode 100644 wrangler.toml 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 0000000000000000000000000000000000000000..4c8c42567f76eedd41918fe1fd3d7473d2093ebf GIT binary patch literal 67646 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 LPCoIZv8?|GC+lM+ literal 0 HcmV?d00001 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 = "."