bigywater.tech
爬虫工程 · 62

原始响应归档:自己写 WARC 记录

爬虫存下的“原始响应”如果只留解析后的字段,规则改了就没法重新解析。把原始响应按 WARC 格式存起来,可以随时重放。这篇自己手写 WARC 记录,用 warcio 读回核对,逐项测写法上的坑。

8640 对 16434
gzip 响应在记录里存的正文字节(原样) 与 requests 的 .content 解压后的字节;后者接在原样的头(Content-Length=8640)后面就对不上了
0.044 对 153 毫秒
有偏移索引的随机读取 与 没有索引顺序扫描到第 3000 条(5000 条的文件)
43.4 对 30.9 MiB
30% 的重复正文,完整写入 与 写成 revisit 记录 的文件大小

原样写一条

实测 · 本机执行输出
  plain    套接字上收到 16577 字节(头 93、正文 16484);写成的记录 16951 字节;warcio 读回:类型 response、URI 正确 True、状态 200、content_stream 给出的正文(按头自动去 chunked/gzip)长度 16484,与原 HTML(长度 16484)相同:True;warcio 摘要核对:True
  gzip     套接字上收到 8787 字节(头 116、正文 8671);写成的记录 9159 字节;warcio 读回:类型 response、URI 正确 True、状态 200、content_stream 给出的正文(按头自动去 chunked/gzip)长度 16484,与原 HTML(长度 16484)相同:True;warcio 摘要核对:True
  chunked  套接字上收到 16625 字节(头 98、正文 16527);写成的记录 17001 字节;warcio 读回:类型 response、URI 正确 True、状态 200、content_stream 给出的正文(按头自动去 chunked/gzip)长度 16484,与原 HTML(长度 16484)相同:True;warcio 摘要核对:True
  chunked 记录里的 WARC-Payload-Digest 如果改算"去掉分块之后的正文"的摘要:warcio 摘要核对 False,问题 ['payload digest failed sha1:ZV2UN6O4KWHSEB2Y75F4DUVALLZLLHS4'](它是对头后面的字节原样算的,即分块格式还在的那些字节);我没有去核对规范里对这一点怎么规定)

WARC 记录由一组记录头(类型、记录 ID、时间、URL、摘要、Content-Length)加一段内容块组成;response 记录的内容块就是 HTTP 响应的完整字节(状态行、头、正文)。我从套接字读到响应字节后原样放入,用 warcio 1.8.1 读回:类型、URI、状态码、正文都对,摘要核对通过,plain、gzip、chunked 三种传输方式都一样。chunked 的响应,把 WARC-Payload-Digest 算成“去掉分块格式之后的正文”,warcio 的摘要核对会失败;它是对头后面存下来的字节原样算的。规范对此怎么规定我没有核对,这里只说 warcio 1.8.1 的行为:摘要要算在你实际存下的字节上。

别存解码之后的字节

实测 · 本机执行输出
  (a) 套接字上的字节原样:warcio 读回 16434 字节,与原 HTML 相同:True;记录里存的正文 8640 字节,头里 Content-Length=8640,Content-Encoding=gzip;存的正文与服务器发出的字节相同:True
  (b) requests 的 .content(是解压后的 16434 字节)接在原样的头后面:warcio 读回 16434 字节,与原 HTML 相同:True;记录里存的正文 16434 字节,头里 Content-Length=8640,Content-Encoding=gzip;存的正文与服务器发出的字节相同:False
  (c) 解压后的正文,头里去掉 Content-Encoding、Content-Length 改成解压后的长度:warcio 读回 16434 字节,与原 HTML 相同:True;记录里存的正文 16434 字节,头里 Content-Length=16434,Content-Encoding=None;存的正文与服务器发出的字节相同:False
  从 requests 里拿原始字节:stream=True 后 resp.raw.read(decode_content=False) 得到 8640 字节,与套接字上的正文相同:True
  chunked 的响应:套接字上的正文 16477 字节(含分块格式);requests 的 resp.raw 读出 16434 字节(分块格式已经被去掉),而 resp.headers 里仍有 Transfer-Encoding: chunked

requests 的 .content 是解压后的,resp.raw 虽然没有分块格式了,但 resp.headers 里还有 Transfer-Encoding: chunked。把解码后的正文接在原来的头后面,头(Content-Length=8640、Content-Encoding: gzip)和正文(16434 字节)就对不上,而 warcio 读回时并没有报错——不报错不等于没问题。要原样保存,就在套接字层抓字节,或者 stream=True 后用 resp.raw.read(decode_content=False) 并自己处理分块;(c) 去掉头里的编码信息再存解压后的正文是自洽的,但存下的已不是服务器发出的字节。

每条记录单独压缩

实测 · 本机执行输出
  构造 5000 条记录:2.47 秒;未压缩 WARC 共 68.2 MiB(平均每条 14298 字节;其中 1/3 的正文本来就是 gzip 压缩过的,所以压缩率不高)
  不压缩 .warc                       68.2 MiB   写入 0.03 秒
  每条记录单独 gzip(.warc.gz)           43.4 MiB   写入 0.97 秒
  整个文件一个 gzip 流                   40.2 MiB   写入 1.36 秒
  warcio 读整个文件一个 gzip 流的那个文件:抛出 ArchiveLoadFailed:ERROR: non-chunked gzip file detected, gzip block continues

每条记录压成一个独立的 gzip 成员再拼接,得到 43.4 MiB;整个文件一个 gzip 流是 40.2 MiB,只小了约 7%,但 warcio 读它直接抛出 ArchiveLoadFailed,要求每条记录一个成员。

偏移索引

实测 · 本机执行输出
  每条单独 gzip + 偏移索引:读 200 次随机记录,平均每次 0.044 毫秒,读出的字节和写入的完全一致
  没有索引、按 URL 顺序扫描(warcio 迭代,目标是第 3000 条):153 毫秒(找到)
  整个文件一个 gzip 流:解压整个文件 117 毫秒(这里没有测增量解压到某个位置就停的情形)

每条记录单独压缩的好处是可以记下它的偏移和长度,以后 seek 过去只解压这一条:200 次随机读取平均 0.044 毫秒;没有索引要从头扫,找第 3000 条用了 153 毫秒。写入时把偏移记进索引(CDX 之类)是这种格式的用法。

重复内容

实测 · 本机执行输出
  5000 个 URL 里有 1500 个(30%)的正文与前面某个 URL 的字节完全相同(例如同一页面带不同的跟踪参数):全部写完整记录(每条单独 gzip)共 43.4 MiB;重复的写成 revisit 记录共 30.9 MiB,省下 29%

正文与已有记录字节完全相同时,可以只存 HTTP 头,用 revisit 记录引用第一次(WARC-Refers-To、payload 摘要):实验里 30% 重复,文件小 29%。被引用的记录不能删。

写到一半被杀

实测 · 本机执行输出
  每条单独 gzip,截断在 26.1 MiB:warcio 顺序读到 3001 条,没有报错;摘要核对结果的分布 {True: 3000, None: 1};最后一条是 http://x.test/plain/3000,头里声明的 Content-Length=16366
  用 zlib 逐个 gzip 成员地走:完整的成员 3000 个,最后一个完整成员结束在偏移 27324868,文件被截断多出 4526 字节;把文件截到 27324868 就能继续追加(走一遍用了 0.85 秒)
  有偏移索引时:第 3000 条在索引里的结束偏移 27333920 大于文件大小 27329394,判定为不完整;前面的记录不受影响
  整个文件一个 gzip 流,同样按位置比例(3000/5000)截断在 24.1 MiB:zlib 流式解压能得到 40.9 MiB 的未压缩数据,自己解析 Content-Length 数出完整记录 2999 条;但 warcio 不认这种文件(见第三节)

进程被杀后,每条单独 gzip 的文件可以逐个成员地走到最后一个完整成员,截断后继续追加。但 warcio 顺序读被截断的文件时,最后那条残缺的记录不报错、摘要核对结果是 None(没有核对),所以读取侧不能只看“没报错”,要看摘要核对结果或比对声明的 Content-Length 与实际读到的长度。有偏移索引时,结束偏移大于文件大小就说明那条没写完。

分步演示

WARC 归档
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8 / requests 2.34.2 / aiohttp 3.14.3 / lxml 6.1.1(系统 Python);所有请求只发给本机自己起的测试服务器(127.0.0.1,端口 9960 以上,不访问任何真实网站);服务器可以按实验需要制造延迟、断连、压缩、重定向等行为,并记录收到的原始请求;计时用 time.perf_counter,数字每次运行会有小幅抖动。服务器是本机的 Srv(每个请求返回由页面编号确定的约 16 KB HTML,plain / gzip / chunked 三种传输方式),用裸套接字抓取响应字节;WARC 记录是我手写的(WARC/1.1 头、Content-Length、sha1 base32 摘要),用临时安装的 warcio 1.8.1 读回核对(用完删除);5000 条记录的文件在临时目录里,用完删除;各情形跑一次,时间是单次测量(写入时间包含 gzip 压缩)
    • 没有验证:WARC 规范原文(只验证了 warcio 1.8.1 的行为,比如 chunked 的摘要是怎么算的);request 记录、metadata 记录、warcinfo 记录;大文件(GB 级)和并发写入;WARC-Truncated(正文太大被截断时的标记);zstd 压缩的 WARC;其它读取工具(如 Wayback 的 pywb)对这些记录的处理;真实站点上带 Cookie、重定向链的响应怎么存
    京ICP备2026067502号-1京公网安备11010502063487号