Python爬虫三层架构:请求、解析、存储与回放验证 简介这份资源是一份Python网络爬虫技术文档面向具备一定编程基础、希望系统掌握网络数据采集方法与合规边界的开发者与学习者。内容从爬虫的基本概念、Python作为首选语言的优势与工作原理讲起依次展开发送请求、解析网页、数据存储三大核心环节并结合requests、BeautifulSoup、lxml与Scrapy等常用库的代码示例说明各自特点与适用场景同时涉及动态加载页面的处理、反爬机制的规避思路与爬虫效率优化等进阶话题最后强调遵守法律法规、网站协议与用户隐私保护。资源包共1个docx文件压缩后约16KB篇幅精炼、结构清晰适合作为入门梳理与项目参考。目前已有629人学习读者可从中获得从基础到实战的完整知识框架与可直接借鉴的示例代码。1. 从一次失败的抓取说起Python 爬虫到底解决什么问题某开发者接到一个需求每周从十几个公开信息页上把表格数据扒下来做横向对比。第一版脚本四十来行requests拿页面、BeautifulSoup找标签、pandas存 Excel跑了两天一切正常第三天开始出问题一部分页面返回空列表一部分直接 403还有一部分拿回来是乱码。他花了一整晚在解析代码里找 bug最后发现解析器根本没问题——是请求层没有超时、没有重试、没有节流站点侧把他当成了异常流量再加上把请求、解析、存储塞在同一段脚本里任何一层出问题都像黑匣子。这件事的教训很朴素Python 爬虫不是「发个 GET 然后 find_all」这么简单网络数据采集真正难的是把请求层、解析层、存储层切成三段每段都能单独测、单独调、单独替换。这篇笔记就按这三段拆一遍再补上踩过的坑和一套验证自己抓得对不对的办法。2. 请求层怎么搭Session 复用、超时与重试的三个显式参数请求层是整个爬虫的地基也是最容易被轻视的一层。新手常见写法是每次requests.get(url)一行了事代码没错但隐含了三个默认值没有超时可能挂死、没有连接复用每个 URL 新建一次 TCP 握手、没有重试策略一次抖动就丢数据。抓几十上百个页面时这些差别不明显抓上万条就是天壤之别。2.1 为什么先封请求层再写解析逻辑解析逻辑依赖页面内容页面内容依赖请求是否成功。如果两件事写在一起你看到「解析出 0 条」时无法判断是选择器写错还是根本没拿到正确 HTML。分开之后判断链条就很清晰先看状态码再看响应长度再看响应内容里有没有关键锚点最后才怀疑选择器。我一般把请求层封成一个函数输入 URL、输出「状态码 文本 最终 URL」三元组解析层只接收文本。这样解析层可以在本地用保存下来的 HTML 反复调试不用每次都打真实站点既快又不给对方制造无谓压力。这也是后续做回归验证的基础——把一批 HTML 落盘就等于有了一个不会变的测试集。另一个理由是错误处理的位置。请求失败超时、连接重置、5xx和解析失败字段缺失、结构变更是两类完全不同的故障处理策略也不同前者应该重试后者重试一百次也没用。分层之后重试只发生在请求层解析层遇到缺字段就记None继续往下走两者互不干扰。2.2 Session 复用连接池与请求头的统一入口requests.Session的核心价值是两个连接复用和默认头。连接复用通过HTTPAdapter挂载把同一 host 的 TCP 连接留在池子里几千次请求的握手开销能砍掉一大截。请求头则决定了对方怎么看你——不是让你去伪装成浏览器而是把「我是谁、我抓什么、怎么联系我」写清楚这既是礼貌也是自保。import requests from requests.adapters import HTTPAdapter def build_session(pool_size: int 10) - requests.Session: 构造一个复用的会话统一请求头 连接池。 s requests.Session() s.headers.update({ # 标识自身而不是冒充浏览器便于对方在日志里识别并联系 User-Agent: DataCollector/1.0 (contact:>import time import random import requests from urllib3.util.retry import Retry RETRY_POLICY Retry( total3, # 总重试次数上限 connect3, # 连接阶段最多重试 3 次 read2, # 读取阶段最多重试 2 次 backoff_factor1.5, # 退避倍率1.5 - 3 - 6 秒 status_forcelist(429, 500, 502, 503, 504), allowed_methodsfrozenset([GET, HEAD]), # 只重试幂等方法 raise_on_statusFalse, # 不抛异常交回调用方判断 ) def polite_sleep(base: float 1.2, jitter: float 0.6) - None: 请求间隔基础间隔 随机抖动避免整齐的请求节奏。 time.sleep(base random.uniform(0, jitter)) def fetch(session: requests.Session, url: str, timeout(3.05, 15)): 发一次请求返回 (状态码, 文本, 最终URL, 错误信息)。 try: resp session.get(url, timeouttimeout, allow_redirectsTrue) except requests.Timeout: return (None, , url, timeout) except requests.ConnectionError as e: return (None, , url, fconn:{e.__class__.__name__}) # 状态码分流不同区段走不同处理路径 if resp.status_code 200: resp.encoding resp.apparent_encoding # 处理缺少 charset 的页面 return (200, resp.text, resp.url, None) if resp.status_code in (403, 401): return (resp.status_code, , resp.url, access_denied) if resp.status_code 429: return (429, , resp.url, rate_limited) return (resp.status_code, , resp.url, unexpected)timeout(3.05, 15)里的 3.05 是requests文档推荐的连接超时值略大于 3 秒能覆盖三次 TCP SYN 重传读取超时 15 秒足够应对大多数公开页面动态渲染页面另说。状态码分流比「失败了就重试」精细得多403 和 401 说明对方明确拒绝重试无意义应当停下来检查请求头、路径和robots.txt429 是明确的限流信号正确做法是加大间隔甚至暂停而不是换 IP 硬顶5xx 才是临时性故障交给 Retry 处理。把这三类混在一起重试是最常见的把小事搞大的方式。最后是节流。无论对方有没有明说公开站点的单 IP 请求间隔保持在秒级是最低限度的礼貌。polite_sleep里加随机抖动是为了避免请求时间戳过于整齐——整齐的节奏在服务端日志里非常显眼属于自己给自己找麻烦。提示抓取前先读robots.txt看Disallow路径和Crawl-delay。跳过这一步后面所有的技术优化都没有意义。3. 解析层选型BeautifulSoup、lxml 与 Scrapy 选择器怎么分工拿到 HTML 之后解析层的任务是把它变成一个字段齐全的字典。这一层的选型争议最大网上常见的说法是「BeautifulSoup 简单、lxml 快、Scrapy 全」但这句话对实际写代码没什么指导意义因为三者解决的根本不是同一个问题。3.1 三种解析路径的定位差异BeautifulSoup是一个容错型文档树封装它的真正优势是「喂进去烂 HTML 也不炸」。真实页面里标签不闭合、属性缺引号、嵌套错乱是常态用严格解析器会直接抛异常而 BeautifulSoup 会尽量修补出一棵树。代价是速度慢且必须指定后端解析器不指定会走html.parserPython 内置最慢但零依赖指定lxml则把解析工作交给 C 实现。lxml直接提供etree走 XPath 路线。它的价值在于 XPath 表达能力强contains(class,item)、following-sibling::、string(.)这些在 CSS 选择器里没有对应写法。处理规整的结构化页面时XPath 一条表达式就能替代十几行 find 调用。Scrapy不是解析库是框架它把调度、下载、去重、管道都包好了你写的parse方法里拿到的是已经下好的response对象。它的优势在规模——几千上万条、需要并发、需要增量去重时自己拿requests拼出来的东西很快就会变成半个 Scrapy。场景推荐选择理由页面结构乱、量小、一次性脚本BeautifulSoup lxml后端容错好调试方便改选择器快结构规整、需要复杂定位表达式lxmletree XPath表达能力强解析速度快一个量级数万条以上、要并发和增量Scrapy调度、去重、管道、重试都是现成的页面里嵌 JSON正则或json.loads直取不必解析整棵树成本最低最后一行值得单独说很多页面的数据其实在script标签里以 JSON 形式存在直接正则截出来json.loads比走 DOM 树快得多、也稳得多。先看页面结构再选工具比先选工具再硬套要好。3.2 CSS 选择器与 XPath 的实战写法下面三种写法抓的是同一份结构可以对照着看差异。假设页面长这样div classlist div classitem hot h3 classtitle标题一/h3 span classprice128.00/span /div /divBeautifulSoup 版本from bs4 import BeautifulSoup def parse_with_bs(html: str) - list[dict]: soup BeautifulSoup(html, lxml) # 显式指定后端避免退化成 html.parser rows [] for item in soup.select(div.list div.item): title_el item.select_one(h3.title) price_el item.select_one(span.price) rows.append({ # 缺字段返回 None而不是让整批任务崩掉 title: title_el.get_text(stripTrue) if title_el else None, price: price_el.get_text(stripTrue) if price_el else None, }) return rowsXPath 版本from lxml import etree def parse_with_xpath(html: str) - list[dict]: tree etree.HTML(html) if tree is None: # HTML 损坏到无法建树时返回 None return [] rows [] # contains(class,item) 兼容 item / item hot 这类多类名情况 for node in tree.xpath(//div[classlist]/div[contains(class,item)]): # string() 会把子树内所有文本拼成一段比 //text() 更好用 title node.xpath(string(.//h3[classtitle])).strip() price node.xpath(string(.//span[classprice])).strip() rows.append({title: title or None, price: price or None}) return rowsScrapy 的选择器版本def parse(self, response): for item in response.css(div.list div.item): yield { # .get(default) 避免字段缺失时返回 None 引发的下游报错 title: item.css(h3.title::text).get(default).strip(), price: item.css(span.price::text).get(default).strip(), }三者的关键差异在这几处contains(class,item)只在 XPath 里能写CSS 的.item匹配多类名时靠的是分词匹配行为一致但表达不如前者直观string(.//h3)会把子孙节点文本全拼起来对于「标题里嵌套了em高亮」这种结构它比//text()再用join更省事get(default)是 Scrapy 选择器特有的兜底参数用原生lxml时得自己写or 。还有一个高频问题select_one和xpath在节点不存在时的返回值不同。BeautifulSoup 返回Nonelxml 返回[]Scrapy 的.get()不传 default 时也返回None。写抽取函数时统一成「缺了就是None或空串」下游的清洗逻辑才好写。3.3 把散落字段拼成一条记录解析的终点不是「取到标题」而是「组装出一条带溯源信息的记录」。除了业务字段我一般会固定塞三个字段source站点标识、url取自response.url跟随重定向后的最终地址、crawled_atUTC 时间戳。前两个是去重键的基础第三个是排查数据新鲜度的依据。拼接时要注意两件事。一是字段清洗不能放在解析层——解析层只负责「取到原生文本」去空格、去货币符号、转类型这些放到清洗层这样同一份 HTML 可以产出多种清洗口径调试时也方便对比原始值。二是异常要就地吞掉并打日志一条记录的某个字段取不到不应该导致整批任务中断。常见的写法是抽一个safe_text工具函数内部 try 一次失败返回None。def safe_text(node, defaultNone): 任何字段抽取都走这里单条失败不影响整批。 if node is None: return default try: text node.get_text( , stripTrue) if hasattr(node, get_text) else node return text or default except Exception: return defaultget_text( , stripTrue)用空格作分隔符能避免spanA/spanspanB/span被拼成AB这个问题在价格和单位组合的字段上特别容易翻车。4. 数据落地从 CSV 到 SQLite 的三级存储方案存储层的选择取决于两个问题这份数据要不要多次跑、要不要支持重跑覆盖。跑一次就完的小脚本CSV 足够每周跑一次、需要去重和更新的就该上 SQLite 了。4.1 什么时候 CSV 就够用单次抓取、字段少、不需要去重、后续直接丢进分析工具这四种情况 CSV 最省事。pandas.DataFrame.to_csv一行搞定注意两个参数encodingutf-8-sig加 BOM 是为了让 Excel 双击打开不乱码indexFalse不写行号。import pandas as pd def save_csv(rows: list[dict], path: str) - None: df pd.DataFrame(rows) # utf-8-sig 让 Excel 按 UTF-8 识别避免双击打开变乱码 df.to_csv(path, indexFalse, encodingutf-8-sig)CSV 的边界也很明确没有唯一约束重跑就重复没有类型读回来全是字符串没有事务写一半崩了就留下半截文件。一旦出现「需要增量更新」的需求就该换存储了。4.2 SQLite 建表、唯一键与 upsertSQLite 是单文件数据库零部署非常适合抓取场景。核心设计是一张表加一个UNIQUE约束把(source, url)作为业务主键然后用ON CONFLICT ... DO UPDATE做更新——这样同一个页面重复抓取时是覆盖而不是追加重跑是幂等的。import sqlite3 DDL CREATE TABLE IF NOT EXISTS items ( id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL, -- 站点标识用于多站点共表 url TEXT NOT NULL, -- 跟随重定向后的最终地址 title TEXT, price REAL, crawled_at TEXT NOT NULL, -- ISO8601 字符串UTC UNIQUE(source, url) -- 业务唯一键去重的依据 ); UPSERT INSERT INTO items (source, url, title, price, crawled_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(source, url) DO UPDATE SET title excluded.title, price excluded.price, crawled_at excluded.crawled_at; def save_sqlite(rows: list[dict], db_path: str data.sqlite3) - int: conn sqlite3.connect(db_path) try: conn.execute(PRAGMA journal_modeWAL;) # 读写并发更友好 conn.executescript(DDL) # executemany 走单事务批量写入比逐条 commit 快一到两个数量级 conn.executemany(UPSERT, [ (r[source], r[url], r[title], r[price], r[crawled_at]) for r in rows ]) conn.commit() return len(rows) finally: conn.close()参数说明journal_modeWAL让读和写可以并行抓取进程写、分析进程读的时候不会互相阻塞executemany把整批放进一个事务失败自动回滚不会出现半截数据excluded.x引用的是本次冲突行的新值这是 SQLite 的 upsert 语法。注意UNIQUE约束必须建在表上事后加会触发全表重建数据量大时很慢。4.3 字段清洗与类型收敛从页面取到的永远是字符串落到库里要收敛成确定的类型。价格转REAL或Decimal涉及金额计算用Decimal日期统一成 ISO8601 的 UTC 字符串布尔值统一成 0/1。转换失败一律返回None不要悄悄填 0——None表示「不知道」0 表示「确实是零」两者在后续统计里含义完全不同。import re from datetime import datetime, timezone PRICE_RE re.compile(r[-]?\d[\d,]*\.?\d*) def to_price(text) - float | None: 从带货币符号和千分位的文本里抠出数值。 if not text: return None # 全角逗号统一成半角这类脏数据极常见 normalized str(text).replace(, ,) m PRICE_RE.search(normalized) if not m: return None try: return float(m.group(0).replace(,, )) except ValueError: return None def utc_now() - str: return datetime.now(timezone.utc).strftime(%Y-%m-%dT%H:%M:%SZ)PRICE_RE里[-]?是为了兼容负数折扣、差额类字段会用到[\d,]*吃掉千分位\.?让小数部分可选。这个表达式不追求严格校验只要能抠出数值就行后续用范围校验兜底更省事比如价格超过合理区间就置None并记一条告警。注意抓下来的时间一律用 UTC 存展示层再转本地时区。混用本地时间跨时区合并数据时会出现无法解释的排序错乱。5. 避坑排查五条翻车记录与对应的定位方法这一章是整篇里最有价值的部分因为下面每一条都是真实撞过的墙。写出来不是为了吓人是想让你在遇到同样现象时能跳过那几个小时的瞎猜。5.1 现象状态码 200但解析出来 0 条原因最常见的是拿到的根本不是内容页而是同意条款页、地区选择页或登录跳转页。这类页面状态码也是 200长度也不小但结构完全不同。第二种可能是响应被压缩后没有正确解码resp.text拿到的是一串乱码自然匹配不到选择器。解决先打印resp.url看最终地址是否被重定向到了别处再打印len(resp.text)和文本前 200 个字符肉眼确认内容。用resp.apparent_encoding覆盖resp.encoding能解决大部分乱码引起的匹配失败。日常调试时保存一份 HTML 到本地用固定样本复现比反复请求真实页面高效得多。5.2 现象中文全部变成æ•°æ®这种形状原因服务端没在响应头里声明字符集requests就按 HTTP/1.1 的默认值 ISO-8859-1 解码UTF-8 字节被按单字节解了就出现经典的「中」现象。解决请求返回后立刻设resp.encoding resp.apparent_encoding它基于内容做编码探测比依赖响应头可靠。如果页面是 GBK也可以手动指定resp.encoding gbk。更隐蔽的一种情况是文件写入阶段没指定编码Windows 下默认走 GBK读回来就乱了——写 CSV 一律utf-8-sig写文本一律encodingutf-8。5.3 现象翻页抓到第 N 页开始内容全是重复的原因两种可能。一是页码参数到了尾页之后不报错直接返回最后一页的内容于是你一直抓一直拿到同样的东西。二是页面用了「加载更多」的分页页码参数实际无效永远返回第一页。解决抓取循环里加一个「与上一页内容对比」的判断哈希一下内容或比对条目标识连续两页相同就主动终止。同时在循环上设硬上限比如最多 200 页防止死循环。如果是加载更多类型去 Network 面板看真实请求参数改抓接口而不是页面。5.4 现象开了 20 个线程之后成功率从 95% 掉到 30%原因不是线程写错了是总请求速率超了对方的承受阈值。并发数不等于吞吐量对方按秒级窗口限流你 20 个线程一秒打 20 次触发限流后全部返回 429 或直接断开。解决并发数从 4 起步逐步加观察成功率曲线拐点在拐点之前停下。用信号量控制全局在途请求数而不是只控制线程数。遇到 429 就整体退避指数退避 抖动别硬顶。更根本的做法是先确认robots.txt里的Crawl-delay按它来。5.5 现象Excel 打开后长数字变科学计数法前导零消失原因CSV 没有类型概念Excel 按自己的规则推断超过 11 位的数字自动转科学计数法以 0 开头的字符串被当数字处理前导零丢掉。解决这类字段编号、手机号、邮编写入时统一加制表符前缀或写成00123的形式成本最低或者干脆不用 CSV直接落 SQLite 或 xlsx用openpyxl显式指定单元格格式为文本。经验上只要字段里出现「编号」两个字就默认按文本处理。提示这五条里前三条占了排查时间的八成。养成「拿到响应先看三类信息最终 URL、长度、前 200 字符」的习惯能省掉大量猜测。6. 进阶技巧用一次小规模回放验证你的爬虫是否可信爬虫写完之后最危险的状态是「跑通了但抓错了」。跑了十万条看起来一切正常实际上有三成字段是None还有两成是重复的——这种问题不会报错只会让你在分析阶段得出错误结论。我现在的习惯是每次上线前做一次小规模回放验证。具体做法从目标站点随机抽 30 个 URL把页面完整保存到本地目录同时记录抓取结果。然后写一个校验脚本对着本地样本重跑解析比对三组指标。指标计算方式合格线说明字段完整率非空字段数 / 总字段数关键字段 ≥ 95%低于就得查选择器记录唯一率去重后条数 / 总条数≥ 98%低于说明去重键选错了状态码分布各状态码占比200 占比 ≥ 95%其余要能解释校验脚本可以很短import json from pathlib import Path from collections import Counter def verify(sample_dir: str, parse_fn): 对本地保存的 HTML 样本重跑解析输出三项指标。 files sorted(Path(sample_dir).glob(*.html)) rows, status [], Counter() for f in files: html f.read_text(encodingutf-8, errorsignore) result parse_fn(html) status[parsed if result else empty] 1 rows.extend(result) keys [title, price] total len(rows) * len(keys) or 1 filled sum(1 for r in rows for k in keys if r.get(k)) unique len({(r.get(url), r.get(title)) for r in rows}) print(f样本数{len(files)} 记录数{len(rows)}) print(f字段完整率{filled/total:.1%}) print(f记录唯一率{unique/len(rows):.1%} if rows else 无记录) print(f解析状态分布{dict(status)}) return rows这里的逻辑很直白把「抓」和「解」彻底分开样本目录就是不会变的输入parse_fn是待验证的解析函数三项指标是判定标准。完整率低说明选择器没匹配上唯一率低说明去重键不够或者页面有重复区块解析状态里empty占比高说明有一批页面结构异于预期。这套方法最大的好处是「可重复」。站点改版之后重新抓 30 个样本丢进去指标一掉就知道出事了不用等全量跑完才发现数据不对。我曾经因为跳过这一步在站点改版后继续跑了两周直到下游做同比分析时发现某字段全是空值回头补数据花的时间是验证成本的几十倍。从那以后我每次改选择器、换解析器、调整并发参数都强制走一遍这 30 个样本的回放指标不过关就不上量。希望帮到你。本文还有配套的精品资源点击获取