原始网络抓取数据几乎从来不会以模型可直接使用的状态出现。抓取数千个产品页面、列表或文章,你得到的是重复行、存储为字符串的价格、五种格式的日期、空白单元格,以及充斥着 HTML 实体和杂乱空白的文本。将这些数据直接馈送给训练任务,模型学习噪声的速度与学习信号一样快。将抓取结果转化为数据集的工作就是数据清洗与结构化,而 AI 或 ML 流程的绝大部分准确性,正是在这里赢得或失去的。

本指南是针对 AI 场景的网络数据结构化与清洗实战演练:加载原始抓取结果、去重、规范化类型和格式、处理缺失值、进行基本的文本清洗和分词、设计下游代码可以依赖的模式,并在数据到达模型之前完成验证。每个步骤都附有可粘贴到 notebook 中运行的 Python 代码。最后我们会说明 Crawlbase 如何提前返回干净的结构化输出,减少你手动完成这些工作的量。

为何清洗和结构化决定模型质量

模型不像你一样推理数据。重复行会为某个样本增加额外权重。存储为 "$1,299.00" 的价格是一个字符串,模型无法将其与 1299.0 进行比较。在某些行写成 "03/04/2025"、在另一些行写成 "2025-04-03" 的日期会变成两个不相关的 token。这些问题没有一个会报错,这正是它们危险的原因:流程照常运行,指标看起来合理,而模型却在悄悄地从被破坏的世界观中学习。

清洗修复明显的损伤(重复、缺失值、格式不一致),结构化则强制执行一个契约:每一列有且只有一种类型、一个单位和一个含义。这个契约让同一份数据集既能今天馈送给分类器,明年又能馈送给另一个模型,没有任何意外。无论你是在数千行上做 用于机器学习的网络抓取,还是在数百万行上运行 大规模网络抓取任务,同样的原则都适用。

从真实的原始抓取数据开始

为使步骤具体可操作,假设你抓取了一批电商产品列表并将其写入 raw_products.csv。真实的抓取结果是杂乱的,所以下面的文件也是如此:重复行、price 中带货币符号和千位分隔符、混合日期格式、空白单元格,以及包含 HTML 实体和参差不齐空白的评论文本。

python
import pandas as pd

df = pd.read_csv("raw_products.csv")

# A first look at the damage before touching anything
print(df.shape)
print(df.dtypes)
print(df.isna().sum())
print(df.head())

dtypes 的输出是关键线索。如果 price 返回的是 object 而不是数字类型,说明 pandas 无法解析它,意味着该列中存在非数字的垃圾内容。提前运行 isna().sum() 可以告诉你哪些列有缺失值以及严重程度,这样你就能在编写任何转换之前决定要修复什么。

首先去重

去重应在所有其他操作之前进行,因为重复项会使后续的每个统计值都虚高。精确重复是最简单的情况:爬虫重复访问同一 URL 或在重叠窗口中分页时产生的完全相同的行。

python
# Drop fully identical rows
df = df.drop_duplicates()

# Deduplicate on a business key, keeping the most recent capture
df = (df.sort_values("scraped_at")
        .drop_duplicates(subset=["product_id"], keep="last"))

实践中第二种模式更重要。一旦价格发生变化,同一 product_id 的两次抓取就不再是完全相同的行,但你通常只需要每个产品一条记录,而不是两条。按抓取时间排序并保留 "last",可以获得最新版本。选择能真正标识你领域中实体的键(产品 ID、URL、SKU),而不是依赖全行相等。

先去重,后填充

这里的顺序不是装饰性的。如果你先填充缺失值再去重,你的填充均值是基于虚高的计数计算的,并且偏向于重复最多的那些实体。请务必在后续步骤依赖的任何统计量(均值、中位数、众数)之前先删除重复项。

规范化类型和格式

每个实体一行之后,让每一列都变成单一的、可预期的类型。这一步将 "$1,299.00" 转换为 1299.0,将五种日期格式统一为一种。货币字符串需要去掉符号和分隔符才能解析为数字;日期需要使用带 errors="coerce" 的单一解析器,使无法解析的乱码变成 NaT 而不是导致运行崩溃。

python
# Strip currency symbols and separators, then parse to float
df["price"] = (df["price"]
    .astype("string")
    .str.replace(r"[^\d.]", "", regex=True))
df["price"] = pd.to_numeric(df["price"], errors="coerce")

# Parse mixed date formats into one datetime type
df["listed_on"] = pd.to_datetime(
    df["listed_on"], errors="coerce"
)

# Normalize a categorical column: trim and lowercase
df["category"] = df["category"].str.strip().str.lower()

规范化分类变量是这里悄然取得的收益。抓取数据常常将 "Electronics""electronics ""ELECTRONICS" 作为同一类别的三个不同值。去除首尾空格并转为小写,就将它们合并为一个,从而得到更干净的分组结果,编码后也只有一个特征而不是三个。对单位也做同样的标准化:如果某些价格用美元,另一些用美分,现在转换为统一单位,趁你还记得哪个是哪个的时候。

有意识地处理缺失值

缺失数据没有通用规则,只有权衡。当缺口很少且该行没有该字段就毫无意义时,删除行是安全的。填充保留了行但创造了一个值,所以只有在下游需要该列且存在合理估计的情况下才有意义。按列逐一决策,而不是对整个数据框应用一个笼统的调用。

python
# Drop rows missing a field the record cannot exist without
df = df.dropna(subset=["product_id", "price"])

# Impute a numeric column with its median (robust to outliers)
df["rating"] = df["rating"].fillna(df["rating"].median())

# Fill a categorical with an explicit sentinel, not a guess
df["brand"] = df["brand"].fillna("unknown")

对于填充数值列,中位数优于均值,因为少数极端离群值(错误抓取的价格 99999)会拉动均值,但几乎不会移动中位数。对于分类变量,明确的 "unknown" 哨兵值是诚实的:它告诉模型该值缺失,而不是假装它属于最常见的那个类别。永远不要让下游编码器悄悄地将 NaN 当作真实类别处理。

清洗文本并分词

如果你的数据集包含自由文本(产品描述、评论、文章正文),它需要单独处理。抓取的文本包含 HTML 实体(&')、残留标签、URL 和不一致的空白。在分词之前先清洗它,否则 token 中会充满垃圾。此处的分词是指将文本拆分为模型消费的单元;下面的示例采用空白分词,足以说明必须在此之前进行的清洗工作。

python
import re
import html

def clean_text(value):
    if pd.isna(value):
        return ""
    text = html.unescape(str(value))   # & -> &
    text = re.sub(r"<[^>]+>", " ", text)   # strip tags
    text = re.sub(r"http\S+", " ", text)   # strip URLs
    text = re.sub(r"\s+", " ", text)         # collapse whitespace
    return text.strip().lower()

df["review_clean"] = df["review"].apply(clean_text)
df["tokens"] = df["review_clean"].str.split()

clean_text 中的顺序是刻意设计的:先反转义实体,这样 &amp;amp; 就变成 & 再执行任何正则表达式;接着去除标签和 URL;最后合并空白,使前几步留下的间隙得以闭合。最后的小写转换使 token 计数保持准确("Fast""fast" 变成同一个 token)。对于真实的 NLP 工作,你会将最后的 split 替换为专业的分词器,但上面的清洗是抓取数据始终需要的部分。

Crawlbase Crawling API

上面大部分实体去除操作的存在,是因为原始 HTML 抓取结果噪声很大。Crawling API 可以返回干净的结构化输出(包括页面的 Markdown 视图),使文本无需标签和样板文字即可到达,将清洗步骤缩减为类型规范化。在免费套餐对公开页面测试,与原始抓取结果进行对比。

设计模式并加以执行

到目前为止,清洗都是被动的。模式让它变成一个契约:一组声明的列,每列有一种类型,每个批次都必须满足。将模式编码为你要转换的类型(以及你要检查的断言),意味着下一次抓取要么符合要求,要么会大声失败,而不是悄悄漂移回你刚刚清洗过的那种混乱状态。

python
# A schema is just a column -> dtype contract
schema = {
    "product_id": "string",
    "category":   "category",
    "price":      "float64",
    "rating":     "float64",
    "brand":      "string",
    "listed_on":  "datetime64[ns]",
}

# Keep only schema columns, in order, and cast each one
df = df[list(schema.keys())].astype(schema)

选取 list(schema.keys()) 可以丢弃爬虫添加的任何多余列,并固定列顺序,使每次导出具有相同的结构。astype(schema) 调用会转换每一列,如果某个值无法强制转换则会抛出异常,这正是你希望的行为:现在大声失败胜过在训练运行后才发现损坏的列。对 category 等低基数字段使用 category dtype 还可以缩小内存占用,并加快对大型数据框的分组操作。

导出前验证

验证是"看起来干净"与"确实干净"之间的关卡。少量断言可以捕获那些悄无声息地毒害模型的失败:残留重复项、超出范围的数字、本应完整的列中的空值。在每个批次上运行它们,并在任何一项失败时停止流程。

python
def validate(frame):
    assert frame["product_id"].is_unique, "duplicate product_id"
    assert frame["price"].between(0, 100000).all(), "price out of range"
    assert frame["rating"].between(0, 5).all(), "rating out of range"
    assert frame[["product_id", "price"]].notna().all().all(), "unexpected nulls"
    return frame

df = validate(df)

范围检查很有价值:0 到 5 分制下评分为 50,或者负价格,几乎都是前面某个步骤的解析错误,而断言会在数据到达模型之前将其暴露出来。如果你对手写断言的需求超出范围,像 Pandera 或 Great Expectations 这样的模式验证库可以声明式地表达相同的规则,但上面的断言已经足以使流程变得可信赖。

导出干净的数据集

数据框经过去重、规范化、填充、模式转换和验证后,以保留类型的格式写出。CSV 具有可移植性,但类型全是字符串;Parquet 保留 dtype、压缩效果好、加载速度快,当你在结果上进行 AI 模型训练时,这一点尤为重要。

python
# Parquet preserves dtypes and is fast to reload
df.to_parquet("clean_products.parquet", index=False)

# CSV if you need maximum portability
df.to_csv("clean_products.csv", index=False)

这个文件现在是一个数据集,而不是一次抓取结果:每个实体一行,每列一种类型,没有残留重复项,缺失值经过有意处理,每个值都在其声明的范围内。从这里到模型,是熟悉的特征工程和训练/测试集划分的路径,其底层数据不会再给你惊喜。

让数据源返回更干净的数据

最快的清洗步骤是因为数据一开始就干净而跳过的那一步。上面大量工作(实体反转义、标签去除、空白合并)的存在,仅仅是因为你抓取了原始 HTML。Crawling API 可以返回页面的干净 Markdown 视图,使文本无需标签和样板文字即可到达;Crawling API 可自动将许多热门网站解析为结构化 JSON 字段,在 pandas 看到数据之前就消除了选择器编写和大部分类型猜测。当你需要轮换住宅 IP 而不需要自己管理代理池时,Smart AI Proxy 覆盖了这一侧面。

这些都不能消除去重、验证和执行模式的需求(这些是你的数据集的属性,而不是页面的属性),但它确实缩减了工作中噪声较多的前半部分。关于这个数据集的下一步去向,请参阅 AI 数据提取的工作原理,以及针对高吞吐量采集的 电商网络抓取模式。

回顾

核心要点

  • 首先去重。在任何统计量之前删除重复项,否则填充的均值会基于虚高、偏斜的计数计算。
  • 规范化类型和格式。将货币字符串转为浮点数,用 errors="coerce" 解析混合日期,并对分类变量进行去空格和小写处理。
  • 按列处理缺失值。删除缺少必要字段的行,用中位数填充数值列,用明确的 "unknown" 填充分类列。
  • 分词前先清洗文本。按顺序反转义实体、去除标签和 URL,然后合并空白。
  • 定义模式并验证。将每一列转换为声明的单一类型,并对每个批次断言唯一性、范围和非空,使坏数据大声失败。
  • 输入越干净,工作越少。Crawlbase 可以返回干净的或 Markdown 输出以及自动解析的 JSON,在 pandas 运行之前缩减清洗步骤。

常见问题

为什么在训练 AI 模型之前数据清洗如此重要?

因为模型学习数据中的一切,包括错误。重复项过度加权某些样本,字符串类型的价格无法比较,混合日期格式碎片化为不相关的值,空单元格会被编码器误读。这些都不会报错,所以流程照常运行,模型悄悄地在被破坏的视图上训练。清洗消除这些损伤,使模型学习信号而不是噪声。

应该先去重还是先处理缺失值?

先去重。如果在删除重复项之前填充缺失值,你用来填充的每个均值都是基于虚高的计数计算的,并且偏向于重复最多的那些实体。先删除精确重复项并基于业务键折叠(保留最新的抓取),然后再计算用于填充的中位数和众数。

如何决定是删除行还是填充缺失值?

按列决策。当缺失字段是记录无法缺少的字段时(如主键或目标值),且缺口很少时,删除行。当列在下游是必需的且存在合理估计时,进行填充:数值用中位数(因为它对离群值有鲁棒性),分类变量用明确的 "unknown" 哨兵值,这样缺失情况被记录而不是被猜测。

抓取数据最少需要做哪些文本清洗?

按顺序反转义 HTML 实体、去除残留标签和 URL,并将连续空白合并为单个空格,然后小写化。抓取的文本通常包含 &amp;、残留标记和参差不齐的间距,这些内容否则会变成噪声 token。这一步在空白分词之前已经足够;对于生产 NLP,之后你会换用专用分词器。

数据已经干净了,为什么还要强制执行模式?

因为除非有某种机制强制要求,下一个批次不会是干净的。模式为每列声明一种类型,并将每个批次转换为该类型,所以漂移的抓取结果(新列、突然无法解析的价格)会大声失败,而不是悄悄地重新引入你刚刚清除的混乱。它将清洗从一次性工作变成可重复的契约。

Crawlbase 能减少我需要做的清洗工作吗?

可以,对于噪声较多的前半部分。Crawling API 可以返回页面的干净 Markdown 视图,使文本无需标签或样板文字即可到达;Scraper API 可自动将许多热门网站解析为结构化 JSON,消除了选择器编写和大部分类型猜测。你仍然需要自己去重、验证和执行模式,因为这些是你的数据集的属性,而非来源页面的属性。

开始构建

大规模爬取任何站点,无需与基础设施对抗。

Crawlbase 负责处理代理、指纹和 CAPTCHA,让你的团队专注于交付数据流水线,而非维护爬取管道。1,000 次请求免费,无需信用卡。

自助开通 · 无需销售通话 · 提供企业级爬取量