简介:这份项目源码包以电影《雄狮少年》的社交媒体讨论为对象,实现网络舆情数据采集、清洗、分析与可视化,适合计算机、数学、电子信息等相关专业学生用于课程设计、期末大作业或毕业设计参考。压缩包共157个文件,约40.47MB,主要包含Python程序(py)、舆情数据表格(csv)、分析图表(png/webp)、说明与笔记文档(md/txt)以及部分网页展示件(html),项目说明与源码目录完整,便于直接运行和二次开发。目前已有65人学习/下载。从数据抓取到结果呈现,资源形成完整分析链路:csv数据文件涵盖多平台评论与热门话题,py脚本提供采集与处理逻辑,png/webp图像直观展示情感分布和关键词热度,pdf/caj等文档辅助理解研究背景。下载后可作为参考资料,学习将网络爬虫、自然语言处理与数据可视化结合,也可根据课程或毕设需要自行调整功能模块。
1. 网络舆情分析的起点:《雄狮少年》这样的电影该看哪些信号
网络舆情分析首先不是跑一段模型,而是确定你能看到的数据边界。对《雄狮少年》这种院线动画,真正有价值的样本不是全网讨论,而是上映前一周到上映后四周内,在豆瓣、B站、微博等公开场景产生的评论。拿到一个名为“网络舆情分析源码+项目说明.zip”的包时,先读项目说明,不要先读算法:平台、时间窗、样本量决定了后面所有结论的边界。
我习惯把这类项目拆成采集、清洗、分析、交付四层。采集负责落盘,清洗负责去噪,分析把文本变成情感分值,交付把运行参数写清楚。接下来我会按这条链路讲,给出能直接改的 Python 片段,并指出哪些环节最容易出现漂亮但不可信的结果。
2. 采集层:把《雄狮少年》的弹幕和短评抓成本地样本
任何网络舆情分析项目,第一步都不是建模,而是确认数据边界。电影舆情并不是“全网声音”,而是几个代表性平台在指定时间段里能公开获取的样本。处理《雄狮少年》这类项目时,我通常选择豆瓣短评、B站弹幕和微博评论三个来源;项目里如果有采集模块,几乎也是一个平台一个模块,因为字段结构、接口限额和反爬策略完全不同,混在一起维护成本很高。
2.1 先圈定数据源和时间窗,再写第一行爬虫
电影舆情往往在上映前一周开始预热,上映后四周内出现多次波动。时间窗不锁死,采集回来的数据会被预告片阶段的弹幕,以及上映很久之后的口碑贴一起搅浑,情感分布自然失真。所以我在config.yaml里固定start_date、end_date,采集脚本里只保留落在窗内的send_time,而不是把所有能抓到的历史数据都导进来。
选择弹幕还有个理由:弹幕自带video_time,可以对齐正片剧情。短评和微博评论只能告诉你用户整体上是否满意,弹幕能告诉你画面放到哪一段时弹幕量突增、负面评论集中在哪个情节。两者结合,正好覆盖“整片口碑”和“场景反应”两个层次。如果你的数据源只有一个,我优先留弹幕,因为时间戳字段更细,后面做演化分析时比短评好用得多。
2.2 B站弹幕采集脚本:把 XML 转成结构化 DataFrame
下面是一份最小可用的采集器。这里先不加入请求频率控制、重试和断点续传,那些工程化手段会干扰你理解数据字段,等链路跑通再加不迟。
import hashlib import requests import xml.etree.ElementTree as ET import pandas as pd headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } def fetch_danmaku(cid: int, save_path: str = "data/raw/danmaku.xml") -> str: # cid 是视频分 P 的编号,不同视频不一样 url = f"https://comment.bilibili.com/{cid}.xml" # 示例接口,以现网可用端口为准 resp = requests.get(url, headers=headers, timeout=10) resp.raise_for_status() with open(save_path, "wb") as f: f.write(resp.content) return save_path def parse_danmaku(xml_path: str) -> pd.DataFrame: root = ET.parse(xml_path).getroot() rows = [] for node in root.iter("d"): p = node.attrib["p"].split(",") # p[0] 弹幕出现时间,p[4] 发送时间戳,p[6] 用户hash rows.append({ "comment_id": hashlib.md5(node.attrib["p"].encode()).hexdigest(), "video_time": float(p[0]), "send_time": int(p[4]), "user_hash": p[6], "content": node.text }) return pd.DataFrame(rows, columns=["comment_id", "video_time", "send_time", "user_hash", "content"]) if __name__ == "__main__": raw = fetch_danmaku(cid=1145141919) # 示例 cid,运行前换成真实视频的 cid df = parse_danmaku(raw) df.to_parquet("data/processed/danmaku.parquet", index=False) print(df.shape)这段代码里有两个容易写错的地方。一是不能直接用文本内容做去重,同一句话可能被不同用户发送多次;我用p字段整体做 MD5,把发送时间、用户 hash 和位置都带进去,生成的comment_id更接近“一次弹幕行为”的唯一键。二是接口返回内容必须用resp.content而不是resp.text去解析,因为 XML 声明里带编码信息,ElementTree 读原始字节能自动识别编码,避免中文乱码。
采集完立刻做一个最小验证:
df["sys_time"] = pd.to_datetime(df["send_time"], unit="s") print(df["sys_time"].min(), df["sys_time"].max()) print(df.shape)如果min、max与项目说明里的时间窗对不上,说明cid选错了,或者采集到的是已经下线很久的旧弹幕池。此时不管情感模型调得多好,结论都建立在错误的样本上。这类问题越早发现越便宜,所以我通常在采集环节就丢掉不合格的数据,而不是等分析完成后再补救。
提示:平台公开接口可能随版本调整,接口路径最好从
collect.py里拆出去,放进config.yaml,避免每换一部电影就改一次代码。
2.3 字段表与存储格式:为后续分析预留扩展位
解析完成后,字段要先固定下来。内容、时间戳、用户、出现位置是最低要求,其他平台字段可以后续追加。
| 字段 | 类型 | 是否必须 | 用途 |
|---|---|---|---|
| comment_id | string | 是 | 去重主键,由用户、时间、内容共同生成 MD5 |
| video_time | float | 是 | 弹幕在视频中的位置,用于剧情对齐 |
| send_time | int | 是 | 发送的 Unix 时间戳,用于演化分析 |
| user_hash | string | 可选 | 匿名用户标识,用于去重,不能还原为 UID |
| content | string | 是 | 弹幕原始文本,保留最原始版本 |
保存时我推荐parquet而不是 CSV。弹幕文本里包含换行、特殊符号和引号,CSV 写入和再读取时容易错位;parquet 是列式存储,保留 pandas 的类型信息,send_time不会在第二次读入时变成字符串。项目说明里也要标注“原始 XML 不删除”,接口变化后还能重新解析,不用回源平台再爬一遍。
3. 清洗和情感计算:网络舆情分析如何把“口碑”变成分值
弹幕和短评进入 DataFrame 之后,不能直接丢给情感模型。原始文本里混着话题标签、URL、at 用户、表情符号,甚至还有一些残缺的引号和不完整词;不清理,分词会把这些噪声当成独立词,情感分值也会被“哈哈哈”“笑死”这类语气词带偏。清洗不是越多越好,而是要先想清楚“让模型看到什么”。
3.1 先清洗再算情感:正则替换的 3 个优先级
我的清洗顺序是:先删表情符号,再删 URL 和 at,最后删话题标签。顺序有讲究,表情符号落在特殊 Unicode 区域,先处理可以避免后续正则把半个表情符号误认为普通字符;URL 和 at 是结构噪声,对情感判断没有贡献;话题标签“#雄狮少年#”会重复出现在每条微博评论里,如果不删除,分词和 TF-IDF 的结果会被同一个词反复占据。
import re import pandas as pd EMOJI_PATTERN = re.compile("[\U0001F300-\U0001FAFF\u2600-\u27BF\uFE0F]") URL_PATTERN = re.compile(r"https?://\S+") AT_PATTERN = re.compile(r"@[\w\u4e00-\u9fa5\-]+") TOPIC_PATTERN = re.compile(r"#.*?#") def clean_text(text: str) -> str: text = EMOJI_PATTERN.sub("", text) text = URL_PATTERN.sub("", text) text = AT_PATTERN.sub("", text) text = TOPIC_PATTERN.sub("", text) text = re.sub(r"\s+", " ", text).strip() return text df["clean_text"] = df["content"].astype(str).map(clean_text) df = df[df["clean_text"].map(len) >= 2] df = df.drop_duplicates(["comment_id", "clean_text"])len >= 2是过滤单字弹幕,比如“好”“顶”“燃”。这类单字词没有明确对象,统计到正负面比例里容易放大噪声;但如果你想保留微博里的“哈哈哈哈”,阈值会再往上调。这里的取舍要在项目说明里写清楚,否则对方看到情感标签数不对,会先怀疑清洗脚本有 bug。
有一点要注意:如果模型换成 BERT 或大模型,表情符号应该保留。SnowNLP 对 emoji 没有专门向量,留着只会干扰输入长度和分词;但 BERT 类模型能把表情符号和语气关联起来。所以清洗规则不是通用模板,而是跟着模型走。后面 3.2 的阈值表默认针对 SnowNLP,换模型后必须重新标定。
3.2 SnowNLP 打分和阈值表:0.45/0.55 比 0.5 更稳
SnowNLP 的sentiments输出在 0 到 1 之间,很多人习惯直接拿 0.5 当正负分界线。问题在于电影弹幕里存在大量“还行”“一般”“不是不好看,但……”这类犹豫表达,默认模型会把它们压在 0.5 附近。我把正负判断设成两个阈值,中间留一个中性区,情感均值才不会因为模型自身噪声反复横跳。
from snownlp import SnowNLP def score(text: str) -> float: try: return SnowNLP(text).sentiments except Exception: return 0.5 df["sentiment"] = df["clean_text"].map(score) df["label"] = pd.cut( df["sentiment"], bins=[0, 0.45, 0.55, 1.01], labels=["negative", "neutral", "positive"], right=False ) print(df["label"].value_counts())right=False代表左闭右开,0.45 分划到 negative,0.55 分划到 positive。如果漏写这个参数,pandas 默认right=True,0.45 会被分类到中性组,中性评论占比明显偏高,正负对比被压缩。1.01只是为了避免 float 边界把 1.0 排除,实际打分不会超过 1。
| 情感分值范围 | 标签 | 使用建议 |
|---|---|---|
| [0, 0.45) | negative | 找负面集中时段和负面关键词,优先抽原文人工复核 |
| [0.45, 0.55) | neutral | 不参与正负对比,但计入评论量,代表讨论热度 |
| [0.55, 1.01) | positive | 找正面动因,比如“画面”“音乐”“台词”相关词 |
阈值定成 0.45/0.55 之后,要检查正负样本量是否失衡。如果 negative 占比不到 5%,要么是电影口碑真的高,要么是清洗时把大量反讽文本删掉了。判断方法是随机抽 50 条 negative 和 50 条 neutral 看原文,对照项目说明里的业务预期,再决定是否微调阈值。
3.3 聚合指标:不要只报告情感均值
单条评论的情感值抖动很大,“还行”是 0.49,“太烂了”是 0.02,均值很容易被中间段评论拉向 0.5。所以我通常按天或按小时计算mean、std、pos_ratio、neg_ratio四个字段,均值只当参考,正负面占比才是判断舆情方向的主指标。
df["date"] = pd.to_datetime(df["send_time"], unit="s").dt.date stats = df.groupby("date").agg( mean_score=("sentiment", "mean"), std_score=("sentiment", "std"), comment_count=("comment_id", "count"), pos_ratio=("label", lambda x: (x == "positive").mean()), neg_ratio=("label", lambda x: (x == "negative").mean()), ).round(4) print(stats.describe())pos_ratio和neg_ratio用 lambda 逐组计算,在这个量级下没问题,超过 10 万条数据后效率会下降。优化时可以把 label 映射成 0/1/2,再用数值均值去算占比,逻辑是一样的。这里按自然日聚合,适合看每日趋势;如果发现某天波动异常,再到小时粒度里找具体爆发点,也就是第 4 章要做的事。
4. 演化分析与主题挖掘:定位《雄狮少年》舆情的拐点
前面算出的情感分值是静态截面,真正能写进项目报告的是分值在时间轴上的移动。电影舆情不是均匀变化的,某个负面话题出现后几小时内弹幕量陡增,情感均值突然下行;只看得分值不看得分变化率,你会错过真正的舆情转折节点。
4.1 用小时聚合找到“情绪拐点”:不能只看日线
日线适合画总结,不适合找拐点。比如下午 3 点出现一条负面热搜,日线只能显示“今天均分下降”,小时粒度则能看到负面讨论在 15:00—16:00 之间快速到达峰值,又在晚上回落。我在项目里把send_time按小时取整,然后计算相邻小时的变化率。
import pandas as pd df["hour"] = pd.to_datetime(df["send_time"], unit="s").dt.floor("h") hourly = df.groupby("hour").agg( count=("comment_id", "count"), mean=("sentiment", "mean"), neg_ratio=("label", lambda x: (x == "negative").mean()) ) hourly["prev_count"] = hourly["count"].shift(1) hourly["growth"] = (hourly["count"] - hourly["prev_count"]) / hourly["prev_count"] burst = hourly[hourly["growth"] > 0.5].copy() burst = burst[burst["prev_count"] >= 50] print(burst.sort_values("growth", ascending=False).head(10))growth > 0.5表示评论量比上一小时提升 50% 以上,这个阈值不是固定值,数据量越大,可以调得越保守。prev_count >= 50很关键:凌晨 2 点每小时只有 4 条弹幕,下一小时变成 8 条,增长率是 100%,但这是随机波动,不是舆情爆发。
定位到爆发小时之后,再切一个时间窗口,把对应时段里被标记为 negative 的弹幕抽出来:
focus_hour = burst.index[0] mask = (df["hour"] == focus_hour) & (df["label"] == "negative") sample = df.loc[mask, ["video_time", "clean_text"]].head(30) print(sample.to_string())这段样本是给人工复核用的。纯文本情感模型很难识别反讽,比如“画面很燃,剧情像一杯白开水”这类评论,模型可能给出中性甚至正面分数,但实际表达的是失望。人工抽看 30 条,远比直接相信neg_ratio列更接近真实舆论。
4.2 正负面分组提关键词:TF-IDF 比词频更好用
找到爆发时间后,下一个问题通常是“为什么爆发”。我一般把样本按 negative 和 positive 两组分别做 TF-IDF,而不是对所有文本统计词频。词频只能看到“电影”“雄狮”这种背景词,TF-IDF 能把每组文本中相对独特的关键词顶上来,更容易看出负面和正面各自的动因。
import jieba from sklearn.feature_extraction.text import TfidfVectorizer df["tokens"] = df["clean_text"].map(lambda x: " ".join(jieba.cut(x))) for label in ["negative", "positive"]: sub = df[df["label"] == label] if len(sub) < 20: continue vec = TfidfVectorizer( max_features=10, token_pattern=r"(?u)\b\w+\b" ).fit(sub["tokens"]) print(label, vec.get_feature_names_out())这里最容易掉的坑是token_pattern。jieba 产出的文本是用空格分隔的词序列,\b\w+\b能按空格切分,同时排除标点符号。如果你不写这个参数,sklearn 默认正则会把整句“雄狮少年 好燃”当成一个 token,关键词基本没有参考价值。
| 参数 | 推荐值 | 作用 |
|---|---|---|
| floor | h | 按小时对齐,消除秒级抖动 |
| growth 阈值 | 0.5 | 评论量较上小时提升 50%,随数据量调整 |
| prev_count 下限 | 50 | 过滤低基数时段,防止凌晨空档误触爆发 |
| max_features | 10 | 每个情感分组只保留 10 个词,便于人工审阅 |
| token_pattern | \b\w+\b | 配合 jieba 空格分词,按词切分 |
关键词输出之后,我会把 negative 组里的词和爆点小时重叠起来看。比如爆发时间是上映首日晚 20:00,negative 关键词里有“改编”“失望”“节奏”,那基本能锁定争议集中在剧情改编上;如果关键词是“配音”“画面”“角色”,方向又不同。主题挖掘的作用不是替你下结论,而是让下结论的位置更具体。
5. 交付层:让“源码+项目说明.zip”能被重新验证
一个包含源码和项目说明的 zip,价值不在预先算好的图表,而在接收方能否用同样数据把结论重新跑出来。我在整理交付物时,会刻意把“可复现”放在“好看”前面。项目说明里如果缺了数据边界和运行顺序,别人只能看到结果,看不到结果是怎么来的。
5.1 目录结构与配置项
常见做法是放一个 README.md、一个 requirements.txt、一个 config.yaml,再加采集、分析、报告几个入口脚本。原始数据不做进 zip,只放脱敏样本,完整数据留在本地,压缩包体积和传输时间都会可控很多。
xiongshi_opinion/ ├── README.md ├── requirements.txt ├── config.yaml ├── collect.py ├── analyze.py └── data/ ├── raw/ │ └── danmaku.xml └── processed/ └── danmaku.parquetREADME 里的项目说明至少要有三块:数据来源和时间窗、运行顺序、关键结论的可复现性。如果只写“采集了《雄狮少年》弹幕”,接收方不会知道cid从哪来、parquet是谁生成的,也无法判断结论是否能复现。
5.2 用一个最小命令完成端到端验证
我会在 README 里留下一段等价的最小命令链:
pip install -r requirements.txt python collect.py --config config.yaml python analyze.py --config config.yaml --output output/report.jsonconfig.yaml里一般包含这样的结构:
data: start_date: "2025-01-01" end_date: "2025-02-01" platform: bilibili cid: 10765125 model: sentiment: snownlp pos_threshold: 0.55 neg_threshold: 0.45 output: top_keywords: 10cid放在配置文件里而不是写死在代码中,参数化的好处是换一部电影也能复用同一套源码。脚本里用argparse或click读取 config,输出路径、阈值、关键词数量都可以通过命令行覆盖,debug 时不用反复改配置文件。
最后留一个验证技巧:跑完analyze.py后,把total_comment_count、negative_ratio、burst_time三个数值写进output/report.json。后续改代码或换模型,重新运行后先比较这三个值。弹幕总数变化超过 5%,说明采集层的时间窗或接口变了;负向占比变化超过 10%,说明清洗规则或情感阈值被改动;只有当这些基础值和上次输出一致时,新的分析结果才有可比性。
本文还有配套的精品资源,点击获取