资讯详情

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

📅 2026/10/18 18:45:47 | 华诺云谱 👁 阅读
数据采集与清洗实战:从HTML解析到模块化复用
最近在整理之前跑过的一个数据采集与分析案例时翻到了当时的项目笔记里面有一个标记为“ch5_2”的章节。这个编号原本只是课程或者项目文档里的第五章第二节但实际做下来发现这一节涵盖的内容密度和踩坑数量远超前面几节的总和。如果你也正在做数据采集、页面解析或者本地数据整理的工作ch5_2这类章节往往就是整个项目的分水岭——跨过去了后面分析部分就顺了卡住了整个进度都会被拖在这里。这篇博文不打算复述某个具体课程的原文而是从实战角度把ch5_2这类章节背后真正需要掌握的东西拆开讲清楚数据怎么采、怎么存、怎么洗、怎么从“能跑的脚本”变成“可复用的工具”。里面所有代码和思路都是我在实际项目中验证过、并且一直在沿用的方案你可以直接照着改。1. ch5_2在项目里的真实定位从“拿数据”到“能用数据”1.1 为什么单独一个章节的编号会如此重要很多资料在划分章节时前面的章节通常是在讲环境搭建、基本语法、请求发送方式、响应内容的查看方法。到第五章左右才会真正进入“连续工作流”的状态——也就是把多个步骤串起来解决一个完整的需求。ch5_2出现在这个位置意味着它默认你已经掌握了最基础的操作因此直接丢给你一个相对综合的任务从目标页面拿到数据处理成规整的结构再保存下来。以我当时的项目为例需求是从某个公开信息站点采集一批列表页与详情页数据。前面几章的内容只用到了后半部分——封装请求函数、处理响应内容、解析目标字段章节开头没有任何铺垫性的“科普”上来就是实战。这给我们的第一个启发是碰到这种章节编号时不要把注意力放在编号本身而要关注它对应的任务边界。ch5_2的任务边界非常清晰输入是一批URL输出是一个结构化、无缺失、可直接入库的数据文件。中间经历的所有步骤——请求、解析、清洗、去重、异常兜底——都是为了把这份输出打磨到可用状态。1.2 这一节要解决的核心矛盾网页的“展示结构”与数据的“存储结构”网页上的信息是给人看的有样式、有排版、有嵌套但数据分析工具或者数据库需要的是扁平的、明确字段名的表格。这个矛盾是ch5_2这类章节存在的主要原因。举个例子一个详情页里商品名称可能在h1标签里价格在span classprice里描述信息可能被拆成好几个段落还夹杂着多余的空白和HTML标签。如果只是把整块HTML存下来后续分析时要反复解析如果把每个字段拆开但没做清洗又会留下大量带标签、带转义字符、带冗余空格的脏数据。所以ch5_2在实操中对应的就是一条“采集清洗”管道构造合法请求拿到HTML源码。定位目标节点提取原始字段。清洗字段内容统一格式。组装成结构化记录写入本地文件或数据库。这几步听起来简单但每一步都有各自的坑。后面几个部分我会按实际操作的顺序把每一步的关键细节和踩坑经验展开来说。2. 采集端的核心细节让数据真正落到本地2.1 请求构造不能只靠requests.get很多初学者写采集脚本时习惯直接requests.get(url)一把梭。ch5_2这种章节的任务通常页面结构不算复杂但直接裸请求往往会遇到两个问题第一目标站点可能对没有浏览器特征的请求直接拒绝第二某些页面内容由异步请求动态加载单纯的requests.get只能拿到一个空壳。我在项目里统一维护了一个请求函数核心逻辑是设置必要的请求头尤其是User-Agent和Referer。这两个字段决定了服务器怎么看待你的请求——是当成普通浏览器访问还是当成非浏览器程序。import requests def fetch_page(url, headersNone, timeout15): 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/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Connection: keep-alive, } if headers: default_headers.update(headers) resp requests.get(url, headersdefault_headers, timeouttimeout) 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_sec1.5, max_sec3.5): time.sleep(random.uniform(min_sec, max_sec))这里还有一个容易被忽略的点如果采集过程因异常中断已经抓到的数据不要丢。我在脚本里每处理完一条记录就立即写入文件而不是全部跑完再写。原因是采集任务动辄几十分钟甚至几小时中途一旦断网、断电、被限流全部缓存在内存里的数据可能瞬间清零。边采边写的策略虽然会让磁盘多几次写入但换来的是任务的可恢复性。import csv def append_record_csv(filepath, record): with open(filepath, a, newline, encodingutf-8) as f: writer csv.DictWriter(f, fieldnameslist(record.keys())) writer.writerow(record)每轮循环把记录追加进CSV后续清洗阶段再整体读入处理。这种做法在实操中帮我挽回过不止一次数据——脚本跑到后半段因为网络波动崩了但前面积累的数据都在文件里。3. 清洗端的核心操作把脏数据变成可分析的结构化字段3.1 清洗的目标不是“看着干净”而是“能被程序正确处理”采集到手的原始字段表面问题很多HTML标签残留、空白字符、全角半角混用、缺失值、重复记录、格式不统一。这些问题的共同点是“人眼看不出大毛病但程序一跑就出错”。比如“价格”字段页面显示是“¥1,299.00”如果不去掉货币符号和千分位分隔符直接存成字符串后续做数值统计时就会报错。又比如“发布时间”可能是“2024/01/15”“2024-01-15”“2024年1月15日”三种格式并存不统一的话无法排序也无法按时间筛选。所以ch5_2的清洗环节核心思路是给每个字段定义“目标格式”然后写对应的清洗规则。我总结了一个清洗顺序按这个顺序处理能减少返工去除HTML标签与实体如果有。去除首尾与内部多余空白。统一大小写与全半角。按字段语义做格式转换时间、数字、百分比等。缺失值填充或标记。去重与校验。3.2 一个典型字段的完整清洗过程以文本类字段为例。假设目标是一个“商品标题”字段原始HTML可能是这样的h1 classproduct-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加errorsignore兜底。我在实际项目中碰到过一条记录里带了一个非法字节结果整个CSV文件写入失败的情况。加上错误处理参数后至少保证写入过程不会中断。4. 一次典型的排错实录ch5_2中的头部字段与结构变化陷阱4.1 遇到的第一个怪象同一套解析代码上午能跑通下午就抓不到内容了这个项目做了几天后某天下午接到反馈同样的URL同样的解析函数输出文件里大量字段变成了空值。我立刻去复现发现页面HTML结构和上午相比有了一处调整——原本的标题节点从h1 classname变成了div classtitle。这种页面微调在采集场景里非常常见。页面改版、A/B测试、甚至不同时间段返回不同模板都可能导致解析规则失效。最麻烦的是它不会让程序报错程序正常运行但字段全是空值。如果清洗环节没有做空值校验这些脏数据会直接进入后续分析。解决思路分两层第一层解析函数要做好“多选择器兜底”。对于一个目标字段不写死单个选择器而是准备一组候选选择器依次尝试提取。def extract_value(soup, candidates): for selector in candidates: elem soup.select_one(selector) if elem: return elem.get_text(stripTrue) return 第二层增加“解析后校验”。每条记录解析完后检查关键字段是否为空如果为空记录一条告警日志并停止后续步骤而不是继续闷头跑。实测下来这种防御式处理能省下大量事后排查的时间。4.2 排查的思路和工具别靠猜靠对比面对“抓不到内容”的问题我惯用的排查流程是用浏览器打开目标URL查看页面实际结构。用requests拿到源码对比浏览器渲染后的DOM确认是动态加载还是结构变化。在解析脚本里临时打印前500字符确认拿到的不是错误页、验证码页或空页面。对比同一字段在不同页面上的选择器路径找出变化规律。这个流程看起来简单但很多人在第一步就偷懒了直接在脚本里反复调试浪费大量时间。浏览器的开发者工具是排查这类问题的第一利器查看网络请求、查看实际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, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnamesfieldnames, extrasactionignore) 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这类章节对应的场景往往要求你在这份数据之上继续做分析、画图、出报告甚至建模。如果你的脚本只能“这次能用”下次页面稍微一变就废掉那后续分析的根基就不稳。从“跑通”到“好用”我认为至少要满足三个条件代码可复用换一批URL、换一个站点改动量很小。过程可观测跑了多少条、失败多少条、哪些页面解析失败都有日志。失败可恢复中断后能断点续采不重复也不遗漏。这三条在实际项目中比“一次跑通”重要得多。因为真实采集任务几乎不可能一次跑完不报错一个完整的采集管道本质上是为“可控的失败”而设计的。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, encodingutf-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( levellogging.INFO, format%(asctime)s | %(levelname)s | %(message)s, handlers[ logging.FileHandler(crawler.log, encodingutf-8), logging.StreamHandler() ] )注意FileHandler的encodingutf-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的实战拆解能帮你在自己的项目里少走一些弯路。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑