☰
Python爬虫实战:BeautifulSoup抓取豆瓣图书Top250并导出Excel
2026/10/14 2:10:41 网站建设 项目流程

2. 项目启动前的准备工作

2.1 环境搭建与依赖安装

开工之前先说结论:这个项目不需要任何重型工具,一个干净的Python 3环境就够了。建议使用3.8以上版本,下面的库是标准组合。

pip install requests beautifulsoup4 pandas openpyxl
  • requests负责发送HTTP请求,拿到网页源码
  • beautifulsoup4负责解析HTML,定位图书信息
  • pandas负责数据整理和导出Excel
  • openpyxl是pandas写Excel时需要的底层引擎

如果安装过程中遇到网速问题,可以临时切换镜像源:

pip install -i https://pypi.tuna.tsinghua.edu.cn/simple requests beautifulsoup4 pandas openpyxl

我个人建议用venv或conda建一个独立环境,不要跟系统环境混在一起。爬虫项目依赖版本变动快,独立的虚拟环境能避免很多莫名其妙的冲突。

2.2 为什么不走接口、不改动模拟登录

很多人一上手就想找豆瓣的隐藏接口,觉得直接拿JSON比解析HTML高级。但豆瓣图书Top250的页面恰好是服务端渲染的,所有数据都实实在在地写在HTML源码里,不需要登录,不需要计算签名,不需要处理加密参数,直接用requests就能拿到完整内容。

对于入门项目来说,这正是刻意选择的价值——把注意力放在爬虫最核心的“请求-解析-存储”链路上。你至少要能看懂普通请求和HTML解析的流程,后面遇到真正需要逆向接口的站点时,才知道问题到底出在哪个环节。一上来就上Selenium无头浏览器,反而掩盖了爬虫的基本功。

另外从维护角度看,HTML页面的结构在豆瓣这种老牌站点上相对稳定,而接口经常伴随前端改版一起变动,解析HTML的存活时间往往更长。

3. 请求构造与网页结构拆解

3.1 请求头模拟

豆瓣的反爬策略不算苛刻,但会校验User-Agent。如果你用requests默认的python-requests/2.x去请求,大概率直接吃一个403或者418。模拟正常浏览器请求,至少需要带上UA和语言偏好:

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-Language": "zh-CN,zh;q=0.9", }

解释一下这里面的逻辑。User-Agent本质上是告诉服务器“我是谁”,服务器首先用它做第一道基础身份识别。Accept-Language代表“我偏好用什么语言”,带上中文以后,服务器返回的HTML就是UTF-8编码的简体中文内容,后续解析时不容易出现中文乱码。

3.2 URL构造与分页规律

豆瓣图书Top250的URL非常直观:

https://book.douban.com/top250?start=0 https://book.douban.com/top250?start=25 https://book.douban.com/top250?start=50

start就是偏移量,从0开始,每页25条,到225结束,一共10页。这个规律一旦摸清,翻页逻辑就变得极其简单,不需要去解析“下一页”按钮的链接。

我第一次写的时候还想着去找翻页的href属性,后来发现完全没必要。直接for循环生成10个URL就行,代码更短,逻辑也更清晰。

3.3 页面HTML结构拆解

拿到网页源码后,先不要急着写解析代码,先花五分钟看一下结构。豆瓣图书Top250页面的每条图书记录都包裹在一个tr class="item"节点里,核心信息分布如下:

<tr class="item"> <td> <a href="https://book.douban.com/subject/..."> <img src="..." /> </a> </td> <td valign="top"> <div class="pl2"> <a href="..." title="百年孤独">百年孤独</a> <p class="pl">[哥伦比亚] 加西亚·马尔克斯 / 范晔 / 南海出版公司 / 2011-6 / 39.50元</p> <div class="star clearfix"> <span class="rating_nums">9.3</span> <span class="pl">( 12345人评价 )</span> </div> <p class="quote"> <span class="inq">魔幻现实主义文学的代表作……</span> </p> </div> </td> </tr>

这里有四个关键字段值得注意:

  • 书名完整信息在a标签的title属性里,页面上展示的书名有时会因过长而被截断
  • 作者、出版社、出版年、价格全部挤在p class="pl"这一行里,中间用斜杠分隔
  • 评分在span class="rating_nums"里
  • 评价人数在span class="pl"里,格式是( 12345人评价 )
  • 简介摘要在p class="quote"里,如果没有内容,这个节点可能不存在

注意:评价人数那里的span class="pl"和下面作者信息里的p class="pl"是重名的,选择器写法要区分清楚,别被类名骗了。

4. 核心解析流程与字段提取

解析阶段的目标是逐条提取图书信息,整理成一个结构化字段集合:书名、作者/译者、出版社、出版年、价格、评分、评价人数、简介。下面是一段可以直接用的解析函数:

import re import requests from bs4 import BeautifulSoup def parse_item(item): title_tag = item.select_one("div.pl2 a") if not title_tag: return None title = title_tag.get("title", "").strip() author_info = item.select_one("p.pl") author_text = author_info.get_text().strip() if author_info else "" rating_tag = item.select_one("span.rating_nums") rating = rating_tag.get_text().strip() if rating_tag else "" comment_tag = item.select_one("div.star span.pl") comment_text = comment_tag.get_text() if comment_tag else "" comment_match = re.search(r"(\d+)", comment_text) comment_count = comment_match.group(1) if comment_match else "0" quote_tag = item.select_one("p.quote span.inq") intro = quote_tag.get_text().strip() if quote_tag else "无简介" return { "书名": title, "作者信息": author_text, "评分": rating, "评价人数": comment_count, "简介": intro, }

从这段代码可以读出几个比较细的经验:

第一,取书名时用get("title")而不是get_text()。页面展示的书名可能只有一部分,但title属性里永远是完整的。这是爬虫解析中非常典型的“显示文本与属性值不一致”陷阱。

第二,作者信息那一长串文本,用get_text()后要自己拆,不过先留着原始字符串就好,清洗时再处理,不要在一个函数里塞太多逻辑。

第三,简介节点是可能不存在的。很多图书没有名人推荐语,p.quote就整个消失了。如果直接调用select_one(...).get_text()会抛AttributeError,导致整个程序中断。所以必须先判空,再用空字符串兜底。

只提取这五个字段,已经把Top250的核心维度都覆盖了。再往细了加字段,比如ISBN、页数,也能从这个页面上拿到,但会增加解析难度,等你跑通第一遍再扩展不迟。

5. 翻页循环、限速与容错处理

单页解析跑通以后,就要把10页连起来跑。这里最核心的工程素养是:请求之间要控制频率,失败要能自动重试,避免因为某一个页面挂了就前功尽弃。

5.1 循环请求的实现

all_books = [] page_errors = [] for start in range(0, 226, 25): url = f"https://book.douban.com/top250?start={start}" resp = None for attempt in range(3): # 每页最多重试3次 try: resp = requests.get(url, headers=headers, timeout=10) if resp.status_code == 200: break except requests.exceptions.RequestException: pass time.sleep(5) if resp is None or resp.status_code != 200: page_errors.append(start) print(f"第{start // 25 + 1}页失败,跳过") continue soup = BeautifulSoup(resp.text, "html.parser") items = soup.select("tr.item") for item in items: book = parse_item(item) if book: all_books.append(book) print(f"第{start // 25 + 1}页抓取完成,累计 {len(all_books)} 条") time.sleep(2)

几个关键点:

  • range(0, 226, 25)正好生成10个地址,0到225
  • 内层循环做的是失败重试,最多3次,每次重试前停5秒
  • time.sleep(2)是每次请求成功之后的最低限速

5.2 为什么强制限速

我见过不少初学者把间隔设成0.1秒,甚至去掉sleep,觉得250条数据很快就能爬完。结果往往是爬到第三页就被服务器限流,之后一直418,最后不得不换IP重新跑。

在爬虫这个场景里,time.sleep(2)不是浪费,而是换取稳定性的代价。250条数据,10个请求,就算每个间隔3秒,全部跑完也不超过1分钟。对个人学习项目来说,这个速度完全够用,没必要去挑战服务器的风控底线。

5.3 请求失败时的现场保存

另一个工程技巧是:不要等到全部爬完再保存。万一跑到第7页网络断了,前面的努力就全没了。可以在每页成功后,把all_books先序列化成JSON临时存一份:

import json with open("temp_books.json", "w", encoding="utf-8") as f: json.dump(all_books, f, ensure_ascii=False, indent=2)

这样即使程序中途崩了,重新启动时只要加载这个临时文件,接着最后一页继续爬即可,不用推倒重来。这个习惯在爬更大规模的网站时尤为重要。

6. 数据清洗与Excel导出

6.1 pandas处理脏数据

爬到手的原始数据,不能直接写进Excel。至少要做三件事:

  • 评价人数字段是字符串,带括号、带“人评价”字样,必须提取数字并转成数值类型
  • 作者信息里有大量斜杠分隔的碎片字段,需要按需求拆分
  • 简介字段可能出现空值或兜底文本
import pandas as pd df = pd.DataFrame(all_books) df["评分"] = pd.to_numeric(df["评分"], errors="coerce") df["评价人数"] = df["评价人数"].astype(int) df["作者信息"] = df["作者信息"].str.replace("\n", "").str.strip() df = df.drop_duplicates(subset=["书名"])

关于作者信息的拆分,很多人会把它拆成“作者”“译者”“出版社”“出版年”“价格”五列。这里要谨慎,因为部分旧版图书的作者信息格式不统一,比如有的没有译者,有的没有价格。直接按斜杠split可能产生错位。我的建议是保留完整的“作者信息”字段,额外的拆解放到后面需要时再做。

6.2 导出到Excel

output_path = "douban_book_top250.xlsx" df.to_excel(output_path, index=False, engine="openpyxl") print(f"已导出 {len(df)} 条数据到 {output_path}")

这一句就能生成一张干净的Excel表。pandas会自动把列名作为表头,每行是一条图书数据。如果想在导出后进一步美化,比如调整列宽、冻结首行、按评分降序排列,可以用openpyxl二次处理基础表格。

6.3 关于中文乱码

用pandas搭配openpyxl导出Excel时,内部是UTF-8编码,Excel能正常显示,一般不会乱码。如果中途改成导出CSV,则要特别注意编码问题:

df.to_csv("douban_book_top250.csv", index=False, encoding="utf-8-sig")

CSV文件必须用utf-8-sig,否则直接用Excel打开CSV时中文会变成乱码。这个坑我用记事本打开完全正常,用Excel打开却乱码,排查了很久才明白是编码签名的问题。

7. 常见问题与排查技巧

7.1 请求返回418或403

先检查请求头里有没有User-Agent。如果UA正常,那多半是请求频率太高被限流了。最简单的处理方式是增大time.sleep间隔,从2秒加到4秒或5秒,再重跑。

有一点需要强调:不要为了绕过风控去主动配置Cookie或者构造特殊请求头。爬这种公开静态页面,故意伪装反而容易触发更严格的验证策略。

7.2 爬了几页后连接超时

豆瓣对连续请求会偶尔断开TCP连接,报read timed out或者ConnectionError。这不是代码逻辑问题,是服务端的间歇性限流。解决方案是设置timeout=10,并且捕获requests.exceptions.RequestException,重试该页3次,每次重试间隔10秒以上。

如果重试3次仍然失败,就把当前页记录下来,先跳过继续跑后面的,拿完大部分数据之后再回来单独补爬失败的页面。这种“失败页重爬”策略比全局中断再重新开始要高效得多。

7.3 解析出来全是空列表

先用print(len(resp.text))看返回内容的长度。如果只有几百字节,说明你拿到的不是图书列表页,而是验证页或错误提示页。这时候要检查你的UA和请求频率。

再看resp.encoding。有时候服务器响应头没有明确声明字符编码,requests会默认按ISO-8859-1解码,导致中文字符乱码。解决办法是拿到响应后强制声明编码:

resp.encoding = "utf-8"

注意这个操作要在resp.text被访问之前设置,否则会拿不到正确解码后的结果。

7.4 评价人数提取出来带括号

评价人数的原始文本是( 12345人评价 ),带空格、带括号、带中文。直接强转int会报错,得先用正则或者字符串替换把数字提出来:

count = int(re.search(r"(\d+)", comment_text).group(1))

更稳妥的写法是提取之前先判断是否匹配成功,避免None.group()报错。

7.5 Excel里出现了重复书名

Top250按理说不应该重复,但你在写解析函数时,如果选择器写宽了,可能把同一个条目解析出多条。比如select("div.pl2 a")选中了同节点下的多个链接,就会出现重复。Add一个drop_duplicates(subset=["书名"])操作,既能排查数据问题,也能保证最终导出数据的干净。

8. 扩展方向与个人体会

这个项目跑完之后,整个“请求-解析-存储”链路就算完整走通了一遍。基于这套代码,后面可以做的扩展有很多:

  • 把评价人数和评分两列做散点图,观察“口碑与人气”的关系
  • 拆分作者字段,统计哪些出版社上榜次数最多
  • 把Excel导出改成写入MySQL或SQLite
  • 换成某个不同的图书榜单站点,只需要改CSS选择器和字段映射,其余代码几乎复用
  • 加一个增量爬取逻辑,定期重跑,对比榜单变化

我个人在做完这个项目后,最大的感受是:爬虫最复杂的部分往往不是请求代码本身,而是数据的稳定获取和清洗。刚开始可能觉得BeautifulSoup提取字段很繁琐,跑得多了就会发现,写一个健壮的解析函数,做好判空和异常兜底,比拼命追求代码简洁要重要得多。

最后再叮嘱一句关于使用边界的事。爬虫本身是中性工具,个人学习场景下爬取公开数据没什么问题,但要注意三点:控制请求频率,别给对方服务器造成压力;只爬公开页面,不碰需要登录才能访问的受限信息;爬下来的数据不要用于商业用途。这些边界守住,爬虫之路才会走得长远。

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

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

立即咨询