ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

微博热搜情绪分析实战:从爬虫采集到可视化呈现

2026/9/16 6:43:59 拓冰建站 浏览量
微博热搜情绪分析实战:从爬虫采集到可视化呈现 前两周的一个周五下班后我照例刷微博热搜从第一条翻到十五条突然意识到一件事我天天看这个页面却从没想过此刻可能有几千万人正对着这些词条产生情绪。热搜词通常只有十几个字却浓缩了当天最热话题的群众情绪这正好是练手NLP情绪分析的绝佳样本。于是我顺手做了个小项目爬取微博热搜数据清洗后交给情绪分析模型打分再把结果用可视化图表展示出来。整个过程涉及爬虫、NLP和可视化三条线工作量不大但每一环都有值得记录的经验。这篇文章就是我的完整复盘从接口选择到情绪打分从数据库设计到前端图表偏实战可以直接照着搭。1. 为什么拿微博热搜练手数据源选型与整体方案1.1 微博热搜的数据价值实时、公域、结构化做数据分析项目最怕的是“数据看着很多实际全是垃圾”。微博热搜恰恰是一个性价比极高的数据源它有四个特点实时、公域、结构化、情绪密度高。所谓实时是指热搜榜单每几分钟就变化一次天然带时间序列属性非常适合做趋势分析。所谓公域是指不需要登录就能看到大部分内容爬取门槛低也不涉及用户个人隐私数据。所谓结构化是指热搜词条本身就是文本标签不需要从长文章中提取主题省掉大量预处理工作。至于情绪密度高一条热搜往往是大众情绪的爆发点比如“某地暴雨”“某团队夺冠”“某新功能发布”这些词条背后天然对应着恐惧、兴奋、期待等情绪这对情绪分析模型是非常友好的语料。当时选型时我还考虑过几个替代数据源豆瓣电影短评、贴吧帖子标题、知乎热榜。豆瓣短评质量高但更新慢贴吧噪音太多知乎热榜也不错但接口反爬比微博强不少。综合下来微博热搜在“获取难度”和“分析价值”之间最平衡。1.2 技术栈与整体链路整套项目我拆成了四条线采集、清洗、分析、展示。链路是Requests 脚本定时采集热搜 → 正则和 jieba 清洗 → 情绪分析打分 → SQLite 入库 → Flask 提供 JSON 接口 → ECharts 前端渲染图表。技术选型如下表这套组合是我权衡过门槛、效果和部署成本后的选择。环节选型理由爬虫Requests BeautifulSoup备用解析不引入 Scrapy避免过度设计热搜接口返回 JSONRequests 完全够用存储SQLite每天数据量在几百到上千条单文件数据库零部署比 MySQL 好维护清洗正则 jieba热搜词就是短文本正则处理标点和标签jieba 负责分词情绪分析规则粗筛 LLM APISnowNLP 等本地库在短文本上准确率差LLM 效果好但慢两者配合最划算后端Flask轻量写几个接口就完事不碰 Django 这类重型框架前端图表ECharts WordCloudECharts 折线图适合看时间趋势词云适合看高频词分布1.3 这个项目练到了什么我总结一下如果你把这套流程走完至少能覆盖四个方面的能力爬虫层面接口发现、请求头伪装、频率控制、异常重试。NLP层面短文本情绪分析的常见坑、LLM API 的提示词设计和结果解析。可视化层面ECharts 折线图和柱状图的基本用法、中文词云的生成姿势。工程层面SQLite 表结构设计、增量去重、定时任务调度。整个项目所有代码加起来大约六七百行一个周末完全可以跑通基础版本。如果想动手术加功能后面可以继续扩展比如接入 Redis 做缓存队列或者用 Redis 可视化工具排查缓存的 Key 状态但这是后话SQLite 在这个量级完全够用。2. 热搜接口观察与爬虫实现先看浏览器做了什么2.1 接口发现比想象中简单很多人习惯直接去搜现成的爬虫代码但我建议你先自己打开浏览器抓一次包。方法很简单打开微博热搜页面按 F12 进入开发者工具切到 Network 面板然后刷新页面在筛选框里输入 hotSearch你会看到一个 XHR 请求接口路径类似https://weibo.com/ajax/sid/hotSearch。这个接口返回的是标准 JSON核心字段在data.realtime数组里。每个元素大致长这样{ note: #全国多地迎来降温#, word_scheme: 全国多地迎来降温, category: 社会, raw_hot: 3285000, flag: 0 }note是页面展示的热搜词条category是话题分类raw_hot是热度值flag用来标识是否为广告位。拿到这个结构爬虫代码就清晰了直接用 Requests 请求接口解析 JSON把需要的字段取出来。2.2 一个干净可跑的采集函数我的采集函数代码如下加了重试和简单限速避免把请求频率打得太高import requests import time HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Referer: https://weibo.com/hot/search, Accept: application/json, text/plain, */*, } def fetch_hot_search(retry3): url https://weibo.com/ajax/sid/hotSearch for i in range(retry): try: resp requests.get(url, headersHEADERS, timeout10) if resp.status_code 403: print(触发风控等待后退避重试) time.sleep(60 * (i 1)) continue resp.raise_for_status() data resp.json() realtime data.get(data, {}).get(realtime, []) return realtime except Exception as e: print(f第 {i 1} 次请求失败: {e}) time.sleep(3) return []这里有三个细节值得说请求头里User-Agent和Referer一定不能省。微博接口会校验来源不带 Referer 大概率返回 403 或者空数据。不要把整个页面 HTML 拿下来解析页面结构经常改而 XHR 接口相对稳定改版也不会频繁动数据结构。频率控制是爬虫的道德底线更是生存之道。我只做定时采集间隔至少 30 分钟完全不打扰服务端。任何情况下都别做高并发抓取热搜数据不值得账号和 IP 被封更不值得。2.3 反爬应对当接口返回空或者 403 的时候很多爬虫教程只教你怎么请求不教你怎么处理意外情况。我实际跑下来的经验微博这个接口的封禁是“阶梯式”的刚开始可能是返回 200 但data.realtime为空数组再严重一点才是 403。遇到这两种情况正确的做法是立即停手等 10 到 30 分钟再继续不要换 IP 疯狂试。把采集间隔拉长远比任何伪装技巧管用。我后来为了避免空数据影响分析在入库前加了一层判断如果本次抓取结果为空直接跳过写入保留上一次的数据状态这样即使某次抓取失败也不会产出空白记录。3. 数据清洗与入库热搜词里藏着不少“脏数据”3.1 三类必须处理的噪音直接把接口返回的note扔给 NLP 模型效果会打折扣。我总结了热搜词里最常见的三类噪音第一类是分组标签。热搜词条经常带“热”“沸”“爆”“新”“荐”这类后缀它们不代表文本语义只是热度标记。如果不处理分词和情绪分析会被这些无关字符干扰比如“某歌手演唱会爆”会被当成一句话来分析实际上的核心词是“某歌手演唱会”。第二类是话题符号和括号内容。note里可能带#号部分词条还带括号补充说明例如“某公司回应(官方通报)”这些括号部分往往是事件背景而非热搜主体情绪倾向未必一致。第三类是 emoji 和特殊字符。热点词条偶尔混入 emoji比如“某地天气真冷❄️”对 LLM 影响不大但你会后悔没处理它——尤其在做词云的时候一堆 emoji 变成方框画面非常难看。我的清洗函数如下import re def clean_word(raw: str) - str: if not raw: return # 去掉话题符和首尾空格 text raw.replace(#, ).strip() # 去掉热度标记后缀 text re.sub(r(热|沸|爆|新|荐)$, , text).strip() # 去掉括号及其内容 text re.sub(r[\(][^)]*[\)], , text).strip() # 去掉大部分 emoji 和特殊符号 text re.sub(r[\U0001F000-\U0001FAFF\u2600-\u27BF], , text) return text.strip()注意正则里的 emoji 范围我这里覆盖的是补平面和常用符号区的表情实际跑下来能过滤掉九成以上的 emoji。3.2 SQLite 表结构与写入逻辑清洗完的数据我落到了 SQLite。表结构设计如下CREATE TABLE IF NOT EXISTS hot_search ( id INTEGER PRIMARY KEY AUTOINCREMENT, rank_no INTEGER, hot_word TEXT, category TEXT, raw_hot INTEGER, clean_word TEXT, emotion_label TEXT, emotion_score REAL, created_at TEXT, hour_bucket TEXT ); CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_item ON hot_search(hot_word, hour_bucket);几个字段的设计意图clean_word是清洗后的文本专门给 NLP 和词云用。emotion_label和emotion_score是情绪分析的结果先预留后填。hour_bucket是小时桶比如2025-01-10 14作用是方便后续按小时聚合趋势。唯一索引idx_unique_item是增量去重的关键。同一个热搜词在一个小时内只会出现一条记录INSERT OR IGNORE可以避免重复写入。写入逻辑也很简单import sqlite3 from datetime import datetime def save_to_db(items): conn sqlite3.connect(hot_search.db) now datetime.now().strftime(%Y-%m-%d %H:%M:%S) hour_bucket datetime.now().strftime(%Y-%m-%d %H) for idx, item in enumerate(items[:50], start1): conn.execute( INSERT OR IGNORE INTO hot_search (rank_no, hot_word, category, raw_hot, clean_word, created_at, hour_bucket) VALUES (?, ?, ?, ?, ?, ?, ?), (idx, item.get(note, ), item.get(category, ), item.get(raw_hot, 0), clean_word(item.get(note, )), now, hour_bucket) ) conn.commit() conn.close()我每次只取前 50 条原因很简单热搜榜前 50 是大众注意力最集中的区域分析价值最高没必要把全部词条都入库数据量小还能让后续处理更快。4. 情绪打分为什么我放弃 SnowNLP 改用大模型 API4.1 通用情感库和短文本的矛盾一开始我也走了“经典路线”用 SnowNLP 给热搜词打情感分。结果跑了一批数据发现它的判断基本没法用。问题的根源在于SnowNLP 是基于商品评论语料训练的它的情感判断更多依赖“好、差、专业、速度”这类词而热搜词条是没有完整语境的短文本。比如“某地气温骤降记得添衣”SnowNLP 看到“骤降”会给出很低的负面分但实际上这条词条传递的是媒体对公众的关怀大众情绪应该偏中性甚至正向。又比如“某游戏新版本上线玩家直呼真香”这里的“真香”是网络黑话词典方法完全不理解给了一个没什么区分度的分数。我做了个小对比采样了三条热搜词分别跑 SnowNLP 和 LLM API结果差异非常明显。热搜词条SnowNLP 情感分人工判断LLM 输出今日气温骤降 记得添衣0.21明显偏负中性偏正向neutral某游戏新版本上线 玩家直呼真香0.62不痛不痒正向positive早起困难户的周一 状态有点emo0.38偏负负向但带调侃negative热搜词本来就没有完整句法再好的词典模型也巧妇难为无米之炊。所以我果断放弃本地词典方案转向大模型 API。4.2 LLM API 的接入与提示词设计我用的是国内可以直接调用的 LLM API接入难度不高。核心在于提示词设计我的系统提示词和调用代码如下import requests import json import time SYSTEM_PROMPT 你是一个网络热词情绪分析助手。 请判断给定微博热搜词条所反映的公众情绪倾向。 只输出 JSON格式为 {label: positive/neutral/negative, score: 0到1之间的小数, reason: 一句话原因} 不要输出任何多余内容。 def analyze_sentiment(text, api_url, api_key): payload { model: glm-4-flash, messages: [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: text} ], temperature: 0.2, max_tokens: 100 } try: resp requests.post( api_url, headers{Authorization: fBearer {api_key}, Content-Type: application/json}, jsonpayload, timeout15 ) resp.raise_for_status() content resp.json()[choices][0][message][content] result json.loads(content) return result.get(label, neutral), float(result.get(score, 0.5)) except Exception as e: print(f情感分析失败: {e}) return neutral, 0.5这段代码有三个关键点提示词里强制指定输出 JSON 格式。如果让它自由发挥返回文本里会夹杂解释解析起来非常痛苦。我在提示词里写死“只输出 JSON”并在解析时用json.loads直接解析。temperature 设成 0.2。情绪分析不是创作任务需要稳定输出温度越低结果越稳定。异常兜底返回 neutral。网络波动或者 API 超时是常事兜底逻辑保证整条数据链路不会断。4.3 成本与速度的折中先规则粗筛再调 LLM发现 LLM 效果好之后我又遇到另一个问题一次调 API 大概需要 1 到 3 秒如果每批次 50 条热搜都调一遍单次要一两分钟时间倒还能接受但没必要所有词条都花这个钱。实际上很多热搜词的情绪倾向是显而易见的比如词条里直接含“泪目”“愤怒”“难受”“幸福”“骄傲”这类强情绪词规则就能准确判断。所以我的最终方案是“规则粗筛 LLM 精评”STRONG_NEGATIVE_WORDS [泪目, 崩溃, 愤怒, 痛心, 去世, 灾难, 地震, 事故] STRONG_POSITIVE_WORDS [夺冠, 点赞, 骄傲, 真香, 太强, 官宣, 获奖, 圆满] def quick_rule(text): for w in STRONG_NEGATIVE_WORDS: if w in text: return negative for w in STRONG_POSITIVE_WORDS: if w in text: return positive return None先跑规则如果命中了强情绪词直接给结论没命中再调用 LLM。这样大约一半的词条可以不经过 API速度和成本都降下来了。这里我特别想强调不要迷信任何一种模型也不要看不起规则。在实际工程里规则模型组合往往是性价比最高的方案。5. 可视化Flask ECharts 把 SQLite 里的情绪画出来5.1 三个图表的设计思路数据分析的结果最后要能直观呈现。我没有做复杂的大屏只做了三个图表第一个是“近24小时情绪趋势折线图”横向是时间纵向是情感分数均值用三条线分别表示 positive、neutral、negative 的占比变化。这个图能直观看出一天当中大众情绪的波动比如晚上文娱热搜多positive 曲线会抬头。第二个是“实时热搜榜 Top10 柱状图”把当前热度最高的词条按热度值画成横向柱状图用于快速浏览当前焦点。第三个是“热词词云”把所有清洗后的热搜词用 jieba 分词统计词频输出一张词云图。这个图信息密度低但视觉冲击力强适合放在页面头部。5.2 Flask 接口与前端核心代码Flask 后端只做一件事从 SQLite 查数据组装成 JSON 返回。核心代码from flask import Flask, jsonify, render_template import sqlite3 app Flask(__name__) def query_db(sql, args()): conn sqlite3.connect(hot_search.db) conn.row_factory sqlite3.Row rows conn.execute(sql, args).fetchall() conn.close() return [dict(r) for r in rows] app.route(/) def index(): return render_template(index.html) app.route(/api/trend) def api_trend(): rows query_db( SELECT hour_bucket, AVG(CASE WHEN emotion_labelpositive THEN 1 ELSE 0 END) AS positive_rate, AVG(CASE WHEN emotion_labelnegative THEN 1 ELSE 0 END) AS negative_rate FROM hot_search WHERE hour_bucket datetime(now, -24 hours) GROUP BY hour_bucket ORDER BY hour_bucket ) return jsonify(rows) app.route(/api/top) def api_top(): rows query_db( SELECT hot_word, raw_hot FROM hot_search WHERE id IN (SELECT MAX(id) FROM hot_search GROUP BY hot_word) ORDER BY raw_hot DESC LIMIT 10 ) return jsonify(rows) if __name__ __main__: app.run(debugTrue)前端我用 ECharts 的 CDN 版本页面核心代码就是初始化图表然后 fetch 接口数据再 setOption。折线图的部分我贴一下div idtrend stylewidth: 100%; height: 400px;/div script srchttps://cdn.jsdelivr.net/npm/echarts5/script script fetch(/api/trend) .then(r r.json()) .then(data { const chart echarts.init(document.getElementById(trend)); chart.setOption({ tooltip: { trigger: axis }, xAxis: { type: category, data: data.map(d d.hour_bucket) }, yAxis: { type: value, max: 1 }, series: [ { name: 正向占比, type: line, data: data.map(d d.positive_rate) }, { name: 负向占比, type: line, data: data.map(d d.negative_rate) } ] }); }); /script这个方案的好处是前端逻辑极简所有数据操作都在后端 SQL 里完成图表只是“翻译官”。如果你对前端布局有更高要求可以换成 AdminLTE 或 H-UI 这类后台模板做整体框架但核心图表依然是 ECharts。5.3 中文词云的坑默认字体不支持中文词云我用的是 Python 的 WordCloud 库。第一次生成时输出的图里全是方框折腾了半天才发现问题WordCloud 默认字体是 DroidSansMono不包含中文字形需要手动指定一个中文字体路径。from wordcloud import WordCloud import jieba text .join(words) wc WordCloud( font_pathC:/Windows/Fonts/simhei.ttf, # 换成你系统里的中文字体 width800, height400, background_colorwhite ).generate(text) wc.to_file(hot_word_cloud.png)在 Windows 上直接用simhei.ttf就行也就是黑体在 Linux 服务器上需要装fonts-wqy-zenhei之类的字体包然后指定对应的字体路径。这个坑不算深但不踩一遍的人还真想不到。6. 从“跑一次”到“天天跑”定时任务与趋势观察6.1 用系统定时任务驱动采集脚本写好了不能天天手动跑。我用系统自带的定时任务让采集脚本每 30 分钟执行一次。在 Linux 环境下编辑 crontab 加一行*/30 * * * * cd /path/to/project /usr/bin/python3 fetch_and_analyze.py logs/crawler.log 21在 Windows 下用“任务计划程序”创建一个任务触发器选“重复任务间隔 30 分钟”操作里指向 Python 解释器和脚本路径即可。注意执行时的工作目录一定要切到项目目录否则相对路径的数据库文件会找不到。每次执行的任务分两部分先爬取数据入库再对新增的clean_word统一做情绪分析并回填字段。回填的逻辑是查出所有emotion_label IS NULL的记录逐条分析后 UPDATE。6.2 增量数据合并与去重定时任务跑起来之后最担心的就是重复数据。我的方案前面说过就是唯一索引 INSERT OR IGNORE。但这里有一个细节唯一索引的粒度是(hot_word, hour_bucket)。为什么要用小时桶而不是分钟或者秒因为热搜词在一个小时之内重复出现在同一榜单上是很正常的我们希望保留它在这个小时内的出现记录同时避免一个小时内重复写入几十次。小时粒度刚好平衡了“去重”和“保留时序信息”。等数据跑了两三天之后你再看hot_search表会发现一条条时间序列记录非常规整每个小时有哪些词、情绪标签是什么、热度多少全都有据可查。到这一步项目已经不是玩具了而是真正可以持续积累的数据资产。6.3 我跑了一天后看到的几个现象我让脚本连续跑了一天多回看数据发现了几个挺有意思的趋势晚间 21 点到 23 点娱乐、综艺类热搜比例明显上升正向情绪占比也同步升高。说明大众在晚上刷热搜更多是“放松”心态爱看点开心的。工作日中午 12 点到 13 点“外卖”“职场”“健康”类词条出现频率上升负面情绪占比微微抬头大概是打工人的午间吐槽时间。某天全国多地降温的热搜冲上榜单后短时间内出现大量“冷”“降温”“注意保暖”相关词条情绪以中性为主说明这类实用性信息不煽情但也抓住了关注。这些观察并不复杂但给了我很大满足感。数据从“字符串列表”变成“有情绪的时间序列”之后整个项目就活过来了。7. 四个让我卡壳的细节排坑流水账7.1 接口突然返回空数据第一时间想的是“被墙了”不是我第一次遇到接口返回空数组时第一反应是风控封禁赶紧停了脚本。后来仔细检查发现其实是当天的热搜榜在凌晨三四点处于更新间隙接口返回的realtime数组本来就是空的不是被限制。这个教训是爬虫出问题先看数据再判断封禁。我在采集函数里加了日志把响应原文和状态码都记录下来方便事后定位。不要一遇到异常就慌空返回也可能是对方服务正常的边界状态。7.2 SQLite 提示 database is locked定时任务跑了两天后某次采集突然报database is locked。原因是我的爬虫脚本和 Flask 服务同时打开了数据库爬虫在写入时持锁Flask 在查询时拿不到读锁。解决办法有两个一是把 Flask 从开发模式换成生产模式避免多线程频繁查询二是开启 SQLite 的 WAL 模式。WAL 模式允许读和写并行极大减少锁冲突。我在初始化数据库时加了一句conn.execute(PRAGMA journal_modeWAL;)加完之后再没出现过 locked 报错。7.3 控制台看到一堆 \u 开头的内容别慌第一次打印情绪分析返回结果时控制台里全是\u67d0\u5730...这样的转义字符我当时以为 API 返回了乱码。后来发现这是 Python 在控制台对 Unicode 字符串的显示方式数据本身是好的打印出来不好看而已。排查方法很简单用json.dumps(result, ensure_asciiFalse)打印确保中文正常显示。这个问题本身不是 bug但排查过程浪费了我不少时间。7.4 词云生成了但图上是方框这个前面已经说过就是字体问题。但这里我还想补充一句在服务器上跑定时任务生成词云时字体路径要和本机不一致Linux 下最常见的是找不到字体文件所以我把字体文件放在项目目录下用绝对路径引用避免环境差异。font_path/path/to/project/assets/simhei.ttf把字体拷贝进项目能省掉以后换环境时的一大堆麻烦。最后再分享一个小技巧。这个项目跑起来之后不要急着加功能先让它“裸跑”两三天。你会发现真正有价值的往往不是前期的技术实现而是数据积累到一定量之后你能从趋势里读出的信息。我后来还想把分析结果接入企业微信机器人每天早上自动推送前一天的微博情绪报告但因为时间关系还没落地。这个方向可以作为后续扩展工具链全是现成的无非是多写一个推送函数的事。