在本网页抓取教程中,我们将了解 PHP 以及如何使用它来抓取网页。虽然 Javascript 和 Python 是最流行的网络抓取语言,但 PHP 具有大部分可用的相同工具,我们今天将深入研究这些工具。 我们将从概述抓取基础知识开始,例如如何发送 HTTP 请求和如何解析 HTML – 所有这些都使用两个最流行的 PHP 网络抓取库:Guzzle和DomCrawler。 最后,我们将通过从https://www.producthunt.com/抓取产品信息,用一个真实的示例项目来总结所有内容。
什么是网页爬取?
Web 爬取是公共数据收集,人们可能想要收集这些公共数据的原因有数千种,从寻找潜在员工到竞争情报。
为什么使用 PHP 进行网页爬取?
PHP 是众所周知的最流行的服务器端网络语言之一,这意味着它非常适合嵌入式实时抓取工具!不仅如此,PHP 还可以运行在许多系统上并且易于访问。
设置
我们需要两个工具:HTTP 客户端和HTML 解析器。 在本教程中,这两种工具都以多个社区库的形式在 PHP 中可用,我们将特别关注两个库:
- Guzzle – 帮助我们检索网页内容的 HTTP 客户端库。
- DomCrawler – HTML 解析客户端,帮助我们从完整的网页 HTML 文档中提取我们想要的特定细节。
我们将本教程分为两部分,每部分都反映了这些工具中的一个:首先,我们将了解如何使用 Guzzle 抓取数据,然后我们将使用 DomCrawler 的 dom 解析器功能来解析这些文档。
发出请求
PHP 提供了许多 HTTP 客户端,但最常用的两个是:标准库的 curl 客户端和最流行的社区客户端Guzzle。 这两个客户端之间有很多差异,但在网络抓取方面,主要差异是:
- Guzzle 提供更好的用户体验 更现代和用户友好的库 API 使我们能够更轻松地处理异常、重试和失败,从而使 Guzzle 网络抓取工具更易于维护。
- Guzzle 提供异步支持 我们将更多地讨论同步代码与异步代码,但本质上异步客户端允许我们并行发出多个请求,从而使我们的网络抓取工具更快!
- Guzzle 不支持较新的 http2 或 http3(QUIC) 协议 目前,有 3 个活动的 HTTP 协议:http1.1、http2 和 http3(又名 QUIC)。虽然 http2/3 中网络抓取的性能提升并不显着,但 http1 连接可能导致网络抓取被阻止。话虽这么说,我们将在本文后面介绍一些解决此问题的方法。
因此,总而言之,Guzzle 更易于使用且通常速度更快,而 curl 库功能更丰富但更难使用且更难优化。我们将暂时坚持使用 Guzzle,但在试用之前,让我们快速概述一下什么是 HTTP?
HTTP协议基础
要从公共资源收集数据,我们需要先与其建立连接。大多数网络都是通过 HTTP 提供服务的,这非常简单:我们(客户端)向网站(服务器)发送对特定文档的请求,一旦服务器处理了我们的请求,它就会回复一个响应(他的文档)——一个非常直接的交流! 即,我们发送一个请求对象,它包含一个方法(又名类型)、位置和标头。反过来,我们收到一个响应对象,它由状态代码、标题和文档内容本身组成。 让我们快速浏览一下这些组件中的每一个,它们的含义以及它们在网络抓取中的相关性。
什么是请求和响应?
当涉及到网络抓取时,我们并不需要知道关于 HTTP 协议的每一个细节,尽管我们应该熟悉请求和响应的概念。
请求方法
HTTP 请求可以方便地分为几种执行不同功能的类型:
GET
requests 旨在请求文件。POST
requests 旨在通过发送文档来请求文档。HEAD
请求旨在请求文档的元信息。
我们在网络抓取中主要会遇到这三个。我们将使用它GET
来检索网页、POST
提交搜索表单和其他网页操作,以及HEAD
查看网页是否值得抓取。 其他在网络抓取中很少遇到的请求方式有:
PATCH
请求旨在更新文档。PUT
requests 旨在创建新文档或更新它。DELETE
请求旨在删除文档。 我们不太可能在网络抓取时看到这些,但很高兴知道它们是什么。
请求位置
URL(通用资源位置)表示我们请求的是什么资源。我们可以将其视为由几个不同部分组成的 ID:
在这里,我们可以可视化 URL 的每个部分:我们有协议,当涉及到 HTTP 时,它是 或http,https然后我们有主机,它是服务器的地址(或域),最后,我们有位置资源和一些自定义参数。 如果您不确定 URL 的结构,您可以随时启动 PHP 的交互式 shell ( php -a) 并让它为您解决:
php > var_dump(parse_url("https://www.domain.com/path/to/resource?arg1=true&arg2=false")); array(4) { 'scheme' => string(4) "http" 'host' => string(14) "www.domain.com" 'path' => string(17) "/path/to/resource" 'query' => string(20) "arg1=true&arg2=false" }
请求标头
虽然看起来请求标头只是网络抓取中的次要元数据细节,但它们非常重要。 标头包含有关请求的基本详细信息,例如谁在请求数据?他们期望什么类型的数据?等等。弄错这些可能会导致网络抓取工具被拒绝访问或返回错误响应。 让我们来看看一些最重要的标头及其含义: User-Agent是一个身份标头,它告诉服务器谁在请求文档。
# example user agent for Chrome browser on Windows operating system: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36
每当您在网络浏览器中访问网页时,都会使用类似于“浏览器名称、操作系统、某些版本号”的用户代理字符串来标识自己。 这有助于服务器确定是为客户端提供服务还是拒绝客户端。在网络抓取中,我们(显然)不想被拒绝访问,所以我们必须通过伪造我们的用户代理看起来像浏览器之一来融入其中。
有许多在线数据库包含各种平台的最新用户代理字符串,例如whatismyborwser.com 的用户代理数据库
Cookie用于存储持久性数据。这是网站跟踪用户状态的一项重要功能:用户登录、配置首选项等。 Accept标头(还有 Accept-Encoding、Accept-Language 等)包含有关我们期望的内容类型的信息。通常当网络抓取时,我们想模仿一种流行的网络浏览器,比如 Chrome 浏览器使用:
text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
有关更多信息,请参阅MDN 默认接受值文档
X-前缀的标头是特殊的自定义标头。这些在网络抓取时非常重要,因为它们可能配置抓取的网站/webapp 的重要功能。 这些是一些最重要的观察结果,有关更多信息,请参阅MDN 的标准 http 标头文档中的大量完整文档
响应状态码
方便的是,所有 HTTP 响应都带有一个状态代码,指示此请求是成功、失败还是需要某些替代操作(如身份验证)。 让我们快速浏览一下与网络抓取最相关的状态代码:
- 200 个范围代码意味着成功!
- 300 个范围代码意味着重定向 – 该页面现在位于其他地方。 换句话说,如果我们请求
/product1.html
它的内容可能会被移动到一个新的位置,比如/products/1.html
. - 400 范围代码表示请求格式错误或被拒绝。 如果我们的网络抓取工具缺少一些标头、cookie 或身份验证详细信息,通常会发生这种情况。
- 500 个范围代码通常意味着服务器问题。 该网站可能现在不可用或有意禁止访问我们的网络抓取工具。
有关 HTTP 状态代码的更多信息,请参阅 MDN 上的 HTTP 状态文档
响应头
在网络抓取方面,响应标头提供了一些关于连接功能和效率的重要信息。例如,Set-Cookie
header 请求我们的客户端为将来的请求保存一些 cookie,这可能对网站功能至关重要。其他标头(例如Etag
)Last-Modified
旨在帮助客户端进行缓存以优化资源使用。 最后,就像请求标头一样,以 为前缀的标头X-
是自定义 Web 功能标头。
我们简单地忽略了核心 HTTP 组件,现在是时候尝试一下,看看 HTTP 在实际 PHP 中是如何工作的了!
发出 GET 请求
在本节中,我们将使用Guzzle HTTP 客户端并探索它如何用于常见的网络抓取任务。 首先,我们需要创建一个Client
对象,也称为连接池会话或HTTP 持久连接会话。我们将使用此对象来处理我们的配置并发送请求:
use GuzzleHttp\Client; $client = new Client(); $url = 'https://httpbin.org/html'; $response = $client->get($url); // ^^^ Here we're using GET request but similarly we can use HEAD or POST printf("POST request to %s", $url); printf("status: %s\n", $response->getStatusCode()); printf("headers: %s\n", json_encode($response->getHeaders(), JSON_PRETTY_PRINT)); printf("body: %s", $response->getBody()->getContents()); // alternative to print full response structure use: var_dump($response);
这里我们使用https://httpbin.org/ HTTP 测试服务来检索一个简单的 HTML 页面。运行时,此脚本应打印出状态代码(例如 200)、标题(元信息)和正文(文档数据)。
发出 POST 请求
有时我们的网络抓取工具可能需要提交某种形式来检索 HTML 结果。例如,搜索查询通常使用POST
带有查询详细信息作为 JSON 值的请求:
use GuzzleHttp\Client; $client = new Client(); $url = 'https://httpbin.org/post'; $response = $client->post( 'https://httpbin.org/post', ['json' => ['query' => 'foobar', 'page' => 2]] // ^^^^^ using json argument we can pass an associative array which will be sent as a json type POST request // alternatively we can use form type request: // ['form_params' => ['query' => 'foobar', 'page' => 2]] ); printf("POST request to %s", $url); printf("status: %s\n", $response->getStatusCode()); printf("headers: %s\n", json_encode($response->getHeaders(), JSON_PRETTY_PRINT)); printf("body: %s", $response->getBody()->getContents());
Guzzle 足够聪明,可以将我们的 PHP 关联数组转换为正确的 JSON 或表单值以用于表单提交。根据json
参数,它将使用适当的标头准备请求Content-Type/Length
,并将正文值从关联数组转换为 JSON 或表单对象。
设置标题以防止阻塞
正如我们之前所述,我们的请求必须提供有关自身的元数据,这有助于服务器确定要返回的内容。 通常,此元数据可用于识别网络抓取工具并阻止它们。现代网络浏览器会自动在每个请求中包含特定的元数据详细信息,因此如果我们不希望作为网络抓取工具脱颖而出,我们应该复制这种行为。 首先,User-Agent
标Accept
头通常是无用的赠品,因此在创建我们的标头时,Client
我们可以将它们设置为普通 Chrome 浏览器会使用的值:
$client = new Client([ 'headers' => [ 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36', 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8', ] ]);
这将确保客户端发出的每个请求都包含这些默认标头。 请注意,这只是机器人拦截和请求标头方面的冰山一角,但是仅仅设置User-Agent
和Accept
标头应该会让我们更难被发现!
如何使用标头来阻止 Web 抓取工具以及如何修复它
现在我们知道如何使用 Guzzle 正确地发出请求,让我们来看看我们如何通过使用异步代码结构使它们更快。
加速异步请求
由于 HTTP 协议是双方之间的数据交换协议,因此需要等待很多时间。 换句话说,当我们的客户端发送请求时,它需要等待它一直传输到服务器并返回,这会拖延我们的程序。为什么我们的程序要袖手旁观,等待环球旅行的请求?这称为 IO(输入/输出)块。 在 PHP 中处理 IO 块的主要方法是使用异步承诺或回调。换句话说,当我们发出请求时,HTTP 客户端会返回一个“promise”对象,该对象将在未来某个时间变成内容。这使我们能够同时安排多个请求,从而使我们的网络抓取器明显更快! 让我们看一下发出 10 个请求的同步代码:
use GuzzleHttp\Client; $client = new Client(); $_start = microtime(true); // Array of 10 urls: $urls = array_fill(0, 50, 'https://httpbin.org/html'); // Create promise objects from urls array: $responses = array_map( function ($url) use ($client) { return $client->get($url); }, $urls ); printf('finished %d requests in %.2f seconds\n', count($responses), microtime(true) - $_start);
在这里,我们向https://httpbin.org/html发出了 10 个请求,如果我们运行代码,大约需要几5
秒钟才能完成。听起来不多,但这几乎是线性扩展的:如果我们发出 100 个请求,那将是 50 秒;1000 个请求 – 将超过 8 分钟! 相反,让我们使用带有承诺的异步编程:
use GuzzleHttp\Client; use GuzzleHttp\Pool; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; // initiate http client $client = new Client([ // Client config allows us to set fail conditions // for example we can set request timeout options: 'connect_timeout' => 5, 'timeout' => 2.00, // we can also specify that 400/500 requests will be considered as failures as well: 'http_errors' => false, ]); // create 10 Request objects: $urls = array_fill(0, 50, 'https://httpbin.org/html'); $requests = array_map(function ($url) { return new Request('GET', $url); }, $urls); // define are callbacks: // This will be called for every successful response function handleSuccess(Response $response, $index) { global $urls; printf("success: %s\n", $urls[$index]); } function handleFailure($reason, $index) { global $urls; printf( "failed: %s, \n reason: %s\n", $urls[$index], $reason, ); } // scrape our requests $_start = microtime(true); $pool = new Pool($client, $requests, [ // we can set concurrency limit to prevent scraping too fast which might cause our scraper to be blocked 'concurrency' => 20, 'fulfilled' => 'handleSuccess', 'rejected' => 'handleFailure', ]); $pool->promise()->wait(); printf('finished %d requests in %.2f seconds\n', count($urls), microtime(true) - $_start);
在这里,我们将代码从同步代码改写为 promise + callback/errorback 结构。我们正在创建 10 个Request
对象并将它们传递到一个请求池,该请求池将一起发送它们。 我们还为我们的池提供了 2 个功能:如何处理每个成功的请求以及如何处理每个失败的请求。理想情况下,我们希望记录/重试失败的并解析好的数据。 在这里,同样的 10 个请求在 1-2 秒内完成,这至少比我们之前的同步示例快 5 倍。当发出数千个请求时,异步方法通常可以快一百倍!
在本节中,我们介绍了如何检索 HTML 文档以及如何在避免被阻止的情况下快速检索。接下来,让我们看看如何从 HTML 中提取数据,并最终将所有内容整合到一个完整的示例中。
解析 HTML 内容
HTML(超文本标记语言)是一种支持网络的文本数据结构。它的伟大之处在于它旨在成为机器可读的文本内容,这对于网络抓取来说是个好消息,因为我们可以轻松地用代码解析数据! HTML DOM(文档对象结构)是一种树型结构,很容易被机器解析。例如,让我们来看这个简单的 HTML 内容:
<head> <title> </title> </head> <body> <h1>Introduction</h1> <div> <p>some description text: </p> <a class="link" href="http://example.com">example link</a> </div> </body>
在这里,我们看到了一个简单网站可能提供的极其基本的 HTML 文档。你已经可以通过文本的缩进看到树状结构,但我们甚至可以进一步说明它:
这种树结构非常适合网络抓取,因为我们可以轻松浏览整个文档。 例如,要查找网站的标题,我们可以看到它在<head>
HTML 元素下,而 HTML 元素又在等下。<title>
换句话说 – 如果我们想为 1000 个不同的页面提取 1000 个标题,我们将编写一个规则来head->title->text
查找他们中的每一个。 当谈到 HTML 解析时,有两种标准的方式来编写这些规则:CSS 选择器和 XPath 选择器——让我们进一步深入,看看我们如何使用它们来解析网络废弃数据!
使用 DomCrawler
我们将使用DomCrawler
HTML 文档解析器,它同时支持 CSS 选择器和 XPATH 选择器,我们在之前的文章中深入介绍过:使用 CSS 选择器解析 HTML和使用 Xpath 解析 HTML 让我们从一个简单的基于 XPath 选择器的解析示例开始:
use Symfony\Component\DomCrawler\Crawler; // example html document $html = <<<'HTML' <head> <title>My Website</title> </head> <body> <div class="content"> <h1>First blog post</h1> <p>Just started this blog!</p> <a href="https://www.jingzhengli.com/blog">Checkout My Blog</a> </div> </body> HTML; // first we build our Crawler tree $tree = new Crawler($html); // then we can run xpaths against it: printf($tree->filterXPath('//a/@href')->text()); // https://jingzhengli.com/blog
在上面的示例中,我们定义了一个示例 HTML 文档,构建了一个树对象 ( Crawler
) 并使用了一个简单的 XPATH 选择器来提取href
第一个链接的属性。 然而,通常 CSS 选择器是更优雅的解决方案。为此,我们可以安装可选的依赖项css-selectorCrawler
,它也为我们的对象提供 CSS 选择器支持:
printf($tree->filter('a::attr(href)')->text()); // https://jingzhengli.com/blog
除了 XPath 和 CSS 选择器之外还有很多东西DomCrawler
,但对于网络抓取,我们最感兴趣的是这两个特性。现在我们已经熟悉了它们,让我们构建一个真正的网络抓取工具吧!
示例项目
是时候把我们学到的所有东西都放到一个示例 PHP 网站抓取工具中了。在本节中,我们将抓取https://www.producthunt.com/,它本质上是一个技术产品目录,人们可以在其中提交和讨论新技术产品。 我们的抓取器应该从产品目录(例如https://www.producthunt.com/topics/developer-tools )中找到产品 url (例如https://www.producthunt.com/products/slack#slack)并抓取每个产品对于字段:标题、副标题、投票和标签: 让我们看看完整的抓取脚本,然后看看各个动作/组件:
use GuzzleHttp\Client; use GuzzleHttp\Pool; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; use Symfony\Component\DomCrawler\Crawler; // initiate http client $client = new Client([ 'connect_timeout' => 10, 'timeout' => 10.00, 'http_errors' => true, 'headers' => [ 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36', 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8', ] ]); // global storage where all results will be added to: $results = []; // First we define our main scraping loop: function scrape($urls, $callback, $errback) { // create 10 Request objects: $requests = array_map(function ($url) { return new Request('GET', $url); }, $urls); global $client; $pool = new Pool($client, $requests, [ 'concurrency' => 5, 'fulfilled' => $callback, 'rejected' => $errback, ]); $pool->promise()->wait(); } // Then, we define our callbacks: // 1. This will be called for every product scrape: function parseProduct(Response $response, $index) { $tree = new Crawler($response->getBody()->getContents()); $result = [ // we can use xpath selectors: 'title' => $tree->filterXpath('//h1')->text(), 'subtitle' => $tree->filterXpath('//h2')->text(), // or css selectors: 'votes' => $tree->filter("span[class*='bigButtonCount']")->text(), // to get multiple elements we need to use each() mapping: 'tags' => $tree->filterXpath( "//div[contains(@class,'topicPriceWrap')] //a[contains(@href, '/topics/')]/text()" )->each(function ($node, $i) { return $node->text(); }), ]; global $results; array_push($results, $result); } // 2. This will be called for every directory scrape: function parseDirectory(Response $response, $index) { $tree = new Crawler($response->getBody()->getContents()); $urls = $tree->filter("div[class*='item'] a[class*=comments]")->each( function ($node, $i) { return 'https://www.producthunt.com' . $node->attr('href'); } ); scrape( $urls, 'parseProduct', 'logFailure', ); } // 3. This will be called for every failed request be it product or directory: function logFailure($reason, $index) { printf("failed: %s\n", $reason); } // Finally, we can define our scrape logic and run the scraper: $start_urls = [ // define urls where to find product urls, like topic directory: "https://www.producthunt.com/topics/developer-tools", ]; $_start = microtime(true); scrape($start_urls, 'parseDirectory', 'logFailure'); printf('scraped %d results in %.2f seconds', count($results), microtime(true) - $_start); echo '\n'; echo json_encode($results, JSON_PRETTY_PRINT);
这看起来很长,所以让我们分解一下,看看我们在这里执行的各个步骤:
- 我们建立我们的全局
Client
对象来处理所有连接 - 然后我们定义了我们的异步抓取器函数,该函数采用要抓取的 URL 列表和将在成功或失败时调用的 2 个函数(或函数名称)。这是我们的抽象抓取执行器。
- 此外,我们定义了我们的解析回调。当产品抓取成功时,
parseProduct()
将被调用,它将从 HTML 中提取数据并将其附加到$results
存储变量。 - 我们也做同样的事情,
parseDirectory()
当目录抓取成功时将调用它并抓取所有找到的产品。 - 我们还需要一个通用的故障处理程序,这是我们的
logFailure()
功能。理想情况下,在生产中,我们希望实现某种重试功能或将失败存储在数据库中以便稍后重试(现在,我们只记录它们) - 最后,我们用一个执行逻辑的小脚本来完成所有工作。我们定义
start_urls
其中包含产品目录的 URL 并安排整个抓取逻辑。
如果我们运行这个脚本,我们应该看到类似这样的输出:
scraped 20 results in 9.25 seconds [ { "title": "Unsplash 5.0", "subtitle": "Free (do whatever you want) high-resolution photos.", "votes": "7,003", "tags": [ "Web App", "Design Tools", "Photography" ] }, { "title": "Sublime Text 3.0", "subtitle": "The long awaited version 3 of the popular code editor", "votes": "5,579", "tags": [ "Linux", "Windows", "Mac" ] }, ...
常问问题
让我们用一些关于 PHP 中的网页抓取的常见问题来结束这篇文章:
PHP 爬虫可以使用无头浏览器吗?
是的,php-webdriver可以用作 Selenium 客户端来启动真正的 Web 浏览器并使用 Web 浏览器操作检索 Web 数据,而不是我们今天使用的 Guzzle HTTP 客户端。
爬行和抓取有什么区别?
Web 抓取涉及一些额外的组件,可帮助抓取工具发现网页。在本教程中,我们介绍了抓取,因为我们提供了直接抓取的 URL。另一方面,网络爬虫是一种可以通过浏览给定网站自行查找产品 URL 的程序。
概括
在这篇内容广泛的介绍性文章中,我们概述了 PHP 中的基本 Web 抓取。我们很快介绍了 HTTP 协议和 HTML 树结构。此外,我们还了解了两个最流行的网络抓取库: Guzzle是一个现代的 http 客户端,DomCrawler允许我们在 XPATH 或 CSS 选择器中解析 HTML 文档中的数据。最后,我们用一些示例和https://www.producthunt.com/的小型产品数据抓取工具来总结所有内容。 这只是您网络抓取之旅的开始。我们还没有涵盖网络抓取中的很多挑战,例如访问阻止、代理、动态内容和许多扩展技术——还有很多东西需要学习,但这个介绍应该是一个很好的起点。