Python爬虫与情感分析实战:五万条城市评论的采集与评分

📅 发布时间:2026/10/17 21:28:29
Python爬虫与情感分析实战:五万条城市评论的采集与评分
简介一份面向旅游数据分析初学者与爬虫开发者的完整项目资源核心围绕“爬取城市评论并做情感分析”展开以潍坊、淄博两地游客评论为样本完整覆盖数据采集、清洗、存储、分析到情感判别的流程。资源包内含1988个文件压缩包大小约29.59MB主要类型包括csv、txt、js、py、json、map等其中csv与txt文件保存了原始评论及中间处理结果py和js脚本用于数据抓取与解析json和map辅助配置信息与可视化展示目录结构清晰适合按模块逐步复现。目前已有587人学习或下载。通过该资源使用者可以掌握真实评论数据获取的实用爬虫写法、中文情感分析的脚本思路与常见排错方法同时获得潍坊、淄博两地的评论数据集以及说明文档和可视化文件便于后续扩展研究或撰写分析报告适合作为爬虫和NLP入门项目的参考范本。1. 爬虫与情感分析五万条城市评论能告诉我们什么当初接到“抓取城市评论做情感分析”这个需求时我先估算了一下成本和收益城市评论的文本密度远高于短视频评论单条评论动辄几十个字包含交通、物价、气候、就业等细分维度五万条文本足够支撑一轮有统计意义的情感分布计算。市面上的舆情分析平台报价不低但自己动手写一套爬虫加情感分析流程其实只需要一台普通电脑跑一个晚上就能拿到底层数据——这份资源的真正价值不在于“五万条”这个数字而在于把“爬取、清洗、打分、可视化”这条链路完整跑通避开了我在这个项目里踩过的所有坑。适合的人群很明确已经会写基础 Python 语法、被反爬机制折磨过的新手以及想快速搭建一个可解释文本分析工具的从业者都可以照着这套流程拿到一份属于自己的城市口碑报告。2. 技术选型requests、SNOWNLP 与 jieba 的职责边界2.1 为什么不用 Scrapy采集规模的决定性因素五万条数据听着多但算下来也就是几千个信息页的事。Scrapy 的优势在于分布式调度、中间件管理和去重队列这是百万级数据量才需要操心的能力。在五万条这个量级上requests 配合 Session 保持连接再加一个简单的循环重试机制足以稳定完成任务。我在这套项目里最终选定了三个基础库requests 负责网络请求BeautifulSoup 负责解析页面结构正则表达式处理嵌套在 JavaScript 变量里的文本字段。选型理由不复杂——这三者都是标准库之外最通用的方案出了问题网上一搜就有答案不需要为一个小项目引入过重的工程框架。import requests from bs4 import BeautifulSoup session requests.Session() session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Accept: text/html,application/xhtmlxml, Accept-Language: zh-CN,zh;q0.8 }) resp session.get(url, timeout10) resp.encoding utf-8 soup BeautifulSoup(resp.text, html.parser)这段代码的关键在 session 的复用爬取城市评论时如果每个请求都新建连接TCP 握手开销会占掉总耗时的近一半而且容易被目标服务器判定为异常流量。timeout 参数设成 10 秒避免某个页面长时间挂起拖垮整轮采集。encoding 字段按照页面 meta 标签来多数中文站点用 utf-8但也有少量老站是 gb2312读取后需要二次检查。2.2 SNOWNLP 与 jieba 的分工逻辑情感分析这条线我先用了 SNOWNLP 的 SnowNLP 对象做粗粒度判断它基于商品评论语料训练对城市评价这种长文本的表现只能算“可用不精准”。原因在于城市评论的句式结构复杂一个句子里可能同时出现“房价高但地铁方便”这样的转折表达简单的情感极性分类处理不了。jieba 在这个项目里干的活是切词和词频统计。城市评论里频繁出现的专有名词——比如具体的地铁线路名、区域板块名、本地化餐饮叫法——会被默认词典切成碎片所以必须准备一个自定义用户词典让 jieba 优先按整体词来切。import jieba jieba.load_userdict(city_words.txt) seg_list jieba.lcut(comment, cut_allFalse)参数说明cut_allFalse 表示精确分词模式每个句子只产出一个最合理的结果组合不会像全模式那样把“华南师范大学”拆成“华南”“师范”“大学”三个独立词。city_words.txt 每行放一个词格式是“词 词频 词性”词频不填时 jieba 会按默认权重处理。2.3 数据存储一条容易被忽略的边界选存储方案时我纠结过 SQLite 和 CSV。CSV 的优势是直观Excel 直接能打开但五万条评论经过分词后会膨胀成几十万行中间数据CSV 的追加写入在多进程场景下会互相污染。最后我选了 SQLite理由是它自带事务机制断点续跑时数据一致性有保障。CREATE TABLE IF NOT EXISTS comments ( id INTEGER PRIMARY KEY AUTOINCREMENT, source_url TEXT, district TEXT, comment_text TEXT, created_at TEXT, sentiment_score REAL ); CREATE INDEX IF NOT EXISTS idx_district ON comments(district);这张表设计的细节在于district 字段单独列出来为后面按区域聚合分析做准备sentiment_score 字段预留 REAL 类型因为 SNOWNLP 的得分是 0 到 1 的连续值不适合用整数存储。索引加在 district 上查询某个区域的所有评论时避免全表扫描。3. 采集落地三个反爬方案与限速策略3.1 第一步识别页面是服务端渲染还是接口返回城市评论系统的常见实现方式有两种服务端直接把评论数据拼在 HTML 返回这种最好处理另一种是页面加载时通过 XHR 请求后端接口拿 JSON 数据这种情况用 BeautifulSoup 解析 HTML 是拿不到数据的。我打开浏览器开发者工具的网络面板刷新评论列表页看到 XHR 分类下有个名叫 get_reviews 的请求返回体是标准 JSON。此时采集器的工作对象从 HTML 变成了 JSON。requests 拿到响应后直接调用.json()方法就能得到 Python 字典解析逻辑比 BeautifulSoup 更简单。这个调整直接影响了整个项目的代码结构——省掉了一层 HTML 解析异常概率也小很多。resp session.get(api_url, params{page: page, limit: 20}) data resp.json() reviews data.get(data, {}).get(list, []) has_more data.get(data, {}).get(has_more, False)这里的 params 参数由 requests 自动编码拼接到 URL 后面不需要手动去处理中文转义。has_more 字段用于翻页控制一般接口设计都会带这个布尔值比自己去算总页数更可靠——有些站点会在翻到最后一页时返回空列表但下一页参数依然存在用没有内容的列表判停就行。3.2 第二步限制速度与线程数采集速度是个两头堵的问题太快会被封 IP太慢五万条数据要跑到天荒地老。我见过有人用单线程慢慢爬结果跑到第 8000 条时目标服务器更新了页面结构整个任务白跑也见过开 20 个协程并发抓十分钟后被限流需要更换网络环境。这个项目里我用了可控的三线程方案。from concurrent.futures import ThreadPoolExecutor import time, random, logging logging.basicConfig(filenamecrawler.log, levellogging.INFO) def fetch_page(page_number): time.sleep(random.uniform(0.5, 1.5)) resp session.get(api_url, params{page: page_number, limit: 20}, timeout10) if resp.status_code 200: save_to_db(resp.json()) else: logging.warning(fpage {page_number} failed, status {resp.status_code}) with ThreadPoolExecutor(max_workers3) as executor: executor.map(fetch_page, range(1, total_pages 1))限速的核心是 random.uniform 的随机区间。固定间隔很容易被识别为脚本行为随机间隔则模拟了人手操作的节奏。0.5 到 1.5 秒的间隔配合三个线程平均每秒抓取约 3 到 4 页一页 20 条数据跑完 2500 页大约需要 12 到 15 分钟。3.3 第三步异常重试与断点续跑网络请求有三类常见故障连接超时、服务端 5xx 错误、被限流后返回 403。最简单的重试策略是对 5xx 类错误做指数退避第一次等 2 秒第二次等 4 秒第三次等 8 秒。403 错误则不需要重试——说明 IP 已经被识别继续请求只会加重问题正确做法是停一小时再继续。断点续跑的核心是记录已经处理到哪一页。我习惯用一张 SQLite 表单独维护采集进度每成功保存一页就更新页码。程序中断重启后先查进度表再决定从第几页继续。def get_last_page(): cursor conn.execute(SELECT MAX(page_number) FROM fetch_progress) return cursor.fetchone()[0] or 0 start_page get_last_page() 1 with ThreadPoolExecutor(max_workers3) as executor: executor.map(fetch_page, range(start_page, total_pages 1))这段代码解决了整个项目最痛的点爬虫崩溃后从头再来。我在这轮项目里至少因为页面结构变化中断过三轮如果没有断点续跑机制每一轮都要重等十分钟预热、半小时抓取时间成本完全不可接受。4. 清洗与打分程度副词词表与逐条评分实现4.1 文本清洗规则原始评论里混着很多非正文内容。最常见的三类纯表情符号用户复制粘贴的营销文案还有重复发布的灌水内容。表情符号在 python 里可以用正则的[\U0001F300-\U0001FAFF]范围匹配掉营销文案的特点是包含外部平台名和二维码提示灌水内容可以用同一用户、同一文本的哈希值去重。import re, hashlib def clean_comment(raw_text): text re.sub(r[\U0001F300-\U0001FAFF], , raw_text) text re.sub(rhttps?://\S, , text) text re.sub(r\s, , text).strip() return text def dedup_key(comment): m hashlib.md5() m.update(comment.encode(utf-8)) return m.hexdigest()去重这里有个坑纯按哈希去重会把两条原本独立、但恰好文案一致的评论误删。评论类文本短重复概率不低。我的处理方式是加上用户 ID 一起参与哈希同一用户发重复内容才判定为灌水不同用户表达相同意思则保留。4.2 程度副词带来权重差异SNOWNLP 的情感得分是 0 到 1 连续值但它对否定词和程度副词的处理不能令人满意。“非常满意”和“满意”两个词输入进去得分差异非常小肉眼几乎分辨不出来。这导致做趋势分析时评论数量接近的两个时间段情感均值拉不开差距。我加了一层程度副词加权规则从公开词表里挑出“非常”“极其”“有点”“不太”等程度词结合 jieba 分词结果判断修饰关系。规则逻辑很简单落在情感形容词前一个位置的程度词会放大或缩小该句的极性强度。intensity_map { 非常: 1.6, 极其: 1.8, 特别: 1.5, 稍微: 0.8, 有点: 0.7, 不太: 0.6 } def adjust_score(raw_score, words): for idx, word in enumerate(words): if word in intensity_map and idx 1 len(words): raw_score * intensity_map[word] return min(max(raw_score, 0), 1)这个加权逻辑的边界是什么对于反向表达“不太满意”如果词表把“不太”的分量设为 0.6实际意思应该是负面的但直接乘系数反而把正面得分拉低了。我后面把否定词独立出来做了极性翻转处理“不太”出现时先把原始极性取反再乘以 0.6 的动摇系数。4.3 完整评分流程封装清洗、分词、加权、打分需要串成流水线每条评论按顺序经过四个环节。代码结构上我写了一个处理函数输入原始文本输出调整后的情感得分和正负面标记。def analyze_comment(text): cleaned clean_comment(text) words jieba.lcut(cleaned) effective_words [w for w in words if w not in stopwords] raw_score SnowNLP(cleaned).sentiments if not effective_words: return 0.5, neutral adjusted adjust_score(raw_score, effective_words) if adjusted 0.65: tag positive elif adjusted 0.35: tag negative else: tag neutral return round(adjusted, 4), tag阈值 0.65 和 0.35 的使用是经验值。SNOWNLP 打出的分数在 0.5 附近聚集明显很多中性描述句得分都落在 0.4 到 0.6 之间所以我把中性区间放宽到 0.3 的跨度只有明确倾向才判极性。这里的 stopwords 是一份基础的停用词表主要过滤“的”“了”“吗”“啊”这类无语义虚词。5. 避坑实录情感分析项目的五个常见问题5.1 问题一Pandas 读取数据库时间字段变成字符串现象SQLite 里的 created_at 字段存的是 ISO 格式文本read_sql_query 读出来后是 object 类型画时间序列图时横轴排序乱掉。原因SQLite 没有独立的 DATETIME 类型写入时用什么类型读出就是什么类型。pandas 不会自动识别时间字符串需要显式转换。解决读取后统一执行pd.to_datetime(df[created_at])。注意格式不统一时加参数format%Y-%m-%d %H:%M:%S避免解析耗时过长。5.2 问题二自定义词典加载后不生效现象city_words.txt 里加了“地铁三号线”这个词分词结果依然拆成“地铁/三号/线”。原因jieba 的词典加载发生在线程池内部每次线程初始化时调用顺序不同部分跑得快的词在加载前就完成了切分。解决把jieba.load_userdict移动到模块顶层进程启动时先完成加载。线程池共享同一个分词器对象不再重复加载。5.3 问题三Token 频率分布出现长尾杂音现象词频统计前十个词里有“我觉得”“感觉”“一个”这类无意义组合。原因基础停用词表覆盖的是单字和双字虚词“我觉得”“感觉”这类组合需要额外过滤。解决统计完成后手工把词频前五十里不属于情感维度的词加入 domain_stopwords.txt重新跑一遍分词环节。这个过程需要迭代两轮才能让词云图里的词都有可读性。5.4 问题四SQLite 并发写入报 database is locked现象线程池跑到一半控制台报错 sqlite3.OperationalError提示数据库被锁。原因三个线程同时向同一个 SQLite 表写入SQLite 只支持单写者并发默认超时时间为 5 秒。解决连接数据库时设置timeout30参数更推荐的做法是线程池内只做内存队列累积统一由单个主线程批量写入数据库。5.5 问题五负面情感判定的语义偏移现象人工抽查时发现“这个城市没啥好玩的”被判成正面得分 0.58。原因SNOWNLP 将“没啥”识别为中性量词“好玩的”被词典切成“好玩/的”“好玩”本身携带正面语义整体得分被拉高。解决手动增加否定词表“没啥”“不怎么”“不太”“几乎没有”这些词出现在句子前三分之一时对整体极性做一次翻转操作。这个规则虽然简单粗暴但在城市评论语料上准确率提升显著。6. 可视化与进阶时间窗口分析技巧情感得分全部落库之后如果只输出一个平均分数字五万条评论的分析价值就浪费了大半。我做的第一张图是按月聚合的情感均值折线横轴是评论发布月份纵轴是当月正负情感比例差值。这张图能直观看到城市口碑的波动周期旅游旺季与就业招聘季的评论情绪差异一目了然。import pandas as pd import matplotlib.pyplot as plt df pd.read_sql_query(SELECT created_at, sentiment_score FROM comments, conn) df[month] pd.to_datetime(df[created_at]).dt.to_period(M) monthly_mean df.groupby(month)[sentiment_score].mean() plt.figure(figsize(12, 5)) plt.plot(monthly_mean.index.astype(str), monthly_mean.values, markero) plt.xticks(rotation45) plt.show()时间聚合的粒度可以根据数据量调整。月粒度适合一年以内的数据集季粒度适合三年以上的长周期观察周粒度会由于样本不均衡产生剧烈震荡需要先检查每周评论数是否超过样本量的最低阈值。词云图是另一个高效工具。处理完停用词和自定义词典后把全部正面评论的分词结果合并成一个字符串用 WordCloud 生成词云。字体参数选择需要注意中文字体要指定系统里已有的字体文件路径否则画出来的图全都是方框。from wordcloud import WordCloud import matplotlib.font_manager as fm font_path fm.findfont(fm.FontProperties(familyMicrosoft YaHei)) wc WordCloud(font_pathfont_path, width800, height600, background_colorwhite, max_words150).generate(positive_text) wc.to_file(positive_wordcloud.png)最后建议你多做一步把每月情感均值和同期的城市事件信息做对照。我在这个项目里发现情感低谷月和某条地铁线路施工公示的月份完全重合这个发现如果不结合时间窗口分析靠人工翻评论是得不出结论的。这套流程跑通之后我又把同样的代码结构迁移到餐饮评论和景区评论上只改自定义词典和采集规则就能复用。从那以后我每次开采集任务都强制走一遍限速、去重、进度记录三件事再也不敢整个白天跑完才发现数据全废了。希望帮到你。本文还有配套的精品资源点击获取