夜雨聆风学习资料网

ARTICLE · 1165736

AI 爬虫爬我的网站一分钱不给:用免费的 Cloudflare Worker 搭个收费站

AI 爬虫爬我的网站一分钱不给:用免费的 Cloudflare Worker 搭个收费站

👇 底部「阅读原文」· 看全部文章

AI 爬虫爬我的网站一分钱不给:用免费的 Cloudflare Worker 搭个收费站

这个号一直写自托管,NAS 上跑博客、相册、文档站的人不少。今年有个说法传得挺凶:GPT 爬你网站一次,就得给你打钱。

方向没错。但流传的版本里,代码用 ... 省掉了最要紧的那几行,报错因果也讲反了。我按「能真正跑通」的标准重写了一遍,代码一行不省。

先说结论:能跑,但别指望靠它赚钱

Cloudflare 在 2025 年 7 月上线了 Pay-Per-Crawl:AI 爬虫要么带着付款意图来拿 200,要么收到 402。这个概念是真的。

自己用 Worker + D1 复现也没问题,免费额度对个人站够用:Worker 每天 10 万次请求,D1 每天 500 万行读、10 万行写(细则以官网为准)。

有一点得说在前面:我这份代码里的 402 是「礼貌拒绝」。爬虫收到 402 会走人,但它不会掏钱,真正替你做收单的是 Cloudflare 官方那条通道,目前还是小范围测试。

所以它的实际价值在另一半:看清谁在爬你。谁每天来、爬了哪些路径、有没有人在翻你的 .env,这些数据本身就值这个功夫。

它是怎么干活的

所有流量先过 Worker。Worker 认 User-Agent,认出来是 AI 爬虫就记一笔,然后把页面从源站取回来给访客。

访客 / AI 爬虫   │   ▼Worker 认 UA   ├─ AI 爬虫 → 写 D1(402)   └─ 垃圾路径 → 放过   │   ▼源站(你的博客或 NAS 上的站)

代码最前面两个开关:CHARGE=false 只记账,CHARGE=true 给爬虫返 402。ORIGIN 填你真实的源站。

五条命令装起来

本地要有 Node.js,18 以上就行。

cd codenpm install -D wranglernpx wrangler login

login 会在浏览器里让你点一次授权。接着建库:

npx wrangler d1 create crawlpay_db

命令会打出一行 database_id,把它填回 wrangler.toml。然后建表、部署:

npx wrangler d1 execute crawlpay_db \  --remote --file schema.sqlnpx wrangler deploy

部署完给你一个 https://ai-crawl-toll.你的子域.workers.dev,以后对外用这个地址,源站藏在后面。记得把顶部的 ORIGIN 改成你自己的源站域名——别填这个 Worker 自己的域名,原因在下面踩坑那段。

完整配置和代码

wrangler.toml

name = "ai-crawl-toll"main = "src/index.js"compatibility_date = "2025-06-01"# 建库后把 database_id 换成你自己的[[d1_databases]]binding = "DB"database_name = "crawlpay_db"database_id = "填你的 D1 数据库 ID"

schema.sql

-- AI 爬虫收费站 · D1 建表-- 用法:--   npx wrangler d1 execute \--     crawlpay_db --remote \--     --file schema.sqlCREATE TABLE IF NOT EXISTS crawls (  id INTEGER PRIMARY KEY AUTOINCREMENT,  ts INTEGER NOT NULL,      -- 毫秒时间戳  bot TEXT NOT NULL,        -- 爬虫关键字  path TEXT NOT NULL,       -- 被爬路径  -- paid 预留给以后接真收款  paid INTEGER NOT NULL DEFAULT 0);CREATE INDEX IF NOT EXISTS idx_crawls_ts  ON crawls (ts);CREATE INDEX IF NOT EXISTS idx_crawls_bot  ON crawls (bot);

src/index.js

// AI 爬虫收费站:Cloudflare Worker + D1// CHARGE=false 只记账;true 给爬虫返 402const CHARGE = false;// 你的真实源站,别填本 Worker 的域名const ORIGIN = "https://你的源站.example";// 后台口令:/dashboard?key=这个值const KEY = "改成你自己的口令";// 认得的 AI 爬虫 UA 关键字(小写)const BOTS = [  "gptbot", "oai-searchbot",  "chatgpt-user", "claudebot",  "claude-web", "anthropic-ai",  "perplexitybot", "googleother",  "applebot-extended",  "meta-externalagent",  "amazonbot", "bytespider",  "ccbot", "cohere-ai",  "diffbot", "petalbot",];// 黑客扫描常打的路径,命中就不记账const JUNK = [  ".env", "/.git", "wp-", "phpinfo",  "/admin", "/login", "/backup",];export default {  async fetch(request, env) {    const url = new URL(request.url);    const path = url.pathname;    const hd = request.headers;    const raw = hd.get("user-agent");    const ua = (raw || "").toLowerCase();    // 1 后台,得带对口令    if (path === "/dashboard") {      const q = url.searchParams;      if (q.get("key") !== KEY) {        return notFound();      }      return dash(env);    }    // 2 垃圾扫描:透传,不记账    if (isJunk(path)) {      return proxy(request);    }    // 3 AI 爬虫:先记一笔    const bot = botOf(ua);    if (bot) {      const sql = "INSERT INTO crawls" +        " (ts, bot, path)" +        " VALUES (?, ?, ?)";      await env.DB.prepare(sql)        .bind(Date.now(), bot, path)        .run();      if (CHARGE) return pay402();    }    // 4 其他一律透传到源站    return proxy(request);  },};function botOf(ua) {  for (const b of BOTS) {    if (ua.includes(b)) return b;  }  return "";}function isJunk(path) {  const p = path.toLowerCase();  return JUNK.some((x) => p.includes(x));}function notFound() {  const opt = { status: 404 };  return new Response("404", opt);}// 402:告诉爬虫「要付钱」function pay402() {  const body = JSON.stringify({    error: "402 Payment Required",    price: "0.001 USDC on Base",  });  return new Response(body, {    status: 402,    headers: {      "content-type": "application/json",      "cache-control": "no-store",      "x-payment": "0.001 USDC on Base",    },  });}// 反代到源站:剔掉 hop-by-hop 头async function proxy(request) {  const url = new URL(request.url);  const target = new URL(    url.pathname + url.search, ORIGIN);  const hd = new Headers(request.headers);  const drop = [    "host", "connection",    "keep-alive", "transfer-encoding",  ];  drop.forEach((x) => hd.delete(x));  const init = {    method: request.method,    headers: hd,    redirect: "follow",  };  if (request.method !== "GET" &&      request.method !== "HEAD") {    init.body = request.body;  }  const res = await fetch(target, init);  const out = new Response(res.body, res);  out.headers.set(    "access-control-allow-origin", "*");  return out;}// 后台统计页async function dash(env) {  const since = Date.now() - 864e5;  const one = async (sql, ...a) => {    const r = await env.DB.prepare(sql)      .bind(...a).first();    return (r && r.c) || 0;  };  const all = (sql, ...a) =>    env.DB.prepare(sql).bind(...a).all();  const cnt = "SELECT COUNT(*) c" +    " FROM crawls";  const sel24 = cnt + " WHERE ts > ?";  const c24 = await one(sel24, since);  const cAll = await one(cnt);  const cBot = await one(    "SELECT COUNT(DISTINCT bot) c" +    " FROM crawls");  const top = (await all(    "SELECT bot, COUNT(*) c FROM crawls" +    " GROUP BY bot ORDER BY c DESC" +    " LIMIT 10")).results;  const hsql = "SELECT (ts/3600000) h," +    " COUNT(*) c FROM crawls" +    " WHERE ts > ? GROUP BY h ORDER BY h";  const hres = await all(hsql, since);  const hours = hres.results;  const rows = top.map((r) =>    "<tr><td>" + r.bot + "</td><td>" +    r.c + "</td></tr>").join("");  const max = Math.max(1, ...hours.map(    (h) => h.c));  const bars = hours.map((h) => {    const px = Math.round(      h.c * 100 / max);    const st = "height:" + px + "px";    return "<div><i style=" +      "\"" + st + "\"></i><b>" +      h.c + "</b></div>";  }).join("");  const css =    "body{background:#111;color:#eee;" +    "font:16px/1.7 sans-serif;" +    "padding:16px}" +    ".c{background:#1e293b;" +    "border-radius:10px;" +    "padding:10px 14px;margin:6px 0}" +    ".n{font-size:26px;font-weight:700}" +    ".h{color:#94a3b8;font-size:13px}" +    "table{width:100%;" +    "border-collapse:collapse}" +    "td{padding:6px;" +    "border-bottom:1px solid #333}" +    ".bar{display:flex;gap:4px;" +    "align-items:flex-end;height:120px}" +    ".bar div{flex:1;text-align:center}" +    ".bar i{display:block;" +    "background:#3b82f6;" +    "border-radius:3px 3px 0 0}";  const card = (t, v) =>    "<div class=c><div class=h>" +    t + "</div>" +    "<div class=n>" + v + "</div></div>";  const mode = CHARGE ? "收费" : "计数";  const html = "<!doctype html>" +    "<meta charset=\"utf-8\">" +    "<meta name=viewport " +    "content=\"width=device-width," +    "initial-scale=1\">" +    "<style>" + css + "</style>" +    "<h2>AI 爬虫收费站</h2>" +    "<div class=c>" +    card("近 24 小时", c24) +    card("累计", cAll) +    card("爬虫种类", cBot) +    card("模式", mode) +    "</div>" +    "<h3>近 24 小时趋势</h3>" +    "<div class=bar>" + bars + "</div>" +    "<h3>Top 爬虫</h3><table>" +    (rows || "<tr><td>暂无</td></tr>") +    "</table>";  return new Response(html, {    headers: {      "content-type":        "text/html; charset=utf-8",    },  });}

我没上线,但把它在本地跑通了

部署要占一个 Cloudflare 账号和一个真源站,我这边只验代码。做法是拿假 D1(内存数组)和假源站把 Worker 的 fetch 直接调起来,14 项全过:

node test/worker.test.mjs

验到的东西:GPTBot 的 UA 会被记账、页面照常拿到;Chrome 的 UA 不记账;/.env、/wp-admin/setup.php 这类扫描路径不记账;口令不对时 /dashboard 返 404;把 CHARGE 改成 true,爬虫拿 402 而且带 x-payment 头,普通浏览器照样 200。

自测脚本 test/worker.test.mjs 也在代码包里(假 D1 只认代码用到的那几条 SQL),整包放在代码库,点下面「阅读原文」就能拿。

三个坑,我先替你踩了

坑一:把 Worker 反代到同一个账号里另一个 Worker 或 Pages 域名。请求一层套一层,最后报 Error 1019。Cloudflare 官方对 1019 的解释是「Worker 脚本递归引用了自己」,解法就一句:源站用独立域名,别让这个 Worker 的请求又绕回它自己。顺带纠正另一件事,网上把 1101 讲成循环报错的版本是错的:1101 是 Worker 里抛了 JS 异常,跟循环是两回事。

坑二:no such table: crawls。表没建就先查了。顺序是 d1 execute ... --file schema.sql 在前,deploy 在后,反了必然报这个。

坑三:后台别裸奔,扫描也别记。/dashboard 不加口令,谁拿到链接都能看你被爬了多少次。还有个更烦的:站一上线,一小时内能涌进来几十条扫描记录,全是 /.env、/wp-admin、/.git。我在代码里加了口令校验和垃圾路径过滤,都在 Worker 里解决,不额外花钱。

我顺手改掉的四处毛病

流传的版本里后台那几个数字是错的,照做会以为数据坏了:

  1. 「今日访问」和「AI 爬虫」统计的是同一张表,两个数字永远一样。记账表本来就只记爬虫——我改成「近 24 小时 / 累计 / 爬虫种类 / 模式」四个真实的数。
  2. 「24 小时趋势」的柱图其实画的是全量数据,柱高又按 24 小时的量算比例,柱子小得几乎看不见。现在查询加了时间条件,柱高按当小时最大值换算。
  3. 「已付费」永远是 0:代码里没有哪一处置 paid=1。这张卡片我先拿掉了,paid 字段留在表里,等你真接了收款再用。
  4. 名单里的 Google-Extended 不是 UA,它是 robots.txt 里的产品 token,不会出现在请求头里,写进 UA 名单永远匹配不上。Google 抓 AI 训练数据用的还是 Googlebot / GoogleOther 那几个。我换成 googleother,另补了 applebot-extended、meta-externalagent、ccbot 这些真在跑的。

还有一句得说明白:UA 可以随便伪造,这套识别是「记账」,不是「认证」。真要拦,得看 IP 段和行为特征,那已经超出两百行代码的范围了。

想真收钱,走官方那条路

Pay-Per-Crawl 是 Cloudflare 在自己 zone 上开的功能,收单、结算都是它做。我这个 402 只能算技术演示。我的用法是:先跑计数模式三天,看清楚自己站的真实被爬情况——这份数据本身就够回本;收费模式当实验玩。

想省事的话

代码包(wrangler.toml + schema.sql + src/index.js + 测试脚本)我放进代码库了,点下面「阅读原文」就能打开,手机上直接复制。

#NAS #Docker #自托管 #Cloudflare #AI爬虫

相关学习资料