豆瓣图书这个数据源,我前前后后折腾过好几次。最早是想把自己近几年的阅读记录做个可视化清单,手动整理了几十本之后实在顶不住,就动了写爬虫的念头。后来陆陆续续帮朋友抓过某类目的图书列表、做过评分对比的小工具,也踩了不少坑。这个项目算是爬虫入门里非常有代表性的一个:网站结构规整、数据字段丰富、有一定的反爬策略但又不至于让人劝退。如果你想系统地练手网络请求、页面解析、数据清洗这一整套流程,豆瓣图书是个很合适的练手目标。
这篇内容我会从整体思路讲起,然后拆解请求策略、页面解析、数据存储,最后是实际运行中常见的报错和排查方法。整个过程按照我当时实操的顺序来写,有些地方会比较啰嗦,但都是实测过的东西,照着做基本能跑通。
1. 项目整体设计:先搞清楚要爬什么,再动手写代码
拿到这个需求,第一件事不是打开编辑器敲代码,而是先想清楚几个问题:爬哪些页面、需要哪些字段、数据量大概多大、存到哪里、多久跑一次。这些想不明白,代码写一半就会卡住。
1.1 目标数据与页面结构分析
豆瓣图书的数据分布在几个不同类型的页面上。最常用的是图书列表页和图书详情页。列表页的URL规则非常规整,比如图书Top250的地址是https://book.douban.com/top250,每页25本书,通过?start=25、?start=50这样的参数翻页。详情页则走另一个模式,https://book.douban.com/subject/xxxxx/,后面那串数字是每本书的唯一编号。
我当时给自己定的需求很简单:抓取豆瓣图书Top250的书名、作者、出版社、出版年份、评分、评价人数、简介,外加每本书的详情页URL。这个规模就是250条数据,属于小数据量,完全不需要分布式爬虫或者并发框架,单线程加适量延时就够了。
建议你把目标字段先列成一张表,后面写解析代码和建数据库表都会轻松很多。
| 字段名 | 来源页面 | 说明 |
|---|---|---|
| 书名 | 列表页/详情页 | 书的主标题 |
| 作者 | 详情页 | 可能有多个作者,需要清洗 |
| 出版社 | 详情页 | 中文出版社名称 |
| 出版年份 | 详情页 | 只取年份数字 |
| 评分 | 列表页 | float类型,保留一位小数 |
| 评价人数 | 详情页 | int类型,注意单位 |
| 简介 | 详情页 | 可能很长,注意换行处理 |
| 详情页URL | 列表页 | 用作去重主键 |
另一个出发点是爬虫与反爬的对抗思路。豆瓣这个网站比较有意思:它的反爬策略不是那种“一步到位”的强制验证码,而是通过请求频率、请求头特征、Cookie有效性来做渐进式限制。初学者很容易遇到的现象就是:前几页抓得好好的,翻到第三页突然返回418或者请求被重定向到登录页。这其实不是你的代码逻辑错了,而是你的请求“看起来不像正常人”。所以做爬虫,本质上是在模拟一个真实用户的浏览行为,而不是单纯地发送HTTP请求。
1.2 技术选型:为什么是requests + lxml + SQLite
爬虫的技术栈选择很多,Scrapy、Selenium、Playwright都能用,但对豆瓣图书这个项目来说,我的建议是轻装上阵。
我用的是requests发请求、lxml做解析、SQLite做存储。这三样东西都足够成熟,而且各自的定位非常清晰:requests处理HTTP层面的事情,lxml负责从HTML里提取数据,SQLite存放最后的结果。相比之下,Scrapy对于这种250条数据的小项目来说有点过重,配置文件的成本大于收益;Selenium适合应对大量JavaScript动态渲染的页面,但豆瓣图书的列表页和详情页都是服务端直接输出的HTML,用不上浏览器渲染,反而白白增加资源和时间开销。
当然,这篇文章给的代码并不是唯一答案。如果你看到某个页面用requests拿不到数据,可以换成httpx或者加一层会话保持机制;解析部分也可以改用BeautifulSoup,看个人习惯。我个人的经验是:lxml的XPath表达能力更强,尤其适合豆瓣这种class属性使用得比较规律的网站,一次写对选择器,后面基本不用改。
1.3 合规边界:个人学习用途的使用尺度
爬虫写久了会越来越在意一个词——边界。豆瓣图书的数据是公开可见的,但公开可见不等于可以随便拿。我在整个项目里始终遵守几个原则:只抓公开页面、不登录抓取非公开内容、请求频率控制在合理范围、不把抓下来的数据用于商业用途。
具体来说,列表页和详情页都是未登录用户可以正常访问的公开信息,这部分属于正常的数据获取范畴。需要注意的不是“能不能抓”,而是“怎么抓”:如果每秒发几十个请求,那不管目标网站有没有明确禁止爬虫,都容易给对方服务器造成压力,也不排除触发对方的安全机制,这不只是技术问题,更是使用分寸问题。我的做法是每次请求之间至少间隔3到5秒,250条数据跑下来十几分钟,对服务器来说感知不强,自己也稳定省心。
合规红线我放在最前面说,原因很简单:爬虫写没写好是技术问题,用没用对是原则问题。个人学习演示、小规模数据分析和非商业用途,都是合理的使用场景;大规模抓取、影响网站正常服务,哪怕技术再厉害也不应该做。
另一个建议是数据抓下来之后不要随意公开发布原始数据,尤其是把整库数据打包分享出去这种行为,很可能带来不必要的麻烦。自己分析、自己看、自己学习,这个维度内是没问题的。如果确实需要分享成果,优先分享分析结论和图表,而不是把原始数据一打包就往外发。
2. 请求策略:如何从豆瓣稳定拿到页面
这部分是整个爬虫最容易出问题的地方。解析代码写得再漂亮,请求发不出去或者被拦截,后面都是白搭。我把它拆成URL规律、请求头、请求时长控制三个小节来讲。
2.1 URL规律与分页参数的设计
豆瓣图书Top250的URL结构是这样的:https://book.douban.com/top250?start=0,每页25本,start=25是第二页,start=50是第三页,一直加到start=225就是最后一页。
也就是说,分页爬取的核心逻辑就是循环生成不同start值的URL,然后把每个页面的HTML交给解析模块去处理。这个逻辑用一段很简单的Python代码就能实现:
base_url = "https://book.douban.com/top250" for page in range(10): start = page * 25 url = f"{base_url}?start={start}" print(f"正在抓取:{url}")需要注意一个细节:start参数是偏移量而不是页码。如果你想抓某一类别的图书列表,URL规则可能会变成https://book.douban.com/tag/小说?start=0&type=T,同样还是偏移量的逻辑。看清URL参数的含义再动手,能省很多调试时间。
还有一种情况是URL里的中文参数。比如按标签搜索时,标签名是中文的,直接拼进URL会乱码,需要用urllib.parse.quote做编码。我当时第一次抓某个分类列表时就栽在这个上面,页面返回200但内容不对,排查了半天才发现是URL编码的问题。
from urllib.parse import quote tag = "小说" url = f"https://book.douban.com/tag/{quote(tag)}?start=0&type=T"2.2 请求头伪装:User-Agent与Cookie的作用
豆瓣的反爬机制里,最基本的检查就是User-Agent。如果你的请求头里没有User-Agent,或者写的是python-requests这类默认标识,服务器很容易直接拒绝——当然这里不是鼓励绕过对方的安全策略,只是在正常获取公开信息时,保持常规浏览器请求的特征是合理的做法。
通常你可以从自己常用的浏览器里复制一段User-Agent,比如Chrome或者Edge的完整UA字符串。我用的是Chrome的UA,实测下来稳定性很高。需要注意:User-Agent不是一次性设完就高枕无忧的,对于小数据量爬取来说,保持一个固定的UA就够了;如果长期高频抓取,建议准备几个常用的UA轮换。
再一个重点是Cookie。豆瓣有一个名为bid的Cookie,是未登录状态下也会种在浏览器里的一个随机字符串。第一次访问时会下发,后面所有请求都带着它。这个bid值在requests会话里会自动处理,但如果你看到页面返回内容异常或者请求被重定向到安全验证页面,多半是和Cookie丢失有关。解决办法是通过requests.Session来维持会话,先访问一次首页让它种下Cookie,再开始正式抓取。
import requests session = requests.Session() 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" } # 先访问首页,让服务器种下基础Cookie session.get("https://book.douban.com", headers=headers, timeout=10)2.3 请求限速:为什么必须sleep,怎么设置
很多人写爬虫的时候不太在意延时,觉得等几秒钟太浪费时间。但实际跑过几轮之后你会明白,有延时和无延时的成功率差别极其明显。无延时连续请求的情况下,豆瓣的服务器会在某个临界点直接返回418状态码——这是一个比较有辨识度的反爬信号,表示服务器识别出自动化流量并主动拒绝。
我的经验是每次请求之间至少time.sleep(3),温和一点就5秒。250条数据,总共需要抓列表页10次、详情页250次,按3秒延时算,大概十几分钟跑完,完全在可接受范围内。如果你希望更快,可以在详情页部分用线程池并发,但并发上限建议控制在3个以内,同时把延时调整为2秒,整体耗时能压缩到几分钟,但对服务端的压力也随之增大,建议按需选择,看清楚目标网站的承载情况再决定。
import time import requests for page in range(10): start = page * 25 url = f"https://book.douban.com/top250?start={start}" resp = session.get(url, headers=headers, timeout=10) print(resp.status_code, url) time.sleep(3)3. 数据解析:从HTML里精准提取图书字段
页面拿下来之后,接下来就是解析。我不推荐用正则表达式硬扣,原因很简单:HTML结构一变,正则是第一个挂的。用XPath或者CSS选择器,写的是“路径”和“结构”,容错性会好很多。
3.1 解析思路:XPath选择器怎么定位元素
豆瓣图书列表页里,每本书被包裹在一个<li class="subject-item">节点里。所有需要提取的字段都藏在这个节点内部,因此解析思路非常清晰:先定位所有包含图书信息的节点,再分别提取每个节点下的书名、评分、评价人数等字段。
XPath的核心逻辑就是沿着HTML的层级一路找下去。比如我想提取一本书的标题,可以先定位到li.subject-item,然后在它下面找h2 a里的文本。写XPath的时候有一个小技巧,就是先用相对路径,不要一上来就写整条绝对路径。绝对路径对页面结构调整非常敏感,稍微改一层标签就失效;相对路径只针对当前父节点做查找,环境影响小得多。
我实际用的XPath定义大概是这样的:
from lxml import html tree = html.fromstring(resp.text) items = tree.xpath('//li[@class="subject-item"]') for item in items: title = item.xpath('.//h2/a/text()')[0].strip() rating = item.xpath('.//span[@class="rating_nums"]/text()')[0].strip() detail_url = item.xpath('.//h2/a/@href')[0].strip()需要留一个心眼:rating_nums这个class在列表页里是存在的,但在某些特殊条目下可能不显示评分。所以稳妥的写法是先判断列表是否为空,不要直接取下标0,不然很容易IndexError。
rating_list = item.xpath('.//span[@class="rating_nums"]/text()') rating = rating_list[0].strip() if rating_list else "暂无评分"3.2 详情页解析:作者、出版社、出版年份、简介一个都不能少
列表页能拿到的信息就那几样,书名、评分、链接是最基本的。书名和作者这类更详细的字段在详情页里。详情页结构里,作者信息位于#info节点下的span元素,出版社、出版年、定价等信息则以pl类别的标签和a标签的形式出现。
我当时的目标字段有出版社和出版年份,这两个字段的提取比作者要稍微麻烦一点点:出版社通常包在a标签里,出版年份则是纯文本,需要按规律找。一个比较取巧的做法是拿详情页#info节点的纯文本内容,然后按正则去切,简单粗暴但效果稳定。
示例代码如下:
import re info_node = detail_tree.xpath('//div[@id="info"]')[0] info_text = info_node.text_content() # 用正则提取出版社 publisher_match = re.search(r'出版社[::]\s*([^\n]+)', info_text) publisher = publisher_match.group(1).strip() if publisher_match else "" # 用正则提取出版年份 year_match = re.search(r'出版年[::]\s*([0-9]{4})', info_text) pub_year = year_match.group(1) if year_match else ""这个思路的原理是:详情页#info节点里的信息都是直排文本,用中文冒号或者英文冒号分隔字段名和字段值。正则匹配虽然看着不高级,但在这种情况下反而比逐层定位更简洁可靠。
简介部分的定位也有规律,通常位于div#link-report节点下,包在div.intro里。需要注意的坑是简介文本可能包含多余换行、空格和引用的语录块,清洗时可以用join和strip处理。
intro_nodes = detail_tree.xpath('//div[@id="link-report"]//div[@class="intro"]/p/text()') intro_text = "\n".join([p.strip() for p in intro_nodes if p.strip()])3.3 分页采集与异常兜底:不能因为一个页面失败就重来
整个爬取过程是循环式:列表页翻页,每个列表页里提取详情页URL,再进入详情页抓详细数据。这里面最大的风险是某个详情页请求超时或者被反爬拦截,如果代码不做异常处理,整个程序就会中断。
我的做法是给每个详情页的请求包一层try-except,失败后记录日志并跳过,最后汇总的时候再单独重试失败的URL。这样整个采集过程是“尽可能多地把数据拿下来”,而不是“一个失败就全部重来”。日志记录我建议至少包括时间、书名、URL、状态码这几项,方便排查。
failed_urls = [] def fetch_detail(url): try: resp = session.get(url, headers=headers, timeout=10) if resp.status_code == 200: return html.fromstring(resp.text) else: failed_urls.append(url) return None except requests.RequestException as e: print(f"请求异常:{url},错误:{e}") failed_urls.append(url) return None4. 数据存储与清洗:去重、增量更新与库表设计
数据解析出来之后不是直接塞进文件就完事了。如果只跑一次,那存个CSV甚至可以了;但如果要考虑后续增量更新、按条件查询,SQLite是更合适的选择。
4.1 存储方案选型:CSV、JSON、SQLite选哪个
CSV的优点是最小、直观,Excel可以直接打开;缺点是字段里有换行、逗号的时候会各种出错,而且不适合大量数据查询。
JSON能保留嵌套结构,比如一本书有多个作者、多个标签,JSON可以直接存数组;缺点是查询效率低,每次读都是全量加载。
SQLite是单文件数据库,不需要单独起服务,Python自带的sqlite3就能操作。它的查询能力和数据管理能力比CSV/JSON强太多了。对于250条图书数据来说,SQLite虽然在“大材小用”的边缘,但胜在后续扩展性——比如你哪天想抓几万条数据,一样能扛住。
4.2 库表设计:主键、索引与字段约束
库表结构设计这块,一个简单的原则是“一个字段一个功能,能拆开的尽量拆开”。我当时建的表是这样的:
CREATE TABLE books ( id INTEGER PRIMARY KEY AUTOINCREMENT, book_id TEXT UNIQUE, title TEXT NOT NULL, author TEXT, publisher TEXT, pub_year TEXT, rating REAL, rating_people INTEGER, intro TEXT, detail_url TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP );几个关键设计的理由:
book_id是豆瓣图书详情页URL里那串数字,每本书唯一。设置UNIQUE约束可以直接用它做去重,看到同一个book_id再次写入时抛异常或者走更新逻辑。rating用REAL类型,因为豆瓣评分是小数;rating_people用INTEGER,评价人数一定是整数。created_at加默认时间戳,用来记录首次入库时间,后续增量更新做对比会很方便。
4.3 幂等写入:同样的数据不会重复插入
爬虫不是跑一次就结束的,过两天你可能想更新一下评分数据。这时候如果直接INSERT,主键冲突会报错。推荐的做法是使用SQLite的INSERT OR REPLACE或者先查后插。
INSERT OR REPLACE的逻辑是:如果唯一键冲突,就删掉旧记录,插入新记录。对于图书数据来说,这个行为是合理的——因为一本书的评分、简介随时间可能变化,旧数据被覆盖是正常的。但要注意,REPLACE会导致id自增字段变化,如果你对id有强引用关系,需要谨慎使用。
我个人的做法是这样:
def save_book(conn, book): sql = """ INSERT OR REPLACE INTO books (book_id, title, author, publisher, pub_year, rating, rating_people, intro, detail_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """ conn.execute(sql, ( book["book_id"], book["title"], book["author"], book["publisher"], book["pub_year"], book["rating"], book["rating_people"], book["intro"], book["detail_url"], ))5. 常见问题排查实录:我的踩坑经历整理
这一节是我觉得对新手最有用的部分。很多问题不看现场报错根本想不到,我把实际跑项目时遇到的典型问题整理成一张速查表。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 请求返回418 | 请求被反爬拦截,通常是没有合理设置请求头或频率过高 | 检查User-Agent,增加请求间隔,必要时先访问首页建立会话 |
| 返回200但页面内容为空 | Cookie丢失或者页面结构发生变化 | 用Session维持Cookie,访问一次首页再开始抓取 |
| 解析时IndexError | 某个图书条目缺少评分类字段 | 判断列表是否为空再取下标 |
| 详情页作者信息抽不到 | 详情页结构因图书类型而异 | 用text_content()提取全文再正则匹配,而不是硬依赖层级定位 |
| 存入数据库后重复数据一堆 | 没有设置唯一键或者主键冲突处理 | 建表时给book_id加UNIQUE,写入用INSERT OR REPLACE |
| URL编码问题导致页面不对 | 参数里的中文没有编码 | 用urllib.parse.quote处理 |
5.1 418状态码和403状态码怎么区分
这两个状态码我都遇到过。简单说,403是服务器理解你的请求,但拒绝执行,通常表示没有权限或者被策略阻止;418在反爬语境下则是“识别出你是自动化工具”的典型信号。处理思路本质上是同一套:降低频率、优化请求头、模拟真实浏览行为。如果这两种状态码频繁出现,第一步永远是检查自己的抓取行为是不是太粗暴了,而不是绞尽脑汁去“破解”对方的限制。
5.2 页面结构变动如何应对
网站改版是爬虫最头疼的事情之一,没有人能保证解析代码永久有效。豆瓣图书的页面结构相对稳定,但class的微小调整也不是没发生过。我的经验是给解析逻辑留一个容错层:每个字段都先判断是否取到值再入库。这样即使某个字段解析不到,数据也不会整体丢失,只是该字段显示为空,后续再看日志补抓就行。
5.3 数据清洗的细节:比较隐蔽的脏数据
最后说几个肉眼不一定看得到的数据坑。第一个是评分字段带上换行和空格,不清洗的话入库后查询很麻烦;第二个是作者字段可能有多个人物,中间用斜杠分隔,要不要拆开取决于你的分析需求;第三个是简介文本里的引号和特殊符号,正常清洗方式是用文本去空白、去换行,但不要过度清洗,保存原貌有助于后续做更细致的文本分析。
清洗代码我通常放在解析完成后、入库前这一段,相当于给数据一个统一的“预处理闸口”。这一层写得干净,后面做分析或者展示会省很多事。
我个人在实际操作中的体会是:爬虫项目最花时间的往往不是爬的过程,而是数据清洗和异常处理。你写解析代码可能一个下午就搞定,但排查各种边界情况、调整字段格式、处理请求失败重试,可能会花掉周末两天。这些都是正常现象,不用觉得自己基础差。豆瓣图书这个项目做完,你对requests的会话管理、XPath的页面解析、SQLite的增删改查都会有一个完整的认知,后面再遇到其他网站,思路是相通的。