豆瓣图书Top250这个题目,几乎可以说是爬虫实战里绕不开的经典练习,我早期做Python爬虫入门时也是拿它当成第一个完整项目来练的。它的好在于:页面结构足够规整、数据字段丰富但是不复杂,而且最终结果是可以直观看到的一张Excel表,特别适合用来理解“请求—解析—清洗—导出”这一整条链路。今天这篇就把我从零到一跑通整个流程的完整过程拆开来讲,包括当时的代码、踩过的坑、还有怎么校验拿到的数据对不对。
1. 项目思路与整体设计
1.1 为什么选豆瓣图书Top250练手
很多教程喜欢用新闻列表或者天气预报接口做示例,但那些数据量太少,字段也太简单,练不到太多东西。豆瓣图书Top250不一样,它一共有10页、每页25条,总共250本书,数据量大到足够让你写出循环抓取的逻辑,又不会大到跑起来费时间。单条数据的维度也很齐全:排名、书名、作者、出版社、出版年份、价格、评分、评价人数、一句话简介,字段类型覆盖了字符串、数字、中文标点、缺省值这些情况,处理起来很考验基本功。
从反爬角度来看,豆瓣图书Top250属于静态页面,不需要执行JavaScript。我用requests直接请求就能拿到完整的HTML,这比上Selenium要简单太多,也稳太多。它虽然有一定的反爬门槛,但核心就集中在User-Agent、请求频率这些常规手段上,非常适合新手理解爬虫与目标站点之间的“相互试探”。对于学了Python基础语法,想接触爬虫但不知道从哪下手的开发者来讲,它是性价比最高的一类练手项目。
1.2 技术栈选型逻辑
我当时选用的组合是requests加lxml加pandas,拆开讲讲为什么这么选。
- requests负责HTTP请求。它是Python生态里最主流的请求库,API设计简洁,会话管理、超时控制、异常处理都写得很顺手。相比urllib这个标准库,requests的代码量能少三分之一,可读性也强很多。
- lxml负责HTML解析。HTML本质上是半结构化的文档,用正则去字符串里硬抠数据当然也能做,但写出来的规则又长又脆,只要页面缩进稍微变一下就全盘崩掉。lxml配合XPath,能按节点层级精准定位,代码逻辑和页面结构是一一对应的,后期维护成本低很多。
- pandas负责数据整合与导出。爬出来的东西是一堆字典组成的列表,随手就能转成DataFrame,然后一行代码就能导出Excel。它的to_excel方法底层调用openpyxl,对于这种小体量数据完全够用,不用再手工写openpyxl的逐行写入代码。
还有一个决策是:不要用Selenium。有人会觉得浏览器自动化更“万能”,但豆瓣图书Top250根本不需要执行JS渲染,硬上Selenium只会带来启动慢、占内存、容易被检测这些副作用。爬虫的第一原则是能静态就不要动态,这是我自己踩过几回性能坑之后总结出来的。
1.3 一条数据从网页到Excel的完整链路
整个项目跑起来是这样的流程:
先构造带分页参数的URL,比如start=0、start=25这样依次递增,用requests带上模拟浏览器的请求头发送请求;服务端返回HTML文档后,先用lxml把文档解析成可查询的节点树,再用XPath规则把当前页里25个书目节点依次定位出来;接着对每个节点分别提取排名、书名、评分等字段,把原始文本做一次清洗加工,比如去掉首尾空白、拆分作者和出版社、从评价人数里抽出数字;最后把所有页的数据汇总成一个列表,转成DataFrame,调用to_excel写出。
听起来不复杂,但每一环都有值得抠的细节。下面重点说页面结构分析和字段定位这部分,因为代码可以照着抄,但只要页面结构变了,XPath规则就必须跟着变,这决定了程序能不能跑通。
2. 动手前先读懂页面:结构分析与字段定位
2.1 翻页规律与URL构造
在写爬虫之前,不要急着写代码,先把页面翻几页观察一下URL的变化规律。豆瓣图书Top250的第一页URL是https://book.douban.com/top250,没有分页参数;第二页就变成了https://book.douban.com/top250?start=25,第三页是?start=50。规律一眼就明白:每页固定25条,start参数就是当前页第一条数据的序号,从0开始,每隔25递增,一直到?start=225就是最后一页。
所以我在代码里就直接循环了10次,start取值为range(0, 250, 25)。这里有个小细节是,虽然我用了requests的params参数来传start,但等价的行为就是拼在URL后面。params的好处在于requests会自动处理URL编码,对于中文参数尤其方便,虽然这里的数字参数用不上编码,但作为一种习惯是好的。
2.2 单本书目节点的HTML结构
有了URL规律,下一步是分析页面结构。浏览器里打开豆瓣图书Top250,按下F12,切换到Elements面板,可以观察到一个现象:每一本书都是放在一个<tr class="item">标签里,一个tr就是一条完整数据。这个tr内部又分了两个td:左边是排名和封面缩略图,右边是书名、出版信息、评分和简介。
复制某个tr的HTML来看,核心结构大致像这样:
<tr class="item"> <td valign="top" width="80"> <div class="pic"> <em class="">1</em> <a href="..."> <img src="..." alt="百年孤独" /> </a> </div> </td> <td valign="top"> <div class="pl2"> <a href="..." title="百年孤独">百年孤独</a> </div> <p class="pl">[哥伦比亚] 加西亚·马尔克斯 著 / 范晔 译 / 南海出版公司 / 2011-6 / 39.50元</p> <div class="star clearfix"> <span class="rating_nums">9.3</span> <span class="pl">(345210人评价)</span> </div> <p class="quote"> <span class="inq">值得反复阅读的文学经典</span> </p> </td> </tr>点评一下这个结构:class命名非常规整,每个字段都有专属的类名,这对用XPath定位来说简直友好到不行。很多时候爬虫遇到的问题是class名字起得乱或者层级嵌套深,豆瓣这个页面的结构设计得相当清爽,这也是它适合练手的另一个原因。
2.3 用XPath把关键字段勾出来
基于上面这个结构,我设计了下面这一组XPath表达式。需要注意,它们是相对每个tr.item节点来写的,所以代码里是先定位到所有tr,再逐条去提取。
- 排名:
.//td[@class="pic"]//em/text(),实际更通用的是.//em/text(),直接在当前tr里找em标签。 - 书名:
.//div[@class="pl2"]//a/@title,之所以取@title而不是取a标签的text(),是因为页面上书名的文字节点可能会因为换行和缩进带上多余空白,title属性反而是干净的。 - 出版信息:
.//p[@class="pl"]/text(),这个text()是个长字符串,后面还要再做拆分。 - 评分:
.//span[@class="rating_nums"]/text()。 - 评价人数:
.//span[@class="pl"]/text()。 - 一句话简介:
.//p[@class="quote"]/span[@class="inq"]/text()。
在3.3节的解析函数里,我会注意处理好列表为空的情况。XPath匹配不到内容时返回的是空列表,直接下标取第0个元素会抛IndexError,所以代码里先用if判断再取值,这是实战中很容易踩的暗坑。
3. 核心代码实现与逐段解析
3.1 环境准备与依赖安装
先准备好依赖库。我用的是Python 3.10,理论上3.8以上的版本都能跑。创建虚拟环境这一步我建议不要省,虽然是老生常谈,但用虚拟环境真的能避免很多版本冲突的问题。
pip install requests lxml pandas openpyxl这里特别说明一下为什么必须装openpyxl。pandas的to_excel方法只是一个高层封装,它并不自带Excel写入能力,底层必须有openpyxl(写xlsx格式)或者xlwt(写xls老格式)来支撑。即使你没在代码里直接import openpyxl,只要没装这个库,跑to_excel的时候就会报ModuleNotFoundError。
安装完成之后,在代码文件顶部统一导入:
import random import re import time import pandas as pd import requests from lxml import html3.2 请求模块:headers、超时、重试与频率控制
写请求模块时,我最看重的是三件事:请求头要像真人、失败要自动重试、请求间隔要合情理。
豆瓣对非浏览器的User-Agent比较敏感,requests默认发给服务端的是python-requests/版本号这种标识,目标站点很可能直接拒绝。所以我在HEADERS里放了一组正常的浏览器请求头,包含User-Agent、Accept、Accept-Language这些常用字段。这不算什么黑科技,只是让请求看起来更像一个普通用户在访问网页,属于爬虫工程里的常规操作。
HEADERS = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' 'AppleWebKit/537.36 (KHTML, like Gecko) ' 'Chrome/122.0.0.0 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,' 'image/avif,image/webp,*/*;q=0.8', 'Accept-Language': 'zh-CN,zh;q=0.9', }请求函数我封装成下面这样,注意几点:超时设为10秒,防止某个页面卡死线程;遇到请求异常或非200状态码时最多重试3次,每次失败后等待时间逐渐增加;返回的是lxml解析好的文档对象,后续就可直接交给解析函数使用。
def fetch_page(start): url = 'https://book.douban.com/top250' params = {'start': start} for attempt in range(3): try: resp = requests.get(url, params=params, headers=HEADERS, timeout=10) if resp.status_code == 200: return html.fromstring(resp.content) print(f"[!] start={start} 返回状态码 {resp.status_code},开始第 {attempt + 1} 次重试") except requests.RequestException as e: print(f"[!] start={start} 请求异常:{e},开始第 {attempt + 1} 次重试") time.sleep(2 + attempt * 2) return None这里有个特别值得说的点:解析用的是resp.content而不是resp.text。resp.text是requests拿到响应后按猜测的编码来解码的,而resp.content是原始字节流。lxml在解析字节流的时候会读取HTML里的<meta charset="utf-8">声明,按页面声明的编码来解码,这样能减少编码猜错导致的乱码问题。实测下来,用resp.text偶尔会出现中文乱码,改成resp.content后一次都没再遇到过。
3.3 解析模块:从文档对象里抓25条数据
解析函数接收上一步返回的文档对象,用XPath定位所有的tr.item节点,然后逐条提取字段。这段代码的核心是防御性写法:每一项XPath的结果都可能是空列表,所以必须先判断再取第一个元素,取到之后统一做strip清理空白。
def parse_page(doc): rows = [] items = doc.xpath('//tr[@class="item"]') for item in items: rank = item.xpath('.//em/text()') rank = rank[0].strip() if rank else '' book_link = item.xpath('.//div[@class="pl2"]//a') title = book_link[0].get('title').strip() if book_link else '' info = item.xpath('.//p[@class="pl"]/text()') info = info[0].strip() if info else '' rating = item.xpath('.//span[@class="rating_nums"]/text()') rating = rating[0].strip() if rating else '' people_raw = item.xpath('.//span[@class="pl"]/text()') people_raw = people_raw[0].strip() if people_raw else '' people_match = re.search(r'(\d+)人评价', people_raw) people = people_match.group(1) if people_match else '' quote = item.xpath('.//p[@class="quote"]/span[@class="inq"]/text()') quote = quote[0].strip() if quote else '' rows.append({ '排名': rank, '书名': title, '信息': info, '评分': rating, '评价人数': people, '一句话简介': quote, }) return rows书名取值那里用了一个小技巧:先定位div.pl2下面的a标签节点,再取它的title属性。因为书名在页面上有时会包含空格和换行,直接取text()可能会带回前后空白字符,而title属性的值在HTML源码里就是完整的书名,非常干净。这种方法比直接链条式地写一整条XPath更稳妥,也方便加一个节点级别的判断。
3.4 数据清洗:把出版信息拆成独立字段
拿到原始信息字符串后,如果不做加工,填进Excel的“信息”列虽然也能看,但不够结构化。实际上一本书的出版信息是“作者 / 出版社 / 出版年 / 价格”这样用斜杠分隔的,我可以按' / '拆开,变成独立列,这样后期要按出版社排序或者统计年份就方便多了。
def split_info(info): parts = info.split(' / ') while len(parts) < 4: parts.append('') author, publisher, year, price = parts[:4] return author, publisher, year, price我在实际打印里发现,绝大多数条目的info字符串能拆成4段,但有些书的作者字段会比较长,比如“XXX 著 / YYY 译 / 某某出版社 / 2022-3 / 68.00元”,拆开之后第0段是“XXX 著 / YYY 译”,第1段是出版社,第2段是出版年份,第3段是价格。还有极少数条目只有3段,少价格或者少年份,while循环补齐空字符串就是用来兜这种底的。这里也体现了为什么爬虫要留原始字段:拆分后的字段即使不够规整,至少原始字符串还保留着,不会丢信息。
3.5 主循环与导出Excel
主流程就是循环10页、逐页请求解析、把数据汇总成列表。每次请求之间我做了一个随机延时,间隔控制在0.5到1.5秒之间。为什么要随机而不是固定时间?因为固定间隔本身也容易被识别成机器行为,随机间隔更接近人的操作节奏。更重要的是,不要为了追求速度把间隔改到0或者用多线程高并发去怼,250条数据而已,慢慢抓一分钟内就跑完了,完全没必要给目标站点造成压力。
all_rows = [] for start in range(0, 250, 25): doc = fetch_page(start) if doc is None: print(f"[!] 第 {start // 25 + 1} 页获取失败,跳过") continue page_rows = parse_page(doc) print(f"[+] start={start} 解析到 {len(page_rows)} 条数据") all_rows.extend(page_rows) if start < 225: time.sleep(random.uniform(0.5, 1.5))拿到all_rows之后,用pandas转成DataFrame。这里要注意列的顺序,字典列表转DataFrame时,列顺序默认就是字典键的插入顺序,但因为我们做了拆分出版信息的操作,所以最好显式指定columns。
df = pd.DataFrame(all_rows) df['作者'] = df['信息'].apply(lambda x: split_info(x)[0]) df['出版社'] = df['信息'].apply(lambda x: split_info(x)[1]) df['出版年'] = df['信息'].apply(lambda x: split_info(x)[2]) df['价格'] = df['信息'].apply(lambda x: split_info(x)[3]) df = df.drop(columns=['信息']) df = df[['排名', '书名', '作者', '出版社', '出版年', '价格', '评分', '评价人数', '一句话简介']] df.to_excel('douban_top250.xlsx', index=False)一个小提示:上面这种写法对每一行调用了四次split_info,数据量小无所谓,但如果以后爬的是几十万条的数据,性能就有优化空间了。更好的做法是在parse_page阶段就把拆分好的字段直接放进去,或者用一个列表推导先形成完整字段字典再转DataFrame。这个项目里怎么直观怎么来,能跑就行。
4. 实测运行与数据校验
4.1 跑一遍看效果
脚本写完,我在终端里执行了python douban_top250.py,输出结果大概是这样的:
[+] start=0 解析到 25 条数据 [+] start=25 解析到 25 条数据 [+] start=50 解析到 25 条数据 ... [+] start=225 解析到 25 条数据10页全部正常抓取,没有触发重试流程。总耗时在30秒上下,因为每页之间还有随机延时。这个速度我个人认为是合理的,毕竟项目是单线程,而且数据量又不大。
打开当前目录下的douban_top250.xlsx,能看到一个250行9列的表格,第一列是排名从1到250,最后一列是评分和评价人数。看到第一版Excel出来的瞬间,那种“从零到有”的成就感确实很实在,这也是我为什么推荐新手做这个项目的原因——反馈非常直观。
4.2 数据条数与内容抽查
但是爬虫写完了,数据出来了,不代表就没问题了。一定要做数据校验。我当时抽了几个维度来查:
- 用
len(df)确认DataFrame的行数等于250。 - 抽查排名为1、50、100、250这四本书,浏览器里翻到对应页面,人工比对书名和评分是否一致。
- 检查有没有缺失值比较严重的列,比如
df.isna().sum(),看看哪些字段有空数据。豆瓣书评里有些书没有“一句话简介”,这个空着是正常的,不代表抓取失败。
另外一个很容易忽略的校验点是排名连续性。因为程序设计成每页25条,理论上排名应该从1连续递增到250,如果中间有跳号或者重号,说明解析时某些节点被漏掉或重复抓了。我用一句简单的代码检查:
rank_series = df['排名'].astype(int) assert list(rank_series) == list(range(1, 251))这条断言过了,基本可以保证页面解析逻辑没有漏数据。这种校验思路比人眼一张张翻页看靠谱得多。
4.3 导出文件时的几个细节
导出Excel时有个默认行为值得注意:pandas的to_excel会把索引列也写进表里。如果index=False没写,那表格最左边会多出一列0到249的序号,看起来非常不专业,第一次跑的同学很容易漏掉。所以在导出时我习惯性写index=False,这一行小参数能避免一个看起来很蠢的问题。
还有表头的列宽。pandas导出的Excel默认列宽比较窄,中文长字段比如书名和一句话简介会显示不全。但需要双击单元格才会自动调整列宽,直接打开文件时内容会被截断,看着很难受。后来我写了一个openpyxl的后期处理函数,专门设置每个列一个合适的宽度,输出文件的观感会好很多。这个属于锦上添花,不影响数据内容,但作为交付给别人的Excel表格,印象分会高不少。
from openpyxl import load_workbook def set_col_width(path, widths): wb = load_workbook(path) ws = wb.active for col_idx, width in enumerate(widths, start=1): ws.column_dimensions[chr(64 + col_idx)].width = width wb.save(path) set_col_width('douban_top250.xlsx', [6, 28, 30, 18, 10, 10, 6, 12, 38])5. 常见问题与避坑清单
5.1 请求返回403或418
这是爬虫实战里最常遇到的反爬信号。造成这个问题的原因通常集中在两点:User-Agent不对,或者请求频率太高。我自己的排查顺序一般是先确认headers是否写完整,尤其是User-Agent那一段,不能写错成python-requests。如果确定不是UA的问题,那就是抓得太猛了,把网络断开等个几分钟再跑,同时把请求间隔调大,比如改成每秒钟最多一次。我实测下来,豆瓣图书Top250这个页面其实比较温和,只要UA正常、隔0.5秒以上,基本能稳定跑完,用不着上重试机制以外的高级手段。
这里也想说一句,不要想着用代理池、并发矩阵那一套去“对抗”目标站点。一个练手项目,数据抓到手、程序逻辑练到位,就足够了。做任何爬虫项目都要遵守目标网站的robots.txt和相关服务条款,控制合理频率,数据仅用于个人学习研究,这是底线。
5.2 XPath取不到内容
XPath规则写完后却解析不到数据,这是新手最容易困惑的地方。我的排查流程是:先在浏览器控制台里用$x('//tr[@class="item"]')这种语法直接验证XPath表达式能不能匹配到节点,匹配不到就说明表达式本身写错了;匹配到了但代码里取不到,那就检查是不是解析的文档内容不对。
这里要提醒一个很隐蔽的坑:如果用resp.text拿到的文本里含有乱码,XPath的文本匹配会失败;或者服务端返回了一段反爬提示页面而不是正常的书单页面,XPath当然什么都匹配不到。所以出问题的时候,先别急着调XPath,把响应内容的前500个字符打印出来看一眼,确认抓到的确实是自己想抓的页面,再往下排查。我之前做其他爬虫项目时,就遇到过被返回一个空壳页面,所有XPath全失效的情况,这个排查思路帮了我大忙。
5.3 抓到的数据条数不对
如果一页里解析出来的条数不是25条,通常是某个字段在个别书目节点上结构不完全一致导致的。这时候不能用“第一项匹配”的简化逻辑,而要看具体是哪条数据被漏掉了。我在调试的时候就发现过,个别书没有“quote”节点,如果把XPath写成直接链式取第一个span的text,整条tr都不会有结果,但正确逻辑应该是“取得到就取,取不到就填空字符串”。所以解析函数里的if判断不是多余的,它就是用来兜这种半结构化差异的。
5.4 中文字符乱码
乱码问题在爬虫里几乎是必经之路。根源就是编码解码不匹配。我在3.2节提到用resp.content传给lxml解析可以规避大部分乱码,因为lxml会读HTML里的charset声明。如果还是乱码,就在requests请求后手动指定编码:
resp.encoding = 'utf-8'这个写法是直接把响应对象的解码方式强制成UTF-8,但前提是目标页面的真实编码确实是UTF-8。豆瓣这个页面就是UTF-8,所以这种方案可行。遇到其他网站时,就得先看响应头里的Content-Type或页面meta标签确认编码,再做对应设置。
5.5 其他容易忽略的坑
保存Excel时报错PermissionError,十有八九是因为你手动打开了同名Excel文件,进程锁定了文件,把文件关掉再跑就行。
另外,抓完整批数据之后,全量打印所有行会把终端刷爆,建议只打印每页条数和最后几行样例数据。这只是个使用习惯问题,但能避免数据多了以后终端卡顿。
6. 最后分享一点个人的体会
做爬虫项目,很多人最关心的是“怎么绕过限制”,但我的体会是,真正拉开差距的往往是基本功:能不能看懂页面结构,能不能写出稳定的解析逻辑,拿到数据之后知不知道怎么校验。豆瓣图书Top250这个项目把这些环节全部串起来了,做完一遍,你对爬虫的整个工作流就会有体感,而不是停在抄代码的层面。
在这个项目的后续扩展方向上,我自己当时是往这几条线走了:一是给爬虫加上数据更新的能力,可以定期重跑并和旧数据做对比;二是把单一Excel导出改成导出为CSV和SQLite,方便其他程序读取;三是对评分和评价人数做最简单的统计分析,发现评分9.0以上的书在出版年份上有什么趋势。把这些方向一个个做下来,爬虫这部分的基本功算是彻底打了底子。