
简介一份关于全面掌握Python爬虫开发的doc教程文档面向从零基础到进阶的开发者系统讲解爬虫原理、requests、BeautifulSoup、Scrapy、Selenium等常用工具库的使用方法并深入解析IP封锁、验证码、JavaScript渲染等反爬虫机制及应对策略。资源为单个doc文件压缩包大小约38KB内容虽精简但结构完整适合随开随读并对照练习。已有295人下载学习适合作为爬虫入门与技能提升的参考资料。文档包含大量可运行的代码示例与电商商品数据抓取实战项目从发送HTTP请求、解析网页到数据存储均有步骤拆解同时配有代理池、请求头伪装等绕过反爬的实操方案可帮助读者快速将理论落地为可用的爬虫脚本。1. Python爬虫开发从入门到实战先弄清这门技术到底解决什么问题Python爬虫开发是那种看起来入门极快、写起来却问题频出的方向一个几十行的脚本就能把数据抓回来可一旦面对真实站点登录态、编码转换、反爬拦截、字段缺失这些坑一个接一个冒出来。我接过好几个类似的需求最典型的是某公司的运营每天下午手动复制竞品公开页面的价格和库存到表格里复制错一行就要返工两小时另一个团队需要按关键词批量采集公开评论做分析手工操作根本做不完。爬虫就是把这类重复劳动变成可验证、可重启、可监控的程序化流程。这篇笔记从基础教程讲起把 requests 请求、页面解析、数据存储这条链路打透再给三个递进式的实战项目、反爬参数设置以及五个常见的翻车现场适合刚学完 Python 语法但还没写过完整爬虫的人也适合写了不少脚本但总被目标站点限制的熟手。2. 从基础教程到开跑requests、解析器与存储的完整链路2.1 用requests发送第一批请求params、timeout与响应编码三个必设项先装好依赖再把一个带查询参数的 GET 请求跑通。pip install requests beautifulsoup4 lxmlimport requests url https://example.com/api/search params { keyword: 纯棉短袖, page: 1, page_size: 20, } headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36, } resp requests.get(url, paramsparams, headersheaders, timeout(5, 15)) # 显式声明编码避免后续解析中文乱码 resp.encoding utf-8 if resp.status_code 200: print(resp.text[:500]) else: print(请求失败状态码:, resp.status_code)参数用字典传而不是自己拼 URL 字符串这是新手最容易忽略的细节。requests 会对 params 里的中文和特殊字符做自动编码手动拼接经常漏掉%转义导致搜索接口返回空结果。timeout(5, 15)是个二元组第一个数字是连接超时第二个是读取超时只传一个数字时两个阶段共用同一个值。连接超时设太短会导致网络抖动时大量误判读取超时设太长又会让卡死的请求拖住整个任务我一般用 5 秒连接、10 到 15 秒读取。resp.encoding是另一个必设项。requests 会按响应头里的 charset 推断编码但很多中文站点的响应头里根本不声明requests 就会按 ISO-8859-1 处理中文全部变成乱码。第一版脚本先打印resp.encoding确认推断结果再手动覆盖成utf-8这样后续解析阶段基本不会再遇到乱码问题。状态码的处理也值得养成习惯200 正常继续403 基本是请求被识别成爬虫404 通常是 URL 结构变了重试没有意义502 和 503 是目标站点网关或服务暂时不可用。只有 429、503 这种临时性状态码才值得退避重试400 和 404 重试一百次也还是同样结果。提示第一版脚本把resp.text原样存成raw.html再写解析逻辑。这样即使解析器写错也不需要重新请求目标站点离线就能排查问题。2.2 页面解析为什么优先用 CSS 选择器而不是 XPath拿到 HTML 之后解析环节决定数据能多干净地从页面里出来。from bs4 import BeautifulSoup soup BeautifulSoup(resp.text, lxml) # 取单个元素页面标题 title_el soup.select_one(h1.product-title) # 取一组元素商品卡片列表 card_list soup.select(div.product-card) for card in card_list: name card.select_one(div.card-name) price card.select_one(span.price) print( name.get_text(stripTrue) if name else 无名称, price.get_text(stripTrue) if price else 无价格, )select_one返回第一个匹配元素select返回所有匹配元素的列表这两个方法组合使用几乎覆盖所有常见场景。解析引擎用lxml而不是 BeautifulSoup 默认的html.parser前者用 C 语言实现解析速度明显更快在几千个页面的大批量采集任务里差距会放大到分钟级。get_text(stripTrue)会把标签内的文本提取出来并去掉首尾空白比拿到文本后手动replace(\n, )干净得多。更关键的是元素可能不存在select_one在找不到元素时返回None如果直接对它调get_text()就会抛AttributeError。上面的三段式写法name.get_text(stripTrue) if name else 无名称就是为了让单条数据缺失时不至于中断整个循环。CSS 选择器和 XPath 的选择没有绝对优劣。CSS 选择器可读性强浏览器开发者工具里可以直接复制XPath 在处理“按文本内容查找”“比如找到所有文案包含‘缺货’的节点”这类场景更灵活。新手从 CSS 选择器入手视觉上更容易知道自己选到了什么。大部分站点改版时只改样式类名CSS 选择器本身也更容易跟着调整。2.3 数据落地CSV、SQLite 和 JSON 三种存储的选型爬虫的目的不是打印是让采集结果能交给下游分析或展示。存储方式在动手前就要确定。import csv import sqlite3 # CSV 落地newline 是为了避免 Windows 下写入产生空行 with open(products.csv, w, newline, encodingutf-8-sig) as f: writer csv.writer(f) writer.writerow([标题, 价格, 链接]) for item in data: writer.writerow([item[title], item[price], item[url]]) # SQLite 落地executemany 批量插入比逐条 insert 快很多 conn sqlite3.connect(products.db) conn.execute( CREATE TABLE IF NOT EXISTS products (title TEXT, price TEXT, url TEXT) ) conn.executemany( INSERT INTO products(title, price, url) VALUES(?, ?, ?), [(d[title], d[price], d[url]) for d in data], ) conn.commit() conn.close()CSV 文件用utf-8-sig而不是utf-8因为 Excel 打开无 BOM 的 UTF-8 文件时中文会显示成乱码utf-8-sig自带 BOMExcel 兼容性最好。newline是 csv 模块在 Windows 平台上的经典坑不设置会在每行之间多出一个空行。CSV 适合数据量不大、交付给同事直接用 Excel 打开的场景。SQLite 用 Python 标准库sqlite3就能操作不需要额外依赖适合数据量持续增长、需要重复查询和增量更新的场景。executemany批量插入比循环逐条insert快一个数量级这是明显的性能提升。表结构里的price字段先存文本清洗阶段再统一转成数值避免在存储层早早丢失精度。JSON 不会在代码里直接写整个文件它更适合做调试存档把原始响应原样保存下来。当目标站点改版导致解析器大面积失效时这些原始 JSON 能告诉你服务器到底返回了什么而不是靠一遍遍重新请求去猜。注意存储方式的价值排序是“可追溯优先于漂亮”。先保证原始数据和清洗后数据都在后续排查问题才有依据。3. 三个实战项目从静态页到登录态再到接口采集3.1 项目一采集公开商品页打通请求到落地的完整主链路第一个项目从纯静态页面开始刻意避开登录态和 JS 渲染这两个复杂度来源。import time import random import csv import requests from bs4 import BeautifulSoup BASE_URL https://example.com/item/ def fetch_item(item_id): url f{BASE_URL}{item_id} headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36, } resp requests.get(url, headersheaders, timeout(5, 10)) resp.encoding utf-8 if resp.status_code ! 200: return None soup BeautifulSoup(resp.text, lxml) title soup.select_one(h1.title) price soup.select_one(span.price) return { id: item_id, title: title.get_text(stripTrue) if title else , price: price.get_text(stripTrue) if price else , } rows [] for item_id in range(10086, 10120): row fetch_item(item_id) if row: rows.append(row) # 两个请求之间随机休息模拟真实用户操作间隔 time.sleep(random.uniform(0.5, 1.5)) with open(items.csv, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnames[id, title, price]) writer.writeheader() writer.writerows(rows)这个项目把第 2 章的所有环节串了起来。循环 34 个商品 ID每次请求前随机休息 0.5 到 1.5 秒这是爬虫开发的第一个基本纪律不要给目标站点造成访问压力。random.uniform(0.5, 1.5)比固定time.sleep(1)更安全固定间隔本身就有机器特征很容易被识别。fetch_item里对标题和价格做了缺失判断单个商品页解析失败不会拖垮整个循环。返回None时直接跳过不中断任务但要在后续的日志里看到失败计数这属于工程化习惯后面第 6 章专门展开。第一次跑这个项目时强烈建议先只循环 3 个 ID打印原始 HTML 片段确认选择器写对了再放量。很多初学者一口气跑几千个请求最后发现选择器从一开始就没选中任何节点白白浪费时间和流量。提示如果rows为空先别急着写 CSV。检查首页返回的是商品详情还是登录跳转页这一步能省掉大量排查时间。3.2 项目二分页列表与登录态处理搞懂 Session 到底在维持什么第二个项目必须处理登录这就绕不开requests.Session。import time import random import requests from bs4 import BeautifulSoup login_url https://example.com/login list_url https://example.com/dashboard/page/{page} session requests.Session() session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36, }) # 第一步先用 GET 打开登录页让 session 存下初始 Cookie session.get(login_url, timeout(5, 10)) # 第二步解析登录页里的隐藏 token很多站点必须有它 login_page session.get(login_url, timeout(5, 10)) login_soup BeautifulSoup(login_page.text, lxml) token_el login_soup.select_one(input[namecsrf_token]) csrf_token token_el[value] if token_el else # 第三步POST 提交表单 login_data { username: your_account, password: your_password, csrf_token: csrf_token, } resp session.post(login_url, datalogin_data, timeout(5, 10)) print(登录请求状态码:, resp.status_code) # 第四步带 session 走分页列表 all_rows [] for page in range(1, 6): r session.get(list_url.format(pagepage), timeout(5, 10)) soup BeautifulSoup(r.text, lxml) for tr in soup.select(table tbody tr): tds tr.select(td) if len(tds) 3: all_rows.append([td.get_text(stripTrue) for td in tds[:3]]) time.sleep(random.uniform(1, 2))Session 和单次requests.get的核心差别在于 Cookie 和连接复用。直接用requests.get每次请求都是全新的连接服务端种下的 Cookie 不会被保存。用session.get和session.postCookie 自动累积多次请求复用同一个 TCP 连接池性能也更好。登录流程很多新手会跳过第一步直接 POST 账号密码。但不少站点的服务端在登录页响应里会种一个初始 Cookie这个 Cookie 缺失时登录接口会校验失败或者登录返回 200 但后续请求仍然跳到登录页。所以规范操作是先用 GET 打开登录页、再 POST 提交表单。CSRF Token 同理先解析登录页 HTML 里的隐藏输入框把值取出来放回表单这一步很多站点强制执行。登录后不要急着抓数据先做一次探活。访问一个需要登录才能看到内容的页面检查resp.url里有没有login关键字或者页面文本里有没有“请先登录”之类的字样。探活通过才进入分页循环。分页 URL 我用format占位而不是 f-string因为分页参数在实战里往往会加sort、keyword、page_size等更多字段format模板在拼接多个变量时更清晰。每次翻页之间的随机等待也比固定等待安全。3.3 项目三JSON 接口采集跳过 HTML 直接拿结构化数据很多站点的列表页和详情页看似是 HTML实际数据是通过 XHR 接口动态加载的。与其模拟浏览器渲染不如直接找接口拿 JSON。import requests api_url https://example.com/api/comments payload { target_id: 10086, sort: hot, page: 1, limit: 50, } headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36, Referer: https://example.com/item/10086, X-Requested-With: XMLHttpRequest, } resp requests.get(api_url, paramspayload, headersheaders, timeout(5, 10)) resp.encoding utf-8 data resp.json() # 兼容两种常见响应结构{data: [...]} 和 {data: {list: [...]}} comments data.get(data, []) if isinstance(comments, dict): comments comments.get(list, []) for comment in comments: content comment.get(content, ) author comment.get(author, {}).get(nickname, ) like_num comment.get(like_count, 0) print(author, like_num, content[:50])找到这个接口不需要什么黑盒技巧浏览器开发者工具切到“网络”面板勾选 XHR刷新页面观察哪个请求返回了 JSON它的 URL 和 Query String 参数就是这个接口的调用方式。把这些参数原样搬进 payload 字典requests 会帮你自动编码和拼接。Referer头不是所有接口都校验但带上它通常没有坏处。有些后端会检查请求来源必须是页面本身缺失 Referer 时直接拒绝返回数据。X-Requested-With是前端 Ajax 请求的常见特征头在遇到 403 时可以补上试试有些服务端仅凭这个头判断是否浏览器请求。这两个头都属于“要了不亏”的配置建议写成默认。resp.json()直接解析 JSON 响应但要小心一种情况目标站点在接口返回异常时会返回 HTML 错误页而不是 JSON此时resp.json()会抛JSONDecodeError。捕获这个异常并打印resp.text[:200]能立刻分辨是参数错误、登录失效还是被限量拦截。JSON 数据里的嵌套结构用链式get取值comment.get(author, {}).get(nickname, )保证即使 author 字段缺失或为 null也不会中断整个循环。爬虫项目里单个脏数据打崩全任务的案例绝大部分都是因为取值时没做防御。3.4 三个项目共用的一套清洗管道空值、类型和日期统一处理采集到原始数据后清洗这一步在入库前必须执行。下面这套逻辑完全可以被三个项目共用。def clean_price(raw): 把 ¥ 1,299.00 转成 int 的 129900 分 raw (raw or ).replace(¥, ).replace(,, ).strip() if not raw: return 0 return int(float(raw) * 100) def clean_date(raw): 把 2025/01/02 统一成 2025-01-02 return (raw or ).strip().replace(/, -) def clean_row(raw_dict): return { title: (raw_dict.get(title) or ).strip(), price: clean_price(raw_dict.get(price) or ), link: (raw_dict.get(link) or ).strip(), date: clean_date(raw_dict.get(date) or ), }清洗逻辑的核心是三个“不信任”不信任目标字段一定存在不信任它是期望的类型不信任它的格式不会变。clean_price里(raw or )先把 None 变成空字符串再依次去掉货币符号和千分位逗号最后转成以分为单位的整数。价格用整数分存储而不是浮点数能彻底避开0.1 0.2这类浮点精度问题这是金融和比价场景的常识放到普通采集任务里同样适用。clean_date把斜杠日期统一成横杠格式让下游分析工具可以直接按字符串排序。clean_row返回固定字段的字典与数据库表结构一一对应。清洗完成后做一次总量校验清洗前后的记录数一致价格字段不应出现负数日期字段不该有1970-01-01这种异常值。校验通过了再入库反之先打印异常记录定位。4. 反爬应对能用与稳定之间隔着这四个细节4.1 请求头伪装UA、Referer 和 Accept-Language 怎么配合反爬检测的第一道防线就是请求头。所谓“稳定”首先要让每个请求看起来像一个真实浏览器发出的请求。import random USER_AGENTS [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15, Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0, ] def build_headers(refererNone): headers { User-Agent: random.choice(USER_AGENTS), 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 referer: headers[Referer] referer return headersUA 列表不一定越长越好关键在于一致性和覆盖度。一个 Windows Chrome、一个 macOS Safari、一个 Linux Firefox三个足够覆盖大多数场景。真正的问题是有些站点会记录 UA 与 Cookie 或 IP 的绑定关系同一个会话里频繁切换 UA 反而容易触发风控。所以我通常每个采集任务全程使用同一个 UA而不是每次请求都随机换。Accept-Language设置为zh-CN,zh;q0.9,en;q0.8对中文站点的价值很高。部分服务端会按语言偏好返回不同版本的页面缺了这个头可能导致返回内容与浏览器里看到的不一致。Referer按真实跳转来源填写比如从搜索列表页点进详情页Referer 就填搜索页的 URL。注意请求头伪装只应用于访问公开数据、且不干扰目标站点正常服务的场景。配合低频请求使用而不是把它当作对抗手段。4.2 请求频率控制为什么固定 time.sleep(1) 照样会被封请求头做得再完整频率控制不到位稳定同样无从谈起。import time import random import requests def request_with_retry(session, url, max_retries3, **kwargs): for attempt in range(max_retries): try: resp session.get(url, timeout(5, 10), **kwargs) if resp.status_code 200: return resp # 429/503/403 都属于可重试的“服务器不欢迎”状态 if resp.status_code in (403, 429, 503): wait 2 ** attempt random.uniform(0, 0.5) print(f第{attempt 1}次失败等待{wait:.1f}秒) time.sleep(wait) continue return resp except requests.exceptions.Timeout: wait 2 ** attempt random.uniform(0, 0.5) time.sleep(wait) return None固定time.sleep(1)的坏处不是间隔太长而是间隔太规律。真实用户的操作间隔存在自然的抖动机器固定间隔反而容易被识别。把time.sleep(1)改成random.uniform(0.5, 1.5)均值不变规律性消失这是一次零成本的安全提升。指数退避的价值在遇到限流时体现得最明显。第一次失败等 2 秒左右第二次等 4 秒第三次等 8 秒给服务端留出恢复时间。重试次数控制在 3 到 5 次就够了无限制重试只会加剧服务端对请求来源的反感。状态码的语义要分清429 表示限流503 表示服务暂时不可用这两种值得退避重试403 改完请求头后仍然出现大概率是 IP 级别或其他更底层的限制应该停止任务而不是继续重试404 是地址错误退避没有意义。Timeout异常也要单独捕获一个连接卡死会让整个采集任务原地不动捕获后走同样的退避逻辑才能保证任务继续推进。4.3 JS 渲染页面静态请求解析不到数据时用 Playwright 兜底有些页面数据是纯 JS 渲染的resp.text里根本没有目标字段。此时需要无头浏览器把 JS 执行完再取 HTML。from playwright.sync_api import sync_playwright def fetch_rendered_html(url, wait_selectorNone): with sync_playwright() as p: browser p.chromium.launch(headlessTrue) page browser.new_page() page.goto(url, timeout30_000) if wait_selector: page.wait_for_selector(wait_selector, timeout10_000) html page.content() browser.close() return html什么时候才值得上 Playwright判断标准很简单resp.text里搜不到目标字段浏览器里却能看到。说明数据是异步接口加载的要么找到 XHR 接口直接取 JSON要么用无头浏览器渲染。前者更快后者更通用。我的建议是优先找接口JSON 的结构化程度远高于渲染后的 HTML接口找得费劲再用 Playwright 兜底。wait_for_selector是这套方案里最容易踩坑的地方。页面刚发起请求时 HTML 已经有了但数据还没渲染进 DOM此时取内容必然拿到半成品。必须等待某个代表“数据加载完成”的节点出现比如评论列表的根元素 class 名。这个选择器写对了渲染结果才可信。无头浏览器的代价也要清楚比 requests 慢一个数量级内存占用高一个量级。几十个并发页面的 Playwright 实例能把一台普通开发机吃满。所以它的定位是兜底方案而不是默认选项。能用 requests 解决的就绝不启动浏览器。4.4 验证码与登录限制先想清楚值不值得过验证码处理是爬虫项目里最容易跑偏的地方也是成本最高的部分。服务端验证码的形态很多四位数字图形、滑块、点选文字、行为验证。图形验证码理论上可以走 OCR 思路识别但实际准确率受背景干扰、字体变形影响很大识别失败的请求要重试拖慢整个任务滑块验证码则要模拟拖拽轨迹轨迹太机械会触发行为检测项目维护成本不低。在动手写验证码识别之前先回答一个问题这个站点的数据真的一定要登录才能拿吗我见过不少案例所谓“必须登录”的数据在未登录状态下其实也能访问一部分只是入口藏得更深。另一类情况是验证码只在请求频率异常时才会出现把采集间隔从 2 秒拉长到 5 秒以上验证码可能就消失了。降低频率永远是最便宜的解决方案它的优先级高于任何识别手段。如果确实需要登录优先考虑能不能用扫码或手动登录一次再把登录后的 Cookie 持久化到本地文件后续请求直接加载 Cookie。这样做既绕开了验证码也不需要维护一套模拟登录逻辑代价是 Cookie 有过期时间过期后需要重新登录。最不推荐的做法是怼着验证码硬解尤其是图形验证码的识别准确率天然有上限错得越多被服务端进一步限制的可能性越大。注意验证码是服务端明确表达“不希望自动化访问”的信号。遇到它时先评估项目的必要性和合规边界再决定是否投入硬闯从来不是长期方案。5. 爬虫避坑记录五个会让项目中断的真实踩坑经历这里的五个问题不是理论推演是我在真实项目中踩出来的。每一条都有明确的现象和对应解法按 现象到原因再到解决 的顺序排好排查时可以直接对照。5.1 返回的文本全是“????”编码声明与页面编码不一致现象resp.text打印出来后中文全部变成????或者“锟斤拷”这样的乱码字符写入 CSV 后用 Excel 打开依然是乱码。原因页面实际使用 GBK 或 GB2312 编码而 requests 在响应头缺少 charset 时按 ISO-8859-1 推断或者页面是 UTF-8 但响应头声明错误。apparent_encoding和chardet都可以用来推断实际编码但推断本身有误判概率。解决import chardet resp requests.get(url, headersheaders, timeout(5, 10)) # 方案A先看 requests 推断成了什么 print(requests 推断编码:, resp.encoding) # 方案B用 chardet 检测实际编码并覆盖 detected chardet.detect(resp.content) resp.encoding detected.get(encoding) or utf-8 # 方案C已经知道站点是 GBK 时直接手工指定 # resp.encoding gbk排查顺序是先打印resp.encoding看推断结果再用 chardet 检测实际编码最后手工覆盖。如果检测结果同时出现GB2312和GBK优先用GBK因为它是GB2312的超集。解码时如果部分字符报错可以考虑resp.content.decode(gbk, errorsignore)丢几个字符合成也比整个任务崩溃好。5.2 解析结果为空列表但浏览器里明明有数据现象soup.select(div.item)返回空列表同样的 URL 用浏览器打开却能看到完整数据列表。原因数据不在服务端渲染的 HTML 里。页面加载后通过 JS 发起异步请求再把数据渲染进 DOM而resp.text里只有空壳 HTML。选择器本身没写错错的是抓取目标。解决先别急着改选择器把resp.text存成 HTML 文件用文本编辑器搜目标关键词。搜不到就直接确认数据是 JS 加载的然后回到第 3.3 节的方法找 XHR 接口或者用第 4.3 节的 Playwright 渲染后取 HTML。这个排查动作只需要两分钟能避免在错误方向上反复尝试。记住requests 拿到的 HTML 和浏览器看到的最终 DOM本来就是两个东西。5.3 登录后请求依然被重定向到登录页现象POST 登录返回 200同一个 session 再去 GET 详情页拿到的却还是登录页 HTML从resp.url里能看到login关键字。原因常见原因有三个。一是没有先用 GET 打开登录页服务端种下的初始 Cookie 缺失二是登录请求和后续请求 User-Agent 不一致服务端校验了 UA 与登录态的绑定三是 CSRF Token 没提交或已经过期。三个原因叠加时整条链路看起来就像是“登录成功但没生效”。解决# 登录后探活访问需要身份才会返回 200 的页面 probe session.get(https://example.com/member/me, timeout(5, 10)) if login in probe.url or 请输入账号 in probe.text: print(登录态没建立检查初始Cookie、UA一致性、CSRF Token) else: print(登录态正常进入采集流程)探活这一步是我强烈建议加的。登录请求返回 200 不代表登录成功很多站点登录成功后立刻 302 跳转状态码本身是 200 或 302 都有可能。直接访问一个需要登录身份的受限接口用resp.url和页面文本双重判断比单纯看状态码可靠得多。5.4 索引越界和 KeyError一条脏数据打崩整个任务现象脚本运行到中途报IndexError: list index out of range或KeyError: price前面已经采集的数据没保存整个任务终止。原因某条记录字段缺失、目标页面结构改版、或反爬返回了错误提示页。错误页里没有目标节点后续解析逻辑从空列表里取下标必然越界。这类问题不是每次都触发具有很强的随机性排查起来特别磨人。解决解析代码的每一个下标访问前都判断长度每一个字典访问都用get带默认值。select_one返回None时不要直接调.get_text()先判空再取值。把“单条数据解析失败”和“整个任务失败”分开单条失败就跳过并计数任务结束后看失败数量只有连续大量失败才中断任务。这样即使目标站点局部改版爬虫也能跑完大部分数据而不是在中途全盘崩溃。5.5 状态码全是 200拿到的却是降级页或缓存页现象请求全部返回 200但数据是昨天的、字段顺序错乱、页面变成了“访问过于频繁”的简化版本。状态码正常内容不对。原因服务端对高频访问做缓存或降级处理200 只代表请求被处理了不代表拿到了真实数据。某些站点还会在检测到自动化访问时返回一个内容缺失但状态码正常的替代页面。解决解析完成后必须做抽样校验。随机挑 3 条记录和浏览器人工核对字段值对得上才认为批次有效。更早的防线是在解析前加特征判断if not soup.select_one(div.product-card): raise RuntimeError(疑似降级页本批数据不可信终止任务)特征选择器用正常页面必有的节点降级页里大概率不存在。这比状态码判断可靠得多因为降级页的状态码往往是 200。养成习惯每次批量采集结束前打印“总记录数、失败数、抽查结果”三个数值数据可信度一目了然。6. 把爬虫工程化日志、断点续采和自检的收尾习惯爬虫从能跑到能稳定交付差的不是功能是三样东西日志、断点续采和自检。日志用来回答“挂在哪了”断点续采让任务可以随时重来自检决定数据敢不敢交给下游。import logging import hashlib logging.basicConfig( levellogging.INFO, format%(asctime)s %(levelname)s %(message)s, filenamespider.log, ) done_keys set() try: with open(done.txt, r, encodingutf-8) as f: done_keys set(line.strip() for line in f if line.strip()) except FileNotFoundError: pass for item_id in item_ids: key hashlib.md5(str(item_id).encode()).hexdigest() if key in done_keys: continue # 采集、清洗、入库的完整逻辑省略 done_keys.add(key) with open(done.txt, a, encodingutf-8) as f: f.write(key \n) logging.info(采集ID%s 完成, item_id)断点续采的原理是用一个去重集合记录已完成的主键每次采集前先查重。主键的选取很有讲究详情页用商品 ID列表页用“页面数排序字段关键词”的组合。任务中断后重新启动已经完成的条目直接跳过只有增量部分需要重新请求。这一套逻辑的实现成本很低但能让万级数据量的任务变得可管理。自检清单我固定在任务末尾执行第一总记录数和预期值是否一致误差超过 5% 就必须排查第二随机抽 3 条记录与浏览器人工核对字段值第三日志里 403、超时、解析失败的数量是否在可接受范围。三个检查都过了这批数据才允许交付。我以前写爬虫有个坏习惯脚本一跑起来就不管了凌晨三点挂了都不知道挂在哪一行第二天早上面对半截日志完全无从下手。后来强制自己补齐日志、断点续采和自检这三个环节之后同样的任务无论规模大小都能随时中断、随时续跑、随时解释数据质量。这几个习惯让我少熬了无数个夜希望帮到你。本文还有配套的精品资源点击获取