ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

大众点评爬虫实战:字体反爬破解与请求风控策略全解析

大众点评爬虫实战:字体反爬破解与请求风控策略全解析 简介面向大众点评数据采集的Python爬虫源码包由已完成毕设项目的开发者整理上传代码均经过调试与运行验证适合计算机相关专业学生用于毕业设计、课程设计或爬虫入门进阶练习。压缩包内共3个文件包括两个Python脚本与一个Markdown说明文档脚本分别承担数据抓取与主流程控制功能README则对运行环境与使用方式进行简要交代整体体积仅3KB结构精简易读便于在现有代码基础上扩展商户信息抓取、评论采集等个性化功能。目前已有380人学习下载说明该源码对同类需求具备一定参考价值。结合源码结构与文档提示读者可快速理解大众点评页面抓取思路、请求构造及数据解析方法并借此上手实际爬虫项目的调试与部署也可将其改造为其他点评类网站的采集工具。1. 先弄清大众点评爬虫要面对什么如果你在搜索引擎里敲下“Python版大众点评爬虫.zip”多半是刚拿到一个压缩包里面是一堆.py文件和一份 README。标题听起来像“解压就能跑”但大众点评不是普通静态站点它的反爬不是靠加头就能过的。随便讲“安装 requests然后 GET 一下”的人八成没跑到 50 条就被封了 IP。这里面的真正难点有三个字体反爬、动态加载、账号行为风控。写这个标题的代码能落地不是靠某个神奇库而是靠把这几件事按顺序处理掉。先把话说清楚大众点评的公开页面可以采集但必须在合法范围内做别拿去碰用户隐私、别高频轮询、别绕过登录做批量抓取。这篇讲的是爬虫工程化里最常见的处理思路从请求构造、字体解密到数据校验。适合已经会 Python 基础语法、写过简单 requests 爬虫但对“为什么大众点评爬虫总失败”没有系统答案的人。新手能照着步骤跑通最小流程熟手可以绕过一些我已经踩过的坑。2. 从网页源码看大众点评的反爬机制字体映射是第一个关卡2.1 页面里你看到的数字并不在 HTML 里用浏览器打开一个商户点评列表按 F12 看“查看源代码”你会发现正常数字全变成了#x...之类的实体或乱码。具体来说在审查元素里看到的“口味 9.0”源码中对应span classscore里是一个类似dianping://font的 URL配合一段自定义字体。但直接抓取这段网页存下来的字符串是明文吗不是。大众点评的字体反爬通常是把数字渲染成自定义字形而 HTML 里的字符编码并非真实数字是映射后的特殊字符。我一般先抓一段响应文本搜索font-face或woff2确认这次反爬用的是字体还是别的方案。多数时候你会看到类似style font-face { font-family: PingFangSC; src: url(//s3plus.meituan.net/v1/mss_0a06a471f6d04c1b9e2c6f6e5c3f3d9f/font/xxxx.woff2) format(woff2); } /style span classrrui-rate口味 9.1/span注意这里面的“9.1”在真实 HTML 里是两个自定义字形对应的字符而不是 ASCII 的9、.、1。渲染时浏览器用这张 woff2 字体去画这些字符所以人眼看到的是数字。爬虫拿到的却是另一些字面字符。不处理的话存进数据库全是垃圾。2.2 破字体反爬的常规套路下载 woff2建立“字符到数字”的映射常见做法是把字体文件下载下来用fontTools解析它的cmap表。每个 glyph 对应一个字符码而 glyph 的轮廓坐标是数字的矢量轮廓。与一套“基准字体”做轮廓相似度对比或者直接用字符名里的 hint比如uniF123去推断。但大众点评的字体会定期换后缀名和字符名都会变所以不能硬编码。我习惯的做法是先准备一组“参考字形”用作匹配。第一次跑的时候打开字体文件把每个字符的轮廓控制点转成规范化坐标和参考字形逐点算欧氏距离距离最小的那一个就是它对应的真实数字。这样即使字符名随机变轮廓不变匹配仍然准确。2.2.1 用 fontTools 解析 woff2 的示例代码from fontTools.ttLib import TTFont import hashlib font_path dm.woff2 font TTFont(font_path) cmap font.getBestCmap() # 打印每个编码对应的字形名称 for code, name in cmap.items(): glyph font.getGlyphSet()[name] coords [] for (x, y) in glyph.coordinates: coords.append((round(x, 2), round(y, 2))) print(hex(code), name, coords[:5])跑完这段你会看到一个典型输出0xf2c3 uniF2C3 [(572, 1084), (573, 1083), ...]这里的0xf2c3就是 HTML 里那个自定义字符的 Unicode 码位。你需要维护一个字典code - real_digit然后对页面字符串做ord()替换。怎么维护拿这个字符的轮廓坐标和 0-9、小数点做比对最近的就是真实值。2.3 为什么直接 OCR 不行以及这个方案的边界有人会想既然浏览器能渲染我截屏 OCR 不就行了吗可以但慢而且点评页一次显示几十个数字OCR 识别率不够稳定还要额外处理图片验证码。字体解析是更稳的路线它不依赖图像质量只依赖矢量坐标匹配速度很快100 个字符百毫秒级。不过边界也很明确如果哪天大众点评改用 Canvas 或 SVG 动态绘制甚至用 CSS transform 把坐标偏转那轮廓匹配就失效了。就目前观察商家列表页的评分、人均、榜单数字仍是 woff2 为主这种机制短期内不会消失因为字体反爬对普通用户透明又给爬虫制造了成本。做的时候做好定期重跑匹配算法的准备把字体文件按hash(font_url)存缓存避免重复下载。3. 用 Python 构造可靠的基础采集请求Headers、Cookie 和解析3.1 请求头不能只带 UA还得带完整的浏览器指纹很多人写 requests 爬虫就贴一个 User-Agent 就发请求这在大众点评上活不过几秒。它的风控会校验User-Agent、Referer、Cookie、Sec-Fetch-*几个头的组合甚至 TLS 指纹。这里不是让你去模拟到像素级但至少要做到“看起来像一次从浏览器地址栏点进来的访问”。我一般这样组织请求头headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36, Referer: https://www.dianping.com/, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Connection: keep-alive, Sec-Fetch-Dest: document, Sec-Fetch-Mode: navigate, Sec-Fetch-Site: same-origin, Upgrade-Insecure-Requests: 1, }注意Referer一定要是对应入口页比如从城市首页点进列表Referer 是城市主页。少了这个部分页面会直接返回 403 或跳反爬页。3.2 首次请求前先拿一次 Cookie大众点评的列表页需要登录。登录后有dper、_lxsdk_cuid之类的 Cookie用于标识身份。爬虫常见做法是先手工登录一次把 Cookie 导出为文本然后爬虫启动时加载。别用 Selenium 每次登录太重了。Cookie 过期频率看账号活跃度经验值是普通账号能撑几小时到一两天。import time import requests from lxml import etree cookies dict(x.replace(\n, ).split(, 1) for x in open(cookie.txt).readlines()) session requests.Session() session.headers.update(headers) session.cookies.update(cookies) url https://www.dianping.com/shanghai/ch30/r1111 resp session.get(url, timeout15) print(resp.status_code, resp.url)如果返回的resp.url跳到了passport.dianping.com或出现“验证码”字样说明 Cookie 失效或风控触发。这里的cookie.txt每行keyvalue不要带多余空格。我通常还会在请求后把页面存一份.html到本地方便排查。3.3 页面解析的关键点只取渲染后的数字拿到 HTML 后先用正则定位font-face取字体 URL再解析 HTML。因为数字被字体替换直接用etree.xpath拿到的文本是乱码必须先做字符替换。所以流程顺序是下载字体文件构建char_code - digit映射。对 HTML 字符串做全局替换把每个#x...;实体转成真实字符。再做 XPath 解析。font_map {0xf2c3: 9, 0xe0a1: 1, 0xd0b2: .} # 示例 def decode_html(html, font_map): def replace(match): code int(match.group(1), 16) return font_map.get(code, match.group(0)) return re.sub(r#x([0-9a-fA-F]);, replace, html) html resp.text html decode_html(html, font_map) tree etree.HTML(html) scores tree.xpath(//div[classscore]//text())这里要注意#x;实体本身是 HTML 层级的编码顺序问题最好在 decode 之前先处理否则替换后又被 lxml 二次解析。参数方面re.sub每次调用都会生成新字符串大批量抓取时可以用str.translate加速但代价是映射表要转换成{char: char}形式。3.4 分页与限速不要用range(1, 100)这种写死逻辑大众点评的列表页分页规律并不是所有城市都一致有的 15 条一页有的 20 条一页。而且翻页 URL 里的r1111是区域 ID不同区域总数不同。我会先从列表页第一页尾部拿到总页数然后生成待抓取队列。total_pages 0 page_links tree.xpath(//a[contains(class,PageLink)]/text()) if page_links: total_pages max(int(x) for x in page_links if x.isdigit()) for page in range(1, min(total_pages 1, 50)): page_url f{base_url}/r1111/p{page} resp session.get(page_url, headersheaders, timeout15) if resp.status_code 200: # 处理 pass time.sleep(random.uniform(3, 6)) # 随机延迟这里的 50 是保护上限防止 bug 导致疯狂翻页。每个页面之间我至少休息 3 秒单位是秒不是毫秒。很多新手设成time.sleep(0.5)然后羡慕别人的爬虫不封其实封不封不看快慢看请求间隔的规律性。随机延迟比固定延迟重要得多固定 3 秒一样能被识别。此外代理 IP 池的配合是另一个维度下一章展开。4. 风控对抗里的稳定策略代理池、打码与请求频率控制4.1 大众点评风控的几个等级以及对应的现象做这个爬虫你得能识别自己处在哪个风控等级。我把常见现象按严重程度列一下方便定位问题现象可能原因处理方式偶尔 403刷新后恢复IP 短暂限流加等待换 IP返回页面是空白或只有框架Cookie 失效重新登录更新 Cookie跳转到安全验证页触发了滑块/点选验证码需要打码平台或人工过验证大量 403 字体文件 404IP 被拉黑换出口 IP清洗代理池请求正常但店内评论只有几条登录态权重不足用高等级账号隔天再来有一个容易被忽略的点大众点评对“同一 IP 在短时间内访问同一区域页”很敏感。即使你带对了 Cookie单 IP 访问十几个城市区域页也会触发验证码。所以我会把任务按区域维度打散每个代理 IP 只负责抓取一个区域的数据抓完休息 10 分钟再换下一个。4.2 搭建一个小型代理池从验证到自动剔除不推荐一上来就买几万 IP 的代理服务先用免费或少量付费 IP 做实验。目标不是每秒请求多快而是保证可用率。class ProxyPool: def __init__(self, proxy_list): self.proxies {p: 0 for p in proxy_list} # proxy: fail_count self.invalid set() def get(self): import random candidates [p for p in self.proxies if p not in self.invalid] if not candidates: candidates list(self.proxies.keys()) self.invalid.clear() return random.choice(candidates) def report_fail(self, proxy): self.proxies[proxy] self.proxies.get(proxy, 0) 1 if self.proxies[proxy] 3: self.invalid.add(proxy)这里的逻辑是每次请求从池里随机选代理连续失败 3 次就暂时拉黑。这个report_fail可以挂在请求后的状态码检查里。注意代理池里每个代理的请求频率要单独控制。简单做法是把代理和限速时间戳挂钩同一代理两次请求间隔至少 5 秒。4.3 验证码处理能避开就别硬刚就算有代理池触发验证码的概率仍存在尤其是访问详情页和评论列表时。验证码常见两种滑块拼图、小图点选。前者可以用 opencv 找缺口后者难度大建议直接接打码平台。打码平台的 API 大同小异通常是先上传图片拿captcha_id轮询识别结果。接的时候我会在爬虫代码里做一个抽象接口避免以后换平台大改class CaptchaSolver: def __init__(self, api_key): self.api_key api_key def solve(self, image_path, captcha_typeslide): if captcha_type slide: return self._solve_slide(image_path) raise NotImplementedError真正需要打码的场景不多我更建议做“避开”而不是“解决”。怎么避开观察触发验证码的规律通常是你用同一账号快速访问详情页 5 次以上或者同一 IP 连续翻页超过 30 次。把节奏调慢就能大幅减少验证码出现。4.4 分布式爬虫是不是必选项热词里有人搜“分布式爬虫”但大众点评这种数据量除非你要抓全站否则单机多线程足够。分布式引入的复杂度在于 URL 去重、代理调度、Cookie 共享。三个账号、两个代理 IP 的场景完全不需要。只有当需要每日数据量超过几十万条时才考虑 Scrapy 加 Redis 队列。如果项目名里的 zip 是你刚拿到的那里面大概率没写分布式多半是普通 requests 脚本。所以别一上来就上 Scrapy先把单机版本跑稳定。单机多线程有个陷阱Python 线程切换加 GIL导致你同时发起 10 个请求但每个请求的响应等待是 IOGIL 影响不大。建议线程数控制在 5 以下并且使用requests.Session()复用连接。每线程一个 SessionCookie 分开否则会出现串号。from concurrent.futures import ThreadPoolExecutor def fetch_one(url, session, font_map): try: resp session.get(url, timeout15) if resp.status_code ! 200: return None html decode_html(resp.text, font_map) return parse_page(html) except Exception as e: print(f{url} 失败: {e}) return None with ThreadPoolExecutor(max_workers3) as pool: results pool.map(lambda u: fetch_one(u, session, font_map), url_list)这里max_workers3是保守值。如果你代理质量好可以升到 5再高反而会触发更严格的验证。姿态放低速度反而稳。5. 最后这步别省用签名校验和样本比对验证你的爬虫5.1 校验数字解密是否准确字体反爬有一个隐蔽问题同一页面多次刷新字体文件可能不变但字符码位会变或者两种字体文件对应同一个码位但图形稍有不同。直接比对坐标可能命中错误数字。所以我会在存储前加一个“校验和”字段用于事后快速验证。一个轻量方案随机抽 3 个页面手工记录当前页面的真实评分然后跑爬虫对比结果。如果差异比例超过 5%必须重新检查字体映射。expected {shopA: 9.1, shopB: 8.7} actual crawl_shop(shopA) # 你实现的函数 diff abs(expected[shopA] - actual) print(f之差 {diff:.2f}) if diff 0.2: print(字体映射有问题)为什么用 0.2 做阈值因为点评的评分精度到 0.10.2 的差距意味着至少一个数字位错了。实际差异通常在 0.4 以上一眼就能发现逻辑错误。5.2 给请求加一个“探针”逻辑检测风控与其事后补救不如在爬虫循环里插入一个探针页面。我常抓一个不太重要的页面比如某城市主页的页脚链接因为它大多数情况能正常返回 200。如果探针也 403说明当前 IP 被全局拉黑就应该停止整个任务而不是继续快速失败。def check_health(session, proxy): probe_url https://www.dianping.com/robots.txt try: r session.get(probe_url, timeout5) return r.status_code 200 and User-agent in r.text except Exception: return False注意抖音爬虫、boss 直聘爬虫开源项目里也有类似探针但大众点评的 robots 是允许访问的所以用它做信号还算干净。如果探针返回 403 但目标页返回 200也别放松可能是目标页缓存了把探针放多个点会更可靠。5.3 把失败详情落本地CSV 日志与重跑机制一个爬虫跑了一小时后发现第 1200 条失败了。如果你不做日志只能重头跑。做法是每条记录一个状态字段写入 SQLite失败的重跑时读出来。这个技巧比任何并发优化都有效。import sqlite3 conn sqlite3.connect(dianping.db) conn.execute( CREATE TABLE IF NOT EXISTS shop ( id TEXT PRIMARY KEY, name TEXT, score REAL, status TEXT, updated_at TEXT )) def save_shop(shop): conn.execute( INSERT OR REPLACE INTO shop(id,name,score,status,updated_at) VALUES(?,?,?,?,?), (shop[id], shop[name], shop[score], done, time.strftime(%Y-%m-%d %H:%M:%S)) ) conn.commit()用 SQLite 而不是 CSV 的原因支持并发写入、按status字段查询未完成记录而且重启后数据不丢。下次运行时先SELECT id FROM shop WHERE status failed把那些 id 重新抓取不需要全量重扫。5.4 一个实用的验收脚本跑完自动出差异报告最后给一个收工前跑的小脚本。它会对比数据库里的数据量和页面分页上的“共 XX 家”是否一致如果不一致输出差异区域和页码。这不算什么高深技巧但能在你每次改完代码后用 30 秒确认字段没抓错。select count(*) from shop; # 与页面显示的共 325 家对比如果差异超过 5%我一般会检查是否漏加Referer或者翻页时 Cookie 过期。大多数情况问题不在代码而在请求节奏。以前写分布式爬虫时总爱加指数退避后来发现大众点评更喜欢“均匀低频”而不是“突发避让”。你可以把请求间隔调成 4 到 7 秒的随机值比 1 秒 失败退避 60 秒的效果好得多。这算是我在多轮踩坑后最想保留的一条经验不要对抗风控而是让流量自然得看起来像个人。真需要更大量级再去研究代理池权重分配、区域 IP 锁定和账号生命周期管理那些是另一个标题的故事了。本文还有配套的精品资源点击获取
返回列表