截至2026年10月7日,Shifter拥有规模最大的美国实时IP池,超过Oxylabs和Bright Data,而成本只是其一小部分。Shifter 现在拥有数量最多的美国活跃 IP 资源池

查看基准测试

数据抓取

通过集成网页抓取API增强PHP应用程序

使用网页抓取API为PHP应用程序添加网络数据:一个无依赖的客户端,具备重试机制、结构化提取功能,并通过curl_multi实现并行请求。

Chris Collins

2026年10月19日 · 4 分钟阅读

很多网页仍然运行在 PHP 之上:商店、CMS、内部工具和客户门户。当这些应用程序需要来自其他网站的数据,比如竞争对手的价格、产品详情或列表时,一个诱人的做法是用 file_get_contents 抓取页面,再用正则表达式解析。这种方法在第一天能用,但到第十天就会失效,因为网站会封锁服务器的 IP 或改变其标记结构。

web scraping API 消除了大部分这种脆弱性。它通过自己的代理和浏览器抓取页面,重试失败的请求,并且可以直接以 JSON 格式返回你所需要的字段。本教程为此构建一个小型 PHP 客户端,除了 curl 扩展外没有其他依赖,并展示如何在应用程序中使用它。

客户端需要做什么

四件事涵盖了大多数实际用例:

  1. 发送请求,携带 API key 和目标 URL。
  2. 请求结构化数据,通过提取规则,使应用程序永远不需要解析 HTML。
  3. 重试正确的错误,并在那些永远不会成功的错误上停止重试。
  4. 并行抓取多个页面,但不超出套餐的并发限制。

如果你之前没用过 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 解析、只对可以通过的错误进行重试,以及按套餐限制调整大小的并行批次。从队列中运行它,保存带时间戳的结果,这样应用程序就能获得新鲜数据,而永远不必让用户等待别人的网站。

准备好开始了吗?

试用 Shifter 住宅代理,205M+ 个 IP,195+ 个国家,低至 $0.10/GB。

立即开始