☰
微博评论爬虫与情感分析:从模拟请求到舆情洞察
2026/10/3 5:08:01 网站建设 项目流程

简介:一份专注微博评论爬取与情感分析完整流程的实战项目源码包,面向希望系统提升网络爬虫与自然语言处理能力的开发者。项目从微博登录后的评论接口入手,使用requests与BeautifulSoup解析页面,配合cookie、session与验证码识别绕过登录限制,采集到的评论经pandas落为CSV,也可写入MySQL供后续分析。分析环节引入结巴分词统计词频并生成词云,再借助SnowNLP等模型输出情感得分,通过阈值划分积极与消极评论,构成完整的数据采集-存储-分析链路。压缩包共17个文件,以2个Python主程序、8个txt配置与停用词/正负情感词典为主,附带真实微博评论CSV样例、README说明及4张示意截图,整体仅465KB,结构精简而完整。当前已有1327人学习,适合想从零搭建中文评论分析流程、理解情感评分机制,并拓展至舆情监控或问答系统场景的学习者。

1. 爬取微博评论这事,比想象中更需要一套完整的流水线

做产品反馈收集或舆情分析时,微博评论区几乎是绕不开的数据源:一条热门微博下面动辄几千条评论,用户情绪、话题焦点、吐槽点都在里面。但真动手试过的人都知道,直接用浏览器复制粘贴不现实,随便写几行 requests 去调评论接口往往拿到一堆乱码和验证码,接着就是账号被限制。像 weibo-comment-crawler 这类项目,要解决的核心不是“把评论存下来”,而是怎么把“抓取、清洗、情感判断、分析导出”串成一个能稳定运行的流水线。这篇笔记就是围绕这条流水线来讲:先摸清评论接口的请求结构,再讲数据存储和增量抓取,接着做情感分析和时间趋势,最后把爬虫踩过的坑一个个拆掉。适合刚接触爬虫、想拿真实中文语料做数据分析的从业者,也适合需要长期跟踪热点评论的产品和运营同学照着搭一套自己的工具。

2. 先搞懂微博评论接口:一次完整请求由什么组成

爬微博评论,第一关不是写代码,而是找到评论接口。网页版微博是前后端分离的,你在浏览器里翻评论区时,页面会通过 XHR 请求向服务器要数据。把浏览器开发者工具打开,切到 Network 面板,翻一页评论,就能看到一个名字里带 comment 的请求,返回的是 JSON。这个 JSON 里不只是评论内容,还包括评论者的昵称、发布时间、点赞数、子评论数量、楼层等字段。weibo-comment-crawler 这类工具的底层逻辑,就是模拟这个 XHR 请求,拿到 JSON 后解析成结构化数据。

2.1 从网页版找评论接口:XHR 请求里的固定规律

不同时期微博的前端接口路径会变,我第一次翻的时候也找了好一会儿。常见做法是:登录微博网页版,打开任意一条热门微博,按 F12 进入开发者工具,选择 Network 面板,刷新页面,然后手动往下翻评论区。在筛选项里输入 XHR,把网络请求按时间排序,找到发送频率很高、名字里带 comment 或 comments 的请求。

点开这个请求,看它的 Headers 和 Query String Parameters。我印象里,评论接口通常需要这几类参数:微博的 mid 或 id(决定抓哪条微博的评论)、max_id(用于翻页,首页为空,翻页后取上次返回的分页游标)、page 或 since_id(部分版本用这个做分页)、以及一个容器 id 或 flow 标记。关键不是死记这些参数名,而是抓到一次完整请求后,把浏览器自动生成的参数原样复制下来,用代码动态替换其中的 mid 和 max_id。

提示:浏览器开发工具里看到的 Query String Parameters 可以直接复制成 requests 的 params 字典,但要注意有些参数带空格或特殊字符,需要先 URL 解码再放进代码。

2.2 登录态、Cookie 与 Container ID:三个缺一不可

微博评论接口对未登录用户有限制,翻到一定页数就不再返回新数据,甚至直接要求登录。所以爬取前必须保证 requests 携带有效的登录 Cookie。多数人用的方式是:在已经登录微博的浏览器里,从 Application 面板复制 Cookie 字符串,粘贴到爬虫配置里。这个 Cookie 有有效期,通常几个小时到几天不等,过期后接口会返回 401 或跳转到登录页。

Container ID 是另一个容易忽略的点。评论接口返回的数据里往往嵌套着微博正文、热门评论、普通评论等分类,如果不指定 container id,默认拿到的可能只是热门评论,普通评论全被过滤掉。打开接口返回的 JSON,找到评论列表所在的字段名,比如 data 下的 comments 数组。很多工具会先请求一次微博详情接口,从返回里拿到 container id 或对应的 flow 参数,再带着这个值去请求评论列表。更稳妥的做法是:先手动翻一次评论区,在 XHR 请求的 Cookie、Referer、Params 三者之间核对,哪个参数变化会改变评论分类,就把那个参数作为必传项写进爬虫配置。

2.3 用 requests 模拟一次评论请求的最小代码

把上面的分析落成代码,最小可用版本长这样。这里不写死接口路径,因为微博改版频繁,你按 2.1 的方式抓到哪个就替换成哪个:

import requests def fetch_comments(mid: str, cookie: str, max_id: str = "") -> dict: # 按抓包结果填路径,不同时期可能是 commentlist、comments 等 url = "https://weibo.com/ajax/statuses/commentlist" params = { "mid": mid, # 微博正文的唯一标识 "max_id": max_id, # 分页游标,第一页传空 "count": 20, # 每页条数,建议 20~50 } headers = { "Cookie": cookie, # 必须带登录态 "Referer": f"https://weibo.com/u/{mid}", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "X-Requested-With": "XMLHttpRequest", # 模拟 XHR } resp = requests.get(url, params=params, headers=headers, timeout=10) resp.raise_for_status() # 非 2xx 直接抛异常,方便排查 data = resp.json() return data if __name__ == "__main__": # 实际使用时从本地配置读,不要硬编码 my_cookie = "SUB=xxx; SUHB=xxx" result = fetch_comments(mid="4952345678901234", cookie=my_cookie) comments = result.get("data", {}).get("comments", []) print(f"拉取到 {len(comments)} 条评论") if comments: print(comments[0]["text"]) # 评论正文是 HTML 文本,后续要清洗

这段代码的核心是 params 和 headers 的完整性。请求头里 Referer 是评论接口校验来源的重要字段,很多 401 就是因为它缺失或写错;Cookie 必须放在 headers 里,不能用 params 传。返回的 JSON 中,评论正文在 text 字段,通常是含 HTML 标签的片段,比如<span class="surl-text">内容</span>,不能直接拿来分析。max_id的作用是翻页,第二次请求把第一次返回的max_id值填进来,直到返回值为空或重复,就说明到底了。

注意:接口路径、参数名会随微博改版变化。如果你在代码里看到 404 或参数报错,先回浏览器抓包确认新路径,再回来改代码,不要硬刚旧地址。

3. 让爬虫按节奏跑起来:存储、去重、断点续爬

接口通了只是第一步。真正做采集时会发现评论量很大,一次抓完不现实,而且网络抖动、账号限流都可能导致中途失败。所以一个合格的工具必须解决三件事:数据存在哪儿、怎么做到只抓增量、挂了之后怎么办。

3.1 评论数据存哪里:JSON 行和 SQLite 的取舍

常见做法是优先存 JSON 行文件,也就是每个评论一行 JSON 对象。原因很简单:微博评论字段不固定,有的带子评论,有的带转发附言,JSON 可以自然表达这种嵌套结构;同时每一行是一个独立记录,写入中途崩溃顶多丢最后一条,不会把整个文件损坏。

等跑通之后再导入 SQLite,用于查询和去重。SQLite 不需要额外装数据库服务,Python 自带 sqlite3 模块,适合单机分析和后续导出。建表时要把评论的唯一标识 cid 设为主键,同时给 mid 建索引。下面这段代码演示了如何把抓到的评论写入 JSON 行,再汇总进 SQLite:

import json import sqlite3 from pathlib import Path def save_jsonl(comments: list, path: str = "comments.jsonl"): with open(path, "a", encoding="utf-8") as f: for c in comments: # 只保留真正需要的字段,避免把无用的跟踪参数也存进去 record = { "cid": c.get("cid"), "mid": c.get("rootid") or c.get("id"), # 所属微博 id "user": c.get("user", {}).get("screen_name", ""), "text": c.get("text", ""), "created_at": c.get("created_at", ""), "like_count": c.get("like_count", 0), "reply_count": c.get("reply_count", 0), "max_id": c.get("max_id", ""), # 用于断点续爬 } f.write(json.dumps(record, ensure_ascii=False) + "\n") def import_to_sqlite(jsonl_path: str, db_path: str = "weibo_comments.db"): conn = sqlite3.connect(db_path) conn.execute("""CREATE TABLE IF NOT EXISTS comments ( cid TEXT PRIMARY KEY, mid TEXT, user TEXT, text TEXT, created_at TEXT, like_count INTEGER, reply_count INTEGER, max_id TEXT )""") with open(jsonl_path, encoding="utf-8") as f: rows = [] for line in f: record = json.loads(line) rows.append((record["cid"], record["mid"], record["user"], record["text"], record["created_at"], record["like_count"], record["reply_count"], record["max_id"])) conn.executemany("INSERT OR IGNORE INTO comments VALUES (?,?,?,?,?,?,?,?)", rows) conn.commit() conn.close()

这里的INSERT OR IGNORE是一个很实用的动作:当 cid 重复时直接跳过,这样即使重复抓取,也不会产生脏数据。max_id字段单独存下来,是为了恢复抓取时能接着上次的翻页位置继续,不用从头再来。实际项目中,我会把 JSON 行文件按天拆分,比如comments_20250101.jsonl,这样如果某天数据格式变差,可以直接丢掉那个文件重爬,不影响历史数据。

3.2 按微博 mid 增量抓取:只抓新评论的思路

很多评论是不断增长的,早上抓完,下午又多了几百条。全量重爬成本太高,常规做法是记录每条微博上次抓到的评论时间或最大 cid,再次抓取时只翻新增部分。

具体逻辑是:第一次抓完某条微博的评论后,把该微博最近一条评论的 cid 存进一个crawl_state表。下一轮抓取时,每翻一页,遇到 cid 与上次记录的相同,就停止翻页。这样用不了几次请求就能抓到新增内容。如果接口支持since_id这类同步游标,就直接用游标翻页;不支持的话,用时间字段做过滤也行,但要处理评论时间相同的情况,这时要结合 cid 的先后顺序判断。

3.3 抓取节奏控制:sleep、重试与随机化

微博的反爬不是看单次请求,而是看单位时间内的频率。一次性短时间发几十个请求,很容易触发风险控制。我在工具里一般会加三样东西:固定的最小间隔、失败重试退避、随机抖动。

间隔不能写死成 1 秒,因为代码执行速度有波动,实际感知更像固定节奏,反而容易被识别。更自然的做法是在 1.5 秒到 3.5 秒之间随机取值,每次请求前time.sleep(random.uniform(1.5, 3.5))。重试也要有开关和上限,比如连续重试 3 次后仍然失败,就跳出循环并保存断点,等下次跑脚本时从断点继续。

提示:爬虫的本质是模拟真实用户,不要让每两次请求的时间间隔完全一致。加随机抖动不是为了对抗什么,是为了减少对目标服务的持续压力,也是一种自我保护。

4. 微博分析与评论情感分析:从词频到情绪曲线

数据抓回来只是开始,接下来要做的是微博分析。很多人拿到几万条评论,第一反应是逐个看,这显然不现实。常规路线是:先清洗,再分词,然后做情感分析,最后把情感分数按时间聚合,看出舆论走向。

4.1 清洗评论:去掉 emoji、@用户、话题标签和重复文案

微博评论文本有三个典型特征:大量 HTML 标签、大量 @ 提及和话题符号、以及大量复制粘贴的重复文案。这些噪音如果不清理,分词和情感分析都会跑偏。

常见的清洗步骤是:先用正则去掉<[^>]+>标签,再替换掉@\w+和#话题#,然后去掉多余的空白字符。emoji 保留还是删除要分场景:如果只做词频统计,删掉;如果做情绪分析,有些 emoji 也有情感倾向,可以单独抽出来作为一个特征。这里我按“保守可用”的方式处理,先删掉非文字部分,后续需要再做扩展。

import re import jieba from snownlp import SnowNLP def clean_comment(text: str) -> str: # 去 HTML 标签 text = re.sub(r"<[^>]+>", "", text) # 去 @用户 与 话题 text = re.sub(r"@\S+", "", text) text = re.sub(r"#.*?#", "", text) # 去转发节点和缩进 text = re.sub(r"转发微博", "", text) text = re.sub(r"\s+", " ", text).strip() return text def sentiment_score(text: str) -> float: cleaned = clean_comment(text) if len(cleaned) < 2: return 0.5 # 过短评论无法判断,返回中性值 s = SnowNLP(cleaned) return s.sentiments # 0 到 1,越接近 1 越正向 def tokenize(text: str) -> list: cleaned = clean_comment(text) # 只保留长度大于 1 的词,过滤单字噪音 return [w for w in jieba.lcut(cleaned) if len(w) > 1]

这段代码要注意三个地方。第一,SnowNLP是训练好的模型,对网络流行语的识别有限,像“绝绝子”“yyds”这类词会被当作普通词处理,情感得分往往偏向中性。如果评论语料集中在某个垂直领域,建议先跑一遍,挑出错判样本,再自己补充正负向词典。第二,sentiment_score返回的是 0 到 1 的小数,0.5 是中性,0.5 以上偏正向,以下偏负向。实际使用时不要只取临界值 0.5,因为模型输出有抖动,我一般会把 0.2 到 0.8 之间都算中性,只统计两端的明确情绪。第三,分词前别忘了加入领域自定义词典,jieba.add_word("白嫖")这种,不然后续词频统计会拆成奇怪的字组。

4.2 情感分析方案:词典法起步,模型法进阶

情感分析落地不能一上来就上深度学习模型。对于评论数据,先跑通一条轻量链路,再考虑准确率优化,才是务实的做法。词典法的逻辑是:准备一份带有正负权重的词表,对每条评论打分,权重相加的正负决定情绪。它的优点是解释性强、速度快、算力要求低,但缺点是词表要维护,网络新词覆盖不够。

我们本地的场景可以对SnowNLP的得分做一个简单校正。比如维护一个neg_extra = ["垃圾", "恶心", "垃圾微博", "骗子"],评论命中这些词时,把得分再压低 0.2;pos_extra = ["支持", "良心", "好评"]则抬高 0.2。这样不改变模型,只是用一个可解释的规则去纠偏,对舆情大方向判断足够用。

如果你有几千条人工标注过的语料,可以跳上模型法。常见做法是先把评论转成 TF-IDF 向量,用逻辑回归或 XGBoost 训练一个分类模型。这一步需要构造训练集,成本比较高,但准确率通常比词典法高 10 到 15 个百分点。模型法的问题在于评论内容变化快,训练周期一长就得重新标数据,所以很多成熟的项目反而会回到“词典 + 规则 + 少量模型”的混合策略。

4.3 把情感结果落到微博分析:按时间聚合出舆论走势

单条评论的情感分数没有太大意义,有意义的是整体分布和时间变化。下面这段代码演示了如何从 SQLite 里读出评论,算出每日情感均值、正向比例,并导出成 CSV 供图表工具使用:

import sqlite3 from datetime import datetime from collections import defaultdict def load_comments(mid: str, db_path: str = "weibo_comments.db"): conn = sqlite3.connect(db_path) rows = conn.execute( "SELECT text, created_at FROM comments WHERE mid=?", (mid,) ).fetchall() conn.close() return rows def aggregate_sentiment(rows): daily = defaultdict(list) for text, created_at in rows: # created_at 可能是 "今天 12:00" 或 "MM-DD",要先补齐年份 parsed = parse_time(created_at) if parsed is None: continue score = sentiment_score(text) daily[parsed.date()].append(score) result = [] for date, scores in sorted(daily.items()): pos_ratio = sum(1 for s in scores if s > 0.8) / len(scores) avg = sum(scores) / len(scores) result.append((date.isoformat(), round(avg, 3), round(pos_ratio, 3), len(scores))) return result def parse_time(created_at: str): # 微博自带的时间描述很随意,需要做归一化 if created_at.startswith("今天"): dt = datetime.now().strftime("%Y-%m-%d") return datetime.strptime(dt + created_at.replace("今天", " "), "%Y-%m-%d %H:%M") if created_at.startswith("MM-DD"): # 示例,实际按你抓到的格式处理 return datetime.strptime(f"{datetime.now().year}-{created_at}", "%Y-%m-%d") return None # 使用:聚合后写 CSV,再画折线图 agg = aggregate_sentiment(load_comments("4952345678901234")) with open("sentiment_trend.csv", "w", encoding="utf-8") as f: f.write("date,avg_score,pos_ratio,count\n") for row in agg: f.write(",".join(map(str, row)) + "\n")

这段代码里最不起眼但最影响结果的是时间解析。微博接口返回的created_at有很多种形式,“刚刚”“5分钟前”“今天 14:30”“05-21 09:00”等,一定要先写出归一化函数,把所有时间统一成标准日期,才能正确聚合。你可能会觉得“刚刚”“5分钟前”不是时间,但对每秒都有新评论的热门微博来说,这些时间信息非常关键,漏掉它们会让当天评论数明显偏少。

注意:情感均值会被中性评论文本稀释。比如 1000 条评论里 800 条是“转发微博”和“哈哈哈”,最终均值会贴近 0.5,看不出真实情绪。所以统计正向/负向比例时,建议先过滤掉长度小于 5 的评论,或者单独统计“明确情绪占比”,这样分析才有区分度。

5. 爬虫翻车常见问题排查:从 401 到验证码的五个坑

跑过微博评论爬虫的人都知道,这个方向最大成本不是写代码,而是维护。微博的反爬策略经常调整,不同时间、不同账号遇到的现象都不一样。我把高频踩坑按现象、原因、解决方式写出来,方便你对照排查。

5.1 请求头缺 Referer,接口返回 401

现象:同样一段代码,在浏览器里请求正常,用 requests 请求就是 401 Unauthorized。

原因:评论接口对 Referer 做校验。浏览器里发请求时,Referer 自动带上当前页面地址。Python 代码如果不显式设置 Referer,requests 默认不带这个头,服务器认为请求来源不合法。

解决:从抓包里把 Referer 原文复制到 headers,常见是https://weibo.com/或某条微博的详情页地址。注意有的接口还要求Origin字段,一并加上。设置后先测一次,如果 401 变成 200,说明问题就在这里。

5.2 频繁请求触发风险控制,返回 wb_xxxx 错误

现象:连续抓取十几页后,返回的 JSON 里 data 字段变成空,或者 code 返回一个类似wb_xxx的错误码,正常时能拿到的评论突然拿不到了。

原因:短时请求频率过高,服务器把账号标记为风险用户。微博的错误提示不总是直白的“封禁”,有时只是返回空数据,你如果不看 code 只看 data,会以为没有更多评论了。

解决:先停掉脚本,至少等几分钟。检查上一轮每页的间隔是否低于 2 秒,如果是,调高到 3 秒以上。另外可以把请求分散到多个时间窗,比如每抓 10 页休息 30 秒,而不是每页固定 sleep。长期维度上,可以准备两三个账号轮换使用,但要注意同一出口下多账号频繁切换也可能被关联,我的习惯是单账号慢跑,优于多账号快跑。

5.3 验证码与滑块出现后,如何降速

现象:请求返回里包含一个验证码链接,或者触发滑块验证。如果是 requests 直接请求,几乎无法通过,因为滑块交互需要浏览器环境。

原因:验证码出现通常是累计频率过高,或者 Cookie 被标记为异常。这时用纯 requests 硬闯成功率很低。

解决:不要硬闯。做法是把当前抓取状态保存到断点,停下来等 30 分钟到 1 小时,再继续。如果你确实急需这轮数据,可以改用自动化浏览器库去处理评论翻页,比如 Playwright 接管真实浏览器,让用户手动滑一次滑块后,再继续后继的自动请求。但这样做有代价,浏览器自动化请求同样要控制频率,不然很快又会触发。

5.4 中文乱码与编码陷阱

现象:存入文件后打开,中文变成\u5377或绉之类的字符。

原因:两种情况,一是响应内容被 requests 按默认编码解码,二是写入文件时编码指定错了。微博返回的多为 UTF-8,但某些接口头里没声明 charset,requests 可能会用 ISO-8859-1 去猜。

解决:拿到响应后先resp.encoding = "utf-8"再进行resp.json()。写入文件时一定加encoding="utf-8",不要用系统默认编码。如果是往 CSV 里写,还要加上utf-8-sig,否则 Excel 打开会乱码。这些是爬虫老手最常踩的阴沟,但每次改了就能好。

5.5 数据字段缺失:max_id 分页失效

现象:翻页到某一页后,返回的 max_id 一直不变,导致脚本在同一页死循环,或者提前结束。

原因:接口在特定情况下(比如评论数太少或被过滤)不返回 max_id,或者返回的 max_id 与上次相同。代码里如果只判断“max_id 为空就退出”,没有判断“max_id 与上一次相同”,就会死循环。

解决:维护一个上一次的 max_id 变量,如果本次与上一次相同,就强制退出;另外设置最大翻页数,比如最多 200 页,超过则认为异常。更可靠的是用评论 cid 的唯一性判断,如果连续 5 页拿到的都是重复 cid,就停止。这样即使接口游标出错,也不会无意义消耗请求数。

6. 进阶验证:把爬下来的评论做成一个可更新的分析小工具

前面五章解决的是“能跑起来”,这一章说一个能让这个方向长期可用的进阶技巧:把爬虫从一次性脚本变成定时更新的小工具。

常见的做法是把整个流程拆成三步:定时触发采集、自动做增量清洗、输出当天的情绪简报。采集部分可以直接复用第 3 章的代码,但要加一个运行状态文件,记录每个微博 mid 上次抓到的 cid。每次启动时先读取状态,只抓新增评论,完成后更新状态。这一步完成后,配合系统 crontab 或 Windows 任务计划程序,每天固定时间跑一次即可。

验证产出有一个很实用的技巧:不要只看“今天跑成功没有”,要看“今天抓到的评论与昨天比有没有异常”。我习惯在分析脚本里加三个阈值检查:新增评论数、情感均值、正向比例。如果情感均值比前一天突降超过 0.3,或者新增评论数突然翻了 10 倍,就把当天的日报标记为“需人工复核”。这种自动标记比事后看图要直观得多,毕竟绝大多数时候我们关注的是异常变化,而不是绝对值。

最后的落地上,我会把结果输出成两个文件:一个 CSV 供继续清洗,一个简单的 HTML 报告。HTML 里直接用 ECharts 从 CSV 读数据画趋势图,不需要额外搭建可视化服务。这样每天早上打开看一眼,就知道过去 24 小时哪条微博的评论区情绪异常,需要重点跟进。工具本身不用做得多复杂,稳定的爬取、可靠的去重、可解释的情感分,加上一个自动校验机制,已经能覆盖大多数微博分析需求。

做爬虫这几年,我养成的习惯是“先让脚本学会报错,再让脚本学会跑完”。所有异常都必须留下日志和断点,不硬撑。遇到接口变化,先回到浏览器抓包重新看参数,改完代码先跑 5 页确认数据质量,再放量去抓。这套思路帮我少走了很多弯路,希望也能帮你在微博评论分析和情感分析的落地上省下几个晚上的调试时间。

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

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

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

立即咨询