Python爬虫实战:批量采集图书评分与评论数据

发布时间:2026/10/12 18:29:25
Python爬虫实战:批量采集图书评分与评论数据
1. 项目缘起与整体设计思路做数据分析和内容运营的朋友大概都有过这样的经历想研究某类图书的市场口碑或者想给自己的读书会选几本高分好书结果发现手动一本本翻看评分和评论效率低到让人抓狂。我最初动这个念头是因为帮一个做图书推荐的朋友整理选品清单他需要近两年评分在8.0以上的社科类图书还要附带读者评论里的高频关键词。手动操作了三十多本之后我果断放弃了——这活儿必须交给代码。这个项目的核心目标很明确批量获取图书的基础信息书名、作者、评分、评价人数等以及每本书下的读者评论内容最终整理成结构化数据方便后续做评分分布分析、评论情感倾向判断或者关键词提取。它适合有一定Python基础、想入门数据采集的开发者也适合做内容运营、市场调研、学术研究的朋友直接拿来改改用。整体设计上我把项目拆成了三个独立又衔接的模块列表页采集、详情页解析、评论区分页抓取。为什么这么拆因为这三块面对的页面结构完全不同列表页是卡片式布局详情页是信息聚合页评论区则是异步加载的分页结构。分开写的好处是任何一块出了问题排查范围小修改也不会牵一发动全身。另一个关键决策是采集节奏的控制——我见过太多新手一上来就开多线程猛拉结果很快就被限制访问了。这个项目里我采用了单线程加随机延时的方式虽然慢一点但稳定跑完几千条数据完全没问题。提示任何数据采集行为都应遵守目标网站的robots协议和相关服务条款采集频率不宜过高数据仅用于个人学习和研究切勿用于商业牟利或大规模分发。2. 核心细节解析与实操要点2.1 目标页面结构分析在动手写代码之前我花了大概四十分钟把目标页面的结构摸了一遍。这一步很多人会跳过直接打开编辑器就写结果写到一半发现选择器对不上又回头翻页面来回折腾反而更慢。图书列表页的结构相对规整每本书是一个独立的卡片容器里面包含封面图、书名、评分、评价人数、作者和出版社信息。我习惯用浏览器的开发者工具逐层展开DOM树找到每个字段对应的标签和类名然后记在一个临时文本里。这里有个小技巧优先选择带有语义化类名的标签作为定位锚点比如包含“title”“rating”这类关键词的类名比纯数字或随机字符串的类名稳定得多因为后者很可能是构建工具自动生成的页面一改版就失效。详情页比列表页复杂一些除了基础信息更完整之外还多了内容简介、作者简介、目录等大段文本。评论区则是单独的一块默认只展示前几条需要点击“更多评论”或者翻页才能加载后续内容。我实测下来评论区的分页是通过URL参数控制的这意味着不需要模拟点击直接构造带页码的请求地址就能拿到数据省去了不少麻烦。2.2 请求头与访问节奏控制请求头里最关键的是User-Agent这个字段相当于告诉服务器“我是什么浏览器”。如果不设置用默认的Python请求库标识去访问很多网站会直接返回拒绝访问的页面。我的做法是准备三到五个常见的浏览器User-Agent字符串每次请求随机选一个模拟真实用户的多样性。除了User-Agent还有一个容易被忽略的点是请求间隔。我一开始设的是固定1秒跑了大概两百条之后发现偶尔会返回空数据。后来改成随机间隔范围在1.5到3.5秒之间就再没出现过异常。这个逻辑很好理解固定节奏的请求模式太像机器了随机化之后更接近真人浏览的行为特征。import requests import random import time HEADERS_POOL [ { 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;q0.9, }, { User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15, Accept-Language: zh-CN,zh;q0.9, }, ] def fetch_page(url, retry3): for attempt in range(retry): try: headers random.choice(HEADERS_POOL) resp requests.get(url, headersheaders, timeout10) if resp.status_code 200: return resp.text except requests.RequestException as e: print(f第{attempt1}次请求失败: {e}) time.sleep(random.uniform(1.5, 3.5)) return None上面这段代码是我实际用的请求封装函数加了重试机制和随机延时。重试次数设3次是个经验值——大部分临时性故障在3次之内都能恢复超过3次还失败的基本就是地址本身有问题了再重试也是浪费时间。2.3 解析库的选型考量解析HTML我试过三种方案正则表达式、BeautifulSoup和lxml的XPath。正则适合提取结构极其简单的单字段但图书信息字段多、嵌套深用正则写出来的模式串又长又难维护改一个字段就要重新调整个表达式。BeautifulSoup上手快语法直观适合新手但解析速度在数据量大时明显偏慢。最后我选了lxml配合XPath速度快定位精准尤其是处理嵌套层级深的节点时XPath的路径表达式比CSS选择器更灵活。举个例子提取评论列表时每条评论的文本可能分布在不同的子标签里有的在p里有的在span里。用XPath的//div[classcomment]//text()可以直接把某个容器下所有文本节点一次性取出来再在Python里做清洗比逐个标签去匹配省事得多。注意页面结构不是一成不变的建议把所有的选择器或XPath表达式集中写在一个配置字典里页面改版时只需要改这一个地方不用满代码找。3. 实操过程与核心环节实现3.1 列表页采集与图书链接提取列表页的任务是拿到每本书的详情页链接以及一些可以直接在列表页获取的字段比如书名和评分。我用的策略是分页遍历每页提取所有图书卡片的链接存入一个待抓取队列。from lxml import etree def parse_list_page(html): tree etree.HTML(html) items tree.xpath(//li[classsubject-item]) results [] for item in items: title_list item.xpath(.//h2/a/title) link_list item.xpath(.//h2/a/href) rating_list item.xpath(.//span[classrating_nums]/text()) if title_list and link_list: results.append({ title: title_list[0].strip(), url: link_list[0].strip(), rating: rating_list[0].strip() if rating_list else 暂无评分 }) return results这段代码里有个细节值得说rating_list做了空值判断。因为有些书确实还没有评分如果不判断直接取[0]程序就会抛异常中断。这种边界情况在采集初期很容易被忽略等到跑了几百条突然崩了才发现前面的数据还得重新跑。分页遍历时我设了一个最大页数限制比如只抓前10页。为什么不设成无限循环因为实际测试中发现当页码超过实际总页数时网站会返回一个空列表页面而不是报错如果不加限制程序会一直请求下去白白浪费时间。加一个判断——如果当前页解析结果为空就跳出循环——这个逻辑简单但很管用。3.2 详情页字段解析详情页需要提取的字段更多我列了一个清单书名、作者、出版社、出版日期、价格、ISBN、评分、评价人数、内容简介。每个字段的XPath表达式我都单独测试过确保能准确命中。def parse_detail_page(html): tree etree.HTML(html) info {} info[title] extract_text(tree, //span[propertyv:itemreviewed]/text()) info[rating] extract_text(tree, //strong[propertyv:average]/text()) info[rating_count] extract_text(tree, //span[propertyv:votes]/text()) info_div tree.xpath(//div[idinfo]) if info_div: raw_text info_div[0].xpath(string(.)) info[raw_info] raw_text.strip() summary tree.xpath(//div[idlink-report]//span[classall hidden]//text()) if not summary: summary tree.xpath(//div[idlink-report]//div[classintro]//text()) info[summary] .join(summary).strip() if summary else return info def extract_text(tree, xpath_expr): result tree.xpath(xpath_expr) return result[0].strip() if result else 这里有个处理作者和出版社信息的技巧。详情页里这些信息往往混在一个大段文本里格式类似“作者: XXX / 出版社: XXX / 出版年: XXX”。与其用复杂的XPath逐个提取不如先把整段文本取出来再用字符串分割的方式解析。我用的分割符是斜杠分割后每段再按冒号拆成键值对。这种方法的好处是容错性强即使某个字段缺失也不会影响其他字段的提取。内容简介有个坑有些书的简介是折叠的默认只显示前几行完整内容藏在一个隐藏的span里。我的做法是先尝试提取隐藏的完整版如果取不到再退而求其次取默认显示的部分。这个优先级顺序很重要反过来的话就只能拿到残缺的简介了。3.3 评论区分页抓取评论区是这个项目里最有意思的部分。前面提到过评论分页是通过URL参数控制的格式类似?p2表示第二页。我实测发现每页默认展示20条评论翻页参数从1开始递增。def parse_comments(html): tree etree.HTML(html) comments [] items tree.xpath(//div[classcomment-item]) for item in items: user extract_text(item, .//span[classcomment-info]/a/text()) rating_tag item.xpath(.//span[contains(class,allstar)]/title) content extract_text(item, .//span[classshort]/text()) vote_count extract_text(item, .//span[classvotes vote-count]/text()) comments.append({ user: user, rating: rating_tag[0] if rating_tag else 未评分, content: content, votes: vote_count }) return comments评论内容里经常夹杂换行符和多余空格我在入库前统一做了清洗把连续空白字符合并成一个空格去掉首尾空白。另外有些评论是“展开全文”的形式短评只显示一部分完整内容需要额外请求。我权衡了一下短评本身已经能反映读者的核心观点为了控制采集规模就没有去追完整版。评论翻页的终止条件我设了两个一是当前页返回的评论数为零二是已经达到预设的最大页数。双保险避免死循环。3.4 数据存储与去重存储方案我选了SQLite原因很简单单文件、零配置、支持SQL查询对于这个规模的数据量几千到几万条完全够用。建了两张表一张存图书信息一张存评论通过图书ID关联。import sqlite3 def init_db(db_pathbooks.db): conn sqlite3.connect(db_path) cursor conn.cursor() cursor.execute( CREATE TABLE IF NOT EXISTS books ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT UNIQUE, url TEXT, rating TEXT, rating_count TEXT, summary TEXT ) ) cursor.execute( CREATE TABLE IF NOT EXISTS comments ( id INTEGER PRIMARY KEY AUTOINCREMENT, book_id INTEGER, user TEXT, rating TEXT, content TEXT, votes TEXT, FOREIGN KEY (book_id) REFERENCES books(id) ) ) conn.commit() return conn去重这块图书表用书名做唯一约束插入时用INSERT OR IGNORE重复的书名直接跳过。评论表没有做唯一约束因为同一个用户可能对同一本书发表多条评论强行去重反而会丢数据。如果你确实需要去重可以用“图书ID用户评论内容”的组合做判断。提示采集过程中建议每处理完一本书就提交一次事务而不是全部跑完再统一提交。这样即使中途程序中断已经采集的数据也不会丢失。4. 常见问题与排查技巧实录4.1 请求被拒绝或返回空页面这是最常见的问题表现是status_code返回403或者200但内容为空。排查思路按优先级来先检查User-Agent是否设置再检查请求频率是否过高最后看是否需要携带Cookie。我遇到过一次比较隐蔽的情况单独请求某一页正常但连续请求五六页之后开始返回空数据。后来把间隔从固定1秒改成随机1.5到3.5秒就解决了。这说明目标网站对请求节奏是有感知的固定频率太容易被识别。问题表现可能原因解决方向返回403User-Agent缺失或被识别更换User-Agent增加请求头字段返回200但内容为空请求频率过高被限流增大随机延时降低并发部分页面正常部分异常特定页面结构不同单独检查异常页面的DOM结构连续请求后突然失败触发频率阈值加入随机休眠模拟人工节奏4.2 解析结果为空或字段错位解析失败通常有两个原因XPath表达式写错了或者页面结构和你预想的不一样。我的排查习惯是先把请求到的HTML保存到本地文件用浏览器打开再用开发者工具对照着检查。这样比反复请求线上页面效率高得多也不会给目标网站增加额外负担。字段错位是另一个坑。比如你按顺序提取了五个字段结果发现第三个字段的值跑到了第四个字段的位置。这往往是因为某些图书缺少某个字段导致列表长度不一致。解决办法是不要依赖位置索引而是用XPath精确定位每个字段取不到就返回空字符串保证字段数量始终一致。4.3 评论内容乱码或截断乱码问题一般是编码没处理对。requests库会根据响应头自动推断编码但有时候推断不准。我的做法是手动指定resp.encoding utf-8如果还是乱码就试试gbk。截断问题前面提过是折叠式短评导致的如果确实需要完整内容就得额外发一次请求去拿展开后的文本。4.4 数据入库时的类型问题SQLite对数据类型比较宽松但如果你后续要用Pandas做分析类型不一致会很麻烦。比如评分字段有的存成了字符串“8.5”有的存成了浮点数8.5。我的做法是在入库前统一做类型转换评分转成浮点数评价人数去掉“人评价”后缀转成整数。转换失败的字段统一置为None后续分析时再单独处理。def safe_float(value): try: return float(value) except (ValueError, TypeError): return None def safe_int(value): try: return int(str(value).replace(人评价, ).strip()) except (ValueError, TypeError): return None这两个小函数看着不起眼但能省掉后面大量的数据清洗时间。我踩过的坑是有一批数据的评价人数字段里混入了“暂无”这样的文本直接转换会报错中断加了异常捕获之后就稳了。4.5 采集规模与时间估算最后说一个实操中容易被低估的问题时间成本。假设每本书的详情页加评论页一共需要请求5次每次间隔2秒那么一本书大约需要10秒。采集500本书就是5000秒接近一个半小时。如果评论页数多时间还会更长。我的建议是先用小样本测试比如先跑20本书确认整个流程没问题、数据质量达标之后再放开规模。同时做好断点续采的准备——记录已经采集过的图书ID下次启动时跳过这些避免重复劳动。这个习惯在采集大批量数据时能救命我有一次跑到一半程序崩了因为没有断点记录只能从头再来白白浪费了两个小时。5. 数据后续利用的一些思路数据拿到手之后能做的事情其实比采集本身更有意思。我自己试过几个方向效果还不错。第一个是评分分布分析。把同一类图书的评分做成直方图能直观看出这类书的整体质量水平。比如我分析了一批计算机类图书发现评分集中在7.5到8.5之间9分以上的凤毛麟角这个结论对选书很有参考价值。第二个是评论关键词提取。把评论文本做分词处理统计高频词能快速了解读者最关注什么。我做过一次发现读者评论里出现频率最高的词是“翻译”和“排版”这提示我选书时除了看评分还要特别留意这两个维度。第三个是时间趋势观察。如果你采集的数据里包含出版日期可以按年份分组看评分变化判断某个领域的图书质量是在提升还是下滑。这个分析需要的数据量比较大建议至少采集几百本才有统计意义。提示做文本分析时记得先做停用词过滤把“的”“了”“是”这类无意义的高频词去掉否则关键词统计结果会被它们淹没。6. 几个我踩过的坑和对应解法第一个坑是忽略了页面编码。早期采集回来的数据里偶尔出现乱码排查了半天才发现是部分页面的编码声明和实际编码不一致。后来我在请求函数里统一加了编码检测逻辑先按响应头推断推断结果如果和实际不符再手动指定这个问题就再没出现过。第二个坑是评论翻页的边界判断。我一开始只判断了“评论数为零”作为终止条件结果有一次目标网站返回了一个包含推荐内容的页面评论数不为零但全是无关数据。后来加了最大页数限制双条件判断才彻底解决。第三个坑是数据库并发写入。我试过用多线程加速采集结果多个线程同时写SQLite导致锁表报错。SQLite本身不适合高并发写入如果确实需要多线程建议每个线程写独立的数据库文件最后再合并。或者干脆用单线程加异步请求的方式既能提速又不会锁表。第四个坑是字段缺失导致的程序中断。前面提过评分字段的空值问题其实作者、出版社这些字段同样可能缺失。我的做法是所有提取操作都包一层空值判断取不到就返回空字符串保证程序不会因为单个字段的问题整体崩溃。这个原则说起来简单但实际写代码时很容易忘记尤其是在赶进度的时候。7. 关于合规与伦理的一点个人体会做数据采集这些年我越来越觉得技术本身是中性的关键在于怎么用。采集公开的图书信息和评论用于个人学习研究和用这些数据去做商业竞争性质完全不同。我的原则是只采集公开可见的信息控制请求频率不影响网站正常服务数据仅用于个人分析不做二次分发。另外采集到的评论内容涉及用户的个人表达在做分析展示时应该做脱敏处理不要直接暴露用户名和原始评论。这既是对他人的尊重也是保护自己的必要措施。我在实际项目中存储时会保留用户名用于关联分析但对外展示时一律用匿名标识替代。这些经验都是我在实际项目中一点点积累起来的有些是踩了坑才明白的有些是同行交流时学到的。希望对你有所帮助少走一些弯路。