☰
Python爬虫实战:用requests和lxml抓取全站图书数据
2026/10/7 5:09:16 网站建设 项目流程

爬虫界也有“练习场”,如果你还没听过 Books to Scrape,那说明你的爬虫之路还少了最友好的一堂入门课。这个网站是专门给爬虫学习者准备的沙盒:整个站点用静态 HTML 渲染,没有登录墙,没有验证码,几乎没有反爬策略,数据量又恰到好处——50 个分页、1000 本图书,够你把一套完整的采集流程跑得明明白白,又不会让你被海量数据淹没。这篇文章就是一次完整的 Python 爬虫实战复盘:从环境准备、页面拆解,到用 requests 和 lxml 把全站图书抓下来,再用 csv 模块导出成结构化 CSV 文件,确保 Excel 打开不乱码、列顺序稳定、字段齐全。适合刚刚学完 Python 基础、想找一个正经练手项目的人,也适合想把 requests + lxml + CSV 这套经典组合彻底吃透的读者。

1. 先搞清楚目标:Books to Scrape 是什么、要抓什么

1.1 为什么这个网站适合当爬虫练习场

先说说我为什么推荐这个站。很多初学者一上来就直接盯上电商平台、社交平台或点评类网站,结果要么被验证码拦在门外,要么刚发几个请求就收到了 403,连列表页都没解析明白就劝退了。Books to Scrape 不一样,它的定位就是“爬虫练习专用”。站内图书信息全部写在 HTML 里,不依赖 JavaScript 动态渲染,这就意味着你不必上 Playwright、Selenium 这种重武器,用最基础的 requests 就能拿到原始页面。

更重要的是,它的数据结构非常有代表性:列表页是一行行的图书卡片,详情页里还有一张标准的书籍信息表格。这种“列表页拿摘要、详情页拿补充字段”的架构,恰恰是绝大多数真实网站的通用形态。你在这个小小练习站上练会的 XPath 技巧、分页处理、字段清洗、CSV 写入,几乎可以无缝迁移到其他静态站点。所以我一直说,别小看这个只有 1000 本书的网站,它其实把爬虫流程里最容易翻车的几件事都替你安排好了。

还有一点,Books to Scrape 允许爬虫抓取,robots.txt 对采集者非常友好。也就是说你在这个站上折腾,可以把全部精力放在技术实现上,不需要去考虑绕过验证码这类灰色操作,也不存在踩踏目标站点服务条款的负担。对新手来说,这种“放心大胆练”的体验比什么都重要。

1.2 全站数据的字段构成:从列表页到详情页

在写任何代码之前,先把“我要抓什么”想清楚。Books to Scrape 的数据分布在两个层级。列表页(也就是每页 20 本的分页页面)可以直接拿到图书标题、价格、评分、库存数量以及对应的详情页链接。但如果你只想做一次完整的数据采集,光拿这些还不够——UPC、分类、是否含税价格、Review 数量、产品描述这些更有价值的信息,全部藏在详情页里。

这里我给出一份我这次采集用到的字段清单:

字段名来源说明
title列表页书名
price列表页展示价格,带英镑符号需清洗
rating列表页1-5 星评分
availability列表页库存数量,需从文本中提取数字
detail_url列表页详情页链接,用于二次请求
upc详情页图书唯一编码,Excel 中需当文本处理
category详情页面包屑中的分类名称
product_type详情页产品类型
price_excl_tax详情页不含税价格
price_incl_tax详情页含税价格
tax详情页税额
reviews详情页评论数量
description详情页书的描述,有的书没有

所谓“全站图书数据”,在这个项目里就是 50 个分页 × 每页 20 本 = 1000 条记录。我实测时站点就是这个量级,如果以后页面结构有调整,总数可能会变,但采集逻辑不变。这里也顺便说一下,“全站”对新手来说最容易理解成一个固定数字,实际上对爬虫而言,全站 = 你覆盖了站点上所有可入口数据页面,而不是穷尽每一张页面里的每一个字符。分清主数据、补充数据和无关数据(比如导航栏、页脚广告),是设计字段清单的第一步,这决定了你后续代码能写得多清晰。

2. 工具选型与环境准备

2.1 依赖就两个:requests 和 lxml

爬虫这个领域有个很有意思的现象:工具越重,上手越慢。很多人一上来就装 Scrapy,结果被 Item Pipeline、Middleware、Selector 这些概念绕晕,连第一个蜘蛛都没跑起来就放弃了。对这种一次性全站采集任务,我的选择非常简单:requests 负责发请求,lxml 负责解析 HTML,csv 是 Python 标准库,直接用来写文件。

pip install requests lxml

就这两行。为什么用 lxml 而不是 BeautifulSoup?我两个库都用过,坦白说 BeautifulSoup 的 find 系列方法在文档结构混乱时确实更宽容,但 Books to Scrape 的页面结构非常规整,列表卡片、价格、评分都有清晰的 class 命名。这种场景下 lxml 的 XPath 优势很明显:表达式稳定可复用,解析速度比 bs4 快一个量级,而且写出来的定位逻辑一眼就能看懂。等你遇到那种页面结构混乱的站点,再切回 BeautifulSoup 也不迟。

至于 Scrapy、Playwright、requests-html 这些,我的建议是:先别碰。这种轻量级练习任务的本质是让你理解 HTTP 请求、HTML 解析、数据落地这三件事,requests + lxml 组合就是最透明的教学载体。等你把流程吃透了,再升级成 Scrapy 做分布式采集,或者上 Playwright 处理动态页面,都能自然衔接,不会觉得跨度太大。

2.2 页面结构拆解:XPath 怎么定位每一条数据

写解析代码之前,先在浏览器里按 F12 打开开发者工具,把页面结构摸清楚。Books to Scrape 的列表页里,一本图书的基本单位是这样的:

<article class="product_pod"> <h3><a href="catalogue/xxx/index.html" title="书的名字">书的名字</a></h3> <p class="price_color">£51.77</p> <p class="instock availability">In stock (19 available)</p> <p class="star-rating Three"></p> </article>

这个结构意味着你可以用几个非常直接的 XPath 把它们全部定位出来。取整个图书卡片用//article[@class="product_pod"],在卡片内部继续取标题、价格、评分、库存、链接。注意评分那一项,star-rating后面的类名才是真正的星级,比如Three就是 3 星,需要从 class 属性里提取出来,而不是直接把整段 class 文本当成评分。

我先教你在浏览器控制台验证 XPath 的小技巧:打开开发者工具,在 Console 里输入$x('//article[@class="product_pod"]'),回车就能看到当前页命中了多少个元素。这个技巧能让你在不写 Python 代码的情况下,先把选择器调通,等所有表达式都验证正确之后,再去写正式脚本,效率会高出不少。很多新手对着文档写 XPath 要么漏了斜杠,要么写错层级,用控制台先验证是最稳的做法。

2.3 合规与礼貌爬取的红线

这部分必须单独说。爬虫本身不是原罪,但一定要清楚边界在哪。这次练习我用 Books to Scrape,是因为它本身就是官方允许爬取的教学站点,robots.txt 里对采集非常宽容,数据也全部是公开信息。即便如此,我在代码里依然做了三件事:设置正常的 User-Agent(不伪装成搜索引擎也不是默认的 python-requests)、请求之间加上 0.5 秒以上的间隔、每个页面最多重试三次。

这三件事背后的逻辑很简单:爬虫是在消费目标站点的服务器资源,礼貌程度决定了你的练习是“学习”还是“骚扰”。我特别想提醒的是,如果你以后要采集真实业务里的数据,务必先阅读目标站点的服务条款和 robots.txt,评估数据用途是否合规,不要在练习阶段养成暴力请求的习惯。练手选 Books to Scrape 这种沙盒站,生产环境选有授权的数据源,这条原则值得刻在脑门上。

另外,别拿这个套路去随便爬电商巨头或者某些社交平台,那些站点的反爬机制和法务风险都不是新手练手项目该碰的。爬虫技术没有好坏,但使用技术的场景和方式分境界,很多初学者就是没分清这个界限,才把原本单纯的学习变成了一堆麻烦。

3. 代码实现:分步拆解采集全流程

3.1 先写一个带重试的请求函数

请求是整个流程的地基。我最开始写爬虫的时候,直接requests.get(url)一把梭,然后被各种连接超时、偶发的 SSL 错误教育了一整晚。后来才明白,一个健壮的爬虫脚本第一件事就是把请求函数写好,而不是把解析逻辑写得花里胡哨。

import time import requests from lxml import etree BASE_URL = "https://books.toscrape.com/catalogue/" HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36" } def fetch_html(url, max_retries=3): for attempt in range(max_retries): try: resp = requests.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() return etree.HTML(resp.text) except Exception as e: print(f"第 {attempt + 1} 次请求失败: {url} -> {e}") if attempt < max_retries - 1: time.sleep(2) return None

这里有两个细节值得说。第一个是timeout=10,如果不设置这个参数,遇到连接卡死的情况脚本可能挂很久都不报错;设了超时,最多等 10 秒就放弃,配合重试机制,整个流程才不会因为个别网络抖动而中断。第二个是raise_for_status(),它的作用是当服务器返回 4xx 或 5xx 状态码时直接抛异常,避免你拿着一个错误页面当正常页面去解析,最后解析出空数据还找不到原因。

重试策略我用了最简单的三次线性重试:第一次失败等 2 秒,第二次失败再等 2 秒,最多试三次。Books to Scrape 这种低压力站点,三次基本足够。如果换成生产环境,通常会改用指数退避(比如 1 秒、2 秒、4 秒),或者配合随机抖动,那是后话,但你现在就知道这个方向,后面改起来也不陌生。

3.2 列表页解析:拿到 20 本书的入口

解析函数的核心逻辑是:先用etree.HTML把响应文本变成可以被 XPath 查询的文档对象,然后定位所有product_pod卡片,逐个提取字段。代码写出来大概是下面这样:

import re from urllib.parse import urljoin RATING_MAP = {"One": 1, "Two": 2, "Three": 3, "Four": 4, "Five": 5} def parse_list_page(doc, page_url): books = [] for item in doc.xpath('//article[@class="product_pod"]'): title_nodes = item.xpath('.//h3/a/@title') price_nodes = item.xpath('.//p[@class="price_color"]/text()') avail_nodes = item.xpath('.//p[contains(@class,"instock")]/text()') rating_nodes = item.xpath('.//p[contains(@class,"star-rating")]/@class') link_nodes = item.xpath('.//h3/a/@href') if not title_nodes or not link_nodes: continue title = title_nodes[0].strip() price_text = price_nodes[0].strip() if price_nodes else "0" avail_text = " ".join(t.strip() for t in avail_nodes if t.strip()) if avail_nodes else "" rating_cls = rating_nodes[0].split()[-1] if rating_nodes else "Zero" match = re.search(r"\((\d+)\s+available\)", avail_text) books.append({ "title": title, "price": float(price_text.replace("£", "")), "availability": int(match.group(1)) if match else 0, "rating": RATING_MAP.get(rating_cls, 0), "detail_url": urljoin(page_url, link_nodes[0]), }) return books

每个字段都值得展开说。价格文本里带的英镑符号必须去掉再转成 float,否则数值比较和后续分析都会出问题。库存文本常见格式是In stock (19 available),我用正则把括号里的数字抠出来,正则写错一步就可能拿到 None,所以后面加了if match else 0兜底。评分是文本映射,Three转成数字 3,这里用字典最清晰,后续不管是排序还是统计都比字符串好用。

还有一个关键操作是用urljoin(page_url, link_nodes[0])拼接详情链接。列表页的 href 是相对的,直接存下来后面没法请求;用 urljoin 就能自动拼出完整的绝对地址。我特意把page_url传进函数,就是因为有的页面目录层级不同,拼出来的结果会不一样,用当前页作为基准更稳妥,这是我在别的项目里踩过坑之后才养成的习惯。

3.3 详情页解析:补齐 UPC、分类、描述这些关键字段

详情页的数据集中在两个地方:面包屑导航里有分类信息,product information表格里有 UPC、价格、税额、库存、评论数这些字段。页面底部还有一个产品描述段落。解析详情页的时候,我把上一个函数拿到的列表页数据作为基础,再往字典里补充新字段。

FIELD_MAP = { "upc": "upc", "product_type": "product_type", "price_(excl._tax)": "price_excl_tax", "price_(incl._tax)": "price_incl_tax", "tax": "tax", "availability": "availability_detail", "number_of_reviews": "reviews", } def parse_detail_page(doc, base_book): book = dict(base_book) crumbs = doc.xpath('//ul[@class="breadcrumb"]//li/a/text()') book["category"] = crumbs[2].strip() if len(crumbs) >= 3 else "" desc_nodes = doc.xpath('//*[@id="content_inner"]/article/p/text()') book["description"] = " ".join(t.strip() for t in desc_nodes).strip() if desc_nodes else "" rows = doc.xpath('//table[contains(@class,"table-striped")]//tr') for row in rows: cells = row.xpath('./td/text()') if len(cells) < 2: continue key = cells[0].strip().lower().replace(" ", "_").replace("(", "").replace(")", "") value = cells[1].strip() mapped = FIELD_MAP.get(key, key) book[mapped] = value return book

这里最值得学的是“按行遍历表格”而不是“按固定行号取值”。很多人第一次写的时候会直接用//table/tr[1]/td[2]/text()去取 UPC,这种写法在页面结构不变时没问题,但一旦网站新增了一行信息,行号就全错位了。我这种写法把表格的每一行读出来,用第一列文本做字段名,再映射成统一字段,无论表格行序怎么变,代码都能稳定工作。这也是一种典型的“防御式解析”,在生产级爬虫里非常常见。

分类从面包屑第三个li里取,这是 Books to Scrape 固定的结构(Home > Books > 分类名)。描述这里要注意,有些书没有描述段落,所以必须判空。如果不管三七二十一直接取第一个元素的下标,遇到没有描述的书就会索引报错,整个脚本直接崩掉,后面的数据全白爬。判空这种写法看起来啰嗦,但它能保证脚本在面对脏数据时依然走得下去。

3.4 分页循环、数据汇总与 CSV 导出

解析函数都写好了,接下来就是组装主流程。Books to Scrape 的分页 URL 非常有规律,从page-1.html到page-50.html,直接一个 for 循环就能覆盖。循环里每解析完一个列表页,就进去逐个请求详情页,把补充字段加进来。全部塞进一个all_books列表,最后用 csv 模块写文件。

import csv def save_to_csv(books, filename="books_all.csv"): fieldnames = [ "title", "upc", "category", "price", "price_excl_tax", "price_incl_tax", "tax", "availability", "rating", "reviews", "description", "detail_url" ] with open(filename, "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore") writer.writeheader() writer.writerows(books) def main(): all_books = [] for page in range(1, 51): page_url = f"{BASE_URL}page-{page}.html" doc = fetch_html(page_url) if doc is None: print(f"第 {page} 页获取失败,跳过") continue list_books = parse_list_page(doc, page_url) for book in list_books: detail_doc = fetch_html(book["detail_url"]) if detail_doc is not None: all_books.append(parse_detail_page(detail_doc, book)) time.sleep(0.5) print(f"第 {page} 页完成,累计 {len(all_books)} 本") time.sleep(0.8) save_to_csv(all_books) print(f"导出完成,共 {len(all_books)} 条数据")

关于 CSV 导出,有两个坑是我亲测非常折磨人的。第一个是编码,如果直接encoding="utf-8",生成的 CSV 用 Excel 打开就会乱码,因为 Excel 默认按 ANSI 解析文件头。解决办法是用utf-8-sig,它会在文件开头写入 BOM,Excel 就能正确识别为 UTF-8。第二个是换行,open()里必须加newline="",否则写出来的 CSV 每一行之间会多一个空行,这是 csv 模块在 Windows 平台上著名的坑。

字段顺序我固定成一个fieldnames列表,这样导出的文件列顺序是稳定的,不会因为字典插入顺序变化而导致每次导出列顺序不同。extrasaction="ignore"则保证如果某个字典里混入了多余字段,也不会报错,只会被忽略。这两行代码看起来不起眼,但对维护数据列格式非常有用,尤其是后续要做跨批次数据合并的时候。

4. 实测运行与踩坑记录

4.1 实测结果:全站跑完需要多久

我本地实测跑了一轮:单线程、每次请求后 sleep 0.5 秒、列表页翻页 sleep 0.8 秒,全站 1000 本图书跑完大概花了 12 到 15 分钟,生成的 books_all.csv 约 800 KB,描述文本占了大头。这个速度对于一个练习项目来说完全够用,比人手工复制粘贴不知道快了多少倍,也能让你在等待过程中观察每一步日志输出。

跑完第一件事,永远是校验数据:打开 CSV 数一下总行数是不是 1000(不含表头),再随便抽几本书对一下原始网站的信息。我在列表里检查过,1000 条记录一条不多一条不少。如果发现数量对不上,最可能的原因是某几个请求超时后被跳过了,这时需要加大重试次数或者拉长间隔,而不是急着去检查解析代码。打印日志的好处就在这,你能看到是哪一页哪一步出了问题。

这里特别想说的一点是:跑爬虫和我平时做数据分析的心态一样,结果出来第一眼不要看字段对不对,先看条数对不对。条数对了再抽查几个字段,条数不对就先看日志定位是哪个请求丢了。这个排查顺序能帮你省掉大量无头苍蝇式的调试时间,我见过太多人一跑出数据就开始盯着某个字段纠结,结果最后发现是缺了一整页,白忙活半天。

4.2 常见问题排查速查表

我把这个项目里以及教别人写类似爬虫时最常遇到的问题整理成了一张表,基本覆盖了绝大多数新手翻车现场:

问题现象可能原因解决办法
请求超时或连接失败网络不稳定或并发过高加 timeout、重试和 sleep;降低单次请求频率
拿到 403 ForbiddenUA 被识别为爬虫设置浏览器 UA,去掉默认 python-requests 指纹
页面解析出来是空的XPath 写错或页面结构变化先在浏览器 Console 用$x()调通表达式再写代码
中文乱码站点编码不是 UTF-8用 resp.encoding 识别或手动指定正确的编码
CSV 用 Excel 打不开或乱码编码用了 utf-8改成 utf-8-sig
导出的数字列变了UPC/ISBN 被当成数字处理导出时用字符串,或 Excel 里设置成文本列
某些书没有描述导致报错详情字段缺失解析时统一判空,默认值兜底
数据条数比预期少部分请求失败被跳过检查日志定位失败页,增大重试次数
价格转 float 报错货币符号或空文本混入先 strip 再去符号,处理异常值兜底

这张表基本就是我从“爬虫新手”到“能稳定写采集脚本”过程中踩过的所有坑的浓缩。每一个问题我都在 Books to Scrape 或者其他站上真实遇到过。特别是 csv 模块的 newline 参数和 utf-8-sig 编码,这两件事几乎每个初学者都会踩一次,踩完才会记住,现在我直接把答案摆出来,你就不用再受一遍这个罪了。

4.3 让爬虫更稳的几个习惯

说几个代码之外的习惯。第一个,用 Session 而不是裸 requests.get。Session 会自动复用底层的 TCP 连接,减少三次握手次数,对服务器也更友好。对 Books to Scrape 这种小站效果不明显,但等你爬数据量大的站点时,快与稳的差距就出来了。

session = requests.Session() session.headers.update(HEADERS)

第二个,边爬边存,不要爬完才落盘。我的主流程里虽然是最后统一写 CSV,但如果你爬的是几千上万条数据,强烈建议每处理完一页就增量写入一行到 CSV,或者先用 JSON 缓存每个阶段的进度。不然你爬到第 900 条时进程崩了,重启再爬又要从第 1 条开始,那种绝望我经历过太多次。折中方案是每页解析完就 append 到本地临时文件里,最后再统一合并成正式 CSV,这样即使中途挂掉,损失的也只是最后一页的数据。

第三个,把枚举范围、重试次数、延时这些值提取成变量。别小看这个习惯,我刚学的时候直接把range(1, 51)写死在循环里,后面想改成动态获取页数、想调大间隔,就得来回改代码。把参数集中写在文件顶部,维护成本会低很多。这个习惯用到后面做自动化任务时尤其值钱,因为自动化最怕的就是“改一行代码要翻遍整个文件找位置”。

5. 这套代码还能怎么扩展

5.1 按分类爬取与增量更新

Books to Scrape 的导航栏里有几十个分类入口,每个分类对应一个独立列表页。上面这套代码稍作修改,就能变成按分类爬取:先请求首页,解析出所有分类链接,再对每个分类跑一遍分页循环,最后在数据里额外记录分类字段。这个升级其实是电商类站点采集的常见需求,不是要全站快照,而是要按业务线更新。

增量更新则是生产级爬虫绕不开的话题。全站重爬很快,但代价是每次都请求所有页面,对服务器不友好。更合理的做法是用 UPC 或 detail_url 作为主键,先把历史数据加载进内存,遇到已存在的记录就跳过,只补充新增的图书。这个逻辑本质上是数据同步。我在写这个练习项目的时候顺手做了一个简化版:把上一次导出的 CSV 里的 UPC 集合读进来,新数据只有在 UPC 不在集合中时才写入。代码很简单,但对理解“增量”这个概念非常有帮助。

5.2 从 CSV 到数据分析与可视化

辛辛苦苦抓下来 1000 条数据,只放在 CSV 里躺着有点浪费。下一步完全可以把 CSV 交给 pandas 做分析,比如统计不同类别的图书均价、看价格分布直方图、分析评分和价格之间的关系。Books to Scrape 的数据虽然简单,用来练数据分析却非常合适。我只举一个例子:用 pandas 读入 CSV 后,按类别分组计算均价,再排序输出,就能一眼看出哪类书最贵、哪类书最便宜。

import pandas as pd df = pd.read_csv("books_all.csv") print(df.groupby("category")["price"].mean().sort_values(ascending=False))

这个扩展方向其实比爬虫本身更有价值。很多人的技术成长路径都是先学会采集数据,再学会清洗数据,最后学会用数据讲故事。爬虫只是基建,分析才是决策依据。Books to Scrape 这 1000 条数据虽然不算海量,但足够你练习数据透视、分组聚合、缺失值处理这些基本功,练完再去碰真实业务数据,心里会踏实很多。

我自己平时练习爬虫时还有个习惯:每写完一个采集脚本,都会顺手做一个简单的校验脚本,对比本地 CSV 和线上数据的抽样结果。这个习惯让我后来写真实项目的自动化采集任务时少踩了很多坑。所以最后想对正在入门的朋友说一句:Books to Scrape 这个站练完不算完,把数据抓下来之后,试着用它试试清洗、建模、可视化,你会发现爬虫的乐趣才刚刚开始。

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

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

立即咨询