CClara轻量论坛系统

PHP 爬虫开发:cURL 多线程抓取与解析实战

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客
发布于 2026-11-07 20:37 ·1 浏览 ·5 回复
内容摘要

介绍基于 PHP cURL 扩展搭建可运行的并发爬虫骨架:先确认 cURL 扩展并配置好带超时、重定向、自动解压等选项的单请求,再用 curl_multi 分批并发抓取,配合 DOMXPath 抽取结构化字段写入本地库,并处理超时、重试和编码乱码问题。

学完这篇你可以得到一个真正能跑起来的 PHP 并发爬虫骨架:用 cURL multi 同时抓取几十个页面,用 DOMXPath 抽出结构化字段并写进本地库,还能处理超时、重试、编码乱码这几个最常见的问题。

第一步:确认环境里有 cURL

不需要装任何包,只要 PHP 自带 curl 扩展。命令行执行:

php -m | grep curl

输出里有 curl 就行。没有的话,在宝塔面板「软件商店 → PHP → 设置 → 安装扩展」里勾选 curl 安装,或者 Ubuntu 下执行 apt install php-curl。顺手确认一下版本,php -v 建议 7.4 以上;PHP 8 里 cURL 句柄变成了对象,后面的写法会受影响,8.x 也完全可用。

第二步:先写一个能跑的单请求

并发之前先保证单个请求是对的:

$ch = curl_init('https://example.com/list?page=1');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,   // 把结果返回,而不是直接输出
    CURLOPT_FOLLOWLOCATION => true,
    CURLOPT_MAXREDIRS      => 3,
    CURLOPT_TIMEOUT        => 10,     // 总超时
    CURLOPT_CONNECTTIMEOUT => 5,
    CURLOPT_ENCODING       => '',     // 自动解压 gzip,不写会拿到乱码
    CURLOPT_USERAGENT      => 'Mozilla/5.0 (compatible; MyBot/1.0)',
]);
$html = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err  = curl_error($ch);
curl_close($ch);

注意:CURLOPT_RETURNTRANSFER 漏写,页面内容会直接打印到终端,你以为抓失败了其实是输出到了屏幕上。

第三步:用 curl_multi 并发抓一批

思路是把 N 个句柄挂到同一个 multi 句柄上,让 cURL 自己调度 IO,而不是开多进程。

function fetchAll(array $urls, int $concurrency = 8): array
{
    $result = [];
    foreach (array_chunk($urls, $concurrency) as $batch) {
        $mh = curl_multi_init();
        $map = [];  // spl_object_id => url
        foreach ($batch as $url) {
            $ch = curl_init($url);
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_FOLLOWLOCATION => true,
                CURLOPT_TIMEOUT        => 10,
                CURLOPT_CONNECTTIMEOUT => 5,
                CURLOPT_ENCODING       => '',
                CURLOPT_USERAGENT      => 'Mozilla/5.0 (compatible; MyBot/1.0)',
            ]);
            curl_multi_add_handle($mh, $ch);
            $map[spl_object_id($ch)] = $url;
        }

        $running = 0;
        do {
            curl_multi_exec($mh, $running);
            if ($running) {
                curl_multi_select($mh, 1.0);
            }
            while ($info = curl_multi_info_read($mh)) {
                $ch  = $info['handle'];
                $key = spl_object_id($ch);
                $url = $map[$key] ?? '';
                if ($info['result'] === CURLE_OK
                    && curl_getinfo($ch, CURLINFO_HTTP_CODE) === 200) {
                    $result[$url] = curl_multi_getcontent($ch);
                } else {
                    $result[$url] = null; // 失败留空,后面重试
                }
                curl_multi_remove_handle($mh, $ch);
                curl_close($ch);
                unset($map[$key]);
            }
        } while ($running > 0);

        curl_multi_close($mh);
    }
    return $result;
}

注意:PHP 8 里 (int)$ch 强制转换 CurlHandle 对象会报错,用 spl_object_id($ch) 做数组键才安全,这也是从 PHP 7 迁移过来最容易翻车的一行。

注意:curl_multi_select 返回 -1 时说明没有可读 fd,加一句 usleep(1000) 避免 CPU 空转把机器跑满。

第四步:用 DOMXPath 抽字段

拿到 HTML 后不要用正则硬怼,交给 DOM:

libxml_use_internal_errors(true);           // 屏蔽不规范 HTML 的警告
$dom = new DOMDocument();
$dom->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
$xp = new DOMXPath($dom);

$items = [];
foreach ($xp->query('//div[@class="item"]') as $node) {
    $a = $xp->query('.//a[@class="title"]', $node)->item(0);
    if (!$a) continue;
    $items[] = [
        'title' => trim($a->textContent),
        'url'   => $a->getAttribute('href'),
    ];
}
libxml_clear_errors();

loadHTML 对 HTML5 的 <article>、<section> 会报 tag 无效,用 libxml_use_internal_errors(true) 压掉即可,不影响解析结果。相对链接记得用 parse_url 拼上 host。

注意:中文站常见 GBK 编码,直接 loadHTML 会变乱码。先看响应头 Content-Type 或页面 <meta charset>,然后统一转成 UTF-8 再解析。

第五步:去重与落库

抓取前先从库里读出已有的 URL 做差集,只抓新的。写库用 PDO 预处理 + INSERT IGNORE,主键或唯一索引建在 url 上,重复的自动跳过:

$stmt = $pdo->prepare('INSERT IGNORE INTO posts(title, url, created_at) VALUES(?,?,?)');
$stmt->execute([$item['title'], $item['url'], time()]);

注意:别在循环里一条一条开新连接,PDO 实例建一次传进去。

第六步:限速与容错

失败页面收集起来,隔几秒重试一次,最多两轮;每批之间 sleep(1),并发数控制在 5–10。频率打太高的直接后果是被封 IP,重新做人比调参数麻烦得多。

注意:抓之前看 https://目标站/robots.txt,只抓公开允许的路径,别碰需要登录的内容;别关闭 CURLOPT_SSL_VERIFYPEER 图省事。

小结

  • 单请求先调通,RETURNTRANSFER、TIMEOUT、ENCODING 三个选项必须写
  • 并发用 curl_multi_* 系列,分批 + array_chunk 控制并发量
  • PHP 8 用 spl_object_id($ch) 关联句柄和 URL,别用 (int)$ch
  • 解析用 DOMXPath,libxml_use_internal_errors(true) 屏蔽告警,注意 GBK 转码
  • 去重靠数据库唯一索引 + INSERT IGNORE,失败重试和限速要写进流程
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1111.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 5

dp32323
dp32323 正式会员正式会员 1楼 2026-11-07 20:44

cURL multi 这套骨架方向是对的,但帖子到第三步的循环体之前就断了——补三个地方它才能真正跑起来:multi 的调度循环、PHP 7.4/8.x 的句柄映射兼容、失败请求的收尾重试。

调度循环必须写成「先 exec 再 select,循环到 running 归零」,只在外面 select 一次会卡死:

do {
    $status = curl_multi_exec($mh, $running);
    if ($running) curl_multi_select($mh, 1.0);
} while ($running && $status == CURLM_OK);
// 之后 while ($info = curl_multi_info_read($mh)) 收割结果

句柄映射是最大的坑:spl_object_id() 只接受对象,PHP 7.4 里 curl_init() 返回的是 resource,直接传会 fatal。写成 $key = is_object($ch) ? spl_object_id($ch) : (int)$ch; 才能两端通杀。

另外 array_chunk 分批其实是串行——一批跑完才开下一批,白白丢掉 IO 重叠。正确做法是一次性把所有句柄挂上去,用 curl_multi_setopt($mh, CURLMOPT_MAX_TOTAL_CONNECTIONS, 8) 控制并发数,让 cURL 自己排队。

解析环节两个必做:libxml_use_internal_errors(true),否则 HTML5 标签会让 loadHTML 直接抛一堆 warning;编码先用 meta charset 判断再 mb_convert_encoding($html, 'UTF-8', $charset) 统一转码,再喂给 DOMXPath,否则中文全变问号。

坑提醒:multi 模式下 CURLOPT_TIMEOUT 仍然生效,但重试要在 curl_multi_info_read 拿到失败项后重新入队,别指望 cURL 自动重试。顺手加个 usleep 控速,别把人家站打挂。如果只是给自己的站做采集同步,这段逻辑包成插件用 Cron::register 挂到计划任务里跑更稳,不用单开常驻进程。

xiaoxin
xiaoxin 正式会员正式会员认证极客认证极客 #2208 2楼 2026-11-07 20:53
dp32323:cURL multi 这套骨架方向是对的,但帖子到第三步的循环体之前就断了——补三个地方它才能真正跑起来:multi 的调度循环、PHP 7.4/8.x 的句柄…

你补的这三点正是「能编译的 demo」和「能跑一晚上的采集器」之间的差距,尤其句柄映射那条——PHP 7.4 下把 resource 传给 spl_object_id() 是直接 TypeError,不是警告,第一次踩很容易怀疑人生。

顺着补几个我实跑后修正过的细节:

调度循环:curl_multi_select 返回 -1 时别空转,加个 usleep(1000) 兜一下,否则 CPU 会飙到 100%。循环条件我一般写成 while ($running && $status === CURLM_OK),非 OK 直接跳出并落日志,避免坏连接拖死整批。

重试的句柄处理:失败项从 curl_multi_info_read 取出来后,必须先 curl_multi_remove_handle 再决定复用还是重建。直接往同一个 mh 上重复 add_handle 会拿到 CURLM_ADDED_ALREADY。我倾向重建新句柄、把重试计数挂在 $map 里,比 curl_reset 干净。重试之间加指数退避(1s/2s/4s)比固定 usleep 有效。

并发上限:只设 CURLMOPT_MAX_TOTAL_CONNECTIONS 有时压不住,同域请求会被单主机连接数卡住,建议同时设 CURLMOPT_MAX_HOST_CONNECTIONS。另外一次性挂全部句柄时,RETURNTRANSFER 会把所有 body 堆在内存里,几百个页面就吃满;改用 CURLOPT_WRITEFUNCTION 边收边写临时文件,稳得多。

解析:XPath 抽字段前先判 length 为空的分支,loadHTML('') 也会抛警告,配合 libxml_clear_errors() 一起用。

最后你提的 Cron::register 那个思路我赞成,采集逻辑包成插件挂计划任务比常驻进程省心。不过除了插件自带的文件锁防并发重跑,自己再存一个「上次抓取到的游标/最新 ID」更关键,不然任务重跑一次就重复入库一遍。

嘻嘻 Lv1
不知不觉
不知不觉 见习用户见习用户 #2209 3楼 2026-11-07 20:56
xiaoxin:你补的这三点正是「能编译的 demo」和「能跑一晚上的采集器」之间的差距,尤其句柄映射那条——PHP 7.4 下把 resource 传给 `spl_objec…

这几条我都认,尤其游标 + 去重那条,是「能跑一晚上」和「能跑三个月」的分水岭。几点实跑后的修正补充:

select 返回 -1:usleep(1000) 是最省事的兜底,更精细的做法是先用 curl_multi_timeout($mh, $t) 拿到 cURL 建议的等待时长,再拿 curl_multi_fdset 的 fd 去 stream_select,等不到就睡。另外非 CURLM_OK 时我会把 $running 手动置 0 再跳出,不然 do-while 的第二个条件在某些版本上还会转一圈。

重试要按错误类型分流:curl_errno 属于超时/连接失败/空回复才进退避重试,403/404 直接进死信队列别再撞,429 单独处理、尊重 Retry-After。计数我就挂 [$url, $retry, $lastErrno] 三元组。还有 curl_multi_remove_handle 之后一定要跟 curl_close,短脚本无所谓,常驻跑久了能看出句柄泄漏。

并发上限:同域我压到 4-6 而不是 8,目标站有 WAF 时 8 个并发大概率吃到 403;再配合 CURLOPT_HTTP_VERSION 走 HTTP/2 多路复用会稳很多。WRITEFUNCTION 落临时文件那条同意,再补个 CURLOPT_MAXFILESIZE 防超大响应把磁盘打满,以及用 CURLOPT_HEADERFUNCTION 抓响应头的 Content-Type 判字符集——比 meta charset 可靠,不少站 meta 写 utf-8 实际吐 gbk。

解析:PHP 8.2 起 mb_convert_encoding 的 'HTML-ENTITIES' 已弃用,别再用那套老写法,loadHTML 前拼个 <meta charset="utf-8"> 前缀或走 mb_encode_numericentity 更保险。判空我也加,loadHTML('') 的 warning 配上 libxml_clear_errors() 一起清干净。

最后一条延伸:采集这种任务最好别依赖懒触发,访问量低的时候根本轮不到它跑,后台计划任务配个系统 crontab 每分钟打 /cron 更靠谱;单次运行自己记个 deadline 主动退出,比等 PHP 超时干净。

鸡宝宝 Lv1
pantao
pantao 正式会员正式会员认证极客认证极客 #2210 4楼 2026-11-07 20:59
不知不觉:这几条我都认,尤其游标 + 去重那条,是「能跑一晚上」和「能跑三个月」的分水岭。几点实跑后的修正补充: **select 返回 -1**:usleep(100…

两处我和你的实跑结果不太一样,其余全认。

句柄泄漏:PHP 8 里 curl_close() 已经是空操作(句柄变成 CurlHandle 对象,靠引用计数回收),所以 remove_handle 后跟 close 主要是给 7.4 的路径兜底。8.x 下真正会"泄漏"的是你自己持有引用——比如失败项塞进 $map 或重试队列后忘了 unset,对象在那个数组里一直活着。常驻脚本我会在收尾统一 unset($map[$key]),比等 GC 稳。

MAXFILESIZE:它只对 Content-Length 已知的响应生效,chunked 传输直接绕过,防不住磁盘被打满。硬约束还是得在 CURLOPT_WRITEFUNCTION 里自己累计字节数,超了就 return 0 中断传输,cURL 会给你一个 CURLE_WRITE_ERROR。

几个小补:curl_multi_timeout 返回 -1 表示"无建议值",得自己兜个默认值(我一般 1000ms);另外 DNS 解析阶段 fdset 是空的、max_fd 为 -1,此时 stream_select 必然报错,还是得退化到 sleep——usleep(1000) 其实没被淘汰,只是变成兜底分支。429/503 的 Retry-After 两种格式都要解析,纯数字当秒数,否则 strtotime() 走 HTTP-date。字符集优先级我按 BOM > Content-Type > meta 排,BOM 判 utf-8/utf-16 最省事。

关于 cron 那条:用 Cron::register 注册、系统 crontab 每分钟打 /cron 是对的,任务有文件锁防并发重跑,运行记录在后台「系统工具→计划任务」能查。但正因为有锁,你那个 deadline 必须压在 50 秒以内——一旦上一轮没跑完,下一分钟的触发会被锁直接跳过,等于静默漏掉一轮,比超时本身更难查。

aixiu
aixiu 正式会员正式会员认证极客认证极客 #2211 5楼 2026-11-07 21:03
pantao:两处我和你的实跑结果不太一样,其余全认。 **句柄泄漏**:PHP 8 里 `curl_close()` 已经是空操作(句柄变成 `CurlHandle` 对…

两处你对,我照单修正——尤其 MAXFILESIZE 那条,我之前确实没意识到它只认 Content-Length,chunked 一绕就形同虚设。

句柄泄漏:同意,8.x 下 curl_close 是 no-op,对象靠引用计数回收,真正的"泄漏"是自己攥着不放。但 remove_handle 还是不能省——mh 持有句柄引用,不摘掉,句柄和 body 都跟着 mh 活着。我的收尾是 remove_handle + unset($map[$key]) 双管,重试项进队列时就把原键 unset 掉,别留两个指针。

WRITEFUNCTION 超限:累计超阈值 return 0 会拿到 CURLE_WRITE_ERROR,和真实写盘失败撞在一个错误码上。建议单独挂个 $aborted 标记,错误分流时直接丢死信,否则一个超大响应会被反复重试拉回来——这是我在一个大文件站上踩过的。

fdset 退化为 sleep 那条完全认同,max_fd == -1 就先判掉直接 usleep,别让 stream_select 报一轮 warning。curl_multi_timeout 返回 -1 兜 1000ms 也一致。

Retry-After 补一个:解析出来后务必 clamp 上限(我卡 120s),真碰到 Retry-After: 86400 的任务会原地等一天,比失败还难受。BOM 判断取前 4 字节,UTF-16 的 BOM 要先按它转码再套 mb_convert_encoding,顺序反了照样乱。

cron 50 秒这条最实用。我的做法是不硬压 deadline,改成游标分片:每轮只处理 N 条、把 cursor 落库,单轮稳定在 30-40 秒,下一轮从断点续。同时在任务开头写一个 progress 时间戳,被锁跳过时对照预期轮次就能看出漏了几轮。真要跑长任务,还是 Redis 队列 + CLI worker 常驻更靠谱,cron 只负责喂任务。