恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Python爬虫工程化实战:请求重试、动态页面与断点续爬
首页
资讯中心
/
Python爬虫工程化实战:请求重试、动态页面与断点续爬
Python爬虫工程化实战:请求重试、动态页面与断点续爬
发布时间:2026/10/16 19:18:42
简介面向具备一定 Python 与网络基础的数据分析师、爬虫开发及机器学习等人员这份代码包围绕网络数据自动抓取展开覆盖网页文本、图片等信息的采集与清洗可支撑数据分析、舆情监测、市场调研等场景。压缩包内共 21 个文件以 16 个 py 脚本为主体从数字、字符串、列表、字典等语法基础到 requests 网络请求、BeautifulSoup 文档遍历、csv 文件读写均有示例另含 2 个 txt 说明文本、2 个 csv 数据集及 1 张图片用于测试与效果展示整包仅 34KB轻量易读。脚本兼顾入门级知识点与可运行实战案例如豆瓣读书简介抓取、Glassdoor 职位信息采集、新东方头像批量下载等便于对照学习请求构造、页面解析与存储落盘的整体流程。目前已有 4379 人学习下载适合希望快速建立爬虫知识框架并动手实践的读者。1. 入手先看骨架这份 Python 爬虫代码包的四层结构抓取一个动态渲染的列表页第一版能跑通是运气能连续跑一周不翻车才是工程。这套 Python 爬虫代码包把请求、解析、反爬、存储拆成四个独立模块下载之后你可以按目标站参数去换 UA、换选择器、换存储方式。它适合有一定 Python 基础、想直接看工程化写法的人既要拿到能跑的代码也要看清每一步为什么这么写。数据抓取的核心从来不是语法而是稳定性——请求会超时、页面会改版、登录态会过期。所以本篇按请求层、解析层、动态页面、排查路径、断点续爬的顺序展开每一层都给出可验证的检查方法。2. 请求层与重试机制session 复用、超时与指数退避的工程化写法2.1 为什么请求层要单独封装单个页面用requests.get(https://...)没什么问题一旦数据量上来性能会断崖式下跌每次请求都重新建连、每次都要手动带 cookie写到后面代码里全是重复的 header 字典。常见做法是把请求封装成一个带会话的模块让requests.Session帮你维护连接池和 cookie。我一般会在代码包里看到两层设计一层是连接级别的复用另一层是失败重试。import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry def build_session(retries3, backoff0.5, timeout(5, 15)): session requests.Session() retry Retry( totalretries, # 总重试次数 connectretries, # 连接失败重试 readretries, # 读超时重试 backoff_factorbackoff, # 退避系数 status_forcelist(429, 500, 502, 503, 504), allowed_methodsfrozenset([GET, POST]), ) adapter HTTPAdapter(max_retriesretry, pool_connections20, pool_maxsize20) session.mount(https://, adapter) session.mount(http://, adapter) session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Accept-Language: zh-CN,zh;q0.9, Accept: text/html,application/xhtmlxml,application/json;q0.9,*/*;q0.8, }) return session这里最容易被忽略的是timeout(5, 15)第一个值是连接超时第二个值是读超时。连接超时设 5 秒足够判断目标是否可达读超时设 15 秒给慢接口留余量。backoff_factor0.5生成的退避序列是 0.5 秒、1 秒、2 秒配合status_forcelist遇到 429 和 503 会自动等待再重试。注意别把 404 放进去404 是确定结果重试多少次都一样。2.2 重试次数与退避参数3 次还是 5 次区别在哪重试次数不是越多越好。Retry(total5)看起来稳妥实际会放大请求压力一个接口抖动5 次重试全打过去反而把服务打得更慢。我的习惯是total3配合退避已经能覆盖大多数网络抖动。如果目标接口偶尔返回 429total5也可以但必须在退避里加随机抖动防止多个线程在同一秒同时重试形成雪崩。调试时有个麻烦内置Retry不打印日志你想确认是连接失败还是读超时时只能在外层套循环。我一般会临时写一段带日志的循环把失败阶段的细节暴露出来。import time import logging logging.basicConfig(levellogging.INFO) logger logging.getLogger(spider) for url in url_list: for attempt in range(3): try: resp session.get(url, timeout(5, 15)) resp.raise_for_status() break except (requests.ConnectionError, requests.Timeout) as exc: logger.warning(第 %s 次失败: %s, %s, attempt 1, url, exc) time.sleep(0.5 * (2 ** attempt)) # 指数退避这段代码和内置Retry是互补关系循环负责暴露「第几次失败、因为什么失败」真正上线时再换回Retry让代码更简洁。日志里如果频繁出现Read timed out优先怀疑目标接口响应慢而不是重试次数不够。2.3 请求头与编码联动UA、Accept 与乱码的隐藏关系请求头里最不值得纠结的是User-Agent随便用一个主流浏览器的版本就行真正影响结果是Accept和Accept-Encoding。Accept如果漏了application/json某些接口会回退到 HTML 格式解析逻辑就得全部重写。Accept-Encoding最好不要手动设让requests自己管理压缩否则可能在解压时拿到乱码。这里有个隐藏坑请求层看到的乱码往往不是解析问题而是编码推测失败。resp.text会按响应头里的charset去解码很多站点这个字段是错的。我在抓一个老平台时响应头写的是ISO-8859-1实际页面是 GBK最后resp.text全是乱码。正确姿势是先拿到resp.content原始字节再按候选编码逐个尝试这个逻辑在下一章的解析层具体展开。3. 解析层与编码parsel 选择器、乱码修复与字段规整3.1 解析器选型三个常见库的取舍代码包里选解析器时我建议优先看三件事语法好不好写、换框架成本高不高、性能够不够。经常被拿来做对比的三个库差异其实很明显。解析库速度语法风格适用场景BeautifulSoup4慢对象属性 select小型任务、新手学习lxml快原生 etree XPath复杂 HTML、性能敏感parsel中与 Scrapy 同款 XPath/CSS列表抓取、模块复用我在这份代码包里看到的是 parsel这个选择比较务实语法和 Scrapy 完全一致今天用 parsel 写完的 XPath明天放进 Scrapy 项目可以直接复用迁移成本为零。BeautifulSoup 写起来确实直观但速度慢数据量过万之后差距会放大lxml 性能最强但它的 API 偏底层写复杂提取逻辑时心智负担重。3.2 列表页解析实战XPath 与 CSS 的配合假设目标页面的结构是ul[classitem-list]下多个li每个li里有标题、链接和价格。用 parsel 的解析代码很接近生产环境的写法。import parsel def parse_list(html): sel parsel.Selector(html) rows sel.xpath(//ul[classitem-list]/li) items [] for row in rows: title row.xpath(.//h2/a/text()).get() link row.xpath(.//h2/a/href).get() price row.xpath(.//span[classprice]/text()).get() if not title: continue items.append({ title: title.strip(), link: link, price: price.strip() if price else , }) return items这里有两个经常踩坑的细节。第一个是row.xpath(.//h2/a/text())里的点号.//表示从当前节点继续往下找丢掉点号就会从文档根节点重新找结果往往是空列表。第二个是get()和getall()的区别get()只取第一个匹配getall()返回全部价格字段只要一个值就够所以用get()。3.3 乱码与缺失值先修复编码再清洗字段解析层最玄学的问题是乱码。如果你发现 title 变成一堆看不懂的符号第一反应不应该是写清洗正则而是回去检查解码方式。我习惯用一段兜底式解码def decode_response(resp): raw resp.content candidates [resp.encoding, resp.apparent_encoding, utf-8, gbk] for enc in candidates: if not enc: continue try: html raw.decode(enc) return html except UnicodeDecodeError: continue return raw.decode(utf-8, errorsignore)这段代码的逻辑是先信任响应头里的resp.encoding解不了就换apparent_encoding再解不了就试utf-8和gbk。实际用到最后两个候选的场景不多但一旦遇到一个声明错误的站点这段兜底就是后悔药。字段清洗比编码简单原则是宁缺毋滥strip()去掉首尾空白数字字段统一格式标题缺失就跳过该条别让脏数据进存储。价格字段可能没有保留空字符串比直接抛异常更合适。把字段规整和编码修复放到解析层存储层就不用反复处理脏数据。4. 动态页面与登录态selenium 选型、等待条件与登录复用4.1 动静结合什么时候不该硬啃 HTML如果你在浏览器里能看到数据但用 requests 拿到的 HTML 里什么都没有别急着去写更复杂的选择器——大概率是页面内容由 JavaScript 动态渲染数据根本没进首次返回的源码。这时的排查顺序应该是先按 F12 打开开发者工具切换到 Network 面板找 XHR 请求看看数据是不是从接口返回的 JSON 里来的。很多站点的列表页其实有现成接口只是接口带了签名参数或者需要特定的 cookie。先尝试直接请求接口能拿到 JSON 就用 requests 批量抓性能和稳定性都远好过浏览器渲染。只有当接口被加密、参数无法还原、或者需要真实点击操作时才值得上 selenium。把 selenium 当成最后手段不是第一选择。4.2 用 undetected_chromedriver 抓动态列表代码包里的动态页面模块用了undetected_chromedriver它比原生 selenium 更适合抓取场景能规避一部分自动化特征识别。基础用法并不复杂import undetected_chromedriver as uc from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC options uc.ChromeOptions() options.user_data_dir ./chrome_profile driver uc.Chrome(optionsoptions) driver.get(https://target.example.com/list) wait WebDriverWait(driver, 20) wait.until(EC.visibility_of_element_located((By.CSS_SELECTOR, div.item))) items driver.find_elements(By.CSS_SELECTOR, div.item) for item in items: title item.find_element(By.CSS_SELECTOR, h2 a).text print(title)options.user_data_dir ./chrome_profile是关键参数它把浏览器的用户数据目录写到本地磁盘。第一次运行时你在浏览器里手动登录账号状态会保存在这个目录里下一次启动uc.Chrome(optionsoptions)登录态自动带上不用重新扫码或输密码。等待条件用visibility_of_element_located表示目标元素真正可见才算完成比presence_of_element_located更适合列表页渲染的场景。4.3 登录态复用与自动化识别的边界登录态复用是效率的分水岭。很多数据抓取任务卡在登录墙requests 直接请求会跳转到登录页如果硬要绕过登录取数很容易把时间浪费在验证码上。我一般让 selenium 先把登录全程跑完再把页面里的 cookie 导出给 requests 的 session 用。代码包里的做法是直接持久化整个user_data_dir简单粗暴但可靠。自动化识别的边界也要心里有数navigator.webdriver属性、窗口尺寸、鼠标轨迹都可能暴露自动化特征undetected_chromedriver能降低风险但不能保证永远不被检测。遇到个别站点盯着不放时最实际的方案是把浏览器里的真实操作录下来找到真正的数据接口绕开渲染这层墙。selenium 是黑匣子用它解决登录和动态渲染但不该靠它完成全量抓取——一个页面加载两三秒抓一万条就是几个小时的差距。5. 爬虫避坑手册五个高频故障的排查路径与修复代码5.1 返回 200 但解析全部为空现象请求状态码是 200日志看起来一切正常但解析出来 items 列表是空的。原因目标页面可能是 JavaScript 动态渲染requests 拿到的 HTML 只是空壳。也可能是页面内容被转义或嵌套在注释里选择器根本匹配不上。解决先打印响应内容前 500 个字符确认拿到的是不是预期 HTML。resp session.get(url) print(repr(resp.content[:500]))如果是空壳页面切到 XHR 接口或上 selenium如果内容在再调整选择器。这一步能帮你区分是解析问题还是渲染问题避免在错误的方向上调半天选择器。5.2 抓几十条后全部变成登录页现象前几十条正常再往下抓所有接口返回的页面都变成了登录页。原因cookie 过期或者触发了站点风控。很多站点会对高频访问的 Session 做标记一旦发现特征异常就强制跳登录。解决检查响应 URL 是否发生跳转确认登录页特征。if login in resp.url: print(cookie 过期需要重新登录)临时方案是把浏览器里的新 cookie 手动复制进 session长期方案是保存好登录状态后用固定 user_data_dir 持续复用让 cookie 自动刷新。5.3 CSV 用 Excel 打开乱码现象抓下来的内容用文本编辑器看没问题用 Excel 打开中文全是乱码。原因CSV 文件默认按 UTF-8 写入Excel 在 Windows 上默认按 GBK 打开两边编码不一致就乱码。解决写 CSV 时用utf-8-sig编码这是 Excel 最友好的格式。import csv with open(data.csv, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnames[title, price]) writer.writeheader() writer.writerows(items)utf-8-sig会在文件头部写入 BOM 标记Excel 看到标记后会自动用 UTF-8 解码。这个坑我自己踩过好几次后来凡是给业务方看的 CSV一律默认utf-8-sig。5.4 触发限流后连续失败现象抓取速度一开始稳定过了一会儿突然开始连续超时或大量报错。原因目标站点检测到请求频率过高对该 IP 或 Session 做了限流。解决把固定间隔改成随机间隔减少请求规律性。import time import random time.sleep(random.uniform(0.5, 1.5))固定间隔比如每 1 秒一次反而更容易被识别随机间隔让请求时间点分布更接近真实用户。这也是血泪经验第一次跑全量时我没做限速两千条数据后直接被拒后来凡是批量任务强制在请求层加随机延时。5.5 日志全是重试但程序像卡死现象日志里不断输出重试信息但程序长时间没有进度看起来像死循环。原因读超时设置太长配合重试后单个 URL 的耗时被放大了好几倍。比如读超时 60 秒重试 3 次一个坏 URL 就占 3 分钟。解决把读超时调短并限制重试次数。session build_session(retries2, timeout(5, 10))如果接口本身要 20 秒才返回可以把读超时放宽到 30但重试次数不要超过 2。遇到个别超时 URL直接跳过比反复等待更划算。6. 断点续爬与最终验证用 SQLite 让采集任务可中断6.1 进度表结构设计一次性把几万条抓完不太现实中途断网、进程崩溃、改选择器重启是常态。最常见的后悔药是记录已抓取状态。SQLite 单文件、零服务、免配置适合中小采集任务拿它做进度表比写一个done.txt可靠得多。CREATE TABLE IF NOT EXISTS article ( url TEXT PRIMARY KEY, title TEXT, price TEXT, crawled_at TEXT DEFAULT CURRENT_TIMESTAMP );url做主键是去重的基础。同一地址只抓一次重启后跑INSERT OR IGNORE自动跳过已抓过的条目。6.2 断点续爬主流程import sqlite3 conn sqlite3.connect(crawl.db) cursor conn.cursor() for url in url_list: cursor.execute(SELECT 1 FROM article WHERE url ?, (url,)) if cursor.fetchone(): continue item fetch_item(url) if not item: continue cursor.execute( INSERT OR IGNORE INTO article(url, title, price) VALUES (?, ?, ?), (url, item[title], item[price]), ) conn.commit()先查一遍是否已存在存在就跳过不存在再抓。每抓一条就commit()避免进程崩溃时丢失大批进度。如果某次选择器写错了重启后把表清空或只删指定 url 即可不用整个重来。6.3 采集质量自检抓完别急着交差。先跑三个 SQL 确认质量总条数、URL 重复数、标题缺失数。SELECT COUNT(*) FROM article; SELECT COUNT(*) - COUNT(DISTINCT url) FROM article; SELECT COUNT(*) FROM article WHERE title IS NULL OR title ;从那以后我每次正式采集前都会先抓两页样本、确认编码和字段缺失率再放全量跑。数据抓取的工程化无非是让每一步都可验证、可恢复、可重跑这套代码包帮你把骨架搭好了剩下的就交给你的目标站去磨合。希望帮到你。本文还有配套的精品资源点击获取