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 压缩以减少传输数据量。然而:
- Python xmlrpc.client 的限制:默认情况下,xmlrpc.client 的 Transport 类不会自动处理 gzip 压缩的响应
- Content-Encoding 头缺失:某些服务器可能在返回 gzip 数据时没有正确设置
Content-Encoding: gzip响应头,导致客户端无法自动识别 - HTTP/HTTPS 协议差异:不同服务器在不同协议下的压缩行为可能不同
解决方案
在自定义的 Transport 类中添加 gzip 解压缩支持:
实现步骤
- 检测 gzip 压缩:检查响应数据的前两个字节是否为
1f 8b(gzip 魔数) - 自动解压:如果检测到 gzip 数据,使用 Python 的
gzip.GzipFile进行解压 - 错误处理:如果解压失败,继续使用原始数据,确保兼容性
- 调试输出:在调试模式下显示解压过程和结果
代码实现
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,但在某些情况下:
- 服务器没有正确设置
Content-Encoding响应头 - 使用了自定义的 Transport 类绕过了默认的 HTTP 处理
- HTTPS 连接中的压缩处理与 HTTP 有所不同
兼容性考虑
实现时考虑了以下兼容性:
– 只在检测到 gzip 魔数时才尝试解压
– 解压失败时降级使用原始数据
– 支持 HTTP 和 HTTPS 两种协议
– 不影响未压缩的正常响应
验证方法
使用 --debug 参数运行工具:
python md_to_wordpress.py --info --debug
查看调试输出中是否包含:
– [DEBUG] 检测到 gzip 压缩数据,正在解压...
– [DEBUG] gzip 解压成功: xxx -> xxx 字节
– 解压后的 XML 内容应该以 <?xml 开头
经验总结
- 十六进制调试很有用:当文本内容看起来正常但解析失败时,查看十六进制表示能快速发现问题
- 不要忽视压缩:现代 Web 应用普遍使用压缩,处理网络响应时要考虑到这一点
- 添加详细的调试输出:在排查问题时,详细的调试信息能大大提高效率
- 向后兼容:新功能应该在不影响现有功能的情况下实现,使用条件判断和错误处理确保兼容性
相关文件
md_to_wordpress.py– 主程序文件,包含 CustomTransport 和 SafeTransport 类config.py– 多站点配置文件config_example.py– 配置文件示例