shumengya
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.wrangler/
|
||||
.claude/
|
||||
85
README.md
Normal file
85
README.md
Normal file
@@ -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
|
||||
<img src="https://你的 Pages 域名/api/favicon?url=https://example.com" alt="favicon" width="32" height="32" />
|
||||
```
|
||||
|
||||
## 部署到 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 中的 \<link rel="icon">**(支持 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
|
||||
167
functions/api/favicon.js
Normal file
167
functions/api/favicon.js
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* 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': '*',
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
42
index.html
Normal file
42
index.html
Normal file
@@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Favicon 代理 API</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: system-ui, sans-serif; max-width: 640px; margin: 0 auto; padding: 1.5rem; line-height: 1.6; }
|
||||
h1 { font-size: 1.5rem; }
|
||||
code { background: #f0f0f0; padding: .2em .4em; border-radius: 4px; }
|
||||
pre { background: #f5f5f5; padding: 1rem; overflow: auto; border-radius: 6px; }
|
||||
.example { margin: 1rem 0; }
|
||||
.example img { vertical-align: middle; margin-right: .5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Favicon 代理 API</h1>
|
||||
<p>通过 HTTP API 代理获取任意网站的 favicon,使用 <strong>Google</strong>、<strong>DuckDuckGo</strong> 与<strong>直连目标站</strong>三路竞速,谁先返回就用谁,适合在国内无法直连的站点。(Bing 无公开 favicon API,故用 DuckDuckGo 作为第二源。)</p>
|
||||
|
||||
<h2>用法</h2>
|
||||
<p>GET 请求,支持参数:</p>
|
||||
<ul>
|
||||
<li><code>url</code> 或 <code>domain</code> 或 <code>u</code>:目标网站地址或域名(必填)</li>
|
||||
<li><code>size</code>:图标尺寸,16–256,默认 128(仅对 Google 源有效)</li>
|
||||
</ul>
|
||||
|
||||
<h2>示例</h2>
|
||||
<div class="example">
|
||||
<code>/api/favicon?url=https://twitter.com</code><br>
|
||||
<img src="/api/favicon?url=https://twitter.com" alt="twitter" width="32" height="32">
|
||||
</div>
|
||||
<div class="example">
|
||||
<code>/api/favicon?domain=github.com&size=64</code><br>
|
||||
<img src="/api/favicon?domain=github.com&size=64" alt="github" width="64" height="64">
|
||||
</div>
|
||||
|
||||
<h2>直接调用</h2>
|
||||
<pre>GET https://你的 Pages 域名/api/favicon?url=https://example.com</pre>
|
||||
<p>返回为图片二进制(或 400/502 JSON 错误)。</p>
|
||||
</body>
|
||||
</html>
|
||||
0
public/.gitkeep
Normal file
0
public/.gitkeep
Normal file
7
public/README.md
Normal file
7
public/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# 默认 Favicon
|
||||
|
||||
将你的默认 `favicon.ico` 放在此目录下。
|
||||
|
||||
当 Google、DuckDuckGo 与直连目标站都获取失败时,API 会返回本目录下的 `favicon.ico` 作为回退;若未放置该文件,则返回 502。
|
||||
|
||||
响应头会带上 `X-Favicon-Fallback: true` 表示本次返回的是默认图标。
|
||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
3
wrangler.toml
Normal file
3
wrangler.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
name = "cf-favicon"
|
||||
compatibility_date = "2024-01-01"
|
||||
pages_build_output_dir = "."
|
||||
Reference in New Issue
Block a user