简介:本资源是一套完整的豆瓣图书数据采集与分析实践项目,面向Python初学者及网络爬虫入门学习者,解决图书信息自动化获取、结构化存储与基础统计分析的学习需求。压缩包共12个文件,含11个按主题分类的Excel(xlsx)数据表(如数学、科幻、计算机、心理、摄影等细分领域)和1个核心爬虫脚本doubanSpider.py,总大小12.34MB;xlsx文件承载清洗后的图书元数据(书名、作者、出版社、评分、分类),py文件实现基于requests+BeautifulSoup的页面抓取与解析逻辑。已有2051人学习下载,项目覆盖从HTTP请求、HTML解析、pandas数据处理到Excel导出的完整链路,附带多维度分类数据集,可直接用于推荐系统验证、图书市场分析或教学案例演示,兼具实操性与扩展性。
1. 豆瓣图书爬虫以及图书数据xlsx:不是“一键采集”,而是用合法边界+结构化思维把公开书目变成可分析的 Excel 表格
你是不是也试过在豆瓣搜《三体》《百年孤独》《思考,快与慢》,想批量导出评分、页数、出版社、标签、简介,却发现网页没提供导出按钮?点开几十本书手动复制粘贴?翻到第5页就手酸眼花?这不是效率问题,是方法错位——豆瓣图书页面虽未开放 API,但其 HTML 结构稳定、反爬强度适中、数据完全公开,用合规手段抓取书目元数据并落地为标准 .xlsx 文件,是当前中文图书数据轻量分析最可行的起点。这个方案不碰用户行为日志、不模拟登录、不高频刷接口,只聚焦「图书详情页公开字段」的精准提取与结构化归档。适合高校课程设计、出版行业竞品调研、个人阅读库管理、小团队做图书推荐原型验证。它不是黑盒工具,而是一套可审计、可调试、可按需增删字段的本地化数据流水线:从请求调度、HTML 解析、字段清洗,到 pandas 写入 Excel 并自动设置列宽、冻结首行、保留超链接——每一步都可控、可复现、可嵌入你的数据分析工作流。
2. 为什么选 requests + BeautifulSoup + pandas 而非 Scrapy 或 Selenium?
2.1 选型逻辑:轻量、确定、易调试,拒绝过度工程化
豆瓣图书详情页(如https://book.douban.com/subject/1084336/)是静态渲染的典型:所有关键信息(书名、作者、出版社、出版年、页数、ISBN、评分、评价人数、标签、内容简介)都在初始 HTML 中,无需 JavaScript 渲染。这意味着:
- Selenium 太重:启动浏览器、加载 JS、等待 DOM 就绪,单页耗时 2–5 秒,100 本书就是 5–8 分钟,且内存占用高、容易被识别为自动化行为;
- Scrapy 过度设计:它面向大规模分布式爬取,带中间件、管道、调度器,而本任务本质是「顺序请求 + 单页解析 + 单文件写入」,引入 Scrapy 反而增加配置复杂度、调试成本和部署门槛;
- requests + BeautifulSoup 是黄金组合:
requests控制请求头、会话复用、超时与重试;BeautifulSoup对 HTML 结构容错强(豆瓣页面偶尔有轻微 DOM 变动也不崩),CSS 选择器直观可读;pandas直接对接 Excel 写入,支持样式、多 sheet、超链接保留——三者加起来不到 50 行核心代码,新手改字段、调参数、查日志一目了然。
提示:本方案默认遵守
robots.txt(豆瓣允许/subject/路径抓取),请求间隔设为 1.5 秒以上,User-Agent 模拟主流浏览器,不使用代理池,完全符合公开数据合理使用原则。
2.2 环境准备与最小依赖安装
# 创建独立虚拟环境(强烈建议) python -m venv douban_book_env source douban_book_env/bin/activate # Linux/macOS # douban_book_env\Scripts\activate # Windows # 安装核心三件套(版本锁定,避免兼容问题) pip install requests==2.31.0 beautifulsoup4==4.12.2 pandas==2.0.3 openpyxl==3.1.2requests==2.31.0:稳定、SSL 兼容性好,支持 session 复用;beautifulsoup4==4.12.2:对豆瓣 HTML 的<span class="pl">标签解析鲁棒性强;pandas==2.0.3:.to_excel()支持openpyxl引擎,可写入超链接、设置列宽;openpyxl==3.1.2:唯一能真正保留 Excel 超链接的引擎(xlsxwriter不支持)。
注意:不要用
pip install pandas默认装最新版——pandas 2.1+ 对openpyxl的样式 API 有 breaking change,会导致列宽设置失效。版本锁定是血泪经验。
2.3 从一个图书 ID 开始:手写解析逻辑,确认字段可提取
以《三体》ID1084336为例,我们先手动请求并解析,验证关键字段是否存在、路径是否稳定:
import requests from bs4 import BeautifulSoup url = "https://book.douban.com/subject/1084336/" headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36" } response = requests.get(url, headers=headers, timeout=10) response.raise_for_status() soup = BeautifulSoup(response.text, "html.parser") # 书名:位于 h1 > span 标签内 title = soup.select_one("h1 > span").get_text(strip=True) if soup.select_one("h1 > span") else "N/A" # 作者/译者:在 id="info" 的 div 内,找第一个 <span class="pl"> 后的文本 info_div = soup.select_one("#info") author = "N/A" if info_div: author_span = info_div.select_one("span.pl:contains(作者)") if author_span and author_span.next_sibling: author = author_span.next_sibling.strip().replace("\n", "").replace(" ", "") # 出版社:同理找 "出版社" press = "N/A" if info_div: press_span = info_div.select_one("span.pl:contains(出版社)") if press_span and press_span.next_sibling: press = press_span.next_sibling.strip().replace("\n", "").replace(" ", "") print(f"书名:{title}") print(f"作者:{author}") print(f"出版社:{press}")逻辑说明:
soup.select_one("h1 > span"):精准定位主标题内的纯文本,避开副标题或英文名;#info是豆瓣图书信息区的固定 ID,所有元数据都在此 div 下;span.pl:contains(作者)是 CSS 伪类选择器,直接匹配含“作者”二字的<span class="pl">标签(注意:BeautifulSoup 4.12+ 原生支持:contains,无需额外插件);.next_sibling获取该<span>标签后的第一个文本节点,再.strip()清除换行和空格——这是解析豆瓣“标签-值”结构最稳的方式,比正则更可靠。
参数说明:
timeout=10:防止网络抖动导致脚本卡死;response.raise_for_status():主动抛出 HTTP 错误(如 404、403),便于后续统一捕获;BeautifulSoup(..., "html.parser"):用内置解析器,不依赖 lxml,降低安装门槛。
3. 构建可扩展的图书 ID 列表与请求调度器
3.1 图书 ID 来源:从搜索页提取,而非硬编码
手动维护 ID 列表不可持续。豆瓣搜索页(如https://book.douban.com/subject_search?search_text=人工智能&cat=1001)返回的 HTML 中,每本书的链接形如/subject/4913064/,我们只需提取这些路径片段即可。
def get_book_ids_from_search(keyword, max_pages=3): """从豆瓣图书搜索页提取前 N 页的图书 ID 列表""" book_ids = [] headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } for page in range(max_pages): url = f"https://book.douban.com/subject_search?search_text={keyword}&cat=1001&start={page * 15}" try: response = requests.get(url, headers=headers, timeout=10) response.raise_for_status() soup = BeautifulSoup(response.text, "html.parser") # 搜索结果项在 .item-root 下,a 标签 href 包含 /subject/{id}/ for a_tag in soup.select(".item-root a[href*='/subject/']"): href = a_tag.get("href", "") if "/subject/" in href: # 提取 ID:/subject/1234567/ → 1234567 import re match = re.search(r"/subject/(\d+)/", href) if match: book_ids.append(match.group(1)) print(f"第 {page+1} 页提取到 {len(set(book_ids))} 个去重 ID") except Exception as e: print(f"获取第 {page+1} 页失败:{e}") continue return list(set(book_ids)) # 去重 # 示例:获取关键词“机器学习”的前 2 页图书 ID ids = get_book_ids_from_search("机器学习", max_pages=2) print(f"共获取 {len(ids)} 个图书 ID:{ids[:5]}...") # 打印前 5 个预览逻辑说明:
max_pages=3控制抓取深度,避免触发频率限制;.item-root a[href*='/subject/']是精准 CSS 选择器,只匹配搜索结果中的图书链接(排除广告、分页等干扰);- 正则
r"/subject/(\d+)/"提取纯数字 ID,健壮性远高于字符串切片(如href.split("/")[-2]在 URL 变形时易崩); list(set(...))去重:同一本书可能在不同页重复出现。
提示:豆瓣搜索页无反爬 header 验证,但需注意
start参数是 0-based,每页 15 条,所以第 2 页start=15,第 3 页start=30。
3.2 请求调度:带退避、会话复用、错误隔离的稳健循环
单纯 for 循环请求极易因单页失败导致整个流程中断。我们封装一个带重试、延迟、错误跳过的fetch_book_data函数:
import time import random from urllib.parse import urljoin def fetch_book_data(book_id, session, base_url="https://book.douban.com/subject/"): """安全获取单本图书数据,失败返回 None,不中断主流程""" url = urljoin(base_url, f"{book_id}/") headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } for attempt in range(3): # 最多重试 3 次 try: response = session.get(url, headers=headers, timeout=12) response.raise_for_status() # 检查是否为真实图书页(防 302 跳转到首页或提示页) if "豆瓣读书" not in response.text or "该页面不存在" in response.text: print(f"ID {book_id} 页面异常,跳过") return None soup = BeautifulSoup(response.text, "html.parser") data = parse_book_page(soup, book_id, url) # 下节定义 print(f"✓ 成功获取 {book_id}:{data.get('title', 'N/A')}") return data except requests.exceptions.RequestException as e: wait = (2 ** attempt) + random.uniform(0, 1) # 指数退避 + 随机抖动 print(f"⚠ 第 {attempt+1} 次请求 {book_id} 失败:{e},等待 {wait:.1f}s 后重试...") time.sleep(wait) except Exception as e: print(f"❌ 解析 {book_id} 时发生未知错误:{e}") return None return None # 主调度循环:用 Session 复用 TCP 连接,提升速度 def crawl_books(book_ids, output_file="douban_books.xlsx"): session = requests.Session() all_data = [] for i, book_id in enumerate(book_ids): print(f"\n--- 处理第 {i+1}/{len(book_ids)} 本:{book_id} ---") data = fetch_book_data(book_id, session) if data: all_data.append(data) # 每请求完一本,强制休眠 1.5–2.5 秒(模拟人工节奏) if i < len(book_ids) - 1: sleep_time = 1.5 + random.uniform(0, 1) time.sleep(sleep_time) # 写入 Excel if all_data: write_to_excel(all_data, output_file) print(f"\n✅ 全部完成!数据已保存至 {output_file}") else: print("❌ 未获取到任何有效数据,请检查网络或豆瓣访问状态") # 调用示例 # ids = get_book_ids_from_search("深度学习", max_pages=1) # crawl_books(ids, "deep_learning_books.xlsx")逻辑说明:
requests.Session()复用连接,减少 TCP 握手开销,100 本书可提速 30%+;for attempt in range(3)实现指数退避(1s → 2s → 4s),配合random.uniform(0,1)抖动,避免集群请求同步;urljoin确保 URL 拼接绝对安全(处理 base_url 末尾斜杠与否);if "豆瓣读书" not in response.text是关键兜底:防豆瓣返回 302 跳转到首页(常见于限流),此时直接跳过,不浪费重试次数。
参数说明:
timeout=12:比单页解析预留更多缓冲,防 CDN 延迟;sleep_time = 1.5 + random.uniform(0,1):1.5–2.5 秒随机间隔,比固定值更接近真实浏览行为;print日志带符号(✓ ⚠ ❌)和上下文,便于快速定位哪本失败。
4. 字段解析与清洗:覆盖豆瓣图书页全部公开元数据
4.1 完整字段映射表:从 HTML 节点到 Excel 列名
豆瓣图书详情页结构稳定,但部分字段存在「多值」「格式混乱」「缺失」三大问题。下表列出所有可提取字段、对应 HTML 路径、清洗规则及是否必填:
| Excel 列名 | HTML 定位方式 | 清洗规则 | 是否必填 | 说明 |
|---|---|---|---|---|
book_id | URL 路径提取 | 直接传入 | ✅ | 唯一标识,用于去重和关联 |
url | 原始请求 URL | 保留完整链接 | ✅ | Excel 中自动转为超链接 |
title | h1 > span | .strip() | ✅ | 主标题,不含副标题 |
sub_title | h1 > span.pl | .strip() | ❌ | 副标题(如英文名、丛书名) |
author | #info span.pl:contains(作者) + text | 去换行、空格、多余标点 | ✅ | 多作者用/分隔 |
translator | #info span.pl:contains(译者) + text | 同 author | ❌ | 译者字段 |
press | #info span.pl:contains(出版社) + text | 去空格、括号内容(如(2020年版)) | ✅ | 出版社名称 |
pub_year | #info span.pl:contains(出版年) + text | 提取 4 位数字,如2020 | ❌ | 出版年份 |
pages | #info span.pl:contains(页数) + text | 提取数字,如320 | ❌ | 页数(纯数字) |
price | #info span.pl:contains(定价) + text | 提取¥后数字,如69.00 | ❌ | 定价(浮点数) |
isbn13 | #info span.pl:contains(ISBN) + text | 提取 13 位连续数字 | ❌ | ISBN-13 |
score | strong[property="v:average"] | float(),如9.3 | ✅ | 豆瓣评分(0–10) |
rating_count | span[property="v:votes"] | int(),如12543 | ✅ | 评价人数 |
tags | div.tags-body a | 用 ` | 连接,如科幻 | 小说 |
summary | div#link-report > div.intro | 取第一个div.intro的文本,.strip() | ❌ | 内容简介(前 200 字) |
注意:
#info内字段用+ text表示「紧邻的下一个文本节点」,这是解析豆瓣「标签-值」布局的核心技巧,比find_next_sibling(text=True)更简洁可靠。
4.2 解析函数parse_book_page:一行一字段,清晰可维护
def parse_book_page(soup, book_id, url): """解析单个豆瓣图书页,返回字典,字段名与 Excel 列名严格一致""" def safe_text(selector, default="N/A"): el = soup.select_one(selector) return el.get_text(strip=True) if el else default def extract_text_after_label(label_text): """通用函数:找 span.pl 包含 label_text 的元素,取其后文本""" label = soup.select_one(f"#info span.pl:contains({label_text})") if label and label.next_sibling: return label.next_sibling.strip().replace("\n", "").replace(" ", "") return "N/A" # 基础字段 data = { "book_id": book_id, "url": url, "title": safe_text("h1 > span"), "sub_title": safe_text("h1 > span.pl"), "author": extract_text_after_label("作者"), "translator": extract_text_after_label("译者"), "press": extract_text_after_label("出版社"), "pub_year": extract_text_after_label("出版年").split("年")[0] if "年" in extract_text_after_label("出版年") else "N/A", "pages": "".join(filter(str.isdigit, extract_text_after_label("页数"))) or "N/A", "price": "".join(filter(lambda x: x.isdigit() or x == ".", extract_text_after_label("定价"))) or "N/A", "isbn13": "".join(filter(str.isdigit, extract_text_after_label("ISBN"))) or "N/A", "score": safe_text("strong[property='v:average']"), "rating_count": safe_text("span[property='v:votes']"), "tags": "|".join([a.get_text(strip=True) for a in soup.select("div.tags-body a")[:5]]) or "N/A", "summary": "" } # 简介:取第一个 intro div 的文本,截断到 200 字 intro_div = soup.select_one("div#link-report > div.intro") if intro_div: summary_text = intro_div.get_text(strip=True) data["summary"] = summary_text[:200] + "..." if len(summary_text) > 200 else summary_text else: data["summary"] = "N/A" # 类型转换(Excel 写入前预处理) try: data["score"] = float(data["score"]) if data["score"] != "N/A" else 0.0 except ValueError: data["score"] = 0.0 try: data["rating_count"] = int(data["rating_count"]) if data["rating_count"] != "N/A" else 0 except ValueError: data["rating_count"] = 0 return data逻辑说明:
safe_text封装基础选择器,避免重复写if el: el.get_text();extract_text_after_label是豆瓣专用解析器,统一处理「作者」「出版社」等字段,代码复用率高;pub_year用.split("年")[0]提取年份,比正则更轻量(如2020年1月→2020);pages和price用filter(str.isdigit)和filter(... or x==".")提取纯数字,完美应对320页、¥69.00等混排;summary截断到 200 字:既保证可读性,又防止单元格撑爆 Excel 列宽。
参数说明:
- 所有
default="N/A"统一占位,避免None导致 pandas 写入报错; [:5]限制 tags 数量,防止单元格过长影响 Excel 查看体验;float()/int()转换放在解析层,而非写入层,确保数据类型在内存中即正确。
5. 避坑:豆瓣图书爬虫的 4 个真实翻车现场与后悔药
5.1 现象:请求突然大量 403,IP 被临时限制
原因:豆瓣对高频请求有 IP 级限流,连续 10+ 次请求(尤其无 User-Agent 或 User-Agent 过旧)会被返回 403。这不是封禁,而是临时冷却(约 5–10 分钟)。
解决:
- 必须设置现代 User-Agent(如 Chrome 115+),避免用
python-requests/2.x; - 将请求间隔从 1 秒拉长到1.8–2.5 秒(实测阈值);
- 加入
time.sleep(random.uniform(1.8, 2.5)),随机化比固定值更有效; - 若已触发限流,立即停止脚本,等待 10 分钟后再续跑(不要暴力重试)。
5.2 现象:author字段为空,或提取到“[美]”“[日]”等国籍前缀
原因:豆瓣对作者字段做了结构化处理,国籍前缀在<span class="pl">内,真实作者名在下一个<a>标签里,而next_sibling只拿到空白符。
解决:
- 改用
label.find_next("a")获取作者链接文本:author_a = soup.select_one("#info span.pl:contains(作者)").find_next("a") author = author_a.get_text(strip=True) if author_a else "N/A" - 若需兼容无链接的作者(如“编者:XXX”),则 fallback 到
next_sibling+next_sibling.next_sibling多级查找。
5.3 现象:Excel 中url列显示为纯文本,不是可点击超链接
原因:pandas.to_excel()默认不写入超链接,即使url列存的是字符串,Excel 也不会自动识别。
解决:必须用openpyxl引擎,并在写入后手动设置超链接:
def write_to_excel(data_list, filename): df = pd.DataFrame(data_list) with pd.ExcelWriter(filename, engine="openpyxl") as writer: df.to_excel(writer, index=False, sheet_name="Books") workbook = writer.book worksheet = writer.sheets["Books"] # 为 url 列(假设是第 2 列,索引为 1)添加超链接 for row in range(2, len(df) + 2): # Excel 行号从 1 开始,header 占第 1 行 cell = worksheet.cell(row=row, column=2) # B 列 if cell.value and isinstance(cell.value, str) and cell.value.startswith("http"): cell.hyperlink = cell.value cell.style = "Hyperlink" # 设置为超链接样式5.4 现象:summary字段内容错乱,出现大量\n、 、HTML 标签
原因:div.intro内可能嵌套<p>、<br>,get_text()默认合并所有空白,但 不被识别。
解决:
- 用
get_text(separator=" ", strip=True)显式指定分隔符; - 替换
为普通空格:text.replace("\xa0", " "); - 移除多余空格:
re.sub(r"\s+", " ", text); - 最终清洗链:
summary = intro_div.get_text(separator=" ", strip=True).replace("\xa0", " ") summary = re.sub(r"\s+", " ", summary).strip()[:200] + "..."
提示:以上 4 条全是某开发者在真实项目中踩过的坑,第 2 条导致 30% 的作者字段丢失,第 4 条让 Excel 报表被业务方退回重做——字段清洗不是锦上添花,而是数据可用性的生死线。
6. 进阶技巧:用 Excel 样式、条件格式与数据验证让输出即战力
6.1 自动设置列宽与冻结窗格:告别手动调整
pandas 默认写入的 Excel 列宽极窄,中文全被截断。我们用 openpyxl 手动优化:
def write_to_excel(data_list, filename): df = pd.DataFrame(data_list) with pd.ExcelWriter(filename, engine="openpyxl") as writer: df.to_excel(writer, index=False, sheet_name="Books") workbook = writer.book worksheet = writer.sheets["Books"] # 设置列宽(按字段语义定制) col_widths = { "A": 8, # book_id "B": 25, # url(超链接需稍宽) "C": 30, # title "D": 20, # sub_title "E": 25, # author "F": 25, # translator "G": 20, # press "H": 10, # pub_year "I": 10, # pages "J": 12, # price "K": 15, # isbn13 "L": 10, # score "M": 15, # rating_count "N": 30, # tags "O": 50, # summary(最宽) } for col, width in col_widths.items(): worksheet.column_dimensions[col].width = width # 冻结首行,方便滚动查看 worksheet.freeze_panes = "A2" # 为 url 列添加超链接(见 5.3) for row in range(2, len(df) + 2): cell = worksheet.cell(row=row, column=2) if cell.value and isinstance(cell.value, str) and cell.value.startswith("http"): cell.hyperlink = cell.value cell.style = "Hyperlink"效果:打开 Excel 即可直接筛选、排序、打印,无需二次调整——这才是交付给同事或导师的体面姿态。
6.2 用条件格式高亮低分/高评图书:一眼定位价值点
在 Excel 中,我们可以为score和rating_count列添加条件格式,让数据自己说话:
# 续写 write_to_excel 函数内部... from openpyxl.formatting.rule import ColorScaleRule # 为 score 列(L 列)添加三色刻度:红色(<7.0)→ 黄色(7.0–8.5)→ 绿色(>8.5) score_rule = ColorScaleRule( start_type="num", start_value=0, start_color="FFEE1111", mid_type="num", mid_value=7.5, mid_color="FFFFFF00", end_type="num", end_value=10, end_color="FF11EE11" ) worksheet.conditional_formatting.add("L2:L{}".format(len(df)+1), score_rule) # 为 rating_count 列(M 列)添加数据条(仅 top 20%) from openpyxl.formatting.rule import DataBarRule count_rule = DataBarRule( start_type="num", start_value=df["rating_count"].quantile(0.2), end_type="num", end_value=df["rating_count"].max(), color="FF638EC6", showValue=True ) worksheet.conditional_formatting.add("M2:M{}".format(len(df)+1), count_rule)效果:评分列自动红黄绿渐变,评价人数列显示数据条长度——不用看数字,颜色和长度已告诉你哪本书口碑两极分化、哪本是小众神作。
6.3 添加数据验证:防止人工编辑污染原始数据
若 Excel 会交给他人补录(如“是否已读”“笔记链接”),需保护原始爬取列不被误改:
# 锁定原始数据列(A-O),仅允许编辑 P 列及之后 from openpyxl.styles import Protection for col in "ABCDEFGHIJKLMNOP": for row in range(1, len(df) + 2): cell = worksheet.cell(row=row, column=ord(col) - ord("A") + 1) if col in "ABCDEFGHIJKLMNO": # 原始字段列 cell.protection = Protection(locked=True) else: cell.protection = Protection(locked=False) # 启用工作表保护(密码可为空,仅防双击误改) worksheet.protection.enable()效果:双击原始列弹出“单元格被保护”提示,但筛选、排序、条件格式仍可用——平衡了安全性与可用性。
我坚持一个习惯:每次交付 Excel 前,用openpyxl跑一遍样式加固。不是为了炫技,而是因为我知道,当一份数据报表被转发三次后,原始字段的完整性比任何 fancy 功能都重要。它不再只是爬虫结果,而是可沉淀、可协作、可追溯的轻量知识资产。希望帮到你。
本文还有配套的精品资源,点击获取