用 Claude Code 安全实现 Web Scraping:Fetch、Playwright 与审计日志
用 Claude Code 安全实现 Web Scraping:robots.txt、Fetch、Playwright、CSV 与审计日志。
先定义边界,再让 Claude Code 写代码
Web scraping 指的是用程序读取网页上的信息。它可以用于监控自己的网站、整理公开文档、做少量市场调研,也可以用于内容质量检查。但公开网页不等于可以任意收集。Claude Code 能很快生成代码,所以更要在生成之前写清楚边界:只处理公开数据,遵守网站条款和 robots.txt,限制请求频率,除非有合法依据否则不收集个人信息,并保留审计记录。
适合初学者的顺序是:先找官方 API、RSS、sitemap、CSV 导出或其他结构化入口。它们通常比 HTML 更稳定,使用规则也更清楚。只有在任务合理、数据量很小、没有更好来源时,才考虑读取 HTML。
本文把 Claude Code 当作实现助手,而不是绕过限制的工具。这里不讨论登录绕过、CAPTCHA 绕过、反爬保护绕过、批量抓取邮箱、抓取受限数据等做法。如果流程涉及个人信息、销售触达或合规要求,先确认使用目的、合法依据、隐私政策、保留时间、退订方式和当地规则。
设计时请以官方资料为准:robots.txt 协议看 RFC 9309,搜索抓取解释看 Google robots.txt 文档,标准请求能力看 MDN Fetch API,浏览器自动化隔离看 Playwright Browser contexts。
推荐流程
flowchart TD
A["明确一个用途"] --> B["先查 API、RSS、sitemap"]
B --> C["检查条款与 robots.txt"]
C --> D{"静态 HTML 能解决吗?"}
D -->|可以| E["用 Fetch 一页一页获取"]
D -->|不行且已获许可| F["用 Playwright 检查渲染后的 DOM"]
E --> G["CSV 保存 URL 与时间"]
F --> G
G --> H["人工抽样复核后再使用"]
这样写需求,Claude Code 才不会自由发挥。不要说“把这个站点都抓下来”,而是说“只访问这个允许的 origin,每次请求至少间隔 2 秒,被 robots.txt 阻止就停止,CSV 必须包含 sourceUrl 和 fetchedAt”。边界越具体,越不容易生成脆弱选择器、激进循环或不该保存的数据。
Fetch 与 Playwright 怎么选
如果需要的信息已经在 HTML 响应里,就用 fetch。静态文档、博客文章、公开价格页、状态页、从 sitemap 得到的 URL 检查,通常都适合 Fetch。它只是发 HTTP 请求并读取文本,依赖少,也容易审计。
如果内容必须由浏览器执行 JavaScript 后才出现,才考虑 Playwright。适合的场景是自己的网站本地预览、staging 环境、得到许可的内部 QA。浏览器自动化会加载脚本、Cookie、localStorage 和前端状态,风险更高,所以要用独立 browser context,避免不同任务的登录状态混在一起。
给 Claude Code 的指令也应如此:先做 Fetch 版本,证明静态 HTML 不够时再添加 Playwright。代码审查时重点看是否有固定 sleep 到处乱放、是否误用了登录状态、选择器是否依赖样式 class、是否缺少限速、是否没有记录来源 URL 和时间。
四个实用场景
第一个场景是监控自己的网站。比如检查培训页、产品页、表单、文章、canonical URL、标题和 CTA 文案是否异常。因为网站是自己的,可以自己维护 robots.txt、稳定选择器和运行频率。这个流程可以和 AI 内容运营自动化 以及 内容漏斗审计 连接起来:脚本发现变化,人再决定是否修改页面。
第二个场景是收集公开文档 URL。团队可能需要整理官方文档、内部 handbook、公开知识库的页面列表。很多时候不需要保存全文,只保存 URL、标题、检查时间和状态即可。这样既能支持搜索和编辑计划,也不会复制过多内容。
第三个场景是人工复核的竞品公开价格页检查。公开价格页可以做低频、少量监控,用来发现套餐名称、公开活动或页面结构变化。但输出不能直接当作事实。价格可能受地区、税费、币种和活动条件影响。CSV 必须保留 source URL 和时间,再由人工复核后才能写进销售资料或比较文章。
第四个场景是线索研究,但要有严格护栏。可以少量收集公司名、官网、行业和公开联系页面;不要盲目抓取个人邮箱并直接导入外呼系统。如果后续要发邮件,必须处理退订、发件人身份、抑制名单和人工确认。只有在收集步骤合法且最小化之后,才把它和 Claude Code 邮件自动化 组合。
常见失败
最常见的问题是不看 robots.txt 和条款。robots.txt 不是完整的法律许可,但它是网站公开表达的机器可读边界,应当尊重。服务条款还可能限制自动访问、再利用或商业监控。
第二个问题是看到邮箱就保存。公开页面上的个人信息仍然可能是个人信息。不需要就不要收集;确实需要时,也要记录目的、依据、保留时间、访问权限、删除流程和退订方式。
没有速率限制也很危险。几百个请求瞬间打过去,在对方眼里可能像攻击。小批量、低频率、清楚的 User-Agent、有限重试和失败即停止,是最低要求。
脆弱选择器会让数据悄悄变错。.card > div:nth-child(2) 今天可用,明天设计改版就失效。优先用语义 HTML、time[datetime]、main h1 或自己控制的 data 属性。关键字段取不到时,不要默默输出空值,要让任务失败并写日志。
绕过保护不是功能。Claude Code 如果开始建议 CAPTCHA 绕过、登录墙抓取、轮换身份、规避限速,就应该停止并换成被允许的数据来源。
最后,不要默认保存敏感内容。原始 HTML、登录后数据、token、客户信息和个人记录,都不应该随便进入 CSV。安全运营的整体思路可以继续读 Claude Code 安全最佳实践。
可复制运行的 Fetch 示例
下面的脚本适用于 Node 18 或更新版本。它只抓取一个允许的页面,保守检查 robots.txt,等待请求间隔,提取标题、H1、描述和链接数量,并写出带 source URL 与时间戳的 CSV,同时生成 JSON 审计文件。
// scrape-allowed-page.mjs
import { writeFile } from "node:fs/promises";
const USER_AGENT = "ClaudeCodeLabAuditBot/1.0 (+https://example.com/bot-info)";
const BOT_TOKEN = "ClaudeCodeLabAuditBot";
const targetUrl = new URL(process.env.SCRAPE_URL ?? "https://example.com/");
const allowedOrigins = (process.env.ALLOWED_ORIGINS ?? "https://example.com")
.split(",")
.map((value) => new URL(value.trim()).origin);
const delayMs = Number.parseInt(process.env.REQUEST_DELAY_MS ?? "2000", 10);
if (!allowedOrigins.includes(targetUrl.origin)) {
throw new Error(`Blocked by allowlist: ${targetUrl.origin}`);
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchText(url, accept) {
await sleep(delayMs);
return fetch(url, {
headers: {
"user-agent": USER_AGENT,
accept,
},
});
}
async function loadRobots(origin) {
const robotsUrl = new URL("/robots.txt", origin);
const response = await fetchText(robotsUrl, "text/plain");
if (response.status === 404) {
return { url: robotsUrl.toString(), status: response.status, text: null };
}
if (!response.ok) {
throw new Error(`robots.txt check failed: HTTP ${response.status}`);
}
return {
url: robotsUrl.toString(),
status: response.status,
text: await response.text(),
};
}
function parseRobots(text) {
const groups = [];
let agents = [];
let rules = [];
function commit() {
if (agents.length > 0) {
groups.push({ agents, rules });
}
agents = [];
rules = [];
}
for (const rawLine of text.split(/\r?\n/)) {
const cleaned = rawLine.split("#")[0].trim();
if (!cleaned) continue;
const separator = cleaned.indexOf(":");
if (separator === -1) continue;
const field = cleaned.slice(0, separator).trim().toLowerCase();
const value = cleaned.slice(separator + 1).trim();
if (field === "user-agent") {
if (rules.length > 0) commit();
agents.push(value.toLowerCase());
continue;
}
if ((field === "allow" || field === "disallow") && agents.length > 0) {
rules.push({ type: field, path: value });
}
}
commit();
return groups;
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function pathMatches(pattern, path) {
if (!pattern) return false;
const exact = pattern.endsWith("$");
const normalized = exact ? pattern.slice(0, -1) : pattern;
const source = `^${escapeRegExp(normalized).replace(/\\\*/g, ".*")}${exact ? "$" : ""}`;
return new RegExp(source).test(path);
}
function isAllowedByRobots(robotsText, url) {
if (robotsText === null) {
return process.env.ALLOW_WITHOUT_ROBOTS === "true";
}
const groups = parseRobots(robotsText);
const bot = BOT_TOKEN.toLowerCase();
const exactGroups = groups.filter((group) =>
group.agents.some((agent) => agent !== "*" && bot.includes(agent)),
);
const fallbackGroups = groups.filter((group) => group.agents.includes("*"));
const selectedGroups = exactGroups.length > 0 ? exactGroups : fallbackGroups;
const rules = selectedGroups.flatMap((group) => group.rules);
const targetPath = `${url.pathname}${url.search}`;
let winner = null;
for (const rule of rules) {
if (!pathMatches(rule.path, targetPath)) continue;
const length = rule.path.replace(/[*$]/g, "").length;
if (!winner || length > winner.length || (length === winner.length && rule.type === "allow")) {
winner = { type: rule.type, length };
}
}
return winner ? winner.type === "allow" : true;
}
function normalizeText(value) {
return value
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<[^>]*>/g, " ")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/\s+/g, " ")
.trim();
}
function firstMatch(html, pattern) {
const match = html.match(pattern);
return match ? normalizeText(match[1]) : "";
}
function extractPageSummary(html) {
const metaMatch =
html.match(/<meta\s+[^>]*name=["']description["'][^>]*content=["']([^"']*)["'][^>]*>/i) ??
html.match(/<meta\s+[^>]*content=["']([^"']*)["'][^>]*name=["']description["'][^>]*>/i);
return {
title: firstMatch(html, /<title[^>]*>([\s\S]*?)<\/title>/i),
h1: firstMatch(html, /<h1[^>]*>([\s\S]*?)<\/h1>/i),
metaDescription: metaMatch ? normalizeText(metaMatch[1]) : "",
linkCount: [...html.matchAll(/<a\s+[^>]*href=["'][^"']+["']/gi)].length,
};
}
function csvEscape(value) {
const text = String(value ?? "");
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}
const robots = await loadRobots(targetUrl.origin);
if (!isAllowedByRobots(robots.text, targetUrl)) {
throw new Error(`Blocked by robots.txt: ${targetUrl.toString()}`);
}
const response = await fetchText(targetUrl, "text/html");
if (!response.ok) {
throw new Error(`Page fetch failed: HTTP ${response.status}`);
}
const html = await response.text();
const fetchedAt = new Date().toISOString();
const row = {
sourceUrl: targetUrl.toString(),
fetchedAt,
...extractPageSummary(html),
};
const headers = ["sourceUrl", "fetchedAt", "title", "h1", "metaDescription", "linkCount"];
const csv = [headers.join(","), headers.map((header) => csvEscape(row[header])).join(",")].join("\n");
await writeFile("scrape-output.csv", `${csv}\n`, "utf8");
await writeFile(
"scrape-audit.json",
JSON.stringify(
{
checkedAt: fetchedAt,
userAgent: USER_AGENT,
robotsUrl: robots.url,
robotsStatus: robots.status,
allowedOrigins,
sourceUrl: row.sourceUrl,
},
null,
2,
),
"utf8",
);
console.log(`Saved scrape-output.csv for ${row.sourceUrl}`);
PowerShell 示例:$env:SCRAPE_URL="https://your-domain.example/page"; $env:ALLOWED_ORIGINS="https://your-domain.example"; node scrape-allowed-page.mjs。输出只有一行 CSV 和一个 JSON 审计文件,正因为简单,后续复核才容易。
只对自有页面使用 Playwright
下面的 Playwright 示例用于自己的网站或本地预览,不用于绕过外部网站保护。它检查渲染后的选择器是否存在,并写出审计文件。
// check-own-site-selectors.mjs
import { writeFile } from "node:fs/promises";
import { chromium } from "playwright";
const target = process.env.LOCAL_PREVIEW_URL ?? "http://127.0.0.1:4321/blog/claude-code-web-scraping/";
const allowedPrefixes = [
"http://127.0.0.1:",
"http://localhost:",
"https://claudecodelab.com/",
];
if (!allowedPrefixes.some((prefix) => target.startsWith(prefix))) {
throw new Error(`Playwright check is limited to owned or local pages: ${target}`);
}
const browser = await chromium.launch();
const context = await browser.newContext({
userAgent: "ClaudeCodeLabAuditBot/1.0 local-preview-check",
});
const page = await context.newPage();
await page.goto(target, { waitUntil: "domcontentloaded" });
const checks = [
{ name: "article title", selector: "main h1, article h1" },
{ name: "updated date", selector: "time, [data-updated-date]" },
{ name: "main article", selector: "main article, article" },
];
const results = [];
for (const check of checks) {
const locator = page.locator(check.selector);
const count = await locator.count();
const firstText = count > 0 ? ((await locator.first().textContent()) ?? "").trim().slice(0, 120) : "";
results.push({ ...check, count, firstText });
}
await writeFile(
"selector-audit.json",
JSON.stringify({ target, checkedAt: new Date().toISOString(), results }, null, 2),
"utf8",
);
await context.close();
await browser.close();
const missing = results.filter((result) => result.count === 0);
if (missing.length > 0) {
throw new Error(`Missing selectors: ${missing.map((result) => result.name).join(", ")}`);
}
console.log(`Saved selector-audit.json for ${target}`);
Playwright context 很重要,因为它隔离 Cookie、localStorage 和权限。不要把真实登录会话随便交给自动化脚本,除非任务已经被批准,并且数据允许被处理。
给 Claude Code 的提示词
可以这样要求 Claude Code:
添加一个只处理 allowlist origin 的单页抓取脚本。先在 README 记录是否存在官方 API、RSS 或 sitemap。抓取 HTML 前检查 robots.txt。CSV 必须保存
sourceUrl和fetchedAt。不要抓取邮箱、个人姓名、登录后数据或秘密信息。不要绕过 CAPTCHA、登录墙、bot 防护或限速。添加请求间隔,被阻止时停止,并展示 JavaScript 文件的node --check结果。
这段提示词的目的,是让 Claude Code 成为可审计的实现者,而不是自动决定合规边界的人。执行前仍然要人工检查 diff、目标 URL、保存字段、请求频率、删除流程和样本输出。
运营检查表
- 先确认 API、RSS、sitemap 或导出功能。
- 确认页面公开,且用途不违反条款。
- 尊重 robots.txt,并把检查结果写入审计日志。
- 使用 origin allowlist 和小批量请求。
- 添加延迟、有限重试和失败停止。
- User-Agent 说明用途或联系页面。
- 保存 source URL、抓取时间、方法和 robots 状态。
- 优先使用语义选择器或自有 data 属性。
- 默认不保存个人信息、秘密、登录数据或原始 HTML。
- 商业使用前进行人工抽样复核。
如果 CSV 会被电子表格打开,还要注意 CSV injection。来自网页的字符串都应视为不可信输入。把抓取流程接入安全审查、内容运营和外联控制,而不是静默写入 CRM。
培训与咨询
代码本身不难,真正难的是决定不收集什么、如何证明收集被允许、如何复核变化、如何删除过期输出。ClaudeCodeLab 可以通过 Claude Code 培训与咨询 帮团队把这些规则落到 CLAUDE.md、Playwright 检查、CSV 审计日志和人工审批流程里。
个人使用时,从一页、一次、公开数据开始。不要先扩大数量,而要先完善审计、失败停止和人工复核。
总结
用 Claude Code 做安全 Web scraping,起点不是选择器,而是边界。优先 API 和 sitemap;静态公开页面用 Fetch;只有自有或已获许可的动态页面才用 Playwright。每次都记录 URL、时间、robots 状态和 User-Agent。
需要避免的事项同样重要:不忽略条款,不盲目抓取邮箱,不绕过限速,不让选择器静默失败,不绕过保护,不保存敏感数据。Claude Code 可以加速实现,但只有当人能说明目的、来源、时间和删除路径时,这个流程才适合投入实际运营。
Masa 按本文流程实际试过后发现,把第一版限制为一个页面、一行 CSV、一个 JSON 审计文件,复核会轻松很多。sourceUrl 和 fetchedAt 对检查价格页和自有站点内容特别有用。相反,先抓很多页面的原型更难追踪选择器失败和策略遗漏,最后往往要重写。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。