ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

数据采集与清洗实战:从HTML解析到模块化复用

数据采集与清洗实战:从HTML解析到模块化复用

最近在整理之前跑过的一个数据采集与分析案例时,翻到了当时的项目笔记,里面有一个标记为“ch5_2”的章节。这个编号原本只是课程或者项目文档里的第五章第二节,但实际做下来发现,这一节涵盖的内容密度和踩坑数量,远超前面几节的总和。如果你也正在做数据采集、页面解析或者本地数据整理的工作,ch5_2这类章节往往就是整个项目的分水岭——跨过去了,后面分析部分就顺了;卡住了,整个进度都会被拖在这里。

这篇博文不打算复述某个具体课程的原文,而是从实战角度把ch5_2这类章节背后真正需要掌握的东西拆开讲清楚:数据怎么采、怎么存、怎么洗、怎么从“能跑的脚本”变成“可复用的工具”。里面所有代码和思路,都是我在实际项目中验证过、并且一直在沿用的方案,你可以直接照着改。

1. ch5_2在项目里的真实定位:从“拿数据”到“能用数据”

1.1 为什么单独一个章节的编号会如此重要

很多资料在划分章节时,前面的章节通常是在讲环境搭建、基本语法、请求发送方式、响应内容的查看方法。到第五章左右,才会真正进入“连续工作流”的状态——也就是把多个步骤串起来,解决一个完整的需求。ch5_2出现在这个位置,意味着它默认你已经掌握了最基础的操作,因此直接丢给你一个相对综合的任务:从目标页面拿到数据,处理成规整的结构,再保存下来。

以我当时的项目为例,需求是从某个公开信息站点采集一批列表页与详情页数据。前面几章的内容只用到了后半部分——封装请求函数、处理响应内容、解析目标字段,章节开头没有任何铺垫性的“科普”,上来就是实战。

这给我们的第一个启发是:碰到这种章节编号时,不要把注意力放在编号本身,而要关注它对应的任务边界。ch5_2的任务边界非常清晰:输入是一批URL,输出是一个结构化、无缺失、可直接入库的数据文件。中间经历的所有步骤——请求、解析、清洗、去重、异常兜底——都是为了把这份输出打磨到可用状态。

1.2 这一节要解决的核心矛盾:网页的“展示结构”与数据的“存储结构”

网页上的信息是给人看的,有样式、有排版、有嵌套;但数据分析工具或者数据库需要的是扁平的、明确字段名的表格。这个矛盾,是ch5_2这类章节存在的主要原因。

举个例子,一个详情页里,商品名称可能在<h1>标签里,价格在<span class="price">里,描述信息可能被拆成好几个段落,还夹杂着多余的空白和HTML标签。如果只是把整块HTML存下来,后续分析时要反复解析;如果把每个字段拆开但没做清洗,又会留下大量带标签、带转义字符、带冗余空格的脏数据。

所以ch5_2在实操中对应的就是一条“采集+清洗”管道:

  1. 构造合法请求,拿到HTML源码。
  2. 定位目标节点,提取原始字段。
  3. 清洗字段内容,统一格式。
  4. 组装成结构化记录,写入本地文件或数据库。

这几步听起来简单,但每一步都有各自的坑。后面几个部分我会按实际操作的顺序,把每一步的关键细节和踩坑经验展开来说。

2. 采集端的核心细节:让数据真正落到本地

2.1 请求构造不能只靠requests.get

很多初学者写采集脚本时,习惯直接requests.get(url)一把梭。ch5_2这种章节的任务通常页面结构不算复杂,但直接裸请求往往会遇到两个问题:第一,目标站点可能对没有浏览器特征的请求直接拒绝;第二,某些页面内容由异步请求动态加载,单纯的requests.get只能拿到一个空壳。

我在项目里统一维护了一个请求函数,核心逻辑是设置必要的请求头,尤其是User-Agent和Referer。这两个字段决定了服务器怎么看待你的请求——是当成普通浏览器访问,还是当成非浏览器程序。

import requests def fetch_page(url, headers=None, timeout=15): default_headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Connection": "keep-alive", } if headers: default_headers.update(headers) resp = requests.get(url, headers=default_headers, timeout=timeout) resp.raise_for_status() resp.encoding = resp.apparent_encoding return resp.text

这段代码里有几个细节值得展开说一下。

resp.encoding = resp.apparent_encoding这行非常关键。有些站点的响应头没有正确声明字符集,直接用默认编码解出来就是乱码;apparent_encoding会基于页面内容去推测实际编码,对于中文页面尤其重要。我在实际操作中碰到过一种情况:页面本身是UTF-8,但响应头写的是ISO-8859-1,不处理的话,后续所有解析结果全是乱码,排错也很难受。

timeout参数也不能省。没有超时控制的请求,遇到某个卡死的响应,可能让整个采集脚本挂住。尤其是在大批量采集时,一个请求卡住,后面的流程全被堵死。

2.2 动态加载内容怎么处理

ch5_2对应的任务如果涉及列表页翻页,往往还会遇到“下一页的内容不是直接写在HTML里,而是通过JavaScript额外请求接口获取”的情况。页面源码里能看到列表框架,但具体的列表项都是JS跑完之后从接口拿到的。

判断方法很简单:用requests拿到HTML后,搜索你期望看到的数据片段,如果找不到,大概率是动态加载。这时候不能硬刚HTML解析,而应去浏览器的开发者工具里找真正的数据接口,直接请求那个接口。

这类接口通常返回JSON格式的数据,解析起来比HTML还简单,字段还更规整。我在项目里有一个实际案例:原始页面直接采集只能拿到约三分之一的字段,剩余字段全部在另一个JSON接口里。把接口地址抓出来后,整个采集效率提升了一倍不止,字段完整度也显著提高。

需要提醒的是,接口请求有时需要额外的动态参数,比如时间戳或者签名。遇到这种情况,先看这些参数是否固定,如果会变化,再去逐步定位参数生成逻辑。多数ch5_2级别的任务不会涉及特别复杂的加密签名,但动态参数的场景很常见。

2.3 批量采集时最容易忽略的节奏控制

数据量小的时候,密集请求无所谓;数据量一大,没有节奏控制的脚本很容易触发目标站点的限流策略。ch5_2的实战任务往往是一个“量变引起质变”的节点:单页采集没问题,连续采几十页就开始出现超时、验证码、请求被拒绝。

我的经验是,在循环请求之间主动增加延时,并且把延时设置为一小段范围内的随机值,而不是固定值。固定延时容易被识别成机器行为,随机延时更接近人工操作的节奏。

import time import random def polite_sleep(min_sec=1.5, max_sec=3.5): time.sleep(random.uniform(min_sec, max_sec))

这里还有一个容易被忽略的点:如果采集过程因异常中断,已经抓到的数据不要丢。我在脚本里每处理完一条记录就立即写入文件,而不是全部跑完再写。原因是采集任务动辄几十分钟甚至几小时,中途一旦断网、断电、被限流,全部缓存在内存里的数据可能瞬间清零。边采边写的策略虽然会让磁盘多几次写入,但换来的是任务的可恢复性。

import csv def append_record_csv(filepath, record): with open(filepath, "a", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=list(record.keys())) writer.writerow(record)

每轮循环把记录追加进CSV,后续清洗阶段再整体读入处理。这种做法在实操中帮我挽回过不止一次数据——脚本跑到后半段因为网络波动崩了,但前面积累的数据都在文件里。

3. 清洗端的核心操作:把脏数据变成可分析的结构化字段

3.1 清洗的目标不是“看着干净”,而是“能被程序正确处理”

采集到手的原始字段,表面问题很多:HTML标签残留、空白字符、全角半角混用、缺失值、重复记录、格式不统一。这些问题的共同点是“人眼看不出大毛病,但程序一跑就出错”。

比如“价格”字段,页面显示是“¥1,299.00”,如果不去掉货币符号和千分位分隔符,直接存成字符串,后续做数值统计时就会报错。又比如“发布时间”可能是“2024/01/15”“2024-01-15”“2024年1月15日”三种格式并存,不统一的话,无法排序,也无法按时间筛选。

所以ch5_2的清洗环节,核心思路是给每个字段定义“目标格式”,然后写对应的清洗规则。

我总结了一个清洗顺序,按这个顺序处理,能减少返工:

  1. 去除HTML标签与实体(如果有)。
  2. 去除首尾与内部多余空白。
  3. 统一大小写与全半角。
  4. 按字段语义做格式转换(时间、数字、百分比等)。
  5. 缺失值填充或标记。
  6. 去重与校验。

3.2 一个典型字段的完整清洗过程

以文本类字段为例。假设目标是一个“商品标题”字段,原始HTML可能是这样的:

<h1 class="product-name"> 高性价比 无线机械键盘 (茶轴) \n</h1>

直接提取出来,字符串两端有空格,中间有多余空格,还有全角括号。清洗逻辑可以这样写:

import re import html as html_lib def clean_text(raw): # 去除HTML标签 text = re.sub(r"<[^>]+>", "", raw) # 反转义HTML实体 text = html_lib.unescape(text) # 去除两端空白与控制字符 text = text.strip() # 合并多个连续空白为一个空格 text = re.sub(r"\s+", " ", text) # 括号统一为半角 text = text.replace("(", "(").replace(")", ")") # 去除空格与括号之间的多余空隙 text = re.sub(r"\s*([()])\s*", r"\1", text) return text

每一步都对应一类真实会遇到的脏数据问题。html.unescape处理的是像&nbsp;、&amp;这类实体,正则合并空白处理的是标签剪贴后留下的乱格式。实际项目里,字段不同,正则细节也不同,但思路是完全一致的。

对于数值型字段,比如价格,处理方式又不一样。我使用时先提取数字和符号,再去掉逗号,最后转成浮点数:

def clean_price(raw): if not raw: return None text = str(raw).replace(",", "").replace("¥", "").strip() match = re.search(r"-?\d+(\.\d+)?", text) return float(match.group(0)) if match else None

为什么用正则去匹配而不是直接转float?因为原始字符串里可能混有各种干扰字符,直接转换会抛异常,正则匹配的方式更稳健。

3.3 缺失值、重复记录、编码问题的处理策略

这三个问题在ch5_2的实战任务中出现频率极高,而且处理不当会直接影响后期分析的准确性。

缺失值方面,我的处理原则是“能补则补,不能补则明确标记”。比如列表页与详情页都有标题字段,列表页抓到的可以用于回填详情页缺失的情况;再比如字段缺失如果是页面结构变化导致,可以在日志里记录下这条记录和缺失字段,方便回溯。

重复记录去重时,最好先用“唯一键”逻辑去重,而不是对整个记录做完全匹配。比如每条记录的业务编号就是唯一键,只要编号相同就认为是同一条记录,保留先采集到的版本。我在项目中通常会记录去重前后的记录数,方便评估数据质量。

def deduplicate(records, key_field): seen = set() result = [] for record in records: key = record.get(key_field) if key in seen: continue seen.add(key) result.append(record) return result

编码问题前面提过一嘴,但清洗阶段还会遇到另一个层面:文件写出时,用utf-8加errors="ignore"兜底。我在实际项目中碰到过一条记录里带了一个非法字节,结果整个CSV文件写入失败的情况。加上错误处理参数后,至少保证写入过程不会中断。

4. 一次典型的排错实录:ch5_2中的头部字段与结构变化陷阱

4.1 遇到的第一个怪象:同一套解析代码,上午能跑通,下午就抓不到内容了

这个项目做了几天后,某天下午接到反馈:同样的URL,同样的解析函数,输出文件里大量字段变成了空值。我立刻去复现,发现页面HTML结构和上午相比有了一处调整——原本的标题节点从<h1 class="name">变成了<div class="title">。

这种页面微调在采集场景里非常常见。页面改版、A/B测试、甚至不同时间段返回不同模板,都可能导致解析规则失效。最麻烦的是,它不会让程序报错,程序正常运行,但字段全是空值。如果清洗环节没有做空值校验,这些脏数据会直接进入后续分析。

解决思路分两层:

第一层,解析函数要做好“多选择器兜底”。对于一个目标字段,不写死单个选择器,而是准备一组候选选择器,依次尝试提取。

def extract_value(soup, candidates): for selector in candidates: elem = soup.select_one(selector) if elem: return elem.get_text(strip=True) return ""

第二层,增加“解析后校验”。每条记录解析完后,检查关键字段是否为空,如果为空,记录一条告警日志并停止后续步骤,而不是继续闷头跑。实测下来,这种防御式处理能省下大量事后排查的时间。

4.2 排查的思路和工具:别靠猜,靠对比

面对“抓不到内容”的问题,我惯用的排查流程是:

  1. 用浏览器打开目标URL,查看页面实际结构。
  2. 用requests拿到源码,对比浏览器渲染后的DOM,确认是动态加载还是结构变化。
  3. 在解析脚本里临时打印前500字符,确认拿到的不是错误页、验证码页或空页面。
  4. 对比同一字段在不同页面上的选择器路径,找出变化规律。

这个流程看起来简单,但很多人在第一步就偷懒了,直接在脚本里反复调试,浪费大量时间。浏览器的开发者工具是排查这类问题的第一利器:查看网络请求、查看实际DOM、模拟不同设备访问,都能快速定位问题层面。

我在处理这个故障时,最终定位到的是“同一URL在不同来源下返回了不同模板”。绕开这个问题的方式是,将关键字段的选择器候选列表扩充,并让清洗模块对解析结果做空值检测,一旦检测到比例异常就告警。这个方案上线后,同样的问题没有再造成数据损失。

4.3 头字段缺失问题的完整修复方案

这里的“头字段”,指的是记录里的表头(列名)。因为输出文件用CSV格式保存,写入时需要统一字段名。某一天我发现,旧数据文件里有两列,新代码输出里变成了三列,列名还不一样。如果用旧工具读取新文件,列对不上,后续合并分析就乱了。

修复方案并不复杂,但很值得借鉴:不要依赖默认字段顺序,而是在写入CSV时显式指定字段顺序。

fieldnames = ["id", "title", "price", "publish_date", "source_url"] def write_records_csv(filepath, records, fieldnames): with open(filepath, "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore") writer.writeheader() writer.writerows(records)

这里有一个小技巧:写出时用utf-8-sig编码而不是utf-8。原因是CSV文件如果在Excel或某些Windows工具里直接打开,utf-8编码的中文会出现乱码,加上BOM(文件头标记)后兼容性会好很多。这个细节在项目交付时非常加分——对方直接双击文件就能看到正常中文。

4.4 数据校验环节如何自动化

经历了几次解析失效事故后,我养成了一个习惯:每次采集完成后,不直接认为数据可用,而是跑一个独立的校验脚本。

校验脚本做的事情很简单:

  • 检查记录总数是否在合理范围内。
  • 检查关键字段的缺失率是否超过阈值。
  • 检查数值字段是否都能正常转换。
  • 检查唯一键是否有重复。

如果以上任意一项异常,程序就返回失败状态,并且输出具体是哪个文件、哪项指标异常。这个“最后一道防线”帮我拦截过多次无效数据的生成,尤其是那些跑完几十分钟采集才发现规则已经失效的场景。

5. 从“跑通”到“好用”:把ch5_2的代码改造成可复用模块

5.1 为什么说“跑通”不等于“完成”

很多人做到“脚本能出数”就觉得任务结束了,但在真实项目里这远远不够。ch5_2这类章节对应的场景,往往要求你在这份数据之上继续做分析、画图、出报告甚至建模。如果你的脚本只能“这次能用”,下次页面稍微一变就废掉,那后续分析的根基就不稳。

从“跑通”到“好用”,我认为至少要满足三个条件:

  1. 代码可复用:换一批URL、换一个站点,改动量很小。
  2. 过程可观测:跑了多少条、失败多少条、哪些页面解析失败,都有日志。
  3. 失败可恢复:中断后能断点续采,不重复也不遗漏。

这三条在实际项目中比“一次跑通”重要得多。因为真实采集任务几乎不可能一次跑完不报错,一个完整的采集管道,本质上是为“可控的失败”而设计的。

5.2 用函数封装把每一环拆出来

把采集、清洗、保存、校验四个环节分别封装成独立模块,是提升可复用性最有效的手段。

我当时在项目里把代码分成四个部分:

# collector.py def collect_pages(urls): ... # cleaner.py def clean_record(raw_record): ... # storage.py def save_to_csv(records, filepath, fieldnames): ... # validator.py def validate_output(filepath, rules): ...

每个模块只负责自己的事,模块之间通过简单的数据结构(字典、列表)传递数据。好处是,页面结构变了,只需要改collector里的解析选择器;字段格式变了,只需要改cleaner里的清洗规则。

这种结构的烂大街程度很高,但真的照着做的项目没那么多。很多人习惯把所有逻辑堆在一个几百行的脚本里,改一个地方就要小心连坐。拆分之后,哪怕只是清理几页数据的小任务,我也推荐至少把“采集”和“清洗”分开,因为这两部分的改动频率最高,耦合在一起很容易互相拖累。

5.3 配置与代码分离:不用改代码就能调整参数

再进阶一步,把采集目标、选择器、字段名、延时范围这些“会经常变的东西”放到配置文件里。这样,即使接手项目的人完全不懂代码,也能通过改配置完成一次新的采集任务。

import json with open("config.json", "r", encoding="utf-8") as f: config = json.load(f) urls = config["target_urls"] candidates = config["field_selectors"] fieldnames = config["fieldnames"] timeout = config["request_settings"]["timeout"]

配置化的思想在很多正式工程里都是基础要求,但个人脚本里很多人会忽略。但正是这样的“小工程思维”,决定了你的代码能不能从“自己用”变成“给别人用”。我在交付数据采集工具时,会把配置文件的结构和使用说明一并写好,对方拿到后只需要填目标URL列表和字段映射关系,就能跑起来。

5.4 日志记录的实用技巧

日志不是一个“装样子”的功能,它在排查问题时至关重要。但日志也不是什么都记,记多了反而干扰视线。

我实践下来最有价值的日志信息有这几类:

  • 每个URL的开始采集时间、结束采集时间、状态码。
  • 每条记录解析成功后对应的唯一键。
  • 清洗时被规则改写过的原始值。
  • 每次异常的具体堆栈与目标URL。

把这些信息按行追加到日志文件里,采集结束后,用关键字筛一遍日志,基本就能判断整个任务是否健康。

import logging logging.basicConfig( level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s", handlers=[ logging.FileHandler("crawler.log", encoding="utf-8"), logging.StreamHandler() ] )

注意FileHandler的encoding="utf-8",不设置的话,在Windows上日志文件会乱码。这是我在实际使用中吃过亏才记住的细节。

6. 我给后来者的一些实在建议:这类章节怎么提升学习转化率

6.1 不要只敲代码,要追问“每一步为什么这么做”

ch5_2这种章节,代码量通常不大,但每一步都有明确的目的。如果只是照着敲一遍,生成了一次数据,收获是很低的。我建议从几个角度追问自己:

这一步解答了什么问题?如果数据源的返回格式变化,这一段代码还能用吗?如果把目标站点换掉,哪些部分是通用的,哪些是专用的?

这些问题想清楚了,你学到的就不只是一份代码,而是一个可迁移的解决问题的框架。后面的项目遇到类似需求,你可以做到“心里有底”,而不是每次都要到处找现成代码。

6.2 建立自己的“常见脏数据修复速查表”

做数据清洗久了,你会发现很多问题反复出现:多的空格、不统一的日期、带货币符号的数字、HTML实体、缺失值等等。把这些问题和对应的处理代码整理成自己的速查表,效率提升非常明显。

我在项目里维护了一个清洗函数库,每个函数只做一件事,函数名直接说明用途:

def strip_html(text): ... def normalize_spaces(text): ... def unify_date(text): ... def remove_currency_symbol(text): ... def to_float(text): ...

下次遇到新的脏数据类型,就往这个库里面补充函数。时间久了,这个库比任何教程都更贴合你自己的实战需求。

6.3 维护一份“项目变更日志”

还有个习惯让我长期受益:每次在项目中做过关键修改,都顺手记录到文档里说明修改原因。比如“某字段选择器从.price改成了.sale-price,因为页面改版了”“某清洗规则增加了全角转半角,因为上游数据格式不统一”。

这份变更日志在遇到下面这些情况时会非常有价值:数据出了问题追溯原因、代码交接给其他人、过几个月自己回来看这个项目还能记得当时为什么这么做。很多项目几个月后回看,代码能看懂逻辑,但看不懂决策原因,变更日志就是补上这部分信息的好办法。

6.4 别忽略“数据好奇心”带来的机会

最后想多说一句:当你通过自己的脚本拿到一份结构化数据后,不要止步于此,花点时间去探索数据本身。比如字段之间的关联、不同类别的分布比例、某个月份的采集量变化。这些探索往往能给你带来新的理解和更好的分析思路。我在实际项目里就试过,原本只是做采集,因为多看了一眼数据分布,顺手发现了一个影响分析结果的重要规律,帮项目避免了方向上的偏差。

数据采集和清洗这个环节,表面上看是“体力活”,但其中的判断、决策、防御性设计,才是真正体现经验的部分。希望这篇关于ch5_2的实战拆解,能帮你在自己的项目里少走一些弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进