打开豆瓣电影排行榜的页面按下 F12你会看到一个“有趣”的现象榜单数据根本不在 HTML 里。页面上明明白白显示着排名、片名、评分但你在源代码里搜“肖申克”三个字搜不到任何结果。这就是我在做“豆瓣电影排行榜数据抓取高级”这个项目时遇到的第一道坎也是这个标题里“高级”二字的第一个注释——你要抓的网站已经不是十年前的静态页面了前后端分离早就成了默认配置。这个项目适合已经写过几个简单爬虫、熟悉 requests 和基本解析库、想要进阶到“能应对真实生产环境”阶段的开发者。它解决的也不是“能不能抓到”的问题而是“怎么稳定地抓、高效地抓、不把 IP 封掉地抓”的问题。我从拿到需求到最终跑通并发抓取前后折腾了两天半中间踩了不少坑把这些过程完整记录下来相信对你有用。1. 拿到需求别急着写代码先搞清楚页面怎么工作很多人写爬虫有个坏习惯拿到 URL 直接 requests.get然后对着结果一通解析发现不对再回头找原因。我在这个项目里犯过同样的错后来养成的习惯是先花半小时把页面“看明白”再花十分钟把代码写完。1.1 榜单页面的实际结构豆瓣电影排行榜泛指 Top250 和分类榜单两个入口。我这次主要处理的是分类榜单页它支持按类型爱情、喜剧、动作、科幻等切换每个类型下面默认展示 100 部电影。页面 URL 是https://movie.douban.com/chart这个入口进去的但真正决定榜单数据的是一组 URL 参数type表示电影类型编号interval_id表示评分区间start表示起始位置limit表示单次条数。用开发者工具切换到 Network 面板刷新页面在 XHR 分类下能看到一个名为top_list的请求返回的是 JSON 数组。每个数组元素对应一部电影的完整数据包括排名、片名、主创、年份、评分、评分人数、剧情简介等多个字段。这就是前后端分离的典型形态浏览器先加载空页面JS 脚本再异步请求 JSON 数据填充到 DOM 里。咱们爬虫要做的事就是跳过中间那层 JS 渲染直接请求那个 JSON 接口。这个发现直接改变了整个项目的技术路线。原本如果只能拿到 HTML就得用 BeautifulSoup 去解析 DOM还得处理各种标签嵌套现在拿到的是结构化 JSON用标准库json模块就能解析代码量至少少一半稳定性还更高。1.2 明确要抓的字段与数据范围把需求拆开我这次要抓的数据分为两批次。第一批次是榜单本身的列表数据每个电影包含排名、电影名、导演、主演、年份、国家/地区、类型、评分、评价人数。第二批次是详情页补充数据包括电影片长、剧情简介、每个星级的分布比例。第二批次需要进一步请求每个电影的子页面复杂度上了一个台阶。数据范围上我要抓的是 Top250 和分类榜单前 100 名。这里有个细节很多人会忽略分类榜单接口的type参数是数字编号你得先去页面上确认每个类型对应的编号值。我当时是手动在页面切换类型从请求参数里逐个读出来的动作类型是 11喜剧类型是 24剧情类型是 11 对应的其实是另一种分类方式。这里特别提醒不要凭经验猜编号要学会从实际请求里获取因为说不定哪次改版编号就变了。字段设计阶段还要想清楚数据最终怎么用。我的话准备存成 CSV所以提前把字段名对齐做了个简单的数据字典。2. 发现 JSON 接口才是“高级”分水岭很多人做爬虫做到一半就卡住了问题不是代码写不出来而是根本不知道去哪儿拿数据。这一章我详细讲讲我是怎么定位到 JSON 接口的以及拿到接口之后怎么把请求伪装得让服务器看不出来你是爬虫。2.1 从开发者工具里找真实接口具体操作路径是这样的打开榜单页按 F12 进入开发者工具切到 Network网络标签页勾选 XHR/Fetch 过滤条件然后刷新页面。这时候你会看到页面加载过程中发起的所有异步请求。挨个点开看响应内容哪个请求返回的是电影数据哪个就是我们要的接口。我找到的接口路径形如https://movie.douban.com/j/chart/top_list注意这个 URL 里有个/j/路径这是豆瓣内部约定俗成的“JSON 数据接口”标识。看到这种结构基本就能判断出返回体是 JSON 了。参数里有type11表示动作分类interval_id100:90表示当前看的是 90 分以上的区间段action是空字符串start0表示从第一部开始limit20表示一次返回 20 条。这里有个极其重要的技巧limit20不是写死的上限。我实测过直接改成limit100就能一次性拿到整个榜单 100 条数据不用翻页。这也是很多教程不会告诉你的细节——你完全可以用更少的请求次数拿到同样多的数据对服务器更友好对自己的效率也更高。类似的站点你都可以试试手动调大limit参数往往会有惊喜但注意不要调到离谱的量级比如一次请求 1000 条有明显的“探测”嫌疑。2.2 请求头伪装别让服务器第一眼就认出你拿到接口之后我直接用 Python 的 requests 库请求了一下结果返回状态码 418——这是一个很有意思的状态码字面意思是“我是茶壶”实际含义是“服务器拒绝响应你的请求”。我当时的第一个反应这是反爬机制启动了问题出在请求头。看一下浏览器实际发起请求时带的 Headers和爬虫默认带的 Headers差异是非常明显的。最要紧的四个字段User-Agent标识客户端类型必须伪装成真实的浏览器版本Referer标识你从哪个页面跳转过来的这里应该填榜单页的 URLHost标识目标主机requests 会自动带上不用手动设置Accept标识你能接受什么类型的响应JSON 接口的话建议带application/json。除了这些基础字段新版 Chromium 内核浏览器还会带一个特殊的Sec-Fetch系列头包括Sec-Fetch-Dest、Sec-Fetch-Mode、Sec-Fetch-Site。这三个字段告诉服务器“我是浏览器发起的页面导航请求”还是“我是脚本发起的跨域请求”。手动在代码里构建请求时加上这一组字段能让服务端的检测逻辑更倾向于认为你是一个正常的浏览器用户。我给每个请求都配上了一份完整的 Headers 模板包括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、Referer: https://movie.douban.com/chart这些字段。实测下来加上这个模板之后单次请求的成功率从最初的约 30% 提升到了接近 100%效果立竿见影。3. 请求模块的工程化会话、重试与限速爬虫界有个共识写一个“能用”的爬虫一小时就够写一个“能稳定跑一整天不挂”的爬虫得要一天。这中间的差距就在请求层的工程化程度上。3.1 用 Session 接管 Cookie 与连接复用requests 库的Session对象和直接requests.get()之间最大的区别是 Session 会自动管理 Cookie同时复用底层的 TCP 连接。对于这种需要频繁请求同一个域名的场景连接复用能显著降低延迟和服务器负载。我之前做过对比实验用同样的 100 个请求Session 模式比裸请求模式整体耗时能下降 30% 左右因为每次连接握手的过程被省掉了。具体到豆瓣这个项目Session 还有一个隐藏功能它在第一次请求后会存下服务器下发的 Cookie后续请求自动带上。虽然豆瓣的分类榜单接口不强制要求登录状态但有些反爬策略会校验 Cookie 中特定的字段值。用 Session 统一管理这些信息比每次手动构造请求头省心得多。另一个值得做的事是给 Session 挂载 HTTPAdapter设置连接池大小和重试策略。我当时的配置长这样import requests from requests.adapters import HTTPAdapter session requests.Session() session.headers.update({ 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, Referer: https://movie.douban.com/chart, Accept: application/json, text/plain, */*, Sec-Fetch-Dest: empty, Sec-Fetch-Mode: cors, Sec-Fetch-Site: same-origin }) adapter HTTPAdapter(pool_connections10, pool_maxsize10, max_retries3) session.mount(https://, adapter)注意HTTPAdapter里的max_retries3它解决的是连接层面的异常重试比如网络闪断、TCP 握手超时。但 HTTP 状态码层面的错误比如 403、418、500max_retries是不管的必须自己在代码里写异常处理逻辑。3.2 指数退避重试与超时控制处理 HTTP 状态码错误的核心思想是“指数退避”——第一次失败后等 2 秒重试第二次失败后等 4 秒第三次等 8 秒等待时间按指数增长。为什么要这样设计因为服务器端如果检测到短时间内有大量请求会启动限流你的请求会被暂时标记为异常。如果你立刻重试只会加重被限流的概率进入恶性循环。退避一段时间再试给服务器的限流窗口一个“冷却时间”反而更容易恢复。一个通用的小型重试机制可以封装成函数import time import random import logging logger logging.getLogger(__name__) def fetch_with_retry(session, url, params, max_retries3, timeout8): for attempt in range(1, max_retries 1): try: resp session.get(url, paramsparams, timeouttimeout) if resp.status_code 200: return resp.json() elif resp.status_code in (403, 418, 429): wait_time 2 ** attempt random.uniform(0, 1) logger.warning(状态码 %s第 %s 次重试等待 %.1f 秒, resp.status_code, attempt, wait_time) time.sleep(wait_time) else: logger.error(未处理的 HTTP 状态码%s, resp.status_code) return None except requests.exceptions.Timeout: wait_time 2 ** attempt random.uniform(0, 1) logger.warning(请求超时第 %s 次重试等待 %.1f 秒, attempt, wait_time) time.sleep(wait_time) except requests.exceptions.ConnectionError: logger.error(连接失败检查网络或目标站点可达性) return None return None这个函数里我故意加了random.uniform(0, 1)的抖动时间目的是让每次重试的等待时间不完全相同避免多个并行任务在同一时间点同时重试造成“惊群效应”。两个并发爬虫进程如果同时间等待 8 秒然后同时发起请求那本质上还是突刺服务器照样识别为异常。加随机抖动后请求的时间分布更接近人类操作的自然状态。超时参数timeout8也很关键。不设置超时的话网络状况差时请求可能挂起几十秒甚至几分钟。8 秒是我在多次实测后取的平衡点正常情况下榜单接口响应时间不到 500 毫秒8 秒足以覆盖 99% 的正常情况超过这个值基本认定有问题。4. 数据解析与字段增强请求层搞定后接下来就是数据的清洗与扩展。JSON 接口返回的数据虽然是结构化的但距离“可分析的干净数据”还有几步路要走。4.1 JSON 解析与数据类型转换接口返回的每条电影记录大概是这样的结构rank是排名title是片名score是评分vote_count是评分人数release_date是上映年份types是类型列表regions是地区列表actors是主演列表directors是导演列表url是详情页链接。直接拿来用会有几个问题。第一types、regions、actors都是数组如果直接存 CSV 会出现列表的 Python 语法表示例如[动作, 犯罪]不利于后续分析。我自己写了个小工具函数把这些列表字段用|符号拼接成字符串存成动作|犯罪这种形式方便任何时候拆回数组。第二release_date是字符串类型的日期我按-分割只保留年份部分。第三score和vote_count在 JSON 里是数字类型但某些特殊情况下可能出现字符串写解析时用float()和int()做一次强制转换顺便处理异常。还有个细节很容易踩坑榜单接口返回的rank是全局排名不是当前类型内的排名。如果你抓取的是分类榜单要注意看这个逻辑对不对。我当时核对了一下豆瓣分类榜单的rank字段其实是按类型内重新排的但 Top250 则不同。最好的方式是抓完数据后抽样比对页面确认字段语义再落库。4.2 关联详情页补齐关键字段榜单列表数据里没有电影片长、没有剧情简介、没有星级分布比例。这些数据需要进一步请求电影详情页。详情页入口就是列表数据里的url字段形如https://movie.douban.com/subject/1292052/。这里我遇到过一个坑详情页也是前后端分离的直接请求详情页 URL返回的 HTML 脚本里其实已经内嵌了完整的 JSON 数据源业内叫application/ldjson或window.__DATA__注入。我一开始想用正则去匹配那段 JSON后来发现页面结构里有一个script typeapplication/ldjson的标签内容就是一个大的 JSON-LD 结构。用 BeautifulSoup 先定位这个标签再把它包在 JSON 里解析比自己写正则稳定十倍。这个思路对所有基于模板渲染的页面都适用以后遇到别的站点也能复用。详情页里能拿到的补充字段包括片长duration、剧情简介summary、以及aggregateRating结构里的豆瓣星级分布。星级分布一般以percent字段表示五分制里的 5 星到 1 星百分比我统一转成浮点数并验证一下五项合计是否接近 100%。详情页请求的量是榜单的 N 倍所以一定要复用第 3 章的重试函数和限速策略。我的策略是每请求一个详情页之前休息 1 到 2 秒这个节奏能保证在 200 部电影的量级下不触发反爬。5. 并发抓取提升效率也要管住手单线程抓 200 部电影每部间隔 1 秒加上请求时间大约需要 4 到 5 分钟。听起来还能接受但如果你把电影数量扩大到全站几万部单线程就明显不够用了。并发是“高级”爬虫绕不开的话题。5.1 线程池的正确打开方式Python 里做并发请求有几种方案多线程、多进程、异步 IO。对于这种 IO 密集型的任务多线程是性价比最高的方案——线程切换开销小代码简单对 requests 这种同步库友好。我用的工具是标准库concurrent.futures.ThreadPoolExecutor核心代码大概是这样的from concurrent.futures import ThreadPoolExecutor, as_completed def fetch_movie_detail(session, movie): detail_url movie[url] params {} data fetch_with_retry(session, detail_url, params) return enrich_movie_data(movie, data) with ThreadPoolExecutor(max_workers5) as executor: futures [ executor.submit(fetch_movie_detail, session, movie) for movie in movie_list ] for future in as_completed(futures): result future.result() result_list.append(result)线程数我最终定的是 5不是随手拍的。来看这个推导过程单个请求的耗时约 0.5 秒线程数 5 意味着单位时间内有 5 个请求在并发执行共 200 个请求大约需要 20 秒。但我还要考虑服务器的承受能力——如果线程数开到 2020 个请求同时打到服务器上就足够触发反爬策略了因为正常用户不可能在一秒内并发打开 20 个页面。我的经验值是单线程 1 个并发5 个线程 每线程之间加一个基本延迟是最稳定的组合。5.2 限速与尊重爬虫的边界感这里想多说一句关于爬虫礼仪的实话。很多人讲爬虫技术的时候喜欢强调“可绕过什么”“可隐藏什么”但我个人的实践原则是爬虫的目标是数据不是攻破服务器。控制请求速率不抢服务器资源不碰需要登录才能访问的数据不去高频请求触发告警这些不是怂是让你这个爬虫能活得久一点。我抓详情页时用的限速策略是信号量 固定延迟。信号量保证同一时间只有 5 个请求在飞固定延迟保证每个请求之间至少有 0.5 秒的间隔。实测这个节奏下跑了 300 个请求没有触发任何反爬提示而一开始用 20 并发不加延迟的版本跑到第 50 个请求就把 IP 限流了后面整整等了 20 分钟才恢复。这是最直观的对比也让我彻底放弃了“多快好省”的想法。还有一个点容易被忽略爬虫要处理的是“公开数据”但公开数据也有版权和使用边界。排行榜数据用于个人学习分析没问题但如果是商业用途建议提前评估合规风险不要拿来直接做盈利产品。6. 高频踩坑排查表附实操笔记写代码不踩坑是不可能的踩了坑能快速定位才是老手和新手的区别。我把这次项目里遇到的典型问题整理成一个排查表你在做类似项目时可以直接对照。6.1 常见异常速查表现象可能原因排查步骤与解决方案返回状态码 418请求头缺失或过于“脚本化”核对 User-Agent、Referer、Sec-Fetch 系列头确认是 Session 发起的请求返回状态码 403IP 被临时限流立即降低并发增加间隔检查是否有其他进程在同时抓取必要时休息 10 分钟返回状态码 500服务器内部错误跟你不一定有关先等 5 秒重试一次连续 3 次 500 就跳过该条数据并记录日志返回空列表参数错误或start超出数据总量检查type、interval_id、start、limit参数组合是否正确请求超时网络波动或服务器响应慢确认 timeout 设置检查代理配置重试函数是否生效JSON 解析报错响应体不是 JSON打印响应文本前 200 字符定位问题确认请求 URL 是否指向 JSON 接口中文乱码编码声明缺失或错误确认响应头中 charsetrequests 一般在resp.encoding中自动识别手动设置resp.encoding utf-86.2 几个容易忽略的细节第一个细节是打印日志。我在项目初期直接 print 一切跑起来之后满屏刷数据一旦报错根本不知道是哪个请求出的问题。后来改用logging模块每个成功请求记录一下title每个失败请求记录一下 URL 和状态码排查问题时一目了然。很多新手不屑于做这件事但实际工程里它比代码本身更值钱。第二个细节是数据的去重。榜单数据有时候会因为网络重试导致同一部电影被写入两次。我在写 CSV 前加了一层去重逻辑用url字段作为唯一标识重复的直接丢掉。第三个细节是增量更新的思路。榜单每周会有变动与其每次全量重抓不如把上一次抓取的结果存成本地文件只对新增或排位变化的数据做更新。我这次没在项目里做这个功能但后续迭代的方向是加一个last_updated字段按时间戳做差异比对这算是给爬虫加上“记忆”能力。第 4 个经验是关于机器人的纪律性。我在并发调试过程中发现即使加了延迟如果浏览器开着豆瓣页面同时爬虫也在抓偶尔也会触发反爬。原因是 Web 应用会统计同 IP 的请求频率你开着页面时浏览器本身会定时发一些保活请求再叠加爬虫的并发瞬时频率就超标了。这个经验让我养成了调试爬虫时关掉浏览器页面的习惯。7. 最后想分享的一点体会这个项目做完之后我最大的收获不是学会了怎么抓豆瓣而是摸清了“爬虫版图”里那一小块拼图的结构怎么判断页面的数据加载方式怎么找出真正的数据接口怎么把请求做得像一个真人怎么用并发提速但不越过红线。这些能力可以迁移到任何公开数据类网站因为你已经知道“套路”了。再有爬虫写完之后一定要想清楚数据的去向和边界。我这次抓的数据用在了自己写的一部电影数据分析报告里统计了近十年各类型电影的评分走势发现喜剧片的平均分比动作片高悬疑片高分占比更大这个结果挺有意思的也侧面验证了抓到的数据质量是可靠的。如果你也用这套方案抓到了排行榜数据可以往“历年榜单变化趋势”“高分电影的国家/地区分布”等方向分析一下基本上是现成的数据分析完会有不错的视野提升。祝你把代码跑起来的时候一次就拿到干净整齐的数据。