各位同学,大家好!欢迎来到今天的“PHP爬虫进阶实战”讲座。我是你们的老朋友,一个在代码世界里摸爬滚打多年的“数字探子”。
今天我们不讲那些枯燥的面向对象理论,也不讲那些让你打瞌睡的MVC设计模式。今天,我们要聊的是如何用PHP编写一个优雅、高效,而且——最重要的是——活得好好的爬虫程序。
想象一下,你是一个夜间的访客,想去隔壁那个“知识宝库”(目标网站)偷点东西。你的目标很明确,但你面对的是守卫森严的“保安队长”(目标网站的服务器)。如果你一上来就疯狂砸门,保安队长肯定会把你轰出去,甚至还会叫来警察(封你的IP)。
所以,今天的核心主题就是:如何在不被保安赶出来的前提下,优雅地把数据偷走?
我们要解决两大核心问题:IP被封和请求频率限制。这听起来像是个死局,对吧?不,只要手段高明,就没有攻不破的堡垒。
准备好了吗?让我们把编程当成一场潜行游戏,开始吧!
第一部分:伪装的艺术——为什么你的请求像个“机器”
首先,我们要明白目标服务器是怎么看我们的。当你发起一个HTTP请求时,你不仅仅是在发送一个请求包,你是在发送你的“身份证”。
如果你不经过任何处理,直接用PHP的file_get_contents()或者最简单的curl发请求,服务器一眼就能认出你:“嘿,这不是PHP脚本发来的吗?快封掉!”
为什么?因为HTTP请求头(Headers)。
HTTP头就像是你去拜访别人时的着装和敲门方式。标准的浏览器请求头里有很多信息,比如:
- User-Agent(用户代理): 告诉服务器你用的是Chrome、Firefox还是Safari。如果服务器发现你的User-Agent里写着“Python/2.7”或者“PHP/5.x”,它就会把你归类为“爬虫”。
- Accept-Language(接受语言): 你的浏览器告诉服务器你喜欢讲中文还是英文。
- Accept-Encoding(接受编码): 告诉服务器你会解压gzip数据。
如果你把这些都省略了,或者设置得很假,服务器一看就穿帮。
代码示例1:给你的curl戴上假面具
来,看看这段代码。这不仅仅是一个请求,这是一个伪装。
<?php
// 这是一个伪装成Chrome浏览器的User-Agent池
// 别只用一个,要轮换!轮换!轮换!重要的事情说三遍。
// 如果所有爬虫都用同一个UA,服务器就一眼看穿了。
$userAgents = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36',
'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:89.0) Gecko/20100101 Firefox/89.0',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0',
// ... 这里可以加几百个
];
function getFakeHeader($url, $userData = null) {
$ch = curl_init($url);
// 1. 随机选一个“假面”UA
$ua = $userAgents[array_rand($userAgents)];
// 2. 设置基本的请求头
$headers = [
'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language: zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7',
'Accept-Encoding: gzip, deflate',
'Connection: keep-alive',
'Upgrade-Insecure-Requests: 1',
'Cache-Control: max-age=0',
// 关键点:告诉服务器我是浏览器,不要用WebSockets这种复杂的连接
'Sec-Fetch-Dest: document',
'Sec-Fetch-Mode: navigate',
'Sec-Fetch-Site: none',
'Sec-Fetch-User: ?1',
'DNT: 1', // Do Not Track,虽然很多网站不在乎,但加上显得更真实
];
// 3. 如果是POST请求,别忘了Content-Type
if ($userData) {
$headers[] = 'Content-Type: application/x-www-form-urlencoded';
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
// 4. 设置User-Agent,这是核心中的核心
curl_setopt($ch, CURLOPT_USERAGENT, $ua);
// 5. 追踪响应头,这对我们判断是否被封很有用
curl_setopt($ch, CURLOPT_HEADER, true);
// 6. 返回响应体,不直接输出
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
// 7. 自动跟随重定向(301, 302),爬虫遇到跳转不能傻站着不动
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
// 8. 设置超时,防止你的脚本卡死在某个坏链接上
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
// 9. SSL证书验证,设为false是因为国内很多网站证书配置得很随意
// 注意:生产环境尽量开启,但这会慢一点
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
$response = curl_exec($ch);
// 10. 检查是否有错误
if (curl_errno($ch)) {
echo 'Curl error: ' . curl_error($ch);
}
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [
'code' => $httpCode,
'body' => $response,
'ua' => $ua
];
}
// 使用示例
$result = getFakeHeader('http://example.com');
if ($result['code'] == 200) {
echo "成功伪装,拿到数据!n";
echo "使用的UA: " . $result['ua'] . "n";
} else {
echo "失败了,可能是被封了或者网络问题。状态码: " . $result['code'] . "n";
}
看懂了吗?这里没有写一行废话。每一个curl_setopt都是为了让你更像一个人,而不是一台机器。如果你看到状态码是403 Forbidden,恭喜你,你的伪装彻底失败了,对方服务器识破了你的真面目。
第二部分:节奏大师——为什么你的请求像个“喝醉的醉汉”
即使你伪装得再完美,如果你像机关枪一样突突突地发请求,保安队长还是会怀疑你。
人类的上网习惯是什么样的?
- 打开一个网页,读一会儿,划两下。
- 点击一个链接,等两秒。
- 看完一篇文章,刷新一下。
而你的PHP脚本呢?for ($i=0; $i<1000; $i++) { sleep(0.01); request(); }。这简直是在侮辱“人类”这个词。
服务器会通过检测请求间隔、请求总量来判定你是否异常。
代码示例2:随机延迟与指数退避
我们不能用固定的sleep(1),因为固定的时间很容易被脚本识别。我们需要“随机性”。
function smartRequest($url) {
// 1. 先假装执行请求
$response = getFakeHeader($url);
// 2. 检查状态码
if ($response['code'] == 403) {
// 如果被禁了,怎么办?
// 策略:不要马上重试,等一会!
$delay = mt_rand(60, 120); // 等待1-2分钟
echo "哎呀,被拦截了。等待 {$delay} 秒后重试...n";
sleep($delay);
return smartRequest($url); // 递归重试
}
if ($response['code'] != 200) {
// 如果是429 Too Many Requests,或者404,稍微等一下再试
$delay = mt_rand(5, 10);
sleep($delay);
return smartRequest($url);
}
// 3. 如果成功,也不要马上走!要像人类一样浏览!
// 随机等待 1 到 5 秒
$humanDelay = mt_rand(1000, 5000) / 1000;
echo "成功!休息 {$humanDelay} 秒继续...n";
sleep($humanDelay);
return $response['body'];
}
上面的代码里,我引入了一个高级概念:指数退避。
简单来说,如果你连续被拦截了几次,下一次等待的时间应该比上次更久。比如第一次等5秒,第二次等10秒,第三次等20秒。这就像是一个喝醉的人,撞了墙之后会站在原地晃悠更久,而不是马上又冲过来。
此外,我们还要注意并发控制。如果你开启了100个线程同时请求,哪怕每个线程都隔了5秒,总流量依然会很大。我们需要控制同时在线的最大请求数。
代码示例3:限流器(Semaphore)
想象一下,你的家里只能容纳5个人,进来5个了,第6个就得在外面排队。
class RateLimiter {
private $maxRequests;
private $timeWindow;
private $requests = [];
public function __construct($maxRequests, $timeWindow) {
$this->maxRequests = $maxRequests;
$this->timeWindow = $timeWindow; // 单位:秒
}
public function acquire() {
$now = time();
// 清理过期的请求记录
foreach ($this->requests as $key => $timestamp) {
if ($timestamp < $now - $this->timeWindow) {
unset($this->requests[$key]);
}
}
// 检查当前请求数
if (count($this->requests) >= $this->maxRequests) {
// 如果满了,计算需要等待的时间
// 获取最早的一个请求时间,算出窗口期还剩多久
$oldestTime = min($this->requests);
$waitTime = ($oldestTime + $this->timeWindow) - $now;
echo "限流触发!请等待 {$waitTime} 秒。n";
sleep($waitTime);
return $this->acquire(); // 递归调用,直到有空位
}
// 记录这次请求
$this->requests[$now] = $now;
}
}
// 使用
$limiter = new RateLimiter(5, 10); // 10秒内最多5个请求
for ($i = 0; $i < 20; $i++) {
$limiter->acquire(); // 先拿通行证
// 然后再发请求
$url = "http://example.com/page/" . $i;
echo "请求第 {$i} 个页面...n";
// 这里调用你的 getFakeHeader
}
这代码虽然简单,但它能保证你的流量就像涓涓细流,而不是洪水猛兽。
第三部分:隐身术——代理IP池
好了,你已经伪装得像个Chrome浏览器,也表现得像个慢吞吞的老人。但是,如果目标网站有“人脸识别”系统,发现这几十个IP都在同一个区域、同一个数据中心发起请求,它们还是会把你当成机器人。
这时候,你需要代理IP(Proxy IP)。
代理IP就像是你的替身。当你访问网站时,你实际上是通过代理服务器的中转才到达目标的。服务器只看到代理服务器的IP,而看不到你的真实IP。
代码示例4:构建一个简单的代理池客户端
你不需要自己去买IP(除非你有大量预算),你可以去那些卖代理IP的服务商买,或者从免费资源里薅(虽然免费的一般都慢且不稳定,但用来练手够了)。
class ProxyPool {
private $proxyList;
private $currentIndex = 0;
public function __construct($proxies) {
$this->proxyList = $proxies;
}
// 获取一个可用的代理
public function getNextProxy() {
if (empty($this->proxyList)) {
return null;
}
$proxy = $this->proxyList[$this->currentIndex];
$this->currentIndex = ($this->currentIndex + 1) % count($this->proxyList);
return $this->formatProxy($proxy);
}
private function formatProxy($proxy) {
// 支持格式: IP:PORT 或 http://IP:PORT
if (strpos($proxy, 'http') === 0) {
return $proxy;
}
return "http://{$proxy}";
}
// 验证代理是否可用(这一步很耗时,建议异步做)
public function isValidProxy($proxyUrl) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, "http://httpbin.org/ip"); // httpbin.org能显示你的真实出口IP
curl_setopt($ch, CURLOPT_PROXY, $proxyUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
$output = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// 如果返回200,且内容里包含的不是你的本地IP,那就是好的
return ($httpCode == 200 && strpos($output, 'origin') !== false);
}
}
// 模拟一个代理列表
$myProxies = [
"115.236.45.24:8080",
"106.11.110.55:8080",
"123.125.114.144:8888",
"180.101.50.242:8888"
];
$pool = new ProxyPool($myProxies);
// 在请求时使用
function requestWithProxy($url, $proxy) {
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_PROXY, $proxy);
// ... 其他配置同上
$response = curl_exec($ch);
// ... 处理
}
警告: 使用免费代理是极其痛苦的过程。它们经常失效、超时,或者被目标网站标记为代理流量而直接拉黑。所以,在实战中,你会看到很多复杂的代理池管理系统,它们会自动检测代理的存活率,把坏的剔除,只保留活的。这个逻辑和之前的“限流器”类似,只不过这里管理的是IP的有效性。
第四部分:分布式爬虫——组建你的特工队
如果目标数据量巨大(比如爬取某乎的所有回答),单机PHP脚本再怎么优化,CPU、内存和网络带宽都是瓶颈。
这时候,你不能只做一个人,你要组建一个“特工队”。这就是分布式爬虫。
架构设计:Redis + 多台机器
想象一下,你有一堆工人,你需要一个经理来分配任务,还需要一个仓库来存放数据。
- 任务队列: Redis的List结构非常适合做队列。我们把需要爬取的URL都扔进这个List里。
- 调度器: 一台机器(Master)或者多台机器(Worker)负责从List里取任务。
- 执行器: Worker机器拿到任务,执行爬取逻辑,然后存数据。
代码示例5:简单的Redis队列调度
假设我们有一个主脚本,负责把URL推送到队列;还有一个Worker脚本,负责从队列里取出来跑。
Master端:
// master.php
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$urls = [
'http://example.com/1',
'http://example.com/2',
// ... 10000个URL
];
foreach ($urls as $url) {
// rpush 表示从右侧推入,lpop 表示从左侧取出,这样就是先进先出
$redis->rpush('crawl_queue', $url);
}
echo "所有任务已推送到队列,Worker们可以开始干活了!n";
Worker端:
// worker.php
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
echo "Worker启动,正在监听队列...n";
while (true) {
// blpop 是阻塞式的。如果队列空了,它就停下来睡觉,不占用CPU。
// 如果有新任务来了,它会被自动唤醒。
$result = $redis->blpop('crawl_queue', 0);
if ($result) {
$url = $result[1]; // blpop返回的是 array([queue_name], [value])
echo "抓取任务: {$url}n";
// 执行核心爬虫逻辑
$content = getFakeHeader($url);
// 假设抓到了数据,存入数据库或者结果队列
if ($content['code'] == 200) {
// saveToDatabase($content['body']);
echo "成功!n";
} else {
echo "失败,重试或丢弃。n";
}
// 抓取完一个,稍微喘口气,防止太快
sleep(mt_rand(1, 3));
}
}
在这个架构中,每台Worker机器都可以独立设置自己的IP代理和频率限制。如果你有10台机器,每台机器每秒能发5个请求,那你每秒就能发50个请求,而且对单台服务器压力很小。
这里有个巨大的好处:容错性。如果其中一台机器崩了(比如断网了),其他的机器会继续工作。等机器好了,它再回来从队列里接着拿任务。
第五部分:直面挑战——JS渲染与验证码
现在,你学会了伪装、控制频率、使用代理、分布式。但是,你会遇到更高级的敌人。
1. JS动态渲染
很多现代网站,内容不是一开始就写死在HTML里的,而是需要JavaScript动态加载出来的。你用PHP curl 抓到的HTML里,根本找不到你想要的数据,只有一堆<div id="app"></div>。
这时候,PHP显得有点力不从心了。因为PHP是服务器端语言,它不懂浏览器怎么运行JS。
解决方案:
- 方案A(暴力): 直接爬API。这是最爽的。很多网站前端代码写得烂,直接调用了一个
.json或.php接口返回数据。用F12开发者工具一看就知道了。 - 方案B(Headless Chrome): 既然PHP搞不定JS,那就让Chrome去搞。你可以写一个简单的脚本,用PHP调用系统的
chrome --headless命令,或者使用像Selenium这样的工具。但这会让PHP爬虫变得非常慢,而且资源消耗巨大。
2. 验证码(CAPTCHA)
这是终极BOSS。
当服务器检测到你的行为模式异常(比如IP突然变化、操作频率过高),它就会扔给你一个图片验证码:“老兄,证明你不是机器人,输这行字。”
应对策略:
- 简单验证码: OCR(光学字符识别)。你可以用PHP调用开源的
tesseract-ocr库,或者集成百度/阿里云的API。但成功率参差不齐。 - 复杂验证码: 这种通常需要人工打码平台介入。你把验证码图片发给平台,平台的人或者机器会告诉你答案,你填进去继续爬。
代码片段(调用百度打码API的伪代码):
function solveCaptcha($imagePath) {
$apiUrl = 'http://api.xxx.com/verify';
$params = [
'image' => base64_encode(file_get_contents($imagePath)),
'appid' => 'YOUR_APPID',
'secret' => 'YOUR_SECRET'
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $apiUrl);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $params);
// ... 返回JSON
$result = curl_exec($ch);
curl_close($ch);
return json_decode($result, true)['result'];
}
第六部分:伦理与红线——不要把人逼急了
最后,我想强调一点:技术是中立的,但使用技术的人要有底线。
- 尊重
robots.txt: 这是网站的“入内须知”。爬虫应该先访问http://目标域名/robots.txt,看看里面写了什么。如果它写了“Disallow: /admin”,你就绝对不要去爬/admin目录。这是最基本的礼仪。 - 不要导致对方宕机: 你的爬虫代码里一定要有超时机制和异常处理。如果你的脚本发起了1000个并发请求,把对方的数据库撑爆了,导致正常用户无法访问,这就是“拒绝服务攻击”。这不仅不道德,还可能导致你的服务器IP和爬取的IP一起被封。
- 数据量控制: 如果目标网站很小,数据量只有100条,你就别写分布式爬虫了,单线程慢慢爬完就撤。没必要给人家服务器造成压力。
总结
好了,今天的讲座接近尾声。我们来回顾一下:
- 伪装: 别让服务器知道你是脚本。用好User-Agent和Headers。
- 节奏: 别像个疯子一样发请求。用随机延迟和指数退避。
- 隐身: 多IP轮换,用代理IP池隐藏真实身份。
- 架构: 数据量大就上分布式,用Redis做队列调度。
- 升级: 遇到JS渲染和验证码,要用更高级的手段(API、OCR、打码平台)。
写爬虫就像是一场猫鼠游戏。技术在不断进步,反爬虫的策略也在不断升级。今天你学会了用Headers伪装,明天服务器可能就要检查TLS指纹;今天你用代理IP,明天对方可能就要查ASN号段。
所以,不要仅仅满足于能跑通代码,要像研究高数一样研究HTTP协议,像研究心理学一样研究服务器逻辑。只有这样,你才能成为一名真正的“资深编程专家”。
谢谢大家!希望你们在未来的爬虫开发中,既能拿到数据,又能全身而退!如果有问题,欢迎私下交流,别在群里喊我,我怕被老板看见骂我摸鱼。