数据抓取

保留原始响应:以WARC格式归档抓取页面以供回放

解析器会改进,提取器会失效,但未保存的页面无法重新解析。如何以WARC格式存储原始响应,附经过测试的代码和真实数据大小。

Matt Brown

Matt Brown

2026年10月1日 · 4 分钟阅读

大多数抓取流水线在提取完数据后就把页面丢弃了。解析器读取 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 压缩的响应,另一次请求未压缩的响应。

压缩响应未压缩响应
页面数4040
传输量3.43 MB20.6 MB
解码后的 HTML20.6 MB20.6 MB
磁盘上的 WARC 文件3.54 MB3.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 个页面的测试存档。

准备好开始了吗?

试用 Shifter 住宅代理,205M+ 个 IP,195+ 个国家,低至 $0.10/GB。

立即开始