用 Cloudflare Workers 构建一个带备用方案的轻量级网页元数据爬虫

意外富翁 · 1个月前 · 技术 · 94 · 0

每天 10 万次免费额度,先直连目标站点,403 就自动去 DuckDuckGo 抓 SEO 信息,平均 200 ms 返回 JSON。代码 120 行,复制-粘贴-部署,全程 5 分钟。


最近在开发一个不一样的网站导航 Pockoo,其中有一个功能是相关网站推荐,需要抓取网站的 Title 和 Description 等元数据,然后抛给 AI 做语义识别生成向量。
最开始的方案的直接后端抓取,但是实际运行过程中发现失败率很高,增加了备用补充方案,使用 DuckDuckGo HTML 搜索引擎获取该网站的索引信息,成功率增加了,但是还是不太理想。

后来想到一个“骚”操作:
“把这个功能使用 Cloudflare Workers 实现,网站可以屏蔽我,但总不好屏蔽 Cloudflare 吧?”

于是把逻辑改成:

  1. 先正常抓,成功直接返回
  2. 403/超时 → 立刻去 DuckDuckGo 搜 site:domain,拿第一条结果的标题 + 摘要

实测成功率从 58% 飙到 96%,成本降到 0——Cloudflare Workers 每天白送 10 万次调用,够了。


02 架构 30 秒速览

        浏览器
          │
          ▼
Cloudflare Worker(全球边缘节点)
 ┌────────┴────────┐
 │1. 直连目标站     │◀── 4 秒超时
 │2. 403/超时      │
 │3. 搜 DuckDuckGo │
 └────────┬────────┘
          ▼
     返回 JSON(title & description)
  • 流式解析:HTMLRewriter 边下载边匹配 <title> / <meta name="description">,内存忽略不计
  • 备用信源:DuckDuckGo 的 html.duckduckgo.com 是无 JS 版,返回纯 HTML,解析飞快
  • 零运维:Serverless,不写 Dockerfile、不装 Redis,代码一保存就全球上线

03 代码与部署

① 打开 Cloudflare 控制台

  1. 注册 → 左侧「Workers & Pages」→「Create Worker」
  2. 名字随便写,例如 meta-fet,点「Deploy」

② 一键替换全部代码

把编辑器里默认内容全删,粘贴下面 120 行,Save and Deploy 收工。

export default {
  async fetch(request) {
    const urlParams = new URL(request.url).searchParams;
    const targetUrl = urlParams.get('url');

    // 基础校验
    if (!targetUrl) {
      return createResponse("", "", "", "Error: Missing url parameter", null, 400);
    }

    let lastError = "";

    // --- 方案 A: 直接抓取 ---
    try {
      const directResult = await fetchDirectly(targetUrl);
      if (directResult.title) {
        return createResponse(
          directResult.title, 
          directResult.description, 
          directResult.keywords, // 增加 keywords
          "direct", 
          targetUrl
        );
      }
      lastError = "Direct fetch returned empty title";
    } catch (e) {
      lastError = `Direct fetch failed: ${e.message}`;
    }

    // --- 方案 B: DuckDuckGo 搜索备用 ---
    try {
      const ddgResult = await fetchFromDDG(targetUrl);
      if (ddgResult.title) {
        // 注意:DDG 无法提供原网站的 keywords,故传空字符串
        return createResponse(
          ddgResult.title, 
          ddgResult.description, 
          "", 
          "fallback_ddg", 
          targetUrl
        );
      }
      lastError += " | DDG fallback returned no results";
    } catch (e) {
      lastError += ` | DDG fallback error: ${e.message}`;
    }

    // --- 最终失败方案 ---
    return createResponse("", "", "", lastError, targetUrl);
  }
};

/**
 * 统一构造返回对象 (增加 keywords 字段)
 */
function createResponse(title, description, keywords, source, url, status = 200) {
  const body = {
    url: url || "",
    title: title || "",
    description: description || "",
    keywords: keywords || "", // 新增字段
    source: source || ""
  };
  return new Response(JSON.stringify(body), {
    status: status,
    headers: {
      "Content-Type": "application/json;charset=UTF-8",
      "Access-Control-Allow-Origin": "*",
      "Cache-Control": "public, max-age=3600"
    }
  });
}

/**
 * 方案 A: 直接请求网站
 */
async function fetchDirectly(url) {
  const response = await fetch(url, {
    headers: {
      'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36',
    },
    cf: { cacheEverything: true, cacheTtl: 3600 }
  });

  if (!response.ok) throw new Error(`HTTP ${response.status}`);

  let data = { title: "", description: "", keywords: "" };
  
  const rewriter = new HTMLRewriter()
    .on("title", { 
      text(t) { data.title += t.text; } 
    })
    .on("meta[name='description']", { 
      element(e) { data.description = e.getAttribute("content"); } 
    })
    .on("meta[property='og:description']", { 
      element(e) { if (!data.description) data.description = e.getAttribute("content"); } 
    })
    // --- 新增关键词匹配 ---
    .on("meta[name='keywords']", { 
      element(e) { data.keywords = e.getAttribute("content"); } 
    })
    .on("meta[name='Keywords']", { // 兼容大小写不规范的标签
      element(e) { if (!data.keywords) data.keywords = e.getAttribute("content"); } 
    });

  await rewriter.transform(response).text();
  return { 
    title: data.title.trim(), 
    description: data.description?.trim() || "",
    keywords: data.keywords?.trim() || "" 
  };
}

/**
 * 方案 B: DuckDuckGo 搜索 (逻辑不变,无法提取关键词)
 */
async function fetchFromDDG(targetUrl) {
  const domain = new URL(targetUrl).hostname;
  const searchUrl = `https://html.duckduckgo.com/html/?q=site:${encodeURIComponent(domain)}`;

  const response = await fetch(searchUrl, {
    headers: {
      'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36'
    }
  });

  if (!response.ok) throw new Error("DDG unreachable");

  let ddgData = { title: "", description: "" };
  let found = false;

  const rewriter = new HTMLRewriter()
    .on("a.result__a", {
      text(t) { if (!found) ddgData.title += t.text; }
    })
    .on("a.result__snippet", {
      text(t) { if (!found) ddgData.description += t.text; },
      onEndTag() { found = true; }
    });

  await rewriter.transform(response).text();
  return { title: ddgData.title.trim(), description: ddgData.description.trim() };
}

③ 试试好不好用

部署后会得到一个类似 https://meta-fet.yourname.workers.dev 的域名,浏览器直接戳:

https://meta-fet.yourname.workers.dev/?url=https://zhulink.vip

返回示例:

{
"url": "https://zhulink.vip",
"title": "🔥热议 - 竹林,链接有趣内容,聚合真实想法,和真实的人一起筛内容,不靠算法也能刷到好东西。",
"description": "竹林是一个链接优质内容和真实用户讨论的去算法推荐社区,由用户分享推荐优质资讯,聚焦真实评论与用户共鸣,和真实的人一起筛内容,依靠用户共识挑出值得一读的内容,不靠算法也能刷到好东西。",
"keywords": "竹林,zhulink,资讯聚合,内容推荐,热点评论,用户精选,新闻互动,每日热榜,去算法推荐",
"source": "direct"
}

再试一个被盾的站点:

https://meta-fet.yourname.workers.dev/?url=https://toutiao.com

大概率能看到 "source": "ddg",说明已经走搜索引擎兜底。


04 两个小优化(可选)

  1. 防蹭流量
    在 Worker 顶部加一行:

    if (request.headers.get('Referer') !== 'https://你的导航站.com') return json({error: 'Forbidden'}, 403);
    
  2. 缓存省额度
    extractMeta 里返回的结果加 Cache-Control: max-age=86400,一天内重复请求走 Cloudflare 边缘缓存,不计入 10 万次配额。

缓存机制:Cloudflare Workers 默认支持 cf: { cacheEverything: true }。对于不经常变动的网站标题,建议开启缓存以节省额度和提升速度。

已复制到剪贴板

评论 0 条

暂无评论,来种下第一颗种子。