豆瓣短评影评爬虫实战:Python请求、XPath解析与反爬避坑指南
简介一套Python爬虫源码针对豆瓣短评与影评采集场景设计适合网络爬虫初学者、数据分析人员以及需要批量获取用户评论进行调研的开发者。代码按目标拆分为两个独立脚本短评脚本抓取序号、用户、地点、推荐度、点赞数、时间、影评内容与用户主页影评脚本则进一步覆盖影评id、IP地址、星级、标题、正文、图片、赞同/反对/收藏/转发/回应等完整字段字段设计较为细致。资源包共2个文件均为.py脚本整体仅5KB轻量易读便于快速部署和二次开发。目前已有116人浏览学习。阅读代码即可掌握requests请求网页、BeautifulSoup/lxml解析HTML、提取字段并保存数据的完整流程同时理解动态加载与访问频率限制的应对思路。数据落地后可直接对接Pandas等工具做清洗分析为舆情监控或市场研究提供素材。1. 豆瓣短评影评爬虫一套能直接跑的 Python 代码包先解决能不能用很多人第一次写豆瓣爬虫是被“分析某部片子的观众口碑”这个需求逼出来的。手动翻短评复制到 Excel 太慢自己写又不断在微信状态 403、翻页为空、时间字段乱码上翻车。这份代码包解决的就是这个事短评和影评两套抓取入口、字段提取、时间规范化、去重和断点续爬都有现成实现拉到本地改个电影 ID 就能跑出 CSV。适合做口碑分析的数据运营、需要数据集写课程设计的在校生以及想批量研究影评文本的自媒体创作者。它不解决“分析”本身解决的是“把数据干净地拿到手里”这段路。2. 请求与解析从 URL 构造到字段提取的完整链路2.1 短评和影评的 URL 结构差异两个入口、一套调度逻辑豆瓣网页版的短评列表和影评列表是两个不同入口但分页参数共用start这就是整套代码能统一调度的基础。短评页路径是/comments影评页是/reviews二者返回的 HTML 结构差异很大所以解析函数要分开写但请求层完全可以共用一套。def build_url(kind: str, movie_id: str, start: int) - str: base https://movie.douban.com/subject/{movie_id}/ if kind comment: return base.format(movie_idmovie_id) fcomments?start{start}limit20 elif kind review: return base.format(movie_idmovie_id) freviews?start{start} raise ValueError(kind 只能是 comment 或 review)这段代码的核心是把短评和影评的 URL 差异收口到一个函数里。start是分页偏移量短评接口每次固定返回 20 条影评接口也是 20 条但 URL 里不显式带limit参数服务端用默认值。实际抓取时按start递增来翻页0、20、40一直到解析不到新的评论项为止。参数上需要注意两点。第一电影 ID 是豆瓣详情页地址里/subject/后面那一串纯数字不是tt开头的 IMDb 编号这串 ID 从网页地址栏复制就好。第二这个函数只负责拼 URL真正的请求头、代理、重试逻辑在下层统一处理这样后面想换成异步请求时只需要替换请求层不需要动 URL 构造。2.2 请求头与 XPath 字段提取从 HTML 到结构化数据的最后一步豆瓣对请求头的校验很敏感尤其是User-Agent。空 UA 的请求基本会被直接拒掉带浏览器 UA 但缺少Referer的请求也有概率命中风控。一套稳的请求头至少包含下面这几项。def build_headers(referer: str) - dict: return { 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: referer, Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Connection: keep-alive, }其中Referer最好填当前页面的详情页地址也就是https://movie.douban.com/subject/{movie_id}/模拟从详情页点进短评列表的真实路径。Accept-Language保持中文优先否则部分时间字段可能渲染成英文格式增加解析负担。拿到 HTML 后用 lxml 的 XPath 提取字段短评和影评各写一套解析函数。短评页每个评论项都挂在classcomment-item的节点上from lxml import html def parse_comments(page_source: str) - list: doc html.fromstring(page_source) items doc.xpath(//div[contains(class,comment-item)]) result [] for item in items: result.append({ user: item.xpath(.//a[contains(class,name)]/text()), rating: item.xpath(.//span[contains(class,rating)]/title), time: item.xpath(.//span[contains(class,comment-time)]/title), content: item.xpath(.//span[contains(class,short)]/text()), votes: item.xpath(.//span[contains(class,vote-count)]/text()), }) return result这段解析逻辑有几个故意的细节。rating取的是title而不是文本内容因为评分是星星图标只有title属性里才有“力荐”“推荐”“还行”这些可读文字。time取的是title因为页面显示的是相对时间完整时间戳存在title属性里。votes取的是文本内容也就是“有用”数字。影评列表页结构与短评不同解析函数要单独写def parse_reviews(page_source: str) - list: doc html.fromstring(page_source) items doc.xpath(//div[contains(class,review-item)]) result [] for item in items: result.append({ title: item.xpath(.//h2/a/text()), author: item.xpath(.//a[contains(class,name)]/text()), time: item.xpath(.//span[contains(class,main-meta)]/text()), useful: item.xpath(.//span[contains(class,useful_count)]/text()), }) return result影评列表页只展示摘要和“有用”数不展示全文想拿全文需要逐个进入影评详情页再抓一次。对于做文本分析来说列表页的摘要通常不够所以实际使用中我给影评部分加了详情页抓取逻辑先用列表页拿到影评的详情页链接再请求详情页提取正文。这个流程在代码包里是独立的fetch_review_detail()函数外面套了同一套限速和重试逻辑。3. 限速与重试让爬虫从“能跑”变成“跑得久”3.1 请求频率控制时间间隔与随机抖动豆瓣对短评接口的频控比影评接口严格得多尤其是热门电影的短评页连续快速翻页很容易触发风控。最常见的表现不是直接拒绝而是返回一个验证页面状态码是 200但页面里根本没有评论数据。import random import time def rate_limited_get(session, url, base_interval3.0, jitter1.5): sleep_time base_interval random.uniform(0, jitter) time.sleep(sleep_time) resp session.get(url, timeout10) print(fGET {url} - {resp.status_code} (sleep {sleep_time:.1f}s)) return resp这里的base_interval是基础间隔jitter是随机抖动的上限。固定间隔容易被识别出机器行为加上随机抖动后请求时间分布更接近人工浏览。参数设置上短评接口建议base_interval4.0、jitter2.0影评接口可以放宽到base_interval2.5、jitter1.0。如果你只是抓几十条做测试间隔可以适当调小但长跑任务建议保守一点。提示频率控制不是越快越好。豆瓣风控对 IP 维度的连续请求数有统计短时间冲刺后封禁往往是以小时计的重启脚本也不会立刻恢复。3.2 失败重试与 UA 池指数退避与身份轮换单次请求被拒不代表代码有 bug可能是当前 UA 已经被标记。我在代码包里加了重试机制配合一个简单的 UA 池做轮换import requests USER_AGENTS [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, 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, Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36, ] def get_session(): session requests.Session() session.headers.update({ User-Agent: random.choice(USER_AGENTS), Accept-Language: zh-CN,zh;q0.9, }) return session def fetch_with_retry(session, url, retries3, backoff2.0): for attempt in range(1, retries 1): try: resp session.get(url, timeout10) if resp.status_code 200: return resp print(f异常状态码 {resp.status_code}第 {attempt} 次重试) except requests.RequestException as e: print(f请求异常: {e}第 {attempt} 次重试) time.sleep(backoff ** attempt) return Nonefetch_with_retry用指数退避策略第一次重试等 2 秒第二次等 4 秒第三次等 8 秒。间隔随时间指数增长避免在 IP 已被风控的情况下继续高频打请求地址。重试次数和退避基数是两个关键参数——短评接口建议retries5、backoff2.0影评接口可以降到retries2毕竟影评总量少没必要反复撞墙。UA 轮换的频率要克制同一会话内不要每次请求都换 UA。一个自然的行为模式是“一个页面会话保持同一个 UA翻完一部电影的页再换”频繁切换反而像脚本特征。4. 数据清洗与去重短评和影评的时间格式坑4.1 时间字段规范化短评的“刚刚”和影评的时间戳豆瓣短评和影评的时间渲染方式差异很大这是我实际抓取时第一次翻车的地方。短评列表里的时间经常是“刚刚”“5 分钟前”“昨天”这种相对时间而影评时间通常是2024-01-15 22:34这种完整时间戳。如果不做统一处理落库的数据根本无法按时间排序。import re from datetime import datetime, timedelta def normalize_time(raw: str) - str: if not raw: return raw raw.strip() now datetime.now() if raw 刚刚: return now.strftime(%Y-%m-%d %H:%M:%S) m re.search(r(\d)\s*分钟前, raw) if m: return (now - timedelta(minutesint(m.group(1)))).strftime(%Y-%m-%d %H:%M:%S) m re.search(r(\d)\s*小时前, raw) if m: return (now - timedelta(hoursint(m.group(1)))).strftime(%Y-%m-%d %H:%M:%S) m re.search(r(\d)\s*天前, raw) if m: return (now - timedelta(daysint(m.group(1)))).strftime(%Y-%m-%d %H:%M:%S) for fmt in (%Y-%m-%d %H:%M:%S, %Y-%m-%d %H:%M, %Y-%m-%d): try: return datetime.strptime(raw, fmt).strftime(%Y-%m-%d %H:%M:%S) except ValueError: continue return raw这个函数把常见的豆瓣时间表达收口成一种标准格式。处理顺序上先把“分钟前”“小时前”“天前”这类相对时间转成绝对时间再尝试用三种常见格式解析完整时间戳。%Y-%m-%d %H:%M:%S是影评详情页的格式%Y-%m-%d %H:%M是影评列表页的格式%Y-%m-%d是短评页的兜底格式。这里有个值得注意的细节短评里的“昨天”应该单列处理因为它的语义比“1 天前”更精确——如果当前时间是凌晨 1 点“昨天”可能意味着 30 小时前而代码跑在白天时通常只差 10 多小时。我在实际代码里加了一个变量记录脚本启动时间所有相对时间都以启动时间为基准计算避免一次长任务跑到凌晨后时间基准错乱。4.2 去重与断点续爬重复抓两次也不会翻车短评和影评都可能因为翻页偏移、网络重试而重复抓取。最简单可靠的去重方式是“用户 内容”的组合哈希这个方案在豆瓣场景下比用 ID 去重更稳因为列表页根本没有暴露评论 ID。import hashlib def dedup_key(user: str, content: str) - str: text f{user}|{content}.strip() return hashlib.md5(text.encode(utf-8)).hexdigest()这个dedup_key生成的是一个 32 位十六进制字符串作为每条记录的唯一标识。短评用“用户 评论内容”构建去重键影评用“作者 标题”构建。存入 CSV 前先检查这个键是否已存在于已抓取集合中存在就跳过。断点续爬是配套的去重逻辑。我习惯把已成功抓取的start偏移量写到本地 JSON 文件里每完成一页更新一次。脚本重启时先读这个文件从上次停止的位置继续翻页而不是从头开始。这个设计在你抓一部几千条短评的热门电影时能省掉大量重复请求也大幅降低被风控盯上的概率。import json import os CHECKPOINT_FILE checkpoint.json def load_checkpoint(kind: str) - int: if os.path.exists(CHECKPOINT_FILE): with open(CHECKPOINT_FILE, r, encodingutf-8) as f: data json.load(f) return data.get(kind, 0) return 0 def save_checkpoint(kind: str, start: int): data {} if os.path.exists(CHECKPOINT_FILE): with open(CHECKPOINT_FILE, r, encodingutf-8) as f: data json.load(f) data[kind] start with open(CHECKPOINT_FILE, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, indent2)load_checkpoint读取出上次翻页进度save_checkpoint每抓完一页就写回。这里有个使用习惯要养成抓取任务完成后记得删除或重命名 checkpoint 文件否则下次换一部电影抓时脚本会从之前的偏移量继续导致数据错位。5. 避坑排查五个高频报错与对应解法5.1 请求返回 403 或 418不是代码问题是身份问题现象脚本打印出403 Forbidden或418 Im a teapot页面内容完全不是豆瓣页面。原因请求头不完整或 UA 被识别为爬虫418 是豆瓣风控系统主动拒绝的姿态码。解决先在get_session()里带上完整请求头再发请求确认Referer指向当前电影的详情页。如果确认请求头没问题还报 418停止脚本至少 10 分钟再跑不要立刻换代理硬冲短时间内换 IP 继续抓只会扩大封禁面。5.2 第二页开始数据为空start 偏移与 limit 参数缺失现象短评第一页抓到了 20 条第二页返回空列表。原因翻页时没有带limit20或者start递增步长和实际单页条数不一致。解决先手动打开第二页地址确认 URL 里start20limit20是否完整再看代码里start是否按 20 递增。有些电影短评不到 20 条时第二页本身就是空的这是正常现象不是 bug。5.3 时间字段解析报错“刚刚”和“昨天”没覆盖现象datetime.strptime抛ValueError: time data 昨天 does not match format。原因normalize_time只处理了“X 分钟前”“X 小时前”“X 天前”没处理“昨天”“前天”这类语义化时间。解决在函数开头加一条if raw 昨天: return (now - timedelta(days1)).strftime(...)同理处理“前天”。如果raw字符串带了 HTML 标签先re.sub(r[^], , raw)清洗再解析。5.4 XPath 提取结果是空列表节点层级和空白文本干扰现象item.xpath(.//span/text())返回空数组但浏览器 DevTools 里明明能看到文字。原因XPath 路径写得太窄或者目标文本在子元素里不在当前节点直接子级。解决先用doc.xpath(//div[contains(class,comment-item)])确认评论项节点能选中再逐步缩短 XPath 路径试探。对短评内容我最后用的是.//span[contains(class,short)]/text()这个表达式能跨过一层子节点拿到文本。5.5 请求一直 200 但页面里没有评论触发了验证码中间页现象状态码是 200但 HTML 里解析不到comment-item页面标题变成“有异常请求从您的 IP 发出”。原因频率过高触发风控豆瓣返回了一个验证中间页而不是直接拒绝请求。解决立刻停脚本等 20 到 30 分钟把base_interval调大一倍再重跑。同时检查normalize_time是否在循环里频繁调用时间解析本身不耗时但如果你在每次请求里重新读取 checkpoint 文件IO 操作叠加会让整体请求节奏更快也容易触发风控。6. 验证抓取结果三个维度的核对方法与批量任务改造代码跑通只是第一步把数据交给别人之前我会强制自己走三遍验证。第一遍是数量核对打开豆瓣页面翻到最后一页看总评论数再对比本地 CSV 的总行数误差超过 5% 基本可以判断翻页中断了。第二遍是字段完整性检查用 pandas 读入 CSV 后统计每列的空值比例rating列允许少量空值因为部分短评不带星级但content列空值超过 1% 就说明解析漏了数据。第三遍是时间分布合理性把normalize_time输出的时间按天做直方图正常数据分布是连续的如果某一天数据量突降到 0多半是翻页跳了一段。最后聊批量任务的改造思路。代码包里目前是单线程串行抓取一部电影跑完再跑下一部。如果想一次抓十部电影最稳妥的做法是外层套一个电影 ID 列表内层仍然保持单部电影的顺序翻页每部电影之间加 60 秒以上的冷却时间。多线程改造不是不能做但你要自己控制全局请求速率——我见过有人用ThreadPoolExecutor把间隔缩短到 0.5 秒跑了十分钟整个办公室的公网 IP 都被封了这个教训让我从那以后每次跑批量任务都先写一个请求计数日志每 50 次请求输出一次平均间隔确保整个任务的请求节奏在可控范围内。希望这些踩坑记录能帮你在拿到这份代码包后少走几步弯路先跑通再跑稳最后再跑快。本文还有配套的精品资源点击获取