chore: sync local changes to Gitea
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +1,4 @@
|
|||||||
.wrangler/
|
.wrangler/
|
||||||
.claude/
|
.claude/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
|||||||
82
README.md
82
README.md
@@ -1,9 +1,11 @@
|
|||||||
# Favicon 代理 API(Cloudflare Pages)
|
# Favicon 代理 API(Cloudflare Workers)
|
||||||
|
|
||||||
通过 HTTP API **代理**获取任意网站的 favicon,解决国内无法直连部分站点的问题。
|
通过 HTTP API **代理**获取任意网站的 favicon,解决国内无法直连部分站点的问题。
|
||||||
使用 **Google**、**DuckDuckGo** 与 **直连目标站** 三路同时请求,**谁先成功返回就用谁**。
|
使用 **Google**、**DuckDuckGo**、**直连目标站 /favicon.ico** 与 **首页 HTML 中的 `<link rel="icon">`** 多路同时请求,**谁先成功返回就用谁**。
|
||||||
|
|
||||||
> 说明:Bing 没有公开的 favicon 接口,因此第二源使用 DuckDuckGo;直连目标站(如 `https://域名/favicon.ico`)作为第三路,真正起到“代理访问被墙站”的作用。
|
前端为 **React + Vite** 静态站点,由 Workers **Static Assets** 托管;`/api/favicon` 在同一 Worker 上运行,**Worker 脚本仅包含 API 逻辑**(React 产物在资源目录中,不计入 Worker bundle)。
|
||||||
|
|
||||||
|
> 说明:Bing 没有公开的 favicon 接口,因此第二源使用 DuckDuckGo。
|
||||||
|
|
||||||
## 接口说明
|
## 接口说明
|
||||||
|
|
||||||
@@ -16,69 +18,55 @@
|
|||||||
## 示例
|
## 示例
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 使用 url 参数
|
curl -o favicon.ico "https://你的Workers域名/api/favicon?url=https://twitter.com"
|
||||||
curl -o favicon.ico "https://你的 Pages 域名/api/favicon?url=https://twitter.com"
|
curl -o favicon.ico "https://你的Workers域名/api/favicon?domain=github.com&size=64"
|
||||||
|
|
||||||
# 使用 domain 参数
|
|
||||||
curl -o favicon.ico "https://你的 Pages 域名/api/favicon?domain=github.com&size=64"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
前端使用:
|
前端使用:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<img src="https://你的 Pages 域名/api/favicon?url=https://example.com" alt="favicon" width="32" height="32" />
|
<img src="https://你的Workers域名/api/favicon?url=https://example.com" alt="favicon" width="32" height="32" />
|
||||||
```
|
```
|
||||||
|
|
||||||
## 部署到 Cloudflare Pages
|
## 开发与构建
|
||||||
|
|
||||||
### 方式一:通过 Wrangler CLI
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # Vite + Workers 本地开发(同官方 Cloudflare Vite 插件)
|
||||||
|
npm run build # tsc -b && vite build → dist/client + dist/cf_favicon
|
||||||
|
npm run deploy # 构建后 wrangler deploy(等价于下面单独一条命令的效果)
|
||||||
|
```
|
||||||
|
|
||||||
1. 安装 [Wrangler](https://developers.cloudflare.com/workers/wrangler/install-and-update/):
|
## 部署到 Cloudflare Workers
|
||||||
```bash
|
|
||||||
npm install -g wrangler
|
|
||||||
```
|
|
||||||
|
|
||||||
2. 登录 Cloudflare:
|
1. 安装 [Wrangler](https://developers.cloudflare.com/workers/wrangler/install-and-update/) 并登录:`wrangler login`
|
||||||
```bash
|
2. 在项目根目录执行:
|
||||||
wrangler login
|
|
||||||
```
|
|
||||||
|
|
||||||
3. 在项目根目录部署:
|
```bash
|
||||||
```bash
|
wrangler deploy
|
||||||
wrangler pages deploy . --project-name=cf-favicon
|
```
|
||||||
```
|
|
||||||
首次会提示创建项目,之后会得到 `https://cf-favicon.pages.dev` 这类地址。
|
|
||||||
|
|
||||||
### 方式二:通过 Git 连接(推荐)
|
Wrangler 会根据 [`wrangler.toml`](wrangler.toml) 中的 `[build] command` 先执行 `npm run build`,再上传 Worker(约几 KiB)与静态资源目录 `dist/client`。自定义域名可在 Dashboard 中为该 Worker 绑定。
|
||||||
|
|
||||||
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/
|
├── src/
|
||||||
├── functions/
|
│ ├── App.tsx # 说明页(React)
|
||||||
│ └── api/
|
│ ├── worker/
|
||||||
│ └── favicon.js # Favicon 代理 API 实现
|
│ │ ├── index.ts # Worker 入口(仅路由 /api/favicon)
|
||||||
├── public/
|
│ │ └── favicon.ts # Favicon 代理实现
|
||||||
│ └── favicon.ico # 可选:默认图标,当所有外部源都失败时返回此文件
|
├── index.html # Vite 入口
|
||||||
├── index.html # 简单说明页(可选)
|
├── vite.config.ts # @cloudflare/vite-plugin + React
|
||||||
├── wrangler.toml # 可选,用于 wrangler 部署
|
├── wrangler.toml # Workers + Static Assets(SPA 回退)
|
||||||
└── README.md
|
└── public/ # 可选:根路径 /favicon.ico,API 全源失败时的回退图标
|
||||||
```
|
```
|
||||||
|
|
||||||
## 行为说明
|
## 行为说明
|
||||||
|
|
||||||
- 请求会同时发往 **Google**、**DuckDuckGo**、**目标站 /favicon.ico** 以及 **目标站首页 HTML 中的 \<link rel="icon">**(支持 ico/png/jpg/webp/svg 等),采用“先成功先返回”,减少单点失败。
|
- 多路竞速详见上文;成功时返回图片及 `Cache-Control: public, max-age=86400`。
|
||||||
- **回退**:若三路均失败,会尝试返回 `public/favicon.ico`;若该文件存在则返回 200 并带响应头 `X-Favicon-Fallback: true`,否则返回 502。
|
- **回退**:若全部外部源失败,会通过 `env.ASSETS` 读取站点静态资源中的 **`/favicon.ico`**(请将可选图标放在 `public/favicon.ico`,构建后为站点根路径)。存在则返回 200 并带 `X-Favicon-Fallback: true`,否则 502 JSON。
|
||||||
- 运行在 Cloudflare 边缘,请求从 CF 出去,相当于代理访问,适合在国内环境调用以获取国外站点的 favicon。
|
- 运行在 Cloudflare 边缘,出站请求由 CF 发起,相当于代理访问。
|
||||||
- 成功时返回图片二进制和 `Cache-Control: public, max-age=86400`;失败时返回 400/502 及 JSON 错误信息。
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
84
api-docs/favicon-api.json
Normal file
84
api-docs/favicon-api.json
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
{
|
||||||
|
"info": {
|
||||||
|
"_postman_id": "a7f3c2e1-4b8d-4f6a-9c2e-1d5b8a4f6e30",
|
||||||
|
"name": "favicon-api",
|
||||||
|
"description": "代理获取任意网站 favicon(Google、DuckDuckGo、直连 /favicon.ico、解析页面 link 图标多路竞速),适用于边缘代理访问被墙站点。",
|
||||||
|
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||||
|
},
|
||||||
|
"variable": [
|
||||||
|
{
|
||||||
|
"key": "baseUrl",
|
||||||
|
"value": "https://favicon.api.smyhub.com",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "获取 favicon(domain)",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/favicon?domain=github.com&size=128",
|
||||||
|
"query": [
|
||||||
|
{
|
||||||
|
"key": "domain",
|
||||||
|
"value": "github.com",
|
||||||
|
"description": "目标域名,与 url、u 三选一"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "size",
|
||||||
|
"value": "128",
|
||||||
|
"description": "图标边长,16–256,默认 128(主要影响 Google 源)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "使用 `domain` 参数指定站点域名。"
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "获取 favicon(url)",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/favicon?url=https%3A%2F%2Ftwitter.com&size=128",
|
||||||
|
"query": [
|
||||||
|
{
|
||||||
|
"key": "url",
|
||||||
|
"value": "https://twitter.com",
|
||||||
|
"description": "完整 URL,与 domain、u 三选一"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "size",
|
||||||
|
"value": "128",
|
||||||
|
"description": "图标边长,16–256,默认 128"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "使用 `url` 参数传入完整页面地址,服务端会解析出域名。"
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "获取 favicon(u 简写)",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/favicon?u=example.com",
|
||||||
|
"query": [
|
||||||
|
{
|
||||||
|
"key": "u",
|
||||||
|
"value": "example.com",
|
||||||
|
"description": "域名或 URL 简写,与 domain、url 三选一"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "使用 `u` 作为 `url`/`domain` 的简写参数。"
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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': '*',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
48
index.html
48
index.html
@@ -1,42 +1,12 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Favicon 代理 API</title>
|
<title>Favicon 代理 API</title>
|
||||||
<style>
|
</head>
|
||||||
* { box-sizing: border-box; }
|
<body>
|
||||||
body { font-family: system-ui, sans-serif; max-width: 640px; margin: 0 auto; padding: 1.5rem; line-height: 1.6; }
|
<div id="root"></div>
|
||||||
h1 { font-size: 1.5rem; }
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
code { background: #f0f0f0; padding: .2em .4em; border-radius: 4px; }
|
</body>
|
||||||
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>
|
</html>
|
||||||
|
|||||||
3410
package-lock.json
generated
Normal file
3410
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
Normal file
29
package.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "cf-favicon",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"cf-typegen": "wrangler types",
|
||||||
|
"deploy": "npm run build && wrangler deploy",
|
||||||
|
"dev": "vite",
|
||||||
|
"preview": "npm run build && vite preview",
|
||||||
|
"check": "npm run build && wrangler deploy --dry-run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/vite-plugin": "^1.15.3",
|
||||||
|
"@cloudflare/workers-types": "^4.20250507.0",
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"typescript": "~5.8.0",
|
||||||
|
"vite": "^6.0.0",
|
||||||
|
"wrangler": "^4.56.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# 默认 Favicon
|
|
||||||
|
|
||||||
将你的默认 `favicon.ico` 放在此目录下。
|
|
||||||
|
|
||||||
当 Google、DuckDuckGo 与直连目标站都获取失败时,API 会返回本目录下的 `favicon.ico` 作为回退;若未放置该文件,则返回 502。
|
|
||||||
|
|
||||||
响应头会带上 `X-Favicon-Fallback: true` 表示本次返回的是默认图标。
|
|
||||||
37
src/App.css
Normal file
37
src/App.css
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
* {
|
||||||
|
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: 0.2em 0.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: 0.5rem;
|
||||||
|
}
|
||||||
53
src/App.tsx
Normal file
53
src/App.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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 className="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 className="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://你的 Workers 域名/api/favicon?url=https://example.com
|
||||||
|
</pre>
|
||||||
|
<p>返回为图片二进制(或 400/502 JSON 错误)。</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import App from "./App.tsx";
|
||||||
|
import "./App.css";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
183
src/worker/favicon.ts
Normal file
183
src/worker/favicon.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* Favicon 代理 API
|
||||||
|
* 通过 Google、DuckDuckGo、直连 /favicon.ico 与解析页面 <link rel="icon"> 多路竞速获取 favicon(支持 ico/png/jpg/webp)
|
||||||
|
* 注:Bing 无公开 favicon API,故使用 DuckDuckGo 作为第二源
|
||||||
|
*/
|
||||||
|
|
||||||
|
function parseDomain(input: string | null): string | null {
|
||||||
|
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: string, baseUrl: string): string | null {
|
||||||
|
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<T>(promises: Promise<T>[]): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let rejectedCount = 0;
|
||||||
|
const total = promises.length;
|
||||||
|
let done = false;
|
||||||
|
const onFulfill = (value: T) => {
|
||||||
|
if (!done) {
|
||||||
|
done = true;
|
||||||
|
resolve(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onReject = () => {
|
||||||
|
if (++rejectedCount === total && !done)
|
||||||
|
reject(new Error("All sources failed"));
|
||||||
|
};
|
||||||
|
promises.forEach((p) => p.then(onFulfill, onReject));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleFaviconGet(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
): Promise<Response> {
|
||||||
|
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: RequestInit = {
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "Mozilla/5.0 (compatible; FaviconProxy/1.0)",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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`;
|
||||||
|
|
||||||
|
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")),
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
})();
|
||||||
|
|
||||||
|
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 {
|
||||||
|
const fallbackReq = new Request(new URL("/favicon.ico", request.url));
|
||||||
|
const fallbackRes = await env.ASSETS.fetch(fallbackReq);
|
||||||
|
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,且未找到站点根路径 /favicon.ico 作为回退",
|
||||||
|
domain,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 502,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/worker/index.ts
Normal file
11
src/worker/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { handleFaviconGet } from "./favicon";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env): Promise<Response> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (request.method === "GET" && url.pathname === "/api/favicon") {
|
||||||
|
return handleFaviconGet(request, env);
|
||||||
|
}
|
||||||
|
return new Response("Not Found", { status: 404 });
|
||||||
|
},
|
||||||
|
};
|
||||||
25
tsconfig.app.json
Normal file
25
tsconfig.app.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
|
"exclude": ["src/worker/**"]
|
||||||
|
}
|
||||||
8
tsconfig.json
Normal file
8
tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.worker.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
14
tsconfig.node.json
Normal file
14
tsconfig.node.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
15
tsconfig.worker.json
Normal file
15
tsconfig.worker.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.worker.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"types": ["@cloudflare/workers-types"]
|
||||||
|
},
|
||||||
|
"include": ["src/worker/**/*.ts", "worker-configuration.d.ts"]
|
||||||
|
}
|
||||||
7
vite.config.ts
Normal file
7
vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { cloudflare } from "@cloudflare/vite-plugin";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), cloudflare()],
|
||||||
|
});
|
||||||
3
worker-configuration.d.ts
vendored
Normal file
3
worker-configuration.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
interface Env {
|
||||||
|
ASSETS: Fetcher;
|
||||||
|
}
|
||||||
@@ -1,3 +1,11 @@
|
|||||||
name = "cf-favicon"
|
name = "cf-favicon"
|
||||||
compatibility_date = "2024-01-01"
|
main = "src/worker/index.ts"
|
||||||
pages_build_output_dir = "."
|
compatibility_date = "2025-10-08"
|
||||||
|
|
||||||
|
[assets]
|
||||||
|
directory = "./dist/client"
|
||||||
|
binding = "ASSETS"
|
||||||
|
not_found_handling = "single-page-application"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
command = "npm run build"
|
||||||
|
|||||||
Reference in New Issue
Block a user