爬虫存下的“原始响应”如果只留解析后的字段,规则改了就没法重新解析。把原始响应按 WARC 格式存起来,可以随时重放。这篇自己手写 WARC 记录,用 warcio 读回核对,逐项测写法上的坑。
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 与实际读到的长度。有偏移索引时,结束偏移大于文件大小就说明那条没写完。