PHP 网络爬虫:实用指南
Expert in Web Scraping Technologies
TL;DR:
- PHP 带来了基本抓取器所需的一切。
ext-curl获取,DOMDocument加DOMXPath解析,并且不涉及 Composer 包。一个有效的提取大约需要十五行。 - 默认的
php-cli安装是不够的。 在一个干净的 Ubuntu 环境中,php-cli只暴露了json和libxml;curl、dom和mbstring每一个都必须单独安装。在编写代码之前检查,而不是之后。 DOMDocument::loadHTML()会用警告淹没你的输出。 真实页面是 HTML5,而解析器预期是 HTML4。在libxml_use_internal_errors(true)中包裹加载,否则噪声会在没有失败的情况下读作失败。- 无抓取通用抓取 API 返回一个 JSON 信封。 标记位于
data,所以你首先解码,然后解析。 chrome-php驱动远程浏览器,但其默认超时时间是为本地浏览器设置的。 与云 CDP 端点相比,库的 5 秒默认完成了 2 次中的 6 次 尝试;提高sendSyncDefaultTimeout后达到 6 次中的 5 次。- 免费开始: Scrapeless 控制面板 发放一个适用于下面每个示例的密钥。
你需要什么
这里的每一个示例都是在 PHP 8.3.6 (cli) 上针对 quotes.toscrape.com 运行的,这是一个专门为抓取练习发布的网站。
在干净的机器上,第一个惊讶是基本 PHP 安装包含的内容少得可怜。新安装的 php-cli 仅报告了 json 和 libxml,这是这里重要的集合。你实际上需要的三个包是单独到达的:
bash
# Ubuntu/Debian — php-cli alone is not enough
apt-get install -y php-cli php-curl php-xml php-mbstring
php -m | grep -E '^(curl|dom|libxml|mbstring)$'
# curl
# dom
# libxml
# mbstring
php-xml 是提供 DOMDocument 的包;扩展名为 dom,这就是为什么 grep xml 一无所获并使人们陷入循环的原因。
使用 ext-curl 获取页面
file_get_contents() 适用于简单情况,但它给你没有状态代码、没有头控制,也没有值得一提的超时。 ext-curl 是任何真实情况的基础:
php
<?php
$ch = curl_init('https://quotes.toscrape.com/');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; php-guide/1.0)',
]);
$html = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
echo "http={$status} bytes=" . strlen($html) . "\n";
这打印出 http=200 bytes=11064。两个选项的权重比看起来要重:CURLOPT_RETURNTRANSFER 是让 curl_exec() 返回主体而不是打印它的原因,而没有 CURLOPT_USERAGENT,许多网站会以不同或者根本不响应裸 PHP 客户端。
使用 DOMDocument 和 DOMXPath 解析
PHP 的 DOM 扩展是 W3C DOM 标准 的完全实现,DOMXPath 给你与任何专用抓取库相同的查询能力。陷阱在于加载器。
php
<?php
$doc = new DOMDocument();
libxml_use_internal_errors(true); // without this, every HTML5 tag warns
$doc->loadHTML($html);
libxml_clear_errors();
$xpath = new DOMXPath($doc);
$quotes = $xpath->query("//div[@class='quote']");
echo "quotes=" . $quotes->length . "\n";
$first = $quotes->item(0);
$text = trim($xpath->query(".//span[@class='text']", $first)->item(0)->textContent);
$author = trim($xpath->query(".//small[@class='author']", $first)->item(0)->textContent);
echo "first_author={$author}\n";
echo "first_text=" . mb_substr($text, 0, 40) . "\n";
输出:
text
quotes=10
first_author=Albert Einstein
first_text=“The world as we have created it is a pr
有三件事值得提及。
libxml_use_internal_errors(true) 在实践中不是可选的。 DOMDocument 实现 HTML4 解析,因此现代页面上每个 HTML5 元素和未加引号的属性都会引发警告。跳过调用,成功抓取的输出会被解析器的噪声埋没 — PHP 手册将这个开关记录为控制该报告的支持方式。
DOMXPath::query() 的第二个参数将查询范围限制在一个节点中。没有它,.//span[@class='text'] 会搜索整个文档,你会为每一行获取第一个引用的文本。那个单一参数就是每行提取和一个稍微错误的数据集之间的区别。
mb_substr() 而非 substr() 重要,因为页面使用了弯曲的引号。substr() 根据字节进行切割,会将多字节字符分割成无效 UTF-8,这正是三步后在数据库中浮现的那种损坏。
纯 PHP 何时无法满足需求
上面的脚本之所以有效,是因为目标在服务器端渲染其内容,并且不在乎是谁在请求。两件事情使得这种情况结束:仅在 JavaScript 执行后才存在的内容,以及决定裸 HTTP 客户端不是浏览器的网站。这两者都不是 PHP 的问题 — 没有任何语言的 HTTP 客户端都能解决这两者。
通过代理池路由请求可以解决第二种情况的一部分,如果你在一个框架内工作,Laravel 代理集成指南 在这方面的配置比这篇文章深入得多。
在这个时候,有两个升级,它们是不同的工具,而不是彼此的更好版本。保持纯 HTTP 路径在有效的地方;它依然是最快和最便宜的选择,差距很大。
升级一:无抓取通用抓取 API
第一个升级保持你的代码形状像一个 HTTP 客户端,并将艰难的部分移至服务器端。请求是普通的 ext-curl;响应是差异显现的地方。
php
<?php
$key = getenv('SCRAPELESS_API_KEY');
$payload = json_encode([
'actor' => 'unlocker.webunlocker',
'input' => ['url' => 'https://quotes.toscrape.com/', 'js_render' => false],
]);
$ch = curl_init('https://api.scrapeless.com/api/v2/unlocker/request');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_TIMEOUT => 90,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-token: {$key}"],
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$body = json_decode($raw, true);
echo "http={$status} envelope_keys=" . implode(',', array_keys($body)) . "\n";
$apiHtml = $body['data']; // the markup lives here, not in $raw
echo "bytes=" . strlen($apiHtml) . "\n";
text
http=200 envelope_keys=code,data
bytes=11064
信封是需要内化的东西。$raw 是 JSON,传递给 DOMDocument 会生成一个没有匹配节点且没有错误的文档——选择器只是返回零行。解码,获取 data,然后解析。将该展开保持在一个助手中,而不是在调用位置重复 json_decode(...)['data']。
字节数另当别论,解析代码没有变化。将 $apiHtml 输入同一个 DOMXPath 块返回相同的十个引用,这是要点:只有数据获取变化。
升级二:来自 PHP 的真实浏览器
当内容真正需要 JavaScript 时,你需要一个浏览器。chrome-php/chrome 支持 Chrome DevTools 协议,可以启动本地 Chrome 或通过 WebSocket 连接远程的 Chrome。
bash
composer require chrome-php/chrome
# Using version ^1.16 for chrome-php/chrome → v1.16.1
php
<?php
require __DIR__ . '/vendor/autoload.php';
use HeadlessChromium\BrowserFactory;
$key = getenv('SCRAPELESS_API_KEY');
$uri = "wss://browser.scrapeless.com/api/v2/browser?token={$key}";
$browser = BrowserFactory::connectToBrowser($uri, [
'sendSyncDefaultTimeout' => 60000, // see below — the default is 5000
]);
$page = $browser->createPage();
$page->navigate('https://quotes.toscrape.com/')->waitForNavigation();
echo "title: " . $page->evaluate('document.title')->getReturnValue() . "\n";
echo "quotes on page: " . $page->evaluate('document.querySelectorAll(".quote").length')->getReturnValue() . "\n";
$browser->close();
Output:
text
title: Quotes to Scrape
quotes on page: 10
默认超时设置为本地浏览器尺寸
sendSyncDefaultTimeout 行是值得文章的部分。chrome-php 将其默认值设置为 5000 毫秒,对于在同一台机器上运行的 Chrome 来说是大方的,但在跨 TLS 连接时则显得不足。两个匹配的六轮测试,相同的脚本,相同的目标,仅更改该选项:
| 配置 | 结果 | 失败模式 |
|---|---|---|
| 库默认(5000 ms) | 2 成功,4 失败 | 每个失败 OperationTimedOut: Operation timed out after 5s |
sendSyncDefaultTimeout => 60000 |
5 成功,1 失败 | 单个失败是连接时的不同错误 |
得出两个结论,第二个是人们常常忽视的。
增加超时是必要的。如果保持默认,大多数尝试在同一消息上失败,而这条消息是误导性的信息——它提到的是超时,因此读起来像是页面或网络很慢,但实际上超时的是库自己对协议回合的等待。
增加超时也不够。唯一幸存的失败是 Cannot connect to the browser, make sure it was not closed,大约在五秒钟时出现,而连接仍在建立中而非在执行命令时。更大的命令超时对此没有影响。将获取浏览器视为工作设计中的一个容易出错的步骤,与在获得浏览器后所做的工作不同,并保持 $browser->close() 在 finally 中以便中途失败不会拖延会话。
一个错误的诊断
关于这些超时的第一个理论是 URI 的查询字符串从未达到 WebSocket 握手,携带了 ?token=。对此有真实证据:Protocol::validateSocketUri() 仅返回 [$scheme, $host, $port],并完全丢弃路径和查询。
但这仍然是错误的。握手是在其他地方构建的,由 Protocol::getRequestHandshake() 调用,后者调用一个单独的 validateUri(),返回五个元素,并在请求行之前明确地重新附加查询。令牌确实到达了。失败是延迟,而两个函数以不同深度解析相同 URI 的情况是一个巧合,恰好看起来像一个错误。
这提醒我们,在一个有多个 URI 解析器的库中,找到一个丢弃数据的解析器并不意味着它就是你关心的路径上的解析器。
在三者之间选择
| 方法 | 使用场合 | 成本 |
|---|---|---|
ext-curl + DOMXPath |
服务器生成的 HTML,宽松目标 | 最低;没有依赖 |
| 通用抓取 API | 被阻止或受到挑战,不需要 JS | 一次 HTTP 调用,信封需解包 |
chrome-php + 抓取浏览器 |
内容需要执行 JavaScript | 最高;每个作业一个浏览器会话 |
逐步向下该列表,而不是向上。大多数看似需要浏览器的页面实际上将数据嵌入在 <script> 标签中,一个 DOMXPath 查询加上 json_decode() 在每个方面都优于浏览器会话。
结论
PHP 是一种完全合理的抓取语言,人们期望缺失的部分都在标准库中。ext-curl 和 DOMXPath 完全涵盖了服务器生成的页面,libxml_use_internal_errors(true) 和 query() 的范围限制第二个参数是区分功能代码和静默错误代码的两个细节。
当目标停止配合时,故意升级。API 路径保持你的代码为 HTTP 客户端,并只要求你解包一个信封。浏览器路径需要一个会话,如果该浏览器是远程的,特定的配置选项:chrome-php 的五秒默认值是本地 Chrome 的假设,保持其不变导致这里六个连接中有四个失败。
准备好从 PHP 抓取吗?
在Srapeless仪表板上创建一个密钥,并针对您已经收集的某一页面运行ext-curl示例。如果它返回您期望的内容,您就完成了——没有依赖,没有浏览器。通用抓取API适用于那些没有返回内容的页面,当前的费用在定价页面上。
FAQ
问:我需要Composer来使用PHP进行抓取吗?
不需要。使用ext-curl进行抓取和使用DOMDocument和DOMXPath进行解析只需要标准安装中的扩展。Composer仅在浏览器驱动程序(例如chrome-php/chrome)出现时才会参与。
问:为什么DOMDocument在每个页面上都会打印警告?
因为它实现了HTML4解析,而现代页面是HTML5。警告是信息性的,而不是失败。请在loadHTML()之前调用libxml_use_internal_errors(true),并在libxml_clear_errors()之后调用,并在实际想查看时检查libxml_get_errors()。
问:当API请求成功时,为什么我的解析什么都不返回?
您几乎可以肯定在解析信封。通用抓取API返回的JSON中包含data内的标记,因此DOMDocument接收JSON字符串并且找不到匹配的节点而没有引发错误。解码响应并解析data。
问:我该使用哪个XPath来匹配类属性?
//div[@class='quote']仅匹配精确的属性值。对于携带多个类的元素,请使用//div[contains(concat(' ', normalize-space(@class), ' '), ' quote ')],这将避免像裸contains()那样匹配quote-footer。
问:我该为大型抓取项目使用PHP吗?
对于大规模的抓取和解析工作,是的——curl_multi_*为您提供了真正的并发,DOM扩展也很快。PHP较弱的地方是长时间运行的浏览器自动化,Playwright和Puppeteer周围的工具更成熟。一个常见的分离方式是将PHP用于HTTP路径,而对于真正需要的页面则使用浏览器服务。
在Scrapeless,我们仅访问公开可用的数据,并严格遵循适用的法律、法规和网站隐私政策。本博客中的内容仅供演示之用,不涉及任何非法或侵权活动。我们对使用本博客或第三方链接中的信息不做任何保证,并免除所有责任。在进行任何抓取活动之前,请咨询您的法律顾问,并审查目标网站的服务条款或获取必要的许可。



