学完这篇你可以得到一个真正能跑起来的 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,失败重试和限速要写进流程