很多网页仍然运行在 PHP 之上:商店、CMS、内部工具和客户门户。当这些应用程序需要来自其他网站的数据,比如竞争对手的价格、产品详情或列表时,一个诱人的做法是用 file_get_contents 抓取页面,再用正则表达式解析。这种方法在第一天能用,但到第十天就会失效,因为网站会封锁服务器的 IP 或改变其标记结构。
web scraping API 消除了大部分这种脆弱性。它通过自己的代理和浏览器抓取页面,重试失败的请求,并且可以直接以 JSON 格式返回你所需要的字段。本教程为此构建一个小型 PHP 客户端,除了 curl 扩展外没有其他依赖,并展示如何在应用程序中使用它。
客户端需要做什么
四件事涵盖了大多数实际用例:
- 发送请求,携带 API key 和目标 URL。
- 请求结构化数据,通过提取规则,使应用程序永远不需要解析 HTML。
- 重试正确的错误,并在那些永远不会成功的错误上停止重试。
- 并行抓取多个页面,但不超出套餐的并发限制。
如果你之前没用过 scraping API,我们的新手指南更详细地介绍了请求格式和状态码。
客户端代码
<?php
final class ShifterScraper
{
private const RETRY = [408, 422, 429, 500];
public function __construct(
private string $apiKey,
private string $endpoint = 'https://scrape.shifter.io/v1',
private int $attempts = 3,
private float $backoffSeconds = 2.0,
) {}
/** Fetch one page; with $extractRules, returns the decoded JSON instead of HTML. */
public function fetch(string $url, array $extractRules = [], bool $renderJs = false): string|array
{
$params = ['api_key' => $this->apiKey, 'url' => $url];
if ($renderJs) {
$params['render_js'] = 1;
}
if ($extractRules) {
$params['extract_rules'] = json_encode($extractRules);
}
for ($attempt = 1; ; $attempt++) {
[$status, $body] = $this->get($this->endpoint . '?' . http_build_query($params));
if ($status === 200) {
return $extractRules ? json_decode($body, true, flags: JSON_THROW_ON_ERROR) : $body;
}
if (!in_array($status, self::RETRY, true) || $attempt >= $this->attempts) {
throw new RuntimeException("HTTP {$status} for {$url}: " . substr($body, 0, 200));
}
usleep((int) ($this->backoffSeconds * $attempt * 1_000_000));
}
}
/** Fetch many pages at once with curl_multi, at most $concurrency in flight. */
public function fetchMany(array $urls, array $extractRules = [], int $concurrency = 10): array
{
$results = [];
foreach (array_chunk($urls, $concurrency) as $batch) {
$multi = curl_multi_init();
$handles = [];
foreach ($batch as $url) {
$params = ['api_key' => $this->apiKey, 'url' => $url];
if ($extractRules) {
$params['extract_rules'] = json_encode($extractRules);
}
$handles[$url] = $this->handle($this->endpoint . '?' . http_build_query($params));
curl_multi_add_handle($multi, $handles[$url]);
}
do {
curl_multi_exec($multi, $running);
curl_multi_select($multi);
} while ($running > 0);
foreach ($handles as $url => $h) {
$status = curl_getinfo($h, CURLINFO_RESPONSE_CODE);
$body = curl_multi_getcontent($h);
$results[$url] = $status === 200
? ($extractRules ? json_decode($body, true) : $body)
: $this->fetchOrNull($url, $extractRules); // retry failures one by one
curl_multi_remove_handle($multi, $h);
}
curl_multi_close($multi);
}
return $results;
}
private function fetchOrNull(string $url, array $extractRules): string|array|null
{
try {
return $this->fetch($url, $extractRules);
} catch (RuntimeException) {
return null;
}
}
private function handle(string $requestUrl): CurlHandle
{
$h = curl_init($requestUrl);
curl_setopt_array($h, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 120]);
return $h;
}
private function get(string $requestUrl): array
{
$h = $this->handle($requestUrl);
$body = curl_exec($h);
$status = curl_getinfo($h, CURLINFO_RESPONSE_CODE);
return [$status, $body === false ? curl_error($h) : $body];
}
}
有几个设计选择值得说明:
- 只在有帮助的地方重试。 超时(
408)、渲染失败(422)、速率限制(429)和服务器错误(500)会以逐渐增长的暂停时间重试。其他情况,比如无效的密钥,会立即抛出异常。 - 提取规则变成数组。 有了规则后,
fetch()返回解码后的 JSON,因此调用代码处理的是$product['price'],而不是标记文本。 - 使用
curl_multi并行请求。fetchMany()一次发送一批请求并等待全部完成。批次大小被设置为保持在你的套餐并发限制之下。批次中失败的页面会通过带有重试机制的fetch()获得第二次机会,如果仍然失败则返回null。
使用方法
<?php
require __DIR__ . '/ShifterScraper.php';
$scraper = new ShifterScraper(getenv('SHIFTER_SCRAPER_KEY'));
// One page, as structured data instead of HTML
$product = $scraper->fetch('https://shop.example.com/p/123', [
'name' => ['selector' => 'h1', 'output' => 'text'],
'price' => ['selector' => '.price', 'output' => 'text'],
]);
// A batch, ten requests in flight at a time; failed pages come back as null
$pages = $scraper->fetchMany($productUrls, ['name' => ['selector' => 'h1', 'output' => 'text']], 10);
将 API key 保存在环境变量或你框架的密钥配置中,绝不要写在代码里。
我们如何测试
我们针对一个代替 API 的本地 PHP 服务器运行了该客户端。它提供了正常页面、一个在成功之前先返回两次 429 的页面、一个始终返回 401 的页面,以及一个始终以 408 超时的页面。测试检查了以下内容:
- HTML 和提取出的 JSON 都正确返回;
- 不稳定的页面在第三次尝试时成功;
401只尝试一次,而408会重试到上限后才失败;- 一批 13 个页面返回了 12 个结果,以及失败页面的一个
null。
在 PHP 8.5 上,全部 11 项检查都通过了。
融入应用程序
不要在页面浏览时抓取数据。 用户永远不应该等待远程网站加载。在队列工作进程或定时任务中运行采集工作,然后提供已存储的结果。
保存结果时附带时间戳。 将每条记录与采集时间一起保存,以便应用程序可以显示数据的新鲜程度,并只刷新过时的部分。
积极使用缓存。 大多数数据不会每分钟都变化。对大多数网站来说,每小时或每天刷新一次价格和列表就足够了,每跳过一次请求就能省下一笔钱。
记录失败情况。 记录哪些 URL 返回了 null 以及原因。失败数量突然上升通常意味着网站改变了其布局,你的提取规则需要更新。
遵守套餐的限制。 将批次大小设置为你套餐并发限制或以下。在 Shifter 上,这一限制在每次 20 到 500 个请求之间,具体取决于套餐。
何时应该直接使用代理
scraping API 适合大多数数据采集工作:它为你处理渲染、重试和 IP 选择。但有些工作仍然更适合直接使用代理连接,比如当你需要完全控制 HTTP 客户端,或需要在同一个 IP 上保持长会话时。我们关于在 PHP 中使用 cURL 和 Guzzle 配合住宅代理的指南介绍了这条路线。
常见问题
PHP 可以用于网页抓取吗?
可以。PHP 的 curl 扩展能很好地处理 HTTP,包括通过 curl_multi 进行并行请求。将其与 web scraping API 搭配使用,意味着应用程序本身不必管理代理、浏览器或应对封锁。
从 PHP 调用 web scraping API 需要 Guzzle 吗?
不需要。本指南中的客户端只使用内置的 curl 扩展。如果你的应用程序已经在使用 Guzzle,它同样可以用。
如何从 PHP 抓取由 JavaScript 渲染的页面?
通过传入 render_js 参数请求 API 渲染页面,客户端已将其作为一个参数暴露出来。API 会运行一个浏览器并返回渲染后的页面,因此 PHP 永远不需要无头浏览器。
我可以同时发送多少个请求?
取决于你的 scraping API 套餐所允许的数量。将 fetchMany() 的批次大小设置为该限制或以下。
结论
PHP 应用程序无需自行构建抓取基础设施就能使用网络数据:一个基于 curl 的小型客户端、用提取规则代替 HTML 解析、只对可以通过的错误进行重试,以及按套餐限制调整大小的并行批次。从队列中运行它,保存带时间戳的结果,这样应用程序就能获得新鲜数据,而永远不必让用户等待别人的网站。