XML-RPC 响应 Gzip 压缩问题排查与解决

XML-RPC 响应 Gzip 压缩问题排查与解决

问题描述

在多站点 WordPress 发布工具中,部分站点(site2)无法正常连接,报错信息:

加载分类列表时出错: not well-formed (invalid token): line 1, column 0
连接测试失败: not well-formed (invalid token): line 1, column 0

但通过 get_raw_response() 获取的原始响应显示 XML 格式完全正确,能够正常解析。

问题排查过程

1. 初步怀疑:XML 声明前有多余字符

怀疑响应中在 XML 声明 <?xml 之前有不可见字符(如 BOM、空白字符等),导致 XML 解析器无法正确解析。

解决方案尝试 1:改进 XML 清理逻辑
– 使用正则表达式 r'<\?(?:xml|DOCTYPE)' 查找 XML 声明
– 支持同时查找 <?xml<!DOCTYPE 两种声明
– 如果找不到 XML 声明,尝试查找 <methodResponse> 标签

结果:未解决问题,调试显示没有清理任何字符。

2. 添加详细调试输出

为了更好地定位问题,添加了以下调试信息:
– 原始响应的十六进制表示
– 原始响应的 repr 表示
– 清理过程的详细日志
– 清理后内容的十六进制表示

运行结果

[DEBUG] 原始响应前200字节 (hex): 1f8b0800000000000003d59d...
[DEBUG] 原始响应前200字节 (repr): b"\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\x03\xd5\x9d...
[DEBUG] 响应清理: 1058 -> 1058 字符
[DEBUG] 清理后前100字符:՝[sFl␦M& a  @2΅/cISBwWv 7bKxIV秣sΞ2Ϛ...

3. 关键发现

从十六进制输出可以看到:
– 开头是 1f8b08 – 这是 gzip 压缩的魔数
1f8b – gzip 魔数标识
08 – deflate 压缩方法

结论:site2 的服务器返回的是 gzip 压缩的数据,但 xmlrpc.client 没有自动解压。而 site1 返回的是未压缩的数据,所以能正常工作。

根本原因

某些 WordPress 服务器(特别是启用了 gzip 压缩的服务器)会对 XML-RPC 响应进行 gzip 压缩以减少传输数据量。然而:

  1. Python xmlrpc.client 的限制:默认情况下,xmlrpc.client 的 Transport 类不会自动处理 gzip 压缩的响应
  2. Content-Encoding 头缺失:某些服务器可能在返回 gzip 数据时没有正确设置 Content-Encoding: gzip 响应头,导致客户端无法自动识别
  3. HTTP/HTTPS 协议差异:不同服务器在不同协议下的压缩行为可能不同

解决方案

在自定义的 Transport 类中添加 gzip 解压缩支持:

实现步骤

  1. 检测 gzip 压缩:检查响应数据的前两个字节是否为 1f 8b(gzip 魔数)
  2. 自动解压:如果检测到 gzip 数据,使用 Python 的 gzip.GzipFile 进行解压
  3. 错误处理:如果解压失败,继续使用原始数据,确保兼容性
  4. 调试输出:在调试模式下显示解压过程和结果

代码实现

import gzip
from io import BytesIO

class CustomTransport(xmlrpc.client.Transport):
    def __init__(self, use_datetime=False, debug=False):
        super().__init__(use_datetime=use_datetime)
        self.debug = debug

    def parse_response(self, response):
        # 读取响应内容
        data = response.read()
        if not data:
            return None

        # 检测并解压 gzip 数据
        # gzip 魔数: 1f 8b
        if len(data) >= 2 and data[0] == 0x1f and data[1] == 0x8b:
            if self.debug:
                print(f"[DEBUG] 检测到 gzip 压缩数据,正在解压...")
            try:
                gzip_data = BytesIO(data)
                with gzip.GzipFile(fileobj=gzip_data, mode='rb') as gz:
                    data = gz.read()
                if self.debug:
                    print(f"[DEBUG] gzip 解压成功: {len(gzip_data.getvalue())} -> {len(data)} 字节")
            except Exception as e:
                if self.debug:
                    print(f"[DEBUG] gzip 解压失败: {e}")
                # 如果解压失败,继续使用原始数据

        # 后续的 XML 清理和解析逻辑...
        # ...

调试输出示例

[DEBUG] 原始响应前200字节 (hex): 1f8b0800000000000003d59d...
[DEBUG] 检测到 gzip 压缩数据正在解压...
[DEBUG] gzip 解压成功: 1058 -> 152 字节
[DEBUG] 解压后前200字节 (hex): 3c3f786d6c2076657273696f6e3d22312e302220...
[DEBUG] 响应清理: 152 -> 152 字符
[DEBUG] 清理后前100字符: <?xml version="1.0" encoding="UTF-8"?>

技术细节

Gzip 魔数

Gzip 文件格式的魔数是固定的:
– 字节 0-1: 0x1f 0x8b – 标识这是 gzip 文件
– 字节 2: 压缩方法(通常为 0x08,表示 deflate)
– 字节 3-7: 额外标志、时间戳等

为什么需要手动解压

虽然 HTTP 客户端通常会自动处理 Content-Encoding: gzip,但在某些情况下:

  1. 服务器没有正确设置 Content-Encoding 响应头
  2. 使用了自定义的 Transport 类绕过了默认的 HTTP 处理
  3. HTTPS 连接中的压缩处理与 HTTP 有所不同

兼容性考虑

实现时考虑了以下兼容性:
– 只在检测到 gzip 魔数时才尝试解压
– 解压失败时降级使用原始数据
– 支持 HTTP 和 HTTPS 两种协议
– 不影响未压缩的正常响应

验证方法

使用 --debug 参数运行工具:

python md_to_wordpress.py --info --debug

查看调试输出中是否包含:
[DEBUG] 检测到 gzip 压缩数据,正在解压...
[DEBUG] gzip 解压成功: xxx -> xxx 字节
– 解压后的 XML 内容应该以 <?xml 开头

经验总结

  1. 十六进制调试很有用:当文本内容看起来正常但解析失败时,查看十六进制表示能快速发现问题
  2. 不要忽视压缩:现代 Web 应用普遍使用压缩,处理网络响应时要考虑到这一点
  3. 添加详细的调试输出:在排查问题时,详细的调试信息能大大提高效率
  4. 向后兼容:新功能应该在不影响现有功能的情况下实现,使用条件判断和错误处理确保兼容性

相关文件

  • md_to_wordpress.py – 主程序文件,包含 CustomTransport 和 SafeTransport 类
  • config.py – 多站点配置文件
  • config_example.py – 配置文件示例

参考资源