大多数抓取流水线在提取完数据后就把页面丢弃了。解析器读取 HTML,写入一行数据,然后响应就消失了。这种做法一直可行,直到某天发现提取器已经出错了一整周,或者上个季度需要一个新字段,或者有人问起页面原本写了什么。到那时,唯一的办法就是重新抓取一遍,而页面在此期间已经变了。
保留原始响应可以解决这三个问题,而且已经有一种为此而生的标准格式:WARC,即网络存档格式(Web ARChive format),被各大网络档案馆和 Common Crawl 使用。本指南将介绍应该存储什么、如何用代码存储,以及在一次真实测试中它的成本如何。
关键要点
- 在解析之前,将每个响应原样存储。从已存储的响应中重新提取数据成本很低;而重新收集历史数据则是不可能的。
- WARC 是标准容器格式:一种开放格式,包含请求、响应和元数据记录,可以被许多现有工具读取。
- 请求压缩后的响应,并以压缩状态存储。在我们对 40 个页面的测试中,这使得 20.6 MB 的 HTML 在传输中压缩到 3.4 MB,在磁盘上为 3.5 MB。
- 从存档中重放全部 40 个页面耗时不到 0.2 秒,而实际抓取它们需要 6 到 10 秒。
- 记录每个文件是如何采集的,包括出口国家,以便日后公平地比较存档页面。
为什么要保留原始响应
几乎在每个长期运行的采集项目中,都会出现三种情况。
提取器会悄无声息地失效。 某个网站更改了标记结构,解析器仍在运行,但某个字段却悄悄变空或出错。模式漂移监控可以捕捉到这一点,但只能在一些错误行已经写入之后。如果磁盘上保留了原始响应,你可以修复提取器,然后针对受影响的日期重新运行。如果没有,那些日期的数据就永远丢失了。
问题会变化。 六个月后,有人需要一个当初没有人提取过的字段:一条配送说明、一个卖家评分、一个徽章。如果页面已经存档,这只是一个批处理任务。如果没有,那么从今天才开始收集,没有历史数据。
证据需要原件。 当采集的数据要支持某项决定、投诉或争议时,服务器实际返回的页面比从中衍生出的一行数据更有说服力,这正是为什么可用于法庭的证据要从保存下来的采集结果开始。
这也改变了你处理提取方式的方式。尝试一种新方法,比如基于模型的提取与选择器的比较,会变成一次针对已存储页面的离线实验,而不是一次新的抓取。
WARC 是什么
WARC 是一种用于网络采集的容器格式,由国际互联网保存联盟(International Internet Preservation Consortium)维护,并标准化为 ISO 28500。一个 WARC 文件是一系列记录,每条记录都有一小段文本头,后面跟着内容。对抓取而言,重要的记录类型有:
| 记录类型 | 包含内容 |
|---|---|
warcinfo | 文件的生成方式:软件、操作者、采集说明 |
request | 发送出去的 HTTP 请求,包括诸如 Accept-Language 之类的请求头 |
response | 接收到的 HTTP 响应:状态行、请求头和正文 |
metadata | 与某次采集相关的其他任何信息,通过记录 ID 与之关联 |
revisit | 指向之前某次相同采集的指针,用于避免重复存储 |
每条记录都带有其内容的摘要值,因此可以检查文件是否损坏。该规范建议对每条记录分别用 gzip 压缩,这样既能保持文件体积小,又能让读取程序直接跳转到某条记录。例如,Common Crawl 就以 WARC 文件的形式分发其抓取数据。
相比自制格式,它的实际优势在于工具支持。按照该标准写入的文件,可以被从未见过你代码的人用现有的开源工具进行索引、校验、重放和搜索。
代码
来自 Webrecorder 项目的 Python 库 warcio,可以读写 WARC。下面的函数用 requests 抓取一个页面,将请求和响应写入一个打开的 WARC 文件,并将正文完全按照服务器发送的原样保留:
from io import BytesIO
from urllib.parse import urlsplit
import requests
from warcio.archiveiterator import ArchiveIterator
from warcio.statusandheaders import StatusAndHeaders
from warcio.warcwriter import WARCWriter
def fetch_and_archive(session, url, writer, **kwargs):
"""Fetch url and write the request and the response, byte for byte as received, to a WARC file."""
response = session.get(url, stream=True, **kwargs)
# Read the body undecoded, so gzip or br responses are stored exactly as the server sent them.
raw = response.raw.read(decode_content=False)
sent = response.request
path = urlsplit(sent.url)
request_line = f"{sent.method} {path.path or '/'}{'?' + path.query if path.query else ''} HTTP/1.1"
request_headers = [("Host", path.netloc)] + list(sent.headers.items())
request = writer.create_warc_record(
sent.url, "request", payload=BytesIO(b""),
http_headers=StatusAndHeaders(request_line, request_headers, is_http_request=True))
# urllib3 has already removed any chunked framing, so drop the header that describes it.
headers = [(k, v) for k, v in response.raw.headers.items() if k.lower() != "transfer-encoding"]
status = f"{response.status_code} {response.reason}"
record = writer.create_warc_record(
response.url, "response", payload=BytesIO(raw),
http_headers=StatusAndHeaders(status, headers, protocol="HTTP/1.1"))
request.rec_headers.add_header("WARC-Concurrent-To", record.rec_headers.get_header("WARC-Record-ID"))
writer.write_record(request)
writer.write_record(record)
return response.status_code, raw
def replay(path):
"""Yield (url, status, decoded body) for every response in a WARC file, with no network access."""
with open(path, "rb") as stream:
for record in ArchiveIterator(stream):
if record.rec_type == "response":
status = int(record.http_headers.get_statuscode())
yield record.rec_headers.get_header("WARC-Target-URI"), status, record.content_stream().read()
通过代理使用它,并带有一条 warcinfo 记录,说明页面是从哪里采集的:
import os
import requests
from warcio.warcwriter import WARCWriter
from archive import fetch_and_archive, replay
proxy = f"http://{os.environ['SHIFTER_PROXY_USER']}-country-de:{os.environ['SHIFTER_PROXY_PASS']}@p.shifter.io:443"
session = requests.Session()
session.proxies = {"http": proxy, "https": proxy}
session.headers["Accept-Encoding"] = "gzip"
# Identify your collector; some sites refuse the library's default User-Agent.
session.headers["User-Agent"] = "ExampleArchiver/1.0 (+https://example.com/bot)"
urls = ["https://en.wikipedia.org/wiki/Web_archiving", "https://en.wikipedia.org/wiki/Web_crawler"]
with open("crawl-2026-10-01.warc.gz", "wb") as output:
writer = WARCWriter(output, gzip=True)
# One warcinfo record per file says how and from where the pages were collected.
writer.write_record(writer.create_warcinfo_record(
"crawl-2026-10-01.warc.gz", {"software": "warcio", "description": "exit country: de"}))
for url in urls:
fetch_and_archive(session, url, writer, timeout=30)
# Months later, with no network access: re-run a new extractor over the same bytes.
for url, status, body in replay("crawl-2026-10-01.warc.gz"):
print(status, url, len(body))
这段代码中的三个细节是在测试过程中发现的。
- 读取未解码的正文。 requests 通常会自动为你解压响应。读取原始流可以保留发送时的字节,这既更忠实,体积也小得多。warcio 会在重放时再次解压它们。
- 去掉分块传输头。 我们四分之一的响应是以分块传输编码(chunked transfer encoding)到达的,而 HTTP 库已经将其解包。如果把该请求头和已解包的正文一起存储,记录就会错误地描述自身。warcio 恰好能够应对这种情况;其他读取器可能不行。
- 发送真实的 User-Agent。 我们第一次运行示例时被 HTTP 403 拒绝了,因为该网站拒绝了 HTTP 库默认的 User-Agent。请诚实地标明你的采集者身份。
还有一个陷阱是在库的源代码中发现的,而非在测试中:如果你接受 Brotli 压缩的响应(br),请安装 brotli 包。否则,warcio 在重放时将无法解码这些正文,并且会在不报错的情况下返回仍处于压缩状态的字节。
成本如何
我们在 2026 年 10 月 1 日,通过位于德国的住宅出口,归档了 40 篇英文维基百科文章,共测试了两次:一次接受 gzip 压缩的响应,另一次请求未压缩的响应。
| 压缩响应 | 未压缩响应 | |
|---|---|---|
| 页面数 | 40 | 40 |
| 传输量 | 3.43 MB | 20.6 MB |
| 解码后的 HTML | 20.6 MB | 20.6 MB |
| 磁盘上的 WARC 文件 | 3.54 MB | 3.51 MB |
| 抓取耗时 | 6 到 10 秒 | 9 到 12 秒 |
| 重放全部 40 个耗时 | 0.19 秒 | 0.18 秒 |
每个重放的页面都与抓取时完全字节一致,通过 SHA-256 哈希校验确认,并且 warcio check 校验了该文件中全部 81 条记录的摘要值。
有两点值得注意。首先,存储成本很低:存档体积比传输的压缩字节大约大 3%,差异来自请求记录和请求头。其次,无论哪种方式,存储大小都是相同的,因为 WARC 文件本身总是会被压缩;真正变化的是带宽。请求未压缩的响应导致传输成本是原来的六倍,而得到的存档却是一样的。对于按带宽计费的采集而言,这正是会体现在账单上的差异,正如削减代理带宽成本一文中更详细解释的那样。
实践准则
- 先归档,后解析。 先写入 WARC 记录,再提取数据。如果提取器崩溃了,页面仍然被保留了下来。
- 按大小或时间滚动分文件。 WARC 规范建议每个文件以 1 GB 作为实际目标大小。文件命名要包含日期和采集批次,并且绝不要向另一个进程正在写入的文件追加内容。
- 记录观察点。 从德国抓取的页面和从美国抓取的页面,可能在语言、价格和内容上都不同。把出口国家和采集设置写入
warcinfo记录中,正如关于记录数据观察位置的论述所主张的那样。 - 为所保留的内容建立索引。 一个包含 URL、日期、文件和偏移量的小型索引,能让你从数 TB 的数据中取出某一次采集,而无需读取全部内容。warcio 的命令行工具
index可以生成这种索引。 - 设定保留策略。 原始页面可能包含个人数据。决定保留多久、限制谁可以读取,并按计划删除。
- 有意跳过重复内容。 当某个页面自上次采集以来没有变化时,可以用一条
revisit记录指向之前的副本,而不是再存储一遍,这与大规模变化检测配合得很好。
结论
解析是抓取流水线中最容易出错、也最容易变化的部分,因此它不应该是所采集内容的唯一记录。在解析之前先以 WARC 格式存储原始响应,请求压缩后的响应并保持其压缩状态,并记下每个文件是从哪里采集的。
在我们的测试中,这为 40 个页面带来了大约 3.5 MB 的磁盘开销,并把原本需要几秒钟的抓取变成了耗时仅为其一小部分的重放。下一次当提取器出错,或者上个月的数据出现新问题时,答案将是一个批处理任务,而不是一周的数据损失。
来源与参考资料
- 国际互联网保存联盟,The WARC Format 1.1。
- Webrecorder,warcio,版本 1.8.1,用于上述代码和测试。
- Common Crawl,Get started,关于其对 WARC 格式的使用。
- 由 Shifter 于 2026 年 10 月 1 日,使用上述代码采集的 40 个页面的测试存档。