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

Python爬虫实战:豆瓣Top250数据抓取详解(含XPath与存储避坑)

Python爬虫实战:豆瓣Top250数据抓取详解(含XPath与存储避坑) ★ FEATURED ARTICLE
Python 爬虫入门十个教程里九个都会拿豆瓣电影 Top250 练手原因很简单页面结构规整、数据量不多不少、反爬门槛存在但不刁钻正好能把请求—解析—存储这一条完整链路跑通。这篇文章我打算把这个经典项目重新拆解一遍从页面分析到源码逐行讲解重点把 XPath 的 text() 函数用法、翻页参数规律、CSV 中文乱码处理这些实操中必踩的细节铺开讲清楚最终落成一份完整可复用的 Python 爬虫源码。新手可以照着一行行敲老手可以重点看解析逻辑和避坑部分。1. 项目整体设计为什么选豆瓣 Top250 当爬虫练手项目爬虫项目的难点从来不是写代码而是分析页面和处理异常。豆瓣电影 Top250 恰好把这两个难点控制在了一个恰到好处的难度区间第一步选型就选对了后面才能少走弯路。1.1 目标页面结构和数据特征先把目标页面的逻辑梳理清楚。豆瓣电影 Top250 的入口是https://movie.douban.com/top250页面会展示 250 部电影分 10 页每页 25 部。翻页靠 URL 参数控制第一页是start0第二页是start25第三页是start50依此类推到第十页是start225。理解了start参数的含义相当于已经完成了三分之一的需求分析。页面上每一部电影的信息都包裹在一个li标签里所有li外面套着一个ol classgrid_view列表。具体到每一部影我们能拿到的关键字段包括电影排名em标签、中文片名第一个span classtitle、评分span classrating_num、评价人数div classstar下的最后一个span、经典台词span classinq部分电影没有、以及电影详情页链接a标签的href属性。这些字段类型覆盖了文本提取、属性提取、空值处理三类常见场景练完这一个项目碰到绝大多数静态页面的信息提取都能直接上手。为什么说这个项目适合入门因为每个li的结构高度一致解析逻辑写一遍就能复用到 250 条数据上。等你真去爬一些结构混乱的网站光是在各种嵌套的div里找到目标数据的父节点就能折腾一整天相比之下豆瓣的页面结构堪称业界良心。1.2 技术选型requests lxml而不是一上来就上 Scrapy我见过不少新人学爬虫第一次动手就直接上 Scrapy结果被中间件、Pipeline、Item Loader 这些概念砸懵最后连数据都没跑出来。我的建议很明确第一个项目全程用轻量级方案先理解爬虫的本质再谈框架。具体选型是这样的请求库用requests它把 HTTP 请求封装得非常简洁requests.get()一行代码就能拿到响应内容对新手来说几乎没有理解成本。解析库用lxml底层是 C 语言实现解析速度快同时完整支持 XPath 语法。存储方面直接用 Python 标准库里的csv模块不需要引入额外依赖唯一需要注意的是编码设置。整条技术链加起来就三个东西逻辑层次非常清楚。为什么不选 Scrapy不是因为 Scrapy 不好而是因为它的抽象层级太高。新手用它写爬虫写出来的代码看起来能跑但一旦出问题你根本不知道是哪个环节出了问题。先拿 requests 把 HTTP 请求、响应头、编码这些底层机制摸清楚回头再上 Scrapy理解框架的设计思路会顺很多。1.3 请求策略和合规边界先把规矩说清楚爬虫圈有个不好的风气上来就开全速几十个线程对着目标站点一顿猛拉结果害人害己。这里先把规矩说清楚第一豆瓣的 robots.txt 协议明确允许爬取/movie/top250这个路径第二我们要抓的是电影评分这类非个人数据不涉及用户隐私第三请求频率必须控制每请求一页睡两秒抓完 10 页也就 20 秒左右对目标站点几乎无感。做爬虫要有一个基本素养你不是在和网站对抗而是在和目标站点共存。疯狂压缩请求间隔、暴力拉取数据的行为会把 IP 拉进黑名单也会给整个爬虫技术圈子招黑。我自己的习惯是不管目标站点反爬多弱请求间隔从不低于 1.5 秒数据抓完就停绝不多扫一个用不到的页面。2. 环境准备和依赖安装说实话环境配置这一步劝退的人比爬虫本身还要多。图书馆都进不去抓什么数据先把开发环境这一关过了后面就是坦途。2.1 Python 环境检查与安装要点打开终端Windows 上是 cmd 或 PowerShellmacOS 上是 Terminal输入python --version如果输出了类似Python 3.10.x的信息说明环境已经就绪。如果提示找不到命令那就需要去 Python 官网下载安装包。下载时注意两点第一选择 3.9 以上版本别用老掉牙的 2.7第二Windows 安装时务必勾选Add Python to PATH这一项不勾的话后面在命令行里敲python会提示找不到命令又得折腾一遍环境变量。安装完成后重新打开一个终端窗口再次执行python --version确认版本。这一步很多人栽在安装成功但命令不生效上绝大多数情况就是安装时没勾 PATH或者安装完没开新窗口。Python 的 PATH 配置是在安装时写入系统环境变量的你开着的旧终端不会自动刷新。2.2 用 pip 安装第三方依赖依赖只有两个执行两行命令就能装完pip install requests pip install lxml如果你在国内网络环境下安装速度很慢可以用清华的 PyPI 镜像源速度能提升一个数量级pip install -i https://pypi.tuna.tsinghua.edu.cn/simple requests lxml装完之后可以顺手验证一下在终端里进入 Python 交互模式依次执行import requests和import lxml没有报错就说明安装成功。这里的验证环节很多人跳过结果后面代码运行时报ModuleNotFoundError: No module named lxml才发现依赖压根没装上。多花 10 秒做一次导入验证能省下后面半小时的排查时间。2.3 开发工具和辅助调试方法编辑器不用追求花哨VS Code 装一个 Python 插件就足够用了。但调试 XPath 的时候我强烈建议配合浏览器自带的开发者工具一起用。按 F12 打开开发者工具在 Elements 面板里找到对应的li元素右键选择 Copy - Copy XPath浏览器会自动生成一条 XPath 路径。虽然这条路径经常带tbody之类的冗余节点、不一定能直接用但它可以帮你快速定位到目标元素附近再手动精简成更稳妥的写法。另外在浏览器 Console 面板里可以直接用$x()函数测试 XPath比如输入$x(//ol[classgrid_view]/li)就会返回一个包含所有电影项的结果数组。这个技巧能让你在写爬虫代码之前就验证解析规则的正确性比写好代码再跑一遍效率高得多。3. 核心源码解析与实操实现这部分是重头戏我把整个项目拆成四个模块来讲请求模块负责拿数据解析模块负责提取字段存储模块负责落盘主流程负责串起所有步骤。每一段代码我都会解释为什么这么写而不是只丢一堆代码让你自己琢磨。3.1 完整流程设计与模块拆分整个爬虫的流程可以用一句话概括构造带参数的请求获取 HTML 文本用 XPath 从 HTML 中提取结构性数据把提取结果批量写入 CSV 文件。听起来简单但代码组织方式会直接影响后续的维护和扩展体验。我推荐函数化拆分而不是把所有逻辑堆在一个main()函数里。理由有三点第一每个函数职责单一出问题的时候能快速定位到具体环节第二请求模块以后可以直接复用换一个目标网站只要改 URL 和 Headers 就行第三解析函数独立出来后你可以单独拿一页 HTML 来测试解析逻辑不必每次都重新发起请求节省时间也减少对目标站点的请求压力。流程上还要加一个延时步骤每请求完一页数据就time.sleep(2)。这个 2 秒不是拍脑袋定的豆瓣的反爬策略虽然不算激进但短时间高频请求大概率会触发限流返回结果会出现 418 或 403 状态码。实测下来客户端用正常浏览器的请求头、加上 2 秒间隔整个抓取过程非常平稳。3.2 请求模块URL 参数、请求头与超时处理先看请求模块代码import requests import time BASE_URL https://movie.douban.com/top250 HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, } def fetch_html(start): params { start: start, filter: , } resp requests.get(BASE_URL, paramsparams, headersHEADERS, timeout10) resp.raise_for_status() resp.encoding utf-8 return resp.text有几个细节值得单独讲。第一个是params参数requests 库会在请求时自动把字典里的内容拼到 URL 后面最终请求的 URL 形如https://movie.douban.com/top250?start0filter。你当然可以在代码里直接拼字符串但我强烈建议用params字典它可以自动处理中文等特殊字符的 URL 编码也方便以后增加更多参数。第二个是User-Agent这个字段用来标识客户端类型。爬虫默认的 UA 是一串类似python-requests/2.31.0的内容服务器一眼就能识别出这是程序在访问直接拒绝请求。我这里的写法是完整模拟一个 Chrome 浏览器的 UA 字符串类别包括系统版本和浏览器内核版本。注意网上很多教程为了图省事只设置User-Agent其实加上Accept和Accept-Language会让请求更像真实浏览器有个别版本的反爬检测会校验这些头信息字段的配套关系缺了反而会触发异常。第三个是编码设置。豆瓣的页面明确声明了 UTF-8 编码requests 库并不总是能正确推断出编码手动指定resp.encoding utf-8是防止解析出来全是乱码的最稳方案。至于raise_for_status()它能自动检查 HTTP 状态码如果服务器返回 4xx 或 5xx 错误函数直接抛出异常终止程序省得拿着一个错误的响应继续解析。3.3 XPath 解析定位节点与 text() 函数的正确用法解析模块是整篇爬虫的技术核心也是热词里提到的python xpath爬虫 text函数最容易踩坑的地方。先把解析代码放出来from lxml import etree def parse_page(html): tree etree.HTML(html) items tree.xpath(//ol[classgrid_view]/li) movies [] for item in items: rank item.xpath(.//div[classpic]/em/text())[0].strip() title item.xpath(.//div[classhd]/a/span[classtitle][1]/text())[0].strip() rating item.xpath(.//span[classrating_num]/text())[0].strip() votes item.xpath(.//div[classstar]/span[4]/text())[0].strip() quote item.xpath(.//span[classinq]/text()) quote quote[0].strip() if quote else link item.xpath(.//div[classhd]/a/href)[0].strip() movies.append({ rank: int(rank), title: title, rating: float(rating), votes: votes.replace(人评价, ), quote: quote, link: link, }) return movies先讲tree etree.HTML(html)这一行。lxml会把原始 HTML 字符串解析成一棵节点树后续所有 XPath 都是从这棵树上搜索目标节点。这样做的好处是你能用.//相对路径从一个节点继续向下搜索而不是每次从头写绝对路径。然后注意//ol[classgrid_view]/li这个表达式。//表示在整个文档中搜索ol[classgrid_view]表示匹配带有指定 class 属性的ol标签末尾的/li表示取它所有的直接子节点li。返回的是 25 个元素节点每个元素对应一部电影。这里有个很常见的坑很多人会写成//ol/li完全不校验 class这在豆瓣页面上恰好也能用因为全页面只有一个ol但换个网站就会定位到错误节点。带 class 属性定位虽然多敲几个字符但能保证在任何页面都不会匹配错误节点。接下来是热词中反复提到的text()函数。XPath 里的text()返回的是当前节点的文本内容不是节点本身。比如em/text()的含义是取em标签内部的文字等价于你在浏览器里看到的那位数字。要注意的是text()返回的是多个文本节点的时候会返回一个列表哪怕只有一个匹配结果也会放到列表里所以必须用[0]来取出第一项。另一个容易混淆的概念是 Python 对象里的.text属性比如item.text那是 lxml 元素对象的一个属性和 XPath 语法里的text()函数不是一回事两者别搞混。.开头的 XPath 表达式也很关键。item.xpath(.//div[classpic]/em/text())中开头的.表示从当前节点开始搜索//表示在当前节点下任意层级搜索。如果不加这个点就会变成从文档根节点全局搜索结果可能匹配到页面其他位置的无关数据。这个细节解释了为什么同一个 XPath 写在tree.xpath()里和写在item.xpath()里有完全不同的语义。还有一个值得单独说明的是经典台词的提取方式。quote item.xpath(.//span[classinq]/text())这一行如果电影有台词text()会返回包含一句台词的列表如果电影没有台词返回的会是一个空列表。我用quote[0].strip() if quote else 做了兼容处理列表非空才取第一个元素否则用空字符串兜底。这种防御式写法在爬虫开发中非常重要因为每个网站总有一些条目缺失某些字段直接下标取值会导致IndexError让程序崩溃加上空值判断就能保证整个抓取流程不中断。顺手说一句.strip()的用途HTML 源码中标签之间的大量换行和缩进空格会混入文本strip()统一去掉前后空白输出才干净。3.4 翻页机制解析翻页在代码里只是一个for循环加一个start变量。但这里的逻辑值得拆开讲清楚因为怎么翻页直接决定了能否拿到全部 250 条数据。all_movies [] for page in range(10): start page * 25 html fetch_html(start) movies parse_page(html) all_movies.extend(movies) time.sleep(2)range(10)生成 0 到 9 的序列配合page * 25就能算出每一页的起始偏移量第一页start0、第二页start25……最后一页start225。这么写的好处是万一豆瓣以后调整了每页数量你只需要改一个数字乘法因子不用手写十个 URL。all_movies.extend(movies)把每页解析出来的列表合并进同一个总表最终得到 10 个页面共 250 条电影数据。关于实际运行时的请求节奏我再补充一句。建议在代码里加一行print(f正在抓取第 {page 1} 页start{start})打点日志这样抓到哪一页、有没有卡住都一目了然。爬虫跑起来如果没有任何输出你会陷入一种到底在跑还是在挂的焦虑中日志是最好的定心丸。3.5 数据存储CSV 编码与空行处理数据抓完就要落盘这里有一个几乎每个人都会踩一遍的坑用 CSV 保存中文用 Excel 打开全乱码。原因在于 CSV 默认用 UTF-8 编码写文件而 Windows 版 Excel 默认按 UTF-8 解码时不会识别中文字符。解决方案是写入时用带 BOM 头的 UTF-8 编码import csv def save_to_csv(movies, filenamedouban_top250.csv): fieldnames [rank, title, rating, votes, quote, link] with open(filename, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnamesfieldnames) writer.writeheader() writer.writerows(movies)utf-8-sig这个编码名会往文件头部写入一个 BOM 标记Excel 读取到 BOM 后就能正确识别编码不再乱码。如果你的环境是 macOS 或 Linux用utf-8即可也能正常查看但为了兼容同事的 Windows 电脑我一律建议utf-8-sig成本几乎为零。另一个细节是newline参数。如果不指定Python 在写 CSV 时会在每行末尾多出一个回车符最终文件打开后每两行之间多一个空行非常难看。这个参数跟编码无关是 Python 在 Windows 平台上处理换行符的兼容性问题。每次写 CSV 我都默认带上newline已经完全变成肌肉记忆了。3.6 完整可运行的源码上面几段代码拼在一起就是完整的项目。为了阅读方便我把完整源码放在这里所有模块保持独立直接复制保存成douban_top250.py即可运行import requests from lxml import etree import csv import time BASE_URL https://movie.douban.com/top250 HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, } def fetch_html(start): params { start: start, filter: , } resp requests.get(BASE_URL, paramsparams, headersHEADERS, timeout10) resp.raise_for_status() resp.encoding utf-8 return resp.text def parse_page(html): tree etree.HTML(html) items tree.xpath(//ol[classgrid_view]/li) movies [] for item in items: rank item.xpath(.//div[classpic]/em/text())[0].strip() title item.xpath(.//div[classhd]/a/span[classtitle][1]/text())[0].strip() rating item.xpath(.//span[classrating_num]/text())[0].strip() votes item.xpath(.//div[classstar]/span[4]/text())[0].strip() quote item.xpath(.//span[classinq]/text()) quote quote[0].strip() if quote else link item.xpath(.//div[classhd]/a/href)[0].strip() movies.append({ rank: int(rank), title: title, rating: float(rating), votes: votes.replace(人评价, ), quote: quote, link: link, }) return movies def save_to_csv(movies, filenamedouban_top250.csv): fieldnames [rank, title, rating, votes, quote, link] with open(filename, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnamesfieldnames) writer.writeheader() writer.writerows(movies) def main(): all_movies [] for page in range(10): start page * 25 print(f正在抓取第 {page 1} 页start{start}) html fetch_html(start) movies parse_page(html) all_movies.extend(movies) time.sleep(2) save_to_csv(all_movies) print(f抓取完成共 {len(all_movies)} 条数据已保存到 douban_top250.csv) if __name__ __main__: main()运行前再核对一遍请求模块、解析模块、存储模块是否都导入正常if __name__ __main__这行缩进是否正确。如果第一次运行就成功拿到 250 行数据恭喜你爬虫这条路你已经正式入了一半的门了。4. 常见问题与排查技巧实录这个项目我跑过很多遍每次带新手都会碰到一些重复性的问题。把这些典型痛点整理成表格对照着排查能省不少折腾时间。现象根本原因解决方案请求返回 418 或 403请求头不完整被识别为脚本补全 User-Agent、Accept、Accept-Language加大请求间隔XPath 取不到任何数据表达式定位错误或响应被拦截先把 HTML 保存成文件肉眼确认页面结构再写表达式输出全是乱码编码未指定或指定错误手动设置resp.encoding utf-8CSV 中每两行间有空行Windows 系统换行符兼容问题open()时加newline评价人数提取为空或报错span 下标定位不准确优先用span[4]固定下标或用span[last()]4.1 请求被拦截状态码 418 和 403 的应对思路418 状态码严格来说是个玩笑彩蛋代码是Im a teapot但被网站拿来做反爬提示时意思就是服务器识破了你的机器人身份。403 则是明确的禁止访问。遇到这类问题先别急着加代理加伪装按顺序排查三个点第一请求头是否完整。只带一个 User-Agent 是最常见的问题很多爬虫教程为了简化只教这一个字段结果用户照抄后发现依然被拦。稳妥的做法是至少带上 User-Agent、Accept 和 Accept-Language 三项浏览器访问一个网页时这三个字段是必然出现的。第二请求频率是否过高。如果连续访问同一个页面间隔不到 1 秒十有八九会触发限流让程序挂上 1 到 2 秒的延时多数情况能直接缓解。第三Cookie 会话是否需要。豆瓣的 Top250 页面不强制要求登录和 Cookie所以你不需要额外处理如果换到需要登录的页面就要考虑用requests.Session()维护会话状态了。4.2 XPath 取不到数据的排查思路这是爬虫新手最崩溃的环节代码没报错但爬回来一个空列表。我的习惯是分三步排查。第一步把响应的 HTML 文本保存到本地文件用编辑器打开看数据到底在不在。你可能会发现有些网站对非浏览器客户端返回的是移动版页面或一个验证页面数据压根不在里面这时候再怎么调 XPath 都是白费力气。第二步直接在浏览器 Console 面板用$x(//ol[classgrid_view]/li)验证 XPath能返回结果说明表达式本身没问题。第三步确认表达式加了.//的相对路径前缀防止在循环中从全局搜索匹配到了错误节点。4.3 乱码问题为什么手动指定编码比自动检测靠谱requests库有一个apparent_encoding属性会自动检测响应的编码。听起来很智能但实际使用中它经常出错尤其是在页面没有charset声明或者声明和实际不一致的时候。豆瓣页面声明的是utf-8如果你不手动赋值resp.encoding utf-8requests 有概率按ISO-8859-1来解码最后拿到手的文本就是整片乱码。我的经验是只要目标页面声明了编码一律手动指定不要依赖自动检测。对于没有声明编码的页面再退回apparent_encoding作为备用方案。4.4 评价人数提取的两种写法代码里用的是item.xpath(.//div[classstar]/span[4]/text())下标 4 对应第四个span标签。在豆瓣当前的页面结构下第四个 span 恰好是评价人数。这种写法精确但脆弱如果页面结构调整下标的含义就会变化。更稳妥的替代方案是item.xpath(.//div[classstar]/span[last()]/text())意思是取 star 节点下最后一个 span结构性更强。两个方案在当前页面都能跑通新手可以都试一遍体会一下固定下标和相对定位的区别。5. 后续扩展从 250 条数据到更大的体量跑通一个基础版之后很多人的瓶颈就不在爬虫本身了而是怎么把这个数据用起来、怎么把抓取效率提上去。这里给出三个实际的扩展方向按难度阶梯排好。5.1 多线程加速的正确打开方式10 页数据加延时总共约 20 秒其实已经不算慢了但你要是想把整个 Top250 每部电影的短评详情页都抓一遍那就是 250 个请求必须考虑并发。concurrent.futures是 Python 标准库里的并发模块使用ThreadPoolExecutor加max_workers5的配置请求延迟可以整体缩短到原来的五分之一左右。但这里有一个非常现实的提醒并发数不是越高越好。豆瓣对并发请求非常敏感用 5 个线程配合原有的 2 秒延时实测还能保持稳定一旦把线程数拉到 20 以上很快会触发反爬请求全部变成 418。框架方面可以等对 HTTP 流程有完整理解后再去接触 Scrapy 或异步协程方案但底层的请求间隔意识从第一天就该养成。5.2 数据分析和可视化250 条数据看起来少但足够支撑一次小型的探索性分析了。把 CSV 读进pandas可以统计评分的分布区间、各年代电影的数量、评价人数和评分之间的相关性。想做可视化的话pyecharts的柱状图、散点图都可以直接画出来。最高评分电影是哪一部这类问题在数据落盘后就成了一行代码的事。5.3 定时更新任务电影排名会随着用户打分不断变化定期更新数据能捕捉到排名的动态波动。在 Linux 上可以用cron设置定时任务在 Windows 上用任务计划程序让脚本每周固定时间运行一次。每次跑完后对比新旧数据就能知道哪些电影排名上升了、哪些跌出了榜单。这种动态追踪才真正把一次性的爬虫变成了一个有持续生命力的工具。我个人在实际操作中最喜欢这个项目的点在于它短小但五脏俱全覆盖了爬虫最核心的骨架逻辑。你写完它、跑通它、加完扩展功能之后再看其他爬虫项目时脑子里会自动浮现出请求、解析、存储、异常处理这几条主线思路会清晰很多。如果你拿到的页面更复杂、数据是 JavaScript 动态加载的那就要研究Selenium或Playwright了这是另一个量级的话题建议先把静态页面这一关彻底打通再说。
阅读完成 · 觉得有帮助?
咨询建站