直接说结论:这个项目是练习爬虫最合适的入门实战之一。Books to Scrape 是专门为爬虫学习设计的演示站点,站内图书结构规整、分页清晰、无复杂登录,几乎没有任何反爬门槛,而且官方允许采集。我前后用 requests 加 XPath 的组合,从零开始到跑完全站 50 页、导出干净可用的 CSV,总共花了不到一个晚上。如果你刚开始学 Python 爬虫,或者想找一个完整的数据采集项目写在简历里,这个项目值得完整做一遍。
这个过程中你会踩到几个非常典型的坑,比如编码导致的中文乱码、XPath 表达式写错取不到数据、列表页数据与详情页字段对不上。我都会在下面展开讲,并把可直接复制的代码和踩坑经验一并放出来。
1. 项目剖析与全站数据结构拆解
1.1 为什么选 Books to Scrape 作为爬虫练手项目
这个站是国外一个专注于爬虫教学的实验性网站,它的定位就是给爬虫学习者一个干净、稳定的数据源。对比其他真实网站,它有几个非常明显的优势。
第一,站点是纯静态 HTML 渲染,所有数据都直接写在响应体里,不需要处理 JavaScript 动态加载,也不需要分析异步接口。你打开页面右键查看源代码,书名、价格、评分、库存、链接全部都能看到。这对新手极其友好,因为你可以先用浏览器验证自己的 XPath 表达式,再放进代码里跑。
第二,全站数据量适中,1000 本书,每页 20 本,共 50 页。这个规模既不会因为数据量太少导致练手不充分,也不会因为数据量太大导致采集时间过长。我实测跑完全站只需要几十秒,完全不用担心被封。
第三,页面结构高度规整。每本书的信息都包在<article class="product_pod">这个标签里,字段位置一致,写一套 XPath 就能批量提取所有图书。真实网站很少有这么统一的结构,所以这个站特别适合用来建立"模板化解析"的思维。
我自己做爬虫项目时有个习惯:接到一个需求,先花 20 分钟手工浏览目标网站的几个关键页面,搞清楚数据长在哪里、分页规则是什么、字段之间怎么关联,然后才写代码。这个项目同样如此,建议你也不急着写爬虫,先打开首页看看结构。
1.2 "全站图书数据"到底包含哪些字段
很多人做爬虫项目时,对"全站数据"的理解比较模糊,往往是爬到什么算什么。但真正的项目中,需求方一定会要求你明确字段清单。这个项目里的"全站图书数据",我建议至少包含以下字段:
| 字段名 | 来源 | 说明 |
|---|---|---|
| book_id | 详情页 URL 末尾 | 图书唯一标识,用于去重和后续关联 |
| title | 列表页或详情页 | 书名,含出版年份后缀 |
| price | 列表页 | 英镑价格,需清洗掉符号转成浮点数 |
| rating | 列表页 class 属性 | 评分分为 One 到 Five 五档 |
| stock | 列表页 | 库存状态,通常是 In stock 或 Out of stock |
| category | 详情页侧边栏 | 图书所属分类 |
| product_url | 列表页链接 | 详情页完整链接 |
| description | 详情页 | 图书简介,清洗后较干净 |
这里要特别提一下 book_id。Books to Scrape 的详情页 URL 尾部通常是一个数字 ID,比如a-light-in-the-attic_1000/index.html里的 1000。这个 ID 在全站范围内是唯一的,非常适合做主键。如果你后面要做增量采集或者多批次合并数据,这个字段就派上大用场了。
1.3 技术选型:requests + XPath 组合的合理性
这个项目技术选型上有三种主流方案:requests + 正则、requests + XPath/CSS选择器、Scrapy 框架。还有更重的 Selenium。我最终选择的是 requests + lxml 的 XPath 解析组合。
requests 负责发起 HTTP 请求并获取响应,lxml 负责把 HTML 文档转成可查询的树状结构,XPath 表达式负责精确提取节点。这套组合的优势是轻量、直观、调试方便,完全覆盖了这个项目的数据采集需求。
对比一下:用正则表达式解析 HTML 虽然不需要额外依赖,但写起来非常痛苦。图书列表页的 HTML 标签嵌套复杂,正则表达式面对这种半结构化文本很容易写出漏洞,比如误匹配、跨行匹配失败。Scrapy 框架功能强大,但对新手来说异步调度、中间件、Item Pipeline 这些概念过于庞大,做这个小项目反而有点杀鸡用牛刀。Selenium 就更不必说了,这个站不需要浏览器渲染,用它是在浪费资源。
等到你把这个项目跑通,再根据自己的需求去进阶学习 Scrapy,那时你对框架的理解会比直接上手深得多。爬虫的核心能力是"看清数据在哪、想清楚怎么取",工具只是手段。
2. 环境准备与请求层实现细节
2.1 环境安装与项目初始化
这个项目只需要三个核心依赖:requests、lxml、pandas(pandas 用于便捷的 CSV 导出,如果你不想装,Python 自带的 csv 模块也能完成同样的事,后面我会给出两种写法)。
pip install requests lxml pandas如果安装过程比较慢,可以临时切换国内镜像源:
pip install requests lxml pandas -i https://pypi.tuna.tsinghua.edu.cn/simple安装完成后,建议先用一个简短的脚本来验证请求链路,确认网络环境和响应状态都正常。这一步虽然简单,但能帮你提前把环境问题排查干净,免得写了一大段代码才发现 requests 都装不上。
import requests from lxml import html url = "https://books.toscrape.com/" resp = requests.get(url) print(f"状态码: {resp.status_code}") print(f"页面大小: {len(resp.text)} 字符") doc = html.fromstring(resp.text) book_count = len(doc.xpath("//article[contains(@class,'product_pod')]")) print(f"首页图书数量: {book_count}")如果看到状态码 200、页面大小正常、图书数量为 20,说明链路已经通了。我实际调试的时候,这一步几乎没出过问题,但多花两分钟验证一下能省下后面的排查时间。
2.2 请求头的伪装与约定
虽然 Books to Scrape 官方设计为允许爬取,但真实项目中请求头(Headers)的控制依然是基本功。我习惯在请求前设置 User-Agent、Accept-Language 和 Connection 这几个关键字段。
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": "en-US,en;q=0.9", "Connection": "keep-alive", }为什么 User-Agent 这么重要?因为服务器会通过这个字段识别客户端的身份。requests 默认的 User-Agent 是python-requests/x.x.x,有些服务器看到这个标识直接拒绝响应,甚至可能把非法请求当成攻击行为。换成主流浏览器的标识可以大大降低请求被拦截的概率。
这里要提醒一个容易被忽视的点:Accept-Language 字段。我用这个站测试时,不设置这个字段的话,部分环境下的响应内容可能不是你预期的版本。设成en-US,en;q=0.9可以确保服务器返回标准的英文页面内容,方便你后续用 XPath 匹配类名。
2.3 用 Session 复用连接
我建议你使用requests.Session()而不是每次请求单独requests.get()。Session 对象会自动管理 Cookie,并复用底层的 TCP 连接,减少握手开销。虽然这个站在多页采集场景下压力不大,但这是个值得养成的习惯。
session = requests.Session() session.headers.update(HEADERS)做好请求层之后,你就可以放心地进入解析环节了。请求层的核心经验就是:先确认你能稳定拿到 HTML,再考虑解析,顺序不能乱。
3. XPath 解析与关键字段提取实战
3.1 XPath 语法速览与调试方法
XPath 是一种在 XML/HTML 文档中查找信息的查询语言,在爬虫领域里比正则更直观。它的核心语法只有几个:
//表示在整个文档中查找,不考虑层级位置/表示直接子节点[]表示条件过滤@表示选取属性text()表示选取文本内容
比如//article[contains(@class, 'product_pod')]的含义是"在整个文档中查找所有 class 属性包含 product_pod 的 article 节点"。在这个站里,每本图书就是一个这样的 article,定位了这个节点后,再在当前节点范围内用.//h3/a这样的相对路径提取具体字段。
调试 XPath 表达式有一个很实用的方法:直接在浏览器里按 F12 打开开发者工具,在 Console 面板里用$x("你的表达式")来测试。比如输入$x("//article[contains(@class,'product_pod')]//h3/a"),浏览器会直接返回匹配到的节点列表。先在浏览器里验证表达式能取到正确节点,再写进 Python 代码,能节约大量调试时间。
3.2 核心字段的 XPath 表达式与原理
我直接给出实战中最常用的一套 XPath 表达式,以及对应的提取逻辑。
# 书名:取 h3 下 a 标签的 title 属性 title = book.xpath(".//h3/a/@title")[0].strip() # 详情页链接:取 h3 下 a 标签的 href 属性,并拼接完整 URL relative_url = book.xpath(".//h3/a/@href")[0] product_url = "https://books.toscrape.com/catalogue/" + relative_url # 价格:取价格节点文本,此时的文本类似于 "£51.77" price_text = book.xpath(".//div[contains(@class,'product_price')]//p[contains(@class,'price_color')]/text()")[0] price = float(price_text.replace("\u00a3", "")) # 评分:取星级节点 class 属性,类似 "star-rating Three",空格分割后取最后一项 rating_class = book.xpath(".//p[contains(@class,'star-rating')]/@class")[0].split()[-1] rating_map = {"One": 1, "Two": 2, "Three": 3, "Four": 4, "Five": 5} rating = rating_map.get(rating_class, 0) # 库存状态 stock_text = book.xpath(".//p[contains(@class,'instock')]/text()")[0].strip()这里有几个非常容易踩的坑。
价格的文本里包含的是英镑符号£,它的 Unicode 编码是\u00a3。直接对它做float()转换会报错,必须先把符号替换掉。有些人习惯用正则去匹配数字,但这里价格格式非常规整,直接用 replace 更简洁可靠。
评分字段不在文本里,而是藏在 class 属性中。这个设计比较特殊,你得先定位到p节点的 class 属性,再取空格分割后的最后一个词,最后通过映射字典转成数字。如果直接取文本内容,你会得到空字符串,因为星级评分的图案是 CSS 生成的,HTML 里根本没有对应的文本。
库存状态text()获取到的文本里往往带有换行和空格,需要 strip。这个站的库存状态比较规整,基本都是 "In stock" 或 "Out of stock",清洗成本很低。
3.3 单页解析函数的封装
把上述逻辑封装成函数,输入是 HTML 字符串,输出是图书字典列表。这个设计思路是把请求和解析解耦,方便后续调试和复用。
def parse_book_list(html_text): doc = html.fromstring(html_text) books = doc.xpath("//article[contains(@class,'product_pod')]") results = [] for book in books: try: relative_url = book.xpath(".//h3/a/@href")[0] product_url = "https://books.toscrape.com/catalogue/" + relative_url book_id = relative_url.split("_")[-1].split("/")[0] title = book.xpath(".//h3/a/@title")[0].strip() price_text = book.xpath(".//p[contains(@class,'price_color')]/text()")[0] price = float(price_text.replace("\u00a3", "")) rating_class = book.xpath(".//p[contains(@class,'star-rating')]/@class")[0].split()[-1] rating = rating_map.get(rating_class, 0) stock_text = book.xpath(".//p[contains(@class,'instock')]/text()")[0].strip() stock = "In stock" if "In stock" in stock_text else "Out of stock" results.append({ "book_id": book_id, "title": title, "price": price, "rating": rating, "stock": stock, "product_url": product_url, }) except Exception as e: print(f"解析单本书失败: {e}") return results封装时我特意加了 try/except。这个习惯对爬虫项目非常重要。真实网站的页面结构不是百分之百稳定的,某个节点为空或结构有细微变化都会导致 XPath 取不到值,如果你不捕获异常,整个脚本就会中断。加了异常处理后,最多是跳过一条数据并打印日志,采集流程不会断。
如果你希望字段更完整,可以在这里对每本书的详情页发一次请求,补充 category 和 description 字段。但这是可选项,我建议你先跑通列表页基础版本,再考虑升级到详情页增强版。
3.4 从详情页补充完整字段的进阶方案
如果你决定爬取详情页,这里有一个值得注意的细节:Books to Scrape 的详情页 URL 结构是https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html,而列表页里提取到的相对链接是a-light-in-the-attic_1000/index.html。所以拼接 URL 时需要在前面补上catalogue/,这个前缀不能漏。
详情页中 title 和 description 的 XPath 分别是:
# 详情页中的图书描述 desc = detail_doc.xpath("//div[contains(@id,'product_description')]/following-sibling::p/text()") # 详情页侧边栏的分类信息 category = detail_doc.xpath("//ul[contains(@class,'breadcrumb')]/li[last()]/a/text()")这些表达式在浏览器里验证一下就能理解。完整跑一遍详情页会让总请求量从 50 次变成 1050 次,注意控制一下并发,别把压力一次性打上去。
4. 分页循环与全站数据组装
4.1 分页 URL 规则与构造
Books to Scrape 的分页 URL 非常有规律。首页也就是第一页的 URL 是https://books.toscrape.com/,从第二页开始格式为https://books.toscrape.com/catalogue/page-2.html,第三页是page-3.html,依此类推,直到page-50.html。
注意第一页和后续页的 URL 格式不一致,这是个容易踩的坑。有人看到第二页的格式后,想当然地用page-1.html去构造第一页,结果发现打不开或拿到 404。实际处理中我的做法是先把第一页单独处理好,然后从 2 到 50 循环拼接。
你也可以先检查页面底部的 next 按钮来推断是否存在下一页:
next_link = doc.xpath("//li[contains(@class,'next')]/a/@href") if next_link: next_url = "https://books.toscrape.com/catalogue/" + next_link[0]这个判断方法更通用,因为真实网站的翻页机制比这个站复杂得多,有时候是跳页、有时候是加载更多按钮。掌握了"判断下一页是否存在"的思路,你面对动态站点时也更有头绪。
4.2 完整采集主流程
我给出的完整主流程是这样的:建立 Session,循环请求各页,每页解析并追加到总列表,最后统一导出 CSV。加上一个time.sleep(0.5)控制请求频率。
import requests import time from lxml import html session = requests.Session() session.headers.update(HEADERS) all_books = [] # 第一页 resp = session.get("https://books.toscrape.com/", timeout=10) all_books.extend(parse_book_list(resp.text)) # 第 2 到 50 页 for page_num in range(2, 51): url = f"https://books.toscrape.com/catalogue/page-{page_num}.html" resp = session.get(url, timeout=10) if resp.status_code != 200: print(f"第 {page_num} 页请求失败,状态码 {resp.status_code}") continue books = parse_book_list(resp.text) all_books.extend(books) print(f"第 {page_num} 页完成,当前累计 {len(all_books)} 本") time.sleep(0.5)这里有几个细节要说明。timeout 参数非常重要,如果你不设置它,请求一旦挂起,脚本就会卡住无法继续。time.sleep 是控制采集频率的关键手段,虽然这个站允许爬取,但保持一个合理的频率是基本的采集礼仪,也是专业爬虫工程师的习惯。
我在跑这个项目时还发现了一个细节:个别页面的响应可能因为网络波动导致解析为空,所以我会在 parse_book_list 里检查 books 列表是否为空,如果为空就打印警告日志。这能帮你快速定位是哪一页出了问题。
4.3 边采集边保存与中断恢复
在真实项目中,数据量一旦大起来,如果程序跑到第 40 页突然因为网络中断,之前爬的都白费了,那会很被动。所以我养成了一个习惯:边采集边增量写入 CSV,而不是全部爬完再一次性导出。
具体的做法是在每一页解析完成后立即追加写入 CSV 文件。这样即使程序中断,之前已经采集到的数据都安全落盘,下次从中断的页码继续即可。
import csv csv_file = open("books_data.csv", "w", newline="", encoding="utf-8-sig") writer = csv.DictWriter(csv_file, fieldnames=FIELDS) writer.writeheader() for page_num in range(1, 51): # ... 请求与解析逻辑 ... for book in books: writer.writerow(book) csv_file.flush()这里用encoding="utf-8-sig"是个关键细节,后面我会详细解释它和乱码问题的关系。
5. CSV 结构化导出与编码避坑
5.1 为什么选 CSV 作为导出格式
CSV 是表格类数据的通用交换格式,几乎任何数据分析工具(Excel、Tableau、pandas、数据库导入工具)都能直接读取。对于 1000 条规模的图书数据,CSV 文件大小通常只有几百 KB,用 Excel 打开也很流畅。如果数据量达到几十万条,我会考虑转成 Parquet 或 SQLite,但那不是这个项目的范畴。
5.2 标准库 csv 模块写法的完整方案
Python 标准库的 csv 模块足够应付大部分场景。下面是一份可以直接运行的完整导出代码:
import csv FIELDS = ["book_id", "title", "price", "rating", "stock", "product_url"] with open("books_data.csv", "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=FIELDS) writer.writeheader() for book in all_books: writer.writerow(book)我特别提醒一下newline=""这个参数。在 Windows 环境下,如果你不指定它为"",csv 模块写入每一行时会在行尾多出一个空行。这个细节不写代码时不觉得,真跑完一打开 CSV 看到满屏空行,你会怀疑自己代码写错了。
5.3 pandas 便捷导出方式
如果你已经装了 pandas,导出代码会更简洁,而且数据类型转换更方便:
import pandas as pd df = pd.DataFrame(all_books) df.to_csv("books_data.csv", index=False, encoding="utf-8-sig")不过 pandas 方式也有个潜在问题:当值里包含非常规字符时,它的默认转义规则可能和你预期的不完全一致。测试下来,纯文本场景没问题,但如果你后面爬的是含大量特殊符号的网站,建议还是用 csv 模块的自定义 quoting 参数来严格控制转义行为。
5.4 用 Excel 打开 CSV 中文乱码的全套解法
这是这个项目里最容易让新手崩溃的问题:明明爬回来的数据在 Python 里打印出来是正常中文,导出成 CSV 后用 Excel 一打开全是乱码。原因其实很简单,标准 CSV 文件默认是不带编码标识的,Excel 在 Windows 下打开 CSV 时默认按 ANSI 编码(比如 GBK)去解码,而 Python 写入时一般用 UTF-8,两边不一致就会出现乱码。
解决方案就是写入文件时指定encoding="utf-8-sig"。utf-8-sig会在文件开头自动加入一个 BOM(字节序标记),Excel 看到 BOM 就能正确识别为 UTF-8 编码。我在项目里统一用这个编码,从源头上杜绝了乱码问题。
顺便说一句,如果你用 pandas 导出也要同样处理,df.to_csv的 encoding 参数传"utf-8-sig"即可。这个坑不踩一次真的不会长记性,踩完之后你以后写所有爬虫导出都会记得加这个参数。
6. 常见问题排查与合规提醒
6.1 高频问题速查表
我按实际调试经验整理了下面这些问题,几乎覆盖了这个项目所有常见的卡点。
| 问题表现 | 可能原因 | 解决方案 |
|---|---|---|
| 请求返回 403 | User-Agent 太暴露 | 设置浏览器级别的请求头 |
| XPath 取到空列表 | 表达式路径有误 | 用浏览器$x()验证表达式 |
| 价格字符串无法转 float | 英镑符号未替换 | 先replace("\u00a3", "") |
| 评分取不到值 | 没从 class 属性里取 | 用@class取属性再处理 |
| CSV 打开中文乱码 | 编码不匹配 | 写入时用utf-8-sig |
| 运行到某页中断 | 网络不稳定 | 加异常处理 + 边采集边保存 |
| 总页数变多 | 新书入库 | 用 book_id 去重,重复跳过 |
6.2 调试技巧与日志设计
爬虫调试的核心思路是"逐步缩小范围"。遇到解析结果不对,先打印出整个 HTML 段落看看结构;遇到请求失败,先打印出状态码和响应头,而不是急着改代码。
我习惯在关键节点加上带页码的日志输出,例如第 12 页完成,累计 240 本。这样跑完一遍就能快速判断数据是否按预期增长,如果某页数量异常,日志会直接给出线索。
6.3 爬虫的边界与合规意识
这个项目完结后,我需要认真提醒一件事:Books to Scrape 是一个允许爬取的练习站,它可以让你放心地练习所有技能。但真实的网站不是这样。做任何爬虫项目都要先检查对方的 robots.txt 文件、了解网站的服务条款,并且控制好采集频率,不要影响对方网站的正常运行。采集到的数据如果涉及个人信息或版权内容,使用前也要审慎评估。
合规不是一句空话,而是这一行能长期做下去的基础。能力越强,边界意识越重要。
7. 项目扩展与个人体会
如果这个项目你已经完整跑通了,可以试着在它的基础上做几件有意思的扩展。第一,把 CSV 导入数据库(比如 SQLite 或 MySQL),写几条 SQL 查询试试,比如按评分排序找出所有五星图书;第二,给爬虫加一个简单的代理池和异常重试机制,模拟真实生产环境的稳定性要求;第三,用 pandas 对结果做一次数据分析,算一算全站图书的平均价格、评分分布,这些都是很自然的下滑方向。
我个人实际跑这个项目最深的一个体会是:爬虫项目的成败并不在于用了多少高级框架,而在于对目标站点的结构理解是否透彻。你花 20 分钟用浏览器看清楚页面布局,比闷头写 2 小时代码有用得多。Books to Scrape 的价值就在于它让你在低风险的环境里把这个"先观察、再动手"的流程刻进习惯里。
最后再分享一个小技巧:把解析函数和请求逻辑分开写,会让这个项目的代码结构清晰很多,也为后续扩展留好余地。保持这个习惯,你后面接更大的采集任务时会感谢现在的自己。