从实战角度分析网络爬虫的工程挑战和对策,涵盖代理轮转、验证码解决、浏览器指纹等核心问题。用 Docker + Playwright 分层架构的完整案例。
几年前,我为前雇主构建并运行了一个数据管道,大规模聚合公开房产列表和人员搜索记录——这些来自那些真心不想被爬取的网站。一旦获得 HTML,剩下的就是普通的软件工程:规范化列表、去重记录、加载仓库。没人会为一个 upsert 操作失眠。
首先获取 HTML 才是实际的工作。"解析响应"上游的每一个决策,实际上都归结为一个问题:不被屏蔽。IP 声誉、验证码解决率、指纹合理性——这些不是抽象的安全顾虑,而是直接影响我们单位经济的线项。
为了解决这个问题,我们构建了一个分两层的 Docker 容器舰队。便宜的层做普通 HTTP 请求以处理低风险端点。昂贵的层运行完整的 Playwright、真实 Chrome、Xvfb 和 VNC 服务器,以便在自动化放弃时,人类可以远程登入手动解决验证码。playwright-extra 加上隐身插件、轮换用户代理、持久化 cookie 罐、类人输入延迟、按轮换生命周期计费的住宅代理。所有标准操作。
但它仍然不可靠。直接从我们自己的日志中提取:对某房产网站成功率约 63%,对另一个为 8%。一度我们甚至有一个代码路径,通过 xdotool 在 X11 会话中逐字面地按住空格键,自动解决"按住不放"的挑战。它有效过一段时间。然后供应商调整了一个信号,我们的成功率悄然下降,我们又回到了原点。
这是这个行业的永久状态。Apify 最近关于爬取未来发展方向的分析将其描述为一场真正的军备竞赛:Cloudflare 定期更新其 Turnstile 有效负载,上周有效的绕过方式今天可能就失效了。没有人能赢得这场战斗。你只是试图停止需要去战斗,这是本文的真正主旨。
这并不是对 Playwright 或隐身插件生态系统的批评。他们从结构上困难的位置解决了一个真正的难题,我稍后会详细讲解为什么。但这就是为什么我不再对任何需要可靠运行、每天运行、持续数月的任务默认采用无头浏览器 + 代理池。本文讲的是我用的替代架构:目前指向 Zillow,并被构建成一个 Apify actor。
大多数爬取建议归纳为四个步骤:让浏览器看起来更像人类、让鼠标像人类一样移动、让 IP 看起来像住宅 IP,或付钱让别人做这三件事。每一个都有特定的原因解释为什么它的效果不如宣传的那么好。
隐身插件在错误的层次上作战。 现代行为防机器人供应商推出了一种叫 Code Defender 的东西(PerimeterX,现在以 HUMAN 品牌出售,是我花费最多时间与之对抗的那个):一个 JS 传感器,监视页面自身的运行时是否被篡改、是否有注入脚本、修改的原型、围绕它关心的 API 的函数包装。在我自己对它的测试中,这包括在敏感访问器如 navigator.webdriver、Canvas、WebGL 和 AudioContext 上调用 .toString(),并将结果与原生未修改的浏览器代码应该返回的内容进行比较。隐身插件用 Object.defineProperty() 来修补这些相同的访问器,这改变了 .toString() 的报告内容。所以用来隐藏自动化的东西恰好就是 Code Defender 被构建来检测的东西。只有应用在 JS 层下方、浏览器引擎自身源代码内部的修补才能存活。
合成鼠标移动会让情况变糟,而不是更好。 这是我会最力力推回的建议,因为这也是我听到重复最有信心陈述的一个。任何你以编程方式分派的事件,el.dispatchEvent(new MouseEvent(...)),都带有 isTrusted: false。真实的用户输入带有 isTrusted: true。这实际上是 DOM 事件规范本身的一部分,自 2016 年以来在每个主要浏览器上都得到可靠支持。JS 分派的"像人类一样"的鼠标路径不会骗过任何东西;它只是向检测器递交了一个它本来不会有的干净信号。获得 isTrusted: true 需要从 JS 引擎之外完全驱动输入,使用原生 OS 输入驱动程序,这是一个真正的技术,但比"给你的鼠标移动加些抖动"暗示的要重得多。
住宅代理解决声誉问题,同时破坏它。 一个共享的住宅 IP 不是一个干净的起点。这是一个共享身份,悄悄积累来自每个经过它路由的其他客户的声誉损害,由跨客户系统评分(PerimeterX 称他们的为"集体智能":单个 IP 的声誉受到跨越运行 PerimeterX 的每个网站的行为影响,而不仅仅是你正在访问的那个)。而且它花真钱:一个足以有效果的代理池每月 50 到 500 美元,对企业合同来说九牛一毛,对 40 美元/月的订阅产品来说是破坏商业模式的线项。
商业爬取 API 是理智检查,不是答案。 一个 ScrapeOps 运行的对七个爬取 API 供应商与 Zillow 的基准测试,由 agenthustler 撰写,表明了这一点。最佳表现者 ScraperAPI 达到了约 98% 成功率,平均每个请求 6.1 秒。Scrapfly 在成功率上接近但耗时几乎是三倍(18.2 秒)。最差表现者 Scrapingdog 在每个请求 19.4 秒时勉强达到 61%。即使在市场顶端,持续延迟也在每个请求 6 到 20 秒之间。瓶颈不是代理质量或指纹复杂性。这是步速。世界上资金最充足的爬虫不是在快速运行。他们在仔细运行。
最后这一点是这个架构其余部分所抽取的线索。
上面的每种技术都试图从外部模拟一个合法的会话:欺骗指纹、伪造事件、洗钱 IP。有一个更简单的步骤。不要模拟会话。使用一个。
浏览器扩展的内容脚本在一个实际的标签页内执行,在页面的真实源上。当它调用 fetch() 时,请求携带标签页的真实 cookie、真实 TLS 指纹、真实的 canvas/WebGL/AudioContext 签名,和 navigator.webdriver === false。没什么是仿真的:没有 CDP 自动化标志、没有给 Code Defender 捕获的修补访问器、没有要检查声誉的代理 IP。
我已经在今年早些时候一个无关的项目上验证了这个模式:一个扩展从已认证的会话中实时读取第三方网站上的数据。(这个模式部分来自 Matt Frisbie 的《Building Browser Extensions》,仍然是我找到的关于内容脚本机制的最佳单一参考。)
// content script, injected into the target page's own origin
async function fetchInPageContext(apiUrl, headers) {
const res = await fetch(apiUrl, {
credentials: 'include', // attaches the page's real session cookies
headers,
});
return res.json();
}
这是真实模式的简化版本;实际代码针对特定的第三方 API 并做这个片段跳过的真实请求构建工作。但重要的技巧恰好就是这个:一个带有 credentials: 'include' 的内容脚本 fetch()。因为脚本运行在页面的自身执行上下文中,那单一选项会附加真实会话。没有 CORS 问题、没有单独的认证流,因为根本没有机器人:这是用户自己的已登录会话,做着真实交互会触发的同一个 fetch。
将同样的模式应用到一个 WAF 保护的、已登出的网站如 Zillow 需要了一个更多的部分。Zillow 不需要登录来浏览,但它在 CloudFront 后面运行 PerimeterX 加上 AWS WAF,积极评分会话行为。指纹问题在这个架构中消失了。步速问题没有,这最后成了整个剩余的战斗。
我正在构建的系统(工作名称:zillow-leads-property-data actor)有三个活动部分,加上 Apify 自己的中间人:

这个图表压缩的一件事:扩展自己的数据库只在内存中,所以它收集的任何东西都不会在重新加载时独自存活。每一行都被缓冲并刷新到 FastAPI 运行者超过 /ingest(分批,每 100 行或 5 分钟,先到者为准),这个刷新就是使运行者的缓存持久的东西。"dispatch shortfall / ingest"箭头是一条双向街道:工作向下进行,收集的行向上返回。
扩展:通过一个真实的、携带 cookie 的标签页驱动搜索和详情收集,从不浏览每个列表的页面。Zillow 服务器渲染完整的属性对象(包括 resoFacts 等所有东西)直接进详情页的初始 HTML,在 NEXT_DATA/gdpClientCache 内。每个列表一个 fetch(),用正则表达式提取嵌入的 JSON。
运行程序:拥有一个持久的 SQLite 缓存,扩展程序没有。当买方的订单到来时,运行程序首先检查是否已能从该缓存中应答请求。如果所有请求的内容都已存在,订单会在几秒内返回,完全无需实时收集,这就是一个缓存命中。如果没有,运行程序会精确计算缺少的部分,仅将这个差异分派给扩展程序。
参与者:面向买方的层。它将订单写入共享的 KV 存储,轮询结果部分到达的数据,按事件计费(列表速率 vs 富集速率),并在订单仍在履行时将行流式传输到买方的数据集,而不是在最后作为一个阻塞转储。
以下是完整的订单生命周期,两条路径同时显示:左侧是快速缓存命中路径,右侧是分派扩展程序的缓存未命中路径。单个订单可以同时使用两条路径,某些行立即从缓存提供,而其余行仍在实时收集中:

从基础设施成本角度来看,我最喜欢的部分是:在小规模情况下,根本不需要在云中运行任何抓取基础设施。参与者和 KV 代理是仅有的由 Apify 托管的组件。实际执行获取的是普通机器上的浏览器扩展程序,使用住宅连接,同时也是最便宜的可信 IP 来源和真实指纹。没有代理池。没有无头浏览器场。没有指纹欺骗军备竞赛的无尽循环。
解决指纹问题并不能消除反爬虫问题。它只是将其隔离到一层:行为生物特征识别,PerimeterX 的真正核心信号。它不是看你的浏览器是什么。它是观察你如何使用它:请求时序、会话连贯性、你的行为看起来像个人还是脚本。
这里我早期犯了一个错误,我想坦诚地承认。我假设 Zillow 的栈是 Imperva,因为几篇外部文章这样声称,不经核实就容易接受了。对实际响应头和 Cookie 的实时检查讲述了不同的故事:AWSALB 和 aws-waf-token(AWS WAF)、_px3、_pxvid、pxcts(PerimeterX)。没有 Imperva 签名。这个错误很重要:围绕 Imperva token 生命周期构建的规避策略会针错目标厂商。在设计策略之前,要针对实时流量检查实际的栈。厂商身份从外部是不可猜测的,猜错会浪费时间。
一旦我找到了正确的目标,我自己针对它进行了一小批实时的受控测试,而不是相信关于 Cookie 生命周期和速率阈值的第三手说法;很难找到确切数字的可靠文档,所以我自己测量了。两个发现与在线流传的数字相矛盾:
_px3 轮换在线上经常被引用为约每 60 秒过期一次。在我自己的测试中,它大约每 120 秒轮换一次,在相隔 10 秒的六次获取中完全没有轮换。它通过 PerimeterX 自己的后台传感器轮询自我刷新,所以没有什么需要主动管理的。
pxcts 在公开文档中没有被充分记录,除了是 PerimeterX 的会话 Cookie 之一。我曾看到它被非正式地理解为意味着"挑战当前处于活跃状态"。在实践中,它是一个残留 Cookie,即使在健康的、未受到挑战的会话中也存在;只有它的值的变化在我的测试中才看起来是有意义的,而不是它的存在。
我为生产方案选择的是:高斯抖动的延迟(Box-Muller,不是固定间隔,因为机械规律的时序本身就是一个信号)、会话开始时更慢的预热、一次一个细节获取、定期"自然"导航来刷新遥测、新分派作业之间 30 秒的冷却时间,以及自适应退避,在检测到挑战时每次延迟翻倍,在 30 分钟内三次挑战后硬暂停 45 分钟。
function jitteredDelay(meanMs, stddevMs, min, max) {
// Box-Muller transform: normal distribution, not uniform.
// Uniform jitter is itself a detectable pattern.
const u1 = Math.random(), u2 = Math.random();
const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
return Math.min(max, Math.max(min, meanMs + z * stddevMs));
}
一个 45 请求、5 分钟的实时运行,采用这种节奏(平均间隔约 6.7 秒)产生零挑战。推断起来,这是每个浏览器配置文件每天约 13,000 个列表的持续吞吐量。不是无头队列意义上的快速,但足够快且可靠,这才是实际重要的属性——每天运行而不是只运行一次。将其与之前的商业 API 上限进行比较(最好情况约 6.1 秒/请求),很明显这不是不寻常的保守。这大约就是该网站实际容忍的东西,无论谁在询问。
"我们使用 AI 来帮助构建这个"没有告诉你任何东西,所以让我具体说明。
有用的模式是与大语言模型(Claude Code,这里)配对,作为跨代码库及其自身历史的调试器和模式识别器,始终由人类指导检查什么并针对实时地面真理验证结果。而不是一个被放任在"去获取数据"的自治 AI 智能体。
使之可行的一部分是工具设计,而不是提示词。我通过 MCP 上的窄的、可组合的工具向模型公开了管道的内部:直接查询数据库、针对一个 URL 运行真实的详情页提取、转储实际架构、获取子系统内部状态的实时快照。这些都不是"去阅读网站并弄清楚"的工具;每个都返回可验证的东西——真实的 HTTP 响应、真实的数据库行、真实流程的实际状态,而不是模型必须解释并希望准确的散文。这种模式可以概括:你越能给模型一个指向地面真理的窄工具,而不是一个必须阅读和猜测的页面,它作为调试器就越有用。少些"让它浏览",多些"让它查询"。
一对实际捕获的例子。运行程序的终端显示了一连串成功的 /health 检查,但实际重要的端点(/ingest、/orders/next、/cache/since)从未出现,即使服务器端路由逻辑独立正确。日志看起来是健康的,而且在主动误导。与其盯着服务器日志看得更硬,我让 Claude Code 构建一个小工具 runner_bridge_debug,从扩展程序自己的 JS 上下文内运行实时探针,并报告扩展程序本身实际看到的。这浮出了真实原因:浏览器悄悄地阻止扩展程序读取自己成功的响应,因为缺少 CORS 头,所以其健康检查将每次调用视为失败并在尝试其他三个端点前放弃。没有读再多的服务器日志也显示不了这个;工具必须从唯一能看到它的有利位置运行。
或者那份我一直在信任的字段映射文档,声称枚举 Zillow 响应中的每个字段并看起来很权威。与其相信它,我让模型用 run_sql 直接查询数据库,并分别用 fetch_zillow_detail 重新获取实时列表,然后针对文档逐字段进行差异。它的错误方式很具体且无趣:缺少真实字段、一个在 Zillow 架构中根本不存在的字段名,以及几个文档中作为字符串但在实时响应中实际是数组的字段(如果你相信文档而不是数据,会导致数据库写入崩溃)。没有通过单独阅读文档是可以猜测的。
这些例子的共同点:每个都需要针对实时的、具体的、当前的数据——来自一个特定网站的捕获的 HTTP 响应、运行流程的实际行为、逐行比较的值。大语言模型的训练数据是一个有截止日期的快照。它无法针对你的运行系统验证声明,除非你给它一种检查方式,以及一个检查工具。
值得直接说明:Claude Code 也可以驱动一个真实的、运行的浏览器,而且单独地,这个项目通过 MCP 公开了自己类似的调试工具(导航选项卡、在其中运行脚本、检查它所在的 URL)。我都用于调试:确认实时页面现在实际在做什么,而不是用于收集管道本身。优势是真实的,它是看到真正当前页面状态的唯一方式,这是训练数据快照无法给你的。缺点正是这整篇文章一直讲述的:实时和交互地驱动浏览器很慢,它正是行为检测建立来标记的不规则的、即兴的模式。一个由人类指导的好调试工具。实际进行收集工作的有节奏、专一目的的获取循环的糟糕替代品。
这里值得明确区分两种情况:你是在指挥工具,还是被征召去服务工具。Cory Doctorow 在《The Reverse Centaur's Guide to Life After AI》中对此的描述一直令我印象深刻:半人马可以自主选择何时以及如何使用工具;反向半人马则被迫按照工具的节奏和时间安排为工具服务。在 LLM 参与的情况下调试,由你主导调查并亲自验证每一项结论,属于半人马式工作。向 AI 智能体下达一个开放式的“去提取这个网站的数据”任务,然后任由它实时自由发挥,则更接近反向半人马:你要用 token、延迟和可靠性为模型买单,让它重新发现那些它并没有特殊渠道可以获知的事实,而目标网站还在主动设法提高这种探索的成本。
这种成本并非抽象概念。有一个记录在案的案例:通过 Markdown 转换 API 抓取一篇 Wikipedia 文章,为大约 3,700 个 token 的实际内容产生了约 93,000 个输入 token(Dubey,2026),膨胀了约 25 倍,其中几乎全部都是模型根本不需要的导航界面元素。这就是“让模型读取原始页面”这种非结构化方法默认要缴纳的税,也充分说明了为什么应该先提取干净的有效载荷,把 LLM 留到分析故障原因时再使用。此外,面对如今专门针对 AI 内容消费构建防御措施的目标网站,这种做法也是一场注定失败的赌局:最近一个开源的“有毒字体”项目会在字体渲染层替换页面中大约四分之一的单词,使抓取器看到的是混乱的无意义内容,而人类看到的仍然是真实页面。这预示着这场对抗未来的发展方向。
你不需要完整的运行器—Actor—代理系统,就能亲自验证其核心思想。最小可行版本只需要一个内容脚本和一个后台脚本。
// manifest.json (MV3)
{
"manifest_version": 3,
"permissions": ["storage"],
"host_permissions": ["https://www.example-target.com/*"],
"content_scripts": [{
"matches": ["https://www.example-target.com/*"],
"js": ["content.js"],
"run_at": "document_idle"
}],
"background": { "service_worker": "background.js" }
}
// content.js: runs in the page's own origin, inherits its session
browser.runtime.onMessage.addListener(async (msg) => {
if (msg.type !== "FETCH_DETAIL") return;
const res = await fetch(msg.url); // same-origin, no CORS problem
const html = await res.text();
// Most server-rendered frameworks embed a state blob somewhere in
// the initial HTML: Next.js uses __NEXT_DATA__, Nuxt uses __NUXT__,
// plenty of custom stacks do their own thing. Find your target's
// equivalent with devtools first, then swap in the real pattern here.
const match = html.match(/<script id="__APP_STATE__"[^>]*>([\s\S]*?)<\/script>/);
return match ? JSON.parse(match[1]) : null;
});
// background.js: owns pacing, never fetches directly
let lastFetch = 0;
async function fetchDetail(tabId, url) {
const wait = jitteredDelay(4500, 1300, 2500, 9000) - (Date.now() - lastFetch);
if (wait > 0) await new Promise(r => setTimeout(r, wait));
lastFetch = Date.now();
return browser.tabs.sendMessage(tabId, { type: "FETCH_DETAIL", url });
}
这就是全部诀窍。内容脚本负责发起请求,因此会继承标签页真实的会话和指纹。后台脚本负责控制节奏,确保任何请求都不会快到超出真实用户可能的操作速度。解析过程直接针对服务器已经嵌入页面的内容,而不是重放一个你无法控制、也无法保证下个月仍然存在的 API 调用。在此基础上,真正值得关注的工程问题几乎完全是严格控制请求节奏和正确设计数据模型,而不是规避指纹,因为已经没有需要规避的指纹了。
吞吐量受会话数量限制,而不是计算能力限制。对于一个安全运行的真实会话,每个浏览器配置文件每天大约抓取 13,000 条房源信息就是上限。要扩展规模,需要增加更多真实会话,而不是更多容器。这条扩展曲线与无头浏览器集群有着根本区别,也不会因为租用更多计算资源而变得更便宜。
它需要在某个地方持续运行一个真实浏览器。这种架构必然是“本地化”的:必须有某个设备保持一个带有真实会话的真实标签页处于打开状态。与纯云端 Actor 不同,这会产生切实的运维成本,但换来的好处是完全不需要代理预算。
它取决于目标服务器实际渲染的内容。这种方法对 Zillow 非常有效,因为 Zillow 会将完整的有效载荷通过服务端渲染嵌入初始 HTML。对于那些仅通过经过身份验证、带签名或短暂有效的客户端调用填充数据的目标网站,则需要采用不同但相关的策略。
这并不是在声称抓取公开数据毫无风险。它讨论的是:一旦你决定进行抓取,如何以可预测的方式完成这件事。就其价值而言,我认为 Doctorow 在书中的观点基本正确:以规范方式抓取公开信息并无不妥,真正的问题是行为恶劣的大规模滥用。这也是一个很有用的思考框架,不仅能帮助我们判断是否应该抓取,还能帮助我们思考应该如何抓取。
这里的重点并不是“Web 扩展在所有方面都优于无头浏览器”。这个结论要窄得多,但我认为也更实用:如果你真正需要的是从采用行为防御机制的网站中持续、可预测地提取数据——不是一次性抓取,而是构建一个每天运行的产品——那么你基本可以选择退出指纹规避的军备竞赛。使用真实浏览器,而不是模拟浏览器,请求节奏控制就会成为唯一值得认真解决的问题。再将这种方案与作为代理的 Apify Key-Value Store,以及作为面向买家并负责计费界面的 Apify Actor 运行时结合起来,你就能获得一个可直接投放市场的产品,同时完全不必在云端运行抓取基础设施。真实的数据采集在本地运行,代理和计费在云端运行:这就是标题中所说的“更加本地化 + Apify”架构。
Akhava