先交代背景:图书类网站的数据量不小,但结构相对规整,非常适合拿来做爬虫练手项目。我做过不少爬虫项目,图书爬虫算是性价比很高的一类——它既能练到请求发送、页面解析、数据存储这些基本功,又能在反爬策略、并发设计、增量更新上做很多文章。这篇博文就把我实际做的一个Python图书爬虫项目完整拆开讲,从设计思路到具体实现,再到后期维护,尽量把该说的坑都说清楚。
1. 整体设计与工具选型
1.1 图书爬虫是什么,拿它练手值不值
先说结论:值,而且是很值。图书数据的特殊性在于它的数据模型非常清晰,一本图书通常有书名、作者、出版社、ISBN、定价、出版日期、分类、简介、封面图链接这些字段。相比抖音爬虫、小红书爬虫要处理复杂的加密参数和动态渲染,图书网站大多还是传统的服务端渲染页面,数据直接嵌在HTML里,这对初学者极其友好,你可以把全部注意力放在爬虫本身的逻辑上,而不是去破解某些平台的签名算法。
我拿一个真实的图书电商页面来举例。当你打开任意一本书的详情页,浏览器按F12切到Elements面板,能看到书名挂在<h1>标签里,价格挂在<span class="price">里,ISBN挂在商品信息表格里。这些数据没有任何加密,就是纯静态HTML嵌入。Python爬虫要做的事情,说白了就是模拟浏览器去拿这些HTML,然后把有用的部分抽取出来。
整个项目做下来,你会走一遍一个爬虫工程师的完整工作流:分析目标网站结构、设计数据模型、编写请求逻辑、处理解析异常、设计存储方案、编写反爬应对策略、上线之后处理封IP和验证码。这些技能不是只会调个requests.get()就能掌握的,需要真实项目来磨。
1.2 技术栈选择的实际考量
核心工具只有四个:requests、BeautifulSoup4、lxml、pandas。后来量上来了,我换成了Scrapy框架重写了一遍,把并发调度和去重全部交给框架处理。
用requests而不是aiohttp,是因为第一版的目标是先把单线程跑通,把数据模型和存储方案验证好。requests的同步阻塞模型简单直接,哪里出错打哪里看,不会像异步代码那样一调三天,最后发现是事件循环的问题。单线程跑通之后,再处理并发才是有意义的优化,否则你连自己的爬虫正确性都没验证,就上并发,排查问题的时候并发和解析错误混在一起,非常痛苦。
解析用BeautifulSoup4配合lxml解析器,原因也简单:图书页面的数据在HTML里的位置虽然嵌套深,但结构规律性很强,用CSS选择器或find方法就能精准定位。Scrapy的Selector底层用的也是lxml,在Scrapy里写选择器的语法知识在这里完全复用。
数据存储第一版用的CSV文件,原因是可以随时打开Excel看数据质量。当数据量到了五万条以上、需要做增量更新时,我换成了SQLite,零配置、单文件、支持SQL查询,比CSV不知道高到哪里去了。如果你未来要做更复杂的查询或者要支持多人协作,再考虑MySQL。
1.3 为什么选这个目标网站作为示例
我平时做爬虫项目有个原则:除非是明确的竞对分析需求,否则优先选公开数据丰富、更新频率适中、反爬策略不极端的网站练手。图书类网站有很多公开的榜单页和搜索页,比如按分类浏览的图书列表、按出版时间排序的新书列表,这些都是爬虫练习很好的目标。
这类网站通常都有几个共性特征:页面有固定的URL分页规律、每条图书数据有独立详情页、列表页会展示书名和价格的摘要信息。这意味着你可以先用列表页做导航,拿到所有详情页的URL之后,再逐条请求详情页拿完整数据。两阶段爬取的设计,在真实项目里非常常见。
从这里也能引出一个重要的爬虫伦理话题:爬取公开页面数据用于个人学习是没问题的,但要注意几个前提条件——请求频率控制在合理范围(比如每秒不超过1次)、不抓取需要登录才能看到的非公开数据、不批量下载受版权保护的电子书文件。这是做爬虫的基本职业素养,一旦越过这条线,就不是技术问题了。
2. 图书数据模型与字段设计
2.1 字段规划的长期眼光
很多人写爬虫上来就写代码,爬到哪个字段存哪个字段,最后数据混乱到根本没法用。我建议先花20分钟把数据模型定义好。
我在这个项目里最终确定的字段如下:
| 字段名 | 类型 | 说明 | 示例 |
|---|---|---|---|
| book_id | string | 图书唯一标识,用ISBN优先 | 9787115428028 |
| title | string | 书名 | Python编程:从入门到实践 |
| author | string | 作者,多作者用/分隔 | 埃里克·马瑟斯 |
| publisher | string | 出版社 | 人民邮电出版社 |
| publish_date | date | 出版日期 | 2020-10-01 |
| price | float | 定价,统一转为小数 | 89.00 |
| rating | float | 评分,无评分为NULL | 9.1 |
| rating_count | int | 评价数量 | 12580 |
| category | string | 图书分类 | 程序设计 |
| summary | text | 内容简介 | 本书是一本针对所有层次的Python... |
| cover_url | string | 封面图链接 | https://.../cover.jpg |
| crawl_time | datetime | 爬取时间戳 | 2024-01-15 10:30:00 |
这里面有几个设计细节值得单独讲一讲。
ISBN作为主键的好处。ISBN是国际标准书号,一本书只要版本确定,ISBN就唯一。用ISBN当成主键字段,天然自带去重功能。第二次爬到这本书的时候,检查ISBN在数据库里是否存在,存在就跳过或者更新,不存在就插入。这比用书名当主键靠谱得多,因为书名有同名风险,而且同一本书的精装版和平装版书名可能完全一样,ISBN却不同。
价格字段用浮点数而不是字符串。网页上价格通常显示成"¥89.00"这种带货币符号的字符串,直接存字符串会导致后续做价格排序、区间筛选时全部抓瞎。清洗的时候用float(price_str.replace('¥', ''))这种处理方式,把价格统一转成数值。如果转换失败,用try-except捕获,置为None,而不是让程序崩溃。
分类字段做多级规划。很多书的分类是多层级的,比如"计算机 > 程序设计 > Python"。我第一版只存了最细粒度的分类"Python",后来做数据统计时发现没法按大类筛选。建议至少存两个字段,category_primary和category_secondary,或者直接把完整路径存成"计算机|程序设计|Python",后续用split('|')拆开就行。
2.2 单表还是分表,怎么选
图书爬虫的数据规模基本在万级到百万级之间,正常来说单表完全够用。我见过有人一上来就设计五六张表,作者表、出版社表、分类表、图书表,还加上外键关联,搞得跟企业级ERP一样。这在爬虫项目里是过度设计,因为爬虫数据的核心诉求是快速存储、方便查询、容易去重,不是事务一致性。
真正需要拆表的场景有两个:第一个是你要对图书的作者做独立的分析维度,比如统计同一个作者一共出了多少本书,这时候作者字段拆出来建表是合理的;第二个是你要做全量历史数据追踪,一本书的价格变动记录单独建表,每次爬取只插入价格快照,这个属于时序数据,单独存是合理的。
我这个项目里用的最简单方案,SQLite单表存储,所有字段平铺。数据量到了十万条级别的时候,给ISBN字段建一个索引,查询速度就有质的提升。建索引的SQL是CREATE INDEX idx_book_isbn ON books(isbn);,一行代码的事,效果显著。
2.3 存储层选择的硬性指标
说完字段,我实际对比过CSV、SQLite、MySQL三种方案,直接给对比结果:
| 存储方案 | 适合数据量 | 并发写入 | 查询能力 | 上手门槛 | 适用场景 |
|---|---|---|---|---|---|
| CSV文件 | 万条以下 | 不支持 | 弱,需pandas | 极低 | 快速验证、Excel可读 |
| SQLite | 百万条以下 | 单写多读 | 支持标准SQL | 低 | 单机爬虫项目主力方案 |
| MySQL/PostgreSQL | 千万条以上 | 支持 | 强,可联表 | 中 | 分布式爬虫、多人协作 |
这里要特别提醒CSV方案的坑:to_csv()写入时如果字段值里包含逗号、换行符、引号,CSV的文件格式就会被破坏,读取的时候所有列都会错位。我第一版爬虫就吃过这个亏,书上简介里全是换行符,导致整个CSV文件乱掉。最简单解决办法是存储前把字段里的换行符和逗号统一替换成空格,比如text.replace('\n', ' ').replace(',', ','),或者在读取时用pd.read_csv(..., engine='python'),能兜底解析一部分错误。
所以我在数据量上升到一定规模后果断换了SQLite,sqlite3是Python内置模块,不用安装任何依赖,也不用启动独立服务。写入时用executemany批量插入,一万条数据大概几秒钟就能写完。
3. 环境准备与requests爬虫核心实现
3.1 环境搭建避坑指南
先用一段实操记录说明环境怎么准备。有些人喜欢直接去Python官网下载安装包,我建议在Windows上用官方安装包,在macOS/Linux上优先用系统包管理器。安装的时候有一个关键选项:一定要勾选"Add Python to PATH",否则后面在命令行里执行python命令会提示找不到。
我建议装Python 3.10以上的版本,目前3.12也已经很稳定,新版对类型注解和异步编程的支持更好。装完检查版本:
python --version再用pip安装依赖库:
pip install requests beautifulsoup4 lxml pandas如果你是新手,强烈建议创建一个虚拟环境再装依赖,不然总有一天会在不同项目之间遇到依赖版本冲突的问题。
python -m venv book_scraper_envWindows上激活虚拟环境:
book_scraper_env\Scripts\activatemacOS/Linux上激活:
source book_scraper_env/bin/activate这一步是让项目环境独立的关键。虚拟环境里装什么都不会污染全局Python环境,以后换机器部署也方便,只要pip freeze > requirements.txt导出依赖清单就行。
3.2 第一步:用requests拿列表页
整个爬虫的第一步,是拿到包含图书列表的HTML页面。目标网站的列表页URL很有规律,翻页时只需要变化页码参数。这种方式在爬虫领域叫URL模板化,是所有爬虫项目的基础功。
import requests base_url = "https://books.example.com/catalog/list?page={}" page = 1 url = base_url.format(page) headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Connection": "keep-alive", } resp = requests.get(url, headers=headers, timeout=10) resp.encoding = resp.apparent_encoding # 自动检测编码 print(resp.status_code, len(resp.text))有几个细节我得重点说。
第一,User-Agent必须设置。虽然requests默认的UA是python-requests/2.x,但这个标识服务端能很容易识别出来,很反爬的第一道门槛就是要模拟正常的浏览器请求头,这算是最基本的礼仪。
第二,resp.encoding设置很重要。有些网站默认返回的编码不是UTF-8,而是GBK或者GB2312,尤其是国内的老牌网站。如果编码不对,你看到的response.text就是一坨乱码。用apparent_encoding是requests自动从响应内容推断编码的方式,大多数场景下比手动指定更稳。也可以更直接地在代码里固定指定resp.encoding = "utf-8",前提是你确认目标站点用的就是UTF-8。
第三,timeout参数必须设置。不设timeout,一旦目标站点响应慢或者连接被重置,你的爬虫可能会一直挂在那里,彻底卡死整个任务。设置10秒超时,请求失败就报异常,下一个循环继续。
3.3 第二步:用BeautifulSoup4解析列表页,提取详情页URL
拿到页面HTML之后,就该展示BeautifulSoup4的功力了。列表页的结构通常是每个图书条目包裹在一个<div class="book-item">或者<li>标签里,里面有一个指向详情页的<a>标签。
from bs4 import BeautifulSoup soup = BeautifulSoup(resp.text, "lxml") book_links = [] book_items = soup.select("div.book-item") for item in book_items: link_tag = item.select_one("a.book-title") if link_tag and link_tag.get("href"): book_links.append(link_tag["href"]) print(f"第{page}页找到{len(book_links)}个图书链接")这里的核心逻辑是select方法配合CSS选择器精准选取页面元素。我踩过的坑是用find_all不加选择条件,结果把广告区域、推荐位里的非图书链接也当成图书详情页链接抓进来了。正确做法是分析页面结构后,根据链接的class属性或者父级容器特征进行筛选。
还有一类情况是链接不是完整URL,而是相对路径,比如/book/12345.html。这种时候需要用urllib.parse.urljoin拼接完整地址:
from urllib.parse import urljoin full_url = urljoin(base_url, link_tag["href"])3.4 第三步:请求详情页,解析完整字段
详情页是数据最完整的地方,但也最容易出错,因为字段多、嵌套深。我的实战做法是写一个专门的parse_detail(html)函数,每个字段的解析都用try-except包起来,解析失败就返回空值或None,绝对不让单条数据的解析异常中断整个爬虫任务。
def parse_detail(html): soup = BeautifulSoup(html, "lxml") data = {} try: data["title"] = soup.select_one("h1.book-title").get_text(strip=True) except AttributeError: data["title"] = None try: price_text = soup.select_one("span.price").get_text(strip=True) data["price"] = float(price_text.replace("¥", "").replace("元", "").strip()) except (AttributeError, ValueError): data["price"] = None try: isbn_elem = soup.select_one("td:contains('ISBN') + td") if isbn_elem: data["isbn"] = isbn_elem.get_text(strip=True) except AttributeError: data["isbn"] = None # 简介可能较长,注意清洗换行 try: summary_elem = soup.select_one("div.book-summary") if summary_elem: data["summary"] = " ".join(summary_elem.get_text().split()) except AttributeError: data["summary"] = None return data这个函数里隐藏了不少实战心得。
get_text(strip=True)方法是BeautifulSoup里非常实用的方法,它会把标签里的所有文本提取出来,做成一个纯字符串。加strip=True参数之后,还会自动去除首尾空白字符。但要注意,它只会去掉首尾的空格和换行,文本中间换行符还是会保留,所以摘要里的换行问题还要多做一步" ".join(text.split())把连续空白符压缩成一个空格。
价格、评分这类字段在网页上显示是有格式的,比如"¥89.00元"或者"89元",直接用float()会报错。要先做字符串清理,再去掉货币符号,最后转换。这个流程我在代码里加了两层保护:第一层replace清除符号,第二层try-except兜底,遇到无法解析的脏数据就不强行转,置为None。
td:contains('ISBN') + td这种选择器非常取巧,利用的是CSS的相邻兄弟选择器,直接找到包含"ISBN"文本的单元格的前一个单元格后面的那个单元格。这个在实际页面解析中很有效。不过要注意,:contains()在标准CSS中并不存在,只有lxml的cssselect扩展支持。在BeautifulSoup里使用这个选择器时,实际是通过lxml的扩展实现的,大多数场景下能用。
3.5 完整主循环与随机延时
单页解析完成后,整个爬虫的主循环逻辑就清爽了。我直接贴出第一版的完整主流程:
import time import random import pandas as pd from urllib.parse import urljoin all_books = [] start_page = 1 end_page = 50 # 先爬50页看看效果 for page in range(start_page, end_page + 1): list_url = f"https://books.example.com/catalog/list?page={page}" try: list_resp = requests.get(list_url, headers=headers, timeout=10) list_soup = BeautifulSoup(list_resp.text, "lxml") detail_urls = [urljoin(list_url, a["href"]) for a in list_soup.select("a.book-title")] except Exception as e: print(f"[第{page}页列表请求失败] {e}") time.sleep(5) continue for detail_url in detail_urls: try: detail_resp = requests.get(detail_url, headers=headers, timeout=10) book = parse_detail(detail_resp.text) book["detail_url"] = detail_url book["list_page"] = page all_books.append(book) print(f"[OK] 书名: {book.get('title')} | 价格: {book.get('price')}") except Exception as e: print(f"[详情页失败] {detail_url} | 错误: {e}") # 请求间隔,控制频率 time.sleep(random.uniform(0.5, 1.5)) print(f"--- 第{page}页完成,累计{len(all_books)}条数据 ---") time.sleep(random.uniform(2, 4)) # 保存到CSV df = pd.DataFrame(all_books) df.to_csv("books.csv", index=False, encoding="utf-8-sig") print(f"全部完成,共{len(all_books)}条数据")这段代码核心要注意三个控制点。
请求间隔用了random.uniform(0.5, 1.5)做随机延时,这是爬虫圈的通用做法。固定间隔反而容易被服务器识别为程序行为,因为是机器就可能在毫秒级稳定间隔,但随机的、带一定抖动的间隔更贴近人的行为。这里不只是为了防封,也是给自己的爬虫留出合理的网络带宽缓冲。
页面之间的间隔拉大到了2到4秒,主要考虑到列表页请求后的详情页请求会造成短时间内请求量集中爆发,这里留出缓冲时间能降低被服务器限流的概率。
全过程的异常处理都是单条级别的,绝不因为一条数据出错就中断整个任务。打印日志我用的是[第x页列表请求失败]和[详情页失败]这种前缀,后续排查问题时按前缀grep日志就能快速定位出错阶段。
CSV写入时用了encoding="utf-8-sig"而不是utf-8,这个细节很关键。utf-8-sig会在文件头部加上BOM标记,Excel打开时才不会出现中文乱码。直接存utf-8格式,用记事本看是正常的,但用Excel打开大概率全是乱码。
4. 数据清洗与常见问题排查
4.1 数据清洗流程
数据清洗是爬虫流程中最容易被忽视、但实际上非常耗时的环节。我爬完第一批数据后发现,50页列表页一共拿到2400多条详情页URL,其中大概有100多条请求失败,返回的是404或者空白页面,还有几十条数据某些字段解析失败。
清洗的第一步是去重。直接用pandas的去重函数:
import pandas as pd df = pd.read_csv("books.csv") df_clean = df.drop_duplicates(subset=["isbn"], keep="first") print(f"去重前{len(df)}条,去重后{len(df_clean)}条")去重的依据是ISBN,这一点在字段设计阶段就规划好了。keep参数选择first,意思是重复数据保留第一条。
第二步是清洗价格字段,把所有无法转成float的字符串统一处理掉,必要时把超过合理范围的数据(比如价格大于10000元的)标记出来人工检查。大多数图书的定价不会超过3000元,如果出现异常值,往往是解析时把"¥0.01"这种促销价格爬进来了,或者是把图书的原价和折扣价拼接成了脏数据。
第三步是空值处理。看每个字段的空值比例:
print(df_clean.isnull().sum())如果某个字段的空值比例超过30%,比如summary字段,就要检查是不是解析逻辑有问题,而不是数据本身缺失。有时候是页面结构发生了变化,HTML里的class名称变了,导致select找不到目标元素。这时候要回到对应的详情页用浏览器检查最新结构,更新解析代码。
4.2 反爬策略的实战应对
图书网站虽然不像某些平台那么激进,但依然会有基础的反爬措施。我实际遇到的场景有:请求频率过高触发限流、IP短期被封禁、验证码弹出、User-Agent被识别。逐一说明应对方式。
限流和IP封禁的应对。最直接的信号是某个时间点之后所有请求突然都返回403或者503状态码。这种情况说明你的IP已经被盯上了。第一反应是把请求频率降下来,比如将随机延时从0.5到1.5秒提升到1到2秒,同时检查是不是单页请求过多导致整体频率超限。
如果降频后还是被限制,就需要换IP策略了。但这里有个非常重要的说明:使用代理IP是一个成熟的爬虫技术,代理IP可以按来源分为透明代理、匿名代理、高匿代理。对于图书这类基础站点,用高匿代理基本可以应对。国内有很多代理服务商,按量计费,一个IP池几百个IP轮换着用,封一个换一个。代码层面就是多一步代理配置:
proxies = { "http": "http://user:password@proxy_ip:port", "https": "http://user:password@proxy_ip:port", } resp = requests.get(url, headers=headers, proxies=proxies, timeout=10)验证码的应对。验证码出现是最烦人的情况之一。如果目标是登录后的数据或者高频访问的接口,验证码几乎无法避免。这里的核心原则是:不要试图去破解验证码识别,那是另一个领域的事。正确做法是降低请求频率,让网站认为你是正常用户在浏览。如果验证码大概率是偶发触发,可以用打码平台的人工识别服务,但成本会上升。对于图书这种公开数据的爬取,不追求极端速度,降低频率是最优雅的解决方式。
请求头伪装。请求头的完整程度直接影响到被识别的概率。我见过有人只设置User-Agent就开爬,短时间没问题,一旦频率上去就会被识别。正确的做法是尽量模拟真实浏览器的全部Header字段:
headers = { "User-Agent": "Mozilla/5.0 ...", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9", "Accept-Encoding": "gzip, deflate, br", "Connection": "keep-alive", "Upgrade-Insecure-Requests": "1", "Referer": "https://books.example.com/catalog/list?page=1", "Cache-Control": "max-age=0", }这里有一个容易踩到的坑:Accept-Encoding设置为gzip, deflate, br之后,requests会自动解压gzip和deflate格式的响应,但br格式需要额外的brotli库支持。如果你发现请求到的HTML是乱码或者特别长的URL编码字符,先检查是不是因为Accept-Encoding里带了br但环境里没有装brotli库。解决办法是直接把Accept-Encoding删掉,requests默认会处理多数编码格式。
4.3 断点续爬的重要性
爬虫爬了3000多页,跑了四个多小时,突然因为网络波动中断在某一页,这时候如果从头开始爬,前四个小时全白费了。断点续爬能力在长耗时爬虫项目中是必须的。
我的实现方案非常简单:批量处理之前,先动态记录一个断点文件progress.txt,每一页处理完成后把当前页码写进去。下次启动时先读取这个文件,如果存在就从上一次完成的页数继续,而不是从第一页开始。
import os def get_last_page(): if os.path.exists("progress.txt"): with open("progress.txt", "r") as f: return int(f.read().strip()) return 1 last_page = get_last_page() for page in range(last_page, end_page + 1): # ... 爬取逻辑 ... with open("progress.txt", "w") as f: f.write(str(page))这个方案看起来土,但在实践中非常有效。你甚至可以把这个思路扩展得更细,记录每个详情页URL的处理状态,但缺陷是记录本身的写入次数会提升不少。对我来说,页码级别的断点已经足够用了。
断点续爬配合增量更新的理念是通用的:爬虫不要每次全量爬更新,可以只爬最近一周或者最近一个新页面里新增的图书。增量更新的核心是时间戳判断,把detail页里的publish_date或者update_time字段跟数据库已有的最大日期做对比,只抓比它更新的数据,才是真正高效的爬虫。
4.4 一个典型案例:编码乱码问题
我在第一版爬虫里遇到过一个很诡异的问题,同一个网站,列表页解析正常,中文完全正常,但详情页解析出来的中文全是乱码。排查了半天,发现是服务器返回的HTTP头里Content-Type没有明确标注编码,requests库推测出来的编码是ISO-8859-1,导致中文全部乱掉。
解决方案分两个层次。第一层是猜测编码,用requests自带的apparent_encoding属性自动推断编码,大多数情况下能解决问题。第二层是直接指定编码查看响应内容是否正常:
resp.encoding = resp.apparent_encoding # 自动推断 # 如果推断有问题,手动指定 resp.encoding = "utf-8" # 甚至可以先打印出来看看推断结果是否符合预期 print(resp.apparent_encoding)乱码问题还有一个常见变体是写入数据库或者CSV时乱码。这类问题多半是写入文件的编码设置不对,解决办法是用utf-8-sig编码保存CSV。如果是写入MySQL,在连接串上加?charset=utf8mb4参数。对于图书简介里经常出现的特殊字符,比如emoji或者UTF-8的四字节字符,MySQL默认的utf8编码不认这些字符,需要升级到utf8mb4。这也是我在很多项目中的必备操作。
5. 并发优化与规模扩展
5.1 单线程跑完之后,下一步做什么
串行爬虫在数据量小的时候完全够用,但当你需要爬几万本书的时候,单线程请求的效率瓶颈就显露出来了。假设每请求一个详情页平均耗时0.7秒,爬一万本书就需要7000秒,接近两个小时。如果换用并发请求,这个时间可以压缩到原来的五分之一甚至十分之一。
但并发优化有一个前提:你已经用单线程爬过一遍,验证了代码解析逻辑没有问题、数据模型没有问题、存储方案没有问题。爬虫并发不是炫技,是为了解决实际的效率瓶颈。直接上并发如果出了问题,你面对的是错误的数量级增长。
5.2 使用ThreadPoolExecutor做多线程并发
Python多线程在CPU密集型任务上会受到GIL的限制,但在爬虫这种I/O密集型任务上,多线程的效果非常显著。因为爬虫大部分时间都花在网络请求上,线程在等待响应的间隙可以切换到其他线程继续发送请求,大大提高了时间利用率。
用concurrent.futures模块的ThreadPoolExecutor实现最简单的并发:
from concurrent.futures import ThreadPoolExecutor, as_completed def fetch_one(detail_url): for retry in range(3): # 单条重试三次 try: resp = requests.get(detail_url, headers=headers, timeout=10) if resp.status_code == 200: book = parse_detail(resp.text) book["detail_url"] = detail_url return book except requests.RequestException as e: print(f"[重试{retry+1}/3] {detail_url} 请求失败: {e}") time.sleep(1) return None with ThreadPoolExecutor(max_workers=8) as executor: future_to_url = {executor.submit(fetch_one, url): url for url in detail_urls} for future in as_completed(future_to_url): result = future.result() if result: all_books.append(result)max_workers=8的意义。这个参数表示同时最多8个线程在请求。线程数不是越大越好,开100个线程可能导致目标网站服务器过载把你IP封了,也会让你的网卡和CPU压力陡增。8个线程对绝大多数图书网站是一个合理的起步值,跑起来之后观察一下请求成功率和耗时,再动态调整。
重试逻辑的加入是并发爬虫的必备配置。单线程时网络错误可以等下一个请求自然规避,并发条件下部分请求失败几乎是常态,所以每个请求重试三次是很基本的容错保障。重试之间加1秒延时是为了避免重试失败后立刻重试反而加重服务端压力。
5.3 协程的进阶方案:用aiohttp做异步
多线程的瓶颈在于线程切换有开销,而且线程数量到达一定阈值后,反而不如异步的效率高。协程方案的核心是用单线程的事件循环来调度无数个没有阻塞的任务,每个请求等待响应的间隙自动切换去执行下一个请求。
下面是一个用aiohttp实现的简单异步并发示例:
import asyncio import aiohttp async def fetch_one(session, detail_url, semaphore): async with semaphore: # 信号量控制并发数量 try: async with session.get(detail_url, headers=headers, timeout=10) as resp: if resp.status == 200: html = await resp.text() book = parse_detail(html) book["detail_url"] = detail_url return book except Exception as e: print(f"[异步失败] {detail_url}: {e}") return None async def main(urls): semaphore = asyncio.Semaphore(20) # 限制最大并发20个 async with aiohttp.ClientSession() as session: tasks = [asyncio.create_task(fetch_one(session, url, semaphore)) for url in urls] results = await asyncio.gather(*tasks) return [r for r in results if r is not None] # loop.run_until_complete(main(detail_urls))aiohttp的优势是单线程内用极低的资源开销管理成百上千个并发请求。20个并发的信号量是一个非常保守的起步值。我在自己的项目中用aiohttp爬过带分页的列表页,几千个详情页URL用20并发跑完只需要几分钟,而同样的规模用串行需要半小时以上。
但要提醒的是,aiohttp调试起来比requests要复杂,尤其是异步回调中的异常栈信息不够直观。我的建议是,如果你第一次写爬虫,先用requests加多线程,跑通之后再尝试aiohttp,这样即使出错也知道是解析问题还是异步调度问题。
5.4 Scrapy:进阶框架的大杀器
当爬虫项目发展到需要调度大量爬虫任务、做跨页面数据关联、对接分布式部署时,手写requests方案会逐渐变得力不从心。这时候Scrapy框架的价值就体现出来了。
Scrapy是一个成熟的Python爬虫框架,内置了请求调度器、去重过滤、下载中间件、item pipeline、日志系统和统计分析功能。你不需要自己写请求循环,只需要定义Item结构、写解析回调、配置Pipeline存储即可。
一个用Scrapy实现图书爬虫的核心结构是这样的:
import scrapy class BookSpider(scrapy.Spider): name = "book_spider" def start_requests(self): base_url = "https://books.example.com/catalog/list?page={}" for page in range(1, 51): yield scrapy.Request(url=base_url.format(page), callback=self.parse_list) def parse_list(self, response): for href in response.css("a.book-title::attr(href)").getall(): yield response.follow(href, callback=self.parse_detail) def parse_detail(self, response): yield { "title": response.css("h1.book-title::text").get(), "price": response.css("span.price::text").get(), "isbn": response.xpath("//td[contains(text(), 'ISBN')]/following-sibling::td/text()").get(), }这段代码的简洁程度令人震惊。response.css和response.xpath两种选择器都已经内置了,response.follow会帮你处理相对路径的拼接,yield scrapy.Request会自动维护请求队列,还会自动去重。你不需要设计自己的延时和重试逻辑,Scrapy的DOWNLOAD_DELAY和RETRY_TIMES参数配置一下就行。
数据存储则交给Item Pipeline处理,以SQLite存储为例:
class SQLitePipeline: def open_spider(self, spider): self.conn = sqlite3.connect("books.db") self.cur = self.conn.cursor() def process_item(self, item, spider): self.cur.execute( "INSERT OR IGNORE INTO books (isbn, title, author, price) VALUES (?, ?, ?, ?)", (item.get("isbn"), item.get("title"), item.get("author"), item.get("price")) ) self.conn.commit() return itemScrapy的并发能力比手写线程池强很多。默认的并发数值是16,通过设置CONCURRENT_REQUESTS = 32可以提高到32。配合DOWNLOAD_DELAY = 1,既能保持比较高的爬取效率,又不会因为请求太密集把目标网站打得措手不及。
很多人在Scrapy和requests之间纠结,我的建议是:如果你只是临时爬一两次、数据量在几百条以内,直接用requests足够了;如果你想做一个长期维护、需要自动调度、未来可能要部署到服务器上持续运行的爬虫,直接用Scrapy,省掉重复造轮子的时间。
5.5 分布式爬虫是不是图书爬虫的刚需
搜索热词里有"分布式爬虫",这里多讲几句。分布式爬虫本质上是把任务切分到多台机器上同时执行,需要一台调度中心做任务分发。典型方案是RabbitMQ或Redis做任务队列,每台机器启动的Scrapy实例消费队列里的任务。
但对图书爬虫来说,大部分场景用不上分布式。理由很简单:图书数据总量有限,一本书的数据量再大也就几万到几十万本,单机并发开到足够水平后几十分钟就能爬完。分布式爬虫的部署成本和运维成本都很高,如果不是要爬百万级别的商品数据、或者数据要持续实时更新,单机加合理并发就足够了。
真正需要分布式的时候是在做全网数据采集,比如监控几个大型图书商城的全量商品价格,每天更新一次,这个时候才需要考虑分布式方案。
6. 可视化与结果展示
6.1 用pandas加matplotlib做基础统计
爬完数据只是完成了第一步,脏活累活还在数据分析和可视化上。爬了几万条图书数据,你当然不满足于只看Excel表格,肯定想看看这些数据能说明什么问题。
用pandas做统计分析非常顺手。我把爬下来的数据读出之后,做了一组简单的统计:
import pandas as pd import matplotlib.pyplot as plt plt.rcParams['font.sans-serif'] = ['SimHei'] # 解决中文显示问题 plt.rcParams['axes.unicode_minus'] = False # 解决负号显示为方块的问题 df = pd.read_csv("books.csv") # 出版社排行 publisher_top = df["publisher"].value_counts().head(10) print(publisher_top) # 价格分布直方图 df["price"].hist(bins=30) plt.title("图书价格分布") plt.xlabel("价格(元)") plt.ylabel("图书数量") plt.show() # 出版年份统计 df["publish_year"] = pd.to_datetime(df["publish_date"]).dt.year year_trend = df.groupby("publish_year").size() year_trend.plot(kind="line") plt.show()matplotlib里有一个重要坑:默认字体配置里不包含中文,所以在绘图前必须设置plt.rcParams['font.sans-serif'] = ['SimHei']。否则图里所有中文标签都会变成小方块。
6.2 进阶可视化:做一个简单的图书数据看板
如果你想让结果展示更直观,可以在爬完数据后用Flask写一个非常简单的web看板,把出版社TOP排行、图书价格区间占比、出版年份分布、评分TOP图书这些图表放到一个页面上。
这个项目本身不复杂,一个后端路由加几个图表文件就行。核心代码大概长这样:
from flask import Flask, render_template import pandas as pd import plotly.express as px app = Flask(__name__) @app.route("/") def dashboard(): df = pd.read_csv("books.csv") publisher_top = df["publisher"].value_counts().head(10).reset_index() publisher_top.columns = ["publisher", "count"] fig = px.bar(publisher_top, x="publisher", y="count", title="出版社图书数量排行榜") chart_html = fig.to_html(full_html=False) return render_template("dashboard.html", chart=chart_html) if __name__ == "__main__": app.run(debug=True)这里用了plotly来做交互式图表,好处是前端零基础也能出漂亮的动态可视化结果。把爬虫结果做成看板给同事或朋友展示,比直接丢一个CSV文件要直观太多。
6.3 数据后续能做什么
数据爬回来只是第一步,数据能产生的价值才是爬虫项目真正的亮点。我爬完之后做过几件事:
评分最高且评价数最多的20本Python图书排行,用评分和评价数两个维度做综合排序,排除只有几个评价就打出9.9分的噪音数据。这个排行在做书籍推荐时非常有用。
出版社出版的图书均价对比,按出版社分组计算平均价格,能看出不同出版社的定价策略差异。
出版时间与价格的关系分析,看同一家出版社近十年的均价走势。找出价格指数上涨最快的出版品牌。
如果要做更复杂的分析,还可以对图书简介做分词、提取关键词做词云、对封面图做批量下载做图片分类。总之爬虫只是数据来源,数据分析与业务洞察才是项目真正的价值所在。
7. 常见问题与排查技巧实录
写到最后一部分,把我实际踩过的坑、以及读者经常问到的问题集中整理成一份速查表。
| 问题 | 常见原因 | 排查思路 |
|---|---|---|
| requests返回403 | 无UA或UA被识别 | 设置完整请求头,降低请求频率 |
| requests返回503 | 对方服务端限流 | 加随机延时,使用代理池 |
| 中文乱码 | 页面编码非UTF-8 | 设置resp.encoding = resp.apparent_encoding |
| 某些字段解析为None | CSS选择器写错或页面结构变化 | 用浏览器检查元素,更新选择器 |
| 数据量过少 | 列表页只爬了一部分 | 检查翻页参数,是否有列表页循环限制 |
| 爬虫中途停止 | 网络波动或单条请求异常未处理 | 全流程加try-except,增加断点续爬 |
| 爬取速度慢 | 单线程串行 | 换多线程或aiohttp并发 |
| CSV打开乱码 | 文件未用utf-8-sig编码 | 改成encoding="utf-8-sig" |
| Excel打开后数字是文本格式 | CSV存储时将数字写成了字符串 | 清洗阶段统一转类型 |
| 请求卡住不结束 | 未设置timeout | 所有requests请求都加上timeout=10 |
| 被识别为爬虫封IP | 请求频率太高 | 降频、换UA池、用代理 |
这些坑几乎每个人都会遇到。分享两条排查问题时的经验。
第一,先复现再排查。遇到问题时不要凭空猜测,先手动在浏览器里访问同一个页面,确认这个URL是否能正常返回数据、页面结构是否跟代码里的选择器一致。很多时候是目标网站改版导致class名变了,而你爬虫代码里的选择器还是旧版的。
第二,善用日志。爬虫跑的时间越长,日志越重要。到后来我养成了习惯:每一次请求成功或失败都打印一条日志,记录请求URL、状态码、处理耗时、解析结果条数。排查问题时,通过日志定位到第一个出错的URL,问题就解决了一半。
再补充一个提速过程中的教训。有次我把Requests的Session复用了,以为跟浏览器保持长连接能提速,结果反而触发了服务器的限流机制。原因是快速复用连接时,请求的Cookie和Header会被固定住,服务器识别出同一个session发来大量高频请求,很快就封了。后来我改成每次请求都手动构建一个新的HTTP连接,虽然慢了一点点,但稳定很多。这里面的取舍是:稳定优先于速度,封IP一次就够你吃一整天的亏。
根据我的实操经验还有一个点:不同网站的反爬策略差异很大,同一个方案在这个网站上能用,到那个网站可能就不可行。所以做爬虫项目,务必先花20分钟分析目标网站的规则和robots.txt文件,再动手写代码,这20分钟能帮你节省后面2小时甚至一整天的调试时间。
爬虫是有意思的技术,图书爬虫更是入门和进阶皆宜的好题材。这个项目的核心技能点在requests和Scrapy两种方案里都有体现,数据模型和存储选择的思路也能平移到其他类型的爬虫项目中。如果你照着这篇文章把一个图书爬虫跑通了,后面再遇到任何同类型的网站,都不会觉得无从下手。