ARTICLE · 1165736
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 里解决,不额外花钱。
我顺手改掉的四处毛病
流传的版本里后台那几个数字是错的,照做会以为数据坏了:
「今日访问」和「AI 爬虫」统计的是同一张表,两个数字永远一样。记账表本来就只记爬虫——我改成「近 24 小时 / 累计 / 爬虫种类 / 模式」四个真实的数。 「24 小时趋势」的柱图其实画的是全量数据,柱高又按 24 小时的量算比例,柱子小得几乎看不见。现在查询加了时间条件,柱高按当小时最大值换算。 「已付费」永远是 0:代码里没有哪一处置 paid=1。这张卡片我先拿掉了,paid字段留在表里,等你真接了收款再用。名单里的 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爬虫