ほとんどのスクレイピングパイプラインは、ページから抽出し終えた瞬間にそのページを捨ててしまう。パーサーがHTMLを読み込み、1行を書き込み、レスポンスは消える。これは、抽出ロジックが1週間ずっと間違っていたと判明する日や、来四半期に新しいフィールドが必要になる日、あるいは誰かがそのページに実際には何が書かれていたのかを尋ねてくる日が来るまでは問題にならない。そうなった時点で唯一戻る方法はすべてを再度取得することだが、その間にページは変わってしまっている。
生のレスポンスを保存しておけば、この3つの問題はすべて解決する。そしてそのために作られた標準フォーマットがある。WARC、つまりウェブアーカイブやCommon Crawlで使われているWeb ARChive形式だ。本ガイドでは、何を保存すべきか、それを保存するコード、そして実際のテストでかかったコストを扱う。
要点
- パース前に、受信したすべてのレスポンスをそのまま保存する。保存したレスポンスから再抽出するのは低コストだが、失われた履歴を取り戻すことは不可能だ。
- WARCは標準的なコンテナ形式であり、リクエスト、レスポンス、メタデータのレコードを持つオープンフォーマットで、既存の多くのツールで読み取れる。
- 圧縮済みのレスポンスをリクエストし、圧縮されたまま保存する。40ページでのテストでは、20.6 MBのHTMLが回線上で3.4 MB、ディスク上で3.5 MBに収まった。
- アーカイブから40ページすべてをリプレイするのに0.2秒もかからなかった。これに対し、取得には6秒から10秒かかった。
- 出口国を含め、各ファイルがどのように収集されたかを記録しておく。そうすれば後でアーカイブされたページを公平に比較できる。
なぜ生のレスポンスを保存すべきか
長期にわたる収集プロジェクトでは、ほぼ必ず3つの状況に遭遇する。
抽出ロジックは静かに壊れる。 サイトがマークアップを変更しても、パーサーは動き続け、あるフィールドが気づかないうちに空になったり誤った値になったりする。スキーマドリフトの監視でそれを検知できるが、それは既にいくつかの不正な行が書き込まれた後だ。ディスクに生のレスポンスがあれば、抽出ロジックを修正して影響を受けた日付分だけ再実行すればよい。なければ、その日々は失われる。
問われる内容は変わる。 6か月経って、誰も抽出していなかったフィールドが必要になることがある。配送に関する注記、販売者の評価、バッジなどだ。ページがアーカイブされていれば、それはバッチジョブで済む。されていなければ、今日から始めることになり、過去のデータは存在しない。
証拠には原本が必要になる。 収集したデータが意思決定や苦情、紛争を裏付ける場合、配信された状態のページは、そこから導出された1行のデータよりも重みを持つ。だからこそ法廷で通用する証拠は、保存されたキャプチャから始まる。
これは抽出への取り組み方も変える。セレクターと比較したモデルベースの抽出のような新しい手法を試すことも、新たなクロールではなく、保存済みページを使ったオフラインの実験になる。
WARCとは何か
WARCはウェブキャプチャ用のコンテナ形式であり、International Internet Preservation Consortiumによって維持され、ISO 28500として標準化されている。WARCファイルはレコードの連なりであり、各レコードは短いテキストヘッダーのブロックとそれに続くコンテンツから成る。スクレイピングにおいて重要なレコードタイプは以下の通りだ。
| レコードタイプ | 保持する内容 |
|---|---|
warcinfo | ファイルがどのように作られたか: ソフトウェア、運用者、収集に関するメモ |
request | 送信されたHTTPリクエスト。Accept-Languageなどのヘッダーを含む |
response | 受信したHTTPレスポンス: ステータス行、ヘッダー、ボディ |
metadata | キャプチャに関するその他の情報。レコードIDによってそのキャプチャにリンクされる |
revisit | 以前の同一キャプチャへのポインタ。重複保存を避けるために使われる |
各レコードはコンテンツのダイジェストを持つため、ファイルの破損をチェックできる。仕様では各レコードを個別にgzipで圧縮することが推奨されており、これによりファイルサイズを小さく保ちながら、リーダーが1つのレコードに直接ジャンプできるようにしている。たとえば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))
このコードに含まれる3つの詳細は、実際にテストして分かったものだ。
- ボディをデコードせずに読む。 requestsは通常、レスポンスをあなたの代わりに展開する。生のストリームを読むことで、送信されたままのバイト列を保持でき、より忠実であると同時にはるかに小さくなる。warcioはリプレイ時に再度それらを展開する。
- chunkedのヘッダーを除く。 我々のレスポンスの4分の1はchunked転送エンコーディングで届いたが、HTTPライブラリがすでにそれを展開していた。展開済みのボディにそのヘッダーをそのまま保存すると、自分自身について誤った説明をするレコードになってしまう。warcioはたまたま問題なく処理できたが、他のリーダーはそうとは限らない。
- 実在するUser-Agentを送る。 この例を最初に実行したとき、HTTPライブラリの既定のUser-Agentをサイトが拒否していたため、HTTP 403で拒否された。自分のコレクターを正直に示すこと。
テストではなくライブラリのソースコードで見つかった、もう1つの落とし穴がある。Brotli圧縮されたレスポンス(br)を受け入れる場合は、brotliパッケージをインストールする必要がある。これがないと、warcioはリプレイ時にそれらのボディをデコードできず、エラーを出さずに圧縮されたままのバイト列を返してしまう。
コストはどのくらいか
我々は2026年10月1日に、ドイツのレジデンシャル出口を経由して英語版Wikipediaの記事40本を、2通りの方法でアーカイブした。1つはgzip圧縮されたレスポンスを受け入れる方法、もう1つは非圧縮のレスポンスを要求する方法だ。
| 圧縮されたレスポンス | 非圧縮のレスポンス | |
|---|---|---|
| ページ数 | 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レコードすべてのダイジェストを検証した。
ここで際立つ点が2つある。まず、ストレージは安い。アーカイブのサイズは転送された圧縮バイト数よりも約3%大きいだけで、その差はリクエストレコードとヘッダーによるものだ。次に、ストレージはどちらの方法でも同じだった。WARCファイルはいずれにせよ圧縮されるためだ。変わったのは帯域幅だった。非圧縮のレスポンスを要求すると、同一のアーカイブに対して6倍の転送量がかかった。帯域幅課金の収集においては、それが請求書に表れる差になる。詳しくはプロキシの帯域幅コストを削減する方法で説明している。
実践上のルール
- パースの前にアーカイブする。 まずWARCレコードを書き込み、それから抽出する。抽出ロジックがクラッシュしても、ページは保持されたままになる。
- サイズまたは時間でファイルを切り替える。 WARC仕様では、1ファイルあたりの実用的な目標サイズとして1 GBを推奨している。日付と収集内容でファイル名を付け、他のプロセスが書き込み中のファイルには決して追記しないこと。
- 観測地点を記録する。 ドイツから取得したページとアメリカ合衆国から取得したページでは、言語、価格、内容が異なることがある。出口国と収集時の設定を
warcinfoレコードに記録すべきだ。これはデータがどこから観測されたかを記録することの重要性で論じた通りだ。 - 保持しているものをインデックス化する。 URL、日付、ファイル、オフセットの小さなインデックスがあれば、何テラバイトもの中からすべてを読み込むことなく1件のキャプチャを取り出せる。warcioのコマンドラインツール
indexがそれを生成する。 - 保持ポリシーを定める。 生のページには個人データが含まれ得る。どれだけの期間保持するかを決め、閲覧できる人を制限し、スケジュール通りに削除すること。
- 重複は意図的にスキップする。 前回のキャプチャから変化していないページについては、再度保存する代わりに
revisitレコードで以前のコピーを指し示すことができる。これは大規模な変化検知とも相性が良い。
結論
パース処理はスクレイピングパイプラインの中で最も間違いが起きやすく、最も変化しやすい部分であるため、収集した内容の唯一の記録であってはならない。パースする前に生のレスポンスをWARCとして保存し、圧縮されたレスポンスをリクエストして圧縮されたまま保持し、各ファイルをどこから収集したかを書き留めておく。
我々のテストでは、それは40ページでおよそ3.5 MBのディスク容量という代償で済み、数秒かかっていたクロールを、ほんの一瞬で終わるリプレイに変えた。次に抽出ロジックが壊れたとき、あるいは先月分について新たな疑問が生じたときの答えは、失われた1週間ではなくバッチジョブになる。
出典および参考資料
- International Internet Preservation Consortium, The WARC Format 1.1。
- Webrecorder, warcio、バージョン1.8.1。上記のコードおよびテストで使用。
- Common Crawl, Get started。WARC形式の利用について。
- 上記のコードを用いて、Shifterにより2026年10月1日に収集された40ページのテストアーカイブ。