资讯动态

Scrapy实战:破解大众点评字体反爬与登录态复用的完整指南

发布时间:2026/9/16 15:39:59 来源:尧图企业网站定制
简介这是一套基于 Scrapy 框架编写的大众点评爬虫项目适合有一定 Python 基础的爬虫学习者和工程师参考。项目完整实现了城市列表、分类列表、店铺链接与店铺信息的多级抓取流程代码中处理了浏览器登录、验证码人工过验、代理切换以及字体反爬等典型反爬难题并明确给出“先城市和分类、再店铺链接、最后店铺信息”的执行顺序使用起来思路清晰。资源共 19 个文件以 13 个 Python 源码文件为核心覆盖 spiders、pipelines、middlewares、utils 等标准 Scrapy 模块另附 requirements.txt 依赖清单、scrapy.cfg 配置和 README 说明结构规整便于快速安装与二次开发。整个压缩包仅 21KB体积小巧但功能闭环完整适合作为进阶爬虫项目的研读样本。目前已有 596 人学习下载对想研究反爬策略或扩展 Scrapy 数据采集能力的开发者来说是一份值得收藏的实战代码。1. 从验证码到字体反爬一个生产级 Scrapy 项目的拆解大众点评的页面结构和反爬机制在爬虫圈里是出了名的复杂字体反爬、CSS 偏移、登录态校验、IP 频率限制层层叠加。这个基于 Scrapy 框架实现的爬虫项目核心价值在于它把「浏览器登录 手动过验证 代理切换 字体解析」这条完整链路打通了爬取对象涵盖城市列表、分类列表、店铺链接和店铺详情四个层级。对于已经掌握 Scrapy 基础、想看看真实商业网站反爬怎么破的开发者来说这是个很好的参考样本。我拆解这个项目时重点关注四个点请求链路的依赖顺序、登录态如何在 Scrapy 中复用、字体反爬的解析策略以及数据管道的落库设计。下面按项目实际结构逐层展开。2. Scrapy 项目结构与核心配置解析2.1 项目文件布局与职责划分项目根目录下的文件结构很清晰符合 Scrapy 标准布局dianping-spider-master/ ├── scrapy.cfg # Scrapy 部署配置 ├── requirements.txt # 项目依赖库清单 ├── dianping/ │ ├── __init__.py │ ├── items.py # 数据模型定义 │ ├── pipelines.py # 数据持久化管道 │ ├── middlewares.py # 下载中间件代理、UA │ ├── settings.py # 爬虫配置文件 │ ├── utils/ # 工具类字体解析等 │ └── spiders/ # 爬虫代码 └── data/ # 爬取结果输出目录scrapy.cfg是 Scrapy 项目的入口配置指定了默认的 settings 模块位置。requirements.txt则锁定了顶层依赖安装时会自动拉取所有依赖项。2.2 settings.py 中的关键配置项这个项目的settings.py有几个配置直接决定了爬虫能跑多远我把要点列出来# 并发与延迟控制 CONCURRENT_REQUESTS 8 DOWNLOAD_DELAY 1.5 RANDOMIZE_DOWNLOAD_DELAY True # 中间件与管道注册 DOWNLOADER_MIDDLEWARES { dianping.middlewares.RandomUserAgentMiddleware: 543, dianping.middlewares.ProxyMiddleware: 544, } ITEM_PIPELINES { dianping.pipelines.DianpingPipeline: 300, } # 请求头与 Cookie 设置 DEFAULT_REQUEST_HEADERS { Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, } COOKIES_ENABLED TrueCONCURRENT_REQUESTS 8意味着同时最多 8 个请求在网络上传输配合DOWNLOAD_DELAY 1.5秒的请求间隔能把对目标站点的压力降到比较低的水平。RANDOMIZE_DOWNLOAD_DELAY开启后Scrapy 会在 0.5 到 1.5 倍之间随机化延迟值这个特性对抗基于请求时间间隔的频率检测非常有效。中间件注册的顺序数字543、544表示执行优先级数值越小越早执行。RandomUserAgentMiddleware在ProxyMiddleware之前执行这样每个请求先随机分配 UA再走代理链路逻辑上更合理。2.3 items.py 与数据模型设计items.py定义了四类数据模型对应爬取的四个层级import scrapy class CityItem(scrapy.Item): city_name scrapy.Field() # 城市名称 city_pinyin scrapy.Field() # 城市拼音 class CategoryItem(scrapy.Item): category_name scrapy.Field() # 分类名称 category_id scrapy.Field() # 分类编号 class ShopUrlItem(scrapy.Item): shop_url scrapy.Field() # 店铺链接 city scrapy.Field() # 所属城市 category scrapy.Field() # 所属分类 class ShopInfoItem(scrapy.Item): shop_name scrapy.Field() # 店铺名称 shop_address scrapy.Field() # 店铺地址 city scrapy.Field() # 所在城市 star scrapy.Field() # 店铺星星数 score scrapy.Field() # 综合评分 review_count scrapy.Field() # 评价数量 avg_price scrapy.Field() # 消费价格 shop_score scrapy.Field() # 店铺评分口味/环境/服务 hot_area scrapy.Field() # 热门商圈这里有个设计细节值得注意ShopUrlItem中冗余了city和category字段。虽然店铺链接本身包含城市和分类信息但在 URL 里通常是编码后的 ID可读性差。通过 item 传递时带上原始信息后续在 pipeline 中处理就不用再回查城市表和分类表了省一次查询。3. 登录态管理与中间件实现3.1 浏览器登录与 Cookie 复用大众点评的大部分数据接口要求登录态这个项目没有用 Scrapy 的 FormRequest 去做自动登录因为它的验证码机制太复杂常见的打码平台识别率也不稳定。项目选择的方案是「浏览器手动登录 Cookie 注入」。操作流程是这样的先在 Chrome 中正常访问大众点评完成登录并手动通过滑块验证然后从开发者工具中复制 Cookie。之后在 Scrapy 的 settings 或 spider 的start_requests方法中注入import scrapy class DianpingSpider(scrapy.Spider): name dianping_spider def start_requests(self): cookie_str 你的浏览器 Cookie cookies {} for item in cookie_str.split(;): key, value item.strip().split(, 1) cookies[key] value for url in self.start_urls: yield scrapy.Request( urlurl, cookiescookies, callbackself.parse_city )start_requests是 Scrapy 中start_urls的替代方案当你需要对初始请求做额外配置时比如带 Cookie、设置代理就应该重写这个方法。这里把浏览器 Cookie 字符串解析成字典传给scrapy.RequestScrapy 会自动维护这个 Cookie 集合。需要说明的是这种方式有 Cookie 过期问题点评的登录态一般能维持几天到几周一旦失效需要重新手动登录获取。3.2 代理中间件实现与频率控制IP 被限制是爬点评最常见的封禁原因之一。项目在 middlewares.py 中实现了代理中间件核心逻辑是从代理池中取一个可用代理绑定到每个请求上class ProxyMiddleware(object): def process_request(self, request, spider): proxy self.get_random_proxy() request.meta[proxy] http:// proxy def get_random_proxy(self): # 从代理池中随机选取一个代理 # 可以是 redis 列表、数据库或本地文件 return 127.0.0.1:8888 # 示例代理地址 def process_response(self, request, response, spider): # 检测响应状态码发现 403 或频繁重定向时 # 标记该代理不可用从代理池中剔除 if response.status in (403, 429): proxy request.meta.get(proxy, ) self.remove_proxy(proxy) return response这段代码里process_request在请求发送前执行把代理地址写入request.meta[proxy]Scrapy 的下载器会读取该字段并使用对应代理发起请求。process_response在响应返回后执行这里做了代理的被动健康检查——发现 403 或 429 就认为该代理已被目标网站封禁及时剔除。代理池的质量直接决定爬虫的存活时间。免费代理几乎不可用建议至少用付费代理池或自建代理池且要定期检测代理的连通性和匿名度。如果代理返回的 IP 被目标网站识别为机房 IP很容易触发更严厉的封禁策略。3.3 自定义 User-Agent 中间件大众点评对 UA 的校验不像某些网站那么严格但是一个完全缺失 UA 或使用默认 Scrapy UA 的请求会在第一轮就被识别出来。项目中的RandomUserAgentMiddleware实现如下import random class RandomUserAgentMiddleware(object): def __init__(self, user_agents): self.user_agents user_agents classmethod def from_crawler(cls, crawler): return cls( user_agentscrawler.settings.get(USER_AGENT_LIST, []) ) def process_request(self, request, spider): request.headers[User-Agent] random.choice(self.user_agents)from_crawler是 Scrapy 中间件的类方法通过它可以从 settings 中读取配置的 UA 列表。每次请求时随机选取一个 UA避免同一 UA 高频访问。这里有个实战技巧UA 列表不需要太长但要覆盖主流浏览器的不同版本比如 Chrome 120、Firefox 115、Edge 120 等混着用效果比大量冷门 UA 更好。4. 字体反爬的对抗策略与 HTML 解析4.1 字体反爬的工作原理这是整个项目中最有技术含量的部分。大众点评的店铺评分、评价数量等数字在 HTML 源码中并不是真实的数字字符而是显示为「乱码」或特殊字符通过自定义字体文件woff/ttf映射回真实数字。浏览器加载字体后特殊字符被渲染成正确的数字但直接抓取 HTML 源码拿到的是加密后的字符。字体反爬的对抗思路是下载页面引用的字体文件解析字体文件中 glyph字形与 unicode 编码的映射关系再用 OCR 或人工比对确认每个 glyph 对应的真实数字建立「加密字符 → 真实字符」的映射表。4.2 解析 woff 字体文件项目 utils 目录下的字体解析工具是这个方案的核心实现主要用到了fontTools库from fontTools.ttLib import TTFont import requests import io class FontParser: def __init__(self, font_url): self.font_url font_url self.font self._download_font(font_url) def _download_font(self, url): resp requests.get(url) return TTFont(io.BytesIO(resp.content)) def extract_mapping(self): 提取 unicode 编码与字形名称的映射关系 cmap self.font.getBestCmap() # 返回格式: {0x9876: glyph00001, ...} return cmap def get_glyph_coordinates(self, glyph_name): 获取指定字形的轮廓坐标 glyf_table self.font[glyf] glyph glyf_table[glyph_name] # 返回轮廓点坐标列表 coordinates [] if glyph.numberOfContours 0: for point in glyph.coordinates: coordinates.append((point[0], point[1])) return coordinatesgetBestCmap()返回的是字体文件中字符编码到字形名称的映射表它就是加密字符的字形索引。下一步的关键是对比字形轮廓将加密字体的数字 glyph 与目标字符集的 glyph 进行相似度对比找到最接近的真实数字。4.3 字形比对与映射重建字形比对有两种思路第一种是坐标逐点对比。将两个字形归一化到相同尺寸后计算所有轮廓点坐标的欧几里得距离之和距离最小的就是匹配项。这个方案对字体缩放、平移敏感需要先做归一化处理。第二种是形状描述符对比。提取字形的周长、面积、重心、凸包等特征计算多维特征向量的余弦相似度。这个方案速度快但精度依赖特征设计。这个项目实际做的是折中方案先用字体工具把每个加密字符对应的字形用getGlyphSet()转换为轮廓对象再用 Pillow 把字形渲染成黑白图片最后做逐像素的相似度计算。这样做误差大因为不同字体渲染出的数字轮廓粗细不同但只要目标网站的字体是「单字体文件内自包含映射」的生成方式就能通过训练样本的方式逐步积累映射表。一个重要的工程加速手段是大众点评的字体文件是定期轮换的但数字样本有限0-9可能加小数点。每次字体更新后只需要重新识别 11 个字符并保存映射关系。项目中的映射表可以持久化到本地文件后续遇到相同字体文件直接复用# 映射表持久化 import json MAPPING_FILE data/font_mapping.json def load_mapping(): try: with open(MAPPING_FILE, r, encodingutf-8) as f: return json.load(f) except FileNotFoundError: return {} def save_mapping(font_url, mapping): all_mapping load_mapping() all_mapping[font_url] mapping with open(MAPPING_FILE, w, encodingutf-8) as f: json.dump(all_mapping, f, ensure_asciiFalse, indent2)这里用文件 hash 或 URL 作为映射表的 key能快速判断当前页面字体是否已经解析过减少重复计算。4.4 店铺详情页的 XPath 抽取页面数字类字段在抽取时先取出加密字符再从映射表反查真实值。以店铺评分为例def parse_shop_info(self, response): # 获取加密的评分字符 encrypted_score response.xpath( //div[contains(class, review-score)]//span[contains(class, score)]/text() ).get() # 映射表反查真实数字 mapping self.font_mapping[response.meta[font_url]] real_score self.decrypt_score(encrypted_score, mapping) # 评价数量和人均价格同理 yield ShopInfoItem( shop_nameresponse.xpath( //h1[contains(class, shop-name)]/text() ).get(), shop_addressresponse.xpath( //span[contains(class, address)]/text() ).get(), scorereal_score, # ... )这里的关键陷阱在于text()拿到的加密字符可能不是单个 unicode 字符而是多个字符组合比如数字「10」被拆成「1」和「0」两个字符分别加密。处理时要把文本中每个字符都过一遍映射表。另外点评的评分 DOM 有新版和旧版两种结构新版用 CSS classreview-score旧版用brief-info建议在解析前先做一次 XPath 匹配检测避免因页面改版导致的解析失效。5. 数据管道、依赖顺序与运行流程5.1 pipelines.py 的数据保存策略项目对数据的落盘方式比较直接——保存为 JSON 行文件每行一条记录。这种方式的好处是结构清晰、追加写入方便配合 jq 等命令行工具做后续处理很顺手import json import os class DianpingPipeline(object): def __init__(self): self.data_dir data if not os.path.exists(self.data_dir): os.makedirs(self.data_dir) def process_item(self, item, spider): # 根据 item 类型决定输出文件名 filename self.get_filename(item) with open(os.path.join(self.data_dir, filename), a, encodingutf-8) as f: line json.dumps(dict(item), ensure_asciiFalse) \n f.write(line) return item def get_filename(self, item): if city_name in item: return cities.jsonl elif category_name in item: return categories.jsonl elif shop_url in item: return shop_urls.jsonl elif shop_name in item: return shop_info.jsonl return others.jsonl这里用a模式追加写入每次运行爬虫都会在原有文件上继续写不会覆盖已有数据。如果需要每次全量爬取并覆盖旧数据可以在 spider 的closed回调里清空重写。另外注意文件的编码问题ensure_asciiFalse可以保证中文以原始字符写入而不是 unicode 转义。5.2 爬取链路的依赖顺序项目 README 中明确提到了依赖关系「爬取店铺链接之前必须先爬取城市和分类爬取店铺信息之前必须先爬取店铺链接」。这个依赖链在设计上完全正确店铺链接 URL 需要城市 ID 和分类 ID 拼接而成而店铺详情页的 URL 需要从店铺链接的响应中提取。# 从城市和分类生成店铺链接 URL def generate_shop_urls(self, city, category): # 大众点评的店铺列表 URL 格式 # https://www.dianping.com/{city_pinyin}/ch{category_id} return fhttps://www.dianping.com/{city[city_pinyin]}/ch{category[category_id]}在实际项目运行中如果你发现店铺信息爬取为空或大量请求返回 404大概率是城市或分类数据不完整或过期了。城市 ID 和分类 ID 是点评根据运营区域动态调整的建议定期重跑城市和分类爬虫来保持数据新鲜度。5.3 四个爬虫的运行命令与调度项目里应该有多个爬虫分别对应不同的数据层级。运行方式很标准# 1. 爬取城市列表 scrapy crawl city_spider # 2. 爬取分类列表 scrapy crawl category_spider # 3. 爬取店铺链接依赖 1、2 的数据 scrapy crawl shop_url_spider # 4. 爬取店铺详情依赖 3 的数据 scrapy crawl shop_info_spider如果希望一条命令串联执行可以在 shell 脚本中用连接scrapy crawl city_spider \ scrapy crawl category_spider \ scrapy crawl shop_url_spider \ scrapy crawl shop_info_spider这样当前一个命令失败时后序命令不会执行能避免在数据缺失的情况下空跑。5.4 请求延迟与重试的参数调优大众点评的反爬触发阈值比较敏感对请求频率的要求很高。项目 settings.py 中的参数建议这样调整参数建议值范围说明DOWNLOAD_DELAY1.0 - 3.0值越大越安全但爬完一轮的时间也会线性增加CONCURRENT_REQUESTS4 - 16建议从低到高逐步试探自己的 IP 质量RETRY_TIMES2 - 5超过 3 次重试仍失败的建议放弃降低请求量RETRY_HTTP_CODES[403, 429, 500, 502, 503]403 和 429 是反爬信号其他是服务器异常DOWNLOAD_TIMEOUT10 - 30太小容易误判超时太大则卡住爬虫这里要特别说明RETRY_TIMES的调优逻辑如果频繁出现 403不要靠调大重试次数来解决因为这意味着 IP 已经被识别重试只会加重封禁风险。正确做法是看代理池质量或者降低并发加大延迟。5.5 爬取中途被 Ban 的恢复策略实际运行中遇到的情况是爬虫跑了几个小时后突然大量出现 403。这时不要直接停进程而是让 Scrapy 自然结束当前队列的剩余请求。恢复步骤是# 1. 先停掉当前爬虫ctrlc 发 SIGINT会触发正常清理流程 # 2. 检查代理池中哪些代理被封 # 3. 更换 Cookie重新浏览器登录 # 4. 使用 -a 参数传参控制断点续爬 scrapy crawl shop_info_spider -a start_offset1000 -a end_offset2000在 spider 中读取start_offset和end_offset参数的方式是def __init__(self, start_offset0, end_offsetNone, *args, **kwargs): super().__init__(*args, **kwargs) self.start_offset int(start_offset) self.end_offset int(end_offset) if end_offset else None这样就可以从上次停下的位置继续爬取不用重新从头开始。6. 字体映射表预构建与增量更新技巧字体反爬这个环节纯靠每次启动爬虫都现解析字体文件是低效的。我一般会在正式跑爬虫前用一个独立的脚本来预构建字体映射表把点评的字体下载几百份样本不同时间生成的字体文件批量解析出所有可能出现的字形与数字的对应关系存成一个全局的「字形指纹库」。这样在爬虫运行过程中遇到一个新的字体文件只需要计算这个字体的指纹在指纹库中查表即可不用每次做轮廓比对。字体指纹的计算方式可以用简单 hash把字形的轮廓坐标归一化后生成一个字符串指纹再用一个全局字典做映射import hashlib def compute_glyph_fingerprint(coordinates): 将字形轮廓坐标转为指纹 # 归一化缩放至 100x100 范围 xs [p[0] for p in coordinates] ys [p[1] for p in coordinates] min_x, max_x min(xs), max(xs) min_y, max_y min(ys), max(ys) normalized [] for x, y in coordinates: nx int((x - min_x) / (max_x - min_x 1e-6) * 100) ny int((y - min_y) / (max_y - min_y 1e-6) * 100) normalized.append((nx, ny)) # 对坐标排序后生成指纹字符串 normalized.sort() fingerprint_str str(normalized) return hashlib.md5(fingerprint_str.encode()).hexdigest()那这个指纹库怎么构建方案是启动爬虫前用浏览器无头模式或纯 requests 请求点评网站的多个分类页面收集页面中引用的所有字体文件 URL然后对每个字体跑一次全量字形提取和人工标注。好在数字字形只有 0-9 和一个小数点人工标注一次后后续同一字体的字形就不用再标了。整个过程大概需要一小时左右的准备时间但能在正式爬取时省下大量解析开销。增量更新机制也很重要正常情况下点评的字体文件是按轮次生成的每周或每两周换一批。你已经有的指纹库可能对旧字体有效但对新生成的字体不覆盖。比较稳妥的做法是每次爬虫运行前用少量页面比如 50 个页面预取字体文件检查是否有新指纹。如果有新指纹自动触发一轮「字形识别回归流程」把新数字标注完再进入正式爬取。这样可以避免在长时间运行中字体突然更换导致大量解析失败、数据错乱。还有一点要提醒字体反爬的解析失败通常不是显式的报错而是提取出的 score 变成一串乱码或无意义的字符。排查时优先看日志中是否有较多「解密异常」的警告以及检查映射表是否有命中记录。如果大量 miss基本可以断定字体已经换批次了需要重新构建映射。最后补充一个日常调试的小技巧点评的字体反爬只作用于数字类字段对于店铺名称、地址、分类这些纯文本字段没有影响。当你怀疑字体解析有问题时可以先用 XPath 只抽取文本字段观察是否有数据——如果文本字段正常而数字字段异常问题锁定在字体层面如果全部异常优先检查登录态和代理状态。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价