简介这份资源是一套完整的“城市评论爬取与情感分析”项目方案面向对Python爬虫、自然语言处理及旅游数据挖掘感兴趣的开发者适合学习从数据采集到分析呈现的全链路操作。项目聚焦潍坊与淄博两地游客评论通过情感分析揭示游客对两地旅游体验的整体评价、满意度与不满意细节可为旅游从业者和决策者提供参考。包内共1988个文件总大小约29.59MB主要包含JavaScript脚本、txt文本、csv数据表、Python代码等其中wfcomments.csv与zbcomments.csv为两地评论原始数据Python与JavaScript承载爬虫和可视化逻辑txt与json等存放中间结果及配置。已有587人学习。资源提供完整可运行的爬虫脚本、评论数据集、情感分析模型代码及前端展示页面可直接复现研究结果也可按需修改用于其他城市或平台的评论分析。1. 从 5 万条城市评论里挖出“情绪”这个项目到底值得做吗看到“利用爬虫爬取5万条城市评论并对其进行情感分析”这个标题你大概已经意识到这不是一次简单的数据搬运。城市评论的典型来源是旅游点评、生活服务评价、本地论坛帖子它们混杂着短句、表情符号、方言和地方特色表达。5 万条这个量级选得很微妙比几千条的作业级爬虫更接近真实生产又比百万级的数据工程更轻量——对爬虫来说这是一个能锻炼反爬应对能力、数据清洗能力和标注策略的合适规模对情感分析来说它也足够训练一个可用的分类模型或者至少能让词典法在统计上呈现出有意义的结果。这篇文章要解决的正是你在这个项目里会遇到的四个现实问题去哪里拿评论、怎么稳定地把 5 万条落盘、用什么方法把中文短文本的情绪读准以及最常见的翻车点在哪。我会沿着一条自己跑过的链路走Requests XPath 抓取、Pandas 清洗、情感词典和朴素贝叶斯双轨并用、最后做可视化输出。新手可以按步骤复现熟手可以直接跳到第四章的参数调优和第五章的坑位清单。2. 定目标源与爬虫策略为什么我选 Requests XPath 而不是 Scrapy2.1 城市评论的目标源怎么选做城市评论的爬虫第一个决定不是写代码而是选数据源。常见的候选有三类旅游点评平台、本地生活服务平台的评价区、以及论坛或贴吧的讨论帖。我一般优先选旅游平台的“城市攻略/景点点评”板块原因是这类页面结构规整评论文本集中在固定的 DOM 节点里翻页参数通常是 URL 里的数字或查询字符串对新手最友好。不推荐一开始就去啃社交媒体评论。它们的评论往往嵌套在动态加载的脚本里需要分析 XHR 请求甚至要处理 WebSocket这对“爬取 5 万条”这个目标来说学习成本会明显超出收益。另外反爬强度也要考虑头部旅游平台对低频爬虫的容忍度通常比社交平台高只要控制好 QPS一般能稳定跑完。2.2 Requests 还是 Scrapy按数据规模做选择很多人一上来就问我“是不是应该用 Scrapy 爬虫项目”我的回答是看你准备投入多少时间维护。Scrapy 的并发调度、去重机制和中间件体系在百万级抓取时能帮大忙但它有学习曲线你需要写 Spider、配置 Item Pipeline、理解 Twisted 的异步模型。而 5 万条评论按每页 20 条算共 2500 个请求哪怕每秒只发 3 个请求加上随机延迟一个多小时也能跑完。这种情况用 Requests XPath 更轻、更容易排查问题——你可以在普通的 Python 脚本里把每一步都看清楚而不是在黑匣子里调试。2.3 最小实现一个能跑通的评论抓取脚本第一步先拿一页数据验证结构。我用 Requests 请求列表页用 lxml 的 XPath 提取评论内容这是爬虫 Python 入门阶段最稳的组合。下面这个脚本可以抓取单个页面的评论标题、正文和评分并把结果追加到 CSV 文件里。import requests import csv import time from lxml import html headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Accept-Language: zh-CN,zh;q0.9, Referer: https://example.com/city-guide } def fetch_page(page_url): resp requests.get(page_url, headersheaders, timeout10) resp.raise_for_status() resp.encoding resp.apparent_encoding # 处理页面编码不一致的问题 return html.fromstring(resp.text) def parse_comments(doc): items [] # 审查元素后发现评论文本在 .comment-list li .content 下面这里要用相对路径 nodes doc.xpath(//ul[classcomment-list]/li) for node in nodes: text node.xpath(.//div[classcontent]//text()) text .join([t.strip() for t in text if t.strip()]) score node.xpath(.//span[contains(class,score)]/text()) items.append({ comment: text, score: score[0].strip() if score else , city: example_city, }) return items if __name__ __main__: url https://example.com/city-guide/comments?page1 doc fetch_page(url) data parse_comments(doc) with open(comments.csv, a, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnames[comment, score, city]) if f.tell() 0: writer.writeheader() writer.writerows(data)这个脚本里有两个值得注意的参数。第一个是resp.apparent_encoding它根据页面 header 猜测编码很多中文站点的声明编码和实际编码不一致不处理的话写入文件的会是乱码。第二个是 XPath 里的.//前缀它表示从当前节点开始向下搜索而不是从文档根节点搜索——在遍历多条评论时这个细节决定你是能抓到每一条还是每次只抓到第一条。2.4 突破一页翻页、去重与断点续爬的落地方案单页跑通之后要处理的是翻页逻辑。最规范的翻页方式是观察 URL 参数比如?page1、?page2递增但有的站点会把页码嵌在伪静态路径里比如/comment-p2.html还有的会带上时间戳防缓存。我的习惯是先手动点击第 2、3 页对比 URL 差异优先选纯参数递增的站点。翻页循环里要做三件事随机延迟、去重、以及异常态下的重试。下面这段代码把这三件事合在一起写import random from urllib.parse import urljoin def crawl_loop(start_page, end_page, base_url): seen set() for page_num in range(start_page, end_page 1): page_url None if ? in base_url: page_url base_url.replace(page1, fpage{page_num}) else: page_url urljoin(base_url, fcomment-p{page_num}.html) try: doc fetch_page(page_url) items parse_comments(doc) new_items [item for item in items if item[comment] not in seen] for item in new_items: seen.add(item[comment]) append_to_csv(new_items) print(fpage {page_num} done, got {len(new_items)} new comments) except Exception as e: print(fpage {page_num} failed: {e}) time.sleep(random.uniform(2, 5))去重集合seen只对当前运行有效。如果中途断网你会想从上次结束的位置继续。我一般会把已抓取的评论主键存到 CSV 的额外一列或者维护一个单独的done_pages.txt每次成功抓完一页就把页码写进去启动时先读取这个文件用未完成的页码作为起始点。这样就不需要每次从头跑节约的时间在大目标下比表面看起来更重要。2.5 防爬的边界延迟、UA 池与“看起来像人”头部平台的防爬主要看三样请求频率、UA 特征、以及访问行为的规律性。我常用的策略是准备一个包含 5-10 个桌面端 UA 的列表每次请求随机取一个延迟在 2~5 秒内随机分布另外把抓取时间安排在白天而非深夜——很多反爬系统对人类用户的时间分布是有建模的。需要特别提醒的是不要为了追求速度把延迟砍到 0.5 秒以内也不要随便使用代理池。城市评论这类数据的访问量本来就不高正常频率下基本不会触发验证码如果你一上来就上高匿代理反而可能因为代理 IP 段被网站批量拉黑而全线崩盘。先把单 IP 慢速方案跑稳再考虑横向扩展。分布式爬虫方案在这里不是必须的5 万条数据量的瓶颈从来只在反爬和数据处理不在网络带宽。3. 清洗 5 万条评论从数据落盘到可分析语料的四个关键步骤3.1 为什么清洗直接决定情感分析的准确率很多人下载完数据就急急忙忙跑情感分析然后发现结果里充满了噪声广告文案被当成正面评论、用户复制粘贴的一段行程被当成情绪表达、还有大量评论干脆是“666”或一串表情。如果你的语料里混入 20% 这样的噪声无论用什么情感模型准确率都会被打到接近随机水平。这不是模型问题是入口问题。我做城市评论情感分析的经验是清洗阶段消耗的时间应该和情感分析阶段相当。爬虫抓取只是把原料搬回家情感分析的结果是否可信取决于你交给模型的文本到底干净不干净。3.2 清洗流水线去重、去噪声、分词与规范化我把清洗拆成 4 步来写每一步都作用于 Pandas 的 DataFrame方便你可以阶段性检查中间结果。第一步是精确去重和近义去重。精确去重用drop_duplicates()即可但真实评论里常见的情况是同一段文案被微妙改写比如只加了一个空格。我用fuzzymatcher或者简单比较前 N 个字符的哈希来判断——很多重复评论的开头是相同的。import pandas as pd df pd.read_csv(comments.csv, encodingutf-8-sig) df df.dropna(subset[comment]) df df.drop_duplicates(subset[comment], keepfirst) # 基于前 20 个字符的签名做近似去重 df[signature] df[comment].str[:20] df df.drop_duplicates(subset[signature], keepfirst) print(fafter dedup: {len(df)} rows)这里需要解释的是keepfirst如果你保存评论时把城市信息混在里面比如评论抓取顺序按城市分区保留第一条意味着保留先抓到的条目这在统计分析时不会引入额外偏差。而基于前 20 个字符的签名去重能滤掉那种“开头相同、结尾不同”的模板式灌水这在城市评论里非常常见。第二步是规则过滤把明显的广告、无意义字符和过长文本剔除。城市评论的广告形式通常是“加微信 XXX”或“优惠券领取”我用正则做粗筛。import re ad_pattern r加微信|优惠券|扫码领取|代购|二手 df df[~df[comment].str.contains(ad_pattern, naFalse)] df[clean_comment] df[comment].str.replace(rhttp\S|www\.\S, , regexTrue) df[clean_comment] df[clean_comment].str.replace(r[\u4e00-\u9fa5a-zA-Z0-9\s。、], , regexTrue).str.strip() df df[df[clean_comment].str.len() 5]这段代码里最容易被忽略的是str.replace(r[^\u4e00-\u9fa5a-zA-Z0-9\s。、], )——它把所有非中英文、数字、常见标点的字符删掉包括 emoji。注意这个操作有利有弊如果后续要用多模态情感分析或表情符号辅助判断就不应该在清洗阶段删掉 emoji但如果目标是纯文本情感分析删掉反而能让分词更干净。第三步是分词和停用词过滤。这里我通常用 jieba 的分词模式配合一个自定义停用词表。import jieba stopwords set() with open(stopwords_cn.txt, encodingutf-8) as f: for line in f: stopwords.add(line.strip()) def segment_text(text): words jieba.lcut(text) words [w for w in words if w not in stopwords and w.strip()] return .join(words) df[segmented] df[clean_comment].apply(segment_text)jieba 默认词表对城市评论的领域词覆盖并不好。比如“氛围感”“出片”“避雷”这些近年非常高频的旅行评论用语早期版本的 jieba 会把它们切成单字。解决方案是往user_dict.txt里手动添加一些自定义词比如“氛围感 5 n”“出片 5 n”“避雷 5 v”加载方式是在调用jieba.initialize()后执行jieba.load_userdict(user_dict.txt)。第四步是标准化。统一把繁体转简体、英文转小写、全角标点转半角这一步比听起来更重要——很多用户会在同一句评论里混用全角逗号和半角逗号如果不统一后面做关键词统计时“好吃”和“好吃”会被识别成完全不同的 Token。def normalize(text): text text.lower() # 全角转半角常见实现见 opencc 或 unicodedata.normalize text unicodedata.normalize(NFKC, text) return text df[norm_comment] df[clean_comment].apply(normalize)3.3 清洗效果检验怎么看清洗有没有做过头清洗不是越激进越好。我会在每一步之后抽样 50 条查看效果而不是全部跑完才检查。一个比较有效的检查指标是“情感密度”——清洗后的文本中情感词占全词的比例。如果比例异常高说明清洗把中性描述误删得太多如果比例偏低说明噪声还没去干净。具体做法是用一个精简的情感词典统计每条评论中正向词和负向词的命中次数再做分布对比。另外一个需要警惕的坑是长度过滤的阈值。我见过有人把少于 10 个字符的评论全部删掉结果把“太差”“推荐”这类简短但情感极强的高价值评论全部误删了。对于城市评论我建议长度下限设置为 2 个中文字符以上即可宁可让后续模型处理一些短文本也不要让高情绪样本从源头消失。4. 情感分析建模词典法、机器学习与深度学习的选型和落地4.1 3 条路线怎么选数据集规模决定模型边界处理 5 万条城市评论情感分析流派大致有三条路线基于情感词典的规则法、基于传统机器学习的分类法朴素贝叶斯 / SVM / LR、基于预训练模型的方法BERT 及其变体。它们的准确率上限从 80% 到 95% 不等但对应的工程复杂度也是递增的。先给结论如果你的目标是快速拿到可解释的结果我推荐先用情感词典法做一轮全量跑分然后从 5 万条里抽 10000 条人工标注训练一个朴素贝叶斯模型做对比如果标注质量足够高再上 BERT 微调——但这不是必须的。5 万条数据对 BERT 来说只是起步量级收益未必比朴素贝叶斯高多少训练成本倒是实实在在的高。4.2 情感词典法基于情感词典的基准情感计算情感词典法最朴素的实现是对每条评论分词统计情感词表中正向词和负向词的命中次数再考虑否定词、程度副词的修正。SnowNLP 是一个很常用的中文情感分析库它内建了一个基于电商评论训练的模型拿来跑城市评论可以直接出 0~1 之间的情感分数但它的问题是通用性偏差。我提供一个改进版的词典法实现它会做两项词典法常忽略的处理否定词翻转和程度副词加权。import pandas as pd # 简化版情感词典实际场景建议使用大连理工情感词汇本体库或 HowNet 词表 pos_words {好评, 推荐, 喜欢, 满意, 值得, 优美, 好吃} neg_words {差评, 失望, 不好, 坑, 后悔, 脏, 贵, 难吃, 踩雷} negators {不, 没, 别, 不太, 毫不} intensifiers {很, 太, 非常, 超, 极, 超极, 特别} def rule_based_score(text): if not isinstance(text, str) or len(text) 2: return 0.5 words jieba.lcut(text) score 0.0 strength 1.0 for i, w in enumerate(words): if w in intensifiers: strength 1.5 if w in negators: strength -1.0 if w in pos_words: score 1.0 * strength strength 1.0 if w in neg_words: score - 1.0 * strength strength 1.0 return 0.5 0.5 * (max(score, -1) / 1.0)这个算法的时间复杂度是 O(N)5 万条评论全量跑完只需要一二十秒很适合作为基准。它的问题在于词典覆盖面。城市评论里有大量的口语化表达和动态沉淀的网络流行语比如“绝绝子”“泰酷辣”这类词如果词典没有收录就会被跳过。我的建议是跑完基准后把所有未命中情感词的评论抽样打印出来人工补充进词典迭代两轮后覆盖率会有明显提升。4.3 朴素贝叶斯分类从 5 万条里抽样本、做标注、训模型词典法的局限在于它无法处理需要上下文理解的情感表达比如“也就那样吧”表面中性实则负面。要提升准确率需要让模型从数据里学。传统机器学习方法里朴素贝叶斯是短文本情感分类最经典的选择它训练快、参数少、对小数据集不容易过拟合。具体做法是从 5 万条评论里分层抽样 1 万条人工标注为“正面/中性/负面”三类划分训练集和测试集然后用 TF-IDF 特征训练模型。from sklearn.model_selection import train_test_split from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.naive_bayes import MultinomialNB from sklearn.metrics import classification_report labeled pd.read_csv(labeled_comments.csv, encodingutf-8-sig) X_train, X_test, y_train, y_test train_test_split( labeled[clean_comment], labeled[label], test_size0.2, random_state42, stratifylabeled[label] ) vectorizer TfidfVectorizer(max_features5000, ngram_range(1, 2), stop_wordsenglish) X_train_vec vectorizer.fit_transform(X_train) X_test_vec vectorizer.transform(X_test) clf MultinomialNB(alpha0.1) clf.fit(X_train_vec, y_train) print(classification_report(y_test, clf.predict(X_test_vec)))参数说明max_features5000限制了特征维度避免 1 万条样本上出现维数灾难ngram_range(1, 2)让模型能捕捉“不好吃”这种双词组合对“不”“好吃”的否定结构有显著帮助alpha0.1是拉普拉斯平滑系数我把它调小是因为中文情感词分布比较集中过大的平滑会抹平词项之间的差异。你们跑的时候可以先试alpha1.0的默认值再调到 0.1 对比 F1 分数的变化。标注的环节是这个流程里最耗人的。我的建议是三条先定统一标准比如包含明确情绪词标正面/负面只有事实陈述标中性两个人标注时算一致率低于 0.7 就需要重新讨论标准标注前先做一轮自动预标注人工只需要改错能省一半时间。4.4 深度学习的边界何时引入 BERT如果你要追求最好的效果可以考虑用中文预训练模型。但 5 万条数据的场景下我一般建议先做渐进式判断如果朴素贝叶斯在测试集上已经达到 85% 以上的 F1优先提升清洗质量和标注量而不是换模型如果文本中大量存在反讽、双重否定、跨句情感这时候再上 BERT 微调。用 BERT 类模型做情感分析最常用的库是 HuggingFace 的 Transformers。它的最小可跑代码大致是加载中文预训练模型如bert-base-chinese挂一个序列分类头用标注数据微调。但在 5 万条评论上你需要警惕的不是模型能力而是训练和推理的时间——单卡 GPU 上全量推理可能要一两个小时哪怕模型准确率高了两个百分点如果重复迭代实验时间成本会迅速放大。4.5 5 万条评论的情感分布怎么解读才算靠谱运行完模型后你得到的不应只是一个“积极 60%消极 40%”的结果。我会按城市、按商圈/景点、按时间段做交叉分析观察分布是否合理。比如一个城市旅游评论的情感得分中位数是 0.8而另一个知名旅游城市只有 0.5先不要着急下结论——先检查原始文本里是否有很多“失望”“不值”等词再确认没有编码错位或者抓取来源偏差。情绪分析结果的“可解释性”比“准确率”更重要。模型输出一个分数你要能说出它主要被哪些高频词推动。一条技巧是对每一条预测结果打印出权重最大的前 10 个特征词。比如负面预测的特征词是“贵、失望、服务差”这就是一个能写进报告的可验证结论如果你只能输出一个黑盒分数那就失去了分析的意义。5. 避坑指南爬虫与情感分析合砍五刀刀刀见血5.1 页面编码识别错误导致全文乱码现象抓回来的评论在 Excel 里打开是正常中文但在 Python 里处理时全是“æµè¡Œ”之类的乱码。原因Requests 库默认猜测编码的逻辑对某些中文站点失效resp.encoding返回的是 ISO-8859-1而你 CSV 写入用的编码和读取时的编码不一致也会导致此问题比如写入用了 UTF-8 但读取用了 GBK。解决抓取时显式用resp.apparent_encoding替代默认编码写入 CSV 用utf-8-sigBOM 头这样 Excel 直接打开也不会乱处理阶段统一用encodingutf-8-sig读入。三步都做到基本不会再遇到编码问题。5.2 反爬没有触发却抓到了大量重复数据现象跑了两个小时去重之后的评论数只有 3000 条远低于预期。原因很多平台对未登录用户会默认降级展示评论列表里只保留一部分热评翻到后面页数时返回的还是同一组热门评论。你看到的页码增长了但数据是循环的。解决抓取前先手工翻到第 10 页和第 20 页比对评论 ID 是否变化如果不变说明需要登录态或者需要换一个评论排序参数比如按时间排序。另一个办法是不仅记录评论文本还记录评论的时间字段如果连续几页的时间都是同一批那就说明在循环抓取。5.3 jieba 分词把好评切成了“好”和“评”现象情感分析结果显示大量短文本被误判为中性人工一看“好评”两个字显然是强正向。原因jieba 基于统计的分词策略在没有上下文时会把“好评”切分为独立单字情感词典里如果有“好评”这个词但分词结果里没有自然匹配不上。解决加载自定义词典把“好评”“差评”“推荐”“值得一去”等评论高频双字词强制收进分词结果。更稳健的方案是在调用jieba.lcut时设置HMMFalse避免它在短文本上做太多概率组合。这也是为什么我在清洗阶段就建了user_dict.txt的原因。5.4 情感分布整体偏正高到让你怀疑人生现象5 万条评论的情感分析结果 92% 是正面和常识明显不符。原因这往往不是模型错而是评论来源本身的幸存者偏差——旅游平台的点评用户多数是玩得开心才愿意写评论不满意的用户直接走了根本不写。另外如果你的词典里正向词数量远多于负向词基准模型的先验就是偏正的。解决抓取前想清楚这个问题。如果你需要均衡的正负面样本需要额外抓取差评筛选入口很多平台有“按评价等级筛选”的 URL 参数。如果来源本身偏正那么后续做分类模型时就不要用“准确率”做主要指标改用行业通用的 AUC 或相对排序指标同时输出情感得分排名而不是绝对值。5.5 人工标注结果和模型预测大面积不一致现象模型预测某条评论为正面但你的标注是负面抽样统计发现不一致率达到 30%。原因多数情况是标注标准的问题而不是模型问题。比如“服务员态度还行但上菜太慢”有标注员按“态度还行”标了正面有标注员按“上菜太慢”标了负面。这种包含混合极性的句子在单标签任务里注定会让模型为难。解决把混合极性单独设一个标签或者只保留极性明确的样本用于训练。我的习惯是标注时给三个选项之外再加一个“无法判断/混合”不让标注员硬选。训练时把“无法判断”的样本直接丢弃这样模型学到的决策边界更干净。6. 把 5 万条评论变成仪表盘情感热力分布、趋势曲线与模型迭代验证走到这一步你已经有了干净的语料、一份基准得分和一个训练好的分类器。最后一步不是写报告而是把结果可视化并且验证模型的可信度。我会用两个图表来呈现情感分析的结果。第一个是城市/景区维度的情感热力分布用pyecharts或plotly在地图上着色第二个是时间维度的情感趋势线按月统计正面/负面评论占比。做这两个图的时候要注意如果评论中缺少时间字段爬虫阶段就应该补抓评论的发布时间通常紧挨着评论文本用 XPath 的../span[classtime]这样的相对路径可以一并提取。情感趋势线最容易暴露模型问题。比如 4 月份突然出现一个极端的情感低谷你需要手工去查看那个月被分到负面组的评论文本如果大量是“太贵了”“不值”那就是价格敏感的合理波动如果出现的是“终于开了”“不用排队”等奇怪文本说明模型把“不”的否定范围判断错了回到标注数据里补充这类句式。还有一个往往被忽略的验证技巧把情感得分和评分字段做相关性分析。城市评论通常会附带星级评分1~5 星如果模型输出的情感得分与星级呈显著正相关皮尔逊相关系数高于 0.5说明模型学到了和人类评价一致的信号如果相关系数接近 0不管测试集上的 F1 多高都要怀疑模型在训练集上过拟合了噪声模式。最后提一个我个人的习惯每次跑完一轮完整流程我会把模型预测的最失望的 100 条评论和最满意的 100 条评论各打印成一份文本文件快速扫一遍。这个动作花不了十分钟但它能帮我发现绝大多数页面解析和清洗遗漏的问题——很多翻车现场都是在这一步才现出原形的。我在这类项目上吃过最大的亏就是低估了人工标注的价值总想着全靠自动工具解决。5 万条评论的情感分析真正决定成败的不是爬虫写得有多快也不是模型选得有多新而是你花在数据清洗和标注校对上的那十几个小时。先跑通最小闭环再逐步加固希望这个流程能帮你的城市评论项目少走几趟弯路。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?