
最近在做一个爬虫练手项目目标是采集 Books to Scrape 网站的图书信息把书名、价格、评分、库存、分类这些关键字段抓下来最终落地成一个本地价格情报库并且支持 CSV 导出。这个项目用的技术栈很经典Requests 负责拿网页BeautifulSoup 负责解析一套组合拳打下来整个流程跑得非常顺。这个站点是专门给爬虫爱好者准备的练习靶场页面结构稳定、反爬策略温和比起去抓那些动不动就封 IP 的电商网站简直是新手福音。但别小看它麻雀虽小五脏俱全分页、详情页跳转、数据清洗、文件存储这些硬骨头一个都不少。这篇文章我会把从分析页面到落盘 CSV 的完整思路和代码细节都摊开讲适合刚入门爬虫但想做一个完整项目的朋友参考也适合想了解如何构建一个可复用抓取框架的开发者阅读。1. 项目背景与需求拆解1.1 为什么要选 Books to Scrape 作为练手目标做爬虫最怕两件事一是目标网站结构乱七八糟二是反爬机制让你寸步难行。Books to Scrape 这个站点几乎是完美地规避了这两个痛点。它的页面是静态 HTML 渲染的没有复杂的 JavaScript 异步加载这意味着用 Requests 就能直接拿到完整内容不需要 Selenium 那样的重型工具也不需要解析 XHR 接口。站点内部有分类、有分页、有详情页完全模拟了真实购物网站的典型结构抓取过程中需要处理的链接提取、相对路径拼接、字段缺失判断这些逻辑在实际商业项目中都会遇到。更重要的是这个站点明确允许爬虫练习响应速度稳定不需要担心法律风险。我见过太多初学者一上来就拿某电商平台练手结果请求频率一高账号被限制、IP 被拉黑项目还没跑起来就劝退了。选择一个合法的、稳定的练习目标能让你的注意力集中在爬虫本身的逻辑上而不是和反爬机制斗智斗勇。这个项目做完之后你学到的这套流程——分析 HTML 结构、用 Requests 发起请求、用 BeautifulSoup 定位元素、清洗数据、批量导出——完全可以平移到其他静态站点上。1.2 核心需求拆解从零构建价格情报库我想要构建的“本地价格情报库”听起来有点高大上实际上就是把图书的基础信息和价格数据结构化存储下来方便后续做价格走势分析、降价提醒、热门书籍排序这些事情。这个情报库的核心字段包括书名title价格price需要去掉英镑符号转成浮点数评分rating一到五星提取数值库存状态availability方便判断是否有货书籍分类category从面包屑导航或者分类页提取详情页 URL方便后续二次采集抓取时间戳记录本次采集时间为多次抓取做对比这些字段听起来简单但每一个都有讲究。价格在网页上显示的是“£51.77”这样带货币符号的字符串你得想办法提取出 51.77 这个数字评分是“One”、“Two”这种英文单词需要映射到 1 到 5 的整数库存信息隐藏在“In stock (19 available)”这样的文本里括号里的数字才是有效信息。如果不做清洗直接存原始字符串后续做数据分析和可视化的时候就会遇到一堆麻烦。我把整个项目拆成几个阶段第一阶段分析站点结构和页面 URL 规律第二阶段用 Requests 抓取列表页和详情页第三阶段用 BeautifulSoup 解析 HTML 提取目标字段第四阶段写数据清洗函数第五阶段将数据写入 CSV 文件并做简单校验。下面我会按照这个顺序一步步把代码和思路都写清楚。2. 技术选型与原理分析2.1 为什么选 Requests 而不是其他 HTTP 库Python 生态里做 HTTP 请求的库不少有 urllib、httpx、requests还有异步的 aiohttp。这个项目选择 Requests核心原因是它在易用性和功能完备性之间取得了最好的平衡。Requests 的 API 设计非常人性化会话管理、请求头定制、超时设置、重定向处理这些功能都是开箱即用代码写起来像自然语言一样流畅。对比 urllibRequests 代码量能减少一半以上而且出错概率更低。另外Requests 的 Session 对象非常有用。如果要抓取几千个页面每个请求都重新建立 TCP 连接效率太低了。用一个 Session 可以复用底层的连接池保持 cookies模拟同一个浏览器的行为。虽然 Books to Scrape 对 cookies 没有硬性要求但使用 Session 是一个良好的习惯后续换到其他站点时你不需要改变代码结构就能应对。还有一个重要原因是 Requests 的响应处理非常清晰。你可以方便地检查状态码、响应头、编码方式还可以通过response.text直接获得解码后的文本response.encoding自动猜测编码。对于 Books to Scrape 这种 UTF-8 编码的站点直接使用response.text就能拿到正确的中文内容虽然站点内容是英文但编码处理思路是通用的。2.2 BeautifulSoup 的定位能力为什么够用BeautifulSoup 是一个纯 Python 的 HTML 解析库它的定位是处理“不规范的、嵌套混乱的” HTML 文档。真实世界里的网页尤其是老旧的站点HTML 标签经常不闭合、属性大小写混乱、还有各种隐式标签。BeautifulSoup 的解析器能够自动修正这些问题构建出一棵标准的解析树。这本书的列表页每个图书卡片都包在article classproduct_pod标签里书名的标签是h3下的a标签价格在p classprice_color里这些结构非常有规律用 BeautifulSoup 的select方法加上 CSS 选择器就能精准定位。性能方面Books to Scrape 一个页面大概 20 本书解析速度在毫秒级完全感觉不到瓶颈。有些同学可能会问为什么不用正则表达式正则虽然也能提取信息但写起来又长又脆弱稍微改一下 HTML 结构就会失效。BeautifulSoup 是基于 DOM 树结构来选择的天然就比正则更贴近“人理解页面”的方式。如果只选一个解析库BeautifulSoup 是最稳妥的选择。2.3 数据落盘CSV 格式的取舍存储方案有很多种比如 SQLite、MySQL、JSON、CSV。这里选择 CSV 主要是考虑到它的普适性和可读性。CSV 可以直接用 Excel、WPS 或任意文本编辑器打开方便非技术人员检查抓取结果同时 pandas、NumPy 等数据分析库对 CSV 支持极好后续做价格分析非常方便。更重要的是Books to Scrape 的数据量并不大几百本书CSV 文件也就几十 KB完全不需要上数据库。当然 CSV 也有缺点比如字段里如果包含逗号、引号、换行符处理不好容易导致列错位。好在 Python 标准库的csv模块已经处理好了这些问题只要使用csv.writer或者csv.DictWriter写入时会自动对特殊字符进行转义。我后面会专门说说这里面的细节坑。3. 环境准备与目标站点分析3.1 环境搭建与依赖安装这个项目对 Python 版本没有苛刻要求3.8 以上就行。我建议在虚拟环境里安装依赖避免污染全局环境。命令行执行以下命令python -m venv books_env source books_env/bin/activate # Windows 下用 books_env\Scripts\activate pip install requests beautifulsoup4如果你还需要用 pandas 做后续数据分析可以顺手装一个pip install pandas但核心抓取只需要 Requests 和 BeautifulSoup 这两个库。所有代码都可以写在单个 Python 文件里或者拆分成模块。我个人习惯是拆成crawler.py主逻辑、parser.py解析函数、storage.py数据存储不过这个项目规模小写在单个文件里反而更容易阅读。3.2 分析目标站点的页面结构在写代码之前一定要先花时间人工浏览一下目标网站搞明白 URL 规律和页面结构。Books to Scrape 的首页地址是https://books.toscrape.com/每个分类页的 URL 格式是https://books.toscrape.com/catalogue/category/books/travel_2/index.html而所有图书列表的总入口是https://books.toscrape.com/catalogue/page-1.html。点开任意一本书详情页的 URL 是https://books.toscrape.com/catalogue/sharp-objects_997/index.html。注意列表页里的图书链接是相对路径比如sharp-objects_997/index.html如果直接拼接在分类页 URL 后面就会出错必须拼到https://books.toscrape.com/catalogue/这个根路径下。这种相对路径转绝对路径的处理方式在实际爬虫中非常常见值得单独写一个函数。页面结构方面列表页的每个图书卡片包含以下关键元素书籍封面图用于视觉识别但不在本次采集范围内书名链接h3 a属性title存书名价格p.price_color文本以£开头评分p.star-ratingclass属性里有One到Five库存文本框p.instock.availability文本形如In stock (19 available)详情页则包含更完整的描述、UPC、产品类型、价格含税/不含税、税、库存数量、评价数量以及产品描述。为了构建价格情报库列表页的信息基本够用但如果想做得更精细可以再抓取详情页的产品描述和 UPC。这个项目我选择仅基于列表页因为列表页已经包含了核心字段而且分页抓取比较简单不需要为每本书额外发起请求。3.3 分页逻辑的理解Books to Scrape 的图书列表总页数可以在首页底部看到默认是 50 页每页 20 本书一共 1000 本。分页 URL 规律是page-1.html、page-2.html一直到page-50.html非常直观。你可以先获取第 1 页然后解析出下一页的链接也可以直接根据总页数循环拼接 URL。我建议先写一个函数获取第一页并找到最后一页的页码然后循环所有页面这样做更贴近“真实世界”的爬虫因为你不可能永远依赖 URL 规律动态解析翻页链接才是通用做法。在解析翻页链接时页面底部的“next”按钮对应的a标签的href属性可能是相对路径比如page-2.html。你需要把这个相对路径拼接到当前页面 URL 的目录部分而不是直接拼接在完整 URL 后面。这里有一个非常经典的坑如果基础 URL 是https://books.toscrape.com/catalogue/page-1.html那么用urljoin解析page-2.html得到的结果是https://books.toscrape.com/catalogue/page-2.html而如果基础 URL 写错成https://books.toscrape.com/catalogue/page-1.html/多一个斜杠结果就会变成https://books.toscrape.com/catalogue/page-1.html/page-2.html。所以使用 Python 的urllib.parse.urljoin函数最安全。4. 核心代码实现从请求到解析的完整流程4.1 构建安全的请求会话先初始化一个 Requests Session设置好请求头模拟浏览器访问。这样做的目的是让目标服务器认为这是一次普通的浏览器请求而不是脚本脚本。虽然 Books to Scrape 不检查 User-Agent但养成设置请求头的习惯是好的尤其是后续爬取其他站点时一个默认的 Python-requests 的 User-Agent 很容易被识别。import requests from urllib.parse import urljoin BASE_URL https://books.toscrape.com/ CATALOGUE_URL urljoin(BASE_URL, catalogue/) HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36, Accept-Language: en-US,en;q0.9, Accept-Encoding: gzip, deflate, } session requests.Session() session.headers.update(HEADERS)注意Accept-Encoding设置为gzip, deflate之后Requests 会自动解压响应内容你不需要手动处理压缩。Session 对象会管理 cookies 和连接池如果请求失败重试时还能自动恢复效率更高。4.2 获取并解析列表页写一个函数获取指定页码的页面内容并返回 BeautifulSoup 对象。这里要特别注意状态码检查如果返回 404 说明页码超出范围应该停止循环如果返回 500 可能是服务器临时错误可以等待几秒重试。一个简单可靠的模式是def fetch_page(url): try: response session.get(url, timeout10) response.raise_for_status() response.encoding response.apparent_encoding return BeautifulSoup(response.text, html.parser) except requests.RequestException as e: print(f请求失败: {url}, 错误: {e}) return None这里用了response.raise_for_status()如果状态码不是 200 会抛出异常方便统一处理错误。response.encoding response.apparent_encoding可以避免某些页面编码识别错误导致乱码。4.3 提取图书卡片字段的细节拿到 BeautifulSoup 对象后用soup.select(article.product_pod)选中所有图书卡片。对这个列表遍历逐个提取字段。提取过程中最容易出问题的就是评分字段因为它的 class 是star-rating One这种形式直接取class属性得到的是[star-rating, One]需要过滤出表示评分的单词。也可以直接用 CSS 选择器p.star-rating的类名但简化的方法是检查class列表中是否包含某个评分值。以下是我写的解析函数def parse_book(book_card): title book_card.select_one(h3 a).get(title, ).strip() price_text book_card.select_one(p.price_color).get_text(stripTrue) rating_class book_card.select_one(p.star-rating).get(class) rating_word rating_class[1] if len(rating_class) 1 else rating_class[0] availability_text book_card.select_one(p.instock.availability).get_text(stripTrue) book_url urljoin(CATALOGUE_URL, book_card.select_one(h3 a).get(href, )) price float(price_text.replace(£, ).replace(,, )) rating_map {One: 1, Two: 2, Three: 3, Four: 4, Five: 5} rating rating_map.get(rating_word, 0) in_stock In stock in availability_text stock_num 0 if ( in availability_text: num_part availability_text.split(()[1].split( )[0] stock_num int(num_part) return { title: title, price: price, rating: rating, in_stock: in_stock, stock_num: stock_num, book_url: book_url, }这个函数里有几个容易踩的坑select_one(p.instock.availability)返回的文本是 In stock (19 available)但有些书可能是 Out of stock所以不能假设格式一定带括号。我的代码先用In stock in availability_text判断是否有货再尝试提取数量如果提取失败就默认为 0这样更健壮。price_text可能是 £51.77 也有可能是小数格式没有千分位但无论如何先替换掉£符号。万一有£1,288.99这种千分位写法还要把逗号去掉再转 float。urljoin处理相对路径。注意这里的CATALOGUE_URL一定要以斜杠结尾否则urljoin会替换最后一个路径片段。使用https://books.toscrape.com/catalogue/作为基础 URL拼接sharp-objects_997/index.html才能得到正确结果。4.4 分页抓取的总控逻辑实现一个遍历所有分页的函数每抓取一页就调用解析函数把结果累积到一个列表里。为了避免请求过快对服务器造成压力可以在每次请求后加上time.sleep(0.5)对 50 页来说总时长约为 25 秒完全可以接受。这里还要处理最后一页的停止条件解析下一页按钮是否存在。def get_total_pages(soup): pager soup.select_one(li.current) if pager: page_info pager.get_text(stripTrue) # 文本格式: Page 1 of 50 total_pages int(page_info.split(of)[1].strip()) return total_pages return 1 def crawl_all_books(): all_books [] first_soup fetch_page(urljoin(CATALOGUE_URL, page-1.html)) if not first_soup: return [] all_books.extend(parse_books(first_soup)) total_pages get_total_pages(first_soup) for page in range(2, total_pages 1): page_url urljoin(CATALOGUE_URL, fpage-{page}.html) soup fetch_page(page_url) if soup: books parse_books(soup) all_books.extend(books) print(f已抓取第 {page} 页, 累计 {len(all_books)} 本书) else: print(f第 {page} 页抓取失败, 跳过) time.sleep(0.5) return all_books这里我还写了parse_books函数其实就是一个对article.product_pod列表的循环调用parse_book并过滤空结果。为了让代码更健壮parse_book里还可以加一个 try/except万一某个卡片结构异常导致提取失败不要把整个循环中断。4.5 详情页的深度采集可选列表页的信息已经足够构建基础情报库但如果你想对某本书做更细的分析可以再抓详情页来补充产品描述、UPC 和税后价格。详情页的地址在列表页已经拿到了只需要再请求一次然后解析特定字段。详情页的关键结构是table classtable table-striped里面的行每一行有th表头和td值。可以用选择器table tr遍历构建一个字典。产品描述在div idproduct_description之后的兄弟节点这个解析稍复杂。考虑到项目核心是价格情报我建议先实现列表页的完整流程等跑通之后再慢慢扩展详情页。这也是爬虫项目迭代的正常节奏。5. 数据清洗与 CSV 导出5.1 自定义字段清洗从原始字符串到可用数据类型前面在解析函数里面我们顺手做了一部分清洗比如价格转 float、评分单词转整数。但真实数据往往会更脏比如书名里可能有前后空格、字符实体如amp;表示、甚至换行符。虽然 BeautifulSoup 的.get_text(stripTrue)已经默认去掉了空白字符但 HTML 实体还需要额外处理。好在get_text已经自动解码了大部分实体所以一般不需要担心。不过你可能会在字段里看到诸如A Light in the Attic这种带引号的字符串CSV 里的引号处理需要留意。另外我还需要给每条记录添加采集时间戳这样每天跑一次就能看到价格变动的历史数据。时间戳用datetime.now().isoformat()生成简单可靠。5.2 csv.DictWriter 的正确使用方法Python 的csv模块提供了DictWriter可以直接将字典列表写入 CSV字典的 key 自动成为表头。这样就不需要手动处理对齐问题。核心代码import csv from datetime import datetime def save_to_csv(books, filenamebooks_price_data.csv): if not books: print(没有数据可写) return fieldnames [title, price, rating, in_stock, stock_num, book_url, crawled_at] for book in books: book[crawled_at] datetime.now().strftime(%Y-%m-%d %H:%M:%S) with open(filename, modew, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnamesfieldnames) writer.writeheader() writer.writerows(books) print(f成功写入 {len(books)} 条记录到 {filename})这里有两个值得特别注意的地方指定的newline防止在 Windows 平台上写入 CSV 时出现多余空行。如果不加这个参数每条记录之间会多一个空行看起来很不美观甚至在导入 Excel 时会错位。编码使用utf-8-sig而不是utf-8因为utf-8-sig会在文件开头写入 BOM字节顺序标记这样用 Excel 打开 CSV 时能自动识别 UTF-8 编码中文不乱码。虽然本项目的书名字段是英文但如果你爬的是中文站点这一个细节能省去很多麻烦。5.3 数据校验与去重逻辑抓取完成之后不能直接就信数据一定要做基本校验。我写了一个简单的校验函数统计书籍总数、空字段数量、价格范围是否合理。合理的价格区间应该在 10 到 1000 之间如果出现 0 或者负数说明解析出错了。去重方面Books to Scrape 每本书在列表中只会出现一次所以理论上不应该有重复。但为了保险我根据book_url做一次去重如果下次抓取时同样 URL 已经存在可以选择跳过或更新。这个逻辑在真实项目中非常重要因为页面结构变化可能导致重复解析。去重实现很简单用 set 记录 URL循环时判断即可。6. 实战中遇到的典型问题与排查技巧6.1 相对路径拼接导致 404这是我刚开始写爬虫时最容易犯的错。列表页里的图书链接是相对路径如果直接把href当作完整 URL 去请求就会变成https://books.toscrape.com/catalogue/category/books/travel_2/sharp-objects_997/index.html这样奇怪的地址category/books/travel_2是分类页的目录而sharp-objects这本书的详情页其实在catalogue下。原因在于页面 URL 的路径深度不同列表页可能在catalogue/page-1.html也可能在catalogue/category/books/travel_2/index.html如果直接拼接相对路径就会解析到错误的目录。解决办法就是永远使用urljoin(基准URL, href)并且基准 URL 一定要是固定的 catalog 根目录而不是当前页面的 URL。6.2 评分字段变成 None 的情况有时候网页结构会出现微调比如某个卡片缺了p.star-rating那么select_one会返回None直接调用.get(class)就会报错。我的经验是写解析函数时先用一个变量保存select_one的结果如果为None就返回默认值。这虽然会让代码多几行但能显著提高容错性。例如rating_tag book_card.select_one(p.star-rating) if rating_tag is None: rating 0 else: rating_class rating_tag.get(class, []) ...类似地标题h3 a也可能不存在。我的建议是在每个字段的提取前都做一个存在性检查不要担心代码冗长爬虫的容错性永远优先于代码的简洁性。6.3 请求频率过高被临时限流虽然 Books to Scrape 没有严格的反爬但如果你 0.1 秒请求一次连续抓上千个页面服务器的访问日志会非常难看。实际的练习中我见过有人抓太快把自己的 IP 封了几分钟的案例。解决方式很简单在请求之间加一个随机延时控制在 0.5 到 1.5 秒之间既可以模拟人类访问又不会明显拖慢速度。随机延时可以用random.uniform(0.5, 1.5)实现。6.4 CSV 中文乱码问题如果你后续把站点换成中文源站写 CSV 时没有用utf-8-sig用 Excel 打开就会看到一堆乱码。这个问题在中文社区里被问了很多次根源就是 BOM 缺失。记住一个原则如果用 Excel 打开 CSV编码就用utf-8-sig如果只是给 Python 或其他程序读取用utf-8就好。也可以在写入时先输出一个空文件然后直接修改编码但最简单的方式还是从一开始就用utf-8-sig。7. 项目扩展从练手到可实战的进阶方向7.1 增量抓取与价格变动监测现在的脚本每次都会重新抓取全部 1000 本书如果我们想监测价格变动这种方式既低效又会增加服务器压力。一个更好的方案是维护一个本地数据库每次抓取前对比已有数据的更新时间只抓取那些可能变化的内容。Books to Scrape 的列表页本身没有提供最近更新排序但我们可以通过比对当前价格和历史价格来找出哪些书降价了。最简单的方式是每次抓完将结果存储到新的 CSV 文件然后用 pandas 读取两个文件做 diff。更轻量的思路只抓取感兴趣的分类比如只抓价格低于 20 英镑的书籍然后每天定时执行形成连续的价格轨迹。这种实现只需要改一下入口函数传入分类 URL 和过滤条件整个框架不需要大的改动。7.2 数据入库与可视化分析CSV 文件适合小规模数据但如果你想保存一年以上的历史数据建议迁移到 SQLite 或 PostgreSQL。Books to Scrape 的数据量即使每天抓 1000 条一年也就是 36 万条SQLite 完全够用。使用 SQLite 的好处是查询速度更快还能用 SQL 语句做聚合统计比如计算某分类的平均价格、找出评分高但价格低于 20 的书籍。可视化方面可以定时把 CSV 或 SQLite 中的数据读入 pandas用 matplotlib 绘制图书价格分布直方图、分类图书数量占比饼图等。这样整个项目就不只是爬虫了而是一套完整的数据分析链路。7.3 添加代理和重试机制提醒而非必需虽然 Books to Scrape 不需要代理但真实世界的爬虫项目往往需要应对 IP 限制和反爬升级。你可以在 Requests Session 挂载一个代理池失败时自动切换 IP。多线程或异步爬取也是常见的优化手段不过需要更小心地控制并发量避免给服务器带来压力。这个项目的架构里我把请求逻辑都封装在fetch_page函数中后续如果要引入重试机制和代理只需要修改这一个函数其他部分完全不用动这就是封装的意义。最后再分享一个我自己的习惯每完成一个爬虫项目我都会把抓取到的样例数据单独存一份同时在代码里写清楚抓取时间和 URL 规律这样哪怕过几个月再回来看也能快速回忆起当时的逻辑。爬虫不是写完就结束的东西它更像一个需要持续维护的小系统。Books to Scrape 只是起点这套 Requests BeautifulSoup 的流程学透之后你已经能处理绝大多数静态站点。剩下的就是在不断的实战中积累经验了。