做爬虫最忌一上来就写代码。这个项目看起来简单,无非是把短评列表页翻一遍,把用户名、评分、发布时间、点赞数、短评正文这些字段抠出来,但真正跑起来才发现,题目标签里列出的7个字段有2个不在同一个页面上,还有3个字段需要先做格式转换才能落到表里。我这次要做的,就是把某影评社区的网页短评分页里每一句话背后的用户画像、热度信号和时间信息全部结构化,最后生成一张可以直接拿去做分析的宽表。这个方案适合两类人:一类是刚接触爬虫、想找个带点反爬对抗的练手项目的新手;另一类是拿数据做用户调研或口碑分析的同学,单条短评的价值不高,但几十万条短评透出的地域分布、用户新旧程度和情绪倾向就很有意义了。如果你只是想把评论存成Excel,那这个项目可能有点重,但它换来的是一套“以后换任何站点都能快速套用”的采集框架。
1. 先拆需求:这7个字段到底想解决什么问题
1.1 每个字段背后的数据用途
爬虫项目最忌讳的就是盲目把所有能看到的文字都抓下来,结果存了几百个字段,真正分析时一个都用不上。所以第一步得想清楚:这7个字段分别解决什么问题。
用户名和用户ID是一对组合。用户名用来展示,用户ID是唯一的,用来做去重和关联。比如同一部电影下某个用户发了多条短评,光看用户名可能有重名,用ID去重才靠谱。更重要的是,用户ID能拼出用户主页地址,后面要补“加入时间”全靠它。
发布城市反映的是地域属性。一条短评显示“来自上海”或“来自成都”,并不是说要拿它做精准定位,而是当样本量够大时,可以看出一部电影在不同地区的讨论热度差异。有些片子在一线城市的短评数量和比例明显偏高,这个信号对宣发分析很有价值。
加入时间则代表用户的新旧程度。2010年注册的老用户和2023年才注册的新用户,对电影的口味偏好、评分宽容度往往不一样。老用户使用更久、评过的片更多,评分通常更克制;新用户更容易打出极端分。这个字段是理解评分偏差的重要维度。
点赞人数是短评的热度权重。一条短评的点赞数高,说明它引起了很多人的共鸣,在情感分析里应该有更高权重。如果只统计评论文本,很容易被少量冷门但偏激的声音带偏。
评分和发布时间则构成时间序列。评分可以看整体口碑,发布时间可以看讨论热度随时间的变化:上映首周的评价、两周后的口碑回落、长尾期的二次讨论,这些都是做舆情监测的关键节奏。
短评正文本身就不用说了,分词、情感分析、话题聚类都靠它。
简单说,这个项目的核心不是“把页面上的字抓下来”,而是“把页面信息转化成能回答业务问题的结构化数据”。想清楚这一点,后面写代码才会有取舍。
1.2 数据与页面结构的对应关系
谈完用途,再看这些字段长在哪。
从网页结构上看,短评列表页每一条短评卡片上,可见的信息包括:
- 用户名:短评标题区域左侧的昵称,同时可提取用户主页链接
- 发布城市:昵称旁边的“来自XX”文本
- 评分:短评下方或标题旁边的星级图标
- 发布时间:短评正文底部,通常是“刚刚”“5分钟前”“昨天”这类相对时间
- 点赞人数:短评下方的“有用”数字
- 短评正文:评论内容本身
所以列表页一次请求,就能拿到7个字段中的6个。唯独“加入时间”不在短评卡片上,它藏在用户主页里。这意味着爬虫需要额外发起一次请求,进入该用户主页,定位到“加入时间”那一行,再返回数据。
这个“二次请求”是整个项目的关键设计取舍。如果不在乎加入时间,爬虫就只是一个简单的列表翻页采集;但如果要完整复现题目标签里的7个字段,就必须接受每个用户多一次请求的代价。我当时的做法是:短评列表页照常爬,把需要补加入时间的用户ID先缓存下来,等列表页任务跑完,再统一去请求用户主页。这样避免“爬一条短评就卡一次用户主页”,效率和稳定性都更好。
2. 整体方案与工具选型
2.1 为什么选Python + requests + BeautifulSoup
市面上能用的方案很多,我最终选了 Python + requests + BeautifulSoup(解析器用 lxml),核心原因有两个。
第一,目标站点的短评列表页是服务端渲染的静态页面。用浏览器打开看到的HTML里,短评内容、用户名、点赞数这些字段都已经直接存在,不需要等待JavaScript异步加载。这意味着完全不必要上浏览器自动化工具,那些重型方案启动慢、内存占用高、容易被识别,属于杀鸡用牛刀。requests 轻量、可控,配合随机延时可以很好地控制采集节奏。
第二,Python的生态让后续的数据处理非常顺滑。解析用 BeautifulSoup 写起来比正则表达式直观得多;清洗阶段用 pandas 几行就能搞定;最后存 CSV 或 SQLite 都有现成库。整个链路不用切换语言。
可能有同学会问:为什么不直接找目标站点的内部接口?确实,有些站点有返回JSON的接口,解析起来更舒服。但我当时的判断是:接口的字段稳定性依赖内部约定,而且未经授权的接口调用容易给服务器带来更大压力;页面HTML结构相对稳定,并且列表页已经能覆盖大部分字段。从通用性角度讲,学会解析HTML比抓接口更值得。
2.2 请求策略与频率控制
采集这类短评数据,最大的风险不是写不出代码,而是控制不好频率。
我当时的策略是:随机延时,而不是固定延时。固定延时看起来有节奏,但机器特征太明显;随机延时则让请求间隔在2到4秒之间浮动,更接近人工浏览的节奏。每爬完一页,记录一下当前页序号,万一中途程序挂了,下次启动可以直接恢复到断点。
请求头方面,至少需要设置 User-Agent 和 Referer。User-Agent 直接用当前浏览器控制台复制一份真实UA字符串即可,不要手写一个看起来像“Python爬虫”的默认UA。Referer 设置为短评所属的电影详情页,这样整体请求上下文会更自然。
我对并发一直比较保守。这个项目全程使用单线程,没有上协程和并发池。短评数据量再大,单线程配合合理延时,一个小时也能爬几千条,完全够分析用了。并发上去了,被封的概率也上去了,对于这种个人研究型项目,收益远小于风险。
2.3 数据规模和容错设计
这个项目的数据规模预期是几千到几万条短评,属于中小体量。因此存储方案选择了轻量组合:一份 CSV 用于快速查看,一份 SQLite 用于后续查询和去重。
SQLite 的价值主要体现在去重上。短评列表翻页时,偶尔会因为网络中断重试而重复抓到同一条评论。如果用的是纯 CSV,去重还得单独写一堆代码;如果用 SQLite,给“用户ID + 发布时间”加个唯一索引,重复数据直接插不进去,问题从源头解决。
代码目录我做了简单分层:请求模块、解析模块、存储模块、主流程脚本。虽然规模不大,但分层之后,目标站点改版时只需要改解析模块,其他部分不用动,后续维护省了很多事。
3. 核心实现:字段提取与数据组装
3.1 请求与解析基础代码
先说请求层。目标站点的短评列表页和其他列表页结构类似,URL 中带一个短评翻页参数,start 表示跳过多少条。我封装了一个基础请求函数:
import requests import time import random from bs4 import BeautifulSoup UA_STRING = "直接复制你当前浏览器控制台里的User-Agent字符串" def fetch_page(url, retries=3): headers = { "User-Agent": UA_STRING, "Referer": "https://example-cinema.com/", "Accept-Language": "zh-CN,zh;q=0.9", } for attempt in range(retries): try: resp = requests.get(url, headers=headers, timeout=10) if resp.status_code == 200: return resp.text elif resp.status_code in (403, 429): print(f"请求被限制,状态码: {resp.status_code},等待后重试") time.sleep(10 + attempt * 5) else: print(f"异常状态码: {resp.status_code}") time.sleep(3) except requests.RequestException as e: print(f"请求异常: {e},重试 {attempt + 1}/{retries}") time.sleep(2) return None这个函数做了两件很基础但重要的事:第一,对 403 和 429 这类限流状态码单独处理,等待更长时间再重试;第二,每次请求失败后不立即返回,而是做指数退避式重试。至于常见的 404 或 500,那属于目标页面本身的问题,重试再多也没意义,直接打印异常继续下一条。
进入解析层,列表页可以先用 BeautifulSoup 定位到每条短评的公共容器:
soup = BeautifulSoup(html, "lxml") comment_items = soup.select(".comment-item")这里每个.comment-item就是一条短评卡片。目标站点页面结构调整时,这个容器选择器可能是最需要检查的地方——因为容器一旦失效,后面所有字段的选择器都会跟着失效。
3.2 评分、发布时间、点赞数的格式化处理
拿到每条评论卡片后,字段提取的难点不在于“找元素”,而在于“把展示文本转换成可分析的格式”。
评分字段在页面上是星座图标的样式,并没有直接显示数字。目标站点常见的做法是把星级映射到不同的 class,比如 allstar50 代表5星、allstar40 代表4星。我维护了一个映射表:
RATING_MAP = { "allstar50": 5, "allstar45": 4.5, "allstar40": 4, "allstar35": 3.5, "allstar30": 3, "allstar25": 2.5, "allstar20": 2, "allstar15": 1.5, "allstar10": 1, "allstar00": 0, } def parse_rating(rating_el): if not rating_el: return None classes = rating_el.get("class", []) for cls in classes: if cls in RATING_MAP: return RATING_MAP[cls] return None发布时间则需要转换相对时间。散落在短评列表里的常见格式有“刚刚”“5分钟前”“3小时前”“昨天”“6天前”,有时也有“2023-10-01”这种绝对日期。我写了一个通配函数:
import re from datetime import datetime, timedelta def parse_time(text): now = datetime.now() if "刚刚" in text: return now m = re.search(r"(\d+)\s*分钟前", text) if m: return now - timedelta(minutes=int(m.group(1))) m = re.search(r"(\d+)\s*小时前", text) if m: return now - timedelta(hours=int(m.group(1))) m = re.search(r"(\d+)\s*天前", text) if m: return now - timedelta(days=int(m.group(1))) if "昨天" in text: return now - timedelta(days=1) m = re.search(r"(\d{4})-(\d{2})-(\d{2})", text) if m: return datetime(int(m.group(1)), int(m.group(2)), int(m.group(3))) return None这里有一个容易被忽略的细节:短评底部的“X分钟前”是从页面生成时刻开始算的,而不是从你开始爬的时候算的。也就是说,即使你在同一秒批量抓取多条短评,它们的相对时间也应该基于页面生成时刻统一折算。如果对时间精度要求高,可以在请求开始时记录一个基准时间,以此推算绝对时间,而不是用代码运行的当前时间。大多数分析场景精度到小时就够了,但知道这个坑在哪,以后碰上要求精准场景时就不会掉进去。
点赞人数就简单了,页面里通常会显示“有用”或“赞”,后面跟着数字。直接提数字即可:
def parse_vote_count(item): vote_el = item.select_one(".vote-count") if not vote_el: return 0 text = vote_el.get_text(strip=True) if not text: return 0 return int(re.sub(r"\D", "", text))3.3 从用户主页补齐“加入时间”
列表页解析完,会拿到一个关键的中间产物:用户ID。用户主页的链接结构通常是https://example-cinema.com/user/{user_id}/,把ID拼回去就能发第二次请求。
加入时间在用户主页里的位置,不同时期、不同版本的布局不一样。但通常会在“个人资料”或“关于我”的区块里,以“加入时间:2023-01-15”这类文本出现。我的解析思路是先定位包含“加入时间”关键字的元素,再从最近的文本节点中抽日期:
def fetch_join_time(user_id): url = f"https://example-cinema.com/user/{user_id}/" html = fetch_page(url) if not html: return None soup = BeautifulSoup(html, "lxml") for text_node in soup.find_all(string=re.compile("加入时间")): parent = text_node.parent joined_text = parent.get_text(strip=True) m = re.search(r"(\d{4}-\d{2}-\d{2})", joined_text) if m: return m.group(1) return None注意:我并没有硬编码具体的CSS选择器,而是用关键字的模糊匹配。这样做的原因是用户主页的结构改版频率比列表页高,写死选择器很容易在改版后完全失效;而关键字匹配通常能多撑几轮。
如果每抓完一条短评就立刻去请求用户主页,整体效率会非常低,而且会让请求频率出现短时脉冲。我的做法是:先把列表页的所有目标字段和 user_id 缓存到内存列表里,等当前列表页翻页任务跑完,再单独遍历列表去补加入时间。这样做还有一个好处:如果某个用户主页请求失败,可以只针对那一批 user_id 重试,而不影响已经完成的列表页数据。
3.4 数据去重与落库
数据组装完成后,先过一遍去重逻辑,再写存储。去重键我选择的是(user_id, publish_time),这两个字段组合起来在现实中几乎不会重复;单纯用user_id做键会把同一个用户在不同时间发的多条短评误删。
SQLite 建表语句如下:
CREATE TABLE IF NOT EXISTS comments ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT, nickname TEXT, city TEXT, join_time TEXT, rating REAL, publish_time TEXT, vote_count INTEGER, comment_text TEXT, UNIQUE(user_id, publish_time) );我在 main 流程里这样写落库逻辑:
def save_to_sqlite(records): conn = sqlite3.connect("comments.db") cur = conn.cursor() for r in records: try: cur.execute( "INSERT INTO comments(user_id, nickname, city, join_time, rating, publish_time, vote_count, comment_text) " "VALUES(?,?,?,?,?,?,?,?)", (r["user_id"], r["nickname"], r["city"], r["join_time"], r["rating"], r["publish_time"], r["vote_count"], r["comment_text"]) ) except sqlite3.IntegrityError: pass # 重复数据直接跳过 conn.commit() conn.close()用 IntegrityError 而不是先 SELECT 再 INSERT,是为了减少数据库查询次数。数据量到几千条之后,这个差异还是能感觉到的。
4. 数据清洗与最小分析:让数据产生价值
4.1 常见脏数据形态
爬下来的数据通常不会干净到可以直接分析。我抓了一批约500条短评的样本做检查,发现的主要问题有:
第一类,城市缺失。有些用户没有设置所在地,页面不显示“来自XX”,字段为空。我统计了一下,大约有15%的短评缺城市。这类数据不能直接删,因为缺失的原因往往不是随机的——新用户、匿名用户更可能缺城市,删掉会造成样本偏差。
第二类,评分缺失。短评列表里有一部分用户只写了评论,没有点星。这种情况下评分字段为空,但在分析评分分布时必须单独处理,不能直接当作0分。
第三类,时间格式不统一。列表页有的显示相对时间,用户主页里补到的加入时间又是绝对日期,分析时需要统一成同一个时区、同一种格式。
第四类,重复数据。翻页重试时容易重复抓到同一条短评,虽然入库时唯一索引能挡住,但在 CSV 里仍可能残留。
4.2 清洗流程
清洗流程我围绕 pandas 来做。大致步骤如下:
import pandas as pd df = pd.read_csv("comments.csv") # 空城市统一填未知 df["city"] = df["city"].fillna("未知").replace("", "未知") # 评分空值单独标记 df["has_rating"] = df["rating"].notna() df["rating"] = df["rating"].fillna(0) # 时间统一成 datetime df["publish_dt"] = pd.to_datetime(df["publish_time"]) df["join_dt"] = pd.to_datetime(df["join_time"]) # 按用户发言去重 df = df.drop_duplicates(subset=["user_id", "publish_dt"], keep="last")清洗的原则是“尽量保留信息,不轻易删行”。城市缺失可以归为“未知”保留;评分缺失可以单独做一列标记。真正需要删除的只有完全重复的记录,以及正文为空的异常数据。
4.3 三个直接能用的分析角度
数据清洗完,可以先跑几个简版分析,验证字段价值。
第一个是评分分布。把所有有评分的短评按分数分组,柱状图一目了然。重点关注的是两端,也就是给1星和5星的比例是否异常。如果1星和5星加起来超过一半,说明口碑呈现两极化,中间观感的人很少,这本身就是一条有分析价值的结论。
第二个是发布时间曲线。把短评按发布时间聚合到天级别,能看到讨论热度的演变。值得注意的一个规律:首映当天的短评通常是情绪最激动的,三天后趋于理性,一周后开始出现更多“后知后觉”的长尾评价。如果你发现逆势上涨的讨论高峰,往往对应着某个具体事件,比如口碑发酵或者争议片段被传播。
第三个是城市与评分的交叉分析。把有城市信息的短评按城市分组,计算平均评分和点赞总数。有些城市用户偏好更鲜明,对特定类型电影的打分更容易走极端。当然,样本量小时没法得出可靠结论,但这个分析方向至少能告诉你数据是能支撑的。
5. 实操中的问题排查与避坑实录
5.1 请求被频控的应对
我这次爬虫跑起来后,前几页非常顺利,翻到二十多页时开始出现响应码从200变成403的情况。这个信号其实很典型:单IP请求频率超出了目标站点的容忍阈值。
遇到这种问题我的处理方式是分三步。第一步,立即停止抓取,而不是继续重试。继续重试只会把封禁时长拉长。第二步,检查请求间隔是否太短。当时我把延时设置成了1到2秒,明显太激进;调整为3到5秒后,现象缓和了很多。第三步,观察请求头是否缺失必要字段。补上完整的浏览器请求头后,403出现的频率进一步下降。
相关排查问题我整理了一张速查表:
| 现象 | 可能原因 | 处理思路 |
|---|---|---|
| 第几页开始返回403 | 请求频率过高 | 拉长随机延时间隔,单次采集量设上限 |
| 偶发504/502 | 服务端负载或网关超时 | 增加敏捷重试,等待1秒后重试 |
| 评论列表为空 | 登录状态或地区条件影响 | 检查请求头Cookie,或补充地区参数 |
| 用户主页返回空字段 | 页面结构调整 | 打开浏览器检查新增的HTML节点 |
| 短评出现重复 | 翻页偏移量错误或重试 | 用唯一索引兜底去重 |
| 相对时间解析失败 | 出现了新的时间词 | 在正则函数里补充对应分支 |
5.2 页面结构与字段缺失
解析中最容易踩的坑是选择器写得太具体。比如某个字段用div.user > span.name能抓到,但目标站点某次改版后,中间多包了一层div.info,选择器就失效了。
我的经验是:抓页面结构时,先打开浏览器开发者工具,看一下目标元素在DOM树里的层级。能直接选中就选中,但不要过度依赖多层CSS选择器嵌套。更稳妥的方式是优先用稳定的属性定位,比如 class 或>