☰
PHP抓取及分析网页的方法详解
2026/10/9 23:56:00 网站建设 项目流程

前言


用 PHP 抓网页这件事本身并不难:发一个 HTTP 请求拿到 HTML,再从 HTML 里把需要的字段抠出来。难的是把这件事做对——难点几乎全在细节上:编码不对导致中文变乱码、XPath 写得过于绝对导致页面改版就失效、正则用错导致提取结果错一半、频率没控制导致被封 IP。


先把合规的底线说在前面,因为这是绕不过去的第一步:抓取别人的网站之前,要看该站的robots.txt有没有禁止你访问的路径,要看服务条款有没有禁止自动化采集,不要绕过任何技术保护措施(验证码、登录墙、频率限制)。同时,抓下来的内容如果要再次发布,会涉及著作权问题;如果内容里含个人信息,还要受《个人信息保护法》约束。本文讲的是技术方法,但方法的使用边界由这几条决定。


另外澄清一个常见的思路误区:能用接口就不要抓页面。如果目标站点的数据是通过 JSON 接口返回的,直接请求那个接口、解析 JSON,比解析 HTML 稳定得多——页面结构天天变,接口字段相对稳定,而且返回的数据本来就是结构化的。很多"抓取难"的问题,本质上是因为找错了入口。


本文分三步讲:怎么把页面抓回来、怎么把内容解析出来、怎么把数据洗干净落库。


一、抓取阶段:把请求发对


抓取只有一个核心动作:发 HTTP 请求。但这里有几个必须处理好的点。


第一是编码声明。老站点常见 GBK/GB2312,新站点是 UTF-8。拿到响应后不能直接按 UTF-8 解析,要先看响应头里的Content-Type,再从 HTML 的meta标签里读charset,两者都不明确时才用合法性校验兜底。


第二是限速与身份标识。请求之间留间隔,User-Agent里写清楚自己是谁。这不只是礼貌——被对方识别为恶意爬虫之后,你会失去整个数据源。


第三是超时与重试退避。建连超时和总超时都要设;失败要按指数退避重试,并设上限。


<?php // 适用于 PHP 8.0+,需启用 curl 与 mbstring 扩展

/**
* 抓一个页面,返回 UTF-8 编码的 HTML 字符串。
*
* @param array<string,string> $headers 额外请求头
*/
function fetchPage(string $url, array $headers = [], float $minInterval = 1.0): string
{
static $lastAt = 0.0; // 进程内限速用

$wait = $minInterval - (microtime(true) - $lastAt);
if ($wait > 0) {
usleep((int) ($wait * 1_000_000));
}
$lastAt = microtime(true);

$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => '', // 自动协商并解压 gzip
CURLOPT_USERAGENT => 'MyAppCrawler/1.0 (+contact: ops@example.invalid)',
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 15,
CURLOPT_FOLLOWLOCATION => false, // 需要跟随时要先校验目标地址,防 SSRF
CURLOPT_HTTPHEADER => $headers,
]);

$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$ctype = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
$err = curl_error($ch);
curl_close($ch);

if ($body === false) {
throw new RuntimeException('抓取失败:' . $err);
}
if ($code >= 400) {
throw new RuntimeException("目标返回 HTTP {$code}");
}

return toUtf8((string) $body, detectCharset($ctype, (string) $body));
}

/** 从响应头的 charset 或 meta 标签里取出编码名,取不到就返回 null */
function detectCharset(string $contentType, string $html): ?string
{
if (preg_match('/charset\s*=\s*"?([\w-]+)"?/i', $contentType, $m)) {
return $m[1];
}
if (preg_match('/<meta[^>]+charset\s*=\s*["\']?([\w-]+)/i', substr($html, 0, 2048), $m)) {
return $m[1];
}
return null;
}

/** 统一转成 UTF-8;无声明时先按 UTF-8 校验,不合法再按 GBK 处理 */
function toUtf8(string $bytes, ?string $charset): string
{
if ($charset !== null && !in_array(strtoupper($charset), ['UTF-8', 'UTF8'], true)) {
return mb_convert_encoding($bytes, 'UTF-8', $charset);
}
if (mb_check_encoding($bytes, 'UTF-8')) {
return $bytes;
}
return mb_convert_encoding($bytes, 'UTF-8', 'GBK');
}

mb_convert_encoding()的参数顺序是"字符串、目标编码、源编码",写反了不会报错,只会得到乱码,一定要看清。另外,不要把字符集探测做得太聪明:优先信任响应头和meta声明,两者矛盾时以响应头为准(HTTP 头优先级高于 HTML 内的声明),兜底才用校验。


二、解析阶段:DOM 与 XPath 是首选


把 HTML 解析成结构,首选DOMDocument加DOMXPath。它是基于 libxml 的真正的 HTML 解析器,能容忍不规范的标签闭合,也能处理嵌套结构,比正则可靠得多。


有一个编码细节必须先处理:DOMDocument::loadHTML()在遇到 UTF-8 内容时,如果没有任何编码声明,会按 Latin-1 解释,导致中文变成乱码。经典解法是在 HTML 前面拼一段 XML 声明来告知编码:


<?php // 适用于 PHP 8.0+

/**
* 用 XPath 从页面里抽取一个表格,返回键值对数组。
* 选择器要按目标页面的实际结构调整。
*/
function extractTable(string $html, string $tableXPath = '//table[1]'): array
{
$prev = libxml_use_internal_errors(true); // 屏蔽不规范的 HTML 警告

$doc = new DOMDocument();
// 拼上 XML 声明,让 libxml 明确知道这是 UTF-8
$doc->loadHTML('<?xml encoding="UTF-8">' . $html, LIBXML_NOWARNING | LIBXML_NOERROR);
$xpath = new DOMXPath($doc);

$result = [];
$tables = $xpath->query($tableXPath);
if ($tables === false || $tables->length === 0) {
libxml_clear_errors();
libxml_use_internal_errors($prev);
return $result;
}

foreach ($xpath->query('.//tr', $tables->item(0)) as $tr) {
$cells = $xpath->query('./td|./th', $tr);
if ($cells->length >= 2) {
$key = trim($cells->item(0)->textContent);
$val = trim($cells->item(1)->textContent);
if ($key !== '') {
$result[$key] = preg_replace('/\s+/u', ' ', $val) ?? $val;
}
}
}

libxml_clear_errors();
libxml_use_internal_errors($prev); // 一定要还原,否则影响后续解析

return $result;
}

几个值得记住的用法:DOMNodeList::item()返回DOMNode或null,嵌套取值前要确认上一层的length;取文字用->textContent属性,取属性值用->getAttribute('href');libxml_use_internal_errors()用完必须还原,因为它改的是全局状态,不还原会让同一个请求里后续的 XML 操作行为变化。


XPath 的选择器写法上有一条经验:尽量用相对路径和属性匹配,少用/html/body/div[3]/div[2]/table这种绝对路径。绝对路径对页面结构变化零容忍,对方在中间插一个div,你的抓取就全部失效。相对路径配合contains(@class, 'xxx')这类条件,容错性高得多。


顺便说一句,PHP 8.4 提供了基于 HTML5 规范的新 DOM API(Dom\HTMLDocument等),对真实页面的解析比老的DOMDocument::loadHTML()更符合浏览器行为。如果你的项目能升到 8.4,可以优先用它;低版本上仍然沿用上面这套经典做法。


如果目标返回的是 XML 或 RSS,simplexml_load_string()会更顺手:它把节点映射成对象,属性和子节点用->和['属性名']访问,代码比 DOM 短很多。但要注意它同样受编码声明影响,并且遇到命名空间时要显式处理。


三、解析阶段:JSON 接口与正则的适用边界


JSON 接口永远优于 HTML 解析。现代网站的前端数据几乎都走 JSON 接口,用开发者工具看一眼网络面板就能找到。接 JSON 时用json_decode($json, true, 512, JSON_THROW_ON_ERROR),第二个参数true让它返回关联数组(默认返回stdClass对象),第四个参数让解析失败时抛异常而不是安静地返回null:


<?php // 适用于 PHP 8.0+
try {
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new RuntimeException('接口返回的不是合法 JSON:' . $e->getMessage());
}

// 逐层取值前先确认存在,不要假设结构一定完整
$items = $data['data']['list'] ?? [];
foreach ($items as $item) {
printf("%s\t%s\n", $item['title'] ?? '', $item['url'] ?? '');
}

正则什么时候用?只在"从一整段文本里抠出某个固定形态的值"时用,比如从页面里取meta描述、取一段 JavaScript 变量里的 JSON 字符串、取某个特定格式的编号。它不适合解析嵌套的 HTML 结构——因为 HTML 不是正则语言,没法用正则表达"配对的标签"。一旦你用正则去匹配"某个 div 之间的内容",就已经埋下了下次改版就崩的隐患。


<?php // 适用于 PHP 8.0+
$html = '<meta name="description" content="这是一个测试页面">';

// 从固定形态的单值里取值:可以接受
if (preg_match('/<meta\s+name="description"\s+content="([^"]*)"/i', $html, $m)) {
echo '页面描述:', html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'), PHP_EOL;
}

写正则时有三个坑要避开:[^"]*这类否定字符类比.*安全得多(后者会贪婪地跨过多个标签);正则里的中文要用/u修饰符,否则按字节匹配会出问题;抓到的文本往往还带着 HTML 实体,要用html_entity_decode()转回来,并且记住第二个参数要显式传,默认值不处理单引号。


四、数据清洗与落库


抓下来、解析出来的数据通常还很脏:有多余空白、有全角半角混用、有重复记录、有缺失字段。落库前必须清洗和校验。


清洗的重点是空白归一化和字段校验。preg_replace('/\s+/u', ' ', $text)把连续空白压成一个空格;trim()去掉首尾;数字字段要转成真正的数字类型,不要原样存字符串。校验的重点是"关键字段非空"和"格式合法",不通过的一律丢弃并记日志,不要"先存下来再说"——脏数据一旦落库,清理成本远高于当时丢弃。


入库必须用参数化查询。抓来的内容属于完全不可信的外部输入,拼接 SQL 会直接导致注入漏洞。用 PDO 预处理加参数绑定:


<?php // 适用于 PHP 8.0+,需启用 pdo_mysql 扩展
$pdo = new PDO('mysql:host=127.0.0.1;dbname=scrape;charset=utf8mb4', $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false, // 用真正的预处理,让类型也正确
]);

$sql = 'INSERT INTO pages (url_hash, url, title, fetched_at)
VALUES (:url_hash, :url, :title, :fetched_at)
ON DUPLICATE KEY UPDATE title = VALUES(title), fetched_at = VALUES(fetched_at)';

$stmt = $pdo->prepare($sql);

// 用 URL 的哈希做唯一键,实现幂等:同一个页面重复抓取不会产生重复行
$stmt->execute([
':url_hash' => hash('sha256', $url),
':url' => $url,
':title' => $title,
':fetched_at' => date('Y-m-d H:i:s'),
]);

这里用 URL 的哈希做唯一键,是为了实现幂等——抓取任务经常要重跑,没有唯一约束的话每次重跑都会产生一批重复数据,后续统计全是错的。另外PDO::ATTR_EMULATE_PREPARES => false让 PDO 使用数据库端真正的预处理,绑定参数的类型会被正确传递,也能避免某些边缘的注入手法。


出错要记日志,但日志里不要落完整的 HTML(体积大且可能含个人信息),记 URL、状态码、错误信息就够了。


常见坑点



  1. ❌ 直接对抓回来的页面调loadHTML(),中文全变乱码


✅ 先确定编码并转成 UTF-8;用loadHTML('<?xml encoding="UTF-8">' . $html)让 libxml 知道编码。



  1. ❌ 调了libxml_use_internal_errors(true)不还原


✅ 它是全局状态,用完必须libxml_use_internal_errors($prev)还原,否则影响同一进程里的其他解析。



  1. ❌ 用正则解析嵌套的 HTML 结构,匹配"两个 div 之间的内容"


✅ 用DOMDocument加DOMXPath;正则只用于提取固定形态的单值。



  1. ❌ XPath 写成/html/body/div[3]/table/tr[2]/td[1]这种绝对路径


✅ 页面结构一变就全废;用相对路径配合属性条件,例如contains(@class, ...)。



  1. ❌json_decode()只用两个参数,解析失败时安静地返回null


✅ 加JSON_THROW_ON_ERROR并用try/catch捕获JsonException,让错误显式暴露。



  1. ❌ 抓取请求之间不留间隔,多进程并发去刷同一个站点


✅ 单目标并发保持 1,请求之间留间隔,并在 UA 里表明身份;被封 IP 后整个数据源就断了。



  1. ❌ 把抓来的内容直接拼进 SQL


✅ 抓取内容是外部不可信输入,必须用 PDO 预处理加参数绑定,并设置ATTR_EMULATE_PREPARES => false。



  1. ❌ 抓取任务没有唯一约束,重跑一次就多一批重复数据


✅ 用 URL 哈希之类的业务唯一键配合ON DUPLICATE KEY UPDATE,让重跑是幂等的。


总结




阶段关键做法常见错误



请求设两个超时、自动解压、自报身份的 UA、请求间隔只设一个超时;并发轰炸;不留间隔

编码优先响应头 charset,其次 meta,兜底做合法性校验直接按 UTF-8 解析 GBK 页面

编码转换mb_convert_encoding(字符串, 目标编码, 源编码)目标/源写反,静默得到乱码

结构解析DOMDocument+DOMXPath,用完还原 libxml 设置用正则解析嵌套 HTML

数据接口优先找 JSON 接口,json_decode加JSON_THROW_ON_ERROR假设返回结构一定完整

单值提取正则只用于固定形态,字符类用否定形式,中文加/u用.*贪婪跨标签

清洗空白归一化、类型转换、关键字段校验脏数据先存后清

入库PDO 预处理、业务唯一键保证幂等拼接 SQL;重跑产生重复数据



把网页抓取做稳,靠的不是更复杂的技巧,而是三件最朴素的事:请求发得有礼貌(限速、标识、退避),解析做得有冗余(用 DOM 不用正则,XPath 不要绝对路径),数据洗得有纪律(校验不过就丢,入库用参数绑定)。最后再强调一次边界:能用官方接口就用官方接口,能用有授权的数据源就用授权数据源;robots.txt、服务条款、以及"是否绕过了技术保护措施",是判断一件事能不能做的标准,技术上的可行性从来不是。




需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询