首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >用 fontTools 拆掉大众点评的字体反爬,评分数字才不会再错位

用 fontTools 拆掉大众点评的字体反爬,评分数字才不会再错位

原创
作者头像
小白学大数据
发布于 2026-10-09 16:58:45
发布于 2026-10-09 16:58:45
1130
举报

做了快两年点评相关的采集,踩过的坑比写过的脚本还多。这篇稿子不聊"突破",只聊"稳定":评论接口在前端做了几层保护,哪些反爬会真正让任务停摆,以及怎么用代理调度、限流和字体映射把采集长期跑住。反爬本质是攻防演进,今天能跑通的方案,下周未必还行,所以下面讲的都是工程骨架,具体参数得拿你自己的样本去校准。

评论接口:签名才是第一道门

点评的评论不是你直接能调的公开接口,别指望拼一个 /shop/{id}/reviews 加几个查询参数就拿回数据。前端在发请求前会算一层签名,常见形态是带下划线前缀的参数,比如 _token、_ts,还有一个跟着时间戳的 _。这些值由页面 JS 用当前时间加盐现场算出来,服务端收到后再校验一遍。你从浏览器里复制一次成功的 curl 能跑通,是因为那一刻的签名还在有效期里,等它过期,同样的请求就回 401 或者直接甩一页空数据。

签名算法一般打在前端 webpack 打包后的混淆代码里,定位入口常用办法是 grep 请求里固定出现的参数字段名,顺着调用栈找到加盐函数。落地有两条路:一条是把加密函数抠出来用 Python 重写,一次写好能扛一阵子,缺点是点评一改版你就得重新抠;另一条是每次请求前让无头浏览器把页面跑一遍,实时拿到签名再带着去请求。我前期验证走第二条,快;要长期跑的生产任务,才值得花时间啃第一条,因为无头浏览器本身也有指纹成本。

IP 频控:代理池要会调度,不是取来就用

点评对单个出口 IP 的评论请求频率卡得很死。同一个 IP 连着刷几百条,轻则弹验证码,重则封整段。代理 IP 在这里不是可选项,是必需品。我们生产用的是亿牛云代理的短效动态 IP 池,按请求从接口现取出口 IP,再叠一层本地限流,把单 IP 的放量压在合理区间。

但光"取到 IP"不够,代理池得会调度。免费代理我建议直接划掉,延时高、存活短,还容易和别人用过的脏请求撞车,封了都查不出原因。付费代理挑的时候看三点:能否按请求切换出口、是否有鉴权、是否给 SLA。亿牛云这几样够稳,我们凌晨跑批遇到过两次 IP 池波动,基本半小时内恢复。

真正决定成功率的是池子的本地治理:每个节点记失败次数,连续失败三次就从可用集剔除;可用节点低于阈值就提前向亿牛云预热拉一批,别等用光了才补;选节点用加权随机而不是纯轮询,让刚成功的节点多休息一会。下面这段代码就是这套逻辑。

代码语言:javascript
复制
import asyncio
import logging
import random
import time
from dataclasses import dataclass
from typing import Optional

import httpx

logger = logging.getLogger(__name__)


@dataclass
class ProxyNode:
    """代理节点,带健康度用于调度时剔除。"""
    url: str
    failures: int = 0
    last_used: float = 0.0

    @property
    def healthy(self) -> bool:
        return self.failures < 3


class ProxyPool:
    """亿牛云短效动态 IP 池的本地封装:预热、加权选取、失败剔除。"""

    def __init__(self, api_url: str, refill_threshold: int = 5) -> None:
        self._api_url = api_url
        self._nodes: list[ProxyNode] = []
        self._refill_threshold = refill_threshold
        self._lock = asyncio.Lock()

    async def acquire(self) -> ProxyNode:
        async with self._lock:
            if len(self._nodes) < self._refill_threshold:
                await self._refill()
            node = self._select()
            node.last_used = time.monotonic()
            return node

    async def _refill(self) -> None:
        # 调用亿牛云取 IP 接口,这里只示意返回结构
        async with httpx.AsyncClient() as client:
            resp = await client.get(self._api_url, timeout=5.0)
            for ip in resp.json().get("proxies", []):
                self._nodes.append(ProxyNode(url=ip))

    def _select(self) -> ProxyNode:
        healthy = [n for n in self._nodes if n.healthy]
        return random.choice(healthy) if healthy else self._nodes[0]

    def report_failure(self, node: ProxyNode) -> None:
        node.failures += 1
        if not node.healthy:
            self._nodes.remove(node)


class TokenBucket:
    """异步令牌桶,控制全局 QPS,比固定 sleep 更接近真实流量形态。"""

    def __init__(self, rate: float, capacity: float) -> None:
        self._rate = rate
        self._capacity = capacity
        self._tokens = capacity
        self._updated = time.monotonic()
        self._lock = asyncio.Lock()

    async def acquire(self) -> None:
        async with self._lock:
            now = time.monotonic()
            self._tokens = min(self._capacity, self._tokens + (now - self._updated) * self._rate)
            self._updated = now
            if self._tokens < 1:
                await asyncio.sleep((1 - self._tokens) / self._rate)
                self._tokens = 0
            else:
                self._tokens -= 1

令牌桶比 time.sleep(1) 更像真人,因为它允许短时突发、长期均值受限,服务端风控看到的流量曲线是起伏的而不是直线。

字体反爬:字形映射比正则靠谱

点评的评论数字和评分经常走自定义字体。网页源码里你看到的是一堆 PUA 区的乱码字符,真实数字藏在 woff 字体文件里,靠字形坐标映射到实际值。这招最阴,因为你抓下来的文本看着"有内容",数字却全是错的,等到对账才发现评分和现实对不上。

处理办法是用 fontTools 把字体文件读进来,取每个字形的轮廓点做指纹,跟一份基准字体比对,得到"乱码 unicode 到真实数字"的映射。字体文件名每次会变,但字形轮廓不会天天换,用字体文件的 hash 当缓存键就够用,同 hash 直接查表,不用重复解析。

代码语言:javascript
复制
from fontTools.ttLib import TTFont


class FontCipher:
    """把点评自定义字体还原成真实字符,字形轮廓做指纹。"""

    def __init__(self, baseline: TTFont) -> None:
        self._baseline = baseline

    def decode(self, text: str, font: TTFont) -> str:
        glyph_map = self._build_map(font)
        return "".join(glyph_map.get(ch, ch) for ch in text)

    def _build_map(self, font: TTFont) -> dict[str, str]:
        # 用字形轮廓与基准字体比对,得到 unicode 到真实字符的映射
        mapping: dict[str, str] = {}
        for unicode_val, glyph_name in font.getBestCmap().items():
            real = self._match_glyph(font, glyph_name)
            if real is not None:
                mapping[chr(unicode_val)] = real
        return mapping

    def _match_glyph(self, font: TTFont, name: str) -> Optional[str]:
        # 实际实现按轮廓点相似度匹配基准字体,这里留接口
        return None

行为风控:别只发请求,要像人

这几年点评的风控从"看 IP、看参数"升级到"看浏览器环境"。纯 API 重放最容易露馅的地方是设备指纹:canvas 渲染哈希、WebGL 渲染器、字体列表、时区与语言组合,这些在无头环境里往往是一套固定值,服务端一眼就能归类成机器。再往上是行为序列,鼠标移动的轨迹、两次请求的间隔分布,真人是有抖动和长尾的,脚本通常是均匀脉冲。

应对上,无头浏览器要注入真实指纹(或者直接用带指纹的浏览器实例),请求间隔走令牌桶加抖动,别写成固定节奏。遇到验证码不要硬顶,把该 IP 标记冷却一段时间,成功率比强行重试高得多。

生产 checklist

cookie 会过期,别写死在代码里,做成定时刷新任务,过期前重新拿,断线比慢速更致命。UA 也别只用一个,准备十几个真实 UA 轮换,移动端和桌面端混着上。日志要打足三个指标:代理命中率、验证码触发率、单店铺失败率,这三者是你第二天判断该调哪里的唯一依据。还有一件常被忽略的事,别一上来就全量跑,先用小批样本验证签名、字体映射、指纹都没问题,再放量,否则半夜被封了都不知道错在哪个环节。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 评论接口:签名才是第一道门
  • IP 频控:代理池要会调度,不是取来就用
  • 字体反爬:字形映射比正则靠谱
  • 行为风控:别只发请求,要像人
  • 生产 checklist
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档