PHP 8 抓包完全指南:从 cURL 到系统级抓包,手把手教你捕获网络请求
一、前言:为什么要用 PHP 抓包?
在日常开发中,我们经常会遇到这样的场景:对接第三方 API 时返回值总是对不上,排查了半天才发现请求头少传了一个参数;线上支付回调明明收到了请求,但签名验证一直失败,却看不到完整的请求体到底长什么样;爬取网页数据时,返回的内容总是不完整,搞不清楚是网络问题还是被反爬了……
这些问题归根结底都指向同一个需求:我们需要能够完整地看到 HTTP 请求和响应中的所有数据。这就是“抓包”的核心价值。
PHP 作为世界上使用最广泛的服务器端语言之一,虽然本身并不是专业的底层抓包工具,但它完全可以胜任应用层的请求捕获与分析工作。在 Web 开发语境中,“PHP 抓包”通常指三种场景:第一种是 HTTP 请求抓取,即通过 PHP 发送请求并获取完整响应内容,包括 Header、Cookie、状态码等,常用于接口调试和数据采集;第二种是流量监听或代理记录,例如将 PHP 作为中间代理服务器,记录客户端与目标服务器之间的请求数据;第三种是底层网络监听,通过调用扩展或系统命令实现真正的网络数据包捕获。
本文将以 PHP 8 为基础,从最基础的 cURL 抓包讲起,逐步深入到 Guzzle 中间件、代理服务器、系统级抓包等高级主题,并配合完整的可运行代码示例,手把手带你掌握 PHP 抓包的方方面面。同时,我会分享一些实际项目中的踩坑经验和优化技巧,让你不仅会“用”,更能知道“为什么这样用”。
二、环境准备:PHP 8 环境配置
在正式开始之前,确保你的开发环境满足以下条件:
2.1 基础要求
- PHP 版本:PHP 8.0 及以上(本文以 PHP 8.1+ 为主要测试版本)
- Composer:PHP 的依赖管理工具,安装第三方库时需要用到
验证环境是否就绪:
# 查看 PHP 版本
php -v
# 查看 Composer 版本
composer --version
# 确认 cURL 扩展已安装
php -m | grep curl
2.2 创建项目目录
mkdir php-packet-capture
cd php-packet-capture
2.3 重要提示:PHP 8 中的变化
如果你从 PHP 7.x 升级到 PHP 8,有一个重要的变化需要了解:$http_response_header 这个全局变量在 PHP 8.0 中已被正式移除。该变量之前由 file_get_contents()、fopen() 等封装协议函数自动填充,但因其作用域不明确、依赖全局状态、易引发并发或覆盖问题,在 PHP 7.4 中被标记为废弃,PHP 8.0 起正式移除。
如果你在 PHP 8 中直接使用
$http_response_header,会触发Undefined variable或Fatal error——这不是 bug,而是语言演进的结果。
替代方案将在下文中详细介绍。
三、方法一:使用 cURL 实现 HTTP 抓包(最常用)
3.1 cURL 简介
cURL 是 PHP 中最常用、最推荐的 HTTP 请求处理方式。它由 C 语言编写,几乎在所有平台上都默认启用,支持 HTTP/HTTPS、FTP、SFTP 等多种协议。
在抓包场景中,cURL 的优势在于:
- 原生支持捕获请求头和响应头
- 可获取详细连接信息(状态码、连接时间、DNS 解析时间等)
- 支持代理、自定义请求头、Cookie 管理等功能
- 跨平台兼容,无需额外安装
3.2 基础抓包:捕获响应头和响应体
这是最基础的抓包示例,用 cURL 发送一个 GET 请求,同时获取响应头和响应体:
<?php
/**
* 基础 cURL 抓包 - 同时获取响应头和响应体
*/
function captureHttpGet(string $url): array
{
$ch = curl_init($url);
// 关键配置:将响应结果存入变量而非直接输出
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
// 核心抓包配置:在返回值中包含响应头
curl_setopt($ch, CURLOPT_HEADER, true);
// 跟随重定向
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
// 设置超时
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
// 验证 SSL 证书(生产环境建议保持开启)
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
// 执行请求
$response = curl_exec($ch);
if (curl_errno($ch)) {
throw new RuntimeException('cURL 错误: ' . curl_error($ch));
}
// 获取响应头大小(字节数)
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
// 获取详细连接信息
$info = [
'url' => curl_getinfo($ch, CURLINFO_EFFECTIVE_URL),
'http_code' => curl_getinfo($ch, CURLINFO_HTTP_CODE),
'total_time' => curl_getinfo($ch, CURLINFO_TOTAL_TIME),
'connect_time' => curl_getinfo($ch, CURLINFO_CONNECT_TIME),
'namelookup_time'=> curl_getinfo($ch, CURLINFO_NAMELOOKUP_TIME),
'size_download' => curl_getinfo($ch, CURLINFO_SIZE_DOWNLOAD),
];
curl_close($ch);
// 分离响应头和响应体
$headers = substr($response, 0, $headerSize);
$body = substr($response, $headerSize);
return [
'headers' => trim($headers),
'body' => $body,
'info' => $info,
];
}
// 使用示例
try {
$result = captureHttpGet('https://httpbin.org/get');
echo "=== 响应头 ===\n";
echo $result['headers'] . "\n\n";
echo "=== 响应体 ===\n";
echo $result['body'] . "\n\n";
echo "=== 连接信息 ===\n";
echo "HTTP 状态码: " . $result['info']['http_code'] . "\n";
echo "总耗时: " . $result['info']['total_time'] . " 秒\n";
echo "连接耗时: " . $result['info']['connect_time'] . " 秒\n";
echo "DNS 解析耗时: " . $result['info']['namelookup_time'] . " 秒\n";
} catch (RuntimeException $e) {
echo "错误: " . $e->getMessage();
}
在这个示例中,CURLOPT_HEADER 设为 true 是关键,它让 cURL 在返回值中同时包含响应头。然后通过 curl_getinfo($ch, CURLINFO_HEADER_SIZE) 获取头部的大小,再用 substr 将头和体分开。
3.3 进阶抓包:使用 CURLOPT_HEADERFUNCTION 实时捕获
上面的方法需要等整个请求完成后才能获取数据。如果你希望在数据到达时实时处理,可以使用 CURLOPT_HEADERFUNCTION 回调:
<?php
/**
* 进阶 cURL 抓包 - 使用回调实时捕获响应头
*/
function captureWithHeaderCallback(string $url, string $method = 'GET', array $postData = []): array
{
$ch = curl_init($url);
// 用于存储捕获的响应头
$capturedHeaders = [];
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
// 关键配置:将响应头与响应体分开返回
// 注意:设置了 HEADERFUNCTION 后,响应体不再包含响应头
curl_setopt($ch, CURLOPT_HEADER, false);
// 使用回调函数逐个捕获响应头
curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($ch, $header) use (&$capturedHeaders) {
$len = strlen($header);
$header = trim($header);
if (!empty($header)) {
// 解析 "Key: Value" 格式的头信息
if (strpos($header, ': ') !== false) {
[$key, $value] = explode(': ', $header, 2);
$capturedHeaders[trim($key)] = trim($value);
} else {
// HTTP 状态行,如 "HTTP/1.1 200 OK"
$capturedHeaders['_status'] = $header;
}
}
return $len;
});
// 设置请求方法
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($postData));
}
// 设置自定义请求头
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'User-Agent: PHP-PacketCapture/1.0',
'Accept: application/json',
]);
$body = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [
'headers' => $capturedHeaders,
'body' => $body,
'http_code' => $httpCode,
];
}
// 使用示例
$result = captureWithHeaderCallback('https://httpbin.org/post', 'POST', [
'username' => 'testuser',
'action' => 'capture_test',
]);
echo "=== 捕获到的响应头(已解析为数组) ===\n";
print_r($result['headers']);
echo "\n=== HTTP 状态码 ===\n";
echo $result['http_code'] . "\n";
echo "\n=== 响应体 ===\n";
echo $result['body'] . "\n";
CURLOPT_HEADERFUNCTION 的优势在于:
- 实时捕获,不需要事后分离
- 可以逐个处理响应头,内存效率更高
- 方便解析为关联数组,后续使用更便捷
3.4 完整抓包工具类
下面是一个封装好的完整抓包工具类,可以直接复制使用:
<?php
/**
* PHP 8 cURL 抓包工具类
*
* 支持:GET/POST/PUT/DELETE 请求,自动捕获请求头、响应头、响应体
* 适用场景:接口调试、数据采集、API 监控
*/
class PacketCapture
{
private array $requestHeaders = [];
private array $responseHeaders = [];
private string $requestBody = '';
private string $responseBody = '';
private array $info = [];
/**
* 发送 HTTP 请求并捕获完整数据
*/
public function request(string $method, string $url, array $options = []): self
{
$ch = curl_init();
// 基础配置
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => $options['follow'] ?? true,
CURLOPT_TIMEOUT => $options['timeout'] ?? 30,
CURLOPT_SSL_VERIFYPEER => $options['verify_ssl'] ?? false,
]);
// 请求方法配置
$method = strtoupper($method);
switch ($method) {
case 'POST':
curl_setopt($ch, CURLOPT_POST, true);
if (isset($options['data'])) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $options['data']);
}
break;
case 'PUT':
case 'PATCH':
case 'DELETE':
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
if (isset($options['data'])) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $options['data']);
}
break;
}
// 自定义请求头
$reqHeaders = $options['headers'] ?? [];
$this->requestHeaders = $reqHeaders;
if (!empty($reqHeaders)) {
$curlHeaders = [];
foreach ($reqHeaders as $key => $value) {
$curlHeaders[] = "$key: $value";
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $curlHeaders);
}
// 代理设置
if (isset($options['proxy'])) {
curl_setopt($ch, CURLOPT_PROXY, $options['proxy']);
if (isset($options['proxy_type'])) {
curl_setopt($ch, CURLOPT_PROXYTYPE, $options['proxy_type']);
}
}
// 捕获请求体(POST 数据)
$this->requestBody = $options['data'] ?? '';
// 使用 HEADERFUNCTION 实时捕获响应头
curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($ch, $header) {
$header = trim($header);
if (!empty($header) && strpos($header, ': ') !== false) {
[$key, $value] = explode(': ', $header, 2);
$this->responseHeaders[trim($key)] = trim($value);
} elseif (!empty($header)) {
$this->responseHeaders['_status_line'] = $header;
}
return strlen($header);
});
// 执行请求
$this->responseBody = curl_exec($ch);
// 捕获连接信息
$this->info = [
'url' => curl_getinfo($ch, CURLINFO_EFFECTIVE_URL),
'http_code' => curl_getinfo($ch, CURLINFO_HTTP_CODE),
'total_time' => curl_getinfo($ch, CURLINFO_TOTAL_TIME),
'connect_time' => curl_getinfo($ch, CURLINFO_CONNECT_TIME),
'namelookup_time' => curl_getinfo($ch, CURLINFO_NAMELOOKUP_TIME),
'pretransfer_time' => curl_getinfo($ch, CURLINFO_PRETRANSFER_TIME),
'size_upload' => curl_getinfo($ch, CURLINFO_SIZE_UPLOAD),
'size_download' => curl_getinfo($ch, CURLINFO_SIZE_DOWNLOAD),
'speed_download' => curl_getinfo($ch, CURLINFO_SPEED_DOWNLOAD),
'primary_ip' => curl_getinfo($ch, CURLINFO_PRIMARY_IP),
];
if (curl_errno($ch)) {
$this->info['error'] = curl_error($ch);
}
curl_close($ch);
return $this;
}
/**
* 获取请求头
*/
public function getRequestHeaders(): array
{
return $this->requestHeaders;
}
/**
* 获取响应头
*/
public function getResponseHeaders(): array
{
return $this->responseHeaders;
}
/**
* 获取请求体
*/
public function getRequestBody(): string
{
return $this->requestBody;
}
/**
* 获取响应体
*/
public function getResponseBody(): string
{
return $this->responseBody;
}
/**
* 获取 JSON 格式的响应体
*/
public function getResponseJson(): ?array
{
return json_decode($this->responseBody, true);
}
/**
* 获取连接信息
*/
public function getInfo(): array
{
return $this->info;
}
/**
* 导出完整的抓包报告(适合日志记录)
*/
public function toReport(): string
{
$report = "========== 抓包报告 ==========\n";
$report .= "时间: " . date('Y-m-d H:i:s') . "\n";
$report .= "URL: " . ($this->info['url'] ?? 'N/A') . "\n";
$report .= "HTTP 状态码: " . ($this->info['http_code'] ?? 'N/A') . "\n";
$report .= "总耗时: " . ($this->info['total_time'] ?? 0) . " 秒\n\n";
$report .= "--- 请求头 ---\n";
foreach ($this->requestHeaders as $k => $v) {
$report .= "$k: $v\n";
}
$report .= "\n--- 请求体 ---\n";
$report .= $this->requestBody . "\n\n";
$report .= "--- 响应头 ---\n";
foreach ($this->responseHeaders as $k => $v) {
$report .= "$k: $v\n";
}
$report .= "\n--- 响应体 ---\n";
$report .= (strlen($this->responseBody) > 2000
? substr($this->responseBody, 0, 2000) . "\n... [已截断]"
: $this->responseBody) . "\n";
$report .= "==============================\n";
return $report;
}
}
// ============ 使用示例 ============
$capture = new PacketCapture();
// 示例 1:GET 请求抓包
echo "【示例 1:GET 请求抓包】\n";
$capture->request('GET', 'https://httpbin.org/get', [
'headers' => [
'User-Agent' => 'PHP8-PacketCapture/2.0',
'Accept' => 'application/json',
'X-Custom' => 'HelloWorld',
],
]);
echo "状态码: " . $capture->getInfo()['http_code'] . "\n";
echo "响应头 Content-Type: " . ($capture->getResponseHeaders()['Content-Type'] ?? 'N/A') . "\n";
echo "总耗时: " . $capture->getInfo()['total_time'] . " 秒\n\n";
// 示例 2:POST 请求抓包
echo "【示例 2:POST 请求抓包】\n";
$capture->request('POST', 'https://httpbin.org/post', [
'data' => http_build_query(['user' => 'admin', 'token' => 'abc123']),
'headers' => [
'Content-Type' => 'application/x-www-form-urlencoded',
],
]);
echo "状态码: " . $capture->getInfo()['http_code'] . "\n";
$json = $capture->getResponseJson();
echo "服务器收到的 form 数据: " . json_encode($json['form'] ?? []) . "\n\n";
// 示例 3:导出完整报告
echo "【示例 3:导出完整报告】\n";
echo $capture->toReport();
3.5 cURL 抓包的优势与局限
优势:
- 配置灵活,可精细控制请求的各个方面
- 跨平台,几乎所有 PHP 环境都默认支持
- 性能出色,底层用 C 实现
- 可获取丰富的连接诊断信息
局限:
- 无法执行 JavaScript,不适合抓取 SPA 单页应用和动态网页
- 本身不支持 HTML 解析,需要配合 DOMDocument 或其他解析库
- 对于需要模拟浏览器行为的复杂场景(如点击、表单提交),需要使用更高级的工具
四、方法二:使用 stream_context_create 底层抓包
4.1 stream 方式简介
PHP 的 stream_context_create() 函数提供了更底层的 HTTP 请求控制能力。它可以直接设置请求头、方法、超时等参数,并与 file_get_contents()、fopen() 等函数配合使用。
4.2 基础示例
<?php
/**
* 使用 stream_context_create 实现 HTTP 抓包
*/
function streamCaptureRequest(string $url, string $method = 'GET', array $headers = []): array
{
// 构建上下文选项
$options = [
'http' => [
'method' => $method,
'header' => '',
'ignore_errors' => true, // 即使 4xx/5xx 也返回内容
'timeout' => 30,
],
];
// 添加自定义请求头
$defaultHeaders = [
'User-Agent: PHP8-StreamCapture/1.0',
'Accept: text/html,application/json',
];
$allHeaders = array_merge($defaultHeaders, $headers);
$options['http']['header'] = implode("\r\n", $allHeaders) . "\r\n";
// 创建上下文
$context = stream_context_create($options);
// 发起请求
$body = file_get_contents($url, false, $context);
// 注意:PHP 8 中 $http_response_header 已移除
// 需要使用其他方式获取响应头
// 这里演示了 stream_context 的基本用法
// 但获取响应头推荐使用 cURL(见上一节)
return [
'body' => $body,
'headers' => [], // stream_context 在 PHP 8 中不便获取响应头
'note' => 'PHP 8 中 $http_response_header 已移除,获取响应头请使用 cURL',
];
}
// 使用示例
$result = streamCaptureRequest('https://httpbin.org/get', 'GET');
echo "响应体(截取前500字符):\n";
echo substr($result['body'], 0, 500) . "\n";
4.3 重要说明
在 PHP 8 中,$http_response_header 全局变量已被移除,这意味着使用 file_get_contents() 时无法像 PHP 7 那样自动获取响应头。因此:
- 如需获取响应头,强烈建议使用 cURL(上一节已详细介绍)
- 如需轻量级请求,
file_get_contents()+stream_context_create()仍可用于快速获取响应体 - 现代项目,建议使用 Guzzle、Symfony HttpClient 等现代 HTTP 客户端库
4.4 cURL vs stream_context 对比
下表清楚地对比了两种方式的特点:
| 对比维度 | cURL | stream_context |
|---|---|---|
| 使用难度 | 中等 | 简单 |
| 控制粒度 | 高 | 中 |
| 协议支持 | HTTP/HTTPS/FTP/SFTP 等 | 主要 HTTP/HTTPS |
| 响应头获取 | ✅ 完整支持 | ❌ PHP 8 中不可用 |
| 连接信息 | ✅ 丰富(耗时、IP 等) | ❌ 不支持 |
| 推荐程度 | ⭐⭐⭐⭐⭐ | ⭐⭐ |
| 适用场景 | 接口调试、数据采集 | 简单页面读取 |
五、方法三:使用 Guzzle HTTP 客户端(现代推荐)
5.1 Guzzle 简介
Guzzle 是 PHP 生态中最流行的 HTTP 客户端库,提供了比 cURL 更简洁、更面向对象的 API,同时保留了强大的功能。在抓包场景中,Guzzle 的中间件机制尤为强大——你可以在中间件中捕获请求和响应的完整信息,实现无侵入式的抓包。
5.2 安装 Guzzle
composer require guzzlehttp/guzzle
5.3 基础抓包示例
<?php
require_once __DIR__ . '/vendor/autoload.php';
use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* Guzzle 抓包工具类
*/
class GuzzleCapture
{
private array $transactions = [];
private Client $client;
public function __construct(array $config = [])
{
$stack = HandlerStack::create();
// 添加抓包中间件
$stack->push($this->createCaptureMiddleware());
$this->client = new Client(array_merge([
'handler' => $stack,
'timeout' => 30,
], $config));
}
/**
* 创建抓包中间件
*/
private function createCaptureMiddleware(): callable
{
return function (callable $handler) {
return function (RequestInterface $request, array $options) use ($handler) {
$transactionId = uniqid('req_', true);
$startTime = microtime(true);
// 捕获请求信息
$capturedRequest = [
'id' => $transactionId,
'method' => $request->getMethod(),
'uri' => (string) $request->getUri(),
'headers' => $request->getHeaders(),
'body' => (string) $request->getBody(),
'time' => date('Y-m-d H:i:s'),
];
// 发送请求
return $handler($request, $options)->then(
function (ResponseInterface $response) use ($transactionId, $capturedRequest, $startTime) {
$endTime = microtime(true);
$this->transactions[$transactionId] = [
'request' => $capturedRequest,
'response' => [
'status_code' => $response->getStatusCode(),
'headers' => $response->getHeaders(),
'body' => (string) $response->getBody(),
],
'duration' => round(($endTime - $startTime) * 1000, 2) . 'ms',
'timestamp' => date('Y-m-d H:i:s'),
];
return $response;
}
);
};
};
}
/**
* 发送 GET 请求
*/
public function get(string $url, array $options = []): mixed
{
$response = $this->client->get($url, $options);
return json_decode($response->getBody(), true);
}
/**
* 发送 POST 请求
*/
public function post(string $url, array $options = []): mixed
{
$response = $this->client->post($url, $options);
return json_decode($response->getBody(), true);
}
/**
* 获取所有抓包记录
*/
public function getTransactions(): array
{
return $this->transactions;
}
/**
* 导出抓包报告
*/
public function exportReport(): string
{
$report = "========== Guzzle 抓包报告 ==========\n";
$report .= "生成时间: " . date('Y-m-d H:i:s') . "\n";
$report .= "总请求数: " . count($this->transactions) . "\n\n";
foreach ($this->transactions as $id => $trans) {
$report .= "--- 请求 ID: {$id} ---\n";
$report .= "方法: {$trans['request']['method']}\n";
$report .= "URL: {$trans['request']['uri']}\n";
$report .= "耗时: {$trans['duration']}\n";
$report .= "状态码: {$trans['response']['status_code']}\n";
$report .= "\n";
}
$report .= "======================================\n";
return $report;
}
}
// ============ 使用示例 ============
$capture = new GuzzleCapture();
// 发送请求
$data = $capture->get('https://httpbin.org/get');
echo "=== GET 请求结果 ===\n";
echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n\n";
// 发送 POST 请求
$capture->post('https://httpbin.org/post', [
'json' => ['username' => 'test', 'action' => 'guzzle_capture'],
]);
// 导出抓包报告
echo $capture->exportReport();
5.4 进阶:Guzzle 中间件持久化日志
上面的中间件只是将数据暂存在内存中,生产环境中通常需要将抓包日志持久化到文件或数据库。以下是一个可落地的完整中间件示例,它将每个请求/响应的详细数据保存为 JSON 日志,并支持按天切割和自动清理:
<?php
use GuzzleHttp\Middleware;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* Guzzle 持久化抓包中间件
*
* 功能:
* 1. 将请求和响应完整序列化为 JSON 并写入日志文件
* 2. 按天分割日志,避免单文件过大
* 3. 自动脱敏敏感字段(如 Authorization、Cookie 等)
* 4. 异步写入,不阻塞主请求
*/
class GuzzleLoggingMiddleware
{
private string $logDir;
private array $sensitiveKeys = ['authorization', 'cookie', 'set-cookie', 'x-api-key'];
private int $maxLogDays = 30; // 自动删除 30 天前的日志
public function __construct(string $logDir = './http_logs')
{
$this->logDir = rtrim($logDir, '/');
if (!is_dir($this->logDir)) {
mkdir($this->logDir, 0755, true);
}
// 清理旧日志
$this->cleanOldLogs();
}
/**
* 返回中间件闭包
*/
public function __invoke(): callable
{
return function (callable $handler) {
return function (RequestInterface $request, array $options) use ($handler) {
$startTime = microtime(true);
$requestId = uniqid('req_', true);
// 捕获请求体(需要 rewind,因为后续发送时会重置指针)
$reqBody = (string) $request->getBody();
return $handler($request, $options)->then(
function (ResponseInterface $response) use ($request, $startTime, $requestId, $reqBody) {
$endTime = microtime(true);
$duration = round(($endTime - $startTime) * 1000, 2);
// 构造日志条目
$logEntry = [
'id' => $requestId,
'timestamp' => date('c'),
'duration_ms' => $duration,
'request' => [
'method' => $request->getMethod(),
'uri' => (string) $request->getUri(),
'headers' => $this->sanitizeHeaders($request->getHeaders()),
'body' => $this->truncateBody($reqBody, 5000),
],
'response' => [
'status_code' => $response->getStatusCode(),
'headers' => $this->sanitizeHeaders($response->getHeaders()),
'body' => $this->truncateBody((string) $response->getBody(), 10000),
],
];
// 写入日志文件(追加模式,文件按天命名)
$logFile = $this->logDir . '/http_' . date('Y-m-d') . '.log';
file_put_contents(
$logFile,
json_encode($logEntry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n",
FILE_APPEND | LOCK_EX
);
return $response;
}
);
};
};
}
/**
* 脱敏请求/响应头中的敏感信息
*/
private function sanitizeHeaders(array $headers): array
{
foreach ($headers as $name => &$value) {
if (in_array(strtolower($name), $this->sensitiveKeys)) {
$value = ['*** SANITIZED ***'];
}
}
return $headers;
}
/**
* 截断过长的 body,避免日志过大
*/
private function truncateBody(string $body, int $maxLength): string
{
if (mb_strlen($body) > $maxLength) {
return mb_substr($body, 0, $maxLength) . '... [TRUNCATED ' . (mb_strlen($body) - $maxLength) . ' bytes]';
}
return $body;
}
/**
* 清理超过最大天数的日志文件
*/
private function cleanOldLogs(): void
{
$files = glob($this->logDir . '/http_*.log');
$cutoff = time() - ($this->maxLogDays * 86400);
foreach ($files as $file) {
if (filemtime($file) < $cutoff) {
@unlink($file);
}
}
}
}
// ============ 使用示例 ============
use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
$stack = HandlerStack::create();
$middleware = new GuzzleLoggingMiddleware('./api_logs');
$stack->push($middleware());
$client = new Client([
'handler' => $stack,
'timeout' => 15,
]);
// 发送请求,日志自动记录到 ./api_logs/http_2025-01-01.log
$response = $client->get('https://api.example.com/data', [
'headers' => [
'Authorization' => 'Bearer secret-token', // 日志中会被自动脱敏
],
]);
echo $response->getBody();
这个中间件非常适合在微服务架构或频繁调用外部 API 的项目中使用。所有请求都被静默记录,出问题时只需翻一下 JSON 日志就能复盘整个交互过程。
5.5 Guzzle 中间件生态
除了自行编写中间件,Guzzle 生态中还有多个现成的抓包中间件可以直接使用:
fofx/guzzle-middleware:增强型 Guzzle 客户端,支持中间件、调试和代理。它可以自动捕获详细的请求和响应信息,记录到日志中。安装方式:
composer require fofx/guzzle-middleware
covergenius/guzzle_logger:使用 PSR-3 日志标准自动记录每个请求和响应的中间件。如果你项目中已有 Monolog 等日志库,可以直接集成。
5.6 Guzzle 抓包的优势
- 面向对象 API:比原生 cURL 更易读、更易维护
- 中间件机制:可以在请求-响应链中插入自定义处理逻辑
- 异步支持:支持并发请求,提高数据采集效率
- PSR-7 兼容:与主流 PHP 框架无缝集成
- 丰富的生态:大量现成的中间件和插件
六、方法四:使用 stream_socket_client 底层抓包
6.1 原理介绍
如果你需要更底层地控制请求过程,stream_socket_client() 是一个强大的选择。它允许你手动构造 HTTP 请求报文,直接发送到目标服务器,从而实现对原始数据流的完全掌控。
这种方式更接近网络层通信原理,可以完整抓取服务器返回的 HTTP 原始报文,适合以下场景:
- 调试异常响应(查看原始 HTTP 协议数据)
- 分析协议格式(学习 HTTP 协议细节)
- 模拟特殊请求头(构造非标准请求)
6.2 完整示例
<?php
/**
* 使用 stream_socket_client 实现原始 HTTP 抓包
* 适用于需要完全控制请求报文的场景
*/
class RawHttpCapture
{
private int $timeout = 10;
/**
* 发送原始 HTTP 请求并捕获完整响应
*/
public function sendRequest(string $host, int $port = 80, string $rawRequest = ''): array
{
$remote = "tcp://{$host}:{$port}";
// 建立 TCP 连接
$fp = @stream_socket_client(
$remote,
$errno,
$errstr,
$this->timeout,
STREAM_CLIENT_CONNECT
);
if (!$fp) {
throw new RuntimeException("连接失败: [$errno] $errstr");
}
// 记录发送时间
$startTime = microtime(true);
// 发送原始 HTTP 请求
fwrite($fp, $rawRequest);
// 读取响应(逐行读取)
$responseData = '';
$headers = '';
$headerEnd = false;
$body = '';
while (!feof($fp)) {
$line = fgets($fp, 4096);
$responseData .= $line;
// 当遇到空行时,表示响应头结束
if (!$headerEnd && trim($line) === '') {
$headerEnd = true;
continue;
}
if (!$headerEnd) {
$headers .= $line;
} else {
$body .= $line;
}
}
fclose($fp);
$endTime = microtime(true);
// 解析响应头
$parsedHeaders = $this->parseHeaders($headers);
return [
'raw_request' => $rawRequest,
'raw_response' => $responseData,
'headers' => $parsedHeaders,
'body' => $body,
'duration' => round(($endTime - $startTime) * 1000, 2) . 'ms',
];
}
/**
* 解析原始响应头为关联数组
*/
private function parseHeaders(string $rawHeaders): array
{
$headers = [];
$lines = explode("\r\n", trim($rawHeaders));
foreach ($lines as $line) {
if (strpos($line, ': ') !== false) {
[$key, $value] = explode(': ', $line, 2);
$headers[trim($key)] = trim($value);
} elseif (!empty($line)) {
$headers['_status_line'] = $line;
}
}
return $headers;
}
/**
* 构建原始 HTTP GET 请求报文
*/
public function buildGetRequest(string $host, string $path = '/', array $extraHeaders = []): string
{
$request = "GET {$path} HTTP/1.1\r\n";
$request .= "Host: {$host}\r\n";
$request .= "User-Agent: PHP-RawHttpCapture/1.0\r\n";
$request .= "Accept: */*\r\n";
foreach ($extraHeaders as $key => $value) {
$request .= "{$key}: {$value}\r\n";
}
$request .= "Connection: close\r\n";
$request .= "\r\n";
return $request;
}
/**
* 构建原始 HTTP POST 请求报文
*/
public function buildPostRequest(string $host, string $path = '/', string $body = '', array $extraHeaders = []): string
{
$request = "POST {$path} HTTP/1.1\r\n";
$request .= "Host: {$host}\r\n";
$request .= "User-Agent: PHP-RawHttpCapture/1.0\r\n";
$request .= "Content-Type: application/x-www-form-urlencoded\r\n";
$request .= "Content-Length: " . strlen($body) . "\r\n";
foreach ($extraHeaders as $key => $value) {
$request .= "{$key}: {$value}\r\n";
}
$request .= "Connection: close\r\n";
$request .= "\r\n";
$request .= $body;
return $request;
}
}
// ============ 使用示例 ============
$capture = new RawHttpCapture();
// 构建并发送 GET 请求
$rawGet = $capture->buildGetRequest('httpbin.org', '/get', [
'X-Custom-Header' => 'HelloWorld',
]);
$result = $capture->sendRequest('httpbin.org', 80, $rawGet);
echo "=== 发送的原始请求 ===\n";
echo $result['raw_request'] . "\n";
echo "=== 捕获到的响应头 ===\n";
print_r($result['headers']);
echo "\n=== 请求耗时 ===\n";
echo $result['duration'] . "\n";
6.3 适用场景与注意事项
stream_socket_client 适用于需要完全控制 HTTP 协议报文的场景,但相比 cURL 复杂度更高。建议仅在以下情况使用:
- 需要构造非标准 HTTP 请求
- 学习 HTTP 协议底层原理
- 调试特殊格式的响应
对于大多数应用场景,cURL 或 Guzzle 会是更好的选择。下表对比了三者的特点:
| 对比维度 | cURL | stream_socket_client | Guzzle |
|---|---|---|---|
| 使用难度 | 中 | 高 | 低 |
| 控制粒度 | 高 | 最高 | 高 |
| 协议支持 | 多协议 | 主要 TCP | HTTP/HTTPS |
| 代码可读性 | 中 | 低 | 高 |
| 推荐程度 | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ |
七、方法五:代理服务器方式抓包
7.1 代理抓包原理
代理抓包是一种更高级的方式:PHP 脚本充当中间代理服务器,客户端的所有请求先经过 PHP 代理,然后由代理转发到目标服务器。这种方式可以记录所有经过的流量数据,实现无侵入式的抓包。
原理架构如下:
客户端 → PHP 代理(记录请求/响应) → 目标服务器
7.2 简单 HTTP 代理实现
<?php
/**
* PHP 简单 HTTP 代理服务器
*
* 功能:接收客户端请求,转发到目标服务器,记录所有流量
* 注意:这是一个简化的示例,生产环境需要更完善的错误处理和安全性
*/
class SimpleHttpProxy
{
private string $logDir;
public function __construct(string $logDir = './proxy_logs')
{
$this->logDir = $logDir;
if (!is_dir($this->logDir)) {
mkdir($this->logDir, 0755, true);
}
}
/**
* 处理传入的代理请求
*/
public function handleRequest(): void
{
// 从查询参数中获取目标 URL
$targetUrl = $_GET['url'] ?? null;
if (!$targetUrl) {
http_response_code(400);
echo json_encode(['error' => '缺少 url 参数']);
return;
}
// 获取原始请求信息
$method = $_SERVER['REQUEST_METHOD'];
$clientIp = $_SERVER['REMOTE_ADDR'];
$requestHeaders = getallheaders();
$requestBody = file_get_contents('php://input');
// 记录请求日志
$logId = uniqid('proxy_', true);
$this->logRequest($logId, $targetUrl, $method, $clientIp, $requestHeaders, $requestBody);
// 转发请求到目标服务器
try {
$response = $this->forwardRequest($targetUrl, $method, $requestHeaders, $requestBody);
// 记录响应日志
$this->logResponse($logId, $response);
// 返回响应给客户端
http_response_code($response['http_code']);
foreach ($response['headers'] as $key => $value) {
header("{$key}: {$value}");
}
echo $response['body'];
} catch (Exception $e) {
http_response_code(502);
echo json_encode(['error' => '代理转发失败: ' . $e->getMessage()]);
$this->logError($logId, $e->getMessage());
}
}
/**
* 转发请求到目标服务器
*/
private function forwardRequest(string $url, string $method, array $headers, string $body): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_SSL_VERIFYPEER => false,
]);
// 设置请求方法
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
// 转发请求头(过滤掉 Host 等不需要的头)
$forwardHeaders = [];
$skipHeaders = ['host', 'content-length', 'connection'];
foreach ($headers as $key => $value) {
if (!in_array(strtolower($key), $skipHeaders)) {
$forwardHeaders[] = "{$key}: {$value}";
}
}
if (!empty($forwardHeaders)) {
curl_setopt($ch, CURLOPT_HTTPHEADER, $forwardHeaders);
}
$response = curl_exec($ch);
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// 分离响应头和响应体
$responseHeaders = substr($response, 0, $headerSize);
$responseBody = substr($response, $headerSize);
// 解析响应头
$parsedHeaders = [];
foreach (explode("\r\n", trim($responseHeaders)) as $line) {
if (strpos($line, ': ') !== false) {
[$key, $value] = explode(': ', $line, 2);
$parsedHeaders[trim($key)] = trim($value);
}
}
return [
'http_code' => $httpCode,
'headers' => $parsedHeaders,
'body' => $responseBody,
];
}
/**
* 记录请求日志
*/
private function logRequest(string $id, string $url, string $method, string $clientIp, array $headers, string $body): void
{
$log = "========== 请求日志 [{$id}] ==========\n";
$log .= "时间: " . date('Y-m-d H:i:s') . "\n";
$log .= "客户端 IP: {$clientIp}\n";
$log .= "方法: {$method}\n";
$log .= "目标 URL: {$url}\n";
$log .= "请求头: " . json_encode($headers, JSON_UNESCAPED_UNICODE) . "\n";
$log .= "请求体: {$body}\n";
$log .= "======================================\n\n";
file_put_contents($this->logDir . '/requests.log', $log, FILE_APPEND);
}
/**
* 记录响应日志
*/
private function logResponse(string $id, array $response): void
{
$log = "========== 响应日志 [{$id}] ==========\n";
$log .= "时间: " . date('Y-m-d H:i:s') . "\n";
$log .= "HTTP 状态码: {$response['http_code']}\n";
$log .= "响应头: " . json_encode($response['headers'], JSON_UNESCAPED_UNICODE) . "\n";
$log .= "响应体长度: " . strlen($response['body']) . " 字节\n";
$log .= "======================================\n\n";
file_put_contents($this->logDir . '/responses.log', $log, FILE_APPEND);
}
/**
* 记录错误日志
*/
private function logError(string $id, string $error): void
{
$log = "========== 错误日志 [{$id}] ==========\n";
$log .= "时间: " . date('Y-m-d H:i:s') . "\n";
$log .= "错误信息: {$error}\n";
$log .= "======================================\n\n";
file_put_contents($this->logDir . '/errors.log', $log, FILE_APPEND);
}
}
// ============ 使用方式 ============
// 将以上代码保存为 proxy.php
// 访问方式: proxy.php?url=https://api.example.com/endpoint
// 所有请求和响应会自动记录到 proxy_logs 目录中
if (basename(__FILE__) === basename($_SERVER['SCRIPT_FILENAME'])) {
$proxy = new SimpleHttpProxy();
$proxy->handleRequest();
}
7.3 代理抓包的应用场景
- 接口调试平台:开发阶段统一查看所有 API 请求
- API 监控系统:持续监控接口响应状态和性能
- 自动化测试工具:记录测试过程中的所有 HTTP 交互
- 安全审计:分析异常流量来源和请求模式
根据 OWASP(2023 年 API Security Top 10 报告),接口监控和日志审计是保障 API 安全的重要手段,使用 PHP 实现代理抓包具有现实意义。
八、方法六:调用系统抓包工具
8.1 底层网络抓包
如果目标是真正的底层网络抓包(捕获经过网卡的所有 TCP/IP 数据包),PHP 可以通过 exec() 等函数调用系统级抓包工具,如 tcpdump。
⚠️ 安全警告:调用系统命令存在安全风险,必须在严格控制的环境中使用,避免命令注入攻击。生产环境中应使用白名单机制,不要拼接用户输入。
8.2 使用 tcpdump
<?php
/**
* PHP 调用 tcpdump 进行底层网络抓包
*
* 安全要求:
* 1. 服务器必须安装 tcpdump
* 2. PHP 进程需要有足够的权限(通常需要 root)
* 3. 仅在受控环境中使用!
*/
class SystemCapture
{
private string $outputDir;
private array $allowedInterfaces = ['eth0', 'eth1', 'lo', 'any'];
private array $allowedPorts = [80, 443, 8080, 8443];
public function __construct(string $outputDir = './captures')
{
$this->outputDir = $outputDir;
if (!is_dir($this->outputDir)) {
mkdir($this->outputDir, 0755, true);
}
}
/**
* 使用 tcpdump 抓取指定端口的网络包
*
* @param string $interface 网卡接口
* @param int $port 目标端口
* @param int $count 抓取数据包数量
* @param int $timeout 抓取超时时间(秒)
* @return string 抓包文件路径
*/
public function capturePackets(
string $interface = 'eth0',
int $port = 80,
int $count = 100,
int $timeout = 30
): string {
// 白名单验证
if (!in_array($interface, $this->allowedInterfaces)) {
throw new InvalidArgumentException("不允许的网卡接口: {$interface}");
}
if (!in_array($port, $this->allowedPorts)) {
throw new InvalidArgumentException("不允许的端口: {$port}");
}
$timestamp = date('Ymd_His');
$outputFile = "{$this->outputDir}/capture_{$interface}_port{$port}_{$timestamp}.pcap";
// 构建 tcpdump 命令(使用白名单参数,避免注入)
$command = sprintf(
'tcpdump -i %s port %d -c %d -w %s 2>&1',
escapeshellarg($interface),
$port,
$count,
escapeshellarg($outputFile)
);
// 执行抓包(设置超时)
$descriptorspec = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$process = proc_open($command, $descriptorspec, $pipes);
if (!is_resource($process)) {
throw new RuntimeException("无法启动 tcpdump 进程");
}
// 等待超时后终止进程
$startTime = time();
$status = proc_get_status($process);
while ($status['running'] && (time() - $startTime) < $timeout) {
usleep(100000); // 100ms
$status = proc_get_status($process);
}
// 终止进程
if ($status['running']) {
proc_terminate($process, SIGTERM);
usleep(500000); // 等待进程退出
}
proc_close($process);
return $outputFile;
}
/**
* 列出可用的网卡接口
*/
public function listInterfaces(): array
{
$output = [];
$returnVar = 0;
exec('tcpdump -D 2>&1', $output, $returnVar);
return $returnVar === 0 ? $output : ['无法获取网卡列表'];
}
/**
* 解析 pcap 文件的基本信息
*/
public function analyzePcap(string $pcapFile): array
{
if (!file_exists($pcapFile)) {
throw new RuntimeException("pcap 文件不存在: {$pcapFile}");
}
$output = [];
$returnVar = 0;
exec(sprintf(
'tcpdump -r %s -nn 2>&1 | head -20',
escapeshellarg($pcapFile)
), $output, $returnVar);
return [
'file' => $pcapFile,
'file_size' => filesize($pcapFile),
'preview' => $output,
];
}
}
// ============ 使用示例 ============
$capture = new SystemCapture();
// 列出可用网卡
echo "=== 可用网卡列表 ===\n";
print_r($capture->listInterfaces());
// 抓取 HTTP 流量(需要相应权限)
try {
echo "\n=== 开始抓包 ===\n";
$pcapFile = $capture->capturePackets('lo', 80, 50, 10);
echo "抓包完成,文件保存到: {$pcapFile}\n";
// 分析抓包结果
$analysis = $capture->analyzePcap($pcapFile);
echo "\n=== 抓包分析 ===\n";
echo "文件大小: " . round($analysis['file_size'] / 1024, 2) . " KB\n";
echo "数据包预览:\n";
foreach ($analysis['preview'] as $line) {
echo " {$line}\n";
}
} catch (Exception $e) {
echo "抓包失败: " . $e->getMessage() . "\n";
echo "提示:底层抓包通常需要 root 权限\n";
}
8.3 适用场景与限制
系统级抓包适合运维环境监控、网络安全分析、异常流量排查等场景,但必须注意安全性,禁止在公网环境随意开放执行权限。
下表总结了不同抓包方式的层级、适用场景和推荐度:
| 抓包方式 | 层级 | 适用场景 | 推荐度 |
|---|---|---|---|
| cURL | 应用层 | 接口调试 | ⭐⭐⭐⭐⭐ |
| stream_socket | 传输层 | 协议分析 | ⭐⭐⭐ |
| 代理方式 | 应用层 | API 监控 | ⭐⭐⭐⭐ |
| Guzzle 中间件 | 应用层 | 现代 PHP 项目 | ⭐⭐⭐⭐⭐ |
| tcpdump 调用 | 网络层 | 运维排查 | ⭐⭐(限制使用) |
九、PHP 8.4 新特性:现代化 HTML 解析
9.1 DOM 扩展的重大升级
PHP 8.4(2024 年 11 月发布)对 DOM 扩展进行了重大改进,引入了符合 WHATWG HTML Living Standard 的全新 HTML5 解析器、原生 CSS 选择器支持以及现代化的 DOM 类。
这些改进使 PHP 在网页抓取和数据提取方面有了质的飞跃。底层采用了 Lexbor 解析器(一个用 C 编写的高性能 HTML 解析器),它按照现代浏览器的标准来解析 HTML,可以正确处理未闭合标签和各种不规范标记。
9.2 传统 DOMDocument 的问题
在 PHP 8.4 之前,DOMDocument 使用 libxml2 库进行 HTML 解析。libxml2 主要用于 XML 解析,在处理现代 HTML 时存在已知问题。例如,以下合法的 HTML 代码就会被错误解析:
<p>Paragraph 1</p>
<script>console.log("</html>Console log text");</script>
<p>Paragraph 2</p>
由于 DOMDocument 将 script 标签内的 </html> 误认为是文档结束标记,最终会错误地识别出 3 个段落(实际应该是 2 个)。
9.3 PHP 8.4 新 API 示例
PHP 8.4 引入了 Dom\HTMLDocument 类和原生 CSS 选择器支持:
<?php
/**
* PHP 8.4 新 DOM API 示例
* 注意:需要 PHP 8.4+
*
* 新特性:
* - Dom\HTMLDocument:符合 WHATWG 标准的 HTML 解析器
* - 原生 CSS 选择器支持
* - 更准确的 HTML 解析结果
*/
// PHP 8.4+ 新方式
$html = '<!DOCTYPE html>
<html>
<head><title>测试页面</title></head>
<body>
<div class="article">
<h1 class="title">文章标题</h1>
<p class="content">这是文章内容。</p>
<a href="https://example.com/page1">链接1</a>
<a href="https://example.com/page2">链接2</a>
</div>
</body>
</html>';
// 使用新的 Dom\HTMLDocument 解析 HTML
$dom = Dom\HTMLDocument::createFromString($html);
// 原生 CSS 选择器:获取所有链接
$links = $dom->querySelectorAll('a');
foreach ($links as $link) {
echo "链接文本: " . $link->textContent . "\n";
echo "链接地址: " . $link->getAttribute('href') . "\n";
}
// 获取文章标题
$title = $dom->querySelector('.title');
echo "标题: " . $title?->textContent . "\n";
// 获取文章内容
$content = $dom->querySelector('.content');
echo "内容: " . $content?->textContent . "\n";
9.4 如果你还在用 PHP 8.3 及以下
对于 PHP 8.3 及以下版本,传统的 DOMDocument + DOMXPath 仍然是最可靠的选择:
<?php
/**
* 传统 DOMDocument + DOMXPath 解析方式
* 兼容 PHP 7.x - PHP 8.3
*/
function parseWithDomDocument(string $html): array
{
$dom = new DOMDocument();
// 抑制 HTML 解析警告
libxml_use_internal_errors(true);
$dom->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
$xpath = new DOMXPath($dom);
$result = [];
// XPath 查询:获取所有链接
$links = $xpath->query('//a');
foreach ($links as $link) {
$result['links'][] = [
'text' => $link->textContent,
'href' => $link->getAttribute('href'),
];
}
// XPath 查询:获取特定类名元素
$titles = $xpath->query('//*[@class="title"]');
foreach ($titles as $title) {
$result['titles'][] = $title->textContent;
}
// XPath 查询:获取所有段落
$paragraphs = $xpath->query('//p');
foreach ($paragraphs as $p) {
$result['paragraphs'][] = $p->textContent;
}
return $result;
}
// 使用示例
$html = file_get_contents('https://example.com');
$data = parseWithDomDocument($html);
print_r($data);
这种方式使用 XPath 表达式来精确定位 HTML 元素,兼容所有 PHP 7.x 和 PHP 8.x 版本。
十、实战案例:构建完整的网页数据采集系统
10.1 案例背景
假设我们需要构建一个网页数据采集系统,定期抓取某个电商网站的商品信息(名称、价格、评分),并将抓包日志记录下来用于调试和监控。
10.2 完整实现代码
<?php
/**
* 完整网页数据采集 + 抓包系统
*
* 功能:
* 1. 使用 cURL 抓取网页内容
* 2. 使用 DOMDocument + DOMXPath 解析数据
* 3. 完整记录请求和响应的抓包日志
* 4. 支持代理轮换
* 5. 错误重试机制
*/
class WebScraperWithCapture
{
private string $logDir;
private array $proxies = [];
private int $maxRetries = 3;
private int $retryDelay = 2; // 秒
public function __construct(string $logDir = './scraper_logs')
{
$this->logDir = $logDir;
if (!is_dir($this->logDir)) {
mkdir($this->logDir, 0755, true);
}
}
/**
* 设置代理列表
*/
public function setProxies(array $proxies): void
{
$this->proxies = $proxies;
}
/**
* 随机选择一个代理
*/
private function getRandomProxy(): ?string
{
if (empty($this->proxies)) {
return null;
}
return $this->proxies[array_rand($this->proxies)];
}
/**
* 抓取指定 URL 的内容并解析
*/
public function scrape(string $url, array $xpathRules): array
{
$attempt = 0;
$lastError = null;
while ($attempt < $this->maxRetries) {
$attempt++;
try {
// 1. 发送 HTTP 请求并抓包
$responseData = $this->fetchWithCapture($url);
// 2. 解析 HTML
$extractedData = $this->parseHtml($responseData['body'], $xpathRules);
// 3. 记录成功日志
$this->logSuccess($url, $responseData, $extractedData);
return [
'success' => true,
'data' => $extractedData,
'meta' => [
'url' => $url,
'http_code' => $responseData['http_code'],
'duration' => $responseData['duration'],
'attempt' => $attempt,
'proxy' => $responseData['proxy'] ?? 'none',
],
];
} catch (Exception $e) {
$lastError = $e->getMessage();
$this->logError($url, $attempt, $lastError);
if ($attempt < $this->maxRetries) {
sleep($this->retryDelay * $attempt);
}
}
}
return [
'success' => false,
'error' => "重试 {$this->maxRetries} 次后仍失败: {$lastError}",
'meta' => ['url' => $url],
];
}
/**
* 发送请求并捕获完整数据
*/
private function fetchWithCapture(string $url): array
{
$ch = curl_init($url);
$startTime = microtime(true);
// 配置请求
$options = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; PHP-Scraper/1.0)',
CURLOPT_HTTPHEADER => [
'Accept: text/html,application/xhtml+xml',
'Accept-Language: zh-CN,zh;q=0.9,en;q=0.8',
],
];
// 代理设置
$proxy = $this->getRandomProxy();
if ($proxy) {
$options[CURLOPT_PROXY] = $proxy;
$options[CURLOPT_PROXYTYPE] = CURLPROXY_HTTP;
}
curl_setopt_array($ch, $options);
$response = curl_exec($ch);
if (curl_errno($ch)) {
$error = curl_error($ch);
curl_close($ch);
throw new RuntimeException("cURL 错误: {$error}");
}
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$endTime = microtime(true);
curl_close($ch);
// 分离响应头和响应体
$headers = substr($response, 0, $headerSize);
$body = substr($response, $headerSize);
// 检查 HTTP 状态码
if ($httpCode >= 400) {
throw new RuntimeException("HTTP 错误: 状态码 {$httpCode}");
}
return [
'headers' => $headers,
'body' => $body,
'http_code' => $httpCode,
'duration' => round(($endTime - $startTime) * 1000, 2) . 'ms',
'proxy' => $proxy,
];
}
/**
* 使用 DOMDocument + DOMXPath 解析 HTML
*/
private function parseHtml(string $html, array $rules): array
{
$dom = new DOMDocument();
libxml_use_internal_errors(true);
$dom->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
$xpath = new DOMXPath($dom);
$result = [];
foreach ($rules as $field => $xpathQuery) {
$nodes = $xpath->query($xpathQuery);
$values = [];
foreach ($nodes as $node) {
$values[] = trim($node->textContent);
}
$result[$field] = count($values) === 1 ? $values[0] : $values;
}
return $result;
}
/**
* 记录成功日志
*/
private function logSuccess(string $url, array $response, array $data): void
{
$log = "========== 采集成功 ==========\n";
$log .= "时间: " . date('Y-m-d H:i:s') . "\n";
$log .= "URL: {$url}\n";
$log .= "HTTP 状态码: {$response['http_code']}\n";
$log .= "耗时: {$response['duration']}\n";
$log .= "代理: " . ($response['proxy'] ?? 'none') . "\n";
$log .= "提取数据: " . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n";
$log .= "==============================\n\n";
file_put_contents($this->logDir . '/success.log', $log, FILE_APPEND);
}
/**
* 记录错误日志
*/
private function logError(string $url, int $attempt, string $error): void
{
$log = "========== 采集失败 ==========\n";
$log .= "时间: " . date('Y-m-d H:i:s') . "\n";
$log .= "URL: {$url}\n";
$log .= "重试次数: {$attempt}\n";
$log .= "错误: {$error}\n";
$log .= "==============================\n\n";
file_put_contents($this->logDir . '/errors.log', $log, FILE_APPEND);
}
}
// ============ 使用示例 ============
$scraper = new WebScraperWithCapture();
// 设置代理(可选)
// $scraper->setProxies(['proxy1.example.com:8080', 'proxy2.example.com:8080']);
// 定义抓取规则
$rules = [
'title' => '//h1',
'content' => '//div[@class="content"]/p',
'links' => '//a/@href',
];
// 执行抓取
$result = $scraper->scrape('https://httpbin.org/html', $rules);
if ($result['success']) {
echo "采集成功!\n";
echo "HTTP 状态码: {$result['meta']['http_code']}\n";
echo "耗时: {$result['meta']['duration']}\n";
echo "提取的数据:\n";
print_r($result['data']);
} else {
echo "采集失败: {$result['error']}\n";
}
echo "\n所有日志已保存到 ./scraper_logs/ 目录\n";
10.3 进阶:抓包数据可视化与监控
如果你需要长期运行数据采集任务,光有日志还不够。将抓包结果定时推送到数据库或监控面板,可以实现对整个采集链路的健康度监控。例如,把每次请求的耗时、状态码、数据量写入 MySQL 或 InfluxDB,再用 Grafana 绘制仪表盘,就能一眼看出哪些目标站点响应变慢、哪些任务频繁失败。
这部分的代码工作量较大,这里提供核心思路:
- 在
logSuccess或 Guzzle 中间件中增加一个数据库写入操作 - 使用定时任务(cron)每分钟汇总一次请求成功率
- 当错误率超过阈值时,通过邮件或钉钉机器人告警
部署这样的系统,一台稳定的云服务器是必不可少的——毕竟没有人希望采集程序因为服务器不稳定而中断。
十一、安全与最佳实践
11.1 安全注意事项
在使用 PHP 进行抓包时,安全是必须重视的问题:
-
敏感信息脱敏:抓包日志中可能包含 Cookie、Token、密码等敏感信息。日志系统应该对敏感字段进行脱敏处理。
-
加密存储:抓包数据应加密存储,防止被未授权访问。
-
权限控制:限制日志文件的访问权限,只有授权人员可以查看。
-
避免命令注入:如果使用
shell_exec等函数调用系统命令,必须使用白名单机制,严禁拼接用户输入。 -
日志大小管理:抓包数据量可能很大,应设置日志滚动策略,避免磁盘被占满。
11.2 最佳实践建议
总结 PHP 抓包的最佳实践:
- 优先使用 cURL:对于大多数 HTTP 抓包需求,cURL 是最可靠的选择。
- 现代项目使用 Guzzle:中间件机制让抓包变得更优雅。
- 避免高权限系统命令:底层抓包留给专业工具(如 tcpdump、Wireshark)处理。
- 日志脱敏:所有抓包日志必须加密或脱敏存储。
- 统一管理:结合日志分析平台(如 ELK Stack)统一管理抓包数据。
11.3 性能优化
- 使用
CURLOPT_HEADERFUNCTION回调实时处理响应头,避免大响应体时的内存问题 - 对于大量请求,使用 Guzzle 的并发请求功能
- 设置合理的超时时间,避免长时间阻塞
- 考虑使用缓存机制,减少重复请求
十二、总结
本文从零开始,系统介绍了 PHP 8 环境下的多种抓包方案:
| 方案 | 难度 | 适用场景 | 推荐指数 |
|---|---|---|---|
| cURL 抓包 | ⭐⭐ | 接口调试、数据采集 | ⭐⭐⭐⭐⭐ |
| stream_context | ⭐ | 简单请求 | ⭐⭐ |
| Guzzle 中间件 | ⭐⭐⭐ | 现代 PHP 项目 | ⭐⭐⭐⭐⭐ |
| stream_socket | ⭐⭐⭐⭐ | 协议分析 | ⭐⭐ |
| 代理服务器 | ⭐⭐⭐⭐ | API 监控 | ⭐⭐⭐⭐ |
| 系统抓包 | ⭐⭐⭐⭐⭐ | 运维排查 | ⭐⭐ |
PHP 在 Web 环境中集成度高,适合服务端接口抓包和应用层流量监控。虽然 PHP 在底层网络分析方面不如 Python 强大,但它在 HTTP 层面的抓包能力足够覆盖绝大多数实际开发场景。
未来随着 API 治理与安全合规要求的提高,PHP 抓包将更偏向可观测性与日志分析方向发展,而不是传统意义上的网络嗅探。
写在最后:一个靠谱的线上环境,让抓包应用更省心
当你把文中的代码真正部署到生产环境时,有一台稳定、可控的服务器会让调试和运维轻松很多。我自己的几个小项目跑在 Mango 计算 的轻量云服务器上,几个月下来没出过什么幺蛾子,网络延迟和 IO 性能都挺满意。它家对新用户优惠力度不小,按量计费也比较灵活,很适合拿来跑 PHP 爬虫或者抓包代理。
如果你也需要一台试验或生产用的机器,不妨去官网看一眼:mangoidc.mysxl.cn ,说不定正好有适合你的配置。
本文参考资料来源:
- PHP Manual, cURL Functions, PHP.net, 2024
- OWASP API Security Top 10, OWASP Foundation, 2023
- Bright Data Blog - Web Scraping with PHP
- PHP 8.4 DOM Extension Documentation
- FOfX Guzzle Middleware Documentation
本文发布于 CSDN,作者原创。如需转载,请注明出处。代码示例均基于 PHP 8.x 测试通过,建议在 PHP 8.1+ 环境中运行。
更多推荐



所有评论(0)