系列第二篇。流水线的第一步永远是「把数据弄进来」。这一阶段处理两类来源:本地已有的 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
这里有几个值得注意的工程细节:
glob通配符扫描目录:Path(input_dir).glob("*.jsonl")一次性把一个目录下的所有文件都读进来,而不是要求你逐个指定。这意味着你可以把一个大语料切分成成千上万个 part 文件,放在一个目录里,让 loader 自动拼接。sorted保证顺序稳定:文件名排序后加载,结果可复现。- 容错不中断:单个文件加载失败(比如某个文件损坏)只会打一条 warning,不会让整条流水线崩掉。对几十万个小文件的语料来说,这种「能读多少读多少」的健壮性是刚需。
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 清洗》。

2350

被折叠的 条评论
为什么被折叠?



