☰
豆瓣短评影评爬虫实战:Python请求、XPath解析与反爬避坑指南
2026/10/16 19:22:47 网站建设 项目流程

简介:一套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_id=movie_id) + f"comments?start={start}&limit=20" elif kind == "review": return base.format(movie_id=movie_id) + f"reviews?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/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Connection": "keep-alive", }

其中Referer最好填当前页面的详情页地址,也就是https://movie.douban.com/subject/{movie_id}/,模拟从详情页点进短评列表的真实路径。Accept-Language保持中文优先,否则部分时间字段可能渲染成英文格式,增加解析负担。

拿到 HTML 后用 lxml 的 XPath 提取字段,短评和影评各写一套解析函数。短评页每个评论项都挂在class="comment-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_interval=3.0, jitter=1.5): sleep_time = base_interval + random.uniform(0, jitter) time.sleep(sleep_time) resp = session.get(url, timeout=10) print(f"GET {url} -> {resp.status_code} (sleep {sleep_time:.1f}s)") return resp

这里的base_interval是基础间隔,jitter是随机抖动的上限。固定间隔容易被识别出机器行为,加上随机抖动后请求时间分布更接近人工浏览。参数设置上,短评接口建议base_interval=4.0、jitter=2.0,影评接口可以放宽到base_interval=2.5、jitter=1.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;q=0.9", }) return session def fetch_with_retry(session, url, retries=3, backoff=2.0): for attempt in range(1, retries + 1): try: resp = session.get(url, timeout=10) 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 None

fetch_with_retry用指数退避策略:第一次重试等 2 秒,第二次等 4 秒,第三次等 8 秒。间隔随时间指数增长,避免在 IP 已被风控的情况下继续高频打请求地址。重试次数和退避基数是两个关键参数——短评接口建议retries=5、backoff=2.0,影评接口可以降到retries=2,毕竟影评总量少,没必要反复撞墙。

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(minutes=int(m.group(1)))).strftime("%Y-%m-%d %H:%M:%S") m = re.search(r"(\d+)\s*小时前", raw) if m: return (now - timedelta(hours=int(m.group(1)))).strftime("%Y-%m-%d %H:%M:%S") m = re.search(r"(\d+)\s*天前", raw) if m: return (now - timedelta(days=int(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", encoding="utf-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", encoding="utf-8") as f: data = json.load(f) data[kind] = start with open(CHECKPOINT_FILE, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2)

load_checkpoint读取出上次翻页进度,save_checkpoint每抓完一页就写回。这里有个使用习惯要养成:抓取任务完成后,记得删除或重命名 checkpoint 文件,否则下次换一部电影抓时,脚本会从之前的偏移量继续,导致数据错位。

5. 避坑排查:五个高频报错与对应解法

5.1 请求返回 403 或 418:不是代码问题,是身份问题

现象:脚本打印出403 Forbidden或418 I'm a teapot,页面内容完全不是豆瓣页面。原因:请求头不完整或 UA 被识别为爬虫,418 是豆瓣风控系统主动拒绝的姿态码。解决:先在get_session()里带上完整请求头再发请求,确认Referer指向当前电影的详情页。如果确认请求头没问题还报 418,停止脚本至少 10 分钟再跑,不要立刻换代理硬冲,短时间内换 IP 继续抓只会扩大封禁面。

5.2 第二页开始数据为空:start 偏移与 limit 参数缺失

现象:短评第一页抓到了 20 条,第二页返回空列表。原因:翻页时没有带limit=20,或者start递增步长和实际单页条数不一致。解决:先手动打开第二页地址确认 URL 里start=20&limit=20是否完整,再看代码里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(days=1)).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 次请求输出一次平均间隔,确保整个任务的请求节奏在可控范围内。希望这些踩坑记录能帮你在拿到这份代码包后少走几步弯路,先跑通,再跑稳,最后再跑快。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询