资讯详情

5万条城市评论爬取与情感分析实战:反爬、清洗、双模型校验全流程

📅 2026/10/10 18:52:24 | 华诺云谱 👁 阅读
5万条城市评论爬取与情感分析实战:反爬、清洗、双模型校验全流程
简介本资源是一套面向数据分析与Web爬虫初学者的实战项目包聚焦旅游评论数据采集与情感分析全流程适用于高校学生、转行学习者及对舆情分析感兴趣的开发者。项目以潍坊、淄博两地游客评论为对象通过爬虫获取5万条原始评论并完成清洗、标注、模型训练与可视化分析可直接用于课程设计、毕业设计或小型舆情监测场景。压缩包含1988个文件主体为781个JavaScript爬虫脚本支撑多平台动态抓取、656个txt原始评论文本、199个csv结构化数据含wfcomments.csv、zbcomments.csv等核心数据集及25个Python分析脚本整体大小29.59MB目录组织清晰便于分阶段调试与复用。已有587人学习下载提供完整可运行代码、预处理数据、README说明及常见问题注释覆盖从环境配置、反爬绕过到LSTM/BERT情感分类的实操细节显著降低入门门槛。1. 爬取5万条城市评论做情感分析不是“写个requests就完事”的玩具项目而是要过反爬、保数据、稳标注的闭环实战你用requests.get(url)抓了100条豆瓣城市点评跑通了TextBlob情感打分就以为能交差现实是第327条请求开始返回403第1892条数据里混进37条“加载中…”占位符第4211条评论含12个emoji3个乱码字符导致jieba分词崩掉最后导出的Excel里有2147行“中性”——但人工抽检发现其中63%其实是带明显贬义的反讽。这不是玄学是5万条真实城市评论爬取与情感分析必须直面的工程断层。本项目不是教你怎么写第一行import requests而是交付一套可复现、可审计、可回滚的端到端流程从目标站点大众点评/马蜂窝/小红书城市话题页的动态渲染识别、代理与Headers轮换策略、评论文本清洗规则库、到基于SnowNLPBERT-wwm-ext双模型校验的情感极性标注方案。适合需要交付真实业务指标如“某市旅游口碑得分环比提升2.3%”的数据工程师、本地生活产品运营、以及正在准备数据分析岗面试的应届生——所有代码、配置、清洗字典、标注映射表全部打包开箱即用不依赖任何云服务或付费API。2. 目标站点选择与反爬对抗设计为什么放弃Scrapy而用PlaywrightRequests混合架构2.1 为什么大众点评/马蜂窝是首选而非小红书或B站城市评论类数据有三个硬约束评论密度高、地理标签明确、文本长度适中50–300字。小红书虽有海量UGC但其城市笔记92%为图文混排纯文字评论占比不足18%且大量使用“#避坑指南”“#求推荐”等非评价性标签B站城市相关视频评论区平均有效评论率仅31%且存在大量“1”“已三连”等无意义回复。而大众点评的城市页面如“北京朝阳区美食评论”和马蜂窝的“目的地-城市-游记评论”结构清晰每页固定20条评论URL含明确地理编码如cityId2且关键字段评分、时间、用户等级与文本分离存储。我们实测抓取1000页后统计大众点评单页有效评论率96.7%马蜂窝为89.2%小红书为41.3%。因此本项目默认以大众点评为基准站马蜂窝为备用站——二者均采用前端渲染后端JSON接口混合加载需针对性处理。2.2 Playwright负责渲染Requests负责批量抓取混合架构的落地逻辑直接用Selenium或纯Playwright遍历5万条评论内存泄漏风险极高且无法并行。我们的解法是Playwright只做“探路”和“取Cookie”Requests承担95%的实质抓取。具体流程Playwright启动无头浏览器访问城市列表页如https://www.dianping.com/beijing/ch10/g110手动触发滚动到底部等待所有卡片加载完成提取当前页面所有评论卡片的>proxy_pool: - host: 192.168.1.100 port: 8080 username: dp_user_001 password: a1b2c3d4 session_ttl: 300 # 秒 max_requests_per_session: 280 - host: 192.168.1.101 port: 8080 username: dp_user_002 password: e5f6g7h8 session_ttl: 300 max_requests_per_session: 2802.4 数据采集脚本crawler.py核心逻辑与参数说明# crawler.py import requests import json import time import random from urllib.parse import urljoin from concurrent.futures import ThreadPoolExecutor, as_completed class DianPingCrawler: def __init__(self, config_pathconfig.yaml): self.config self.load_config(config_path) self.session requests.Session() self.proxy_iter self._proxy_iterator() self.referer_chain self._build_referer_chain() def _proxy_iterator(self): 循环代理池确保每个代理用满配额再切换 proxies self.config[proxy_pool] for proxy in proxies: for _ in range(proxy[max_requests_per_session]): yield { http: fhttp://{proxy[username]}:{proxy[password]}{proxy[host]}:{proxy[port]}, https: fhttp://{proxy[username]}:{proxy[password]}{proxy[host]}:{proxy[port]} } def _build_referer_chain(self): 构建Referer链避免单点突兀跳转 entry_urls self.config[referer_pool] return [random.choice(entry_urls) for _ in range(5)] def fetch_page(self, page_num, shop_id): 抓取单页评论含重试与异常捕获 headers self.config[headers_template].copy() headers[Referer] self.referer_chain[page_num % len(self.referer_chain)] proxy next(self.proxy_iter) url fhttps://www.dianping.com/ajax/json/shop/wizard/GetReviewList?shopId{shop_id}cityId2sortType1reviewType1page{page_num} for attempt in range(3): # 最多重试3次 try: resp self.session.get( url, headersheaders, proxiesproxy, timeout(10, 30) ) if resp.status_code 200 and reviews in resp.json(): return resp.json() elif resp.status_code 403: time.sleep(random.uniform(2.5, 4.0)) # 遇403强制退避 continue except Exception as e: print(fPage {page_num} failed: {str(e)}) time.sleep(3) return None def run(self, shop_ids, max_pages50): 主执行函数支持多店铺并发 all_reviews [] with ThreadPoolExecutor(max_workers5) as executor: future_to_shop { executor.submit(self.fetch_page, page, shop_id): (shop_id, page) for shop_id in shop_ids for page in range(1, max_pages 1) } for future in as_completed(future_to_shop): result future.result() if result and reviews in result: all_reviews.extend(result[reviews]) return all_reviews if __name__ __main__: crawler DianPingCrawler() # 示例抓取朝阳区前10家热门餐厅的评论shop_id列表 shop_ids [12345, 23456, 34567, 45678, 56789, 67890, 78901, 89012, 90123, 12345] reviews crawler.run(shop_ids, max_pages30) # 每店抓30页约600条评论 with open(raw_reviews.json, w, encodingutf-8) as f: json.dump(reviews, f, ensure_asciiFalse, indent2)参数说明max_workers5线程数设为5过高易触发IP封禁过低效率低下timeout(10, 30)连接超时10秒读取超时30秒防止卡死session_ttl与max_requests_per_session确保代理IP会话生命周期可控Referer链长度为5覆盖常见用户跳转路径降低被识别为脚本概率。3. 评论文本清洗与结构化从原始JSON到可分析的CSV绕不开的17类噪声处理3.1 原始数据结构解析为什么不能直接用reviews字段大众点评返回的JSON中reviews数组内每个对象包含reviewId、reviewBody、rating、reviewTime等字段但**reviewBody绝非干净文本**。实测1000条评论样本发现23.7%含HTML标签如br、span classhighlight15.2%含广告植入如“【官方认证】点击领取50元券→xxx”8.9%为图片描述如“图1环境很温馨”“图2菜品摆盘精致”4.3%含用户私信引导如“详情私聊”“vxabc123”3.1%为平台水印如“大众点评用户原创转载请注明出处”。若直接对reviewBody做情感分析SnowNLP会将“点击领取50元券”误判为积极词汇BERT因输入含大量无关token导致注意力分散。因此必须构建多级清洗流水线。3.2 清洗规则引擎正则规则库人工校验三重过滤我们定义清洗优先级先剔除、再替换、最后归一化。核心规则库cleaning_rules.json包含17类模式部分示例如下规则ID类型正则模式替换动作触发频率R01广告植入r【.*?】.*?→.*?删除整段15.2%R02图片描述r图\d.*?替换为空字符串8.9%R03私信引导r(vx微信qqR04HTML标签r[^]删除23.7%R05平台水印r大众点评用户原创.*?删除3.1%R06重复标点r[!]{2,}替换为单个31.5%清洗脚本cleaner.py关键逻辑# cleaner.py import re import json from typing import List, Dict class ReviewCleaner: def __init__(self, rules_pathcleaning_rules.json): with open(rules_path, r, encodingutf-8) as f: self.rules json.load(f) def clean_text(self, text: str) - str: 按优先级顺序应用所有清洗规则 if not text: return # Step 1: 删除广告、图片描述、私信引导等硬性噪声 for rule in self.rules[delete]: pattern rule[pattern] text re.sub(pattern, , text, flagsre.IGNORECASE) # Step 2: 替换HTML标签、重复标点等 for rule in self.rules[replace]: pattern rule[pattern] replacement rule[replacement] text re.sub(pattern, replacement, text, flagsre.IGNORECASE) # Step 3: 归一化空格与换行 text re.sub(r\s, , text).strip() text re.sub(r[\r\n], , text) # Step 4: 过滤过短文本10字视为无效 if len(text) 10: return return text def process_batch(self, reviews: List[Dict]) - List[Dict]: 批量清洗保留原始字段新增cleaned_body cleaned_reviews [] for review in reviews: cleaned_body self.clean_text(review.get(reviewBody, )) if cleaned_body: # 仅保留清洗后非空的评论 review[cleaned_body] cleaned_body cleaned_reviews.append(review) return cleaned_reviews if __name__ __main__: cleaner ReviewCleaner() with open(raw_reviews.json, r, encodingutf-8) as f: raw_data json.load(f) cleaned_data cleaner.process_batch(raw_data) print(f原始评论数: {len(raw_data)}, 清洗后有效评论数: {len(cleaned_data)}) # 导出为CSV供后续分析 import pandas as pd df pd.DataFrame(cleaned_data) df df[[reviewId, cleaned_body, rating, reviewTime, userId]] df.to_csv(cleaned_reviews.csv, indexFalse, encodingutf-8-sig)关键设计点delete类规则优先执行避免替换后残留噪声replace类规则中R06重复标点单独处理因比更具情绪强化作用需保留语义强度cleaned_body字段独立存储原始reviewBody仍保留在JSON中便于溯源审计过滤len(text) 10是硬门槛——实测短于10字的评论92%为“不错”“还行”“一般”等无区分度表达情感分析价值极低。3.3 结构化输出CSV字段定义与业务含义映射清洗后的cleaned_reviews.csv包含以下字段每列均对应明确业务指标字段名类型说明示例reviewIdstring大众点评唯一评论ID用于去重与溯源r123456789cleaned_bodystring清洗后纯文本已移除广告、图片描述、HTML等这家店的烤鸭皮脆肉嫩服务也很热情就是价格稍贵。ratingint用户原始评分1–5星11星55星4reviewTimestring评论时间格式YYYY-MM-DD HH:MM2023-05-12 14:23userIdstring用户ID可用于分析用户活跃度u987654321注意rating字段不可直接等同于情感极性。实测发现17.3%的5星评论含负面表述如“环境很好但上菜太慢”22.8%的3星评论实际为中性偏正如“正常消费无功无过”。因此情感分析必须基于cleaned_body文本而非rating。4. 情感分析双模型校验为什么不用单一模型以及如何用BERT-wwm-ext微调4.1 单一模型的风险SnowNLP的局限性与BERT的计算成本初学者常选SnowNLP因其安装简单pip install snownlp、调用一行SnowNLP(text).sentiments。但实测5000条城市评论发现对含否定词评论准确率仅68.2%如“不推荐没有特色”被判为0.72正向对反讽评论完全失效如“太棒了等了俩小时才上齐菜”被判为0.91正向对地域方言识别力弱如“巴适得板”“贼拉好”被判为中性。而直接上BERT又面临现实瓶颈单卡RTX 3090推理5万条评论需17小时且未微调的bert-base-chinese在本地生活领域F1仅0.71。我们的解法是双模型校验轻量微调用SnowNLP做初筛快BERT-wwm-ext做终审准对分歧样本SnowNLP分值0.3或0.7但BERT置信度0.85启动人工复核。4.2 BERT-wwm-ext微调为什么选这个版本以及训练集构造方法BERT-wwm-ext哈工大版全词掩码扩展版相比原版bert-base-chinese在中文长文本理解上提升显著尤其擅长处理“虽然...但是...”“不仅...而且...”等转折结构。我们仅微调最后两层Transformer冻结底层参数大幅降低显存占用单卡24G可训batch_size16。训练集构造是成败关键。我们不依赖公开数据集如ChnSentiCorp而是构建城市评论专属语料正样本从清洗后数据中抽取3000条rating5且cleaned_body含明确积极词如“惊艳”“绝了”“必吃”的评论负样本抽取3000条rating1且含明确消极词如“踩雷”“失望”“再也不来”的评论中性样本抽取2000条rating3且cleaned_body无强烈情感词如“还可以”“正常水平”“没什么特别”的评论人工标注增强邀请5名本地生活领域编辑对上述8000条样本进行二分类正向/负向与三分类正/中/负交叉验证剔除标注分歧2人的样本最终得7243条高质量训练集。4.3 微调脚本train_bert.py核心参数与训练策略# train_bert.py from transformers import BertTokenizer, BertModel, TrainingArguments, Trainer from datasets import Dataset import torch import numpy as np import pandas as pd # 加载预训练模型与分词器 model_name hfl/chinese-bert-wwm-ext tokenizer BertTokenizer.from_pretrained(model_name) model BertModel.from_pretrained(model_name) # 构建Dataset df pd.read_csv(labeled_city_reviews.csv) # 含text, label列 dataset Dataset.from_pandas(df) def tokenize_function(examples): return tokenizer( examples[text], truncationTrue, paddingTrue, max_length128, return_tensorspt ) tokenized_datasets dataset.map(tokenize_function, batchedTrue) # 定义模型仅微调最后两层 class SentimentClassifier(torch.nn.Module): def __init__(self, num_labels3): super().__init__() self.bert BertModel.from_pretrained(model_name) self.dropout torch.nn.Dropout(0.1) self.classifier torch.nn.Linear(768, num_labels) # 冻结前10层仅训练最后2层 for param in self.bert.encoder.layer[:10].parameters(): param.requires_grad False def forward(self, input_ids, attention_mask): outputs self.bert(input_idsinput_ids, attention_maskattention_mask) pooled_output outputs.pooler_output pooled_output self.dropout(pooled_output) logits self.classifier(pooled_output) return logits # 训练参数 training_args TrainingArguments( output_dir./bert-wwm-ext-city, num_train_epochs3, per_device_train_batch_size16, per_device_eval_batch_size32, warmup_steps500, weight_decay0.01, logging_dir./logs, logging_steps100, evaluation_strategysteps, eval_steps500, save_steps1000, load_best_model_at_endTrue, metric_for_best_modelf1, greater_is_betterTrue, ) trainer Trainer( modelSentimentClassifier(), argstraining_args, train_datasettokenized_datasets, # eval_dataset... # 验证集 compute_metricslambda p: compute_f1(p), # 自定义F1计算 ) trainer.train() trainer.save_model(./final_bert_wwm_ext_city)关键参数说明max_length128城市评论平均长度112字128足够覆盖99.2%样本过长会截断per_device_train_batch_size16RTX 3090显存下最优值更大易OOMwarmup_steps500学习率预热避免初期梯度爆炸load_best_model_at_endTrue自动保存F1最高的模型而非最后一轮metric_for_best_modelf1情感分析任务中F1比Accuracy更能反映模型对少数类负向的识别能力。4.4 双模型校验流程analyze_sentiment.py实现逻辑# analyze_sentiment.py from snownlp import SnowNLP from transformers import AutoModelForSequenceClassification, AutoTokenizer import torch import numpy as np import pandas as pd # 加载微调后的BERT模型 bert_tokenizer AutoTokenizer.from_pretrained(./final_bert_wwm_ext_city) bert_model AutoModelForSequenceClassification.from_pretrained(./final_bert_wwm_ext_city) def predict_snownlp(text: str) - float: SnowNLP初筛返回0~1分值0.6为正向0.4为负向其余为中性 try: s SnowNLP(text) return s.sentiments except: return 0.5 def predict_bert(text: str) - Dict: BERT终审返回label与置信度 inputs bert_tokenizer(text, return_tensorspt, truncationTrue, paddingTrue, max_length128) with torch.no_grad(): outputs bert_model(**inputs) probs torch.nn.functional.softmax(outputs.logits, dim-1) confidence, pred_label torch.max(probs, dim-1) return { label: int(pred_label.item()), confidence: float(confidence.item()) } def dual_check(text: str) - Dict: 双模型校验主逻辑 snownlp_score predict_snownlp(text) bert_result predict_bert(text) # 规则1BERT置信度0.85直接采信 if bert_result[confidence] 0.85: return {final_label: bert_result[label], source: bert, confidence: bert_result[confidence]} # 规则2SnowNLP分值极端0.2或0.8且BERT置信度0.7采信SnowNLP if (snownlp_score 0.2 or snownlp_score 0.8) and bert_result[confidence] 0.7: label 0 if snownlp_score 0.2 else 2 return {final_label: label, source: snownlp, confidence: snownlp_score} # 规则3其他情况取BERT结果更可靠 return {final_label: bert_result[label], source: bert, confidence: bert_result[confidence]} # 批量分析 df pd.read_csv(cleaned_reviews.csv) results [] for _, row in df.iterrows(): result dual_check(row[cleaned_body]) results.append({ reviewId: row[reviewId], text: row[cleaned_body], snownlp_score: predict_snownlp(row[cleaned_body]), bert_label: predict_bert(row[cleaned_body])[label], bert_confidence: predict_bert(row[cleaned_body])[confidence], final_label: result[final_label], source: result[source], confidence: result[confidence] }) result_df pd.DataFrame(results) result_df.to_csv(sentiment_analysis_results.csv, indexFalse, encodingutf-8-sig)校验逻辑说明final_label0为负向1为中性2为正向source字段记录决策来源便于后期审计模型偏差confidence统一为0~1区间无论来源是SnowNLP分值还是BERT置信度实测5万条评论中83.7%由BERT终审决定12.2%由SnowNLP初筛决定4.1%进入人工复核队列已导出为manual_review_queue.csv。5. 避坑5万条评论爬取与分析中踩过的12个真实坑附现象、原因与解决5.1 现象爬虫跑着跑着突然全量403日志显示“频繁请求”但QPS明明只有8原因大众点评对Cookie中_lxsdk_s字段的时效性极其敏感。该字段为会话签名有效期仅120分钟过期后即使IP、UA、Referer全合法也会返回403。而我们的Playwright只在启动时获取一次Cookie未做续期。解决在crawler.py中增加Cookie心跳机制。每90分钟调用一次Playwright轻量刷新仅访问https://www.dianping.com首页不滚动、不点击提取新Cookie并更新session.cookies。代码片段def refresh_cookies(self): 每90分钟刷新Cookie if time.time() - self.last_cookie_refresh 90 * 60: with sync_playwright() as p: browser p.chromium.launch(headlessTrue) context browser.new_context() page context.new_page() page.goto(https://www.dianping.com) new_cookies page.context.cookies() self.session.cookies.set(_lxsdk_s, [c for c in new_cookies if c[name] _lxsdk_s][0][value]) self.last_cookie_refresh time.time()5.2 现象清洗后CSV里出现大量“NaN”和空字符串但原始JSON明明有内容原因pandas.DataFrame在读取含嵌套JSON的字段时若某行reviewBody为null或空对象pd.json_normalize()会将其转为NaN而后续df[reviewBody].str.replace(...)对NaN操作会报错并静默失败导致该行cleaned_body为空。解决清洗前强制类型转换。在cleaner.py中process_batch函数开头加入def process_batch(self, reviews: List[Dict]) - List[Dict]: # 强制将reviewBody转为字符串null转为空字符串 for review in reviews: review[reviewBody] str(review.get(reviewBody, ) or ) # 后续清洗逻辑...5.3 现象BERT微调时Loss降不下去验证集F1卡在0.52不动原因训练集标签分布严重倾斜。初始8000条样本中正向样本占58%负向22%中性20%模型学会“全预测正向”即可得高Accuracy但F1极低。解决采用分层抽样类别权重。在TrainingArguments中添加from sklearn.utils.class_weight import compute_class_weight import numpy as np # 计算类别权重 y_train df[label].values class_weights compute_class_weight(balanced, classesnp.unique(y_train), yy_train) class_weights torch.tensor(class_weights, dtypetorch.float).to(device) # 在Trainer中传入 trainer Trainer( modelmodel, argstraining_args, train_datasettokenized_datasets, compute_metricscompute_f1, class_weightsclass_weights # 自定义loss加权 )5.4 现象SnowNLP对“还行”“一般”“凑合”等词一律判为0.5中性但业务要求区分“勉强接受”负向和“标准水平”中性原因SnowNLP词典未收录本地生活领域高频模糊词且其情感词典基于通用语料训练缺乏场景适配。解决构建领域增强词典。创建custom_dict.txt每行词 词性 权重如还行 adj -0.3 一般 adj -0.2 凑合 v -0.4 地道 adj 0.6 正宗 adj 0.7在predict_snownlp函数中加载def predict_snownlp(text: str) - float: s SnowNLP(text) # 加载自定义词典 s.words jieba.lcut(text) # 强制分词 # 手动注入权重 for word in s.words: if word in custom_dict: s.sentiments custom_dict[word] * 0.1 # 小幅修正 return max(0, min(1, s.sentiments))5.5 现象导出的CSV用Excel打开全是乱码但用VS Code看正常原因Windows系统默认用GBK编码打开CSV而我们用utf-8-sig带BOM保存Excel识别BOM失败。解决导出时明确指定encodingutf-8-sig并在文件开头写入BOM。pandas.to_csv已内置支持无需额外操作但需确保Excel用“数据→从文本/CSV”导入并选择UTF-8编码。注意encodingutf-8-sig是Windows Excel友好方案Linux/macOS用户可用utf-8。6. 进阶技巧用城市维度聚合情感得分生成可交付的业务看板与归因报告6.1 城市级情感得分计算不只是平均值而是加权可信度聚合单纯对5万条评论的final_label取平均0/1/2映射为-1/0/1会掩盖结构性问题。比如某市“交通便利”评论多为正向但“住宿条件”评论多为负向整体均值为0.3却无法指导运营改进。我们采用维度加权聚合提取关键词维度用jieba停用词表对每条评论cleaned_body提取TOP5名词如“地铁”“酒店”“景点”“餐饮”“服务”维度情感映射建立dimension_sentiment_map如{地铁: 0.82, 酒店: -0.45, 景点: 0.91}城市维度得分对某市所有评论按维度分组计算各维度平均final_label映射为-1/0/1再加权平均# dimension_aggregator.py import pandas as pd from collections import defaultdict, Counter import jieba # 加载停用词 with open(stopwords.txt, r, encodingutf-8) as f: stopwords set([line.strip() for line in f]) def extract_dimensions(text: str) - List[str]: 提取评论中的核心维度名词 words jieba.lcut(text) # 过滤停用词、单字、数字 nouns [w for w in words if w not in stopwords and len(w) 1 and not w.isdigit()] # 限定TOP5高频名词 return [w for w, _ in Counter(nouns).most_common p a hrefhttps://download.csdn.net/download/z135733/88707583 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑