前言用 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 arraystring,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 , // 自动协商并解压 gzipCURLOPT_USERAGENT MyAppCrawler/1.0 (contact: opsexample.invalid),CURLOPT_CONNECTTIMEOUT 3,CURLOPT_TIMEOUT 15,CURLOPT_FOLLOWLOCATION false, // 需要跟随时要先校验目标地址防 SSRFCURLOPT_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 encodingUTF-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 APIDom\HTMLDocument等对真实页面的解析比老的DOMDocument::loadHTML()更符合浏览器行为。如果你的项目能升到 8.4可以优先用它低版本上仍然沿用上面这套经典做法。如果目标返回的是 XML 或 RSSsimplexml_load_string()会更顺手它把节点映射成对象属性和子节点用-和[属性名]访问代码比 DOM 短很多。但要注意它同样受编码声明影响并且遇到命名空间时要显式处理。三、解析阶段JSON 接口与正则的适用边界JSON 接口永远优于 HTML 解析。现代网站的前端数据几乎都走 JSON 接口用开发者工具看一眼网络面板就能找到。接 JSON 时用json_decode($json, true, 512, JSON_THROW_ON_ERROR)第二个参数true让它返回关联数组默认返回stdClass对象第四个参数让解析失败时抛异常而不是安静地返回null?php // 适用于 PHP 8.0try {$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 namedescription content这是一个测试页面;// 从固定形态的单值里取值可以接受if (preg_match(/meta\snamedescription\scontent([^]*)/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:host127.0.0.1;dbnamescrape;charsetutf8mb4, $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、状态码、错误信息就够了。常见坑点❌ 直接对抓回来的页面调loadHTML()中文全变乱码✅ 先确定编码并转成 UTF-8用loadHTML(?xml encodingUTF-8 . $html)让 libxml 知道编码。❌ 调了libxml_use_internal_errors(true)不还原✅ 它是全局状态用完必须libxml_use_internal_errors($prev)还原否则影响同一进程里的其他解析。❌ 用正则解析嵌套的 HTML 结构匹配两个 div 之间的内容✅ 用DOMDocument加DOMXPath正则只用于提取固定形态的单值。❌ XPath 写成/html/body/div[3]/table/tr[2]/td[1]这种绝对路径✅ 页面结构一变就全废用相对路径配合属性条件例如contains(class, ...)。❌json_decode()只用两个参数解析失败时安静地返回null✅ 加JSON_THROW_ON_ERROR并用try/catch捕获JsonException让错误显式暴露。❌ 抓取请求之间不留间隔多进程并发去刷同一个站点✅ 单目标并发保持 1请求之间留间隔并在 UA 里表明身份被封 IP 后整个数据源就断了。❌ 把抓来的内容直接拼进 SQL✅ 抓取内容是外部不可信输入必须用 PDO 预处理加参数绑定并设置ATTR_EMULATE_PREPARES false。❌ 抓取任务没有唯一约束重跑一次就多一批重复数据✅ 用 URL 哈希之类的业务唯一键配合ON DUPLICATE KEY UPDATE让重跑是幂等的。总结阶段关键做法常见错误请求设两个超时、自动解压、自报身份的 UA、请求间隔只设一个超时并发轰炸不留间隔编码优先响应头 charset其次 meta兜底做合法性校验直接按 UTF-8 解析 GBK 页面编码转换mb_convert_encoding(字符串, 目标编码, 源编码)目标/源写反静默得到乱码结构解析DOMDocumentDOMXPath用完还原 libxml 设置用正则解析嵌套 HTML数据接口优先找 JSON 接口json_decode加JSON_THROW_ON_ERROR假设返回结构一定完整单值提取正则只用于固定形态字符类用否定形式中文加/u用.*贪婪跨标签清洗空白归一化、类型转换、关键字段校验脏数据先存后清入库PDO 预处理、业务唯一键保证幂等拼接 SQL重跑产生重复数据把网页抓取做稳靠的不是更复杂的技巧而是三件最朴素的事请求发得有礼貌限速、标识、退避解析做得有冗余用 DOM 不用正则XPath 不要绝对路径数据洗得有纪律校验不过就丢入库用参数绑定。最后再强调一次边界能用官方接口就用官方接口能用有授权的数据源就用授权数据源robots.txt、服务条款、以及是否绕过了技术保护措施是判断一件事能不能做的标准技术上的可行性从来不是。