一、前言:为什么要用 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 variableFatal 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 进行抓包时,安全是必须重视的问题:

  1. 敏感信息脱敏:抓包日志中可能包含 Cookie、Token、密码等敏感信息。日志系统应该对敏感字段进行脱敏处理。

  2. 加密存储:抓包数据应加密存储,防止被未授权访问。

  3. 权限控制:限制日志文件的访问权限,只有授权人员可以查看。

  4. 避免命令注入:如果使用 shell_exec 等函数调用系统命令,必须使用白名单机制,严禁拼接用户输入。

  5. 日志大小管理:抓包数据量可能很大,应设置日志滚动策略,避免磁盘被占满。

11.2 最佳实践建议

总结 PHP 抓包的最佳实践:

  1. 优先使用 cURL:对于大多数 HTTP 抓包需求,cURL 是最可靠的选择。
  2. 现代项目使用 Guzzle:中间件机制让抓包变得更优雅。
  3. 避免高权限系统命令:底层抓包留给专业工具(如 tcpdump、Wireshark)处理。
  4. 日志脱敏:所有抓包日志必须加密或脱敏存储。
  5. 统一管理:结合日志分析平台(如 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+ 环境中运行。

更多推荐