简介:一份关于全面掌握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, params=params, headers=headers, 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(strip=True) if name else "无名称", price.get_text(strip=True) if price else "无价格", )select_one返回第一个匹配元素,select返回所有匹配元素的列表,这两个方法组合使用几乎覆盖所有常见场景。解析引擎用lxml而不是 BeautifulSoup 默认的html.parser,前者用 C 语言实现,解析速度明显更快,在几千个页面的大批量采集任务里差距会放大到分钟级。
get_text(strip=True)会把标签内的文本提取出来并去掉首尾空白,比拿到文本后手动replace("\n", "")干净得多。更关键的是元素可能不存在:select_one在找不到元素时返回None,如果直接对它调get_text()就会抛AttributeError。上面的三段式写法name.get_text(strip=True) 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="", encoding="utf-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自带 BOM,Excel 兼容性最好。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, headers=headers, 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(strip=True) if title else "", "price": price.get_text(strip=True) 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="", encoding="utf-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[name=csrf_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, data=login_data, timeout=(5, 10)) print("登录请求状态码:", resp.status_code) # 第四步:带 session 走分页列表 all_rows = [] for page in range(1, 6): r = session.get(list_url.format(page=page), 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(strip=True) for td in tds[:3]]) time.sleep(random.uniform(1, 2))Session 和单次requests.get的核心差别在于 Cookie 和连接复用。直接用requests.get,每次请求都是全新的连接,服务端种下的 Cookie 不会被保存。用session.get和session.post,Cookie 自动累积,多次请求复用同一个 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, params=payload, headers=headers, 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(referer=None): headers = { "User-Agent": random.choice(USER_AGENTS), "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.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;q=0.9,en;q=0.8对中文站点的价值很高。部分服务端会按语言偏好返回不同版本的页面,缺了这个头可能导致返回内容与浏览器里看到的不一致。Referer按真实跳转来源填写,比如从搜索列表页点进详情页,Referer 就填搜索页的 URL。
注意:请求头伪装只应用于访问公开数据、且不干扰目标站点正常服务的场景。配合低频请求使用,而不是把它当作对抗手段。
4.2 请求频率控制:为什么固定 time.sleep(1) 照样会被封
请求头做得再完整,频率控制不到位,稳定同样无从谈起。
import time import random import requests def request_with_retry(session, url, max_retries=3, **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_selector=None): with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page() page.goto(url, timeout=30_000) if wait_selector: page.wait_for_selector(wait_selector, timeout=10_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, headers=headers, 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", errors="ignore"),丢几个字符合成也比整个任务崩溃好。
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( level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", filename="spider.log", ) done_keys = set() try: with open("done.txt", "r", encoding="utf-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", encoding="utf-8") as f: f.write(key + "\n") logging.info("采集ID=%s 完成", item_id)断点续采的原理是用一个去重集合记录已完成的主键,每次采集前先查重。主键的选取很有讲究:详情页用商品 ID,列表页用“页面数+排序字段+关键词”的组合。任务中断后重新启动,已经完成的条目直接跳过,只有增量部分需要重新请求。这一套逻辑的实现成本很低,但能让万级数据量的任务变得可管理。
自检清单我固定在任务末尾执行:第一,总记录数和预期值是否一致,误差超过 5% 就必须排查;第二,随机抽 3 条记录与浏览器人工核对字段值;第三,日志里 403、超时、解析失败的数量是否在可接受范围。三个检查都过了,这批数据才允许交付。
我以前写爬虫有个坏习惯,脚本一跑起来就不管了,凌晨三点挂了都不知道挂在哪一行,第二天早上面对半截日志完全无从下手。后来强制自己补齐日志、断点续采和自检这三个环节之后,同样的任务无论规模大小,都能随时中断、随时续跑、随时解释数据质量。这几个习惯让我少熬了无数个夜,希望帮到你。
本文还有配套的精品资源,点击获取