Repository navigation
Expand file tree
/
Copy pathwarc2zip.py
More file actions
1646 lines (1409 loc) · 77.1 KB
/
Copy pathwarc2zip.py
File metadata and controls
1646 lines (1409 loc) · 77.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import argparse
import asyncio
import configparser
import csv
import io
import json
import mimetypes
import os
import posixpath
import random
import re
import secrets
import sys
import time
import zipfile
from collections import Counter
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse
import aiohttp
from aiohttp import hdrs
from fsspec.implementations.http import HTTPFileSystem
from fsspec.registry import register_implementation
from fsspec.utils import stringify_path
from tqdm import tqdm
from warcio.archiveiterator import ArchiveIterator
from warcio.recordloader import ARC2WARCHeadersParser
from warcio.utils import fsspec_open
# --- Internet Archive input: ia://<identifier>/<filename> ------------------------------------------
#
# Read over https://archive.org/download/, not over IA's S3-like API (s3.us.archive.org): that
# endpoint ignores Range headers and answers GET with a 307 to a plain-http data node, so neither
# fsspec's block reads nor --select could use it (a 100-byte read pulled the whole 1.6 GB file). The
# download URL 302s to a data node that honours Range; it is also what the `internetarchive`
# package itself downloads from. So ia:// is HTTPFileSystem plus a path mapping and credentials.
#
# The class is written to be lifted verbatim into fsspec (fsspec/implementations/ia.py). Once a
# fsspec release ships it, delete everything down to the register_implementation() call.
IA_DOWNLOAD_URL = "https://archive.org/download/"
IA_AUTH_DOMAIN = ".archive.org"
# The internetarchive package's own names (not the shorter ones one might guess).
IA_ENV_ACCESS_KEY = "IA_ACCESS_KEY_ID"
IA_ENV_SECRET_KEY = "IA_SECRET_ACCESS_KEY"
IA_ENV_CONFIG_FILE = "IA_CONFIG_FILE"
def ia_config_path():
"""The ia.ini that `ia configure` wrote, or None.
Searched the way the internetarchive package searches: $IA_CONFIG_FILE, then
$XDG_CONFIG_HOME/internetarchive/ia.ini (XDG_CONFIG_HOME defaulting to ~/.config), then
~/.config/ia.ini, then ~/.ia. The first existing file wins.
"""
home = os.path.expanduser("~")
xdg = os.environ.get("XDG_CONFIG_HOME") or os.path.join(home, ".config")
candidates = [
os.environ.get(IA_ENV_CONFIG_FILE),
os.path.join(xdg, "internetarchive", "ia.ini"),
os.path.join(home, ".config", "ia.ini"),
os.path.join(home, ".ia"),
]
return next((path for path in candidates if path and os.path.isfile(path)), None)
@dataclass(frozen=True)
class IACredentials:
"""The IA-S3 key pair that logs a request in at archive.org; None means anonymous."""
access_key: str | None = None
secret_key: str | None = None
config_file: str | None = None # where they were read from, for messages
@property
def anonymous(self):
return not self.access_key
@property
def authorization(self):
"""The ``Authorization`` header value, or None when anonymous."""
if self.anonymous:
return None
return f"LOW {self.access_key}:{self.secret_key}"
def load_ia_credentials(config_file=None):
"""Credentials from ia.ini and the environment; anonymous when there are none.
`[s3] access/secret` are read from `config_file` (default: ia_config_path()); the `[cookies]`
section `ia configure` also writes is ignored, as the keys log a request in on their own.
IA_ACCESS_KEY_ID / IA_SECRET_ACCESS_KEY override the file's keys and must be set together. A
missing file or section is not an error: public items need nothing.
"""
path = config_file or ia_config_path()
access_key = secret_key = None
if path and os.path.isfile(path):
# RawConfigParser: the file's cookie values contain '%' (URL-encoded emails), which the
# default interpolation would reject.
parser = configparser.RawConfigParser()
parser.read(path, encoding="utf-8")
access_key = parser.get("s3", "access", fallback="").strip() or None
secret_key = parser.get("s3", "secret", fallback="").strip() or None
env_access, env_secret = os.environ.get(IA_ENV_ACCESS_KEY), os.environ.get(IA_ENV_SECRET_KEY)
if bool(env_access) != bool(env_secret):
raise ValueError(f"{IA_ENV_ACCESS_KEY} and {IA_ENV_SECRET_KEY} must be set together")
if env_access:
access_key, secret_key = env_access, env_secret
if not (access_key and secret_key):
access_key = secret_key = None
return IACredentials(access_key, secret_key, path if path and os.path.isfile(path) else None)
def in_domain(host, domain):
"""Whether `host` is `domain` (leading dot optional) or a subdomain of it."""
if not host:
return False
domain = domain.lower().lstrip(".")
host = host.lower().rstrip(".")
return host == domain or host.endswith("." + domain)
def authorized_request_class(authorization, domain, base=aiohttp.ClientRequest):
"""A ``ClientRequest`` that sends `authorization` to every host in `domain`.
aiohttp drops ``Authorization`` when a redirect changes origin, and every archive.org download
is such a redirect (to a data node). It builds a new request from the session's
``request_class`` for each hop, so setting the header here puts it back on the data-node hop;
testing the host keeps it from leaving `domain` should a data node redirect elsewhere.
"""
class AuthorizedRequest(base):
def update_headers(self, headers):
super().update_headers(headers)
if in_domain(self.url.host, domain):
self.headers[hdrs.AUTHORIZATION] = authorization
return AuthorizedRequest
class InternetArchiveFileSystem(HTTPFileSystem):
"""Files in Internet Archive items, addressed as ``ia://<identifier>/<filename>``.
Every path is read from ``https://archive.org/download/<identifier>/<filename>``, which
redirects to a data node that honours HTTP Range requests, so this is ``HTTPFileSystem``
with a path mapping and archive.org credentials. Public items need no credentials.
Restricted items need the account's IA-S3 keys, as written by ``ia configure`` from the
``internetarchive`` package to ``ia.ini``; that file is found the way the package finds it
(``$IA_CONFIG_FILE``, ``$XDG_CONFIG_HOME/internetarchive/ia.ini``, ``~/.config/ia.ini``,
``~/.ia``). Explicit arguments win over the file.
Parameters
----------
access_key, secret_key: str, optional
IA S3 keys, sent as ``Authorization: LOW <access>:<secret>`` to every archive.org host,
including the data node the download URL redirects to (aiohttp drops the header on a
cross-origin redirect; the session's request class puts it back, and only for
``.archive.org`` hosts). Both or neither; ``IA_ACCESS_KEY_ID`` / ``IA_SECRET_ACCESS_KEY``
in the environment override ``ia.ini``. Pass empty strings to stay anonymous despite an
``ia.ini``.
config_file: str, optional
Path to an ``ia.ini`` to read credentials from instead of the default lookup.
kwargs:
Passed to ``HTTPFileSystem``.
"""
protocol = "ia"
download_url = IA_DOWNLOAD_URL
auth_domain = IA_AUTH_DOMAIN
def __init__(self, access_key=None, secret_key=None, config_file=None, **kwargs):
if access_key is None and secret_key is None:
found = load_ia_credentials(config_file)
access_key, secret_key = found.access_key, found.secret_key
elif bool(access_key) != bool(secret_key):
raise ValueError("access_key and secret_key must be given together")
credentials = IACredentials(access_key or None, secret_key or None)
self.access_key = credentials.access_key
self.authorization = credentials.authorization
if self.authorization:
client_kwargs = dict(kwargs.pop("client_kwargs", None) or {})
client_kwargs["request_class"] = authorized_request_class(
self.authorization,
self.auth_domain,
client_kwargs.get("request_class", aiohttp.ClientRequest),
)
kwargs["client_kwargs"] = client_kwargs
super().__init__(**kwargs)
@classmethod
def _strip_protocol(cls, path):
"""``ia://item/file`` (or a bare ``item/file``) becomes the download URL; URLs pass through."""
if isinstance(path, list):
return [cls._strip_protocol(p) for p in path]
path = stringify_path(path)
if path.startswith("ia://"):
path = path[5:]
elif "://" in path:
return path
return cls.download_url + path.lstrip("/")
def unstrip_protocol(self, name):
if name.startswith(self.download_url):
return "ia://" + name[len(self.download_url) :]
if name.startswith("ia://"):
return name
return "ia://" + name.lstrip("/")
# archive.org answers a restricted item with 403 (401 for a bad LOW key). HTTPFileSystem
# reports every failed HEAD/GET as FileNotFoundError and every other status through
# raise_for_status(); both are turned into PermissionError so callers can tell "no such
# file" from "log in".
# HTTPFileSystem's methods take the path as the URL verbatim; the two that callers reach
# with an ia:// path directly (open() strips before _open) map it here.
async def _info(self, url, **kwargs):
url = self._strip_protocol(url)
try:
return await super()._info(url, **kwargs)
except FileNotFoundError as exc:
cause = exc.__cause__
if isinstance(cause, aiohttp.ClientResponseError) and cause.status in (401, 403):
raise PermissionError(url) from cause
raise
async def _cat_file(self, url, start=None, end=None, **kwargs):
return await super()._cat_file(self._strip_protocol(url), start=start, end=end, **kwargs)
def _raise_not_found_for_status(self, response, url):
if response.status in (401, 403):
raise PermissionError(url)
super()._raise_not_found_for_status(response, url)
# clobber=True: if a fsspec release ever lists its own "ia" implementation, ours must still win
# until this inline copy is deleted, rather than fail at import.
register_implementation("ia", InternetArchiveFileSystem, clobber=True)
def format_ia_permission_error(input_file, credentials=None):
"""The stderr message for a 401/403 from archive.org on an ia:// input."""
credentials = credentials or load_ia_credentials()
item = urlparse(input_file).netloc
if credentials.anonymous:
where = credentials.config_file or "no ia.ini found"
how = f"no archive.org credentials ({where}), so the request was anonymous"
else:
where = credentials.config_file or "the environment"
how = f"the IA-S3 keys from {where} were refused (a revoked or mistyped key looks the same)"
return "\n".join(
[
f"Error: archive.org refused access to item '{item}': {how}.",
"",
"A restricted item needs the account that can see it to be logged in:",
"",
" pip install internetarchive && ia configure # writes ~/.config/internetarchive/ia.ini",
"",
f"or set {IA_ENV_ACCESS_KEY} and {IA_ENV_SECRET_KEY}. Public items need neither; check the item name.",
f"Underlying URL: {InternetArchiveFileSystem._strip_protocol(input_file)}",
]
)
MIME_EXTENSION_OVERRIDES = {
"text/html": ".html",
"text/plain": ".txt",
"image/jpeg": ".jpg",
# ARC-era Heritrix wrote DNS lookups as their own records with this type. It has no
# registered extension, and ".txt" would say less than the archive knows, so name it after
# the type rather than after the fact that the body happens to be readable.
"text/dns": ".dns",
}
# Payload kinds settled by the URL rather than by the Content-Type. Servers hand robots.txt out as
# text/plain, text/html, application/octet-stream and worse, and none of those say "this is an
# exclusion file" — the path does. Keyed on urlparse().path exactly: RFC 9309 puts the exclusion
# file at the site root and nowhere else, so "/help/robots.txt" is an ordinary page.
#
# Add other well-known *root* paths here as they come up (e.g. "/sitemap.xml"). Kinds that live at
# arbitrary URLs cannot be caught this way at all — see choose_extension().
PATH_EXTENSION_OVERRIDES = {
"/robots.txt": ".robots",
}
# Pseudo-header carrying the content-type the ARC record itself declared. warcio's ARC->WARC
# mapping overwrites that field with "application/http;msgtype=response" (see _ARC2WARCKeepMime),
# and for the many ARC records with no HTTP layer at all — dns:, whois:, ntp: — it is the only
# mime information the archive has. Present on ARC-derived records only; its presence is also
# how the rest of this module recognises that the input was an ARC.
ARC_CONTENT_TYPE_HEADER = "ARC-Content-Type"
DRY_RUN_MAX = 10 # hard cap on capture records scanned by --dry-run
# The record types main() actually consumes, in the order the summary table lists them.
# Anything else (resource, conversion, continuation, ...) is read and discarded, and the
# table printed by format_record_type_summary() is the only place it shows up at all.
EXTRACTED_RECORD_TYPES = ("warcinfo", "response", "revisit", "request", "metadata")
# Transient-failure policy for reading a remote input (see CountingStream).
FETCH_DEFAULT_RETRIES = 8 # retries after the first attempt
FETCH_MAX_BACKOFF = 60.0 # seconds; the cap on one exponential-backoff wait
FETCH_RETRYABLE_STATUSES = frozenset({408, 425, 429, 500, 502, 503, 504})
def error_status(exc):
"""HTTP status carried by an exception (aiohttp's ClientResponseError), else None."""
status = getattr(exc, "status", None)
return status if isinstance(status, int) else None
def retry_after_seconds(exc):
"""The Retry-After delay the server asked for, in seconds; only the delta-seconds form."""
headers = getattr(exc, "headers", None)
if not headers:
return None
try:
return max(0.0, float(headers.get("Retry-After")))
except (TypeError, ValueError):
return None
def is_retryable(exc):
"""Transient failures only: throttling, server errors, timeouts, dropped connections.
A 404/403 (fsspec raises FileNotFoundError / PermissionError) and any other 4xx are
deterministic and re-raised at once. aiohttp's non-OSError exceptions (payload/disconnect)
are recognised by module, so aiohttp is not imported here.
"""
status = error_status(exc)
if status is not None:
return status in FETCH_RETRYABLE_STATUSES
if isinstance(exc, (FileNotFoundError, PermissionError)):
return False
if isinstance(exc, (OSError, TimeoutError, asyncio.TimeoutError)):
return True
return type(exc).__module__.split(".")[0] == "aiohttp"
def fetch_with_retry(fetch, *, retries=FETCH_DEFAULT_RETRIES, label="", sleep=time.sleep, rng=random.random):
"""Call the zero-argument `fetch` until it returns, retrying transient failures.
Waits Retry-After when the server sends one, otherwise an exponential backoff (2^attempt
seconds, capped at FETCH_MAX_BACKOFF) with jitter in [0.5, 1.5). One stderr line per retry.
Re-raises on a non-retryable error or once `retries` retries are used up.
"""
attempts = max(1, retries + 1)
for attempt in range(1, attempts + 1):
try:
return fetch()
except Exception as exc:
if attempt == attempts or not is_retryable(exc):
raise
delay = retry_after_seconds(exc)
if delay is None:
delay = min(FETCH_MAX_BACKOFF, 2.0**attempt) * (0.5 + rng())
print(
f"warning: {label}: attempt {attempt}/{attempts} failed ({exc}), retrying in {delay:.1f} s",
file=sys.stderr,
)
sleep(delay)
class CountingStream(io.IOBase):
"""Sequential read-through wrapper: counts the bytes handed out and retries transient failures.
tell() is the count, which is what the progress bar reads (sys.stdin.buffer has a tell()
that crashes on a pipe). A read that fails with a transient error — throttling, a 5xx, a
connection dropped mid-block — is retried after seeking the underlying stream back to the
count, so the caller sees one contiguous byte stream and never a duplicated or missing
block. A stream that cannot seek (a pipe) gets no retry: there is nothing to rewind to,
so the error propagates as before.
"""
def __init__(self, raw_stream, *, label="", retries=FETCH_DEFAULT_RETRIES, sleep=time.sleep):
self._stream = raw_stream
self._bytes_read = 0
self._label = label
self._retries = retries
self._sleep = sleep
try:
self._seekable = bool(raw_stream.seekable())
except (AttributeError, OSError, ValueError):
self._seekable = False
def tell(self):
"""Acts as the progress tracker for progress bar libraries."""
return self._bytes_read
def _read_with_retry(self, method, size):
attempts = 0
def once():
nonlocal attempts
if attempts: # a failed attempt may have moved the underlying position
self._stream.seek(self._bytes_read)
attempts += 1
return method(size)
if self._seekable:
data = fetch_with_retry(once, retries=self._retries, label=self._label, sleep=self._sleep)
else:
data = once()
if data:
self._bytes_read += len(data)
return data
def read(self, size=-1):
return self._read_with_retry(self._stream.read, size)
def readline(self, size=-1):
return self._read_with_retry(self._stream.readline, size)
# Forward other essential methods to the underlying stream
def readable(self):
return getattr(self._stream, 'readable', lambda: True)()
def close(self):
self._stream.close()
class _ARC2WARCKeepMime(ARC2WARCHeadersParser):
"""warcio's ARC->WARC header mapper, minus the content-type amnesia.
The stock parser rewrites parts[3] to "application/http;msgtype=response" so an ARC record
looks like a WARC application/http envelope. That is right for the envelope and wrong for the
archive: the ARC header line's fourth field is the only place a dns:/whois:/ntp: record — which
has no HTTP layer to fall back on — ever states what it contains. Re-attach it under
ARC_CONTENT_TYPE_HEADER so nothing is lost.
"""
def _get_protocol_and_headers(self, headerline, parts):
# Read before super(), which mutates parts[3] in place for non-filedesc records.
arc_content_type = parts[3] if len(parts) > 3 else ""
protocol, headers = super()._get_protocol_and_headers(headerline, parts)
headers.append((ARC_CONTENT_TYPE_HEADER, arc_content_type))
return protocol, headers
def open_archive_iterator(stream):
"""ArchiveIterator that understands ARC as well as WARC.
arc2warc=True is what makes ARC input work at all: without it warcio exposes the raw ARC
5-tuple ("uri", "archive-date", ...) and every WARC-Target-URI / WARC-Date lookup in this
module quietly returns None. It also mints a WARC-Record-ID per record, which the grouping
dict needs as a key — ARC/1.1 predates record IDs, so every record would otherwise collide
under the single key None and only the last one would survive into the manifest.
The flag costs pure-WARC input nothing but warcio's per-record format-caching shortcut.
"""
iterator = ArchiveIterator(stream, arc2warc=True)
iterator.loader.arc_parser = _ARC2WARCKeepMime()
return iterator
@dataclass
class RecordGroup:
payload_filename: str = ""
payload_size: int = 0
response_mime_type: str | None = None
response_warc_headers: list[tuple[str, str]] = field(default_factory=list)
response_http_headers: list[tuple[str, str]] = field(default_factory=list)
response_target_uri: str = ""
response_date: str = ""
response_record_id: str = ""
# True when response_record_id was minted by warcio for an ARC record rather than read from
# the archive. Usable as a grouping key, but never written out: it is a fresh random UUID on
# every run, and a CSV consumer would reasonably read it as identifying the source record.
response_record_id_synthetic: bool = False
http_status_code: str = ""
content_type_header: str = ""
response_order: int = -1
response_offset: int | None = None # byte offset of the response record in the WARC
response_length: int | None = None # byte length of that record (gzip member size)
http_status_line: str = "" # Full HTTP status line, e.g. "HTTP/1.1 200 OK"
response_domain: str = "" # Domain from WARC-Target-URI, e.g. "example.com"
concurrent_to: str = "" # WARC-Concurrent-To from the response record (points to request)
# "response" or "revisit". A revisit capture gets every CSV row a response does, but no
# payload file: its content lives in an earlier capture, named by the refers_to_* fields.
warc_type: str = "response"
refers_to_record_id: str = "" # WARC-Refers-To (on CC revisits: the request's record id)
refers_to_target_uri: str = "" # WARC-Refers-To-Target-URI
refers_to_date: str = "" # WARC-Refers-To-Date (absent on ~2% of CC revisits)
requests: list[list[tuple[str, str]]] = field(default_factory=list)
request_http_headers: list[list[tuple[str, str]]] = field(default_factory=list)
request_http_lines: list[str] = field(default_factory=list) # e.g. "GET /path HTTP/1.1"
request_bodies: list[bytes] = field(default_factory=list)
request_offsets: list[int | None] = field(default_factory=list)
request_lengths: list[int | None] = field(default_factory=list)
# (warc header pairs, body text, byte offset, byte length)
metadata_entries: list[tuple[list[tuple[str, str]], str, int | None, int | None]] = field(default_factory=list)
def is_arc_record(record):
"""True when this record came from an ARC, i.e. warcio synthesized its WARC-* header names."""
return record.rec_headers.get_header(ARC_CONTENT_TYPE_HEADER) is not None
def archive_header_pairs(record):
"""A record's headers, minus anything warcio invented that the archive never contained.
For ARC input every WARC-* name is warcio's rename of a real ARC field — except
WARC-Record-ID, which has no ARC counterpart at all and is a fresh random UUID on every run.
Emitting it would invite a CSV consumer to read a per-run value as an identity, and it would
break the --metadata-only invariant, which compares the metadata of two separate runs byte
for byte.
"""
pairs = list(record.rec_headers.headers)
if not is_arc_record(record):
return pairs
return [(name, value) for name, value in pairs if name != "WARC-Record-ID"]
def normalize_mime_type(value):
"""Bare lowercase type from a Content-Type value, or "" if there isn't one."""
if not value:
return ""
return value.split(";")[0].strip().lower()
def detect_mime_type(record):
"""Best available content-type for a record's payload.
Two sources, and which one applies is decided by whether the record has an HTTP layer at all,
never by whether that layer happened to declare a type:
1. `record.http_headers` is not None — an HTTP transaction was captured. Its Content-Type is
authoritative, **including when it is missing**: a capture whose server sent no
Content-Type genuinely has no declared type, and saying so is the honest answer.
2. `record.http_headers` is None — there was no HTTP transaction to parse, so field 4 of the
ARC header line (ARC_CONTENT_TYPE_HEADER) is the only place the archive ever states what
the payload contains. ARC's dns:/whois:/ntp: records are the case that matters; warcio
also reports None for a zero-length record or a non-http(s) scheme, which are the same
situation.
The split is deliberately at `is None` rather than at "no Content-Type value". Letting the ARC
type fill in for an HTTP layer that declared nothing would put a value in manifest.csv's
detected_mime_type that its own content_type_header column cannot corroborate — and that column
is how a CSV-only consumer checks the tool's work. Nothing is lost by the strict rule: the ARC
declaration still reaches the output as an `arc_content_type` row in response_warc_headers.csv
for every ARC record (see archive_header_pairs()), it is just not promoted into a column that
cannot be cross-checked.
"""
if record.http_headers is None:
return normalize_mime_type(record.rec_headers.get_header(ARC_CONTENT_TYPE_HEADER)) or "application/octet-stream"
return normalize_mime_type(record.http_headers.get_header("Content-Type")) or "application/octet-stream"
def mime_to_extension(mime_type):
if mime_type in MIME_EXTENSION_OVERRIDES:
return MIME_EXTENSION_OVERRIDES[mime_type]
return mimetypes.guess_extension(mime_type) or ".unk"
def path_extension_override(target_uri):
"""Extension implied by a capture's URL alone, or None.
Guards on netloc so an opaque scheme is never read as a path: urlparse("dns:ntsb.gov") puts
the host in .path and leaves .netloc empty, and ARC input is full of those. A query string is
tolerated ("/robots.txt?v=2" is the same resource, .query holds the rest); case is not, since
the path is case-sensitive and a server is under no obligation to serve "/Robots.txt".
"""
if not target_uri:
return None
parsed = urlparse(target_uri)
if not parsed.netloc:
return None
return PATH_EXTENSION_OVERRIDES.get(parsed.path)
def choose_extension(mime_type, target_uri):
"""File extension for a payload: the URL where it knows better, else the declared type.
The URL rule is deliberately unconditional — status and body length do not gate it. The
extension records what was *requested*; whether the server actually served it is what
http_status_code and payload_size are for in manifest.csv, and a consumer that cares must
filter there anyway. In the EOT-2004 file that means 336 of 533 ".robots" files are in fact
404 error pages, and the 5 zero-byte 200s — an empty robots.txt is a real allow-all answer,
not a missing one — keep the extension they deserve.
This mechanism only reaches kinds that live at a fixed, well-known path. Feeds do not: they
sit at arbitrary URLs and are announced by <link rel="alternate">, so the thing that
identifies them is their registered type (application/rss+xml, application/atom+xml) and
they belong in MIME_EXTENSION_OVERRIDES instead. Sitemaps are half and half — "/sitemap.xml"
is conventional enough for the table above, but the ones announced by a robots.txt "Sitemap:"
line can be anywhere, and catching those would need the XML root element (<urlset> /
<sitemapindex>), i.e. reading the payload rather than the headers.
"""
return path_extension_override(target_uri) or mime_to_extension(mime_type)
# Every C0 control except TAB, plus DEL. TAB is harmless inside a quoted field and occurs
# legitimately in header values; NUL and CR/LF are not — see sanitize_csv_value().
CSV_CONTROL_RE = re.compile(r"[\x00-\x08\x0a-\x1f\x7f]")
# The dialect is pinned field-by-field rather than inherited from csv.excel's class attributes,
# so a mutated global dialect can't silently change how values are quoted. No escapechar on
# purpose: QUOTE_ALL + doublequote covers every case, and setting one would double every
# backslash in a value (including the \xNN sequences sanitize_csv_value emits).
CSV_DIALECT = {
"delimiter": ",",
"quotechar": '"',
"doublequote": True,
"lineterminator": "\r\n",
"quoting": csv.QUOTE_ALL,
}
def sanitize_csv_value(value):
r"""Make control characters visible so CSV output stays reader-parseable.
Header values off the wire can carry a NUL (seen in CC-MAIN-2026-21: `connection: close\x00`)
or, via obs-fold, an embedded CR/LF. A single NUL makes csv.reader reject the whole file;
rendering these as `\xNN` keeps rows single-line and the file parseable, without discarding
the evidence that the byte was there.
"""
if not isinstance(value, str):
value = str(value)
return CSV_CONTROL_RE.sub(lambda m: f"\\x{ord(m.group()):02x}", value)
def write_denormalized_csv(rows, label="csv"):
"""Write denormalized CSV: filename, header_name, header_value (multiple rows per file).
Returns (content, skipped) — a row that still fails to write is reported and skipped rather
than aborting a conversion that has already streamed the whole WARC.
"""
sio = io.StringIO()
writer = csv.writer(sio, **CSV_DIALECT)
writer.writerow(["filename", "header_name", "header_value"])
skipped = 0
for row in rows:
filename, header_name, header_value = row
header_name = header_name.strip().lower().replace("-", "_")
try:
writer.writerow((filename, header_name, sanitize_csv_value(header_value)))
except (csv.Error, UnicodeError) as e:
skipped += 1
print(f"warning: {label}: skipped header {header_name!r} of {filename}: {e}", file=sys.stderr)
return sio.getvalue(), skipped
def write_multiline_csv(rows, label="csv"):
"""Write multiline CSV: filename, headers (one row per file, headers as multiline string).
Returns (content, skipped) — see write_denormalized_csv().
"""
sio = io.StringIO()
writer = csv.writer(sio, **CSV_DIALECT)
writer.writerow(["filename", "headers"])
skipped = 0
for filename, header_pairs in rows:
headers_str = "\n".join(
f"{name.replace('-', '_').lower()}: {sanitize_csv_value(value)}" for name, value in header_pairs
)
try:
writer.writerow([filename, headers_str])
except (csv.Error, UnicodeError) as e:
skipped += 1
print(f"warning: {label}: skipped headers of {filename}: {e}", file=sys.stderr)
return sio.getvalue(), skipped
# A warc-fields name is an HTTP token (RFC 9110 5.6.2). Checking the charset, not just the
# presence of a colon, is what stops a one-line JSON body ({"just": "json"}) from being read as a
# field named '{"just"' — a real risk, since some producers put JSON in a metadata record.
WARC_FIELD_NAME_RE = re.compile(r"^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$")
def parse_warc_fields(text):
r"""Parse an application/warc-fields body into (name, value) pairs.
Returns None when the body doesn't look like warc-fields, so callers keep the raw block
instead of mangling something that was never a field list (a JSON blob, free text).
Only the first ':' splits a line: CC emits fields like
`http-header-user-agent: Mozilla/5.0 (X11; Linux)` whose value carries its own colons.
Duplicate names are kept as separate pairs, the way repeated WARC headers already are.
"""
pairs = []
for line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
if not line.strip():
continue
if line[0] in " \t":
# obs-fold continuation of the previous field
if not pairs:
return None
name, value = pairs[-1]
pairs[-1] = (name, f"{value} {line.strip()}")
continue
name, sep, value = line.partition(":")
if not sep or not WARC_FIELD_NAME_RE.match(name.strip()):
return None
pairs.append((name.strip(), value.strip()))
return pairs or None
def flatten_body_rows(body_text, prefix="_body"):
"""Shallow-flatten a warc-fields body into ("_body.<field>", value) pairs.
One level only: a field whose value is JSON (CC's languages-cld2) stays a single opaque
value, because the list nested inside it doesn't flatten into columns usefully. Names are
normalized here rather than left to the writers, since write_denormalized_csv() and
write_multiline_csv() normalize slightly differently. A body that isn't warc-fields falls
back to one raw row, so nothing is lost.
"""
pairs = parse_warc_fields(body_text)
if pairs is None:
return [(prefix, body_text)]
return [(f"{prefix}.{n.strip().lower().replace('-', '_')}", v) for n, v in pairs]
def record_location_pairs(offset, length):
"""Pseudo-headers locating a record inside the source WARC.
Same triple a CDX index carries (filename, offset, length): with the WARC filename from
manifest.csv, these two make a record re-fetchable with a single HTTP range request,
because each record is its own gzip member.
"""
if offset is None or length is None:
return []
return [("warc_record_offset", str(offset)), ("warc_record_length", str(length))]
def request_line_pairs(line):
"""Split an HTTP request line into request_method / request_target pseudo-header pairs.
Expects "GET /path HTTP/1.1". A leading HTTP/x token is skipped rather than trusted, so a
line assembled protocol-first can't turn into a row claiming the method is "HTTP/1.1".
"""
parts = line.split()
if parts and parts[0].upper().startswith("HTTP/"):
parts = parts[1:]
if len(parts) >= 2:
return [("request_method", parts[0]), ("request_target", parts[1])]
return []
MANIFEST_COLUMNS = (
"filename",
# "response" or "revisit". Revisit rows name no file in the zip (their filename is the
# synthetic {counter}.revisit key); filter warc_type == "response" for the files that exist.
"warc_type",
"warc_record_id",
"warc_target_uri",
"warc_date",
"http_status_code",
"detected_mime_type",
"content_type_header",
"payload_size",
# The re-fetch pointer of a revisit: which capture holds the content this one deduplicated.
# Empty on response rows (and warc_refers_to_date is absent on a few CC revisits).
"warc_refers_to_target_uri",
"warc_refers_to_date",
# Where this record came from: enough to re-fetch it from this row alone, with no join
# against warcinfo.csv and no knowledge of the zip it was extracted from.
"warc_filename",
"source_uri",
"warc_record_offset",
"warc_record_length",
)
def write_manifest_csv(entries, label="manifest.csv"):
"""Write the wide CSV mirror of manifest.jsonl: one row per capture, one column per key.
Fed from the same entry dicts as the JSONL so the two files cannot drift.
Returns (content, skipped) - see write_denormalized_csv().
"""
sio = io.StringIO()
writer = csv.writer(sio, **CSV_DIALECT)
writer.writerow(list(MANIFEST_COLUMNS))
skipped = 0
for entry in entries:
try:
writer.writerow([sanitize_csv_value(entry.get(column, "")) for column in MANIFEST_COLUMNS])
except (csv.Error, UnicodeError) as e:
skipped += 1
print(f"warning: {label}: skipped entry {entry.get('filename')!r}: {e}", file=sys.stderr)
return sio.getvalue(), skipped
def build_warcinfo_rows(warcinfos, source_uri=""):
"""Build denormalized and multiline CSV rows for the warcinfo record(s).
warcinfo is crawl-level and belongs to no payload file, so the filename column carries a
synthetic key: "warcinfo", then "warcinfo.1" etc. for the extra records a concatenated
WARC brings along.
`source_uri` is the input this zip was converted from, recorded as a synthetic row. The
WARC's own WARC-Filename says what the file is called; this says where it was read from,
which is what a range request actually needs. Emitted even when the WARC carries no
warcinfo record at all, so the provenance is never lost.
"""
rows = []
multi = []
for i, (headers, body_text, offset, length) in enumerate(warcinfos):
key = "warcinfo" if i == 0 else f"warcinfo.{i}"
pairs = record_location_pairs(offset, length) + list(headers)
for n, v in pairs:
rows.append((key, str.lower(n.replace("-", "_")), v))
rows.extend((key, n, v) for n, v in flatten_body_rows(body_text))
multi.append((key, pairs + [("_body", body_text)]))
if source_uri:
rows.insert(0, ("warcinfo", "source_uri", source_uri))
if multi:
multi[0] = (multi[0][0], [("source_uri", source_uri)] + multi[0][1])
else:
multi.append(("warcinfo", [("source_uri", source_uri)]))
return rows, multi
def write_warcinfo_files(zip_file, root_dir, warcinfos):
"""Write the raw warcinfo record(s): WARC headers and warc-fields body, wire bytes preserved."""
zip_file.writestr(
f"{root_dir}/warcinfo.warc",
"\n\n".join("\n".join(f"{n}: {v}" for n, v in headers) for headers, _body, _o, _l in warcinfos),
)
zip_file.writestr(
f"{root_dir}/warcinfo.warc-fields",
"\n\n".join(body for _headers, body, _o, _l in warcinfos),
)
def format_record_type_summary(counts):
"""Aligned per-type table of every record read, extracted types first.
Types outside EXTRACTED_RECORD_TYPES were read and discarded, and are flagged as such:
a `resource` record with no metadata pointing at it leaves no other trace in the output.
Zero-count types are omitted.
"""
ordered = [t for t in EXTRACTED_RECORD_TYPES if counts.get(t)]
ordered += sorted(t for t in counts if t not in EXTRACTED_RECORD_TYPES and counts[t])
if not ordered:
return "Record types: none"
name_width = max(len(t) for t in ordered)
count_width = max(len(str(counts[t])) for t in ordered)
lines = ["Record types:"]
for t in ordered:
line = f" {t:<{name_width}} {counts[t]:>{count_width}}"
if t not in EXTRACTED_RECORD_TYPES:
line += " (not extracted)"
lines.append(line)
return "\n".join(lines)
def get_file_size(input_file):
"""Get file size, handling both local paths and remote URIs."""
try:
return Path(input_file).stat().st_size
except (OSError, ValueError):
return None
def new_run_id():
"""4-char hex that makes one run's outputs unique: shared by the zip name and the root dir."""
return secrets.token_hex(2)
def build_root_dir_name(crawl_name, partial=False, run_id=None):
"""Build a unique root directory name from a crawl name.
Format: {crawl_name}_{YYYYMMDDTHHMMSS}_{4-char hex suffix}[_partial]
The hex suffix doesn't affect sort order since it comes after the timestamp. `partial` is
set iff --limit was given. `run_id` is the hex; main() passes the same one it gave
default_output_path(), so the zip on disk and the directory inside it carry the same tag.
"""
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
suffix = run_id or new_run_id()
if partial:
suffix += "_partial"
return f"{crawl_name}_{timestamp}_{suffix}"
def input_basename(input_file):
"""Basename of the input, whatever shape it comes in.
The README's inputs are local paths, `s3://bucket/key`, `https://host/path`, the same with a
query string (`https://huggingface.co/.../X.warc.gz?download=true`), and `-` for stdin. A
plain posixpath.basename() keeps the query string, which would put `?download=true` in the
zip name and the root directory. URIs are parsed and only the path's last segment kept;
stdin has no name and is labelled "stdin".
"""
if input_file == "-":
return "stdin"
parsed = urlparse(input_file)
if parsed.scheme and parsed.netloc:
return posixpath.basename(parsed.path.rstrip("/"))
# A local path: os.path, so a Windows `C:\dir\x.warc.gz` (scheme "c", no netloc) splits on `\`.
return os.path.basename(input_file.rstrip("/\\"))
def default_output_path(input_file, partial=False, run_id=None):
"""Default zip path when --output is not given: {input_basename}_{hex}[_partial].zip.
The .warc.gz / .warc / .arc.gz / .arc suffix is stripped and .zip appended. The hex is the
run id (see new_run_id) — without it Common Crawl's `warc/`, `crawldiagnostics/` and
`robotstxt/` files, which share a basename and differ only by directory, overwrite each
other's zip. `_partial` follows the same rule as the root directory inside the zip (set iff
--limit was given). Written to the current directory.
"""
basename = input_basename(input_file)
label = extract_crawl_name(basename) if basename else "unknown"
suffix = run_id or new_run_id()
if partial:
suffix += "_partial"
return Path(f"{label}_{suffix}.zip")
def extract_crawl_name(warc_filename):
"""Extract a clean crawl name from a WARC-Filename header value.
Strips path and extensions like .warc.gz to get a usable directory name. Longest suffix
first, so .warc.gz is not left holding a stray ".warc".
"""
name = posixpath.basename(warc_filename)
for ext in (".warc.gz", ".warc", ".arc.gz", ".arc"):
if name.endswith(ext):
return name[: -len(ext)]
return name
def build_group_metadata(group, warc_filename="", source_uri=""):
"""Build CSV rows and JSONL entry for a RecordGroup whose payload is already in the zip."""
payload_filename = group.payload_filename
# Response WARC headers, led by the record's location in the source WARC
response_warc_pairs = record_location_pairs(group.response_offset, group.response_length)
response_warc_pairs += group.response_warc_headers
response_warc_rows = [(payload_filename, str.lower(n), v) for n, v in response_warc_pairs]
response_warc_multi = (payload_filename, response_warc_pairs)
# Response HTTP headers, led by a synthetic status_code row so the status is greppable.
# Deliberately not the status line: CC's WARCs claim HTTP/1.1 even for h2 captures. The true
# protocol is already in response_warc_headers.csv as warc_protocol rows.
status_pairs = [("status_code", group.http_status_code)] if group.http_status_code else []
response_http_pairs = status_pairs + group.response_http_headers
# A capture with no HTTP layer at all still gets one blank row, so both response_http views
# carry every payload file and a consumer filtering manifest.csv down to a subset can join on
# `filename` without hitting a gap. ARC's dns:/whois:/ntp: records are the real case: they are
# complete captures that simply never had an HTTP transaction, so "no headers" is the answer,
# not a missing record. The multiline CSV already emitted a blank row here; this keeps the
# denormalized one from disagreeing about which files exist.
response_http_rows = (
[(payload_filename, str.lower(n), v) for n, v in response_http_pairs]
if response_http_pairs
else [(payload_filename, "", "")]
)
response_http_multi = (payload_filename, response_http_pairs)
# Request WARC and HTTP headers (all requests use the response's payload_filename).
# The HTTP side used to reach sidecar files only, so flat format lost it entirely.
request_warc_rows = []
request_warc_multi_entries = []
request_http_rows = []
request_http_multi_entries = []
for i, req_pairs in enumerate(group.requests):
offset = group.request_offsets[i] if i < len(group.request_offsets) else None
length = group.request_lengths[i] if i < len(group.request_lengths) else None
req_pairs = record_location_pairs(offset, length) + req_pairs
for n, v in req_pairs:
request_warc_rows.append((payload_filename, str.lower(n.replace("-", "_")), v))
request_warc_multi_entries.append((payload_filename, req_pairs))
http_pairs = group.request_http_headers[i] if i < len(group.request_http_headers) else []
request_line = group.request_http_lines[i] if i < len(group.request_http_lines) else ""
http_pairs = request_line_pairs(request_line) + http_pairs
if http_pairs:
for n, v in http_pairs:
request_http_rows.append((payload_filename, str.lower(n.replace("-", "_")), v))
request_http_multi_entries.append((payload_filename, http_pairs))
# Metadata entries (all use the response's payload_filename). The denormalized CSV gets the
# warc-fields body shallow-flattened so each field can be grepped; the multiline CSV keeps the
# raw block, so flat-format output still reproduces the wire bytes despite having no
# .metadata.warc-fields sidecar.
metadata_rows = []
metadata_multi_entries = []
for meta_pairs, body_text, offset, length in group.metadata_entries:
meta_pairs = record_location_pairs(offset, length) + meta_pairs
for n, v in meta_pairs:
metadata_rows.append((payload_filename, str.lower(n.replace("-", "_")), v))
metadata_rows.extend((payload_filename, n, v) for n, v in flatten_body_rows(body_text))
all_pairs = meta_pairs + [("_body", body_text)]
metadata_multi_entries.append((payload_filename, all_pairs))
# JSONL manifest entry
jsonl_entry = {
"filename": payload_filename,
"warc_type": group.warc_type,
# Blank rather than a per-run random UUID when the source was an ARC: the field does not
# exist in ARC/1.1. warc_record_offset/length still make the row re-fetchable on its own.
"warc_record_id": "" if group.response_record_id_synthetic else (group.response_record_id or ""),
"warc_target_uri": group.response_target_uri,
"warc_date": group.response_date,
"http_status_code": group.http_status_code,
"detected_mime_type": group.response_mime_type or "application/octet-stream",