恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
从爬虫到情感分析:城市评论数据闭环实践指南
首页
资讯中心
/
从爬虫到情感分析:城市评论数据闭环实践指南
从爬虫到情感分析:城市评论数据闭环实践指南
发布时间:2026/10/13 3:25:47
简介一份面向旅游数据分析与爬虫初学者的完整项目包围绕爬取五万条城市评论并对潍坊、淄博两地的游客情感态度进行文本挖掘。项目从数据采集、清洗、标注到情感分类与结果统计均有完整代码与中间数据能够帮助读者理解评论数据全流程分析。包体共1988个文件压缩后约29.59MB核心以csv评论数据、py爬虫与情感分析脚本、txt中间结果、json配置、md说明文档为主并含少量png/svg图表csv与json保存清洗后的评论与标注结果py脚本覆盖采集和情感分类md为项目说明目录分层清晰。目前已有587人学习下载通过它可快速复现爬虫采集、评论清洗、情感极性判断与可视化等实操流程并得到潍坊、淄博整体评价、满意度及不满意之处的细节洞察为旅游服务优化和舆情管理提供数据参考。1. 从 0 到 5 万条城市评论爬虫与情感分析一次跑通的数据闭环打开任何一个点评网站输入一座城市名返回的评论条数动辄上万。要把这些评论读完一个人不吃不睡也得一整天更别提每一条的褒贬判断。这里的关键是把“爬虫”和“情感分析”串成一条完整链路先用网络爬虫技术抓取城市评论再用情感分析模型为每条评论打一个正负分最后按城市聚合出口碑结论。这篇笔记会按我实际拆项目的顺序把采集、清洗、打分、验证全过程展开中间穿插翻车记录和参数调整。适合刚入门想跑通数据全流程的人也适合做口碑监测、舆情分析的从业者拿去作对照实现。我不会一上来就堆框架5 万条评论用 Python 3 环境加 requests、lxml、jieba、snownlp 这几个库就能撑起来。2. 评论爬虫的技术选型与信息结构为什么用 requests 而不是一上来就上 Scrapy2.1 网络爬虫原理与选型requests、Scrapy 与分布式爬虫的边界网络爬虫原理不复杂构造 HTTP 请求、拿回响应、解析 HTML、提取目标字段、把数据落盘。城市评论这类场景单机、中小体量5 万条左右用 requests 爬虫反而是最透明的做法。选型理由我一般看三条。第一可观测性requests 每一步都能直接 print() 中间结果遇到反爬或者页面结构变化当场能定位Scrapy 爬虫项目虽然自带去重、中间件、并发调度但对新手来说一个问题可能被层层封装埋住。第二依赖轻requests 加 lxml 两个库就够了Scrapy 要额外理解 Item、Pipeline、Middleware 的概念学习成本高了一个量级。第三分布式爬虫是后话当单机采集到了几十万、上百万条再按 URL 分片上 Scrapy-Redis 或者 Celery 也不迟5 万条评论按每条请求加延时 1 秒算也就十几个小时requests 足够跑完。如果你刚入门爬虫 Python我建议别在框架选型上花太久。城市评论页面的 HTML 结构相对规整requests 的每一步都能看到响应内容调试体验比 Scrapy 友好太多。等到并发量上来、反爬变得复杂再迁移到 Scrapy 也来得及requests 里的解析逻辑可以直接复用。2.2 信息结构设计抓哪些字段为什么评论正文是核心抓城市评论前先把要落库的字段定下来。我一般会先打开一个评论页面右键查看网页源代码确认关键信息的位置然后设计字段表。字段表如下字段类型说明citystr城市名用于最后聚合poi_namestr景点/商家名称user_namestr评论者昵称做脱敏处理ratingfloat用户打的星级1-5contentstr评论正文情感分析主输入comment_timestr评论时间comment_idstr评论唯一 ID用于去重这个结构用 dict 表示就是这样import hashlib def build_item(city, poi_name, user_name, rating, content, comment_time): return { comment_id: hashlib.md5( f{poi_name}|{user_name}|{comment_time}|{content[:50]}.encode(utf-8) ).hexdigest(), city: city, poi_name: poi_name, user_name: user_name, rating: rating, content: content, comment_time: comment_time, }comment_id 用 hashlib.md5 生成参数是 poi_name 加用户名、时间、内容前 50 字主要目的是不依赖平台自带 ID也能在重复采集时去重。rating 后面可以用来做情感分数的参照比如用户打了 4 星但模型打分偏低说明这个领域的口语表达有特殊性需要回头调整清洗规则或阈值。这个资源里我建议把 content 作为分析主输入因为评星是用户显式行为评论正文才包含真正的情绪而 city 和 poi_name 是后面聚合的维度键没有它们就没法按城市输出结论。user_name 从抓取开始就要做脱敏只保留前两位加星号避免落库后触及隐私问题。2.3 请求头、Cookie 与速率控制反爬的第一道防线抓取城市评论最常遇到的反爬就是请求头校验。服务器会检查 User-Agent、Referer、Cookie 等字段如果 UA 是默认的 python-requests很容易被拒。常见做法是准备一组 UA 轮换import random import time import requests user_agents [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36, Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1, ] def get_headers(refererNone): headers { User-Agent: random.choice(user_agents), Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, } if referer: headers[Referer] referer return headers def fetch_page(url, refererNone): resp requests.get(url, headersget_headers(referer), timeout15) resp.raise_for_status() time.sleep(random.uniform(0.8, 1.8)) return respfetch_page 的逻辑分三段get_headers 负责拼接请求头UA 随机轮换是防止同一 UA 高频出现如果页面做了来源校验就必须带上 referertime.sleep 的随机延时是控制请求速率0.8 到 1.8 秒对单机采集来说既不会太慢也能明显降低被封风险。timeout15 是为了避免某个请求卡住拖死整个任务。这里有个参数要注意如果目标网站校验 Cookie需要提前在浏览器登录后复制 Cookie 放进 headers代码里先空着实际跑之前补上。3. 把 5 万条评论稳定抓完游标续采、去重与数据落盘3.1 采集主循环下一页游标与评论解析分页评论一般有两种翻页方式一是 URL 里带 pagen二是页面里有个“下一页”的链接每次拿新的 href。城市评论网站大多数用前者所以我习惯先定义起始页用 while 循环加当前页游标import lxml.html def parse_comments(html_text): root lxml.html.fromstring(html_text) comments [] # 常见评论节点写法每个评论在 li 里class 含 comment for node in root.xpath(//li[contains(class, comment)]): content node.xpath(.//p[contains(class, content)]/text()) rating node.xpath(.//span[contains(class, star)]/data-rating) if content: comments.append({ content: content[0].strip(), rating: float(rating[0]) if rating else None, }) else: # 没有文本的评论直接跳过避免空数据 continue return commentsparse_comments 的核心是 xpath 的 text() 函数.//p[contains(class, content)]/text()取的是该节点下的文本内容注意 class 里可能有多个样式名contains(class, content) 比精确匹配更稳。返回的 comments 列表里每项只有 content 和 rating完整字段留到主循环里再通过 build_item 组装。然后在主循环里用一个变量 page 记录当前页解析完成、落库成功后 page 1如果某页返回空说明到了末尾可以 break。注意不能直接 for page in range()因为一旦断点续采页码可能从 100 继续而不是从 0 开始。这个 while 加游标的结构是后面所有容错和续采的基础。3.2 断点续采记录抓到的位置崩溃不用从头再来5 万条评论跑十几个小时中途断网、IP 被封、程序异常都很常见。如果没有续采机制重跑一遍成本太高。常见做法是维护一个游标文件import json def load_cursor(pathcursor.json): try: with open(path, r, encodingutf-8) as f: return json.load(f) except FileNotFoundError: return {city: , page: 0, poi_index: 0, done_poi: []} def save_cursor(cursor, pathcursor.json): with open(path, w, encodingutf-8) as f: json.dump(cursor, f, ensure_asciiFalse, indent2)这里的关键是每抓完一页就调用一次 save_cursor而不是等程序正常退出再保存。这样做的好处是进程被 kill、断电、异常 crash 时最多丢最后一页的数据重启后 load_cursor 拿到上次的 page 和 poi_index直接续跑。done_poi 列表用于记录已经跑完的 POI下次重启直接跳过省掉重复请求。保存间隔也可以做成按条数控制比如每抓 200 条存一次。对于城市评论这种单页数据量不大的场景一页一存已经够稳。如果你跑的网站页面解析特别慢可以把 page 字段改成 offset 或 last_comment_id思路一样只是游标粒度更细。3.3 数据落盘与去重SQLite 还是 JSON按量级选采集过程中我会先落 JSON Lines每一行一条评论调试方便文本编辑器直接能看。等项目稳定再统一导入 SQLite 做去重和聚合。SQLite 的建表语句很简单CREATE TABLE IF NOT EXISTS comments ( comment_id TEXT PRIMARY KEY, city TEXT, poi_name TEXT, user_name TEXT, rating REAL, content TEXT, comment_time TEXT );comment_id 设为主键后插入时用 INSERT OR IGNORE 就能天然去重。这里说明一下为什么不用 URL 去重因为一个 POI 的评论列表是动态的新评论会插入URL 永远是同一个而 comment_id 是按内容生成的带唯一性用它才能避免重复入库。数据量不同落盘方式的选择也不一样落盘方式适合量级优点缺点JSON Lines5 万条以内可读性好调试无压力去重需要额外载入内存SQLite10 万到百万条单文件自带去重和查询并发写入一般MySQL百万条以上支持并发采集和分布式需要部署和维护我一般在采集阶段用 JSON Lines跑完一轮后统一导入 SQLite 再清洗。城市评论这种体量根本不需要上大数据组件SQLite 一个文件就能把 5 万条评论和情感分数全装下。4. 评论爬虫避坑指南三个翻车现场的定位与解决4.1 现象抓了 2000 条后请求全部 503IP 被封这是我第一次跑城市评论项目时遇到的前 2000 条一切正常后面连续几十个请求全部返回 503程序直接崩掉。原因是没限速requests 一个接一个发目标服务器检测到同 IP 高频请求触发了封禁。解决分三步。第一步立即停止任务等待 10 到 15 分钟让封禁自动解除不要马上换 IP 硬刚。第二步给每个请求加随机延时时间窗口拉大到 1.5 到 3 秒。第三步加入重试机制遇到 503 时退避重试而不是继续打。def fetch_with_retry(url, max_retries3): for attempt in range(max_retries): try: resp fetch_page(url) return resp except requests.HTTPError as e: if e.response.status_code 503: wait 30 * (attempt 1) print(f第{attempt 1}次重试等待{wait}秒) time.sleep(wait) else: raise这里的退避时间按 30、60、90 秒递增如果做了代理池可以在退避后换一个出口 IP。城市评论网站的数据敏感度不算高绝大多数场景降低频率就能解决不需要一开始就上分布式爬虫。从那以后我在所有采集脚本里都强制加了一个最小时延参数默认 1 秒不允许调成 0。4.2 现象xpath 表达式在浏览器里能匹配代码里却返回空列表第二个翻车现场是解析阶段。我在 Chrome 的开发者工具里复制的 xpath在代码里跑出来是空列表。排查后发现有三层原因第一内容由 JS 动态渲染requests 拿到的 HTML 里根本没有评论数据只有空壳节点。第二目标评论在 iframe 内xpath 默认搜不到 iframe 内部内容iframe 在 HTML 中是一个单独节点需要先定位它的 src 再发一次请求。第三class 名称包含多个值contains 写错位置匹配不上。解决也分情况。先打印响应 HTML 前 1000 个字符确认返回的是不是空壳如果是 JS 渲染有两个选择一是找页面里是否有__INITIAL_STATE__之类的内嵌 JSON直接从 script 节点里提取数据这种方案最稳二是用 requests_html 的 render() 渲染后再解析但它依赖 Chromium运行较慢。如果是 iframe则需要先定位 iframe 的 src再请求该地址单独写一个解析方法。另外在 xpath 里 text() 返回的是列表直接取 [0] 可能报 index out of range所以代码里要先判断 if content 再取 content[0].strip()。这个细节能避免不少报错。遇到取空的情况我一般会打印出当前 URL 和页面标题确认是不是跳到了验证码页这也是一种快速定位手段。4.3 现象评论内容全变成乱码或者 json.loads 时提示截断第三个坑出现在数据入库后。打开 SQLite 表评论内容全是乱码部分字段还出现截断。原因是目标页面没有在响应头里声明 charsetrequests 的 apparent_encoding 猜错常见于部分老网站或者响应体过大被网络层截断。解决方式是在 fetch 里显式指定编码resp requests.get(url, headersget_headers(), timeout15) resp.encoding utf-8 text resp.text如果网站实际是 gb2312就把编码改成 gb18030这个编码集合是 gb2312 的超集能覆盖生僻字。如果 JSON 是截断的问题多半出在接收层被截断可以在请求头加Accept-Encoding: identity禁用压缩传输能减少响应被解压异常的情况另外可以把 timeout 从 15 提高到 30这个参数在很多长评论页面上很关键。5. 情感分析的实现与调优Snownlp 打分、分词与阈值校准5.1 数据清洗去掉与情感无关的噪声采集下来 5 万条评论不能直接进模型。先做一个基础清洗去掉空行、纯标点、无意义碎句。这一步很枯燥但直接影响情感分数质量。代码import re def clean_content(text): if not text: return # 去 URL、用户、表情符号 text re.sub(rhttps?://\S, , text) text re.sub(r\S, , text) text re.sub(r[\U0001F000-\U0001FAFF], , text) # 合并连续空白 text re.sub(r\s, , text).strip() return text def is_valid(text, min_len2): text text.strip() if len(text) min_len: return False # 全是标点符号的过滤掉 if re.fullmatch(r[\W_], text): return False return Trueclean_content 的作用是去掉无信息量内容URL、用户、emoji 在后续处理里都会干扰打分。is_valid 的 min_len 参数按 2 个字符起步太短的评论没有情感判断价值[\W_]全匹配标点时直接丢弃。这两步可以过滤掉大约 10% 到 15% 的噪声数据为模型省下计算量。清洗的一个副产品是归一化。比如“环境不错”和“环境不错”在原始文本里是两个样本清洗后统一去掉多余符号这样模型在训练或打分时不会把标点差异当成语义差异。城市评论里感叹号很常见这个处理对后续阈值校准很有帮助。5.2 Snownlp 批量打分从零开始的情感分数计算Snownlp 是一个纯 Python 的中文情感分析库底层是朴素贝叶斯默认训练语料主要来自电商评论。用法非常简单加载评论文本调用 .sentiments 就返回 0 到 1 之间的值越接近 1 表示越积极。批量处理代码from snownlp import SnowNLP import jieba def score_comment(text): s SnowNLP(text) return s.sentiments # 批量打分示例 scores [] for row in comments: text row[content] if not is_valid(text): scores.append(None) continue # 先切词再拼回便于后续加载自定义词典 seg_list jieba.lcut(text) seg_text .join(seg_list) score score_comment(seg_text) scores.append(round(score, 4))注意Snownlp 可以直接吃原文这里先切词是为了自定义词典的扩展。比如把“性价比”“踩雷”加入 jieba 词典让模型更容易识别这类城市评论的高频词。score 保留 4 位小数后续聚合时会减少浮点误差。这一步跑 5 万条耗时取决于文本长度单线程大概 10 到 20 分钟建议先把流程跑通再考虑用 multiprocessing 切分加速。Snownlp 自带的 train 方法也可以做领域微调。如果你手上有一批已经标注好的景点评论可以拿正负样本各几千条调用 SnowNLP.train 重新训练。不过这个训练过程全在内存里做数据量大时要分批。对 5 万条这个体量我更推荐先用默认模型跑再靠阈值校准兜底比重新训练性价比高。5.3 阈值选择与校准默认 0.5 划分不一定准Snownlp 默认把 0.5 作为积极和消极的分界线但如果你抓的是景点评论很多用户写“风景不错但人太多”这种混合情感会被压缩成一个 0.4 左右的分数不能简单归类为消极。常见做法是第一先按 0.5 切分跑一遍看正负样本分布如果正负比例接近 9:1说明阈值可能需要往 0.6 或 0.65 调。第二抽 100 条人工打标签用交叉验证找阈值。简单做法是写一个循环遍历 0.4 到 0.7 之间的阈值统计每个阈值下的准确率取准确率最高的那个。import numpy as np def search_best_threshold(scores, labels): best_acc, best_thr 0, 0.5 for thr in np.arange(0.35, 0.75, 0.01): preds [1 if s thr else 0 for s in scores] acc sum(p l for p, l in zip(preds, labels)) / len(labels) if acc best_acc: best_acc, best_thr acc, thr return best_thr, best_accsearch_best_threshold 的输入是情感分数列表和人工标注列表1 积极、0 消极步长 0.01 就能满足精度。需要注意如果最好阈值落在 0.35 边缘说明不是阈值问题而是数据本身标注或清洗有问题应回头检查。这里也顺带说一句多模态情感分析的边界。如果评论里包含图片文本情感分析只能覆盖文字信息遇到“图很美但实物失望”这种图文对比效果会打折。多模态情感分析需要同时提取图像特征和文本特征做融合通常要上预训练视觉模型算力成本高很多。对城市评论这个体量先做好文本情感分析把结论跑通比一开始追多模态更划算以后要扩展可以从 CLIP 做图文相似度辅助入手。6. 从情感分数到城市口碑结论聚合计算、可视化与验证技巧6.1 按城市聚合情感均值与正负面占比打分完成后按城市聚合是情感分析落地的关键一步。直接用 pandas 就能得出结果import pandas as pd df pd.DataFrame(comments_with_scores) city_stats df.groupby(city)[score].agg( avg_scoremean, pos_ratiolambda x: (x 0.5).mean(), countcount, )agg 里的三个聚合分别对应情感均值、正负面占比和评论条数。pos_ratio 的含义是情感分大于等于 0.5 的条数除以该城市总评论数这个指标比均值更直观均值容易被极端分数拉偏。6.2 可视化用 pyecharts 输出城市情感对比图pyecharts 能在浏览器里渲染一个可交互柱状图适合直接给非技术角色看。代码from pyecharts.charts import Bar from pyecharts import options as opts bar Bar() bar.add_xaxis(city_stats.index.tolist()) bar.add_yaxis(正向比例, city_stats[pos_ratio].round(3).tolist()) bar.set_global_opts(title_optsopts.TitleOpts(title城市评论正向比例)) bar.render(city_sentiment.html)如果不想引入 pyechartsmatplotlib 画横向条形图也够用关键参数就是旋转 45 度让城市名不重叠。渲染出的 HTML 可以直接交给产品同事不用装 Python 环境。6.3 验证技巧抽 50 条人工核对画分布直方图所有结论在对外之前都值得做一次抽样验证。我习惯从每个城市随机抽 50 条人工标一遍再与模型分数比对记录两条一是正负一致率二是模型在极端分数如 0.05 以下、0.95 以上时是否明显偏离人工判断。再画一张 score 的直方图如果呈现明显的双峰分布集中在两端说明模型区分度不错如果全挤在 0.4 到 0.6 之间说明清洗阶段可能把情感词也滤掉了回去检查正则。以前我抓完 5 万条评论直接进模型跑分输出结果就报给项目组结果被抽查出两条明显积极评论打成消极。后来发现清洗正则里把“不错”这个关键词的否定形态处理错了。从那以后我每次跑完情感分析都会强制走一遍抽样人工核对和直方图检查再对外给结论。希望帮到你。本文还有配套的精品资源点击获取