前言
做导航站的朋友都知道,每个网站都需要展示图标(favicon)和网页截图。这两个功能看似简单,但实际操作中会遇到不少问题:
- 图标获取:Google 的 Favicon API 国内无法直接访问,直接请求目标网站的
favicon.ico又经常 404 - 截图获取:WordPress 的 mshots 截图服务(
s0.wp.com/mshots/v1/)在国内访问不稳定,且存在 CORS 问题 - 缓存问题:每次用户访问都实时抓取,既慢又浪费资源
本文介绍一种零成本方案:利用腾讯云 EdgeOne 的边缘函数(Edge Functions),在你的加速域名下部署两个 API 接口,实现图标和截图的代理获取、多源回退、CDN 缓存,前端直接引用即可。
适用场景:你的网站已接入 EdgeOne 作为 CDN 加速(无论源站在哪),都可以用本方案。
一、方案原理
用户浏览器
↓
EdgeOne CDN 节点(国内)
↓ 触发规则匹配 /api/*
↓
边缘函数执行(在 EdgeOne 边缘节点上)
├── /api/icon → 多源抓取图标(目标站 → DuckDuckGo → Google)
└── /api/mshots → 多源抓取截图(mshots → PageShot → urlscan)
↓
返回图片 + 设置缓存头
↓
EdgeOne 节点缓存,后续相同请求直接命中
核心优势:
- 请求在 EdgeOne 边缘节点处理,不回源到你的服务器,零负载
- 多源自动回退,一个源挂了自动换下一个
- EdgeOne 节点缓存,相同请求不再重复抓取
- 所有源都失败时返回缺省占位图,前端不会出现 broken image
二、前置准备
- EdgeOne 账号:注册 腾讯云 EdgeOne,免费版即可
- 已接入站点:你的域名已在 EdgeOne 中添加并开启加速(NS 接入或 CNAME 接入均可)
- 域名备案:如果加速区域包含中国大陆,域名需完成 ICP 备案
如果你还没有接入 EdgeOne,可以参考腾讯云官方文档 从零开始接入 EdgeOne。
三、创建边缘函数
3.1 进入函数管理
- 登录 EdgeOne 控制台
- 左侧菜单 → 服务总览 → 在”网站安全加速”中点击你的站点域名
- 站点详情页左侧导航栏 → 边缘函数 → 函数管理
3.2 新建函数
- 点击 新建函数 → 选择 创建空白函数
- 填写以下信息:
-
函数名称: nav-proxy(只能包含字母、数字、连字符,2-30 个字符) -
描述: 导航站图标和截图代理服务(可选)
-
- 将以下代码粘贴到代码编辑框中
3.3 完整代码
/**
* EdgeOne 边缘函数 - 导航站代理服务
*
* 功能模块:
* M1: 图标资源代理 - /api/icon?domain=example.com
* M2: 截图资源代理 - /api/mshots?url=https://example.com
*
* 多源回退策略:
* 图标: 目标站 favicon.ico → DuckDuckGo → Google
* 截图: WordPress mshots → PageShot → urlscan.io
*
* 所有源失败时返回缺省 SVG 占位图
*/
// ============================================================
// 常量配置
// ============================================================
const CONFIG = {
// 请求超时时间(毫秒)
FETCH_TIMEOUT: 10000,
// 图标缓存时间(秒) - 24小时
ICON_CACHE_TTL: 86400,
// 截图缓存时间(秒) - 1小时
MSHOTS_CACHE_TTL: 3600,
// User-Agent
UA: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
// 缺省图标 SVG (灰色方块 + 问号)
DEFAULT_ICON: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="8" fill="#e5e7eb"/><text x="32" y="42" text-anchor="middle" font-family="Arial,sans-serif" font-size="32" fill="#9ca3af">?</text></svg>`,
// 缺省截图 SVG (灰色背景 + 提示文字)
DEFAULT_MSHOTS: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600"><rect width="800" height="600" fill="#f3f4f6"/><text x="400" y="300" text-anchor="middle" font-family="Arial,sans-serif" font-size="24" fill="#9ca3af">Screenshot Unavailable</text></svg>`,
};
// ============================================================
// 入口函数
// ============================================================
addEventListener('fetch', (event) => {
event.respondWith(handleRequest(event.request));
});
async function handleRequest(request) {
const url = new URL(request.url);
const path = url.pathname;
// 统一设置 CORS 头
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
// 预检请求处理
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
}
let response;
if (path.startsWith('/api/icon')) {
response = await handleIcon(request, url);
} else if (path.startsWith('/api/mshots')) {
response = await handleMshots(request, url);
} else {
return new Response(CONFIG.DEFAULT_ICON, {
status: 200,
headers: { 'Content-Type': 'image/svg+xml', ...corsHeaders },
});
}
// 合并 CORS 头
for (const [key, value] of Object.entries(corsHeaders)) {
if (!response.headers.has(key)) {
response = new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: { ...Object.fromEntries(response.headers), [key]: value },
});
}
}
return response;
}
// ============================================================
// M1: 图标资源代理模块
// ============================================================
/**
* 多源获取网站图标,按优先级依次尝试:
* 1. 目标网站根目录 /favicon.ico (最快,最准确)
* 2. DuckDuckGo 图标服务 (国内可访问)
* 3. Google Favicon API (备用)
*
* 参数:
* domain - 目标网站域名或完整URL (必填)
*/
async function handleIcon(request, url) {
const domain = url.searchParams.get('domain');
if (!domain) {
return new Response(CONFIG.DEFAULT_ICON, {
status: 200,
headers: { 'Content-Type': 'image/svg+xml' },
});
}
// 清理 domain 格式:去除协议前缀和路径
const cleanDomain = domain.replace(/^https?:\/\//, '').replace(/\/.*$/, '').split('/')[0].trim();
if (!cleanDomain || !cleanDomain.includes('.')) {
return new Response(CONFIG.DEFAULT_ICON, {
status: 200,
headers: { 'Content-Type': 'image/svg+xml' },
});
}
// 图标获取源列表(按优先级排序)
const sources = [
{ url: `https://${cleanDomain}/favicon.ico`, label: 'target' },
{ url: `https://icons.duckduckgo.com/ip3/${cleanDomain}.ico`, label: 'duckduckgo' },
{ url: `https://www.google.com/s2/favicons?domain=${cleanDomain}&sz=64`, label: 'google' },
];
for (const source of sources) {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), CONFIG.FETCH_TIMEOUT);
const resp = await fetch(source.url, {
signal: controller.signal,
headers: { 'User-Agent': CONFIG.UA },
});
clearTimeout(timer);
if (resp.ok) {
const contentType = resp.headers.get('content-type') || 'image/x-icon';
const buffer = await resp.arrayBuffer();
console.log(`[ICON] SUCCESS source=${source.label} domain=${cleanDomain} size=${buffer.byteLength}`);
return new Response(buffer, {
status: 200,
headers: {
'Content-Type': contentType,
'Content-Length': buffer.byteLength.toString(),
'Cache-Control': `public, max-age=${CONFIG.ICON_CACHE_TTL}`,
},
});
}
} catch (err) {
console.log(`[ICON] FAILED source=${source.label} domain=${cleanDomain} error=${err.message}`);
continue;
}
}
// 所有源均失败,返回缺省图标
console.log(`[ICON] ALL_FAILED domain=${cleanDomain}, returning default icon`);
return new Response(CONFIG.DEFAULT_ICON, {
status: 200,
headers: {
'Content-Type': 'image/svg+xml',
'Cache-Control': `public, max-age=${CONFIG.ICON_CACHE_TTL}`,
},
});
}
// ============================================================
// M2: 截图代理模块 (HTTPS 反代 + 多源回退)
// ============================================================
/**
* 多源获取网页截图,按优先级依次尝试:
* 1. WordPress mshots (s0.wp.com) - 主源,支持 w/h
* 2. PageShot (pageshot.site) - 备用,完全免费无限制
* 3. urlscan.io - 最终备用
*
* 参数:
* url - 目标网页 URL (必填)
* w - 截图宽度(像素) (可选)
* h - 截图高度(像素) (可选)
*/
async function handleMshots(request, url) {
const targetUrl = url.searchParams.get('url');
if (!targetUrl) {
return new Response(CONFIG.DEFAULT_MSHOTS, {
status: 200,
headers: { 'Content-Type': 'image/svg+xml' },
});
}
// 验证 URL 格式
try {
new URL(targetUrl);
} catch {
return new Response(CONFIG.DEFAULT_MSHOTS, {
status: 200,
headers: { 'Content-Type': 'image/svg+xml' },
});
}
// 读取宽高参数
const width = url.searchParams.get('w');
const height = url.searchParams.get('h');
const w = width && /^\d+$/.test(width) ? width : '';
const h = height && /^\d+$/.test(height) ? height : '';
// 截图源列表(按优先级排序)
const encodedUrl = encodeURIComponent(targetUrl);
const sources = [
{
label: 'mshots',
url: `https://s0.wp.com/mshots/v1/${encodedUrl}` +
(w || h ? `?${w ? `w=${w}` : ''}${w && h ? '&' : ''}${h ? `h=${h}` : ''}` : ''),
},
{
label: 'pageshot',
url: `https://pageshot.site/v1/screenshot?url=${encodedUrl}` +
(w ? `&width=${w}` : '') +
(h ? `&height=${h}` : '') +
'&format=jpeg',
},
{
label: 'urlscan',
url: `https://urlscan.io/liveshot/?${w ? `width=${w}&` : ''}${h ? `height=${h}&` : ''}url=${encodedUrl}`,
},
];
console.log(`[MSHOTS] start url=${targetUrl}${w ? ` w=${w}` : ''}${h ? ` h=${h}` : ''}`);
for (const source of sources) {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), CONFIG.FETCH_TIMEOUT);
const resp = await fetch(source.url, {
signal: controller.signal,
headers: { 'User-Agent': CONFIG.UA, 'Accept': 'image/webp,image/jpeg,*/*' },
});
clearTimeout(timer);
if (resp.ok) {
const contentType = resp.headers.get('content-type') || 'image/jpeg';
const buffer = await resp.arrayBuffer();
console.log(`[MSHOTS] SUCCESS source=${source.label} url=${targetUrl} size=${buffer.byteLength}`);
return new Response(buffer, {
status: 200,
headers: {
'Content-Type': contentType,
'Content-Length': buffer.byteLength.toString(),
'Cache-Control': `public, max-age=${CONFIG.MSHOTS_CACHE_TTL}`,
},
});
}
console.log(`[MSHOTS] RETRY source=${source.label} status=${resp.status} url=${targetUrl}`);
} catch (err) {
console.log(`[MSHOTS] RETRY source=${source.label} error=${err.message} url=${targetUrl}`);
continue;
}
}
// 所有源均失败,返回缺省截图
console.log(`[MSHOTS] ALL_FAILED url=${targetUrl}, returning default screenshot`);
return new Response(CONFIG.DEFAULT_MSHOTS, {
status: 200,
headers: {
'Content-Type': 'image/svg+xml',
'Cache-Control': `public, max-age=${CONFIG.MSHOTS_CACHE_TTL}`,
},
});
}
- 点击 保存,然后点击 部署
四、配置触发规则
触发规则决定哪些 URL 请求会走边缘函数,而不是回源到你的服务器。
4.1 添加触发规则
- 函数部署成功后,点击 新增触发规则(如果弹窗已关闭,去 边缘函数 → 触发规则 标签页)
- 按以下配置填写:
| 配置项 | 值 |
|---|---|
| 匹配类型 | URL path |
| 运算符 | 正则匹配 |
| 值 | ^/api/ |
| 操作 | 选择刚创建的 nav-proxy 函数 |
- 点击 确定,规则立即生效
4.2 规则说明
^/api/匹配所有以/api/开头的路径,如/api/icon、/api/mshots- 匹配到的请求会在 EdgeOne 边缘节点被函数处理,不会回源到你的服务器
- 其他路径(如
/wp-content/*、/)仍然正常回源,互不影响
五、配置缓存规则(推荐)
为了让 EdgeOne 节点缓存抓取结果,减少函数调用次数,建议在规则引擎中添加缓存规则。
5.1 图标缓存规则
进入 站点加速 → 规则引擎 → 创建规则:
| 配置项 | 值 |
|---|---|
| 规则名称 | cache-icon |
| 匹配类型 | URL path |
| 运算符 | 正则匹配 |
| 值 | ^/api/icon |
| 操作 | 缓存配置 |
| 缓存模式 | 遵循源站 |
| 缓存时间 | 86400 秒(24小时) |
5.2 截图缓存规则
同样方式创建第二条规则:
| 配置项 | 值 |
|---|---|
| 规则名称 | cache-mshots |
| 匹配类型 | URL path |
| 运算符 | 正则匹配 |
| 值 | ^/api/mshots |
| 操作 | 缓存配置 |
| 缓存模式 | 遵循源站 |
| 缓存时间 | 3600 秒(1小时) |
注意:截图缓存时间不宜过长,因为网页内容会更新。1 小时是比较合理的值。
六、验证部署
6.1 测试图标接口
在浏览器中访问:
https://你的域名/api/icon?domain=github.com
预期结果:返回 GitHub 的 favicon 图标。
再测试一个不存在的域名:
https://你的域名/api/icon?domain=not-exist-12345.xyz
预期结果:返回灰色问号占位图(缺省图标)。
6.2 测试截图接口
# 默认尺寸截图
https://你的域名/api/mshots?url=https://github.com
# 指定宽高
https://你的域名/api/mshots?url=https://github.com&w=800&h=600
预期结果:返回网页截图(首次请求可能需要等几秒,mshots 最大支持 1280×960)。
七、前端集成
7.1 HTML 直接引用
<!-- 网站图标 -->
<img src="https://你的域名/api/icon?domain=github.com"
alt="GitHub" width="32" height="32">
<!-- 网页截图(默认尺寸) -->
<img src="https://你的域名/api/mshots?url=https://github.com"
alt="GitHub 截图">
<!-- 网页截图(指定宽高) -->
<img src="https://你的域名/api/mshots?url=https://github.com&w=800&h=600"
alt="GitHub 截图">
7.2 JavaScript 动态拼接
/**
* 获取网站图标 URL
* @param {string} domain - 域名或完整URL
* @returns {string} 图标代理 URL
*/
function getIconUrl(domain) {
return `https://你的域名/api/icon?domain=${domain}`;
}
/**
* 获取网页截图 URL
* @param {string} url - 目标网页 URL
* @param {number} [width] - 截图宽度(像素)
* @param {number} [height] - 截图高度(像素)
* @returns {string} 截图代理 URL
*/
function getSnapshotUrl(url, width, height) {
let api = `https://你的域名/api/mshots?url=${encodeURIComponent(url)}`;
if (width) api += `&w=${width}`;
if (height) api += `&h=${height}`;
return api;
}
// 使用示例
const iconUrl = getIconUrl('https://github.com');
const snapshotUrl = getSnapshotUrl('https://github.com', 800, 600);
7.3 在导航站卡片中使用
以常见的导航站卡片为例:
<div class="site-card">
<img class="site-icon"
src="https://你的域名/api/icon?domain=https://github.com"
alt="图标"
width="48" height="48"
loading="lazy">
<img class="site-screenshot"
src="https://你的域名/api/mshots?url=https://github.com&w=400&h=300"
alt="截图"
loading="lazy">
<h3>GitHub</h3>
<p>代码托管平台</p>
</div>
使用
loading="lazy"实现懒加载,提升页面性能。
八、多源回退策略说明
8.1 图标回退链
| 优先级 | 源 | 说明 |
|---|---|---|
| 1 | 目标站 /favicon.ico |
直接请求目标网站,最快最准确 |
| 2 | DuckDuckGo 图标服务 | 国内可访问,覆盖率高 |
| 3 | Google Favicon API | 最终备用,国外节点可访问 |
8.2 截图回退链
| 优先级 | 源 | 说明 |
|---|---|---|
| 1 | WordPress mshots | 主源,支持 w/h 参数,最大 1280×960 |
| 2 | PageShot | 备用,完全免费无限制,支持宽高参数 |
| 3 | urlscan.io | 最终备用,支持宽高参数 |
8.3 缺省占位图
当所有源都失败时(如目标网站不存在、所有外部服务不可用),接口会返回内置的 SVG 占位图:
- 图标缺省:64×64 灰色圆角方块,中间显示
? - 截图缺省:800×600 灰色背景,中间显示 “Screenshot Unavailable”
这样前端 <img> 标签永远不会出现 broken image,页面始终美观。
九、参数说明
9.1 图标接口 /api/icon
| 参数 | 必填 | 说明 | 示例 |
|---|---|---|---|
domain |
是 | 目标网站域名或完整 URL | github.com 或 https://github.com |
代码会自动清理
domain参数,去除协议前缀和路径,只保留域名部分。
9.2 截图接口 /api/mshots
| 参数 | 必填 | 说明 | 示例 |
|---|---|---|---|
url |
是 | 目标网页完整 URL | https://github.com |
w |
否 | 截图宽度(像素) | 800 |
h |
否 | 截图高度(像素) | 600 |
mshots 最大截图尺寸为 1280×960。
h参数仅控制返回图片的裁剪高度,不影响实际视口大小。
十、费用说明
EdgeOne 免费版额度对个人导航站完全足够:
| 资源 | 免费额度 | 导航站日均消耗估算 |
|---|---|---|
| 边缘函数调用 | 10 万次/月 | 数百~数千次 |
| CDN 流量 | 10 GB/月 | 毫 GB 级别 |
| 规则引擎 | 10 条规则 | 已使用 3 条 |
即使你的导航站有几千个站点,每天被访问几百次,也远低于免费额度上限。
十一、常见问题
Q1:函数没有生效,访问 /api/icon 返回 404
原因:触发规则未生效,请求回源到了你的服务器(如 WordPress),服务器不认识 /api/ 路径。
排查步骤:
- 确认触发规则已保存并发布
- 确认匹配类型为 URL path,运算符为 正则匹配,值为
^/api/ - 确认触发规则绑定的函数名称正确
- 在 EdgeOne 控制台 → 边缘函数 → 运行日志中查看是否有请求记录
Q2:图标/截图加载很慢
原因:首次请求需要实时抓取,且 mshots 生成截图本身需要几秒。
解决:
- 已配置 EdgeOne 节点缓存,相同请求第二次访问会直接命中缓存
- 前端使用
loading="lazy"懒加载,不阻塞首屏渲染
Q3:某些网站的图标获取不到
原因:该网站确实没有 favicon,且 DuckDuckGo 和 Google 也没有收录。
解决:已配置缺省占位图,会自动显示灰色问号图标,不影响页面美观。
Q4:截图接口返回灰色占位图
原因:三个截图源均不可达(可能是 EdgeOne 节点外网连通性问题)。
排查:
- 检查 EdgeOne 控制台 → 边缘函数 → 运行日志
- 确认 EdgeOne 节点能访问外网(免费版默认支持)
Q5:可以自定义缺省图吗?
可以。修改代码中 CONFIG.DEFAULT_ICON 和 CONFIG.DEFAULT_MSHOTS 的 SVG 内容即可。也可以替换为 Base64 编码的 PNG 图片。
十二、总结
本方案利用 EdgeOne 边缘函数实现了:
- 图标代理:多源回退(目标站 → DuckDuckGo → Google),国内用户也能稳定获取
- 截图代理:多源回退(mshots → PageShot → urlscan),支持自定义宽高
- CDN 缓存:EdgeOne 节点缓存结果,减少重复请求
- 缺省占位:所有源失败时返回 SVG 占位图,前端永不 broken
- 零成本:EdgeOne 免费版额度完全够用
- 零负载:请求在边缘节点处理,不回源到你的服务器
整个方案只需在 EdgeOne 控制台操作,无需额外服务器、无需 API Key、无需复杂配置。适合所有使用 EdgeOne 作为 CDN 加速的网站,尤其是导航站、目录站等需要大量展示外部网站图标和截图的场景。
相关文档: