
做数据采集的时候真正花时间的往往不是“把网页请求下来”这一步而是请求下来之后那堆混合着 HTML 标签、空格、换行、单位符号的原始字符串怎么变成一张能直接交给 pandas、Excel 或数据库的规整表格。这篇内容属于 Python 小爬虫系列的第三篇目标是用一段可以完整复现的代码把数据采集与数据处理这条链路跑通构造请求、解析页面、提取字段、清洗数据、落地成文件。学完这个最小闭环后面再遇到列表页、详情页、分页采集和增量更新都能在这个基础上扩展。很多初学者只写到“打印出 title 和 price”就停住了结果换一个网站、多几条脏数据代码就崩。真正的工程化爬虫不是把数据抓下来而是把抓下来的数据变成可信赖的数据资产。所以这一篇会重点讲三件事怎么用 requests 和 BeautifulSoup 稳定提取字段怎么用 pandas 做清洗、去重和类型转换怎么把结果输出成 CSV 和 Excel。同时会说明本地测试页、分页处理、日志与断点续采、常见报错排查以及生产环境中必须补齐的部分。1. 先理解爬虫与数据处理的分工1.1 一条完整的数据采集链路应该是什么样一个成熟的爬虫任务至少包含五个阶段任务定义、页面请求、内容解析、数据清洗、结果落地。很多人把“爬虫”等同于“请求网页”实际上请求只占整条链路的很小一部分。任务定义明确要采集哪些字段、字段类型、采集频率、数据量级。页面请求用 HTTP 客户端获取 HTML、JSON 或文件内容。内容解析从 HTML 中定位目标节点提取结构化字段。数据清洗处理缺失值、重复值、类型转换、单位统一。结果落地写入 CSV、Excel、数据库或消息队列供下游使用。如果只做前两步得到的是离散的网页字符串做完后三步才能得到可分析、可展示、可入库的数据。数据处理在这里不是爬虫的“附加功能”而是爬虫能否真正投入使用的关键环节。1.2 为什么把“清洗”放到爬虫代码里做有些团队选择先把原始 HTML 全部存下来后面再异步清洗。这种方式适合超大规模采集但对个人项目和小型数据任务来说成本偏高。直接在爬虫代码里做清洗有四个实际好处尽早暴露字段问题。如果页面结构变了解析阶段就能发现而不是等清洗任务跑完才发现数据全是空值。减少存储成本。清洗后只保留有效字段原始 HTML 体积大、噪声多全量保存浪费磁盘。下游直接用。数据落地后可以立刻做统计、画图或导入报表无需二次处理。便于调试。解析和清洗写在一起用少量样本页就能验证逻辑是否正确。要注意的是清洗逻辑一定要独立成函数。不要把所有逻辑都堆在一个 for 循环里否则字段一变排查成本会非常高。1.3 本文要实现的最小闭环这篇文章的演示项目是一个简单的书籍信息采集任务。我们会先准备一个本地测试页面里面包含若干条书籍记录每条记录有书名、作者、价格、出版日期和评分。然后写一个爬虫脚本从页面中提取这些字段组装成 DataFrame依次完成清洗、去重、类型修正最后导出为 CSV 和 Excel 两个文件。之所以用本地页面而不是直接抓线上网站是因为本地页面稳定、可控、不涉及对方服务器压力适合验证流程。等整个流程跑通后再根据目标网站的实际结构替换选择器和 URL 规则。2. 环境准备与依赖版本2.1 Python 版本与虚拟环境本示例代码基于 Python 3.10 以上版本编写使用到的新语法主要是类型注解Python 3.8 以上基本都能运行但建议新项目直接使用 3.11 或 3.12。创建独立虚拟环境可以避免依赖冲突python -m venv venv source venv/bin/activateWindows 环境激活命令是venv\Scripts\activate。激活后确认解释器路径避免后面安装的包装到了全局环境which python python --version2.2 依赖库选型与版本参考本次需要四个库requests 负责发送 HTTP 请求beautifulsoup4 负责解析 HTMLpandas 负责数据处理openpyxl 负责 Excel 写入。库名称用途常见稳定版本区间requests发送 HTTP 请求处理响应2.31.xbeautifulsoup4HTML 解析与节点定位4.12.xpandas数据清洗、转换、去重2.1.x 以上openpyxl配合 pandas 导出 Excel3.1.x安装命令pip install requests beautifulsoup4 pandas openpyxl注意pandas 依赖的底层库较多安装速度慢是正常现象。如果安装失败优先检查 Python 版本是否过旧以及 pip 源是否可用。生产环境建议固定版本号并生成 requirements.txt。2.3 项目目录结构设计推荐把爬虫任务拆分成独立模块而不是只写一个main.py。下面是一个适合中小型任务的结构book_spider/ ├── venv/ # 虚拟环境 ├── data/ # 输出文件目录 │ ├── books.csv │ └── books.xlsx ├── logs/ # 日志目录 │ └── spider.log ├── pages/ # 本地测试页面 │ └── books.html ├── spider.py # 主脚本 └── requirements.txt # 依赖清单这个结构的好处是页面资源、输出结果、日志和代码分离。后续如果采集源增加可以按“每个站点一个模块”的方式扩展。3. 构造一个可本地运行的测试页面3.1 为什么建议先在本地跑通直接对线上网站写爬虫会遇到三个不确定因素网站改版导致选择器失效、访问频率触发限制、网络波动导致请求超时。本地测试页面没有这些问题网络请求也是发往本机服务响应速度极快非常适合反复调试解析逻辑。本地页面还能故意塞入一些脏数据比如价格字段带单位、出版日期缺年份、评分字段为空等用来验证清洗逻辑是否可靠。这些情况在真实网站里几乎一定会出现。3.2 本地 HTML 示例在项目目录下创建pages/books.html内容如下!DOCTYPE html html langzh-CN head meta charsetUTF-8 title本地图书列表/title /head body div classbook-list div classbook-item h3 classtitlePython编程从入门到实践/h3 span classauthorEric Matthes/span span classprice89.0元/span span classdate2020-10/span span classrating9.1/span /div div classbook-item h3 classtitle流畅的Python/h3 span classauthorLuciano Ramalho/span span classprice139.0元/span span classdate2017-05/span span classrating9.4/span /div div classbook-item h3 classtitle数据结构与算法分析/h3 span classauthorMark Allen Weiss/span span classprice79.00元/span span classdate2016-11/span span classrating/span /div div classbook-item h3 classtitlePython编程从入门到实践/h3 span classauthorEric Matthes/span span classprice89.0元/span span classdate2020-10/span span classrating9.1/span /div /div /body /html这个页面故意设置了几个坑价格有的是89.0元有的是79.00元格式不统一第三本书的评分是空标签第四本书和第一本完全重复。后面清洗阶段会处理这些情况。3.3 启动本地静态服务Python 自带 HTTP 服务器可以在项目根目录启动cd book_spider python -m http.server 8000启动后访问http://localhost:8000/pages/books.html可以看到页面内容。请求发往本机不会产生任何外部访问适合作为爬虫开发期的测试目标。实际项目改为线上 URL 时要重新评估对方网站的访问政策和压力承受能力。4. 实现基础爬虫请求发送与 HTML 解析4.1 requests 请求模块的正确用法发送 GET 请求并获取页面内容核心是四件事设置超时、指定 User-Agent、检查状态码、正确解码。下面是一个基础封装import requests def fetch_html(url: str) - str: headers { User-Agent: ( Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36 ) } resp requests.get(url, headersheaders, timeout10) resp.raise_for_status() resp.encoding resp.apparent_encoding return resp.text这里有几个关键点timeout10同时设置连接超时和读取超时避免某个页面卡死导致整个程序挂住。raise_for_status()在返回状态码为 4xx 或 5xx 时抛出异常防止拿错误页面继续解析。resp.encoding resp.apparent_encoding用于处理没有声明字符集或声明错误的页面。本地页面声明了 UTF-8直接使用也可以但真实站点经常需要这一步。4.2 BeautifulSoup 定位目标节点BeautifulSoup 的核心能力是帮你从 HTML 里“按条件找节点”。常用方法有find、find_all、select和select_one。其中select和select_one使用的是 CSS 选择器写起来更直观。from bs4 import BeautifulSoup def parse_books(html: str): soup BeautifulSoup(html, html.parser) items soup.select(div.book-item) rows [] for item in items: title_node item.select_one(h3.title) author_node item.select_one(span.author) price_node item.select_one(span.price) date_node item.select_one(span.date) rating_node item.select_one(span.rating) rows.append({ title: title_node.text.strip() if title_node else , author: author_node.text.strip() if author_node else , price: price_node.text.strip() if price_node else , date: date_node.text.strip() if date_node else , rating: rating_node.text.strip() if rating_node else , }) return rows使用html.parser是 Python 内置解析器不需要额外安装 lxml。如果目标页面结构非常复杂可以换成lxml解析器解析速度更快容错性也更好soup BeautifulSoup(html, lxml)这里要注意一个常见坑孤立地使用find(span).text有可能拿到错误节点的内容。一定要给每个字段明确选择器并处理节点不存在的情况。上面代码中if title_node else 就是兜底逻辑。4.3 提取字段并组装 DataFrame把解析结果交给 pandas是为了方便后续清洗。先用简单的列表构造 DataFrameimport pandas as pd rows parse_books(fetch_html(http://localhost:8000/pages/books.html)) df pd.DataFrame(rows) print(df)此时输出还是带单位、带空格、有空值的原始数据。下一步开始清洗。5. 数据处理清洗、转换、去重5.1 常见脏数据长什么样网络页面提取出来的字段通常有以下几类问题问题类型示例影响字符串混入单位89.0元无法做数值计算数值格式不统一79.00与89.0展示不一致空值评分标签为空统计时缺失重复记录同一本书出现两次计数翻倍日期不完整2020-10只有年月无法直接做时间序列首尾空白Python编程分组和比较失效清洗的目标不是把所有数据变成同一种形态而是让每种字段都符合它的业务含义价格是数值评分是数值日期可以排序文本字段没有多余空白。5.2 用 pandas 完成清洗针对上面的测试页清洗逻辑可以分为四步去空白、价格转数值、评分处理空值、去重。def clean_books(df: pd.DataFrame) - pd.DataFrame: # 1. 字符串字段去首尾空格 text_cols [title, author] for col in text_cols: df[col] df[col].str.strip() # 2. 价格去掉单位并转成 float df[price] ( df[price] .str.replace(元, , regexFalse) .str.strip() .astype(float) ) # 3. 评分空值先用 0 填充再转数值类型 df[rating] ( df[rating] .replace(, pd.NA) .fillna(0) .astype(float) ) # 4. 按关键字段去重保留第一条 df df.drop_duplicates(subset[title, author]) return df.reset_index(dropTrue)这里有几个需要解释的细节str.replace(元, , regexFalse)中regexFalse表示按普通字符串替换而不是正则避免“元”字被理解成正则元字符。评分空值先替换成pd.NA再fillna(0)是为了避免把空字符串直接转 float 时报错。去重使用subset[title, author]因为相同书名可能出不同版本必须同时看作者才能判定是否重复。清洗后打印结果cleaned clean_books(df) print(cleaned) print(cleaned.dtypes)预期的输出是 3 行数据价格列是float64评分列也是float64重复的第四本书被删除。5.3 数据校验与异常值处理清洗完成后还要做校验。最直接的手段是看每个字段的取值范围、空值数量和唯一值数量print(cleaned.isna().sum()) print(cleaned.describe())describe()会输出数值列的计数、均值、最小值、最大值。如果发现价格为负数、评分为 0 或超过 10就要回到解析逻辑排查。评分为 0 可能是空值填充也可能是网站本身未提供评分这两种情况语义不同。严格来说空评分应该保留为空并在下游处理而不是一律填 0。演示代码中填 0 只是为了展示数值转换真实项目里要根据业务需求决定。6. 数据落地CSV 与 Excel 输出6.1 pandas 导出 Excel 需要确认的依赖pandas 写 CSV 不需要额外依赖写 Excel 需要 openpyxl。如果安装的是精简版 pandas可能缺少这个库导出时会报ModuleNotFoundError: No module named openpyxl。安装后可以用以下代码导出output_dir data cleaned.to_csv(f{output_dir}/books.csv, indexFalse, encodingutf-8-sig) cleaned.to_excel(f{output_dir}/books.xlsx, indexFalse, sheet_namebooks)注意 CSV 的编码使用utf-8-sig。如果使用普通utf-8Excel 打开 CSV 时中文可能显示为乱码因为部分版本的 Excel 默认按 ANSI 或 GBK 解码。utf-8-sig会写入 BOM 头Excel 可以正确识别。6.2 文件命名与输出目录管理固定文件名会导致每次运行覆盖上一次的结果。对于需要保留历史数据的任务建议按日期命名from datetime import datetime today datetime.now().strftime(%Y%m%d) cleaned.to_csv(f{output_dir}/books_{today}.csv, indexFalse, encodingutf-8-sig) cleaned.to_excel(f{output_dir}/books_{today}.xlsx, indexFalse, sheet_namebooks)输出目录要先创建否则会报FileNotFoundError。可以在脚本开头统一处理from pathlib import Path Path(data).mkdir(exist_okTrue) Path(logs).mkdir(exist_okTrue)6.3 增量采集与全量采集的选择演示脚本是全量采集每次运行都把页面里的数据全部抓下来。真实任务中页面数据会持续变化此时要考虑三种策略全量覆盖数据量小、页面变化不大时最简单。增量追加采集前先读历史文件与本次结果做差集只追加新增记录。幂等写入用数据库主键约束保证重复运行不会产生重复数据适合写入 MySQL、PostgreSQL 等场景。学习阶段先掌握全量流程进入生产前要确定主键字段和更新策略。比如书籍可以用 ISBN 作为唯一键如果没有 ISBN就只能用书名加作者组合。7. 分页采集与请求稳定性控制7.1 分页 URL 的构建方式真实网站几乎都有列表分页。常见形式有两种路径参数和查询参数。https://example.com/books/page/2 https://example.com/books?page2size20处理分页时不要手工拼接字符串用参数化方式更安全。requests 支持通过params传参def fetch_page(page: int) - str: params {page: page, size: 20} resp requests.get(https://example.com/books, paramsparams, timeout10) resp.raise_for_status() return resp.text分页的终止条件一般有几种返回的列表为空、页码超过总页数、返回码变成 404、超出网站规定的最大页数。稳妥做法是设置最大页数上限例如只抓前 100 页防止死循环。7.2 请求频率、User-Agent 与超时设置集中高频请求会给目标服务器造成压力也可能触发对方的风控。一个基础做法是在每次请求之间加入随机延时import random import time def polite_sleep(): time.sleep(random.uniform(1.0, 3.0))User-Agent 要设置成真实浏览器的值但不是用来“伪装绕过”而是让服务器识别这是一个普通请求。配合requests.Session可以复用连接减少 TCP 握手次数session requests.Session() session.headers.update({ User-Agent: Mozilla/5.0 ..., }) resp session.get(url, timeout10)7.3 日志与断点续采脚本跑到第 80 页时失败如果从头再来既费时间又加重对方服务器负担。两种改进方式记录进度和断点续采。记录进度最简单的办法是每完成一页写一行日志import logging logging.basicConfig( filenamelogs/spider.log, levellogging.INFO, format%(asctime)s %(levelname)s %(message)s, ) logging.info(fpage {page} done, collected {len(rows)} items)断点续采则是在启动时读取之前已经采集的页码或主键集合finished_pages set() # 从日志或状态文件恢复 # 每次请求前判断 page 是否在 finished_pages 中严格来说断点续采在服务端数据频繁变化时会有数据不一致的问题但对大多数列表页采集场景优势远大于风险。生产环境更推荐把任务状态写入数据库便于多机并行时统一调度。8. 常见问题与排查路径8.1 请求超时或连接失败现象是 requests 抛出requests.exceptions.ConnectTimeout或ConnectionError。排查顺序先确认 URL 本身能否用浏览器访问。检查本机网络是否能连通目标域名。确认timeout参数是否设置过小。查看是否被目标服务器拒绝观察响应状态码。如果之前请求频率过高先停止一段时间再测试。不要直接无限重试。建议使用有限次数的重试并在重试间隔中加入退避时间for attempt in range(3): try: resp session.get(url, timeout10) resp.raise_for_status() break except requests.RequestException as e: if attempt 2: raise time.sleep(2 * (attempt 1))8.2 解析结果为空现象是页面请求成功状态码 200但select返回空列表DataFrame 全是空值。可能原因和检查方式可能原因检查方式页面内容由 JavaScript 动态渲染查看 HTML 中是否真的包含目标标签搜索某个文本片段选择器写错在浏览器开发者工具中复制完整选择器再与代码对比页面被重定向到其他地址检查resp.url与请求 URL 是否一致反爬机制返回了验证页面打印响应文本前 500 个字符看是否包含验证关键词要特别说明的是如果页面内容由 JS 渲染requests 拿到的 HTML 里根本没有目标数据这时候应该换接口请求或者使用浏览器自动化工具而不是一直调整选择器。8.3 Excel 导出报错常见报错是ModuleNotFoundError: No module named openpyxl解决方式是安装 openpyxl。另一个可能问题是 sheet 名称长度超过 Excel 限制sheet_name不能超过 31 个字符且不能包含特殊字符。如果数据量特别大写 Excel 会明显变慢这是 Excel 格式本身的限制。大数据量场景优先输出 CSV 或 ParquetExcel 只用于少量展示数据。8.4 中文乱码问题请求阶段乱码resp.encoding设置不对改成resp.apparent_encoding后重新解析。CSV 用 Excel 打开乱码写入时使用encodingutf-8-sig。HTML 本身乱码确认网页声明的 charset 与服务器实际返回是否一致可以在响应头Content-Type中查看charset。9. 合规边界、生产环境要求与检查清单9.1 爬虫的合规前提爬虫采集不是一个“能访问就能抓”的操作。无论演示代码还是生产任务都要注意三点遵守目标网站的 robots.txt 规则和服务条款。只采集自己有权使用的数据不采集个人隐私、账号信息或受版权保护的内容。控制请求频率避免对目标服务器造成压力。本文的本地页面演示只用于学习不构成对任何线上网站的采集行为。替换成真实网站时需要自行确认合法性并对采集范围、字段用途和存储方式做完整评估。9.2 学习环境与生产环境的差异维度学习环境生产环境目标跑通流程稳定运行、可监控、可回滚请求本地页面一两次即可需要限速、重试、随机延时数据量几行到几十行可能百万行以上存储CSV/Excel数据库或数据仓库异常处理try 兜底即可完整日志、告警、任务调度配置写死在代码里外置配置文件或环境变量安全不涉及敏感字段脱敏、访问控制生产环境至少还要补齐失败任务的自动告警、数据质量检查、任务幂等性、采集结果与源站数据的一致性校验、代码版本管理。任何一个环节缺失都可能在长期运行后引发数据问题。9.3 可复用检查清单每次写完一个爬虫任务建议按下面的清单核对一遍是否设置了超时和有限重试是否对每个字段做了空值兜底是否处理了中文乱码和编码问题数值字段是否转成了正确的类型是否去除了重复记录输出文件是否带日期或版本标识是否有日志记录采集进度是否设置了分页上限和请求频率控制是否确认了目标网站的采集合规性生产环境是否做到配置外置、异常可追踪、结果可校验这个清单可以作为代码评审的依据。不要只看“程序能跑起来”还要看异常分支、数据质量和运行成本。爬虫代码最大的风险不是语法错误而是某个字段解析失败后整批数据静默变脏而程序仍然正常退出。下一步的扩展方向有两个一是用 Scrapy 管理大规模采集任务利用它的中间件、管道和调度机制代替手写循环二是给爬虫接入数据库把清洗后的数据写入 MySQL 或 PostgreSQL让下游分析任务直接使用。无论选择哪个方向请求、解析、清洗、落地的思路都适用。新手上手时建议先把这个最小闭环在多台机器上运行几次观察日志和输出文件确认自己对每一步的状态都心中有数再去追求复杂的框架和分布式能力。