首页 / 资讯中心 / 文章详情

Python爬虫实战:requests+lxml.xpath高效采集豆瓣高分书籍

Python爬虫实战:requests+lxml.xpath高效采集豆瓣高分书籍 ★ FEATURED ARTICLE
1. 项目概述为什么用 requests lxml.xpath 而不是其他组合“python爬取豆瓣高分书籍信息requestxpath”这个标题看似简单但背后藏着一个非常典型的、高频复现的实战场景在不依赖重型框架的前提下用最轻量、最可控、最易调试的方式稳定获取结构化公开数据。我带过不少刚入门的数据采集项目发现很多人一上来就冲向 Scrapy 或 Selenium结果卡在环境配置、异步调度或反爬绕过上两周没跑出第一条有效数据。而这个标题里明确点出的requests xpath组合恰恰是经过十年以上行业验证的“稳准快”黄金搭档——它不炫技但每一步都踩在真实生产环境的节奏上。核心关键词“豆瓣高分书籍”指向的是一个高度结构化、语义清晰、更新频率适中非实时、且无登录强依赖的优质数据源。豆瓣读书频道对“评分≥9.0”“标记人数≥5000”的图书会打上“高分”标签这类页面 HTML 结构稳定class 名称语义明确如subject-item、rating_nums、pl非常适合用 XPath 精准定位。而requests的价值在于它完全剥离了浏览器上下文没有渲染开销、没有 JS 执行干扰、没有 session 管理黑盒——你发什么请求服务器就回什么响应中间没有任何“魔术”。这在排查问题时简直是救命稻草当你看到返回的 HTML 里根本没有你想要的span classrating_nums9.6/span那问题一定出在请求头、Referer、Cookie 或 URL 构造上而不是“JS 没加载完”这种玄学判断。这个方案最适合三类人一是想快速验证数据价值的产品/运营同学需要半小时内拿到 200 条书名评分简介做初步分析二是正在学 Python 网络编程的学生需要一个“改几行就能跑通、看几眼就能懂原理”的教学案例三是中小型团队里的数据工程师要为内部知识库定期同步豆瓣书单要求脚本能稳定运行半年不报错。它不解决“如何绕过复杂验证码”或“如何模拟用户滚动行为”但它把“确定性”做到了极致——只要豆瓣不重构页面这段代码三年后还能原样跑通。我自己在某高校图书馆数字资源组做过一个持续两年的自动书单同步项目就是基于这套逻辑至今没换过核心解析逻辑只微调了两次请求头字段。2. 整体设计思路与技术选型深挖2.1 为什么放弃 BeautifulSoup 而坚持用 lxml.xpath这是新手最容易纠结的问题。网上教程一半用BeautifulSoup一半用lxml看起来都是解析 HTML差别在哪我实测对比过 5000 页豆瓣图书列表页每页20本共250页的解析耗时解析方式平均单页耗时ms内存峰值MBXPath 表达式灵活性对 malformed HTML 容错性bs4 html.parser86.342.1中等需转为 CSS 或 find_all 链式调用强自动修复闭合标签bs4 lxml41.738.9中等同上中等lxml.etreexpath()19.226.5极高原生支持轴、谓词、函数、命名空间弱严格 XML 规范但豆瓣 HTML 可预处理关键差异在第三列XPath 是一种声明式查询语言它描述的是“我要找什么”而不是“我一步步怎么找”。比如提取“所有评分高于9.0且评论数超5000的书籍标题”用 XPath 一行搞定//li[classsubject-item]//span[contains(class,rating_nums) and number(text())9.0]/ancestor::li//h2/a/text()而用 BeautifulSoup 得写三层嵌套先find_all(li, class_subject-item)再对每个li做find(span, class_rating_nums)判断文本转浮点再find(div, class_star clearfix)里找评论数最后才取h2.a.text。代码行数多一倍可读性反而下降调试时还得逐层 print。更重要的是XPath 支持number(),contains(),starts-with(),position()等函数能直接在表达式里做数值比较和字符串匹配。豆瓣页面里常有“9.6”和“暂无评分”混排用number(text())9.0就能天然过滤掉非数字文本不用额外 try-except。我自己在调试时发现某次豆瓣更新后span classrating_nums里多了个空格变成nbsp;9.6XPath 的normalize-space()函数一行就解决number(normalize-space(text()))9.0而 BeautifulSoup 得先.get_text().strip()再转换多两行还容易漏。当然lxml 对 HTML 错误容忍度低但豆瓣的 HTML 质量极高几乎全是标准 XHTML 结构。我们只需在解析前加一行预处理from lxml import etree # 用 html.fromstring 替代 etree.fromstring自动修复常见 HTML 错误 html_tree etree.HTML(response.text)这行代码就解决了 99% 的容错问题代价是增加 3ms 开销完全值得。2.2 为什么不用 Selenium真实业务场景中的取舍逻辑Selenium 确实能解决“动态渲染内容”但豆瓣高分书籍列表页是服务端直出的静态 HTML所有数据都在初始 response.text 里。我专门抓包对比过打开https://book.douban.com/tag/小说?start0typeTNetwork 面板里document类型的响应体大小是 1.2MB里面完整包含了 20 本书的所有标题、作者、评分、简介、封面链接——根本不需要等 JS 执行。而引入 Selenium 意味着启动 ChromeDriver 进程内存占用 150MB每次请求等待页面 load 事件平均 800ms需要维护 WebDriver 版本与 Chrome 版本兼容性上周 Chrome 自动升级到 125我的旧版 chromedriver 直接报错日志里全是DevTools listening on ...这种无关信息干扰调试更致命的是稳定性。我在某公司内部知识库项目中试过 Selenium 方案连续运行 72 小时后Chrome 进程莫名卡死CPU 占用 100%必须手动 kill。而 requests 方案跑了一年半唯一一次失败是因为豆瓣临时调整了 User-Agent 黑名单我改了三行请求头就恢复。当然如果目标是“豆瓣读书小组最新热帖”内容由 AJAX 加载那必须用 Selenium 或 requests 分析 XHR 接口。但本项目标题明确是“高分书籍信息”页面结构决定了 requests 是更优解。技术选型不是比谁更高级而是比谁在当前约束下最可靠。就像修自行车胎你不会因为有激光焊接机就不用胶水补胎——前者成本高、耗时长、还可能焊穿轮毂。2.3 URL 构造策略如何系统性覆盖“高分书籍”全量范围标题里没说“从哪爬”但这恰恰是项目成败的关键。豆瓣没有“高分书籍总榜”聚合页它的高分数据分散在三个维度按标签筛选/tag/小说?start0typeT→ 按“小说”标签排序取前 N 页按评分排序/subject_search?search_textcat1001sortrank→ 搜索全部按评分降序按榜单入口/top250?start0filter→ 经典 Top250 榜单我实际测试发现仅靠 Top250 榜单太窄只有 250 本且多年未更新按标签筛选覆盖面广但质量参差“网络小说”标签下大量 7 分书最优解是组合使用“评分排序”“标签过滤”。具体操作先用/subject_search?search_textcat1001sortrank获取总页数通过解析页脚total字段每页 20 条计算start参数start (page_index - 1) * 20但直接遍历 1~500 页效率低很多页是低分书所以加一层前置过滤只请求start为 0, 20, 40...200 的前 11 页覆盖前 220 名再对每页结果用 XPath 提取//span[classrating_nums]若该页首条评分 8.5则停止后续请求因豆瓣按评分降序后面必然更低这个策略让我在 3 分钟内拿到 187 本评分 ≥ 9.0 的书籍而暴力爬 500 页要 12 分钟且含大量无效数据。URL 构造不是拼凑字符串而是对目标网站数据分布规律的理解。就像钓鱼得知道鱼群在哪个水深区域而不是把饵撒满整片湖。3. 核心细节解析与实操要点3.1 请求头构造不是复制粘贴而是理解每个字段的业务含义很多人以为 requests 只要加个User-Agent就能跑通结果被 403 拦在门外。豆瓣的反爬机制虽不激进但对请求头有明确校验逻辑。我通过反复抓包对比正常浏览器访问和 requests 访问的 header 差异总结出必须包含的 5 个关键字段及其业务含义Header 字段必填典型值为什么必须实测影响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豆瓣识别客户端类型过滤爬虫 UA缺失 → 403Accept是text/html,application/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8告知服务器能接收的响应格式缺失 → 406Accept-Language是zh-CN,zh;q0.9,en;q0.8影响返回内容的语言版本中文/英文缺失 → 返回英文页XPath 失效Referer是https://book.douban.com/豆瓣校验请求来源防止盗链缺失 → 403 或重定向到首页Cookie否但建议ll118282; bidxxx维持会话避免触发频率限制缺失 → 前 50 次正常之后 429特别注意Referer豆瓣会检查该字段是否来自其子域名book.douban.com或www.douban.com。如果设成https://google.com直接返回 403。我见过太多人把 Referer 写成https://book.douban.com/tag/小说结果爬第一页成功第二页start20就失败——因为第二页的 Referer 应该是第一页的 URL而非根目录。正确做法是headers { User-Agent: Mozilla/5.0 ..., Referer: https://book.douban.com/tag/小说?start0typeT # 动态更新为上一页URL }Cookie字段看似可选但强烈建议带上。豆瓣对无 Cookie 请求有隐式限速连续请求超过 30 次/分钟就会返回 429 Too Many Requests。而带上有效的bid随机 11 位字母数字和ll城市定位 cookie能将阈值提升到 100 次/分钟。生成bid很简单import string, random bid .join(random.choices(string.ascii_letters string.digits, k11))ll值固定为118282代表北京不影响数据但能通过基础校验。提示不要用浏览器复制的完整 Cookie里面含__utmz等 Google Analytics 字段豆瓣根本不读。只保留bid和ll即可精简 header 更利于调试。3.2 XPath 定位表达式编写从“能用”到“健壮”的四层打磨初学者写的 XPath 常是//div[classpl]/a/text()看似能取到出版信息但一遇到页面更新就全崩。真正的健壮 XPath 需经四层打磨第一层用语义化 class 名替代位置序号错误写法(//div[classpl])[1]/a/text()—— 依赖 DOM 顺序页面加个广告位就失效正确写法//div[classpl and contains(text(),出版)]/following-sibling::div[1]/text()—— 用“出版”文本定位再取下一个兄弟节点第二层用normalize-space()处理空白符豆瓣 HTML 常有换行缩进span classpl 出版社: /span直接text()会得到\n 出版社: \n导致contains(text(),出版社)失败。必须用contains(normalize-space(text()),出版社)第三层用string()函数合并多节点文本作者字段常分多行div classpub 人民文学出版社 / 2020-1 / 98.00元 /div用//div[classpub]/text()返回列表[\n , 人民文学出版社 / 2020-1 / 98.00元, \n]需string(//div[classpub])合并为单字符串再用 Python 正则切分。第四层用not()排除干扰项豆瓣页面底部有“相关图书”推荐区也用subject-itemclass但属于#db-rec-section下。XPath 加排除//li[classsubject-item and not(ancestor::*[iddb-rec-section])]//span[classrating_nums]/text()我整理了一份豆瓣图书页常用 XPath 表达式速查表覆盖 95% 字段目标字段健壮 XPath 表达式说明书名//li[classsubject-item]//h2/a/text()用 h2 包裹确保是主标题评分//li[classsubject-item]//span[classrating_nums and number(normalize-space(text()))9.0]/text()内置评分过滤评价人数//li[classsubject-item]//div[classstar clearfix]//span[classpl]/text()提取(23456人评价)中数字作者/译者//li[classsubject-item]//div[classpub]/text()需后续正则清洗简介//li[classsubject-item]//p[classquote]/span/text()quote 类是短评p.quote 是简介封面图链接//li[classsubject-item]//a[classnbg]/img/srcnbg 是“no background”缩写专指封面图注意所有 XPath 必须用html_tree.xpath()执行而非etree.Element.xpath()前者自动处理命名空间和编码。3.3 数据清洗与结构化别让脏数据毁掉整个流程爬下来的数据远非干净 CSV。豆瓣的“作者”字段可能是作者: 余华 / 译者: 何卫东也可能是编者: [美] 约翰·史密斯甚至主编: 王小波 等。直接存数据库会引发后续分析灾难。我采用三级清洗策略一级字段级标准化Python 正则import re # 出版社、年份、价格三合一字段清洗 pub_text 人民文学出版社 / 2020-1 / 98.00元 publisher re.search(r^([^/]), pub_text).group(1).strip() # 人民文学出版社 year re.search(r/\s*(\d{4}), pub_text).group(1) # 2020 price re.search(r/\s*([\d.])\s*元, pub_text).group(1) # 98.00 # 作者字段清洗处理多种分隔符 author_text 作者: 余华 / 译者: 何卫东 authors [x.split(:,1)[1].strip() for x in author_text.split( / ) if : in x] # → [余华, 何卫东]二级逻辑校验业务规则评分必须是 0~10 的浮点数且rating 9.0标题要求“高分”评价人数必须是整数且comments_count 5000豆瓣高分书基本门槛书名长度 2~50 字过滤或...等无效值三级去重与归一化基于 ISBN豆瓣 URL 含 ISBNhttps://book.douban.com/subject/1002025/其中1002025是豆瓣 ID非国际 ISBN。但页面 HTML 中有meta propertybooks:isbn content9787020000000。用此字段做去重比书名作者更可靠同一书不同译本 ISBN 不同。最终数据结构定为 Python 字典直接可转 JSON 或 Pandas DataFrame{ douban_id: 1002025, isbn: 9787020000000, title: 活着, rating: 9.4, comments_count: 125432, authors: [余华], publisher: 人民文学出版社, pub_year: 2020, price: 35.0, cover_url: https://imgX.doubanio.com/view/subject/m/public/s1002025.jpg, summary: 讲述了主人公福贵... }4. 实操过程与核心环节实现4.1 完整代码实现从零开始的可运行脚本以下是我实际部署在树莓派上每日自动运行的精简版已移除日志和异常重试专注核心逻辑import requests from lxml import etree import re import time import random import json def get_headers(): 生成合法请求头 user_agents [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36, Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ] return { User-Agent: random.choice(user_agents), Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Referer: https://book.douban.com/, Cookie: fbid{.join(random.choices(abcdefghijklmnopqrstuvwxyz0123456789, k11))}; ll118282; } def fetch_page(url): 获取单页HTML try: response requests.get(url, headersget_headers(), timeout10) response.raise_for_status() return etree.HTML(response.text) except Exception as e: print(f请求失败 {url}: {e}) return None def parse_book_list(html_tree): 解析一页中的所有书籍 books [] # 定位所有书籍条目 subject_items html_tree.xpath(//li[classsubject-item]) for item in subject_items: try: # 书名 title_elem item.xpath(.//h2/a/text()) title title_elem[0].strip() if title_elem else # 评分带过滤 rating_elem item.xpath(.//span[classrating_nums]/text()) rating float(rating_elem[0].strip()) if rating_elem and re.match(r^\d\.\d$, rating_elem[0].strip()) else 0 # 跳过低分书 if rating 9.0: continue # 评价人数 comments_elem item.xpath(.//div[classstar clearfix]//span[classpl]/text()) comments_text comments_elem[0].strip() if comments_elem else comments_count int(re.search(r(\d)人评价, comments_text).group(1)) if re.search(r(\d)人评价, comments_text) else 0 # 跳过低评价数 if comments_count 5000: continue # 出版信息 pub_elem item.xpath(.//div[classpub]/text()) pub_text pub_elem[0].strip() if pub_elem else # 提取出版社、年份、价格 publisher re.search(r^([^/]), pub_text).group(1).strip() if re.search(r^([^/]), pub_text) else year re.search(r/\s*(\d{4}), pub_text).group(1) if re.search(r/\s*(\d{4}), pub_text) else price re.search(r/\s*([\d.])\s*元, pub_text).group(1) if re.search(r/\s*([\d.])\s*元, pub_text) else # 简介 summary_elem item.xpath(.//p[classquote]/span/text()) summary summary_elem[0].strip() if summary_elem else # 封面图 cover_elem item.xpath(.//a[classnbg]/img/src) cover_url cover_elem[0].strip() if cover_elem else # 豆瓣ID从URL提取 subject_url item.xpath(.//h2/a/href)[0] if item.xpath(.//h2/a/href) else douban_id re.search(r/subject/(\d)/, subject_url).group(1) if re.search(r/subject/(\d)/, subject_url) else # ISBN从meta标签 isbn_elem item.xpath(.//meta[propertybooks:isbn]/content) isbn isbn_elem[0].strip() if isbn_elem else books.append({ douban_id: douban_id, isbn: isbn, title: title, rating: rating, comments_count: comments_count, publisher: publisher, pub_year: year, price: price, cover_url: cover_url, summary: summary }) except Exception as e: print(f解析单本书失败: {e}) continue return books def main(): 主流程 all_books [] base_url https://book.douban.com/subject_search?search_textcat1001sortrank # 爬取前10页start0,20,...,180 for page in range(0, 10): start page * 20 url f{base_url}start{start} print(f正在爬取第{page1}页: {url}) html_tree fetch_page(url) if html_tree is None: continue books parse_book_list(html_tree) print(f第{page1}页获取 {len(books)} 本高分书) all_books.extend(books) # 反爬延迟随机 1~3 秒 time.sleep(random.uniform(1, 3)) # 去重按豆瓣ID seen_ids set() unique_books [] for book in all_books: if book[douban_id] not in seen_ids: seen_ids.add(book[douban_id]) unique_books.append(book) # 保存为JSON with open(douban_high_score_books.json, w, encodingutf-8) as f: json.dump(unique_books, f, ensure_asciiFalse, indent2) print(f总计获取 {len(unique_books)} 本高分书籍已保存至 douban_high_score_books.json) if __name__ __main__: main()关键参数说明cat1001豆瓣图书分类 ID1001全部图书sortrank按评分降序排列start0起始偏移量每页20条time.sleep(random.uniform(1,3))模拟人工浏览节奏避免触发频率限制实测效果在 2Mbps 家庭宽带下10 页200 本书耗时约 4 分 20 秒成功率 99.2%2 本因网络抖动失败。生成的 JSON 文件可直接导入 Excel 或 Pandas 分析例如统计“9.0 书籍的出版社分布”import pandas as pd df pd.read_json(douban_high_score_books.json) df.groupby(publisher).size().sort_values(ascendingFalse).head(10)4.2 请求频率控制与反爬应对不是对抗而是共生很多人把反爬当成“攻防战”其实豆瓣的设计逻辑是“欢迎合理采集拒绝恶意刷量”。它的限制非常人性化单 IP 每分钟最多 60 次请求超过返回 429连续请求间隔 0.5 秒会被静默丢弃无状态码response.text 为空无 Cookie 请求 30 次/分钟即限速因此我们的策略是“主动合规”固定延迟 随机扰动time.sleep(1.5 random.uniform(0, 1))既保证 1 秒又避免周期性规律请求头轮换每次请求换User-Agent和Cookie.bid让豆瓣认为是多个真实用户失败重试机制生产环境必加for attempt in range(3): try: response requests.get(url, timeout10) if response.status_code 200: break except: pass time.sleep(2 ** attempt) # 指数退避我曾用同一脚本在阿里云 ECS 上连续运行 30 天每天爬 500 本书从未被封 IP。关键不是“怎么绕过”而是“怎么证明自己是个好邻居”。就像去图书馆借书你每次只借 5 本、登记姓名、按时归还管理员自然给你开绿灯。5. 常见问题与排查技巧实录5.1 典型问题速查表从报错信息直击根源报错现象可能原因排查命令/步骤解决方案requests.exceptions.ConnectionErrorDNS 解析失败或网络不通ping book.douban.comnslookup book.douban.com检查网络代理设置或换 DNS如8.8.8.8requests.exceptions.Timeout服务器响应超时curl -v https://book.douban.com/...测试增加timeout15检查是否被防火墙拦截response.status_code 403请求头缺失或非法print(response.request.headers)补全User-Agent、Referer、Accept-Languageresponse.status_code 429请求过于频繁查看response.headers.get(X-RateLimit-Remaining)加大time.sleep()添加 Cookiexpath() 返回空列表页面结构变化或 XPath 错误print(etree.tostring(html_tree, encodingunicode)[:500])用浏览器开发者工具复制最新 XPath注意normalize-space()ValueError: could not convert string to float评分字段含非数字字符print(rating_elem[0])加re.search(r(\d\.\d), text)提取纯数字IndexError: list index out of rangeXPath 未匹配到元素print(len(html_tree.xpath(//li[classsubject-item])))检查是否被重定向到登录页打印response.urlUnicodeEncodeError控制台不支持中文print(repr(title))在文件写入时指定encodingutf-8独家技巧用 curl 快速验证请求头当 Python 脚本失败时先用 curl 模拟相同请求排除代码问题curl -H User-Agent: Mozilla/5.0 ... \ -H Referer: https://book.douban.com/ \ -H Cookie: bidabc123; ll\118282\; \ https://book.douban.com/subject_search?search_textcat1001sortrankstart0如果 curl 成功而 Python 失败一定是 Python 代码里 header 字段名写错了如user-agent小写或值里有不可见字符。5.2 页面结构突变应急方案当豆瓣某天凌晨改版2023 年 11 月豆瓣悄悄把rating_numsclass 改成rating_nums star导致所有用classrating_nums的 XPath 全部失效。我当时正在跑一个周报任务凌晨 3 点收到告警邮件。应急处理三步走第一步快速定位变更点不用重看整个 HTML直接用 XPath 查找所有含 “9.” 的 span# 在 Python 中临时执行 spans html_tree.xpath(//span[contains(text(),9.) or contains(text(),8.)]) for s in spans[:5]: print(s.attrib, s.text)输出{class: rating_nums star} 9.6 {class: rating_nums} 8.5立刻确认是 class 名追加了star。第二步最小化修改把classrating_nums改成contains(class,rating_nums)一行解决无需重构整个解析逻辑。第三步建立监控机制在脚本开头加健康检查# 检查页面是否返回有效内容 if not html_tree.xpath(//li[classsubject-item]): raise RuntimeError(页面结构异常未找到 subject-item 元素请检查豆瓣是否改版)这样下次改版脚本会在第一行就报错而不是默默爬了 100 页空数据。5.3 数据质量陷阱那些你以为对、其实错的“常识”误区1“评分高口碑好”豆瓣评分是加权平均但权重算法未公开。我对比过《三体》系列《三体Ⅰ》9.4 分120 万评价《三体Ⅲ》8.9 分85 万评价但后者在专业读者圈讨论热度更高。单纯按评分排序会漏掉重要作品。误区2“评价人数多数据可靠”豆瓣存在“刷评”现象尤其新书上线时。我抓取过某畅销书24 小时内新增 3000 条评价其中 2800 条是“很好看”“强烈推荐”无任何细节。这类数据需结合comments_count和summary字数过滤如len(summary) 20的视为无效。误区3“封面图 URL 永久有效”豆瓣 CDN 会定期清理冷门图片。我存档的 2020 年数据中12% 的cover_url已返回 404。生产环境必须加图片有效性检查def is_image_valid(url): try: return requests.head(url, timeout5).status_code 2
阅读完成 · 觉得有帮助?
咨询建站