大众点评爬虫实战:字体映射与Cookie校验解决方案
2026/9/17 23:49:58 网站建设 项目流程

简介:面向计算机相关专业学生、毕业设计开发者及爬虫初学者的大众点评爬虫Python源码包,聚焦大众点评平台数据采集场景,包含完整可运行的爬虫脚本与说明文档。代码经过测试验证,可帮助读者快速上手网页解析、请求处理与数据提取等核心环节。压缩包内共3个文件,包括两个Python脚本与一个Markdown说明文档,脚本构成爬虫与数据处理主体,说明文档提供使用说明;整个zip包仅3KB,结构精简,便于直接阅读和二次修改。目前已有380人学习下载,适合用于课程设计、毕业设计起步或Python爬虫技能进阶。基于这份源码,读者既能跑通大众点评数据采集的基本流程,也能在此基础上扩展店铺信息抓取、评论分析等功能,从而加深对requests、BeautifulSoup等常用库的实际运用能力。

1. 大众点评爬虫的难点不在请求,而在字体映射与 Cookie 校验

直接拿 requests 打开大众点评的列表页,第一眼会觉得很顺利:HTML 能返回,状态码也是 200。把页面里的店铺名、评分、人均消费存进 CSV 后才发现,数字字段全是乱码,评论数也是空的。再刷新两次,请求头里稍微少了某个 Cookie 字段,服务器就直接返回 403。这个项目带 getData.py 和 wjk.py 两个脚本,前者负责带 Cookie 的请求和数据落地,后者专门处理 HTML 解析和字体映射。它的代码结构不像 Scrapy 那样重,但对于想弄懂“一口气到底能卡在哪”的人来说,正好适合当毕业设计或课程练手,把反爬最常见的三个环节——请求构造、选择器匹配、字体还原——完整走了一遍。

2. 项目结构与请求链路:getData.py 怎么把数据拉下来

2.1 两个 py 文件的分工与运行顺序

解压后能看到的有效代码文件是两个:getData.py 和 wjk.py,外加一个 README.md。从命名习惯来看,getData.py 是数据采集入口,wjk.py 是解析模块。README 里一般会写依赖清单和运行顺序:先执行 getData.py 把列表页 HTML 存成 list_1.html、list_2.html 这样的本地文件,再执行 wjk.py 读取这些文件,输出结构化结果。把抓取和解析拆开是一般爬虫项目里比较克制又实用的做法。大众点评的响应里同时含有列表数据和字体文件链接,如果不先把原始 HTML 落盘,解析时遇到验证码或字体变化就很难回查。

我通常会在 getData.py 里保留响应头、最终 URL、状态码和 Cookie 信息,把这些信息并列写进一个 debug 文件。这样 wjk.py 解析结果不对劲时,可以先看 debug 文件判断是不是请求阶段就已经被风控拦截,而不是怀疑解析器写错。反过来,如果先跑 wjk.py,程序会因为找不到 list_1.html 直接报 FileNotFoundError,跑不起来。这个顺序也说明项目作者把数据抓取和数据处理当成了两个独立环节,便于逐步调试。

2.2 请求头、Cookie 和 Referer 的组装方式

大众点评对请求来源的判断很敏感,单纯设置一个 User-Agent 并不够。Cookie 里缺少_lxsdk_cuiddper这类字段时,服务端会把这个请求当成未登录访客,返回部分字段缺失或者直接跳转验证码。把请求构造封装在一个函数里,后续所有请求都复用同一个 Session,是更稳妥的写法:

import requests def build_session(cookie_str: str) -> requests.Session: s = requests.Session() s.headers.update({ "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", "Referer": "https://www.dianping.com/shanghai/ch10", }) # 这里只是示例字段,实际使用时从浏览器复制整段 Cookie for item in cookie_str.split("; "): if "=" in item: key, value = item.split("=", 1) s.cookies.set(key, value) return s

这段代码的关键点是Session会自动管理 Cookie,不需要每次请求都手动拼 Cookie 头。User-Agent建议固定成一个真实浏览器版本,不要每次随机变换,否则服务端能看到同一 IP 的 UA 在跳变,反而更容易被识别。Referer要指向大众点评站内页面,直接从外部站点带过来的来源经常触发 403。把cookie_str;切分并写进 Session,可以避免手动维护 Cookie 字典。

下表列出请求头里最影响结果的几个字段:

字段作用建议值
User-Agent告诉服务器客户端类型固定为 Chrome 120 的 UA 字符串
Referer标志访问来源当前分类页或大众点评首页
Cookie维持登录态和风控标记浏览器登录后复制完整 Cookie 头
Accept-Language决定返回页面语言zh-CN,zh;q=0.9

请求头字段并不是越多越好。像Accept-Encoding如果不设置,requests 会默认发送gzip, deflate,而大众点评有时返回压缩流,处理起来会多一层解码。直接把Accept-Encoding留空,让 requests 自动解压,反而更省事。

2.3 从列表页到详情页的 URL 构造

列表页 URL 结构一般是https://www.dianping.com/{city}/ch10/g{page},其中ch10是美食频道的分类编号,page是分页序号。还有一类写法是城市走子域名,例如https://shanghai.dianping.com/food,需要以浏览器实际跳转后的地址为准。构造 URL 时最好写一个函数,方便后续扩展其他分类:

def build_list_url(city: str, category: str = "ch10", page: int = 1) -> str: return f"https://www.dianping.com/{city}/{category}/g{page}"

拿到 URL 后用构建好的 Session 发起请求,同时把返回结果保存到本地:

s = build_session(cookie_str="你的完整cookie") resp = s.get(build_list_url("shanghai"), timeout=10) if resp.status_code == 200 and "shop-all-list" in resp.text: with open("list_1.html", "w", encoding="utf-8") as f: f.write(resp.text) else: print("请求失败,状态码:", resp.status_code, "响应长度:", len(resp.text))

这里的判断语句有实际意义:shop-all-list是列表容器在页面里的稳定标识。只要响应里存在这个字符串,就说明拿到的是正常列表页而不是验证码页。如果状态码 200 但响应体里没有这个标识,多半是触发了风控页面,此时保存下来的 HTML 对解析没有帮助。timeout=10设置请求超时时间,防止某个 IP 被拖死时脚本长期卡住。

3. wjk.py 的解析逻辑:从 HTML 到结构化字段

3.1 解析入口与选择器设计

wjk.py 的输入是 getData.py 保存的 HTML 文件。解析库选择 BeautifulSoup 加 lxml 解析器,因为大众点评的 HTML 标签嵌套并不标准,大量<li><div>存在未闭合的情况。lxml 在容错性上比纯 html.parser 好,同时保留 CSS 选择器语法,写起来比 XPath 短不少。解析入口可以先这样写:

from bs4 import BeautifulSoup with open("list_1.html", encoding="utf-8") as f: soup = BeautifulSoup(f.read(), "lxml") items = soup.select("#shop-all-list > ul > li") print("本页店铺数量:", len(items))

选择器#shop-all-list > ul > li用的是 ID 加子选择器,而不是li.shop-list这种依赖 class 的写法。大众点评改版后经常给元素加上带随机后缀的 class,例如shop-list-8f3a,这类选择器一旦页面更新就失效。ID 属性通常保持不变,>号限定直接子元素,能避开外层嵌套的干扰。如果items的数量为 0,第一反应不应该是换选择器,而是回头检查 getData.py 保存的 HTML 是否真的是列表页。

3.2 店铺名、评分、人均消费的字段提取

每个li内部包含一个店铺卡片,结构大致是:

<li> <div class="txt"> <div class="tit"> <a href="https://www.dianping.com/shop/123456">示例餐厅</a> </div> <span class="score">4.5</span> <span class="mean-price">¥120</span> </div> </li>

要从中提取店铺名、详情页 URL、评分和人均消费,可以逐个解析:

import re shops = [] for li in items: title_node = li.select_one("div.tit a") name = title_node.get_text(strip=True) if title_node else "" url = title_node.get("href", "") if title_node else "" score_node = li.select_one(".score, .star-score") score = score_node.get_text(strip=True) if score_node else "" price_node = li.select_one(".mean-price, .price") price_text = price_node.get_text(strip=True) if price_node else "" price = re.sub(r"[^\d.]", "", price_text) shops.append({ "name": name, "url": url, "score": score, "price": price, }) print(shops[:3])

这里用了多项选择器.score, .star-score,这是为了兼容新旧两套页面模板。get_text(strip=True)能去掉标签内部的空白和换行,提取出来的字符串更干净。为什么用正则[^\d.]而不是直接replace("¥", "")?因为“人均”附近的文本可能是“人均 ¥120”或“¥120/人”,正则可以把所有非数字和小数点的字符去掉,避免漏掉看不见的全角空格。shops列表里的每个字典可以直接交给json.dump或写入数据库。

3.3 处理字体反爬的通用方案

大众点评的数字加密属于字体反爬,页面里的数字用@font-face定义的自定义字体渲染,真实数字被映射到私人使用区。把 HTML 里的文本抓出来,会看到类似“ ”或“” 的字符;把这些字符直接写进 CSV,最后落到 Excel 里就是乱码。字体反爬的通用处理分三步:下载 woff 文件、读取 cmap 表、做字符替换。这里先给出一个手动维护映射字典的版本:

FONT_MAP = { "\ue8a9": "0", "\ue8b0": "1", "\ue8c1": "2", "\ue8d2": "3", "\ue8e3": "4", "\ue8f4": "5", "\ue905": "6", "\ue916": "7", "\ue927": "8", "\ue938": "9", } def restore_number(text: str) -> str: for font_char, real_num in FONT_MAP.items(): text = text.replace(font_char, real_num) return text

注意,上面这个映射表里的 code point 是示例,真实值会随着大众点评更新字体而改变。最稳妥的做法是每次解析时都动态读取 woff 文件里的 cmap,再和这份字典做对照。下面这段代码先从页面 HTML 里提取 woff 地址,再用fontTools读取码表:

import io import re import requests from fontTools.ttLib import TTFont def extract_woff_url(html: str) -> str: m = re.search(r"url\('(//[^']+\.woff)'\)", html) return "https:" + m.group(1) if m else "" def get_cmap_entries(html: str, session: requests.Session) -> dict: url = extract_woff_url(html) if not url: return {} resp = session.get(url, timeout=10) font = TTFont(io.BytesIO(resp.content)) cmap = font.getBestCmap() return {chr(code): name for code, name in cmap.items()}

getBestCmap()返回的字典里,key 是 Unicode 码点对应的字符,value 是字形名称。把这些字形名称和FONT_MAP里的真实数字逐一对应,就能生成完整的替换表。由于字体文件每次发布都可能更换,把映射关系存成 JSON 文件,解析前先读取本地映射,比硬编码进代码更容易维护:

import json with open("font_map.json", "r", encoding="utf-8") as f: current_map = json.load(f)

如果发现页面里出现了current_map不认识的字符,就把这条记录打印出来,手动确认真实数字后更新 JSON。这种半自动方式比全自动模板匹配好在可控,毕业设计演示时也能解释清楚“字体反爬的还原链路”。

4. 跑通爬虫:环境依赖、限速与代理配置

4.1 Python 环境与依赖安装

项目源码没有依赖 Scrapy,所以安装成本很低。在 Python 3.8 以上的环境里,直接执行:

pip install requests beautifulsoup4 lxml fake-useragent fonttools

requests负责网络请求,beautifulsoup4负责页面解析,lxml是解析加速器,fake-useragent用来生成 User-Agent。fonttools在 3.3 节已经出现过,负责读取字体文件。如果所在的机器不能正常访问外网源,把fake-useragent去掉,换成固定 UA 字符串即可,其他几个库在内网 pip 镜像里都能找到。装完之后在项目根目录运行python getData.py > run.log 2>&1,把输出重定向到日志文件,避免终端信息被大量打印刷屏。

4.2 运行参数与限速配置

从 README 看,getData.py 应该支持通过命令行参数指定城市和页码范围。实际改起来也不复杂,用argparse接收参数,循环抓取时加入随机睡眠,可以避免固定间隔被识别:

import argparse import random import time parser = argparse.ArgumentParser(description="大众点评列表页抓取") parser.add_argument("--city", default="shanghai", help="城市名或拼音") parser.add_argument("--start", type=int, default=1, help="起始页码") parser.add_argument("--end", type=int, default=10, help="结束页码") args = parser.parse_args() for page in range(args.start, args.end + 1): url = build_list_url(args.city, page) resp = s.get(url, timeout=10) if resp.status_code == 200 and "shop-all-list" in resp.text: with open(f"list_{page}.html", "w", encoding="utf-8") as fp: fp.write(resp.text) print(f"第 {page} 页保存成功") time.sleep(random.uniform(3, 6))

限速不是单纯把 sleep 时间调大就安全,关键是“随机”。固定 sleep 3 秒会在服务端形成等间距访问痕迹,随机区间配合每次请求重置连接,更容易混在真实流量里。random.uniform(3, 6)的意思是每次暂停 3 到 6 秒之间的随机秒数。如果目标数据量大,把间隔调到 8 到 15 秒,宁可多一点时间,也不要让 IP 提前被封。

4.3 代理轮换与会话保持的常见做法

当单个 IP 连续抓取超过几十页后,大众点评会返回验证码或者 403。常见做法是准备一个 HTTP 代理列表,每次请求前随机选取一个:

proxy_list = [ {"http": "http://user:pass@101.1.2.3:8080", "https": "http://user:pass@101.1.2.3:8080"}, {"http": "http://user:pass@45.67.8.9:8080", "https": "http://user:pass@45.67.8.9:8080"}, ] def random_proxy(): return random.choice(proxy_list)

调用时传入proxies=random_proxy()。注意代理有失效概率,遇到超时要切换下一个代理并重试,而不是无限等待。同一个 Session 里的 Cookie 在换 IP 后仍然有效,这是 HTTP 代理和登录态可以共存的根本原因。下面的状态码表可以拿来判断当前 IP 是否被限制:

返回码含义应对方式
403Forbidden,被服务器拒绝检查 Cookie 和 Referer,换代理
418被反爬机制识别清空会话,重新初始化 Session
444响应为空降低请求频率,重试一次
503服务暂不可用停止 30 秒,再换代理重试

如果响应码常驻 403,先别急着换代理,把前两次成功的请求头和当前失败的请求头做 diff,查看 Cookie 是否在同一会话里发生了变化。很多 403 是因为代码里重新build_session导致 Cookie 丢失,不是代理问题。

5. 验证数据质量与把结果落到 SQLite 的实操细节

5.1 字段完整度自检

解析完成后,先不要直接写入文件,先做一次字段完整度检查:

def validate(shops): total = len(shops) if total == 0: raise ValueError("没有解析到任何店铺") filled = sum(1 for item in shops if item["name"] and item["score"] and item["price"]) print(f"字段完整度: {filled}/{total}") if filled / total < 0.8: print("大量字段缺失,可能是字体映射失效")

字段完整度低于 80% 时,优先检查wjk.py里的选择器是否还匹配页面,其次检查字体映射是否被更新。单独打印一页的原始 HTML,用文本编辑器搜索“score”,看页面里是否存在这个 class,能快速区分是请求问题还是解析问题。

5.2 写入 SQLite 并处理重复数据

数据量不大时不用上 MySQL,SQLite 足够存储几万条店铺记录,而且 Python 标准库直接支持。把shops列表写进数据库时可以带上shop_id,从url里正则提取:

import sqlite3 import re conn = sqlite3.connect("dianping.db") conn.execute(""" CREATE TABLE IF NOT EXISTS shops ( shop_id TEXT PRIMARY KEY, name TEXT, url TEXT, score TEXT, price TEXT ) """) rows = [] for item in shops: match = re.search(r"/shop/(\d+)", item["url"]) shop_id = match.group(1) if match else "" rows.append((shop_id, item["name"], item["url"], item["score"], item["price"])) conn.executemany( "INSERT OR REPLACE INTO shops(shop_id,name,url,score,price) " "VALUES(?,?,?,?,?)", rows, ) conn.commit() conn.close()

INSERT OR REPLACEshop_id作为主键,第二次抓到同一家店铺时会直接覆盖旧记录。这样做的好处是:后续增加评论页爬取时,可以用shop_id关联店铺和评论两张表,形成一对多关系。如果只做毕业设计展示,把rows改成csv.writer写 CSV 也一样,只是 CSV 没有主键概念,重复运行会产生大量重复行。

sqlite3executemany适合批量插入,比一行一条 INSERT 快很多。最后那句conn.commit()一定要执行,否则数据只停留在内存里。如果后续还要做关键词筛选,比如只看人均消费 50 到 100 的店铺,直接执行SELECT name, price FROM shops WHERE CAST(price AS REAL) BETWEEN 50 AND 100即可,不需要把 CSV 再读回内存。这也算是在原项目基础上多走了一步。

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

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

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

立即咨询