☰
豆瓣图书Top250爬虫实战:requests+lxml+SQLite数据采集与清洗
2026/10/15 19:41:02 网站建设 项目流程

豆瓣图书这个数据源,我前前后后折腾过好几次。最早是想把自己近几年的阅读记录做个可视化清单,手动整理了几十本之后实在顶不住,就动了写爬虫的念头。后来陆陆续续帮朋友抓过某类目的图书列表、做过评分对比的小工具,也踩了不少坑。这个项目算是爬虫入门里非常有代表性的一个:网站结构规整、数据字段丰富、有一定的反爬策略但又不至于让人劝退。如果你想系统地练手网络请求、页面解析、数据清洗这一整套流程,豆瓣图书是个很合适的练手目标。

这篇内容我会从整体思路讲起,然后拆解请求策略、页面解析、数据存储,最后是实际运行中常见的报错和排查方法。整个过程按照我当时实操的顺序来写,有些地方会比较啰嗦,但都是实测过的东西,照着做基本能跑通。

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 None

4. 数据存储与清洗:去重、增量更新与库表设计

数据解析出来之后不是直接塞进文件就完事了。如果只跑一次,那存个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的增删改查都会有一个完整的认知,后面再遇到其他网站,思路是相通的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询