简介:这是一套基于Python开发的网络舆情分析系统,面向舆情监控管理人员及高校相关专业学生,旨在帮助用户实时采集、分析并可视化社交媒体(如微博、微信公众号、贴吧)中的公众言论,及时识别潜在负面舆情,辅助决策与风险防控。资源包共292个文件,含42个核心Python源码文件(实现数据爬取、情感分析与用户管理)、34个JavaScript与75个GIF动画资源(支撑前端交互与图表动态渲染)、28个JPG/PNG图片及15个CSS样式文件(含layui、layer等主流UI框架),整体压缩包大小为115.16MB。已有150人学习下载,提供完整可运行项目结构,涵盖管理员多级权限控制、用户信息维护、言论数据统计看板及饼状图可视化模块,并内置登录认证、密码修改、用户增删改查等实用功能,适合作为毕业设计参考或舆情分析方向的实战入门项目。
1. 为什么你写的舆情爬虫跑三天就挂了?——一个能真正在业务里跑满30天的Python网络舆情分析系统长什么样
你肯定试过:用requests + BeautifulSoup写个微博热搜爬虫,跑两小时还行;加个关键词过滤、情感打分,第三天凌晨突然报错ConnectionResetError;再补个重试逻辑,第四天又卡在反爬验证码上;最后把selenium加进来,CPU 占满 95%,日志里全是TimeoutException,老板问“舆情日报怎么还没发”,你盯着终端里第17次driver.get()失败的报错,手抖着删掉整个venv。这不是玄学,是没把「网络舆情分析」当一个工程化数据流来设计——它不是单次脚本,而是持续采集、动态去重、语义归类、异常熔断、结果可溯的闭环。这个.zip名字平平无奇,但里面封装的是我在三个政务舆情平台、两个电商客服中台落地过的最小可行系统:不依赖云服务、不调外部API、纯本地 Python 实现,从原始网页到带时间戳/来源/情感分/主题聚类的 Excel 报表,全程可控。适合需要自主掌握数据主权、对响应延迟敏感(比如公关危机5分钟内要出首份摘要)、且不愿被SaaS工具按月收费卡脖子的团队。新手能照着requirements.txt装完就跑通 demo,熟手能直接拆开core/目录改调度策略或换 embedding 模型。
2. 从零搭起舆情数据管道:采集、清洗、存储三步不可省略
网络舆情分析不是“爬下来再分析”,而是“边爬边筛、边存边标、边标边验”。我把整个流程压成三个原子模块,每个模块失败都不影响其他环节继续工作——这是系统能跑满30天的核心设计。
2.1 用httpx+fake-useragent构建抗干扰采集器:为什么不用requests
requests在持续运行时有两个硬伤:一是连接池复用不稳定,长时间运行后ConnectionPool is full错误频发;二是默认不支持 HTTP/2,面对现在主流媒体站(如人民网、澎湃新闻)的 HTTP/2 服务,效率直接打五折。httpx同时支持同步/异步、HTTP/1.1/HTTP/2,且内置连接池自动回收机制。关键参数必须显式配置:
# core/collector.py import httpx from fake_useragent import UserAgent def create_client(): ua = UserAgent(browsers=['edge', 'chrome', 'firefox'], os=['win', 'mac']) return httpx.Client( headers={ "User-Agent": ua.random, "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", "Accept-Encoding": "gzip, deflate", "Connection": "keep-alive", }, timeout=httpx.Timeout(15.0, connect=30.0, read=60.0), # 读超时拉长,防慢响应 limits=httpx.Limits(max_connections=50, max_keepalive_connections=20), follow_redirects=True, http2=True, # 强制启用 HTTP/2 )提示:
fake-useragent的browsers和os参数必须显式限定,否则首次初始化会尝试下载在线 UA 库,导致启动卡死。我一般预生成 200 条 UA 字符串存进data/user_agents.txt,运行时随机读取,彻底规避网络依赖。
2.2 清洗层必须做三件事:HTML 结构还原、噪声标签剥离、文本指纹去重
很多舆情系统翻车,不是爬不到,而是“爬到了垃圾”。比如某论坛帖子正文里混着 12 个<script>标签、4 个广告 div、3 段用户头像 base64 编码——这些全塞进后续 NLP 模型,情感分析结果必然漂移。清洗不是简单get_text(),而是分层处理:
# core/cleaner.py from bs4 import BeautifulSoup import re def clean_html(html: str) -> str: soup = BeautifulSoup(html, 'lxml') # Step 1: 移除所有 script/style 标签及其内容 for tag in soup(['script', 'style', 'nav', 'footer', 'header']): tag.decompose() # Step 2: 保留仅含文本的块级元素,过滤掉纯链接/广告容器 main_content = soup.find('article') or soup.find('div', class_=re.compile(r'content|post|body')) if not main_content: main_content = soup.body # Step 3: 提取纯文本并做基础标准化 text = main_content.get_text(separator='\n', strip=True) text = re.sub(r'\n\s*\n', '\n\n', text) # 合并多余空行 text = re.sub(r'[ \t]+', ' ', text) # 压缩空白符 text = re.sub(r'[\u200b-\u200f\u202a-\u202f]', '', text) # 清除零宽字符 return text.strip() def generate_fingerprint(text: str) -> str: """生成文本指纹用于去重:取前200字符+MD5,避免长文本哈希耗时""" short_text = text[:200].encode('utf-8') return hashlib.md5(short_text).hexdigest()[:16]参数说明:
BeautifulSoup(html, 'lxml')中的'lxml'解析器比默认html.parser快 3.2 倍(实测 10MB HTML 解析耗时从 2.1s 降到 0.65s),且对破损 HTML 容错更强;generate_fingerprint不用全文 MD5 是因为单条新闻平均 1200 字,计算全文哈希在万级数据量下会成为性能瓶颈,而前200字符已足够区分绝大多数同源转载。
2.3 存储设计:SQLite 足够撑住日均 5 万条舆情,但必须加 WAL 模式和 PRAGMA
别一上来就上 MySQL 或 MongoDB。舆情数据写多读少、结构固定(标题/正文/时间/来源/情感分/主题ID)、查询模式单一(按时间范围+关键词检索),SQLite 在 WAL 模式下并发写入性能碾压多数人认知。关键配置三行:
# core/storage.py import sqlite3 def init_db(db_path: str): conn = sqlite3.connect(db_path, check_same_thread=False) conn.execute("PRAGMA journal_mode = WAL") # 启用 WAL,允许多写者 conn.execute("PRAGMA synchronous = NORMAL") # 平衡安全与速度 conn.execute("PRAGMA cache_size = 10000") # 扩大缓存,减少磁盘IO conn.execute(""" CREATE TABLE IF NOT EXISTS news ( id INTEGER PRIMARY KEY AUTOINCREMENT, fingerprint TEXT UNIQUE NOT NULL, -- 文本指纹,强制唯一约束 title TEXT NOT NULL, content TEXT NOT NULL, publish_time TIMESTAMP NOT NULL, source_url TEXT NOT NULL, source_site TEXT NOT NULL, sentiment_score REAL DEFAULT 0.0, topic_id INTEGER DEFAULT -1, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) # 为高频查询字段建索引 conn.execute("CREATE INDEX IF NOT EXISTS idx_time ON news(publish_time)") conn.execute("CREATE INDEX IF NOT EXISTS idx_topic ON news(topic_id)") conn.execute("CREATE INDEX IF NOT EXISTS idx_fingerprint ON news(fingerprint)") return conn血泪经验:
PRAGMA journal_mode = WAL是生死线。没开 WAL 时,10 个采集线程并发写入,SQLite 会频繁锁表,QPS 从 800+ 骤降到 40;开了之后,实测 50 线程写入稳定在 1200 QPS。fingerprint TEXT UNIQUE NOT NULL这一行让重复数据自动被数据库拒绝,比在 Python 层做if not exists判断快 17 倍(因免去一次 SELECT 查询)。
3. 让机器看懂情绪:轻量级情感分析模型选型与本地部署
别碰 HuggingFace 上动辄 1GB 的bert-base-chinese。舆情分析要的是快、准、小——500ms 内完成单条打分,准确率在 82% 以上(人工标注基准),模型体积小于 50MB。我们用SnowNLP做基线,但必须重训其词典并注入领域词。
3.1 为什么SnowNLP比jieba + TextRank更适合舆情场景
jieba分词后走TextRank只能出关键词,无法给出“正向/负向/中性”量化分;而SnowNLP内置基于贝叶斯的情感词典,对短文本(微博、评论)敏感度高。但它原生词典严重偏向古文和通用场景,对“绝绝子”“芭比Q了”“栓Q”等网络热词完全失灵。解决方案:用真实舆情语料微调其sentiment模块。
# core/sentiment.py from snownlp import SnowNLP import jieba # 加载自定义舆情词典(格式:词 词性 权重) def load_custom_dict(dict_path: str): with open(dict_path, 'r', encoding='utf-8') as f: for line in f: parts = line.strip().split() if len(parts) >= 3: word, pos, weight = parts[0], parts[1], float(parts[2]) jieba.add_word(word, freq=5000, tag=pos) # 高频加入jieba # 注入SnowNLP情感词典(需修改snownlp.sentiment包源码) # 实际操作:在snownlp/sentiment/__init__.py中追加 self.words[word] = weight def get_sentiment_score(text: str) -> float: """返回[-1, 1]区间情感分,>0.2为正向,<-0.2为负向""" s = SnowNLP(text) # 关键:对长文本取句粒度平均分,避免标题党扭曲结果 sentences = [s for s in re.split(r'[。!?;]+', text) if len(s.strip()) > 5] if not sentences: return s.sentiments scores = [SnowNLP(sent).sentiments for sent in sentences] return sum(scores) / len(scores)参数说明:
jieba.add_word(..., freq=5000)中freq=5000是经验值——设太低(如100)则新词仍易被切碎,设太高(如50000)则破坏原有分词逻辑;sentences切分用[。!?;]+而非\n,因舆情文本换行不等于语义断句;最终返回sum(scores)/len(scores)而非s.sentiments,实测在微博短评上准确率提升 11.3%(从 76.2% → 87.5%)。
3.2 主题聚类:不用 BERT,用 TF-IDF + KMeans 实现秒级聚类
BERT 做主题聚类?单条推理 2.3 秒,1000 条要 40 分钟,根本没法进实时 pipeline。我们用传统但高效的TfidfVectorizer+KMeans,配合MiniBatchKMeans支持流式更新:
# core/topic.py from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.cluster import MiniBatchKMeans import numpy as np class TopicCluster: def __init__(self, n_clusters=50): self.vectorizer = TfidfVectorizer( max_features=10000, # 限制特征数,控内存 ngram_range=(1, 2), # 加入二元词,捕获"疫情防控"等固定搭配 stop_words=self._load_stopwords(), # 自定义停用词表 min_df=2, # 词频<2的丢弃,去长尾噪声 max_df=0.95 # 出现在95%文档的词丢弃(如"的""了") ) self.kmeans = MiniBatchKMeans( n_clusters=n_clusters, batch_size=200, # 每批200条,平衡速度与精度 random_state=42, n_init=3 # 减少初始化次数,提速 ) self.is_fitted = False def _load_stopwords(self) -> list: # 加载融合版停用词:哈工大停用词 + 舆情特有词(如"转发微博""@某某") with open('data/stopwords.txt', 'r', encoding='utf-8') as f: return [line.strip() for line in f if line.strip()] def fit_transform(self, texts: list) -> np.ndarray: tfidf_matrix = self.vectorizer.fit_transform(texts) labels = self.kmeans.fit_predict(tfidf_matrix) self.is_fitted = True return labels # 使用示例:每天凌晨用昨日新增数据增量训练 # cluster = TopicCluster(n_clusters=30) # new_texts = db.query_new_news(last_24h=True) # 从SQLite查新数据 # labels = cluster.fit_transform(new_texts)避坑点:
TfidfVectorizer的max_df=0.95必须设,否则像“疫情”“防控”这种高频词会淹没真正有区分度的词;MiniBatchKMeans的batch_size=200是实测最优值——设 100 时收敛慢,设 500 时内存暴涨且精度不升反降;ngram_range=(1,2)能抓到“核酸检测阴性”这种完整语义单元,比单纯一元词准确率高 22%。
4. 避坑指南:这5个错误让90%的舆情系统活不过第一周
现象、原因、解决,一条都不能少。以下全是我在客户现场亲眼看着崩掉的案例。
4.1 现象:爬虫跑着跑着突然全部卡死,ps aux显示进程状态为D(uninterruptible sleep)
原因:Linux 内核级 I/O 等待,根源是httpx默认的limits.max_connections=100在高并发下触发文件描述符耗尽。系统ulimit -n默认 1024,100 连接 × 每连接 2 个 socket(client+server)= 200,但实际每个连接还占 DNS 缓存、SSL 握手等资源,10 个线程就轻松突破上限。
解决:
① 启动前执行ulimit -n 65535;
②httpx.Client中显式设limits=httpx.Limits(max_connections=30, max_keepalive_connections=10);
③ 采集线程数严格 ≤ 15(经压测,15 线程在 4C8G 机器上 CPU 利用率稳定在 75%)。
4.2 现象:SQLite 报错database is locked,且持续 30 秒以上
原因:WAL 模式虽启用,但PRAGMA synchronous = FULL(默认值)强制每次写入都刷盘,遇到机械硬盘或高 IO 负载时,写事务阻塞读事务。
解决:
① 初始化 DB 时必须执行PRAGMA synchronous = NORMAL;
② 所有写操作包裹在BEGIN IMMEDIATE事务中,避免隐式事务竞争;
③ 读操作一律用conn.execute("SELECT ...", (), isolation_level=None)禁用自动事务。
4.3 现象:情感分析结果大面积偏正向,比如“服务器崩了,用户投诉不断”被判分为 0.62
原因:SnowNLP原生词典中“崩”“崩了”未收录,分词后只剩“服务器”“用户”“投诉”“不断”,其中“用户”“不断”被标为中性偏正,“投诉”权重不足。
解决:
① 在自定义词典data/sentiment_dict.txt中添加:
崩 v -0.85 崩了 v -0.92 宕机 v -0.88 炸了 v -0.90 芭比Q了 v -0.95②SnowNLP源码中修改snownlp/sentiment/__init__.py的__init__方法,在self.words.update(...)后追加self.words.update(custom_dict);
③ 重启 Python 进程(因词典加载在模块初始化时)。
4.4 现象:主题聚类结果每天变化巨大,昨天“新能源汽车”聚成一类,今天分散到5个簇
原因:KMeans每次fit都随机初始化中心点,且未固定random_state,导致聚类结果不可复现。
解决:
①MiniBatchKMeans必须设random_state=42(任何固定整数);
② 每日聚类不重新fit,而是用partial_fit增量学习:
# 每日凌晨执行 cluster.partial_fit(new_tfidf_matrix) # 传入昨日新数据的TFIDF矩阵③ 保存聚类中心到磁盘:joblib.dump(cluster.cluster_centers_, 'model/centers.pkl'),启动时cluster.cluster_centers_ = joblib.load(...)。
4.5 现象:程序运行一周后内存占用从 300MB 涨到 4.2GB,psutil显示gc.garbage非空
原因:BeautifulSoup对象未显式decompose(),大量Tag实例驻留内存;httpx.Response对象未调用.close(),底层连接未释放。
解决:
① 所有BeautifulSoup处理完立即soup.decompose();
②httpx.Client获取响应后必须:
with client.get(url) as response: html = response.text # 自动 close()③ 每 1000 条数据后手动触发垃圾回收:
import gc if i % 1000 == 0: gc.collect()5. 把日报自动化:用schedule+openpyxl生成带图表的 Excel 舆情简报
系统价值不在后台跑,而在每天早上 8:00 邮箱里那份带趋势图的 Excel。我们不用pandas画图(太重),用openpyxl原生图表功能,10 行代码搞定。
5.1 每日定时任务:schedule比APScheduler更轻量,且无后台进程依赖
APScheduler需要单独启服务、处理 job 持久化,而舆情日报只需每天固定时间执行一次。schedule15 行代码嵌入主循环,零依赖:
# main.py import schedule import time from datetime import datetime import threading def generate_daily_report(): today = datetime.now().strftime('%Y-%m-%d') # 1. 从SQLite查昨日数据 db = init_db('data/news.db') sql = """ SELECT strftime('%H', publish_time) as hour, COUNT(*) as count, AVG(sentiment_score) as avg_sentiment FROM news WHERE date(publish_time) = date('now', '-1 day') GROUP BY hour ORDER BY hour """ rows = db.execute(sql).fetchall() # 2. 生成Excel报表(见5.2节) create_excel_report(rows, f'report/{today}_report.xlsx') # 启动调度器(后台线程) def run_scheduler(): schedule.every().day.at("08:00").do(generate_daily_report) while True: schedule.run_pending() time.sleep(60) # 每分钟检查一次 threading.Thread(target=run_scheduler, daemon=True).start()注意:
daemon=True确保主线程退出时调度线程自动结束,避免僵尸进程;time.sleep(60)是经验值——检查太频繁(如1秒)浪费 CPU,太长(如5分钟)可能错过精确时间点。
5.2 用openpyxl绘制双轴图表:舆情热度(柱状图)+ 情感倾向(折线图)
openpyxl图表能力常被低估。它支持双 Y 轴,且生成的 Excel 兼容 Office 2010+ 和 WPS,无需额外渲染服务:
# utils/excel_report.py from openpyxl import Workbook from openpyxl.chart import BarChart, LineChart, Reference from openpyxl.chart.axis import DateAxis def create_excel_report(data: list, filename: str): wb = Workbook() ws = wb.active ws.title = "舆情日报" # 写入表头 ws.append(["小时", "舆情数量", "平均情感分"]) for row in data: ws.append([int(row[0]), row[1], round(row[2], 3)]) # 创建柱状图(舆情数量) bar_chart = BarChart() bar_chart.title = "每小时舆情数量" bar_chart.x_axis.title = "小时" bar_chart.y_axis.title = "数量" cats = Reference(ws, min_col=1, min_row=2, max_row=len(data)+1) data_ref = Reference(ws, min_col=2, min_row=1, max_row=len(data)+1) bar_chart.add_data(data_ref, titles_from_data=True) bar_chart.set_categories(cats) # 创建折线图(情感分) line_chart = LineChart() line_chart.title = "每小时平均情感分" line_chart.y_axis.axId = 200 # 设为第二Y轴 line_chart.y_axis.title = "情感分" data_ref2 = Reference(ws, min_col=3, min_row=1, max_row=len(data)+1) line_chart.add_data(data_ref2, titles_from_data=True) line_chart.set_categories(cats) # 合并两个图表 bar_chart += line_chart # 插入图表 ws.add_chart(bar_chart, "E2") wb.save(filename)参数说明:
line_chart.y_axis.axId = 200是关键——axId必须为整数且与柱状图不同,否则双轴失效;bar_chart += line_chart是openpyxl合并图表的唯一正确语法,不能用+或append;Reference(ws, ...)的min_row=1包含表头,确保图表识别列名。
5.3 进阶技巧:用jinja2模板生成 HTML 版日报,支持邮件直发
Excel 适合存档,但给领导快速浏览,HTML 邮件更高效。我们用jinja2渲染模板,生成带响应式表格和内联 CSS 的 HTML:
# utils/html_report.py from jinja2 import Template HTML_TEMPLATE = """ <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>舆情日报 {{ date }}</title> <style> table { border-collapse: collapse; width: 100%; } th, td { border: 1px solid #ddd; padding: 8px; text-align: left; } th { background-color: #f2f2f2; } .positive { color: green; } .negative { color: red; } </style> </head> <body> <h2>舆情日报 {{ date }}</h2> <p><strong>总舆情量:</strong>{{ total }} 条</p> <p><strong>热点主题:</strong>{% for t in topics %}<span class="topic">{{ t }}</span>{% endfor %}</p> <table> <tr><th>时间</th><th>数量</th><th>情感分</th></tr> {% for row in data %} <tr> <td>{{ row.hour }}:00</td> <td>{{ row.count }}</td> <td class="{% if row.sentiment > 0.2 %}positive{% elif row.sentiment < -0.2 %}negative{% else %}normal{% endif %}"> {{ "%.3f"|format(row.sentiment) }} </td> </tr> {% endfor %} </table> </body> </html> """ def render_html_report(data: list, date: str, total: int, topics: list): template = Template(HTML_TEMPLATE) return template.render( date=date, total=total, topics=topics, data=[{"hour": r[0], "count": r[1], "sentiment": r[2]} for r in data] ) # 使用:send_email(html_content) 即可发邮件技巧:
jinja2模板中{{ "%.3f"|format(row.sentiment) }}比 Python 字符串格式化快 4 倍(因模板编译后缓存);class动态绑定.positive/.negative让领导一眼抓住风险点;所有 CSS 内联,避免邮件客户端屏蔽外链样式。
6. 我坚持的三个习惯:让舆情系统从“能跑”变成“敢信”
最后说点不写进代码、但决定系统成败的习惯。这些是我踩过 17 次坑后刻进肌肉的记忆。
6.1 每日人工抽检 50 条,建立“黄金样本集”
自动化永远有盲区。我要求自己每天早会前,从昨日入库数据中随机抽 50 条,用 Excel 手动标出:
- 是否真实舆情(排除广告、公告、重复转载)
- 情感分是否合理(-0.95 的“服务器崩了” vs 0.3 的“产品上线”)
- 主题聚类是否准确(“特斯拉降价”和“比亚迪销量破纪录”是否同簇)
这 50 条存为data/golden_sample_20240520.csv,每周用scikit-learn的classification_report评估模型 drift。一旦 F1 下降 >5%,立刻停用当日聚类结果,回滚到上周模型。没有人工校验的自动化,只是把错误规模化。
6.2 所有配置外置为config.yaml,禁止硬编码
config.yaml内容示例:
collector: concurrency: 12 timeout: 15.0 retry_times: 3 user_agent_file: "data/user_agents.txt" storage: db_path: "data/news.db" wal_mode: true sentiment: custom_dict: "data/sentiment_dict.txt" threshold_positive: 0.2 threshold_negative: -0.2 topic: n_clusters: 40 vectorizer_max_features: 10000 report: email_to: ["pr@company.com", "ceo@company.com"] excel_template: "template/daily.xlsx"为什么重要:某次客户要求把情感阈值从
0.2改为0.15,运维同事直接改代码里的if score > 0.2,结果忘了改另一处elif score < -0.2的对应值,导致负向漏报。外置配置后,git diff config.yaml一眼看清变更,且支持灰度发布——先对测试库生效,验证无误再切生产。
6.3 日志必须带 trace_id,且 ERROR 级别自动触发钉钉告警
舆情系统最怕静默失败。我在logging配置中加入trace_id,并在httpx请求钩子里注入:
# core/logger.py import logging import uuid from functools import partial def add_trace_id(logger, method_name, event_dict): if 'trace_id' not in event_dict: event_dict['trace_id'] = str(uuid.uuid4())[:8] return event_dict structlog.configure(processors=[add_trace_id, structlog.processors.JSONRenderer()]) logger = structlog.get_logger() # httpx 请求钩子 def log_request(request): logger.info("http_request", url=str(request.url), method=request.method, trace_id=request.headers.get('X-Trace-ID', 'unknown')) def log_response(response): request = response.request logger.info("http_response", status_code=response.status_code, duration=f"{response.elapsed.total_seconds():.3f}s", trace_id=request.headers.get('X-Trace-ID', 'unknown'))实战效果:当某次
ConnectionResetError发生,我直接在日志平台搜trace_id: a1b2c3d4,5 秒内定位到是www.xxx.gov.cn的 CDN 节点故障,而非我方代码问题。ERROR 日志自动触发钉钉机器人,消息模板:【舆情系统告警】trace_id: a1b2c3d4 | 错误: ConnectionResetError | URL: https://xxx.gov.cn/news | 时间: 2024-05-20 14:22:33
这比等老板问“日报怎么没发”再排查,快 22 分钟。
希望帮到你。
本文还有配套的精品资源,点击获取