Python舆情分析实战:网易新闻评论采集与情感打分平台
简介面向Python课程设计与毕业设计场景的综合性舆情分析项目覆盖网络爬虫、数据清洗、文本挖掘、情感分析、关键词提取及可视化展示等完整技术链路可用作系统实训参考。资源共1403个文件、约23.83MB其中js/css构成前端交互界面py源码与pyc文件承载后端逻辑html页面组织展示结构并含sql数据脚本与csv样例数据便于直接还原项目环境。项目围绕网易新闻及评论展开涉及requests/Scrapy抓取、jieba分词、SnowNLP情感分析、sklearn建模、TF-IDF关键词提取以及matplotlib词云与趋势图表绘制同时通过Flask/Django提供Web入口完整呈现数据采集、存储、分析到展示的流程。已有166人学习/下载适合需要快速搭建同类平台、撰写课程报告或毕业设计说明的开发者参考。1. 用「新闻评论」做舆情热点分析为什么我选网易而不是微博想做舆情热点分析第一反应通常是去翻微博热搜但热搜榜塞满明星八卦和广告位对“某款新品发布后口碑到底怎么样”这种问题几乎没有参考价值。真正能反映公众情绪的信息其实沉在新闻正文和评论区里新闻给出事件主干评论给出人群态度和情绪走向。这个平台方向就是用 Python 把网易新闻的列表、正文和逐条评论拉下来再用分词、时间衰减和情感分析把“什么在热、大家怎么评价”变成可排序、可筛选的榜单。适合做课程设计、舆情值班、产品口碑监测这三类场景单机就能跑不需要 GPU 和大模型。我按这个思路做过一版下面把从采集到展示的动手细节和踩过的坑一次说清楚。2. 平台架构与数据源选型一个 requests SQLite Flask 的四层方案2.1 为什么是网易新闻而不是微博、头条或百度先解决选型问题。微博的搜索接口带签名参数要算加密逻辑数据字段也经常变头条的反爬更强短时间抓多了直接封 IP对新手很不友好。百度新闻虽然聚合来源广但同一事件的不同媒体稿之间评论分散无法把评论和事件稳定绑定。网易新闻的做法是“门户编辑选稿 统一评论系统”每篇文章都有独立的 docurl、commentUrl 和发布时间评论挂在同一个产品 ID 下结构非常干净。更重要的是网易 web 端一直保留着榜单类的 JSONP 接口和评论接口不需要扫码、不需要登录态就能拉到列表页数据。这意味着一个 requests 正则 json 就能完成采集层不需要维护复杂的签名或 cookie。对舆情分析这个场景来说评论的“楼层结构”也很关键——网易评论有楼中楼能按父评论分组方便后面做语义聚类和观点抽样。需要注意网易新闻覆盖时政、财经、科技、体育、娱乐多个频道分类全话题分布广。做热点分析时可以直接按频道分组也可以混合全站数据算热度自由度比单一垂类媒体高。如果你手头的项目只关心某垂直领域比如数码产品口碑建议在采集时限定频道关键词否则分析结果会被娱乐新闻冲淡。2.2 平台分层采集、存储、分析、展示各管一段我习惯把这类舆情分析平台拆成四层避免所有逻辑堆在一个文件里。采集层负责拿新闻列表和评论原文存储层用 SQLite 保存原始数据分析层做分词、热度评分和情感判定展示层用 Flask 起一个 Web 页面把结果以表格和图表的形式给出。这样每层可以单独调试采集挂了不影响分析分析跑挂了原始数据还在随时能重跑。存储层为什么用 SQLite 起步而不是直接上 MySQL单机数据量在十万评论以内时 SQLite 完全够用而且不需要额外装服务端读者拉下来就能跑降低复现门槛。如果你的场景评论量上了百万再把 SQLite 换成 MySQL 的 pymysql 版本采集和分析代码基本不用动。分析层选 jieba 和 snownlp是因为它们都是纯本地计算不需要联网调大模型接口也不会产生额外费用。词级特征对热点分析足够舆论的大方向判断用情感概率足够精确到句法语义不是这个阶段的目标。2.3 环境准备与项目结构从零到能跑的最小依赖开始动手前先确认 Python 环境。建议 Python 3.8 或更高版本VSCode 里配置好解释器后直接装依赖pip install requests pandas flask jieba snownlprequests 负责 HTTP 请求pandas 用来做数据统计和排序flask 是展示层框架jieba 做分词和关键词提取snownlp 做情感判断。这些库都是纯 CPU 计算安装时不会像某些 OCR 框架那样吃满内存按 numpy 的安装方式处理即可。项目目录我一般这样组织便于后面定位问题news_sentiment/ ├── config.py # 全局参数:超时、半衰期、数据库路径 ├── news_spider.py # 新闻列表采集 ├── comment_spider.py # 评论采集与入库 ├── analysis.py # 分词、情感、热点打分 ├── app.py # Flask 展示层 ├── templates/ │ └── index.html └── data/ └── news.dbconfig.py 里放的是所有可调参数requests 超时时间、两次请求间隔、热度半衰期小时数、情感阈值。把这些散落在各文件里的数字收敛到一个文件里后面调参不用翻代码也不容易出现“这个文件改了那个文件没改”的灵异问题。3. 用 requests 拉网易新闻与评论两个核心接口与断点续采3.1 新闻列表接口JSONP 格式与 callback 参数的处理网易新闻 web 端的榜单页用的是 JSONP 接口简单说就是服务端把 JSON 包在一个函数调用里返回浏览器通过 script 标签加载绕开跨域限制。我们用 requests 请求时不能直接 json()得先用正则把外层函数调用剥掉。常见做法是请求排行榜页动态传一个时间戳参数避免命中缓存import requests import re import json import time def fetch_news_list(callbackdata_callback): url https://news.163.com/special/00011K6L/rank_all.html params { _: int(time.time() * 1000), # 时间戳绕开服务端缓存 callback: callback, # JSONP 的回调函数名 } headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64), Referer: https://news.163.com/, } resp requests.get(url, paramsparams, headersheaders, timeout10) resp.raise_for_status() text resp.text # 把 data_callback({...}) 里的 JSON 部分提取出来 match re.search(rdata_callback\((.*)\), text, re.S) if not match: return [] data json.loads(match.group(1)) # 返回结构按频道分组这里把各频道的 list 展开成一条条新闻 news_items [] for key in data: if isinstance(data[key], dict) and list in data[key]: news_items.extend(data[key][list]) return news_items这段代码的核心在 re.search 那一行。JSONP 的响应体形如data_callback({list: [...]})直接 json.loads 一定报错必须先提取括号内的部分。参数_传时间戳是为了让服务端认为这是一次新请求否则可能命中 CDN 缓存拿到旧榜单。headers 里的 Referer 与 User-Agent 一起用能降低被简单风控拦掉的概率。返回的每条新闻里我们最需要的是三个字段title标题、url 或 docurl文章页地址、commentUrl评论入口地址。后续拉评论时要用 commentUrl 里的评论 ID所以这个字段一定要存下来。3.2 评论接口从 commentUrl 里挖评论 ID再拼通用评论 API新闻列表给了 commentUrl但评论接口并不同等于 commentUrl 本身。通常在新闻详情页里能取到一个评论 ID形如一串数字字母组合然后拼到统一的评论 API 上。我一般这样处理import requests import re COMMENT_API https://comment.news.163.com/api/v1/products/{pid}/threads/{cid}/list def extract_comment_id(comment_url): # 从 commentUrl 或详情页里提取评论线程 ID match re.search(rcomment_id([\w\d]), comment_url) if not match: match re.search(r/([\w\d]{16,})\.html, comment_url) return match.group(1) if match else None def fetch_comments(cid, pida28696745714177, limit30): params { offset: 0, # 从第几条开始取 limit: limit, # 每次取多少条 head: 1, # 是否在列表前部返回热门评论 tail: 0, # 是否在列表尾部追加快捷评论 } headers {User-Agent: Mozilla/5.0} url COMMENT_API.format(pidpid, cidcid) resp requests.get(url, paramsparams, headersheaders, timeout10) resp.raise_for_status() data resp.json() # 普通评论在 comments 里热门评论在 newComments 里 return data.get(comments, []) data.get(newComments, [])pid是网易评论系统的产品 ID同一个站点的新闻基本共用这一个值可以放进 config.py 里统一维护。offset和limit是分页参数每页最多 30 到 50 条翻页时把 offset 加 limit 继续请求直到返回为空。head1会把高赞评论放到列表头部这对舆情很有用——高赞评论代表主流情绪tail1则会在尾部拼一批按时间排序的最新评论两者都取更容易看清舆论全貌。这里要注意评论接口返回的 JSON 里comments是普通评论newComments是新增热门评论这两者之间可能有重叠入库去重时不能只看列表长度。3.3 入库与断点续采先落盘再抓下一页绝不把数据放内存采集最怕的不是慢是跑了一半挂掉后要从头再来。我的原则是每抓一条新闻或一页评论立刻写进 SQLite下次启动时先查已存在的 ID跳过去重部分。建表语句和去重逻辑如下CREATE TABLE IF NOT EXISTS news ( docurl TEXT PRIMARY KEY, title TEXT, comment_url TEXT, publish_time TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS comments ( cid TEXT PRIMARY KEY, docurl TEXT, user TEXT, content TEXT, create_time TEXT, score INTEGER, sentiment REAL );import sqlite3 import hashlib DB_PATH data/news.db def get_conn(): conn sqlite3.connect(DB_PATH) return conn def news_exists(conn, docurl): cur conn.execute(SELECT 1 FROM news WHERE docurl?, (docurl,)) return cur.fetchone() is not None def insert_news(conn, item): conn.execute( INSERT OR IGNORE INTO news (docurl, title, comment_url, publish_time) VALUES (?,?,?,?), (item[docurl], item[title], item[commentUrl], item.get(time, )) ) conn.commit() def insert_comment(conn, cid, docurl, user, content, create_time, score): conn.execute( INSERT OR IGNORE INTO comments (cid, docurl, user, content, create_time, score) VALUES (?,?,?,?,?,?), (cid, docurl, user, content, create_time, score) ) conn.commit()INSERT OR IGNORE依赖主键去重docurl和cid分别是新闻和评论的唯一键。评论的cid如果接口没直接给可以用hashlib.md5((user content create_time).encode()).hexdigest()拼一个出来保证同一用户同时发的相同内容不会重复入库。断点续采的体验改进是把“每十页提交一次”改成“每页提交一次”。虽然 SQLite 提交频繁一点会慢但换来的好处是任务中断后已采集的部分全部在库重启脚本直接跳过已存在的 docurl。也别把数据库连接跨线程共享后面避坑章节会专门讲。4. 热点识别与情感分析TF-IDF、热度半衰期与 snownlp 阈值4.1 用 jieba 做分词与关键词提取TF-IDF 参数怎么调新闻标题和评论里藏着的关键词就是热点的直觉来源。jieba 的analyse.extract_tags封装了 TF-IDF 算法不需要自己拼词频矩阵。我一般把标题和正文拼接后提取词性限制在名词、动词和地名上避免“的”“了”这种虚词进榜import jieba import jieba.analyse def extract_keywords(title, content, topK10): text title content return jieba.analyse.extract_tags( text, topKtopK, # 要几个关键词 withWeightTrue, # 返回权重方便排序 allowPOS(ns, n, vn, v), # 只保留地名、名词、动词 )allowPOS是关键参数。默认不限制词性时形容词和副词会大量混入比如“真的”“简直”这种高频词既不能代表事件也不能代表情绪。限制到n名词和vn动名词后提取出来的词基本是“发布会”“价格”“续航”“翻车”这类信息点。需要注意jieba 默认词典对网络新词和产品名覆盖不足比如“某手机型号”会被切成“某”和“手机型号”。解决办法是准备一个 userdict.txt每行一个词加词频和词性在程序启动时加载jieba.load_userdict(userdict.txt)4.2 热度打分模型评论数、点击量时间衰减与半衰期把新闻按评论数直接排序是最容易踩的坑——24 小时前的旧闻评论再多也不是“当下热点”。热度应该是一个随时间衰减的分数我常用的公式是热度 (评论数权重 点击量权重) × 0.5^(新闻年龄/半衰期)。半衰期设成 24 小时表示一篇新闻的热度每过一天衰减一半三天后基本让位给新内容import math import time HALF_LIFE_HOURS 24 # 24 小时热度减半 WEIGHT_COMMENT 1.0 # 一条评论计 1 分 WEIGHT_CLICK 0.3 # 一个点击/阅读计 0.3 分 def hot_score(comment_count, click_count, publish_ts, now_tsNone): now_ts now_ts or time.time() age_hours (now_ts - publish_ts) / 3600.0 if age_hours 0: age_hours 0 decay math.pow(0.5, age_hours / HALF_LIFE_HOURS) raw comment_count * WEIGHT_COMMENT click_count * WEIGHT_CLICK return round(raw * decay, 2)HALF_LIFE_HOURS决定了“热度”的敏感度。设成 6 小时则只反映半天内的爆点适合监测突发事件设成 72 小时则会让热点持续更久适合观察话题周期。评论权重高于点击权重是因为一条评论背后代表用户主动表达了态度价值远高于一次被动阅读。如果你拿不到点击量就把WEIGHT_CLICK设为 0模型退化为评论数时间衰减结果依然可用。发布时间的解析是所有热度的前提后面避坑章节会专门说“今天 11:30”这种非标准时间带来的灾难。4.3 情感分析snownlp 的阈值切分与高赞加权聚合snownlp 是基于贝叶斯训练的库sentiments属性输出 0 到 1 之间的概率越接近 1 越正面越接近 0 越负面。我按 0.4 和 0.6 做三分类阈值低于 0.4 判负面高于 0.6 判正面中间是中性。聚合到新闻层面时不只是简单平均还要给高赞评论更高的权重因为高赞代表更多人的共鸣from snownlp import SnowNLP def classify_sentiment(score): if score 0.6: return 正面 if score 0.4: return 负面 return 中性 def news_sentiment(comments): if not comments: return 0.5, 无评论, 0 total_weight 0.0 total_score 0.0 for c in comments: s SnowNLP(c[content]).sentiments # 权重 1 点赞数开根号防止刷赞数影响过重 w 1.0 math.sqrt(c.get(score, 0)) total_weight w total_score s * w avg total_score / total_weight return round(avg, 3), classify_sentiment(avg), len(comments)snownlp训练语料偏向电商评论对“绝了”“yyds”“绷不住”这类网络用语基本是黑匣子式乱猜。我的习惯是把高赞评论单独抽出来做一轮关键词和情感交叉统计如果一篇新闻的平均情感是正面但高赞前三条全是讽刺性骂评那基本可以判定模型被骗了。这种场景下宁可信人工标记的高赞也不要信模型均值。情感结果写回comments表的 sentiment 字段后面展示层直接查均值画折线图不需要重新分析。5. 舆情采集与分析避坑验证码拦截、时间混乱与情感翻车5.1 坑一评论接口突然返回“请输入验证码”或 HTTP 418现象脚本第一次跑能正常拿几百条评论继续跑十几分钟后requests 返回 200 但内容是验证码页面或者直接出现 HTTP 418。原因网易评论接口有频率触发风控。单 IP 每秒超过 2 个请求就会触发滑块验证码requests 默认的 User-Agent 特征明显也容易进黑名单。解决把请求间隔调到 0.5 秒以上用requests.Session()复用 TCP 连接同时固定 UA 和 Referer。我一般只开两个线程跑评论采集而不是一口气开 10 个线程。另一个有效手段是错峰请求给每个评论 ID 加一点随机偏移后再 sleep避免所有请求以固定节奏出现。import time import random sess requests.Session() sess.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64), Referer: https://news.163.com/, }) # 每页评论请求之间随机停 0.5~1.5 秒 time.sleep(random.uniform(0.5, 1.5))5.2 坑二发布时间的“今天 11:30”解析失败热度排序全乱现象新闻表里发布时间混着“2025-03-01 12:00:00”和“今天 11:30”两种格式。热度排序时解析失败的行时间戳变成 0永远排在最前面榜单被几篇旧文霸屏。原因不同新闻条目在列表接口里的时间字段格式不统一有些是绝对时间有些是服务端渲染的相对时间。直接pd.to_datetime解析只能处理一种格式另一类全部变成 NaT。解决入库前统一转成时间戳解析不了就丢弃该字段并给默认值。我写了一个parse_time函数专门处理“今天”“昨天”“X分钟前”这几种相对表达import datetime def parse_time(text): if not text: return None text str(text).strip() try: return datetime.datetime.strptime(text, %Y-%m-%d %H:%M:%S).timestamp() except ValueError: pass now datetime.datetime.now() if 今天 in text: hm text.split(今天)[-1].strip() dt datetime.datetime.strptime(hm, %H:%M).replace(yearnow.year, monthnow.month, daynow.day) return dt.timestamp() if 分钟前 in text: minutes int(text.replace(分钟前, ).strip()) return (now - datetime.timedelta(minutesminutes)).timestamp() return None解析失败时宁可返回 None 也不返回time.time()。否则一篇三个月前的旧闻会因为“采样时刻即为发布时间”而拿到满热度整个榜单直接废掉。5.3 坑三snownlp 把“绝了”“yyds”全部判成负面现象某条新闻评论里大量出现“这波操作真的绝了”“yyds 永远滴神”情感聚合结果却是负面和常识完全相反。原因snownlp 的语料来自商品评论那个时代的“绝了”多用于差评模型学到的情感权重是负的。网络新词和反讽用法没有进入训练语料它只能按字面概率猜。解决先做高频词筛选把频次超过阈值但情感置信度在 0.4~0.6 之间的评论抽出来人工打标再写一个修正表覆盖模型结果。更简单的办法是建一个自定义情感词典对含“绝了”“yyds”的句子直接设偏移量SENTIMENT_OVERRIDE { yyds: 0.85, 绝了: 0.65, 绷不住: 0.20, } def corrected_sentiment(content): s SnowNLP(content).sentiments for word, score in SENTIMENT_OVERRIDE.items(): if word in content: # 让模型判断和人工先验各占一半 return round((s score) / 2, 3) return round(s, 3)别指望任何模型一次到位。舆情分析的价值在于“能解释结论怎么来的”黑匣子输出不经过人工抽检就发布最后受害的是自己的判断。5.4 坑四SQLite 写入频繁报 database is locked现象评论采集线程一多控制台不断刷sqlite3.OperationalError: database is locked部分评论没写进去。原因SQLite 同一时刻只允许一个写连接。多线程各自sqlite3.connect()再并发commit()数据库文件会被锁住后到的写入直接报错。解决让所有写操作走同一个连接并在外面套一个线程锁或者把评论攒够 50 条再用executemany批量写入减少提交次数。批量写对单机数据的性能提升最明显import threading write_lock threading.Lock() def batch_insert_comments(conn, rows): with write_lock: conn.executemany( INSERT OR IGNORE INTO comments (cid, docurl, user, content, create_time, score) VALUES (?,?,?,?,?,?), rows ) conn.commit()5.5 坑五定时任务跑一半停掉日志只在控制台现象设了每天凌晨 2 点自动采集第二天看数据库发现只采了两三页然后就没动静了。原因任务进程被系统杀掉或者笔记本休眠导致进程暂停而输出全在控制台进程一断什么都留不下。解决每采集一条就同步写库同时把日志写到文件而不是控制台。这样即使中断重跑时自动跳过已采数据日志也能看到最后停在哪条。日志用 Python 自带logging即可输出到文件的同时保留终端输出方便前几分钟观察状态。6. 进阶把平台跑成每天自动更新的小服务并验证结果可信度数据和分析链路跑通之后下一步是把平台从“手动执行”变成“每天自动更新”。Linux 上用 cron 定时执行采集脚本即可建议先采集后分析顺序用 shell 串起来0 2 * * * cd /path/news_sentiment /usr/bin/python3 news_spider.py logs/spider.log 21 0 3 * * * cd /path/news_sentiment /usr/bin/python3 comment_spider.py logs/comment.log 21 0 4 * * * cd /path/news_sentiment /usr/bin/python3 analysis.py logs/analysis.log 21cron 表达式的意思是每天凌晨 2 点采新闻、3 点采评论、4 点分析。错开一小时的原因很简单新闻采集完成后才知道有哪些新评论要采评论采完才能做聚合分析。Windows 用户直接在任务计划程序里设置触发器即可参数一致。这里有个我之前踩过的教训——cron 里的 python 一定要用绝对路径因为定时任务环境不加载你的 shell 配置直接写python3经常找不到解释器。展示层我用 Flask 返回一个 JSON 接口加一个表格页面前端用 pyecharts 画三张图热度趋势折线图、正负面情感占比饼图、关键词词云。Flask 部分只需要一个路由查询当天 TOP20 新闻把标题、热度分、情感标签、评论数渲染到页面。数据量小不需要任何前端工程化一个index.html加模板变量就够。结果验证要从两个方向做。第一是热度榜准不准把当天平台生成的 TOP10 与网易首页热搜榜、百度热搜做对比重合率达到一半以上说明热度模型的时间衰减和权重设置合理如果老新闻长期霸榜把半衰期从 24 小时调短到 12 小时。第二是情感判得准不准手工抽 200 条评论打标与corrected_sentiment的结果对比准确率低于 70% 就回到第 5.3 节扩充修正表。验证这一步不能省很多人批判舆情分析是“数字游戏”多半就是因为拿不出人工对照数据。最后说一个我养成的习惯所有原始数据必须保留在库里不做物理删除只做增量追加。热点分析最怕的不是数据多而是某天跑出来的结果不对想复盘时发现原始评论已经被覆盖。先落盘、再分析、然后展示这个顺序让我少踩了至少三次“结果不可信需要返工”的坑。舆情分析和做料理工序一样原料干净后端的切片炒制才有意义。希望帮到你。本文还有配套的精品资源点击获取