
1. 为什幺要抓豆瓣短评一次真实的数据需求做内容分析或者用户调研的时候短评是特别有信息密度的一类数据。一个普通的短评页单条评论里就同时藏着用户昵称、地理位置、评分、发布时间、内容、点赞数把这些字段组合在一起可以回答很多问题评分分布是否合理、哪个城市的用户更活跃、高赞评论到底在夸什幺还是在骂什幺、短评发布的时间曲线什幺时候最密集。我最初做这个项目是因为要分析某部电影的观众画像。手动一页一页复制短评显然不现实几百条数据会浪费一下午而且容易漏字段。于是干脆写一个爬虫把发布城市、加入时间、点赞人数、用户名、评分、发布时间、短评这 7 个字段完整抓下来再导进表格做统计。整个项目做下来花在分析页面结构上的时间比写代码还多这也正是爬虫项目的常态搞清楚数据藏在哪比代码本身更重要。这个项目很适合两类读者一类是刚学完 Python 基础、想找个真实场景练手的爬虫初学者另一类是经常需要短评数据做分析但不想手动复制粘贴的运营或产品同学。如果你只是想临时抓一批数据照着这篇文章的代码改改电影编号就能跑如果你想真正理解爬虫的字段解析思路这篇文章也会把每一步为什幺这样做讲清楚。2. 动手前先看清目标这几个字段藏在页面什幺位置2.1 短评列表页的信息密度先打开任意一部电影的短评页浏览器里的结构比想象中规整。每个短评块都是一个.comment-item里面大致包含这几块内容用户头像和昵称昵称通常是一个指向个人主页的链接昵称后面跟着来自某地这就是发布城市星级评分页面上显示为几颗星对应对应的 class 名称发布时间格式可能是2024-01-01 10:00:00也可能是5分钟前短评正文有用后面的数字就是点赞人数。这里有个容易忽略的点发布城市并不是每条评论都有很多人没有填所在地那这个字段就缺失。点赞人数也可能是 0页面里0并不显眼解析时要注意默认值。发布时间更是有刚刚、x分钟前、x小时前、具体日期多种形态后面处理时要统一成标准时间。还有一个坑是加入时间它并不在短评列表页上而是在用户个人主页里。也就是说要拿到这个字段就得从短评里的昵称链接提取用户 ID再请求一次个人主页。这个请求量会放大很多倍后面专门讲怎么控制。2.2 为什幺用 HTML 解析而不是官方接口豆瓣曾经提供过公开 API但申请门槛高key 有配额限制很多老接口也废弃了。对一次性抓几百条短评的需求来说直接解析 HTML 是最快捷的方式而且还能借此练习 CSS 选择器和正则表达式这些技能以后爬其他网站也用得上。但直接解析 HTML 不等于乱来。任何公开网站的数据抓取时都要注意频率不能把对方服务器打挂。我个人的习惯是先看robots.txt虽然它不是法律文件但代表了站方的容忍度抓取时严格控制并发加随机延时遇到验证页面立刻停止。做技术练习可以但要有分寸这也是从业者最基本的职业素养。3. 技术选型与准备工作3.1 工具链选择requests BeautifulSoup lxml pandas我用的组合很常规没有引入框架因为项目规模不需要。requests负责发请求BeautifulSoup负责解析 HTMLlxml作为解析引擎速度比默认的 html.parser 快不少最后用pandas做数据清洗和导出。这个组合的好处是每个环节都能看得懂、改得动出了问题也好排查。如果你用scrapy确实能写出更工程化的爬虫但学习成本和对异步调度的理解要求更高。对于一个目标是拿到 7 个字段并导出的脚本requests 同步请求已经足够代码也更直观。3.2 环境准备建议使用 Python 3.10 以上版本最好新建一个虚拟环境避免依赖冲突。安装依赖就三条命令pip install requests pip install beautifulsoup4 pip install lxml如果后面要导出 Excel还需要openpyxlpip install openpyxl3.3 请求头里的三个细节初次写爬虫的人最容易栽在这里不带 User-Agent 直接请求大概率返回 418 或 403。豆瓣这类网站对浏览器特征很敏感至少要伪装成真实浏览器。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/ }Referer 设置为短评页所在域名模拟从站内跳转过来的正常访问。另一个细节是不要每次都固定一个 User-Agent可以在一个列表里随机切换但这只是基础防护最核心的还是请求频率。注意任何抢频率、绕验证的操作都不在我的讨论范围内。频率控制不是为了应付网站而是为了不给服务器增加负担。你抓得慢一点数据并不会少只是多等几分钟。4. 核心实现7 个字段一次抓全4.1 翻页逻辑与总框架豆瓣短评页的 URL 格式很规律核心是subject/{电影ID}/comments翻页靠start参数每页 20 条。sortnew_score可以按最新排序statusP表示只看看过该片用户的短评。整体框架就是循环翻页逐页解析。import requests import time import random import re import pandas as pd from bs4 import BeautifulSoup BASE_URL https://movie.douban.com/subject/{movie_id}/comments def fetch_comments(movie_id, pages5): all_rows [] for page in range(pages): url BASE_URL.format(movie_idmovie_id) params { start: page * 20, limit: 20, status: P, sort: new_score } resp requests.get(url, paramsparams, headersheaders, timeout10) if resp.status_code ! 200: print(f第 {page1} 页请求失败状态码: {resp.status_code}) break soup BeautifulSoup(resp.text, lxml) items soup.select(.comment-item) if not items: print(当前页没有解析到短评可能页面结构有变化或已被拦截) break for item in items: row parse_comment_item(item) if row: all_rows.append(row) print(f第 {page1} 页完成累计 {len(all_rows)} 条) time.sleep(random.uniform(3, 6)) return pd.DataFrame(all_rows)parse_comment_item是关键函数后面逐字段拆。4.2 逐字段解析用户名、城市、评分、时间、内容、点赞数单条短评的结构相对固定我用 CSS 选择器直接定位。先看代码def parse_comment_item(item): # 用户昵称 user_tag item.select_one(.comment-info a) username user_tag.get_text(stripTrue) if user_tag else # 用户主页链接 user_href user_tag.get(href, ) if user_tag else uid if user_href and /people/ in user_href: m re.search(r/people/([^/])/, user_href) uid m.group(1) if m else # 发布城市昵称后面的来自某地 info_text item.select_one(.comment-info).get_text( , stripTrue) city if 来自 in info_text: city info_text.split(来自)[1].split( )[0] # 评分星级 class rating_tag item.select_one(.comment-info .rating) rating rating_map.get(rating_tag.get(class, [])[-1], ) if rating_tag else # 发布时间 time_tag item.select_one(.comment-time) raw_time time_tag.get_text(stripTrue) if time_tag else publish_time normalize_time(raw_time) # 短评内容 short_tag item.select_one(.short) content short_tag.get_text(stripTrue) if short_tag else # 点赞数 vote_tag item.select_one(.vote-count) votes vote_tag.get_text(stripTrue) if vote_tag else 0 votes int(votes or 0) return { 用户名: username, 用户ID: uid, 城市: city, 评分: rating, 发布时间: publish_time, 短评: content, 点赞人数: votes }这里几个细节值得展开说。城市字段的坑在于.comment-info的文本可能是用户名来自北京五星这种拼接。直接get_text后切分来自再取下一段空格前的部分基本能拿到城市名。但要注意有些用户没有填所在地那info_text里就没有来自这种情况直接给空字符串后面清洗时再处理。评分字段不是看星星图片而是看span的 class 名称映射关系固定页面显示class 名称分值力荐allstar505 星推荐allstar404 星还行allstar303 星较差allstar202 星很差allstar101 星我建议映射成5星、4星这样的文本还是一并记录数值后面做统计更方便。这里以数值为例rating_map { allstar50: 5, allstar40: 4, allstar30: 3, allstar20: 2, allstar10: 1 }发布时间不能直接用原始文本因为格式太乱了。我写了个normalize_time做统一转换def normalize_time(raw): raw raw.replace(年, -).replace(月, -).replace(日, ) raw raw.replace(分钟前, ).replace(小时前, ).replace(刚刚, ) # 转成标准时间这里简化为直接返回清洗后的文本 return raw.strip()更严谨的做法是把x分钟前换算成当前时间减 x 分钟但如果只是做趋势分析统一成日期级别就够了。我实际抓取时把两种都保留原始时间和标准化时间。4.3 先小样本验证再全量抓取第一次运行不要直接翻几十页。建议先抓 1 页打印前 5 条看看字段是否对得上。我踩过最典型的坑是评分 class 换了或者.comment-item这个外层容器变了导致整页什么都解析不出来。先小样本调试还有一个好处就是能确认当前网络环境访问豆瓣短评是否正常。如果请求返回的是验证页面直接停手等一段时间再试不要硬刚。5. 难点攻坚加入时间从用户主页获取5.1 从昵称链接提取用户 ID短评里的昵称链接长这样https://www.douban.com/people/{uid}/uid 一般是字母数字组合。用正则提取uid后就可以构造个人主页 URL。所有用户 ID 的抓取都在第 4 节的parse_comment_item里完成了现在只差最后一步请求。5.2 请求个人主页的加入时间个人主页里有加入时间这个信息但短评页里没有。我的思路是用正则直接搜文本PROFILE_URL https://www.douban.com/people/{uid}/ def fetch_join_time(uid): profile_url PROFILE_URL.format(uiduid) resp requests.get(profile_url, headersheaders, timeout10) if resp.status_code ! 200: return m re.search(r加入时间/span\s*([\d-]), resp.text) return m.group(1) if m else 注意这个函数在真实场景会很慢。假设你抓了 500 条短评就要额外请求 500 次个人主页等于工作量翻倍。所以有两个应对策略第一种是全量抓取但把延时拉长到 5 秒以上几百条数据可能要多花几十分钟。第二种是抽样抓取只对点赞数高于某个值的短评获取加入时间这样既能拿到高质量用户画像又不会产生太多请求。我更推荐第二种尤其是只需要看一个大概趋势的场景。实际抓取时我建议把fetch_join_time的结果缓存到内存字典里同一个 uid 只请求一次。因为一个用户可能发多条短评重复请求是浪费。重要提醒个人主页的请求频率要更低。短评列表页是主要目标个人主页是附属请求如果页面返回 403 或验证码说明频率太高立刻停止过几小时再继续。我见过有人为了拿这个字段把 IP 打到被封完全不值得。6. 数据清洗与导出6.1 用 pandas 统一字段格式抓回来的 DataFrame 可能有一堆空值和脏数据。比如城市字段有空字符串点赞人数可能存在字符串和整数混在一起评分可能有缺失。简单清洗三步走def clean_data(df): # 点赞人数统一为整数 df[点赞人数] pd.to_numeric(df[点赞人数], errorscoerce).fillna(0).astype(int) # 评分统一为字符串缺失填未知 df[评分] df[评分].fillna(未知) # 城市统一为字符串缺失填未知 df[城市] df[城市].replace(, 未知) # 发布时间统一为字符串去掉多余空格 df[发布时间] df[发布时间].str.strip() # 删除完全没有内容的行 df df[df[短评].notna() (df[短评] ! )] return df清洗这一步不只是为了好看后面做统计时数据类型不统一会让value_counts()、groupby()全部出错。宁可多花几分钟清洗也不要拿脏数据直接分析。6.2 导出 CSV 和 Excel导出简单pandas一行搞定df.to_csv(movie_comments.csv, indexFalse, encodingutf-8-sig) df.to_excel(movie_comments.xlsx, indexFalse)注意 CSV 用utf-8-sig而不是utf-8否则用 Excel 打开中文会乱码。如果你只要 CSV那to_csv就够Excel 需要额外装openpyxl。7. 抓完别闲着用数据回答几个小问题拿到结构化数据后统计就很有意思了。最简单的几个分析评分分布print(df[评分].value_counts().sort_index())城市 Top 10print(df[城市].value_counts().head(10))高赞短评print(df.nlargest(10, 点赞人数)[[用户名, 短评, 点赞人数]])时间趋势可以按发布日期分组统计df[发布日期] pd.to_datetime(df[发布时间]).dt.date print(df.groupby(发布日期).size())这些分析虽然简单但已经能回答观众对不同评分的态度差异短评发布的高峰期哪个地区讨论热情最高这类问题了。如果结合词云还能做更细的情绪分析。项目做到这一步才算真正把爬虫数据用起来。8. 踩坑实录常见问题与排查技巧把我在这个项目里实际遇到的坑整理成一张表方便你直接对照现象可能原因解决办法返回 418 或 403User-Agent 未伪装或被识别检查请求头更换真实的浏览器 UA降低频率第一页能抓翻页后为空start 参数构造错误确认每页 20 条start page * 20点赞人数全部为 0选择器错误或类名已变化打开浏览器检查元素确认.vote-count是否存在城市字段全部为空页面文本格式和预期不同打印.comment-info的完整文本调整切分逻辑加入时间抓不到个人主页被拦截或 uid 解析失败先检查 uid 是否正确再检查个人主页请求状态码中文乱码CSV 编码不是utf-8-sig改用utf-8-sig导出抓了一半被验证码拦住请求频率过高立即停止延后几小时继续不要硬抢还有几个实操建议是我自己反复用到的一是把每次请求的响应状态码和当前页数打出来。脚本跑几十分钟如果不打印进度中间挂了都不知道在哪一步。二是多做断点缓存。哪怕你只抓了 100 条就停了先保存这 100 条下次继续而不是从头开始。三是对所有请求包一层超时和异常捕获不要因为单个请求超时让整个脚本崩溃。四是在处理加入时间这种高成本请求时设置一个最大重试次数超过就跳过别让脚本卡死在某个用户上。这个项目本身难度不高难的是把细节做稳。我后来把这套脚本改成了定时任务每周跑一次持续监控某几部电影的短评变化比手动翻页效率高太多。做爬虫最重要的就是先分析清楚数据结构再用最克制的方式拿数据这样才能跑得久、跑得稳。