☰
微博文本分析全流程:Python爬虫、清洗、分词与可视化
2026/9/29 8:09:35 网站建设 项目流程

简介:这是一份围绕微博用户数据采集与文本分析的Python爬虫资源包,面向具备基础爬虫知识、希望进阶到数据清洗与可视化分析的开发者。通过给定目标用户UID,可抓取个人资料、原创与转发微博(含图片、视频),进而生成关键词词云、电子名片,按日/月/年度统计点赞和转发趋势,绘制微博@好友关系图;同时支持按评论数阈值抓取热门评论,统计高活跃用户并挖掘狂热粉丝。压缩包为zip格式,整体约8.25MB,便于下载解压与按需查阅。目前已有5405人学习。内容以完整功能实现为主线,覆盖请求解析、数据存储、文本处理和可视化呈现的典型流程,对微博反爬应对、评论筛选阈值设定、关系网络绘图等环节都有可借鉴的实现思路,适合作为中级Python学习者的综合实战参考,也可为社交媒体舆情分析项目提供胶囊式模板。

1. 用爬虫抓微博文本做分析:先搞清楚这套流程值不值得做

想分析一个微博账号的发文习惯、情绪变化和话题偏好,最笨的办法是一页页翻手工复制。但当你面对几百条、上千条微博时,手工这条路基本走不通。用 python 爬虫 抓取微博用户的历史微博,再对这些文本做清洗、分词、情感分析和可视化,是一条成熟且可复现的路线。

这套流程适合谁?适合需要做用户画像、竞品账号内容分析、舆情监测的从业者,也适合拿真实中文数据练手的数据分析新手。难点不在爬虫本身,而在微博的登录态维护和反爬频率控制。只要把这两点理顺,后面的文本分析和可视化其实都是标准动作。

2. 微博数据采集:从 Requests 到 Cookie 池,把登录态和反爬机制先理顺

2.1 为什么微博爬虫首选 Requests 而不是 Scrapy

常有人一上来就上 Scrapy,觉得分布式爬虫才够专业。但单用户微博采集场景,数据量最多几千条,Scrapy 的工程成本反而成了负担。Requests 轻量、直观,能直接看到每次请求的响应状态,调试反爬时特别方便。Scrapy 的下载中间件、Item Pipeline 都要额外配置,一旦遇到微博这种登录态频繁失效的站点,排查成本会成倍上涨。

另一个理由是微博接口返回的是 JSON,Requests 拿回来后用 resp.json() 就能直接解析,不需要像 Scrapy 那样还要定义 Item 和 Feed 导出。当然,如果你要爬几千个用户的全部微博,再考虑 Scrapy 的并发和断点续爬。个人做分析,Requests 足够。

2.2 构造带 Cookie 的请求:最小可运行脚本

微博大部分数据接口都要求登录态。常见做法是先从浏览器登录微博,把 Cookie 复制出来,写进代码里。注意不要用模拟登录去撞验证码,那是给自己找麻烦;手动维护 Cookie 反而更稳。下面是能跑通的最小脚本:

import requests import time # 从浏览器复制,注意 Cookie 会过期,过期后重新复制 COOKIE = "SINAGLOBAL=xxx; SUB=xxx; SUBP=xxx; ..." 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", "Cookie": COOKIE, "Referer": "https://weibo.com/", "X-Requested-With": "XMLHttpRequest", } def fetch_user_timeline(uid, page=1): url = "https://weibo.com/ajax/statuses/mymblog" params = {"uid": uid, "page": page, "feature": 0} for attempt in range(3): try: resp = requests.get(url, params=params, headers=HEADERS, timeout=10) resp.raise_for_status() return resp.json() except requests.RequestException as e: print(f"第{attempt + 1}次请求失败: {e}") time.sleep(2) return None

这里 url 用的是 weibo.com 的 ajax 接口,它返回的 JSON 里 data.list 就是当前页的微博数组。params 里的 uid 是要分析的微博用户 ID,page 是页码。feature 置 0 表示只取原创微博,如果想要转发微博可以改成 1,不过后面清洗会多一步。

参数说明:timeout=10 是单次请求的等待上限,避免某次请求卡死整个脚本;重试 3 次且每次睡 2 秒,是应对微博偶发超时的最低策略。Referer 和 X-Requested-With 两个头在 ajax 接口上不能省,缺了很容易被当成异常请求。

2.3 翻页与时间线处理:按 page 和 since_id 抓取全量

上面的 fetch_user_timeline 只抓一页。微博用户时间线单页返回大约 10~20 条,要抓全量就得翻页。常见做法是循环递增 page,直到返回的 data.list 为空,或者解析到的微博 ID 出现重复。还有一种基于 since_id 的分页方式,但 ajax 接口用 page 更直观。

一个完整的采集循环可以这样写:

import json import time from sqlalchemy import create_engine, Column, String, DateTime, Text from sqlalchemy.orm import declarative_base, sessionmaker # 数据库表定义 Base = declarative_base() class WeiboPost(Base): __tablename__ = "weibo_posts" id = Column(String(32), primary_key=True) # 微博ID,天然去重 uid = Column(String(32), index=True) text = Column(Text) # 清洗后的正文 raw_text = Column(Text) # 原始文本 created_at = Column(DateTime, index=True) reposts_count = Column(String(16)) comments_count = Column(String(16)) attitudes_count = Column(String(16)) engine = create_engine("sqlite:///weibo.db") Base.metadata.create_all(engine) Session = sessionmaker(bind=engine)

上面这段把数据表结构先定好,下面抓取循环里直接往这张表写。用微博 ID 当主键,天然去重,重复抓同一页也不会产生脏数据。

def crawl_all(uid, max_pages=100): session = Session() page = 1 while page <= max_pages: data = fetch_user_timeline(uid, page) if not data or not data.get("data") or not data["data"].get("list"): break posts = data["data"]["list"] for p in posts: # 部分微博没有文本字段,先留空 text = p.get("text_raw") or p.get("text") or "" row = WeiboPost( id=str(p["id"]), uid=str(uid), text=text, raw_text=text, created_at=parse_weibo_time(p["created_at"]), reposts_count=str(p.get("reposts_count", "")), comments_count=str(p.get("comments_count", "")), attitudes_count=str(p.get("attitudes_count", "")), ) session.merge(row) # 有则更新,无则插入 session.commit() print(f"page {page} done, got {len(posts)} posts") page += 1 time.sleep(3) # 单用户采集,3秒间隔足够温和 session.close()

这里 session.merge() 是 SQLAlchemy 的按主键更新或插入方法,比先查询再判断效率高。created_at 需要把微博的时间字符串解析成 Python datetime,微博返回的格式类似 "Tue May 30 10:00:00 +0800 2024",建议用 datetime.strptime 解析,时区固定 +0800。

注意 max_pages 只是保护性上限,实际循环会在 list 为空时自动退出。sleep(3) 是刻意为之,微博对单 IP 高频请求很敏感,宁可慢一点也不要让脚本中途死在验证码上。

2.4 数据落库:用 SQLAlchemy 储存爬虫数据,别再把结果堆在 CSV

很多人爬完微博喜欢直接存 CSV,几行代码就完事。但一旦涉及后续的文本分析和可视化,CSV 的缺点就暴露了:去重要额外写逻辑,字段类型混乱,长时间多次采集后文件越来越大,Excel 打开还容易乱码。用 SQLAlchemy 存 SQLite 或 MySQL,是更稳妥的做法,也方便后续用 SQL 做日期筛选、聚合统计。

上面代码里已经给出了表结构,这里补充一个从数据库读取的示例:

from sqlalchemy import func # 统计某用户每天发博数 with Session() as s: daily = s.query( func.date(WeiboPost.created_at).label("day"), func.count(WeiboPost.id).label("cnt") ).filter(WeiboPost.uid == uid).group_by("day").all() for day, cnt in daily: print(day, cnt)

这个聚合查询后面会直接喂给 ECharts 画时间线图。SQLAlchemy 的 func.date 是把 datetime 截断到天,group_by 后得到每日发博量。如果换 MySQL,语法完全一样。用数据库的好处就是你不用在内存里维护一堆 dict,分析维度多了也方便。

提示:Cookie 里的 SUB 和 SUBP 是关键字段,失效后接口会返回重定向或空 list。建议每次采集前先跑一个测试请求,确认返回码 200 再进入循环。

3. 数据清洗与文本预处理:把“转发微博”“表情符号”“@用户”先收拾干净

3.1 清洗规则:去重复、去转发标记、去URL和话题标签

爬下来的微博文本基本不能直接用。常见脏数据有三类:以“转发微博”开头的空转发、带 http 链接的推广文案、以及大量 @用户 和 #话题#。这些内容对词频和情感分析都会造成干扰,需要单独清洗。

一个基础清洗函数可以这样写:

import re def clean_weibo_text(raw): if not raw: return "" # 去掉“转发微博”这种无内容前缀 text = re.sub(r'^转发微博', '', raw) # 去掉URL text = re.sub(r'http[s]?://\S+', '', text) # 去掉@用户 text = re.sub(r'@[\w\u4e00-\u9fa5\-]+', '', text) # 去掉#话题#,但可以保留标签内容另作统计 text = re.sub(r'#([^#]+)#', '', text) # 去掉HTML实体和图片占位符 text = re.sub(r'<[^>]+>', '', text) text = re.sub(r'\[(图片|视频|音乐)\]', '', text) # 去掉多余的空白 text = re.sub(r'\s+', '', text).strip() return text

清洗逻辑里有个取舍:话题标签里的关键词往往很有分析价值,直接删掉会损失信息。常见做法是清洗正文时删掉标签符号,但把标签内容单独提取出来,存成另一个字段。这样既不影响高频词统计,也能单独做话题热度分析。

另外,微博文本里经常混有表情符号和特殊 emoji,比如“哈哈哈[允悲]”“[doge]”。中括号里的内容是微博自带的表情占位符,可以直接删掉。如果保留,分词时会被切成“允悲”“doge”这类无意义词,拉低词频统计的准确性。对于真正的 Unicode emoji,可以用正则匹配[\U0001F300-\U0001F9FF]一并移除,不过如果你打算做情绪分析,也可以先留着,观察表情与情感值的关系。

3.2 中文分词与停用词:jieba 和自定义词典的配合

清洗后的文本要分词才能统计词频。中文分词首选 jieba,简单好用,而且支持自定义词典。微博文本里有很多网络新词、缩写(yyds、emo、破防),默认词典切不准,需要手动维护一个自定义词典文件。

import jieba jieba.load_userdict("weibo_dict.txt") # 每行一个词,可以带词频和词性,例如:yyds 100 n stop_words = set() with open("stopwords_cn.txt", encoding="utf-8") as f: for line in f: word = line.strip() if word: stop_words.add(word) def tokenize(text): words = jieba.cut(text) return [w for w in words if w not in stop_words and len(w) > 1]

这里加载的 stopwords_cn.txt 是常见的哈工大停用词表,根据场景可以自己增删。注意 filter 条件里 len(w) > 1 会把单字词过滤掉,这在微博文本里能去掉大量“了、的、我”等语气词,但也会误删“梦”“家”这种有实义的单字。如果你的分析对象里单字词很关键,这个条件要改成 >= 1。

自定义词典是 jieba 用法里最容易忽略的一环。默认词典不包含“yyds”“绝绝子”这类网络表达,不加载词典的话,“yyds”会被切成“yy”“ds”,词频统计直接失效。我一般把微博里高频出现的网络词、人名、产品名,按每行一个词维护在 weibo_dict.txt 里,积累一段时间后,分词效果会明显变好。

3.3 构建分析语料:按时间、按频率、按关键词筛选子集

全量微博文本做出来的分析往往太散。更常见的做法是按维度切片:只看某个月的微博、只看转发超过 100 的微博、只看包含某个关键词的微博。切片之后再做词频和情感分析,结论才有针对性。

from sqlalchemy import Integer, cast def load_texts(session, uid, start=None, end=None, min_reposts=None, keyword=None): q = session.query(WeiboPost).filter(WeiboPost.uid == uid) if start: q = q.filter(WeiboPost.created_at >= start) if end: q = q.filter(WeiboPost.created_at <= end) if min_reposts is not None: # reposts_count 存的是字符串,要转成整数后再比较 q = q.filter(cast(WeiboPost.reposts_count, Integer) >= min_reposts) if keyword: q = q.filter(WeiboPost.text.like(f"%{keyword}%")) rows = q.all() docs = [] for row in rows: cleaned = clean_weibo_text(row.text) if cleaned: docs.append(cleaned) print(f"筛选后语料规模: {len(docs)} 条") return docs

注意 reposts_count 在表里定义成了 String,因为微博返回的字段有的是字符串,有的是数字,统一转成字符串最省事。需要数值比较时就 cast。这个筛选函数是后面所有分析的基础,建议把 start、end、min_reposts、keyword 做成参数,方便在 Jupyter 里反复试。

筛选之后一定要看一眼语料规模。如果某个月的微博只有 3 条,做词云和情感均值都没有统计意义。我一般会设定一个最低条数,比如少于 20 条就直接提示“样本不足”,不做后续分析。这样能避免因为数据稀疏得出误导性结论。

4. 文本分析与可视化:词频、情感倾向、主题分布怎么落到图表上

4.1 词频统计与词云:从 collections.Counter 到 WordCloud 参数

清洗分词之后的文本列表,直接喂给 collections.Counter 就能得到词频。词云图是微博文本分析最直观的呈现方式,但很多人生成的词云全是高频通用词,问题出在没有好好过滤停用词。

from collections import Counter from wordcloud import WordCloud all_words = [] for text in all_docs: all_words.extend(tokenize(text)) counters = Counter(all_words) # 输出前20高频词 for word, cnt in counters.most_common(20): print(word, cnt) # 生成词云 wordcloud = WordCloud( font_path="msyh.ttc", # 中文字体必须指定,否则全是方块 width=800, height=600, background_color="white", max_words=200, colormap="viridis" ).generate_from_frequencies(counters) wordcloud.to_file("weibo_wordcloud.png")

WordCloud 有两个容易踩的参数:font_path 不指定中文会乱码;generate_from_frequencies 比 generate(text) 更适合已有词频的情况,因为可以避免 jieba 和 WordCloud 内置分词不一致。colormap 控制颜色风格,viridis 是 matplotlib 自带配色,看起来比默认色更专业。

高频词排序之后,通常会发现前几名被“真的”“觉得”“感觉”这类词占据。如果停用词表没有覆盖,可以补充进去再跑一次。词频分析的目的是找出这个账号区别于其他账号的特色用语,而不是验证停用词表够不够全。

4.2 情感分析:基于情感词典和 SnowNLP 的取舍

微博文本情感分析有两个方向:一是 SnowNLP,开箱即用,但它是基于电商评论训练的,用在微博上偏消极的阈值需要重新校准;二是自定义情感词典,准确率高但要人工维护。如果你是做单账号长期观察,我建议先用 SnowNLP 跑一遍,把分数存下来,再抽样看几条人工核对,不要直接信分数。

from snownlp import SnowNLP def sentiment_score(text): # 空文本直接返回0.5,避免影响均值 if not text or len(text) < 2: return 0.5 try: s = SnowNLP(text) return s.sentiments except Exception: return 0.5

SnowNLP 的返回值是 0 到 1,越接近 1 越积极,0.5 是中性。微博里大量“哈哈哈哈”“笑死”这类文本,SnowNLP 经常误判,因为训练语料里没有网络表达。实际使用中,我一般会把分数低于 0.4 视为消极、高于 0.6 视为积极,中间算中性,而不是用 0.5 做硬切。这样分析出来的情绪占比更接近人工判断。

如果你只想看情绪走势,可以不用太纠结单条文本的准确率。对几百条文本求均值后,随机误差会相互抵消,趋势仍然可信。真正需要人工核对的是那些异常高分或低分的微博,比如某天情感均值突然跌到 0.2,去翻原始文本,往往是几条负面博文集中爆发,这时候分析结论才对得上。

4.3 ECharts 可视化:把时间线、高频词、情感占比做成可交互图表

文本分析的结果最终要落到图表上。ECharts 是这里最顺手的工具,它支持从 Python 生成的 JSON 直接渲染。常见做法是用 pyecharts,也可以把聚合结果导出成 HTML 模板里的 JSON 变量。

from pyecharts.charts import Line, Bar, Pie from pyecharts import options as opts # 每日发博量折线图 line = Line() line.add_xaxis([str(d) for d in days]) # days 是日期列表 line.add_yaxis("发博数", counts) line.set_global_opts( title_opts=opts.TitleOpts(title="每日发博量"), tooltip_opts=opts.TooltipOpts(trigger="axis") ) line.render("daily_trend.html")

pyecharts 生成的 HTML 自带 ECharts JS,用浏览器打开就能交互。如果你想把多个图集成到一个页面做可视化大屏,可以用 Page 对象组合多个图表,或者直接把 ECharts 的 option 写进一个 HTML 模板。这里的关键是数据结构要对:x 轴是日期,y 轴是聚合值,前后端分离,方便后续换其他数据源。

除了折线图,情感占比用饼图、高频词用横向柱状图更直观。比如你要做一个“情绪占比”饼图,把积极、中性、消极三类文本的数量统计出来,用 Pie 组件 30 秒就能出图。展示的时候记得给每个图配一句解读,否则可视化只是漂亮的摆设,读者不知道你想表达什么。

5. 微博爬虫避坑指南:登录失效、验证码、频率限制、字段缺失的 4 个典型翻车现场

5.1 现象:Cookie 明明有效,请求却返回 400 或 414

有次我把用户 ID 直接拼在 URL 里,像 https://weibo.com/ajax/statuses/mymblog?uid=1234567890&page=1,结果偶尔返回 400,偶尔 414。后来才发现是某些参数拼接方式触发微博的 URL 长度校验,而且缺少 Referer 也会 400。

原因:URL 过长或 header 不完整。解决:所有参数一律用 params 字典传给 requests.get,不要手拼;Referer 固定为 https://weibo.com/;另外确认 headers 里没有多余的空格或换行。

5.2 现象:爬着爬着突然要验证码,或者被重定向到 passport.weibo.com

这个最常见。开始时一切正常,抓了几百条后突然返回的 JSON 里 data 变成了 None,或者响应内容变成登录页 HTML。

原因:请求频率超过阈值,或者 Cookie 被服务端主动失效。解决:先停掉脚本,等 10 分钟再试;把 sleep 从 3 秒拉长到 5 秒以上;另外检查系统时间是否准确,微博的 Cookie 校验偶尔会带上时间戳。不要尝试去自动识别验证码,那是另一个灰色领域,且对个人分析毫无必要。

5.3 现象:文本里混着大量“转发微博”空内容

你抓到的 list 里,有些微博的 text 字段值是“转发微博”,真正的正文在 retweeted_status 里。如果只取 text,你的语料里会混入大量无意义文本,词频统计里“转发”这个词可能排到第一。

原因:转发微博的正文结构是嵌套的。解决:解析时判断如果 text 是“转发微博”且存在 retweeted_status,就取 retweeted_status 里的 text,同时保留原始转发标记,方便后续分析传播行为。

def extract_text(post): text = post.get("text_raw") or post.get("text") or "" if text == "转发微博" and post.get("retweeted_status"): rt = post["retweeted_status"] text = rt.get("text_raw") or rt.get("text") or "" return text

5.4 现象:字段缺失或错位:有的微博没有 text 字段

微博正文有时候是长文章、视频、直播链接,text 字段可能为空,或者变成了“// :”这种异常值。如果直接 post["text"] 会 KeyError,全脚本崩溃。

原因:微博的多种内容形态共用一个接口,字段并不是每条都齐全。解决:所有字段都用 get() 获取,并给默认值;对 text 做空值判断。另外注意 list 里还可能混有“置顶”“抽奖”这类特殊条目,建议把 raw_text 也存下来,怀疑数据质量时回来查原始值。

5.5 现象:本地能跑,一上服务器就反爬严重

同样的代码和数据,在本地跑一上午没事,放到云服务器上几分钟就被限制。主要是服务器 IP 段被微博标记过,或者同一出口 IP 有大量其他爬虫活动。

原因:机房 IP 的信任度比家用宽带低。解决:把并发降到 1,sleep 提到 8 秒以上;或者干脆只在本地或家用网络跑。如果非要在服务器上跑,先跑一个低频小任务测试,确认连续 30 分钟不出验证码再扩大范围。不要使用任何第三方转发服务,那些 IP 更脏,被限制的概率反而更高。

6. 把分析结果做成长时间序列的观察脚本:一个基于日期聚合的可视化模板

6.1 模板思路:每日发博量、情感均值、Top关键词变化

单次采集只能做切片分析,更实用的做法是把整个流程包装成一个按日期聚合的观察脚本。输入是一个用户 ID,输出是一份包含每日发博量、每日情感均值、每日 Top5 关键词的 HTML 报告。这样你可以每月跑一次,观察账号的内容风格和情绪走向。

模板分析涉及“微博签到数据”这种带时间戳的文本特征,按天切分后能看到用户在特定日期的活跃规律。整体文本分析则是长期趋势,比如某账号从日更变成周更,或者情感均值逐月下滑,都能直观反映在折线图上。

6.2 代码骨架:按天聚合 + ECharts 折线图

下面给出一个可扩展的脚本骨架。它先从数据库读数据,按天聚合出三个指标,最后用 pyecharts 生成两个图:发博数折线图、情感均值折线图。

import datetime from sqlalchemy import func from pyecharts.charts import Line from pyecharts import options as opts def daily_report(session, uid, months=6): since = datetime.date.today() - datetime.timedelta(days=30 * months) rows = session.query( WeiboPost.created_at, WeiboPost.text, WeiboPost.id ).filter( WeiboPost.uid == uid, WeiboPost.created_at >= since ).all() # 按天聚合 daily_count = {} daily_sentiment = {} for created_at, text, wid in rows: day = created_at.date().isoformat() daily_count[day] = daily_count.get(day, 0) + 1 score = sentiment_score(text) daily_sentiment[day] = daily_sentiment.get(day, 0) + score days = sorted(daily_count.keys()) counts = [daily_count[d] for d in days] avg_senti = [round(daily_sentiment[d] / daily_count[d], 3) for d in days] line1 = Line() line1.add_xaxis(days) line1.add_yaxis("发博数", counts, is_smooth=True) line1.set_global_opts(title_opts=opts.TitleOpts(title="每日发博量")) line2 = Line() line2.add_xaxis(days) line2.add_yaxis("情感均值", avg_senti, is_smooth=True, yaxis_opts=opts.AxisOpts(min_=0, max_=1)) line2.set_global_opts(title_opts=opts.TitleOpts(title="每日情感均值")) return line1, line2

这个函数返回两个 ECharts 图表对象,调用方可以分别 render 到 HTML,也可以用 Page 拼在一起。半个月以上的数据画折线图才有意义,所以默认拉取 6 个月。如果用户微博量很大,建议把时间跨度做成参数,避免一次加载太多数据把页面拖垮。

我个人的习惯是每次跑完都把生成的 HTML 文件名带上日期,比如 daily_report_20240601.html,这样月底回看时能快速对比不同阶段的变化。还有一点:情感均值这种指标在样本量少于 10 条的天会很不稳定,我会在图表 Tooltip 里补上当日发博数,否则看单条折线很容易误读。希望这个模板能帮你少走一段弯路,把精力真正放到解读数据上。

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

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

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

立即咨询