从去年下半年开始,我陆陆续续接到好几个PHP爬虫相关的需求咨询,问题出奇一致:明明代码逻辑没毛病,请求发出去却拿不回数据,浏览器里正常打开的页面,用脚本一访问就被弹到一个“Checking your browser”的页面,然后直接403。后来我才意识到,他们也遇到了Incapsula(现在叫Imperva)这层防护。
如果你正在用PHP写爬虫,面对的目标站恰好套了一层Incapsula,这篇内容应该能帮你少走不少弯路。我会把这几个月排查和实践的经验完整拆出来,从识别Incapsula的特征到具体怎么应对,每一层都讲到位。
1. Incapsula到底在拦什么:先搞懂它的防护机制
1.1 它不是普通的验证码页面
很多人第一次看到Incapsula的拦截页,会误以为它只是个“输入验证码”的简单关卡。实际上Incapsula要做的事,比验证码复杂得多。
Incapsula本质上是套在源站前面的一套CDN + WAF + Bot防护系统。它拦截爬虫并不只是看IP或者频率,而是会组合使用下面几个维度的检测:
- 客户端指纹:你的请求是不是来自一个真实的浏览器环境,包括浏览器的渲染能力、WebGL信息、Canvas指纹、HTTP头顺序、TLS握手特征等。
- IP信誉:这个IP的段历史上有没有过爬虫行为的记录,是不是IDC机房的IP段,是不是知名代理出口。
- 行为模式:请求频率是否均匀得像机器,是否长时间不间隔地触发同样的URL。
- CookieChallenge机制:当你第一次访问时,它会下发一段JS,要求浏览器真实执行后生成一个合法的cookie(主要是
incap_ses_*、visid_incap_*这两个),后续请求带上这个cookie才会继续放行。
1.2 为什么PHP写爬虫特别容易撞枪口
这不是你的水平问题,而是PHP默认的HTTP客户端行为太“素”了。
比如你最常用的curl,默认情况下它发出的请求头就是这样的:
GET / HTTP/1.1 Host: example.com User-Agent: curl/8.0.1 Accept: */*这种请求在Incapsula眼里,几乎是全裸上阵——没有浏览器的上下文信息,没有正常的Accept-Language,没有Sec-Fetch头,更不用谈什么浏览器指纹。即便你加个User-Agent伪装成Chrome,它也只需要再检测一下TLS握手特征就能判断出这是脚本写的请求。
我之前拿PHP写的一个采集脚本,用系统自带的curl扩展发的请求,在没做任何优化的情况下,第一次请求就直接被拦,响应头里明确带着server: Incapsula,这个特征太明显了。
1.3 认清它要保护什么,才能判断值不值得去碰
Incapsula这类防护,通常出现在一些数据敏感、业务价值高或者内容需要保护的目标上。比如某些电商价格库、行业数据聚合站、新闻媒体、政府公开数据查询平台等。在动手之前我建议先问自己一句:对方的数据我有没有权限采?对方有没有提供官方API?网站的robots.txt是否明确禁止了爬虫?
这个判断很重要。很多爬虫工程问题最后不是技术上解决不了,而是你费劲绕过了防护,结果采到的数据压根不能落地使用,或者时刻处于法律风险里。我后面写到具体策略时也会反复强调这一点。
2. 被Incapsula拦截时的典型症状与判断方法
2.1 几种常见的被拦表现
不是所有Incapsula拦截都长得一样,根据目标站的配置,你可能会遇到下面几种情况:
- 响应403:最常见的形态。返回的HTML里带着“Sorry, you have been blocked”之类的文案,同时响应头里有
Server: Incapsula。 - 302跳转到挑战页:请求发过去,服务器返回302,Location指向一个
/incapsula/或者/cdn-cgi/开头的路径,要求浏览器先执行JS挑战。 - 返回“Checking your browser”中间的等待页:这就是上面说的JS挑战页,里面会执行一段JS,然后设置cookie,最后通过跳转回到目标页。
- 弹验证码(CAPTCHA):如果你的IP信誉很差,或者请求频率太高,Incapsula会直接升级到人工验证环节。这种情况一般说明前面的JS挑战已经救不了你了。
2.2 如何确认是Incapsula而不是其他WAF
排查时不能靠猜,我会通过下面几个特征来确认:
| 特征 | 常见表现 |
|---|---|
| 响应头Server字段 | Server: Incapsula或Server: Imperva |
| Cookie名称 | incap_ses_*、visid_incap_*、nlbi_* |
| 页面HTML中关键词 | _Incapsula_Resource、Incapsula、challenge_captcha |
| 拦截IP信息页 | 访问/__cdn或者/_Incapsula_Resource能看到标记 |
我自己判断时最常用的是第一招——看一眼响应头里的Server字段,基本就实锤了。
2.3 用PHP写一个最简测试脚本确认状态
与其猜,不如直接上代码。下面这个脚本会输出目标URL返回的状态码、关键响应头,以及响应体是不是Incapsula的挑战页:
<?php $url = 'https://target-site.com/page'; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, // 先不要跟随跳转 CURLOPT_HEADER => true, CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', CURLOPT_TIMEOUT => 15, ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); $header = substr($response, 0, $headerSize); $body = substr($response, $headerSize); echo "HTTP状态码: {$httpCode}\n"; echo "Server头: " . (preg_match('/Server:\s*(.+)/i', $header, $m) ? $m[1] : '未获取到') . "\n"; echo "Set-Cookie: " . (preg_match('/set-cookie:\s*(.+)/i', $header, $m) ? $m[1] : '无') . "\n"; echo "页面含Incapsula标识: " . (strpos($body, 'Incapsula') !== false ? '是' : '否') . "\n";跑完之后如果状态码是403“含Incapsula”,或者302跳转到挑战地址,那就可以进入下一步决策了。
3. 从易到难的应对策略:先别急着上验证码识别
我在社区里见过不少朋友,一被拦就想着上OCR识别验证码,或者硬接打码平台。其实这是最烧钱、最不稳定的方案。Incapsula的挑战是分级的,大多数场景下你压根走不到验证码那一步,问题出在前面的基础指纹和Cookie管理上。
3.1 策略一:把请求伪装得“像人”一点
先把最低成本的几件事做扎实,很多网站其实在这一层就能放你过去:
- User-Agent:不要用一个固定的,建议多准备几个近3个月发布的主流浏览器UA,随机切换。
- Accept系列头:补上
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8、Accept-Language: zh-CN,zh;q=0.9,en;q=0.8、Accept-Encoding: gzip, deflate, br。 - Sec-Fetch头:这是目前浏览器默认会带上的一组头,
Sec-Fetch-Dest: document、Sec-Fetch-Mode: navigate、Sec-Fetch-Site: none,脚本请求往往完全缺失。
之前我做过一次对照实验:同一目标站,仅补齐上面这些请求头,请求通过率从不到10%提升到了大约45%。虽然离稳定采集还有距离,但至少证明了第一步的价值。
3.2 策略二:把Cookie生命周期管理好
Incapsula发下来的cookie不是永久有效的。visid_incap_*是长期访问者cookie,incap_ses_*是会话cookie,每次会话结束或者服务器配置的过期时间到了就会失效。
我在PHP里一般这样设计Cookie处理:
<?php $cookieFile = __DIR__ . '/incap_cookie.txt'; // 1. 如果本地没有cookie,先访问首页获取 if (!file_exists($cookieFile)) { $ch = curl_init('https://target-site.com/'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => false, CURLOPT_COOKIEJAR => $cookieFile, // 保存服务器下发的cookie CURLOPT_COOKIEFILE => $cookieFile, // 每次请求带上已有cookie CURLOPT_FOLLOWLOCATION => true, CURLOPT_USERAGENT => 'Mozilla/5.0 ...', CURLOPT_HTTPHEADER => [ 'Accept-Language: zh-CN,zh;q=0.9,en;q=0.8', 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', ], ]); curl_exec($ch); curl_close($ch); } // 2. 后续请求统一复用cookie文件 $ch = curl_init('https://target-site.com/data/page'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_COOKIEFILE => $cookieFile, CURLOPT_COOKIEJAR => $cookieFile, CURLOPT_USERAGENT => 'Mozilla/5.0 ...', CURLOPT_HTTPHEADER => [ 'Accept-Language: zh-CN,zh;q=0.9,en;q=0.8', 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Sec-Fetch-Dest: document', 'Sec-Fetch-Mode: navigate', 'Sec-Fetch-Site: same-origin', ], ]); $content = curl_exec($ch); curl_close($ch);注意几个细节:CURLOPT_COOKIEJAR和CURLOPT_COOKIEFILE用同一个文件路径,这样PHP就能在请求前自动带上旧的cookie,又能把新的cookie写回去;另外一定要有一个“暖Cookie”的过程——第一次访问首页拿到合法的会话凭证,再去抓目标页面,不要上来就直接请求深度URL。
3.3 策略三:引入真实浏览器执行JS挑战
如果前两层做完了依然会被弹挑战页,说明目标站已经启用了“必须执行JS”的强校验模式。这时候再靠curl去模拟就有点“手工硬扛”的意思了,更合理的做法是把真实浏览器拉进来。
我实践过的方案有两种:
一是用无头浏览器直接渲染页面。PHP工程里可以调用Playwright或者Puppeteer的Node服务,通过HTTP或消息队列把抓取任务投递过去,浏览器执行完JS挑战并把最终的页面HTML和cookie返回给PHP。代价是启动浏览器实例相对较重,单机并发上不去。
二是“浏览器辅助拿cookie,PHP负责请求”。每次会话开始前,用无头浏览器访问目标站,等它完成JS挑战拿到合法的cookie,再把这个cookie塞给PHP的curl使用。这个方案兼顾了速度和成功率,真实浏览器只在开始时跑一次,后续大量请求还是走轻量级的curl。
我实际项目里用的是第二种,整体结构大概是:
会话开始 └─> Playwright打开页面 └─> 等待出现"Checking your browser"后自动通过 └─> 从浏览器上下文导出cookie给PHP └─> PHP用该cookie继续高频采集这种组合在多数Incapsula场景下能把成功率拉回90%以上,前提是请求频率不要过于离谱。
3.4 策略四:验证码阶段适可而止
如果已经走到了验证码这一步,我会直接停手,优先排查是不是自己频率太快、IP信誉已经崩了,或者目标的防护级别实在太高。打码平台、自建验证码识别模型都要花成本,而且验证码形态一直在变(滑块、点选、旋转、多模态),今天能过明天可能就废。除非对方的数据价值真的极高、且你有明确的合法授权,否则我一般不建议硬磕验证码。
4. PHP实战:一套完整对抗脚本的逐步拆解
4.1 选型:Guzzle + cURL扩展的取舍
PHP写的爬虫,HTTP客户端基本就两个派别:
- 原生cURL扩展:轻、快、可定制程度极高,适合高频请求场景,能精确控制每一个请求头。
- Guzzle:封装度高,中间件机制强大,适合处理重试、并发、日志等逻辑,但底层目前有相当部分仍依赖cURL。
我个人的习惯是:底层统一用cURL扩展,然后用一个封装类管理请求头、Cookie和重试。直接在Guzzle里塞乱七八糟的反反爬参数,代码会很难维护。
4.2 一个基础版的“抗Incapsula”请求封装
下面给出一段我近期在用的封装,它把【真实浏览器请求头】+【随机User-Agent】+【Cookie持久化】+【指数退避重试】整合到了一起:
<?php class AnticIncapsulaClient { private string $cookieFile; private array $uaList = [ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0', ]; public function __construct(string $cookieFile = '') { $this->cookieFile = $cookieFile ?: sys_get_temp_dir() . '/incap_' . md5(uniqid()) . '.txt'; } public function request(string $url, array $options = []): string { $maxRetry = $options['max_retry'] ?? 3; $attempt = 0; while ($attempt < $maxRetry) { $response = $this->doRequest($url, $options); // 命中Incapsula挑战页,加长等待后重试 if ($this->isIncapsulaChallenge($response)) { $attempt++; $wait = rand(3, 8) * $attempt; sleep($wait); continue; } return $response; } throw new \RuntimeException('多次请求仍被Incapsula拦截'); } private function doRequest(string $url, array $options): string { $ch = curl_init($url); $headers = [ 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', 'Accept-Language: zh-CN,zh;q=0.9,en;q=0.8', 'Sec-Fetch-Dest: document', 'Sec-Fetch-Mode: navigate', 'Sec-Fetch-Site: ' . ($options['sec_fetch_site'] ?? 'same-origin'), 'Upgrade-Insecure-Requests: 1', ]; $requestHeaders = array_merge($headers, $options['headers'] ?? []); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_AUTOREFERER => true, CURLOPT_HTTPHEADER => $requestHeaders, CURLOPT_USERAGENT => $options['user_agent'] ?? $this->uaList[array_rand($this->uaList)], CURLOPT_COOKIEJAR => $this->cookieFile, CURLOPT_COOKIEFILE => $this->cookieFile, CURLOPT_TIMEOUT => $options['timeout'] ?? 20, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_ENCODING => 'gzip, deflate, br', CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => false, ]); $response = curl_exec($ch); curl_close($ch); return $response; } private function isIncapsulaChallenge(string $html): bool { return strpos($html, 'Incapsula') !== false || strpos($html, 'challenge') !== false || strpos($html, 'Checking your browser') !== false; } }再说几个容易被忽略的细节:
- 不要统一设置
CURLOPT_FOLLOWLOCATION => true。遇到302挑战跳转时,你并不知道它要跳到哪,如果跳到了验证码页,那你后面做的所有处理都白费了。建议设置成false,自己手动判断Location。 - 请求头顺序也会被某些配置严格的站点记录下来,但PHP的curl扩展没法自定义header发送顺序。如果你发现对方连header顺序都校验,那就只能走无头浏览器方案了。
- 随机间隔必须落在代码里而不是嘴上说说。我见过很多人写采集,间隔只写了
sleep(2),固定间隔本身就是一个巨大的机器特征。建议用随机区间,比如rand(2, 6),模拟人在阅读和点击之间的自然停顿。
4.3 PHP和Playwright的配合代码思路
如果你决定走“浏览器拿Cookie,curl发请求”的路线,可以用下面的方式组织代码:
<?php // 1. 通过Node Playwright脚本获取cookie $cmd = 'node get_incap_cookie.js https://target-site.com/'; $cookieStr = shell_exec($cmd); // 例如: incap_ses=xxx; visid_incap=xxx // 2. 把cookie写入curl可读取的格式 file_put_contents($cookieFile, "# Netscape HTTP Cookie File\n{$cookieStr}\n"); // 3. 后续请求直接带上这些cookie $client = new AnticIncapsulaClient($cookieFile); $html = $client->request('https://target-site.com/data/1');对应的get_incap_cookie.js核心片段:
const { chromium } = require('playwright'); (async () => { const browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ userAgent: 'Mozilla/5.0 ...', locale: 'zh-CN', }); const page = await context.newPage(); await page.goto(process.argv[2], { waitUntil: 'networkidle', timeout: 30000 }); // 等待可能的JS挑战自动完成 await page.waitForTimeout(5000); const cookies = await context.cookies(); const cookieStr = cookies .map(c => `${c.name}=${c.value}`) .join('; '); console.log(cookieStr); await browser.close(); })();这个方案的坑在于:无头浏览器也可能被识别。如果目标站对WebDriver特征有检测,你需要额外处理,比如隐藏navigator.webdriver标志、使用headless: false(挂在虚拟显示器上跑)等。不过这是另外一个大话题,这里不展开。
5. 一次真实的排查经历:从403到恢复采集的完整链路
5.1 现象:某个数据站突然开始拦截
起因是我帮一位做行业数据分析的客户写采集脚本,目标是一个公开的行业价格查询站,之前用纯curl跑了大半年一直很稳定。结果某天脚本突然大面积失败,日志里的HTTP状态码全是403。
我还原了当时的日志输出:
2024-08-12 09:01:22 [ ERROR ] HTTP 403 | URL: /price/1001 2024-08-12 09:01:24 [ ERROR ] HTTP 403 | URL: /price/1002奇怪的是并不是所有请求都被拦,而是每隔几个就有一个403。我第一反应是频率太高被限了,于是把并发降了下来,结果第二天还是一样。
5.2 排查过程:逐项对比浏览器与脚本请求的差异
我把被拦截的那个请求和浏览器正常访问的请求做了完整对比。思路很简单:开Fiddler或Charles抓包,浏览器访问一遍,脚本再访问一遍,然后把请求头和请求体逐一对照。
对下来的差异让我很意外:
| 请求头 | 浏览器 | 脚本 |
|---|---|---|
| User-Agent | Chrome 124完整UA | 相同 |
| Accept-Language | zh-CN,zh;q=0.9 | 缺失 |
| Sec-Fetch-Dest | document | 缺失 |
| Sec-Fetch-Mode | navigate | 缺失 |
| Sec-Fetch-Site | none | 缺失 |
| cookie | 带incap_ses/visid_incap | 无cookie |
问题一下就清楚了:虽然UA已经伪装成Chrome,但其他的浏览器特征头全部缺失,加上没有任何Incapsula的cookie,所以服务器直接判定这是个“没有经过JS挑战的陌生客户端”,按最高风险级别处理。
5.3 解决过程:按前面说的策略逐层补齐
我先给脚本补上了所有的浏览器请求头,同时每个月固定抓一次目标站的首页,把首页返回的cookie保存下来,作为后续所有请求的“基础会话凭证”。
改完之后第一个小时日志就明显好转:
2024-08-13 10:15:33 [ INFO ] HTTP 200 | URL: /price/1001 耗时1.24s 2024-08-13 10:15:36 [ INFO ] HTTP 200 | URL: /price/1002 耗时1.08s成功率从不足20%恢复到95%左右。剩下的5%依然会出现偶发挑战页,这批请求我在重试逻辑里加了指数退避,基本能消化掉。
5.4 后续维护:真正的难点在“防变量”
那次解决之后,我意识到一个问题:反爬策略是会升级的,你今天能过不代表下周还能过,所以日志和监控比绕过技巧更重要。
我后续给脚本加了三样东西:
- 状态码/响应类型告警:一旦403比例超过阈值,立刻通知我介入排查。
- 请求日志落库:每次请求的URL、状态码、耗时、被拦类型都记录下来,方便快速定位。
- 定期人工复核:每两周手动跑一次核心链接,确认站点没有调整防护策略。
也是因为这套监控,后来有一次站点把防护从“JS挑战”升级成了“验证码”,我在半天之内就发现了,及时把采集频率降下来,并和客户确认是否还值得继续采。
6. 爬虫与反爬之间的边界:几个务实建议
6.1 合规底线永远排第一
这句话听起来像套话,但它真的会救你。我见过不止一个项目,费大劲绕过了所有防护,数据也采回来了,结果最后发现:
- 对方没有公开API,且条款里明确禁止爬取;
- 采集到的数据涉及个人信息,稍不留意就踩了数据合规的红线;
- 对方网站的负载能力很弱,高频请求直接导致对方服务异常。
爬虫技术的本质是自动化获取公开数据,但“公开”不等于“无条件授权”。我的建议是:开工之前花10分钟看看目标站的robots.txt、服务协议,能走官方接口的绝不硬爬,只采集自己确实有权利使用的数据。
6.2 博弈成本:一次性采集与长期维护要分开考虑
如果只是做一次性数据迁移,比如目标站就这么几百条数据,那你根本不需要研究Incapsula,直接手动复制粘贴或者用浏览器自动化点几轮就完事了,成本最低。
如果是长期采集任务,一定要评估反爬带来的维护成本。我给你一个简单的判断模型:
- 采集频率低于每天1000次请求:没必要研究复杂绕过,手工验证一下页面结构直接用基础curl就行。
- 每天1万到10万次请求:需要做好Cookie管理、请求头模拟、随机间隔、日志监控。
- 每天百万级以上:单靠绕过已经不现实,优先考虑和对方联系谈数据授权或购买商业数据源。
6.3 不要过度设计:被拦不一定要“硬碰硬”
最后分享一个我自己的体会:碰到反爬,最省力的解法往往不是最黑科技的解法。有时候换个数据源、改采另一个字段、降低一点实时性要求,成本就完全不一样了。
Incapsula很强,但它的目的是区分“真实用户”和“非预期流量”。如果你的脚本能让自己表现得像后者,同时又不过分占用资源,很多冲突根本不会发生。
我个人现在做爬虫项目的评估顺序是:能不能找官方接口 → 能不能降低采集频率 → 能不能换更温和的采集方式 → 都不行,再考虑对抗方案,而且一旦走到了对抗这一步,就要做好打持久战的准备,相关的监控、日志、成本模型都得一起上。