ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python爬虫实战:requests、BeautifulSoup与SQLAlchemy的采集调试指南

Python爬虫实战:requests、BeautifulSoup与SQLAlchemy的采集调试指南 最近刚把一个数据采集项目从“能跑就行”的脚本重构成一个稍微能见人的小工具整个过程中踩了不少坑也攒了一堆调试心得。这篇不打算写成入门教程而是想聊聊我用 Python 做爬虫采集时的真实选择、踩坑记录和排查思路核心围绕 requests 请求、页面解析、数据入库和本地调试这几块。如果你也正在被反爬、乱码、动态加载、重复数据这些问题折腾那这篇应该对你有用。先说下背景目标站点是一个公开的信息网站我需要采集它上面的文章列表和详情页字段包括标题、发布时间、正文摘要、原文链接和分类标签。量级不算大单日新增几十条历史累计几千条。这种规模完全用不着分布式爬虫或者大数据框架一台普通机器跑个定时脚本就能搞定。问题的关键不在于“能不能爬到”而在于“能不能稳定地重复跑、增量跑、出错能自查”。1. 项目定位与技术选型为什么是 Python 这一套组合1.1 采集需求的真实场景分析拿到这个需求后我第一件事不是写代码而是把采集目标拆清楚。数据源是什么类型是静态 HTML 还是接口返回的 JSON更新频率怎么样字段有哪些需要存到什么数据库里。这些问题直接决定技术选型。这个项目的目标页面结构比较规整列表页是服务端渲染的 HTML详情页里大部分字段也是静态的只有少部分数据是异步加载的。这意味着我不用一上来就上浏览器自动化优先用 requests 直接请求 HTML 就够用。异步加载的部分再单独找接口补上这样性能会好很多。我的习惯是所有采集任务都先回答三个问题数据量多大、更新频率多少、字段结构是否稳定。量级小就单机同步脚本量级大或者字段复杂才考虑 Scrapy 或者任务队列。过度设计是爬虫项目最常见的坑很多时候一个脚本能解决的事情非得上分布式纯粹给自己找罪受。1.2 requests 起步还是直接上 Scrapy很多人一提到 Python 爬虫就想到 Scrapy但我的建议是如果你刚开始做数据采集或者项目规模不大先用 requests 把整条链路跑通再说。requests 是同步 HTTP 库写起来直观返回什么、报什么错都是一行行看得见的调试心智负担很小。Scrapy 的优势在于异步并发、扩展插件、中间件机制这些在需要采集大量站点或者实时性要求很高的时候才有明显价值。但它也有学习成本比如 Item Pipeline、Spider Middleware、twisted 的事件模型对新手来说很容易陷入“框架用不明白自己的逻辑也写不顺手”的状态。我这边实际用的技术组合是requests处理所有 HTTP 请求配合 Session 保持会话状态BeautifulSoup lxml解析 HTML定位目标节点re处理少量文本清洗和格式规整pandas快速做数据检查和去重SQLAlchemy统一管理数据库操作支持 SQLite 和 MySQL 切换选 SQLAlchemy 而不是直接写 SQL 的原因是爬虫的字段经常要临时调整ORM 只需要改模型类迁移和维护都更省事。等数据量上来之后这套组合也能平滑扩展。2. 请求层搭建与反爬前置处理2.1 请求头伪装不要一上来就裸请求很多初学者写爬虫就是requests.get(url)一把梭结果返回 403 或者直接封 IP然后开始怀疑人生。实际上大多数反爬第一步就是检查请求头尤其是 User-Agent、Referer、Accept-Language 这几个字段。我一开始也偷懒只设置了一个 User-Agent后来发现某个站点的接口对 Referer 有强校验不带 Referer 就返回空数据。排查了半天才通过抓包发现浏览器请求里每个请求头都不是随便带的服务器端完全可以根据这些信息判断你是真人还是脚本。我的完整请求头配置大概是这样的headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Referer: https://example.com/, Connection: keep-alive, }这里有个细节User-Agent 不要用一个过老的版本服务器端可能会根据 UA 版本和系统版本做兼容性判断。另外Accept 字段虽然大部分时候不起决定性作用但缺了某些字段会让服务器返回 406所以我还是会完整地带上参考浏览器抓包得到的头信息。2.2 Session、超时与重试三个最容易被忽略的细节requests 库最被低估的功能就是 Session。Session 会自动管理 Cookie 和连接池对于需要登录或者需要保持会话状态的站点来说尤其重要。同一个 Session 发出的多次请求会保持服务端会话这样你在首次访问时获得的 Cookie 可以在后续请求中复用避免反复认证。session requests.Session() adapter requests.adapters.HTTPAdapter(max_retries3, pool_connections10, pool_maxsize10) session.mount(http://, adapter) session.mount(https://, adapter)然后是超时设置。requests.get(url, timeout5)这个 5 秒指的是连接超时但读取超时也要考虑。网络抖动的时候连接建立了很久数据却迟迟不返回如果只设置了连接超时依然会卡住整个脚本。我的做法是设置元组resp session.get(url, headersheaders, timeout(3.05, 10))前面是连接超时后面是读取超时。具体值要根据目标站点的响应速度来调太短容易误判失败太长会影响整体采集效率。重试机制也很有讲究。max_retries3在 urllib3 层面处理的是连接错误和部分可重试的状态码但 403、404 这种明确错误不会重试。如果你的目标是获取最新数据3 次重试已经比较合理再多的话不仅浪费时间还会加大对目标服务器的压力。同时建议配合退避策略失败后等几秒再试而不是立刻连打三次。2.3 请求频率控制礼貌爬虫才能活得久爬虫写出来容易但能长时间稳定运行才是本事。最容易把自己玩死的方式就是高并发猛刷目标站点几分钟内 IP 就被封掉。我见过不少新手在同一线程里用 for 循环疯狂请求不加任何间隔结果数据还没采完先收到了验证码页面。我的做法是在每次请求之间加随机延时import time import random time.sleep(random.uniform(1, 3))为什么用随机而不是固定 1 秒因为固定间隔的规律性太容易被检测到。人类的浏览行为是看不出严格时序的而脚本就是一台精确的节拍器目标站点很容易通过访问时间模式识别出机器人。当需要采集的页面数量比较大的时候还可以通过信号量或者队列控制并发数而不是直接开几十个线程。并发太高即使加了延时服务器端的负载和日志也会暴露你的存在。我个人的经验是单机采集的目标站点数量不多时串行加随机延时是最省心也最安全的方式。3. 页面解析与数据清洗实录3.1 解析工具选型正则、BeautifulSoup、XPath 怎么选解析这块是爬虫项目里最容易写乱的地方。刚上手的时候看到什么用什么最后代码里正则、字符串截取、BeautifulSoup 混在一起维护起来简直是灾难。我的建议是先看页面结构再决定解析方案。工具优点缺点典型场景正则表达式执行快、零依赖复杂规则容易写错、难维护从 script 标签或 JSON 字符串中提取 IDBeautifulSoup容错性好、代码直观性能一般嵌套深时可读性差通用 HTML 节点定位和属性提取lxml XPath性能好、选择器表达能力极强XPath 语法有学习成本结构稳定的列表页批量解析在这个项目里列表页和详情页的 HTML 结构都比较稳定我主要使用 BeautifulSoup 配合 slector 选择器部分字段用 XPath 提取。例如文章列表页每篇文章的标题和链接都在div classlist-item下from bs4 import BeautifulSoup soup BeautifulSoup(resp.text, lxml) items soup.select(div.list-item) for item in items: title_node item.select_one(h2.article-title a) title title_node.get_text(stripTrue) link title_node.get(href)不要太迷信某一种工具。如果目标站点主体返回的是 JSON 字符串正则提取反而比 DOM 解析简单直接。如果详情页嵌套很深XPath 的绝对路径未必可靠用 CSS 选择器反而更稳定。工具是死的页面是活的灵活组合才是正解。3.2 数据藏在接口里动态加载页面的处理方案这个项目里最大的一个坑是部分列表页的数据是前端页面加载完后再通过 XHR 请求从接口获取的。requests 拿到的 HTML 里只有页面骨架文章列表连影子都看不到。最初我以为是自己请求头不全的问题反复调整无果最后打开浏览器开发者工具才发现真相。处理流程固定是这样的F12 打开开发者工具切到 Network 标签刷新页面筛选 XHR 或 Fetch 类型的请求找到返回目标数据的接口点击预览确认是 JSON右键复制该请求的 cURL在命令行里先试通用 requests 复现同样的请求拿到 JSON 后直接解析接口拿数据通常比解析 HTML 更舒服因为结构是现成的不用跟标签纠缠。比如某个接口返回的结构是{ data: { items: [ { id: 12345, title: 文章标题, publish_time: 2024-03-01 10:00:00 } ] } }用 requests 请求接口后直接resp.json()就能拿到 Python 字典再按字段提取即可。如果接口带了简单的签名参数先看参数是不是在页面源码或者 JS 文件中固定写死如果是直接复制使用如果参数是动态加密生成的再考虑 Selenium 或 Playwright 兜底。但这种情况优先级放最后因为浏览器自动化太重、太慢且容易被检测。3.3 数据清洗与去重入库前的最后一关爬到原始数据不等于可以入库。HTML 里的文本可能带着换行符、空格、HTML 标签日期格式也可能是“2024年3月1日”这种乱七八糟的写法不做清洗后面分析数据的时候每一行都要处理一遍。清洗逻辑一般包括几个标准动作。第一步是去空白strip()去掉首尾空格和换行第二步是去标签正文摘要里可能残留p或span用正则.*?替换掉第三步是统一格式日期字段统一转成YYYY-MM-DD HH:MM:SS价格字段统一去货币符号转 float。import re def clean_text(raw): text re.sub(r.*?, , raw) return text.strip()去重我习惯用 URL 作为自然键因为同一篇文章的 URL 必然唯一。在内存里去重可以用set保存已见过的 URL在数据库层面就需要建立唯一索引这放在下一部分详细讲。还有一个坑是详情页里有些字段可能为空入库时最好对默认值做处理把 None 替换成空字符串避免后续查询统计时报错。4. 数据落库SQLAlchemy 配合关系型数据库存储爬虫数据4.1 为什么选择 SQLAlchemy 而不是手写 SQL爬虫数据的存储方案有很多种存 CSV、存 JSON、存 SQLite、存 MySQL。前期我直接用 pandas 的to_sql每次全量覆盖用下来发现两个问题一是全量覆盖容易丢历史数据二是字段一旦变化就要重跑逻辑。后来换成了 SQLAlchemy ORM核心原因是它能把“数据模型”和“数据库操作”解耦。我在代码里定义好模型类表结构由模型自动生成增删改查通过会话对象完成。这样切换底层数据库时只需要改连接字符串业务代码几乎不用动。对比手写 SQLORM 的另一个好处是类型映射。Python 的datetime对象可以直接映射到数据库的 DATETIME 字段不用自己拼字符串。事务管理也更省心session.commit()统一提交出错可以session.rollback()回滚。4.2 建表与批量写入从单条插入到批量插入首先是模型定义以文章表为例from sqlalchemy import create_engine, Column, Integer, String, DateTime, Text, UniqueConstraint from sqlalchemy.orm import declarative_base, sessionmaker Base declarative_base() class Article(Base): __tablename__ articles id Column(Integer, primary_keyTrue, autoincrementTrue) url Column(String(500), uniqueTrue, nullableFalse, indexTrue) title Column(String(500), nullableFalse) summary Column(Text, default) publish_time Column(DateTime, nullableTrue) category Column(String(100), default) created_at Column(DateTime, defaultdatetime.now) __table_args__ (UniqueConstraint(url, nameuq_article_url),)这里把 URL 设为唯一索引数据库层面干掉了重复数据。插入时使用批量方式engine create_engine(sqlite:///articles.db) Base.metadata.create_all(engine) Session sessionmaker(bindengine) session Session() session.add_all(article_list) session.commit()批量插入前要做好内存管理如果一次采集上万条全部放进article_list会把内存吃满。我的做法是分块每 500 条提交一次提交后清空列表这样即使中途出错已提交的数据也不会全部丢失。4.3 增量采集与去重逻辑的实现思路增量采集是这个项目里最核心的功能。目标站每天都有新文章但我总不能每次把几千条历史数据重新采一遍。增量逻辑的本质就是只处理新增或者有变化的数据。最简单的方案是采集前先查询库中已有的 URL 集合existing_urls set(row[0] for row in session.query(Article.url).all()) for item in fetched_items: if item[url] not in existing_urls: session.add(Article(**item)) session.commit()但对于大数据量一次性把所有 URL 加载到内存并不理智。这时候依赖数据库的唯一约束更合理插入重复数据时捕获异常即可from sqlalchemy.exc import IntegrityError try: session.add(article) session.commit() except IntegrityError: session.rollback()不同数据库还有各自的原生去重写法。SQLite 支持insert ... on conflict do nothingMySQL 支持insert ... on duplicate key update。SQLAlchemy 中可以用sqlalchemy.dialects.sqlite.insert实现类似这样from sqlalchemy.dialects.sqlite import insert stmt insert(Article).values(...) stmt stmt.on_conflict_do_nothing(index_elements[url]) session.execute(stmt) session.commit()增量采集还有一个关键点记录采集游标。比如每次采集前记录当前最大发布时间或最大文章 ID下次从游标位置继续。这样即使重复运行脚本也不会重新处理已见过的数据。5. 调试实战盘点那些年踩过的爬虫坑5.1 状态码背后的反爬信号爬虫调试的第一步永远是看响应状态码。200 说明请求成功403 说明被拒绝但这些语义在不同站点上可能会有细微差异。下面是我实际遇到过的几种状态码含义应对策略403服务器拒绝请求大概率是请求头不全或者 IP 被限制补全 Headers换 User-Agent降低请求频率404页面不存在或接口地址错误检查 URL 拼接逻辑看看参数是否需要编码418反爬机制标记或站点故意逗爬虫换请求头加 Cookie换代理 IP429请求过于频繁被限流停止当前任务等几秒到几分钟加随机延时500/502/503目标服务器异常或负载过高先别急着排查自己等对方恢复需要特别注意的是有些站点即使被反爬依然返回 200响应体里却是一段“访问过于频繁请稍后再试”的 JSON 或 HTML。所以调试时一定要打印响应体的前几百个字符而不是只看状态码。我习惯性地在每次请求后加一行调试日志输出 URL、状态码和响应体长度一眼就能发现哪里不对。5.2 浏览器能打开代码却拿不到数据“浏览器明明能看到requests 却拿不到”这个问题的出现频率非常高。通常的原因有三个第一个是页面数据由异步请求加载requests 请求到的 HTML 是空的第二个是请求头不完整服务端根据 Referer 或 Accept 字段识别出非浏览器请求第三个是目标站有反爬脚本在页面中嵌入了环境检测代码。排查思路就一句话用抓包来对比浏览器和代码之间的差异。打开开发者工具 Network 面板找到目标数据对应的 XHR 请求复制它的完整请求头和参数。然后对比两者差异缺什么补什么。这一步几乎能解决 80% 的动态加载问题。还有一种很实用的调试技巧把 requests 返回的 HTML 保存到本地文件然后用浏览器打开看看和线上页面有什么不同。如果本地文件里能看到目标数据说明是请求环节的问题如果本地文件里没有说明数据就是异步加载的。with open(debug_page.html, w, encodingutf-8) as f: f.write(resp.text)然后直接用浏览器打开debug_page.html肉眼观察页面结构比在代码里反复打印省事得多。5.3 本地调试三板斧print、logging、pdb爬虫调试我遵循一套从简到繁的顺序。最先用print打印关键变量的值和请求返回的前缀内容。print 是最直接的但不是所有场景都能高效定位问题因为程序一多到处是 print 输出反而分不清是哪一步调用的。第二种是logging比 print 更规范可以分级输出、写日志文件。我通常在代码开头做基础配置import logging logging.basicConfig( levellogging.INFO, format%(asctime)s - %(levelname)s - %(message)s, filenamecrawler.log, filemodea, )这样程序的运行过程会完整记录在日志文件里出错了就把日志拖出来查不用盯屏幕。特别适合定时任务在后台跑的场景。第三种是pdbPython 自带的交互式调试器。当逻辑复杂到 print 已经看不出问题在哪一步时就在可疑位置插入import pdb pdb.set_trace()程序运行到这里会暂停进入交互模式可以输入变量名查看值输入n单步执行输入c继续跑完全部。调试完记得删掉这行代码不然自动化任务会卡住。5.4 反爬升级时的应对Selenium 什么时候才用得上遇到验证码、JS 环境检测这种高级反爬很多人的第一反应就是上 Selenium。但 Selenium 是最后的手段不是第一的选择。它在启动浏览器、加载页面时消耗大量资源而且很容易被自动化检测识别出来。能用接口解决的事决不开浏览器。使用 Selenium 的典型场景是目标站点的数据完全由前端 JS 动态渲染而且接口有加密签名单纯分析 JS 成本太高。这种情况下直接模拟浏览器交互等待页面渲染完成再提取数据确实是更高效的选择。from selenium import webdriver from selenium.webdriver.common.by import By import time driver webdriver.Chrome() driver.get(https://example.com/dynamic-page) time.sleep(3) titles driver.find_elements(By.CSS_SELECTOR, .article-title) for title in titles: print(title.text) driver.quit()但要注意这种方案需要本地安装对应浏览器的驱动且运行效率不高。如果采集任务量大用 Selenium 会让你等到怀疑人生。我在实际项目中一般把它放在最后兜底的位置能不用就不用。5.5 常见问题速查表最后整理一份踩坑速查表是我日常排查问题的流程症状可能原因排查思路解决方案请求超时网络波动、IP 被限流看日志中的 timeout 参数加超时、重试、降低并发获取的 HTML 与浏览器不一致异步加载抓包找 XHR 接口直接请求接口中文乱码编码识别错误打印resp.encoding设置resp.encoding utf-8插入数据库报重复键URL 冲突检查唯一索引捕获 IntegrityError 后忽略请求状态码 403请求头不全或 IP 被封抓包对比 Headers补全 Headers换代理返回空列表但状态码 200动态加载或接口参数错误检查响应体内容检查参数和请求头6. 从脚本到小工具工程化补充与红线边界6.1 定时任务与无人值守设计脚本写完之后就开始考虑自动化。最简单的做法是用系统的定时任务比如 Linux 的 crontab 或者 Windows 的任务计划程序。定时任务跑起来后脚本就不再是手动运行的一次性工具而是需要具备几个基本素质幂等性、可重入性、可观测性。幂等性意味着无论脚本运行多少次最终的数据状态都是正确的。增量采集逻辑天然支持这一点但要注意重复插入时不要产出脏数据。可重入性意味着脚本运行一半挂了下一次运行还能从断点继续。可观测性则是说出了问题能及时知道日志文件是必不可少的。# 每天凌晨 1 点执行一次 0 1 * * * cd /path/to/project /usr/bin/python3 run_crawler.py crawler.log 21Windows 下可以写.bat文件丢进任务计划程序逻辑是一样的。关键是要记得重定向输出到文件否则脚本报错你根本看不到。6.2 异常捕获与告警机制爬虫最怕的不是采不到数据而是数据采着采着突然断开你人不在电脑前第二天才发现程序已经挂了很久。基础方案是把异常捕获后记录到日志但更高阶的方案是加告警通知。遇到连续多次失败可以发送邮件或者调用 webhook 通知自己。try: resp session.get(url, timeout10) resp.raise_for_status() except requests.exceptions.RequestException as e: logging.error(f请求失败: {url}, 错误: {e}) failed_count 1 if failed_count 5: send_alarm(爬虫连续失败请检查目标站点或 IP 状态)告警阈值要设置合理不要因为偶尔一次网络抖动就疯狂报警否则一段时间之后你对告警就麻木了。我惯用的手法是连续失败 3 次开始重试连续失败 5 次才发告警。这样既不会错过关键问题也不会因为偶发异常草木皆兵。6.3 采集边界与经验小结爬虫做多了心里要有一根弦。不管数据多值钱都必须遵守几个基本原则只采集公开数据不绕过登录和付费限制遵循目标站点的 robots 协议声明控制请求频率不给对方服务器带来压力涉及个人信息的内容要格外谨慎数据使用也要标明来源。这不是说教而是务实的自我保护。市场上有很多采集需求打着“公开信息”的名义但实际涉及用户隐私和版权内容接了这种单子就是给自己埋雷。技术本身是中性的但用在什么地方、怎么用决定了这个项目能做多久。回到这个项目本身我最深的感受是写爬虫最花时间的不是“抓取”这一步而是解析、调试、清洗、去重这些不能直接看到成果的环节。你写了一个小时请求代码可能只采集了 20 条数据但如果你写了一个小时的调试日志和异常处理可能帮你省下未来几天的问题排查时间。后面如果目标站点的接口封得更严我大概率会引入 Playwright 来做补充不过那是另一个故事了。
返回列表