Python爬虫学习框架:从请求到存储的完整技术路径
简介:这份PDF面向零基础到进阶的Python爬虫学习者,系统梳理了从入门到实战的完整知识框架,帮助初学者解决「学什么、怎么学、在哪里学」的困惑。内容围绕爬虫三部曲展开:获取数据环节讲解urllib、requests、aiohttp等请求库及异步抓取思路;解析数据环节覆盖pyquery、BeautifulSoup、XPath与正则表达式的定位技巧;数据存储环节则涉及TXT、JSON、CSV及MySQL、MongoDB等方案,并延伸至代理IP、请求头伪装、Selenium动态渲染与分布式爬虫等进阶技巧,同时提醒Robots协议与合规边界。资源包为1个PDF文件,大小约2.1MB,结构紧凑便于随时查阅。目前已有174人学习,适合希望快速建立爬虫知识体系、按图索骥补齐短板的开发者参考。
1. 爬虫的本质:为什么你写的代码能“看见”网页背后的数据
很多人第一次写爬虫,代码跑通了,数据也拿到了,但心里其实没底——为什么我发一个请求,服务器就把数据给我了?浏览器里明明有图片、有样式、有交互,怎么我拿到的是一堆尖括号?这个问题的答案,决定了你后面是在“抄代码”还是在“写爬虫”。
Python 爬虫的完整学习框架,本质上是在回答一件事:如何让程序代替人,完成“打开网页 → 找到数据 → 拿走数据”这个动作。浏览器做的事是提交请求、下载代码、解析渲染成页面;爬虫做的事是模拟请求、下载代码、只提取有用数据、存进文件或数据库。两者的分水岭在于:浏览器关心“怎么好看”,爬虫关心“数据在哪”。理解了这个分水岭,你才能明白为什么解析库、请求库、存储方案会演化出这么多分支——每一个分支都在解决“数据在哪”这个问题的不同侧面。这份学习框架把这条路径拆成了可执行的阶段,接下来逐层拆开看。
2. 请求层:urllib、requests、aiohttp 到底在解决什么
2.1 从 urllib 到 requests:HTTP 请求的抽象代价
Python 标准库里的 urllib 是内置的 HTTP 请求工具,不需要额外安装,但它对开发者不太友好。用 urllib 发一个带参数的 GET 请求,你需要手动拼接 URL、处理编码、构造 Request 对象、读取响应、解码字节流。代码写出来大概是这样:
import urllib.request import urllib.parse # 构造请求参数 params = urllib.parse.urlencode({'keyword': 'python爬虫', 'page': 1}) url = 'https://example.com/search?' + params # 构造 Request 对象,设置请求头 req = urllib.request.Request(url) req.add_header('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)') # 发起请求并读取响应 with urllib.request.urlopen(req, timeout=10) as resp: html = resp.read().decode('utf-8')这段代码能跑,但问题在于:每次发请求都要重复这套模板,参数编码、请求头设置、超时处理、异常捕获全部散落在业务逻辑里。requests 库的价值就在于把这些重复动作封装成一行调用。同样的请求用 requests 写:
import requests # 一行完成请求,参数自动编码,请求头通过 headers 传入 resp = requests.get( 'https://example.com/search', params={'keyword': 'python爬虫', 'page': 1}, headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'}, timeout=10 ) html = resp.text # 自动根据响应头推断编码requests 的 slogan 里写着“非转基因”,意思是它没有隐藏的魔法——你传什么参数,它就发什么请求。这种透明性对调试非常重要。当你发现请求返回 403 时,可以直接打印resp.request.headers看到实际发出的请求头,对比浏览器开发者工具里的请求,逐项排查差异。
但 requests 是同步的。同步意味着:发一个请求,程序就停在那里等响应,响应回来之前什么都做不了。如果目标网站响应慢,或者你要抓 100 个页面,同步代码就是“发一个等一个”,总耗时是每个请求耗时之和。这就是异步请求库要解决的问题。
2.2 aiohttp 与异步:为什么 100 次请求能从 30 秒压到 3 秒
同步请求的耗时瓶颈不在 CPU,而在网络 I/O。程序发出请求后,大部分时间都在等服务器响应,CPU 是空闲的。异步编程的核心思路是:在等待响应的间隙,把 CPU 让出去处理其他请求。aiohttp 基于 asyncio 事件循环实现这一点,用 async/await 语法写出来:
import aiohttp import asyncio async def fetch(session, url): # async with 确保响应对象在使用后正确释放 async with session.get(url, timeout=10) as resp: return await resp.text() async def main(urls): # 创建一个共享的 session,复用 TCP 连接 async with aiohttp.ClientSession() as session: # gather 并发调度所有请求 tasks = [fetch(session, url) for url in urls] return await asyncio.gather(*tasks) urls = [f'https://example.com/page/{i}' for i in range(100)] results = asyncio.run(main(urls))关键在asyncio.gather:它把所有请求任务同时提交给事件循环,事件循环在某个请求等待响应时,自动切换到下一个请求去发送。100 个请求的耗时不再累加,而是接近单个最慢请求的耗时。原文提到“同步 100 次至少 30 秒,异步只需 3 秒左右”,这个差距的来源就是 I/O 等待时间的重叠。
但异步不是没有代价的。aiohttp 的代码结构比 requests 复杂,调试难度更高,异常堆栈也更难读。而且异步的优势只在 I/O 密集场景下成立——如果你的瓶颈在 CPU 解析,异步帮不上忙。常见的做法是:请求层用 aiohttp 并发抓取,解析层用同步代码处理已下载的 HTML,两者通过队列衔接。
2.3 动态页面:Selenium 和抓包分析的分工
有些页面的数据不在初始 HTML 里,而是通过 JavaScript 异步加载的。你右键查看源代码,找不到目标数据,但浏览器里能看到。这时候有两种策略:
第一种是抓包分析。打开浏览器开发者工具的 Network 面板,刷新页面,找到实际返回数据的 XHR/Fetch 请求,直接模拟那个请求。这种方式的优点是快、轻量,缺点是接口可能有签名校验、token 过期等机制,需要逆向分析。
第二种是 Selenium。它驱动一个真实浏览器,等页面渲染完成后再提取数据。代码大致是:
from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC driver = webdriver.Chrome() driver.get('https://example.com/dynamic') # 显式等待目标元素出现,最多等 10 秒 element = WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.CSS_SELECTOR, '.data-list')) ) items = driver.find_elements(By.CSS_SELECTOR, '.data-item') for item in items: print(item.text) driver.quit()Selenium 的代价是慢——启动浏览器、渲染页面、执行 JavaScript 都需要时间。它适合页面交互复杂、接口难以逆向的场景,不适合大规模并发抓取。选择哪种方式,取决于目标站点的反爬强度和你的抓取规模。
3. 解析层:HTML 字符串怎么变成结构化数据
3.1 解析器的选择:lxml、BeautifulSoup、pyquery、正则的取舍
拿到 HTML 字符串之后,下一步是从中提取目标数据。HTML 本质上是嵌套的标签树,解析器的任务就是把这棵树构建出来,然后让你用某种语法去定位节点。
lxml 是底层解析引擎,用 C 语言实现,解析速度快。BeautifulSoup 是上层封装,可以挂载不同的解析器(包括 lxml),API 更友好。pyquery 同样基于 lxml,但提供了 jQuery 风格的 CSS 选择器语法。正则表达式则是直接对字符串做模式匹配,不构建树。
用 BeautifulSoup 提取数据的典型写法:
from bs4 import BeautifulSoup html = '<div class="article"><h2>标题</h2><p class="content">正文内容</p></div>' soup = BeautifulSoup(html, 'lxml') # 指定 lxml 作为底层解析器 # 通过 CSS 选择器定位 title = soup.select_one('.article h2').get_text(strip=True) content = soup.select_one('.article .content').get_text(strip=True)用 pyquery 做同样的事:
from pyquery import PyQuery as pq doc = pq(html) title = doc('.article h2').text() content = doc('.article .content').text()两者的区别在于语法风格。pyquery 的链式调用更接近前端开发习惯,BeautifulSoup 的 API 更 Pythonic。性能上,pyquery 直接基于 lxml,通常比 BeautifulSoup 快一些,但差距在大多数场景下可以忽略。
正则表达式的定位方式完全不同:
import re html = '<div class="article"><h2>标题</h2><p class="content">正文内容</p></div>' title_match = re.search(r'<h2>(.*?)</h2>', html) content_match = re.search(r'<p class="content">(.*?)</p>', html) title = title_match.group(1) if title_match else '' content = content_match.group(1) if content_match else ''正则的优势是速度极快,不需要构建 DOM 树。但它的缺点是脆弱——HTML 结构稍有变化,正则就可能匹配失败或匹配到错误内容。而且正则不处理 HTML 的嵌套结构,遇到多层嵌套标签时几乎无法正确提取。
选择建议:结构规整的页面用 CSS 选择器(pyquery 或 BeautifulSoup),需要复杂条件筛选时用 XPath,数据格式极其简单且量大时可以考虑正则。原文提到“前端基础扎实的用 pyquery 最方便”,这个判断是合理的——CSS 选择器的思维模型和前端开发一致,学习成本最低。
3.2 XPath 与 CSS 选择器:两种定位思维的差异
XPath 和 CSS 选择器解决的是同一个问题:在树结构中定位节点。但它们的表达方式不同。
CSS 选择器从外向内匹配,语法简洁:.article .content表示“article 类下面的 content 类”。XPath 则更像路径表达式://div[@class="article"]//p[@class="content"],从根节点或任意位置开始,逐层定位。
XPath 的优势在于它可以向上查找父节点、可以按文本内容筛选、可以做数值比较。比如“找到包含‘下一页’文本的链接”:
# XPath 写法 next_page = tree.xpath('//a[contains(text(), "下一页")]/@href') # CSS 选择器无法直接按文本内容筛选,需要先取所有 a 标签再遍历 links = soup.select('a') next_page = [a['href'] for a in links if '下一页' in a.get_text()]XPath 提供了超过 100 个内建函数,覆盖字符串处理、数值计算、日期比较等。在需要复杂条件筛选时,XPath 的表达能力更强。但 CSS 选择器的可读性更好,维护成本更低。实际项目中,两者经常混用——简单定位用 CSS,复杂条件用 XPath。
4. 存储层:从 TXT 到 Redis,数据量决定方案
4.1 文件存储的边界在哪里
数据量小的时候,存成 TXT、JSON、CSV 都行。JSON 适合嵌套结构,CSV 适合表格数据,TXT 适合纯文本。用 Python 写文件很简单:
import json import csv # JSON 存储:适合嵌套结构 data = [{'title': '标题1', 'tags': ['python', '爬虫']}, {'title': '标题2', 'tags': ['数据', '分析']}] with open('output.json', 'w', encoding='utf-8') as f: json.dump(data, f, ensure_ascii=False, indent=2) # CSV 存储:适合扁平表格 with open('output.csv', 'w', newline='', encoding='utf-8') as f: writer = csv.DictWriter(f, fieldnames=['title', 'url']) writer.writeheader() writer.writerow({'title': '标题1', 'url': 'https://example.com/1'})文件存储的问题在于:查询困难、并发写入不安全、数据量大时读写效率急剧下降。当你需要“找出所有包含某个关键词的记录”时,文件方案只能全量加载再遍历。当数据量到百万级时,内存可能扛不住。
4.2 MySQL、MongoDB、Redis 的分工
MySQL 是关系型数据库,数据存在表里,表有固定的列定义。适合结构化数据,支持 SQL 查询、事务、索引。爬虫场景下,MySQL 常用来存储需要关联查询的数据,比如商品信息和分类信息的关联。
MongoDB 是文档型数据库,数据以类似 JSON 的文档形式存储,不需要预先定义表结构。适合非结构化数据,比如评论内容、图片链接、动态字段。用 PyMongo 操作:
from pymongo import MongoClient client = MongoClient('localhost', 27017) db = client['spider_db'] collection = db['articles'] # 插入文档,字段可以动态变化 collection.insert_one({ 'title': '文章标题', 'content': '正文内容', 'tags': ['python', '爬虫'], 'crawled_at': '2024-01-01' }) # 查询:查找包含 python 标签的文章 results = collection.find({'tags': 'python'})MongoDB 的优势在于 schema 灵活——不同页面的数据结构不一样时,不需要改表结构。而且它的查询语法接近自然语言,学习成本低。
Redis 是内存数据库,数据存在内存里,读写速度极快。爬虫场景下,Redis 主要用来做两件事:一是去重,用 Set 结构记录已抓取的 URL;二是做分布式爬虫的任务队列,多个爬虫实例从同一个 Redis 队列里取任务。
import redis r = redis.Redis(host='localhost', port=6379, db=0) # 去重:如果 URL 已存在,sadd 返回 0 url = 'https://example.com/page/1' if r.sadd('crawled_urls', url): print('新 URL,继续抓取') else: print('已抓取过,跳过') # 任务队列:生产者推入,消费者弹出 r.lpush('task_queue', 'https://example.com/page/2') task = r.rpop('task_queue')选择哪种存储方案,取决于数据特征和访问模式。结构化且需要关联查询的用 MySQL,非结构化且字段多变的用 MongoDB,需要高速去重或队列的用 Redis。实际项目中经常组合使用:Redis 做去重和队列,MongoDB 存原始数据,MySQL 存清洗后的结构化数据。
5. 避坑与排查:爬虫跑不起来时先看这几处
5.1 请求返回 403:不是代码问题,是身份问题
现象:requests 请求返回 403 Forbidden,但浏览器访问正常。
原因:服务器识别出请求不是来自真实浏览器。默认的 User-Agent 是python-requests/2.x,一眼就能被识别。
解决:设置真实的 User-Agent,并补齐常见请求头:
headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', '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', 'Referer': 'https://example.com/', }如果补齐请求头仍然 403,可能是 IP 被限制,需要降低请求频率或更换出口 IP。
5.2 解析结果为空:先确认拿到的是不是目标 HTML
现象:CSS 选择器写对了,但select_one返回 None。
原因:请求返回的 HTML 和浏览器看到的不一样。可能是页面重定向到了登录页,可能是返回了验证码页面,也可能是数据由 JavaScript 动态加载。
解决:先把resp.text保存到文件,用浏览器打开看实际内容。如果内容不对,检查请求 URL 是否正确、是否需要 Cookie。如果内容对但选择器不匹配,用浏览器的“检查元素”功能复制选择器路径,对比手写的选择器。
5.3 异步代码报 “Event loop is closed”
现象:aiohttp 代码在 Jupyter Notebook 或某些 IDE 里运行时报RuntimeError: Event loop is closed。
原因:asyncio 的事件循环在某些环境中会被提前关闭,或者被多次创建和销毁。
解决:在脚本入口处显式创建和管理事件循环:
import asyncio loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: loop.run_until_complete(main()) finally: loop.close()在 Jupyter 中,使用await main()而不是asyncio.run(main()),因为 Notebook 已经运行在事件循环中。
5.4 数据重复入库:去重逻辑写在了错误的位置
现象:同一篇文章被多次存入数据库。
原因:去重逻辑写在了解析之后、入库之前,但并发场景下多个协程可能同时通过去重检查。
解决:把去重放到入库操作里,利用数据库的唯一索引:
# MongoDB:对 url 字段建唯一索引 collection.create_index('url', unique=True) # 插入时捕获重复键异常 from pymongo.errors import DuplicateKeyError try: collection.insert_one({'url': url, 'title': title}) except DuplicateKeyError: pass # 已存在,跳过或者在 Redis 里用sadd的原子性做去重,确保同一 URL 只有一个协程能拿到“新 URL”的返回值。
5.5 爬取速度突然变慢:可能是触发了限流
现象:爬虫刚开始跑得很快,一段时间后响应时间明显变长,甚至大量超时。
原因:目标网站检测到异常流量,对 IP 或账号做了限流。限流策略可能是降低响应速度、返回空数据、或者直接封禁。
解决:在请求之间加入随机延迟,模拟人类操作节奏:
import time import random time.sleep(random.uniform(1, 3)) # 每次请求间隔 1-3 秒随机同时监控响应状态码和响应时间,发现异常时自动降速或暂停。如果限流严格,需要考虑使用多个出口 IP 轮换,但这涉及更复杂的架构设计。
6. 框架选型:Scrapy、PySpider、Nutch 的设计取舍
轻量级爬虫能解决大部分日常需求,但当项目规模变大——需要管理成百上千个爬虫、需要分布式部署、需要监控和调度——手写代码的维护成本会急剧上升。这时候需要引入爬虫框架。
Scrapy 是 GitHub 上 star 数最高的 Python 爬虫框架。它的核心设计是“引擎 + 组件”架构:引擎负责调度,下载器负责发请求,爬虫负责解析,管道负责存储。组件之间通过信号和中间件解耦,你可以替换任意一个组件而不影响其他部分。Scrapy 的 Selector 基于 lxml,支持 CSS 和 XPath,解析效率高。它的异步下载器基于 Twisted,天然支持并发。学会 Scrapy 之后,你具备的是“爬虫工程师”的思维——把抓取任务拆解成可配置的组件,而不是写一堆脚本。
PySpider 的定位不同。它提供了一个 Web UI,你可以在浏览器里编写脚本、调度任务、查看结果。后端用 MySQL 或 MongoDB 存储,支持定时任务和失败重试。PySpider 更像一个产品而不是框架——适合需要可视化管理和快速上手的场景。但它的灵活性不如 Scrapy,深度定制时反而更麻烦。
Nutch 是 Apache 顶级项目,定位是搜索引擎解决方案。它支持分布式抓取,底层依赖 Hadoop 做存储和索引。Nutch 的插件框架允许扩展解析、过滤、查询等功能。但 Nutch 的学习曲线最陡,配置复杂,适合需要构建搜索引擎级抓取系统的团队。
三者的取舍可以这样理解:Scrapy 给你最大的控制权,PySpider 给你最快的上手速度,Nutch 给你最强的扩展能力。原文建议“先从 Scrapy 学起”,这个路径是合理的——Scrapy 的架构设计能帮你建立工程化思维,之后再接触 PySpider 或 Nutch 时,能更快理解它们的设计取舍。
我自己的习惯是:每次启动一个新爬虫项目之前,先花十分钟想清楚三件事——数据量级有多大、目标站点反爬强度如何、后续是否需要持续维护。这三个问题的答案直接决定选型:小规模、低反爬、一次性任务,requests + BeautifulSoup 就够了;中等规模、需要调度和重试,上 Scrapy;大规模、需要分布式和可视化,再考虑 PySpider 或 Nutch。从那以后我每次选型都强制走一遍这个判断,避免了一上来就上重框架、结果大部分功能用不上的尴尬。希望帮到你。
本文还有配套的精品资源,点击获取