☰
Python爬虫实战:大众点评评论抓取、数据清洗与消费分析报告
2026/10/11 12:37:12 网站建设 项目流程

简介:这份资源面向计算机相关专业学生与开发者,提供一套完整的大众点评评论采集与消费分析实战项目,可作为毕业设计、课程设计或技术研究的参考案例。项目包含爬虫源码、数据清洗脚本与消费分析报告,覆盖从评论抓取、图片采集到数据预处理、情感分析与可视化建模的完整链路,适合具备一定Python基础、希望快速复现或二次开发的人群。压缩包共61个文件,约90KB,以20个py脚本为核心,辅以js、xml、json、md等配置与说明文件,另有bat、sh启动脚本和yaml配置,目录结构清晰,便于按模块查阅。目前已有154人学习下载。资源不仅提供可稳定运行的完整代码,还配套设计文档与多环境测试说明,读者可据此理解爬虫调度、MongoDB存储、OCR识别与SHAP解释等关键实现,并在此基础上修改扩展功能,用于毕设、课设或原型验证,遇到环境配置问题还可获得远程指导支持。

1. 从一份「大众点评评论爬虫源码」说起:它到底能跑出什么

很多人第一次搜「Python大众点评评论爬虫源码」,脑子里想的是一份解压就能跑的压缩包,双击之后评论哗哗往下掉。现实是,这类项目真正值钱的部分从来不是那几十行 requests 请求,而是它背后串起来的一条完整链路:抓取、清洗、分析、出报告。标题里四个词——爬虫、数据清洗、消费分析报告、实战案例——其实是一条数据流水线的四个工位,缺一个,最后那份报告就是废纸。

我见过太多人卡在中间:评论抓下来了,几万条躺在 CSV 里,字段乱七八糟,评分是「4.5分」这种带单位的字符串,人均消费是「¥120/人」,时间戳是「2024-03-15 更新」这种混着中文的格式。这时候你拿 pandas 直接 groupby,出来的全是 object 类型,算不了均值,画不了图。所以这篇不打算只讲怎么发请求,而是把从抓取到出消费分析报告的整条路走一遍,重点放在数据清洗和指标设计上。适合两类人:刚学完 requests 和 pandas、想找个真实数据集练手的新手,以及做过爬虫但没认真处理过脏数据的熟手。下面所有代码都是能直接改改就用的骨架,不依赖任何不存在的私有库。

2. 抓取层怎么搭:requests + 分页 + 字段落库的最小闭环

2.1 为什么选 requests 而不是上 Scrapy 或分布式

先说选型。大众点评这类站点的评论页,本质是「列表页 + 详情页」结构,单店评论量通常在几百到几千条,分页参数清晰。这种量级用 Scrapy 属于杀鸡用牛刀,分布式爬虫更是没必要——你面对的不是百万级页面,而是几十家店的评论聚合。requests + 会话保持 + 分页循环,代码量控制在 100 行以内,调试成本最低。

真正要花心思的是三件事:请求头伪装、分页终止条件、字段抽取的容错。大众点评对未登录请求有较严的限制,常见做法是带上完整的浏览器请求头,尤其是User-Agent、Referer和Cookie。Cookie 需要你自己在浏览器登录后复制,这一步没有捷径,也不建议用任何自动化登录方案,容易触发风控。

import requests import time import random import csv from parsel import Selector # 比 lxml 更顺手,语法接近 HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36", "Referer": "https://www.dianping.com/", "Cookie": "你的Cookie放这里", # 从浏览器开发者工具复制 } def fetch_page(shop_id, page): """抓取单店某一页评论,返回 HTML 文本""" url = f"https://www.dianping.com/shop/{shop_id}/review_all/p{page}" try: resp = requests.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() return resp.text except requests.RequestException as e: print(f"[失败] shop={shop_id} page={page} err={e}") return None def parse_reviews(html): """从列表页 HTML 抽取评论字段,返回字典列表""" sel = Selector(text=html) items = [] for node in sel.css(".review-list .review-item"): items.append({ "user": node.css(".user-name::text").get(default="").strip(), "score": node.css(".score::text").get(default="").strip(), "comment": "".join(node.css(".review-words::text").getall()).strip(), "date": node.css(".review-date::text").get(default="").strip(), "cost": node.css(".cost::text").get(default="").strip(), }) return items

这段代码的逻辑很直白:fetch_page负责拿 HTML,parse_reviews负责抽字段。关键参数有三个。timeout=10是必须的,不设超时,一个卡住的请求能把整个脚本挂死。default=""保证字段缺失时不会抛异常,脏数据留到清洗层处理,抓取层只负责「尽量拿全」。random和time.sleep配合做请求间隔,我一般设 2 到 5 秒随机,太快必被封。

2.2 分页终止与断点续爬:别让一次中断白跑

分页循环最容易翻车的地方是终止条件。很多教程写「当返回的评论数为 0 就停」,但大众点评在超出范围时可能返回上一页内容或空列表,你得同时判断「本页条数」和「是否与上一页重复」。更稳的做法是设一个最大页数上限,配合去重集合。

def crawl_shop(shop_id, max_pages=50): seen = set() all_rows = [] for page in range(1, max_pages + 1): html = fetch_page(shop_id, page) if not html: break rows = parse_reviews(html) if not rows: print(f"shop={shop_id} 第{page}页无数据,停止") break new_count = 0 for r in rows: key = (r["user"], r["date"], r["comment"][:30]) if key not in seen: seen.add(key) all_rows.append(r) new_count += 1 if new_count == 0: print(f"shop={shop_id} 第{page}页全重复,停止") break time.sleep(random.uniform(2, 5)) return all_rows def save_csv(rows, path="raw_reviews.csv"): if not rows: return with open(path, "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=rows[0].keys()) writer.writeheader() writer.writerows(rows)

encoding="utf-8-sig"这个细节很多人忽略,用 Excel 打开 CSV 时中文乱码,就是因为少了 BOM 头。断点续爬的思路是把seen集合和all_rows定期落盘,脚本崩了下次读回来接着跑。我一般每抓 5 页存一次,用json存中间态比 CSV 方便,因为集合不能直接序列化,得转成列表。

提示:抓取频率和并发数直接决定你会不会被封。单 IP 单线程、间隔 2 秒以上,是能长期稳定跑的下限。别一上来就开多线程,先跑通单线程再说。

3. 数据清洗:把「4.5分」「¥120/人」变成能算的数值

3.1 脏数据的四种典型形态和对应处理

抓下来的原始数据,字段类型几乎全是字符串,而且混着各种符号。我把它归成四类,每类有固定的处理套路。

第一类是带单位的数值,比如评分「4.5分」「4.5」,人均「¥120/人」「120元」。处理方式是正则提取数字部分。第二类是缺失值,评论里经常没有人均消费,字段是空字符串或「暂无」。这类不能简单填 0,因为 0 和「未知」在消费分析里含义完全不同,我一般填NaN,后续统计时用dropna或单独标注。第三类是时间格式不统一,「2024-03-15」「3月15日」「2024.03.15」都可能出现,统一转成datetime。第四类是文本噪声,评论里的换行、多余空格、表情符号,影响后续分词和关键词统计。

import pandas as pd import numpy as np import re def clean_score(x): """'4.5分' -> 4.5, 空值 -> NaN""" if not isinstance(x, str) or not x.strip(): return np.nan m = re.search(r"(\d+(\.\d+)?)", x) return float(m.group(1)) if m else np.nan def clean_cost(x): """'¥120/人' -> 120.0, '暂无' -> NaN""" if not isinstance(x, str): return np.nan if "暂无" in x or not x.strip(): return np.nan m = re.search(r"(\d+(\.\d+)?)", x) return float(m.group(1)) if m else np.nan def clean_date(x): """多种日期格式统一""" if not isinstance(x, str): return pd.NaT x = x.strip() x = re.sub(r"[年月]", "-", x).replace("日", "") return pd.to_datetime(x, errors="coerce") df = pd.read_csv("raw_reviews.csv") df["score_num"] = df["score"].apply(clean_score) df["cost_num"] = df["cost"].apply(clean_cost) df["date_dt"] = df["date"].apply(clean_date) df["comment"] = df["comment"].str.replace(r"\s+", " ", regex=True).str.strip()

errors="coerce"是 pandas 里最实用的参数之一,转换失败的直接变NaT,不会让整个脚本崩掉。清洗完一定要做一次体检,看看每列的空值率和分布,不然你后面算出来的均值可能是被几个异常值带偏的。

print(df[["score_num", "cost_num"]].describe()) print("空值率:\n", df[["score_num", "cost_num", "date_dt"]].isna().mean())

3.2 去重、异常值和时间窗口对齐

清洗完基础字段,还有三件事要做。去重不能只按评论内容,因为不同用户可能发一样的「好吃」,得用「用户 + 日期 + 内容前 30 字」组合键。异常值方面,人均消费出现 9999 这种,基本是商家填的占位符或者用户乱填,我一般按分位数截断,超过 99 分位的直接置为NaN。时间窗口对齐是为了做趋势分析,把date_dt转成「年-月」或「年-周」,方便按月聚合。

# 去重 df = df.drop_duplicates(subset=["user", "date", "comment"], keep="first") # 异常值截断 q99 = df["cost_num"].quantile(0.99) df.loc[df["cost_num"] > q99, "cost_num"] = np.nan # 时间窗口 df["year_month"] = df["date_dt"].dt.to_period("M").astype(str) df = df.dropna(subset=["date_dt"]) # 时间缺失的行对趋势分析无用

这里有个血泪经验:dropna的时机很关键。如果你在清洗早期就把所有含空值的行删掉,可能损失一半数据,因为人均消费缺失太常见了。正确做法是分字段处理——做评分分析时用有评分的行,做消费分析时用有人均的行,各取所需,而不是一刀切。

注意:to_period("M")转出来是 Period 类型,直接存 CSV 会变成「2024-03」,想保留这个格式没问题,但如果要再转回时间戳,记得用to_timestamp()。

4. 消费分析报告:从清洗后的表到能看的结论

4.1 该算哪些指标,为什么不是越多越好

数据清洗完,很多人第一反应是「把所有能算的都算一遍」,结果报告里堆了二十个指标,没人看得懂。消费分析报告的核心是回答三个问题:这家店贵不贵、大家满不满意、什么时候人多。对应到指标就是人均消费分布、评分与消费的关系、评论量的时间趋势。三个方向,每个方向两三个指标,足够了。

人均消费分布不能只看均值,因为消费数据天然右偏,少数高消费会把均值拉高。我一般同时给中位数和四分位数。评分与消费的关系用分组聚合,把人均消费分成几档,看每档的平均评分,能发现「是不是越贵评分越高」这种反直觉结论。时间趋势按月聚合评论数,能看出旺季淡季。

# 人均消费分布 cost_stats = df["cost_num"].describe(percentiles=[0.25, 0.5, 0.75]) print(cost_stats) # 消费分档 vs 平均评分 df["cost_bin"] = pd.cut(df["cost_num"], bins=[0, 50, 100, 200, 500, np.inf], labels=["50以下", "50-100", "100-200", "200-500", "500以上"]) score_by_cost = df.groupby("cost_bin", observed=True)["score_num"].agg(["mean", "count"]) print(score_by_cost) # 月度评论趋势 monthly = df.groupby("year_month").size().reset_index(name="review_count") print(monthly.tail(12))

pd.cut的分箱边界不是拍脑袋定的,得看你的数据实际分布。如果大部分消费集中在 80 到 150,那分箱就该围绕这个区间细化。observed=True在新版 pandas 里必须加,否则空的分箱也会出现在结果里,干扰阅读。

4.2 把结论落成图表和一句话摘要

指标算出来只是半成品,报告要能让人一眼看懂。我的习惯是每个方向配一张图加一句话结论。图用 matplotlib 或 seaborn 都行,重点是别追求花哨,柱状图、箱线图、折线图三件套覆盖 90% 场景。一句话结论要具体,比如「人均 100-200 区间的评分最高,达到 4.6,说明中端价位性价比感知最强」,而不是「消费与评分存在相关性」这种废话。

import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["SimHei"] # 中文显示 plt.rcParams["axes.unicode_minus"] = False fig, axes = plt.subplots(1, 3, figsize=(15, 4)) df["cost_num"].dropna().hist(ax=axes[0], bins=30) axes[0].set_title("人均消费分布") score_by_cost["mean"].plot(kind="bar", ax=axes[1]) axes[1].set_title("各消费档平均评分") monthly.set_index("year_month")["review_count"].plot(ax=axes[2]) axes[2].set_title("月度评论量趋势") plt.tight_layout() plt.savefig("report.png", dpi=150)

SimHei是 Windows 自带中文字体,Linux 上可能没有,换成WenQuanYi Micro Hei或直接指定字体文件路径。dpi=150保证导出图清晰,报告里插图够用。这三张图加上前面的指标表,就是一份能交付的消费分析报告的最小形态。

提示:如果要做成可交互的报告,pandas 的to_excel配合 openpyxl 能直接输出带格式的表格,比 CSV 更适合给非技术同事看。

5. 避坑与排查:那些让脚本半夜挂掉的细节

5.1 请求返回 200 但内容是空的

现象:resp.status_code是 200,但parse_reviews抽出来 0 条。原因通常是触发了反爬,服务器返回了一个验证页或空壳页,状态码却是正常的。解决办法是先打印resp.text[:500]看实际内容,如果出现「验证」「滑动」等字样,说明需要降低频率或更换 Cookie。我一般会在解析前加一个判断,如果页面里没有预期的 CSS 选择器,就记录警告并跳过。

5.2 清洗后数据量骤减

现象:原始 5000 条,清洗完只剩 800 条。原因多半是dropna用得太狠,或者日期解析失败率过高。排查方法是分步骤统计每列的空值率,找出「杀手字段」。如果是日期格式太杂导致to_datetime大量失败,就扩展clean_date的正则,把更多格式纳入。记住一个原则:清洗的目标是「让数据可用」,不是「让数据完美」,能保留的尽量保留。

5.3 分组聚合结果里出现 NaN 分组

现象:groupby之后多出一行索引是NaN的组。原因是分组字段本身有空值,pandas 默认会把空值也当成一组。解决办法是在分组前dropna(subset=[分组字段]),或者用groupby(..., dropna=True)(新版 pandas 支持)。这个坑在按消费分档时特别常见,因为pd.cut对NaN会返回NaN类别。

5.4 中文乱码在 Excel 和代码里反复横跳

现象:代码里打印正常,存成 CSV 用 Excel 打开乱码;或者反过来。根因是编码不一致。写 CSV 用utf-8-sig,读的时候也用utf-8-sig,保持一致。如果中间经过 Excel 编辑再读回来,可能变成gbk,这时候用chardet检测一下实际编码再读。别小看这个,我见过有人因为乱码以为数据抓错了,白白重抓一遍。

5.5 时间戳时区导致的趋势错位

现象:月度趋势图里某个月的数据明显偏少或偏多。原因是to_datetime默认按本地时区解析,如果原始数据里混了不同时区的时间,聚合就会错位。稳妥做法是解析时统一指定utc=True,再转成你需要的时区。对于评论数据,一般统一到东八区即可,但显式处理比默认行为可靠。

6. 进阶:把一次性脚本变成可复用的分析模板

跑通一遍之后,你会发现真正耗时的不是写代码,而是每次换一家店就要改一堆硬编码。我的做法是把整条链路拆成配置驱动:用一个config.yaml存店铺 ID 列表、请求间隔、分箱边界、输出路径,主脚本读配置跑循环。这样换目标只需要改配置,不用动代码。

import yaml with open("config.yaml", "r", encoding="utf-8") as f: cfg = yaml.safe_load(f) for shop in cfg["shops"]: rows = crawl_shop(shop["id"], max_pages=cfg["max_pages"]) save_csv(rows, f"raw_{shop['name']}.csv") # 后续清洗和分析复用同一套函数

对应的config.yaml长这样:

shops: - id: "12345678" name: "某火锅店" - id: "87654321" name: "某日料店" max_pages: 50 request_interval: [2, 5] cost_bins: [0, 50, 100, 200, 500, 99999] output_dir: "./output"

配置化的好处不只是省事,更重要的是可复现。你把配置和代码一起存档,三个月后想重跑,改个日期就能对比「这家店评分是不是掉了」。验证方法也很简单:拿两家已知情况的店跑一遍,看算出来的中位数人均和你在点评页面上看到的区间是否吻合,吻合就说明清洗逻辑没跑偏。

最后说个我自己的习惯。每次做完一个爬虫分析项目,我会把清洗函数单独抽成一个clean_utils.py,下次遇到类似数据直接 import。这些函数攒到十几个之后,你会发现大部分脏数据处理都是重复劳动,真正需要动脑的只有指标设计和结论提炼。爬虫和分析的门槛从来不在代码,而在你愿不愿意花时间把数据看仔细。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询