利用 EdgeOne 边缘函数实现导航站图标与截图代理(零成本方案)

前言

做导航站的朋友都知道,每个网站都需要展示图标(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

二、前置准备

  1. EdgeOne 账号:注册 腾讯云 EdgeOne,免费版即可
  2. 已接入站点:你的域名已在 EdgeOne 中添加并开启加速(NS 接入或 CNAME 接入均可)
  3. 域名备案:如果加速区域包含中国大陆,域名需完成 ICP 备案

如果你还没有接入 EdgeOne,可以参考腾讯云官方文档 从零开始接入 EdgeOne


三、创建边缘函数

3.1 进入函数管理

  1. 登录 EdgeOne 控制台
  2. 左侧菜单 → 服务总览 → 在”网站安全加速”中点击你的站点域名
  3. 站点详情页左侧导航栏 → 边缘函数函数管理

3.2 新建函数

  1. 点击 新建函数 → 选择 创建空白函数
  2. 填写以下信息:
    • 函数名称nav-proxy(只能包含字母、数字、连字符,2-30 个字符)
    • 描述导航站图标和截图代理服务(可选)
  3. 将以下代码粘贴到代码编辑框中

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}`,
    },
  });
}
  1. 点击 保存,然后点击 部署

四、配置触发规则

触发规则决定哪些 URL 请求会走边缘函数,而不是回源到你的服务器。

4.1 添加触发规则

  1. 函数部署成功后,点击 新增触发规则(如果弹窗已关闭,去 边缘函数触发规则 标签页)
  2. 按以下配置填写:
配置项
匹配类型 URL path
运算符 正则匹配
^/api/
操作 选择刚创建的 nav-proxy 函数
  1. 点击 确定,规则立即生效

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.comhttps://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/ 路径。

排查步骤

  1. 确认触发规则已保存并发布
  2. 确认匹配类型为 URL path,运算符为 正则匹配,值为 ^/api/
  3. 确认触发规则绑定的函数名称正确
  4. 在 EdgeOne 控制台 → 边缘函数 → 运行日志中查看是否有请求记录

Q2:图标/截图加载很慢

原因:首次请求需要实时抓取,且 mshots 生成截图本身需要几秒。

解决

  • 已配置 EdgeOne 节点缓存,相同请求第二次访问会直接命中缓存
  • 前端使用 loading="lazy" 懒加载,不阻塞首屏渲染

Q3:某些网站的图标获取不到

原因:该网站确实没有 favicon,且 DuckDuckGo 和 Google 也没有收录。

解决:已配置缺省占位图,会自动显示灰色问号图标,不影响页面美观。

Q4:截图接口返回灰色占位图

原因:三个截图源均不可达(可能是 EdgeOne 节点外网连通性问题)。

排查

  1. 检查 EdgeOne 控制台 → 边缘函数 → 运行日志
  2. 确认 EdgeOne 节点能访问外网(免费版默认支持)

Q5:可以自定义缺省图吗?

可以。修改代码中 CONFIG.DEFAULT_ICONCONFIG.DEFAULT_MSHOTS 的 SVG 内容即可。也可以替换为 Base64 编码的 PNG 图片。


十二、总结

本方案利用 EdgeOne 边缘函数实现了:

  • 图标代理:多源回退(目标站 → DuckDuckGo → Google),国内用户也能稳定获取
  • 截图代理:多源回退(mshots → PageShot → urlscan),支持自定义宽高
  • CDN 缓存:EdgeOne 节点缓存结果,减少重复请求
  • 缺省占位:所有源失败时返回 SVG 占位图,前端永不 broken
  • 零成本:EdgeOne 免费版额度完全够用
  • 零负载:请求在边缘节点处理,不回源到你的服务器

整个方案只需在 EdgeOne 控制台操作,无需额外服务器、无需 API Key、无需复杂配置。适合所有使用 EdgeOne 作为 CDN 加速的网站,尤其是导航站、目录站等需要大量展示外部网站图标和截图的场景。


相关文档

© 版权声明

相关文章

暂无评论

none
暂无评论...