数据采集:从 Common Crawl 的 WARC 文件到一篇篇文本

系列第二篇。流水线的第一步永远是「把数据弄进来」。这一阶段处理两类来源:本地已有的 JSONL/Parquet 文件,以及互联网上最著名的公开网页语料——Common Crawl。我们从最简单的本地加载讲起,再逐步拆解 Common Crawl 下载那套「三跳寻址 + WARC 解析 + 正文抽取」的完整链路。


一、两种来源,一个统一出口

在代码里,数据采集有两种模式,由配置中的 acquisition.source 决定:

acquisition:
  source: "local"      # 或者 "common_crawl"

无论走哪条路,最终产物都是一样的:一个带 text 列的 pandas DataFrame。这一步极其重要——因为下游的清洗、过滤、去重、导出,全都只认这个 DataFrame 约定(有 text 字段),而完全不关心数据「从哪来」。这就是数据流水线里经典的「约定优于配置」:只要你在入口处把各种来源都归一化成同一种数据结构,后面每一段就能对「数据源」这件事完全免疫。


二、本地加载:LocalFileLoader

先看最简单的情况——你手头已经有一堆 JSONL 或 Parquet 文件,想把它们读进来。

src/acquisition/local_loader.py 的核心逻辑非常直白:

class LocalFileLoader:
    input_dir: str
    file_format: str = "jsonl"   # "jsonl" 或 "parquet"
    text_field: str = "text"

    def load(self) -> pd.DataFrame:
        input_path = Path(self.input_dir)
        if not input_path.exists():
            raise FileNotFoundError(...)

        pattern = f"*.{self.file_format}"
        files = sorted(input_path.glob(pattern))   # 扫描目录下所有匹配文件

        frames = []
        for file_path in files:
            try:
                df = self._load_file(file_path)
                if self.text_field not in df.columns:
                    # 缺 text 列的文件直接跳过,并打 warning
                    continue
                frames.append(df)
            except Exception as e:
                logger.warning("Failed to load %s: %s", file_path, e)

        combined = pd.concat(frames, ignore_index=True)
        return combined

这里有几个值得注意的工程细节:

  1. glob 通配符扫描目录Path(input_dir).glob("*.jsonl") 一次性把一个目录下的所有文件都读进来,而不是要求你逐个指定。这意味着你可以把一个大语料切分成成千上万个 part 文件,放在一个目录里,让 loader 自动拼接。
  2. sorted 保证顺序稳定:文件名排序后加载,结果可复现。
  3. 容错不中断:单个文件加载失败(比如某个文件损坏)只会打一条 warning,不会让整条流水线崩掉。对几十万个小文件的语料来说,这种「能读多少读多少」的健壮性是刚需。
  4. text_field 校验:每个文件都检查是否包含约定好的文本列(默认 text),没有就跳过。这防止了「读进来一堆没有正文的元数据」污染下游。

输入文件长这样(JSONL,一行一个 JSON 对象,必须含 text 字段):

{"text": "This is document one with full text content...", "url": "https://example.com/1"}
{"text": "This is document two with different content...", "url": "https://example.com/2"}

text 之外的列(比如 url)会被原样保留、随数据一路传递下去,直到导出阶段再决定要不要写进输出。


三、Common Crawl:互联网的「免费快照」

本地加载没什么玄机,真正有意思的是 Common Crawl 这条路。

Common Crawl 是一个非营利组织,它定期(大约每月一次)抓取全网公开网页,并公开完整的快照供任何人免费使用。截至今天,它的规模已经覆盖了数十亿个网页、PB 级别的数据,是 GPT-3、LLaMA 等大模型预训练语料的重要来源之一。

但要用好它,你必须先理解它的数据是怎么「摆」的。Common Crawl 的数据存储有一套三跳寻址结构:

第一跳:collinfo.json          →  列出所有可用的快照(snapshot)ID
第二跳:warc.paths.gz          →  某个快照下所有 WARC 文件的路径清单
第三跳:WARC 文件               →  真正的数据,每个文件里装着许多网页记录

一个「快照」(snapshot)的 ID 长这样:CC-MAIN-2024-10。这个项目里下载器的核心任务,就是按你指定的快照范围,走完这三跳,把 WARC 文件一个个拉下来、解出正文。


四、CommonCrawlDownloader:三步走完

src/acquisition/common_crawl.py 里的 CommonCrawlDownloader 把这条链路实现得清清楚楚。

第一步:拿到快照列表,筛出目标范围的 WARC 路径

CC_INDEX_URL = "https://index.commoncrawl.org/collinfo.json"

def get_snapshot_urls(self) -> list[str]:
    resp = requests.get(CC_INDEX_URL, timeout=30)
    collections = resp.json()

    urls = []
    for collection in collections:
        cid = collection.get("id", "")          # 例如 "CC-MAIN-2024-10"
        if self._snapshot_in_range(cid):        # 判断是否落在 start~end 之间
            warc_paths_url = f"https://data.commoncrawl.org/crawl-data/{cid}/warc.paths.gz"
            paths_resp = requests.get(warc_paths_url, timeout=60)
            with gzip.open(io.BytesIO(paths_resp.content), "rt") as f:
                for line in f:
                    path = line.strip()
                    if path:
                        urls.append(f"https://data.commoncrawl.org/{path}")
    return urls

关键点:warc.paths.gz 是一个 gzip 压缩的纯文本文件,每一行是一个 WARC 文件的相对路径。代码把它在内存里解压(io.BytesIO + gzip.open),逐行读出,拼出完整的下载 URL。

而「快照是否在范围内」的判断,靠的是一段字符串比较:

def _snapshot_in_range(self, collection_id: str) -> bool:
    parts = collection_id.split("-")
    if len(parts) >= 4 and parts[0] == "CC":
        snapshot = f"{parts[2]}-{parts[3]}"      # 抽出 "2024-10"
        return self.start_snapshot <= snapshot <= self.end_snapshot

因为快照 ID 是年月格式的字符串(2024-10),所以 Python 的字典序比较恰好就等于时间顺序比较——一个看似偷懒、实则正确且优雅的小技巧。

第二步:遍历 WARC,只挑出 HTML 的 response 记录

WARC(Web ARChive)是一种专门用于网页归档的容器格式,一个文件里混装着多种记录类型:request(请求)、response(响应)、metadata(元数据)等。我们要的只有 response,而且还要进一步过滤出 Content-Type 为 HTML 的:

def _iterate_warc(self, url: str):
    from warcio.archiveiterator import ArchiveIterator
    resp = requests.get(url, stream=True, timeout=120)

    for record in ArchiveIterator(resp.raw):
        if record.rec_type != "response":        # 只要响应记录
            continue
        content_type = record.http_headers.get_header("Content-Type") or ""
        if "text/html" not in content_type:      # 只要 HTML
            continue
        html = record.content_stream().read().decode("utf-8", errors="replace")
        text = self.extract_text(html)
        if text:
            yield {
                self.text_field: text,
                "url": record.rec_headers.get_header("WARC-Target-URI") or "",
                "source_id": f"cc-{self.start_snapshot}",
            }

几个细节:

  • warcio 库的 ArchiveIterator 流式解析,配合 requests.get(..., stream=True)边下载边解析,而不是把整个(可能几十 GB 的)WARC 文件先全部下到内存里。
  • 解码用 errors="replace":遇到非法字节序列就用替换符顶替,绝不因为个别坏字节让整个解析崩溃
  • 每条记录除了正文 text,还额外带上了原始 url 和一个 source_id(标记它来自哪个快照),方便后续溯源和审计。

第三步:HTML → 纯文本

这是整条链路里最「有学问」的一步。HTML 里夹杂着导航栏、页脚、广告、脚本,怎么抽出真正属于「正文」的那部分?代码里支持三种策略,由配置的 html_extractor 决定:

def extract_text(self, html: str) -> str | None:
    if self.html_extractor == "trafilatura":
        return self._extract_trafilatura(html)
    elif self.html_extractor == "justext":
        return self._extract_justext(html)
    else:
        return self._extract_basic(html)

trafilatura(推荐):专门为网页正文抽取设计的库,综合了 DOM 分析、文本密度、链接密度等多重信号,抽取质量最好。

jusText:基于「正文段落 vs 样板段落」的分类思想——正文段落通常标点丰富、链接稀疏,样板段落(导航、页脚)则链接密集。它用一个停用词表和启发式规则把段落分类,只保留非样板的部分:

def _extract_justext(self, html: str) -> str | None:
    import justext
    paragraphs = justext.justext(
        html.encode("utf-8"), justext.get_stoplist("English")
    )
    text_parts = [p.text for p in paragraphs if not p.is_boilerplate]
    return "\n\n".join(text_parts) if text_parts else None

基本回退:如果上面两个库都没装,还有一个「保底」的正则方案——去掉 <script>/<style> 块、剥掉所有标签、压缩空白:

def _extract_basic(self, html: str) -> str | None:
    text = re.sub(r"<script[^>]*>.*?</script>", "", html, flags=re.DOTALL)
    text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL)
    text = re.sub(r"<[^>]+>", " ", text)      # 剥标签
    text = re.sub(r"\s+", " ", text).strip()
    return text if len(text) > 50 else None   # 太短视为无正文

有一个关键的工程判断藏在这三套策略里:正文抽取不应该用「手写正则」硬扛。正则只能做「剥标签」这种粗活,它没法理解「哪段是正文、哪段是导航」这种语义概念。所以代码把 trafilatura/jusText 这类专门算法设为默认,把正则方案降级为「最后的保底」。这一条原则,是每一个做过网页抓取的人用血泪换来的经验。


五、download_and_extract:把所有步骤串起来

下载器的入口方法把「取 URL 列表 → 逐个下载解析 → 汇总成 DataFrame」串成一条线:

def download_and_extract(self, urls=None) -> pd.DataFrame:
    if urls is None:
        urls = self.get_snapshot_urls()

    records = []
    for i, url in enumerate(urls):
        try:
            for record in self._iterate_warc(url):
                records.append(record)
        except Exception as e:
            logger.warning("Failed to process %s: %s", url, e)
            continue

    return pd.DataFrame(records)

依然是那条「单点失败不中断」的原则:某个 WARC 文件下载超时、解压失败,打条 warning 就继续下一个,绝不让一个大语料的下载任务因为一个坏文件而整体报废。对于 Common Crawl 这种动辄几万个文件、且难免有零星损坏的数据源,这种健壮性不是锦上添花,而是能不能跑完的区别。


六、小结

数据采集这一阶段,表面上只是「把数据读进来」,但拆开后能看到两条截然不同的难度曲线:

  • 本地加载是「工程基本功」:glob 扫描、容错、字段校验、统一 DataFrame 出口。
  • Common Crawl 下载是「数据工程的核心手艺」:理解三跳寻址、流式解析 WARC、用专门的算法(而非正则硬扛)抽取正文。

而它们最终都收敛到同一个约定——一个带 text 列的 DataFrame。这个约定,正是让后面四个阶段能彼此解耦、各自独立进化的基石。

但读进来的文本,此刻还带着满身的乱码、换行和样板套话。下一步,就是让它们「变干净」。

下一篇:《文本清洗:Unicode 修复、换行规范化与 C4 清洗》。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值