简介:利用爬虫获取5万条城市评论并完成情感分析的完整项目包,主要面向旅游数据分析入门者、爬虫学习者和城市形象调研人员。项目以潍坊、淄博两地的游客评论为样本,通过采集大量文本数据,对游客情感倾向、满意度及不满点进行细粒度拆解,为景区服务优化、旅游推广和舆情管理提供参考。压缩包共1988个文件,约29.59MB,包含csv评论数据集、Python爬虫与分析脚本、js/ts辅助模块、json/txt配置与中间数据,以及markdown说明文档,目录结构便于按采集、清洗、分析、可视化流程查看。目前已有587人学习下载。通过案例可掌握评论爬虫的字段设计与反爬应对、情感分析的特征提取与结果呈现,并可直接复用潍坊、淄博评论样本进行扩展研究或模型训练,减少从零搭建环境的成本。
1. 爬虫爬5万条城市评论做情感分析:一次能复用的最小闭环
一次城市级口碑调研,最常见的需求不是“爬得更多”,而是“爬下来之后能算出个靠谱结论”。这个标题里藏着一条完整链路:用 Python 爬虫抓取 5 万条带地理属性的城市评论,清洗后交给情感分析模型打分,最后按城市、商圈、POI 维度聚合成可用的“好感度”数据。它适合做舆情监控、选址评估、用户研究的工程师,也适合想用最短路径跑通“数据采集 → 清洗 → 建模 → 落库”全流程的个人开发者。
一个反直觉的结论先说在前面:这个项目真正的瓶颈不在模型,而在数据质量。5 万条评论里如果混着广告、短评、重复文本和非中文内容,再好的情感模型也会被带偏。所以本文把重心放在爬虫的反爬参数调优、清洗去重、中文情感分析的阈值标定上,按这套流程走,从零到出分析结果约一两天时间。
2. 城市评论爬虫:数据源选型、字段设计与断点续爬
2.1 先定数据源和字段:决定后面清洗和建模成本
城市评论的数据源一般分三类:点评类平台(餐饮、酒店、景点)、出行类平台(机票、火车、目的地攻略)、本地生活社区。选型时除了看数据量,更要看字段完整性——只有评论文本而没有城市/商圈/POI 字段,后面做空间聚合时会直接卡死。
我一般会先确定目标字段,再写爬虫。一个够用的最小字段集是:
| 字段 | 示例 | 说明 |
|---|---|---|
| city | 杭州 | 城市级聚合维度 |
| district | 西湖区 | 商圈级聚合维度 |
| poi_name | 某连锁餐厅 | 门店级聚合维度 |
| poi_type | 餐饮 | 业态筛选维度 |
| rating | 4.5 | 商家原始评分 |
| content | “环境不错但排队太久” | 情感分析主输入 |
| comment_time | 2025-01-12 | 时间筛选维度 |
| user_id | 匿名化字符串 | 用户去重 |
| source | dianping | 数据来源标记 |
真实项目里容易犯的错是“先爬再定字段”,结果爬到一半发现缺少时间或城市信息,只能回头补爬。字段设计这步花十分钟,能省掉后面数小时的返工。
2.2 requests 批量请求 + xpath 解析:最小可用抓取脚本
数据源选定后,抓取层用 requests + lxml 就能跑通。先不急着上 Scrapy,它虽然管好了调度和去重,但排错成本对新手偏高。一个手写的循环反而更容易看清每一步发生了什么。
import time import random import requests from lxml import html 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", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", } def fetch_comments(url, page): # 每页请求间隔随机化,避免固定频率被识别为脚本访问 time.sleep(random.uniform(1.2, 3.5)) resp = requests.get(url, headers=HEADERS, timeout=10) if resp.status_code != 200: return [] tree = html.fromstring(resp.text) # 评论列表通常包裹在 li 或 div 中,用 xpath 定位 items = tree.xpath('//li[@class="review-item"]') data = [] for item in items: city = tree.xpath('//meta[@name="city"]/@content') poi_name = tree.xpath('//h1[@class="poi-name"]/text()') content = item.xpath('.//p[@class="review-content"]/text()') rating = item.xpath('.//span[contains(@class,"star")]/text()') if content: data.append({ "city": city[0] if city else "", "poi_name": poi_name[0].strip() if poi_name else "", "content": content[0].strip(), "rating": rating[0].strip() if rating else "" }) return data这段代码的关键点有三个:UA 伪装、随机延时、xpath 定位到列表项。UA 伪装是绕过最基础反爬的手段;随机延时 1.2 到 3.5 秒是为了不让请求频率形成固定节奏;xpath 里用contains(@class, "star")而不是精确匹配,避免平台改样式后选择器失效。
解析时多用strip(),评论文本两端常常带有换行和空格。如果平台列表页里没有完整评论,需要进入详情页,可以在列表页先抓详情页 URL,再对每个 URL 发起请求——但注意,这个操作会把请求量放大一个数量级,延时必须加长。
2.3 把抓取变成“不会中途报废”的工程:CSV 落盘、断点续爬与异常重试
爬虫最怕的不是慢,是跑到第 3 万条时被平台封了 IP,手边只有内存里的一堆变量,全丢。所以从第一行代码开始,每拿到一页结果就写盘,而不是等全部抓完再存。用 Python 内置的csv模块加追加模式,便已足够。
import csv from pathlib import Path def save_batch(rows, filepath="comments.csv"): path = Path(filepath) new_file = not path.exists() with open(path, "a", encoding="utf-8-sig", newline="") as f: writer = csv.DictWriter(f, fieldnames=list(rows[0].keys())) if new_file: writer.writeheader() writer.writerows(rows) def crawl_with_resume(start_page, end_page, url_template): seen = set() # 已抓过的页记录在当前进程内,重启时从 start_page 重新开始 for page in range(start_page, end_page + 1): url = url_template.format(page=page) try: rows = fetch_comments(url, page) if rows: save_batch(rows) seen.add(page) except Exception as e: print(f"[error] page {page}: {e}") continueutf-8-sig编码写盘,是为了让生成的 CSV 能被 Excel 直接打开不乱码;newline=""防止空行插入;seen集合在当前进程内做页码去重。断点续爬的“续”体现在start_page参数上,中断后从失败页继续,比从零开始划算得多。
真正上规模时,5 万条评论大概对应 3000 到 8000 个页面,单机跑需要几小时到十几个小时。中间如果出现连续报错,常见做法是先停 30 分钟再继续,而不是硬刷重试。硬刷只会让封禁时间更长。
3. 把5万条评论洗到能建模:清洗、去重与数据质量
3.1 URL、文本哈希与相似度三重去重
平台详情页不重,但同一个 POI 的评论可能被多页重复抓取,或者详情页有多入口导致重复入库。去重是清洗第一步,首选文本哈希去重,简单且对短文本敏感。
import hashlib import pandas as pd df = pd.read_csv("comments.csv", encoding="utf-8-sig") # 基于评论文本生成 sha1 哈希,结合 poi_name 做复合去重 def make_hash(row): raw = f"{row['poi_name']}|{row['content']}".strip() return hashlib.sha1(raw.encode("utf-8")).hexdigest() df["text_hash"] = df.apply(make_hash, axis=1) df = df.drop_duplicates(subset=["text_hash"], keep="first") print(f"after hash dedup: {len(df)}")复合键poi_name|content比单独用 content 更稳——同一个用户在不同的店写一样的“挺好的”,会被误删。对于更隐蔽的重复(同一内容被平台改个字重发),再补一道相似度去重:
from difflib import SequenceMatcher def similarity(a, b): return SequenceMatcher(None, a, b).ratio() # 按 poi_name 分组,组内两两比较,相似度超 0.9 视为重复 df_unique = [] threshold = 0.9 for _, group in df.groupby("poi_name"): kept = [] for _, row in group.iterrows(): dup = False for k in kept: if similarity(row["content"], k["content"]) > threshold: dup = True break if not dup: kept.append(row) df_unique.append(row) df = pd.DataFrame(df_unique)这种两两比较是 O(n^2),5 万条数据量下按 POI 分组后单个组内一般只有几百条,跑得动。它抓的是“平台微调文本”造成的重复,哈希层抓不到的,这层兜住。
3.2 标准过滤:短评、广告、非中文与语言混杂
城市评论里短评占比极高,“好”“不错”“还行”这种三个字以内的评论,情感分析模型几乎无法判断真实倾向,建议过滤。广告内容则是另一个大敌,常见形态是“加微信 xxx”“代刷好评”等模式。
import re def is_noise(text): text = text.strip() if len(text) < 10: return True # 中文字符占比低于 50% 视为噪声 zh_chars = re.findall(r'[\u4e00-\u9fff]', text) if len(zh_chars) / max(len(text), 1) < 0.5: return True # 广告常用联系方式的模式 if re.search(r'(微信|VX|wx|q{2,}|加我|私聊)', text, re.IGNORECASE): return True return False df["is_noise"] = df["content"].apply(is_noise) df = df[~df["is_noise"]] print(f"after noise filter: {len(df)}")长度阈值 10 是经验值,你能看到把阈值调到 20 会把“环境很好,服务一般”这类有效短评误杀;调到 5 又挡不住“真好”“绝了”这类无意义文本。所以我这里采用 10 和中文占比 50% 的双条件过滤,在保留信息量和降噪之间取平衡。
这个阈值是“玄学”成分极高的参数,不同平台、不同数据源差异很大。建议拿到一批数据后先随机看 200 条再定最终值,不要照抄网上的配置。
3.3 给样本贴标签:为后续评估情感模型留一个评价基准
这个步骤最容易被跳掉,但它决定了你后面对“情感分析结果”有没有底。具体做法是:清洗后随机抽样 300 条,人工按“正向/中性/负向”打标签,存成一份基准文件。
sample_df = df.sample(n=300, random_state=42) sample_df["label"] = "" sample_df.to_csv("eval_baseline.csv", encoding="utf-8-sig", index=False)打好标签后,这份 300 条基准可以用来算情感分析模型的精确率和召回率。5 万条数据在模型层没有必要全部人工标注,300 条的评估集已经足够指出现有模型哪里偏了。
这个步骤会花 30 到 60 分钟时间,它的价值在后期:当你怀疑“模型是不是把中性都判成正向了”,拿这份基准跑一遍便知道答 案。没有基准就调参,只能是靠感觉,精度无法量化。
4. 中文情感分析落地:规则基线、SnowNLP 与结果落库
4.1 选型理由:为什么可以先从 SnowNLP 起步而不是 DeepSeek
很多从业者一上来就部署大模型做情感分析,这不是不可以,但在 5 万条评论、预算有限、结果需要可复现的调研场景里,基于 SnowNLP 这类轻量词典方案是性价比更高的起点。它基于词频和规则,零显存需求,5 万条在单机上十分钟内跑完,对中文短文本的基线效果通常在 0.7 到 0.8 的准确率,足够回答“哪个商圈口碑更好”这类问题。
大模型的优势在于理解复杂语义,比如“排队一小时但味道值得”这种转折句。但它的成本在于私有化部署要 GPU,调 API 要按量收费,还有一个隐性成本:输出不稳定。同一句话跑两次可能给出不同极性,这对需要聚合分析的口碑调研是个麻烦。所以更稳妥的路线是先用轻量模型跑一遍,拿人工标注评估,再把模型分偏的样本批量交给大模型修正。这个“规则兜底 + 大模型微调”的分层路线,是大模型时代做情感分析的主流架构。
4.2 SnowNLP 批量打分:阈值分为三类的实践
import pandas as pd from snownlp import SnowNLP df = pd.read_csv("cleaned_comments.csv", encoding="utf-8-sig") def sentiment_score(text): try: return SnowNLP(text).sentiments # 0~1,越接近1越正向 except Exception: return None # 批量打分,5万条大约耗时几分钟 df["score"] = df["content"].apply(sentiment_score) # 常见做法是把连续分映射为三分类 def polarity(score): if score >= 0.6: return "positive" if score <= 0.4: return "negative" return "neutral" df["label"] = df["score"].apply(polarity) df.to_csv("scored_comments.csv", encoding="utf-8-sig", index=False)阈值 0.6/0.4 是词典模型的通用配置,但强烈建议用自己的标注集重算一遍。将标注为“正向/中性/负向”的三类样本分别跑出 score 分布,找到两个分位数作为新阈值,效果会明显好于抄默认值。SnowNLP 在无标注、无 GPU 的场景下,是一个“退而求其次却足够用”的方案。
这步输出的scored_comments.csv就是后续分析的底表。注意保留city、district、poi_name字段,它们是下一步聚合分析的外键。
4.3 用聚合结果回答“城市好恶”:按城市、商圈、POI 维度的情感统计
情感分析的价值从来不在逐条打分,而在聚合。把 5 万条评论的逐条分数聚合成城市级和商圈级,才算真正回答了标题里“城市评论”的价值。
city_stats = df.groupby("city").agg( total=("content", "count"), positive=("label", lambda x: (x == "positive").sum()), negative=("label", lambda x: (x == "negative").sum()), avg_score=("score", "mean") ) city_stats["pos_ratio"] = city_stats["positive"] / city_stats["total"] city_stats = city_stats.sort_values("pos_ratio", ascending=False) district_stats = df.groupby(["city", "district"]).agg( total=("content", "count"), avg_score=("score", "mean"), )pos_ratio正比,是比平均分更稳定的口碑指标——平均分会被极端高分拉高,而正向评论占比更贴近用户真实感受。商圈级聚合则可以回答“这个城市哪个片区的餐饮口碑最好”,对选址类需求极具参考价值。
需要注意的是,评论量与情感均值之间存在不可忽视的选择性偏差:评论越多的门店,往往满意度分布越极端。所以做对比时建议按评论量加权重,比如只看总量超过 100 条的城市或商圈。
5. 爬虫与情感分析的常见坑:5 条一线踩坑记录
5.1 现象:爬着爬着请求全被拒,或者直接返回验证页面
原因:请求频率超过了平台的阈值,触发封禁。这是最典型的反爬手段,很多平台并不是第一次就拒绝,而是检测到一段时间内同一 IP 的请求频率超过某个值后再集体拉黑。
解决:先停 30 分钟再观察;降低并发至单线程;把随机延时的下限提高到 2 秒以上;本地挂代理池属于进阶操作,单机简单项目不建议一上来就做 IP 池。更好的办法是改爬取时段,在凌晨两点到六点之间跑批量任务,这个时段平台的频控策略通常相对宽松。
5.2 现象:有的评论被反爬隐藏,开始跑着正常,后面全是重复数据
原因:平台用 Cookie/Session 追踪你的浏览行为,翻页到一定数量后实际返回的是缓存页面或空列表。表现很隐蔽——HTTP 状态码 200,但页面结构和内容不再变化。
解决:在解析层加一道“内容指纹”校验:连续 5 页返回的评论列表完全一致,就停止爬虫并切换 Cookie 或等待一段时间。另一个做法是记录每页的第一条评论的 hash,和上一页尾部的评论比对,如果有重叠,多半是触发了缓存页。
5.3 现象:清洗后数据量骤降,从 5 万变成 2 万
原因:去重阈值设置过严,或者长度过滤阈值设得太高。5 万条原始评论里,真实去重后剩 3.5 万到 4 万是正常的;如果只剩 2 万,基本可以断定是清洗逻辑误杀了有效数据。
解决:去重的相似度阈值从 0.9 降到 0.95,只删完全一致的;长度过滤的阈值从 10 降到 6,然后人工抽查 100 条被删样本,看是否真的有价值。“清洗变屠杀”是新手最容易翻车的地方,宁可多留一点脏数据,也不要杀光有效信息。
5.4 现象:SnowNLP 对“短评论”打分极端,一堆 0 分或 1 分
原因:SnowNLP 的模型对词频敏感,短文本包含的关键词少,很容易被一两个情感词带飞。比如“太难吃了”打 0.1 没问题,但“不错,但有点贵”这种转折短句会被误判为负向。
解决:转折句在情绪表达中很常见,这时可以补充简单的规则层——识别“但”“不过”“就是”等转折词,以转折后面的短语作为最终极性的主要依据。另一种方案是收集错误样本,在打分后人工修正一批,作为微调数据传给大模型,让大模型学习短文本的语气判断。
5.5 现象:CSV 读回来全是乱码或字段错位
原因:写入时用了utf-8,读时用了gbk;或csv.DictWriter的 fieldnames 顺序不一致导致表头错位。
解决:写入统一用utf-8-sig,读取时指定encoding="utf-8-sig"。分析脚本里pd.read_csv需要带上同一编码参数。这个坑是所有新手都会经历一遍的,但只需要 10 秒就能根治。
6. 进阶:从一次性脚本到可持续复用的轻量分析服务
数据驱动的工作流里,爬虫加情感分析一般要跑不止一次。每月新增评论、商圈变化、竞对动向,都需要反复更新分析结果。这一步把散落的脚本组合成一条可重复调用的流水线,用 Flask 封装一层 HTTP 接口,让后续任务只需提交时间和城市参数就能重新跑出结果。
from flask import Flask, request, jsonify import subprocess app = Flask(__name__) @app.route("/run", methods=["POST"]) def run_pipeline(): payload = request.get_json() city = payload.get("city", "") limit = payload.get("limit", 50000) # 调用已有的爬虫脚本,传入城市和数量参数 subprocess.run(["python", "crawler.py", "--city", city, "--limit", str(limit)]) subprocess.run(["python", "analysis.py", "--city", city]) return jsonify({"status": "done", "city": city})封装后,可以定时触发任务,也可以把它嵌入到更上层的 BI 系统里。进阶方向按轻重缓急排序,优先级最高的是“评估集”的追踪:每次跑完都顺手把模型输出的错误样本记录下来,月度累积后就能形成一份业务侧真实的脏数据字典,这个价值比换模型大得多。
一条我自己的血泪经验:不要一上来就上自动调度,先把手动流程在同一个目录下连续跑三遍,确认每一步的输入输出都稳定,再上 crontab 或 Airflow。自动化的前提是过程可重放,日志可追踪,否则调度任务半夜失败,你要在第二天早上面对一堆没有日志的中间文件,那种感觉绝对不好受。
5 万条城市评论的情感分析是一个入门门槛低、但天花板足够高的方向。爬虫解决“数据有没有”,清洗解决“数据能不能用”,情感分析解决“数据怎么说”,三件事的边界清晰,容易一条龙掌握。后续不论是想往分布式爬虫进化,还是往多模态情感分析发展,这套流程都会是靠谱的地基。希望帮到你。
本文还有配套的精品资源,点击获取