eFootball 爬虫技术拆解:从 JA3 到 Cookie 大法

我的 eFootball 球员数据爬虫是怎么从 38,000 次请求砍到 1,177 次的——TLS 指纹伪装、Perlin 噪声限速、熔断退避,以及那个救了命的 cookie 发现。

· 13 min read

eFootball 爬虫技术拆解:从 JA3 到 Cookie 大法

一份面向「知道我的爬虫能跑、但不清楚里面每个零件为什么存在」的技术笔记。


爬虫到底在干什么

你打开 pesdb.net/efootball/ 时,浏览器做的事很简单:

  1. 给服务器发一个 HTTP 请求——“把这页 HTML 给我”
  2. 服务器回一坨 HTML
  3. 浏览器把它画成你看到的表格

爬虫做的是同一件事,只是把第 3 步换掉了——不画给人看,而是从 HTML 里把球员数据抠出来,存进 CSV。

所以我的脚本只有两个动作,反复做:

  • (fetch):发请求,拿回 HTML——fetch_html()
  • 解析(parse):从 HTML 里提取数据——parse_list_page()

pesdb 有 37,650 个球员。如果每个球员一个详情页,就是 3.7 万次”抓”。太多了——而我这个项目最聪明的地方,就是把这个数字砍到了 1,177 次


核心杀招:Cookie 列表页大法

Cookie 是网站存在你浏览器里的一张小纸条。每次你再访问这个网站,浏览器会自动把纸条夹在请求里一起发过去,网站就能”认出”你、记住你的偏好。

pesdb 的球员列表页默认只显示几列(名字、位置、总评……)。但这个网站允许你自定义要显示哪些列——而”你选了哪些列”这个偏好,它就存在一张叫 columns 的 cookie 里。

关键洞察:如果你在 cookie 里把全部 118 列都勾上,那么列表页一次就会把这一页所有球员的全部数据都吐出来——包括原本要点进详情页才看得到的所有能力值、技能、踢球风格。

这意味着什么

方案要抓的页面数
笨办法:爬每个球员的详情页37,650 次
我的方案:cookie + 列表页1,177 次

请求量直接砍掉 97%。 这就是为什么我能用单 IP、不用代理、几分钟就跑完——根本原因不是别的优化,而是这一招把”要敲门的次数”压到了极低。

# _COLS_PARAM 里列出了全部 118 列
_COLS_PARAM = "id,club_number,…,P05,P06,P07"

def _get_session():
    session.cookies.set(
        "columns",
        urllib.parse.quote(_COLS_PARAM, safe=""),
        domain="pesdb.net",
    )

这段就是在伪造那张”会员卡”,告诉 pesdb:“我这个用户想看全部 118 列”。


网站是怎么识别「你不是真人」的

既然爬虫就是”发请求”,那网站凭什么拦我、放行真人?因为程序发的请求和真浏览器发的请求,在很多细节上长得不一样。主要有四层:

层级网站看什么真人 vs 裸爬虫的区别
① TLS 指纹建立加密连接时的”握手方式”Python 默认库的握手和 Chrome 完全不同
② HTTP 头浏览器型号、语言偏好…裸爬虫的头又少又假
③ 行为节奏请求的频率、规律性真人有快有慢有停顿,爬虫匀速猛敲
④ IP 地址请求来源网络地址单个 IP 短时间海量请求 = 可疑

我对这四层各有一件武器:

  • ① → TLS 指纹伪装(curl_cffi)
  • ② → 浏览器身份轮换(BrowserProfile)
  • ③ → 拟人化限速 + 熔断退避
  • ④ → 代理池(但我这个量用不上)

武器一:TLS 指纹伪装(JA3 / curl_cffi)

访问 https:// 网站时,浏览器和服务器要先握手,商量好用什么加密方式。握手的第一步,客户端会发一份”我支持这些加密套件、这些扩展、按这个顺序”的清单(ClientHello)。

不同的软件,这份清单的内容和顺序都不一样——Chrome 是一种排法,Firefox 另一种,Python 标准库又是另一种。把这份清单压缩成一串哈希值,就得到一个指纹。JA3 是最有名的指纹算法(还有更新的 JA4)。

Python 默认网络库(requestsurllib)的握手手感是”Python 味”的,和任何真浏览器都对不上。反爬系统一摸就知道:“这不是浏览器,是个脚本。“

我怎么解决的

curl_cffi 借用了 Chrome 同款的底层加密引擎(BoringSSL)和 Firefox 同款的(NSS),所以它发出的 ClientHello 和真 Chrome / 真 Firefox 一模一样。而且它不只骗 JA3——它连 HTTP/2 的通信指纹(另一种更新的识别手段)也一起复刻了。

from curl_cffi.requests import Session as CurlSession
session = CurlSession(impersonate="chrome136")

impersonate="chrome136" 就是在说:“握手的时候,假装你是 Chrome 136。“

稳定性提醒

impersonate 的值必须是你安装的那个 curl_cffi 版本认识的型号。我现在用的是 0.15.0,代码里 6 个型号都支持。但要小心:

  • 换台机器装到更老的 curl_cffi,可能不认识 chrome136 → 直接报错
  • 真实 Chrome 一直在升版本,我代码里最老的 chrome116 在 2026 年已经是个”老古董指纹”,长期看应该把伪装目标整体往新版本挪

武器二:浏览器身份轮换(BrowserProfile)

就算每个请求都伪装成 Chrome,如果几千个请求全都是”同一个 Chrome 136、同一种语言设置”,也很可疑。

我准备了 6 套不同的浏览器+语言组合:

_PROFILES = [
    ("chrome136",  "en-US,en;q=0.9"),
    ("chrome124",  "en-GB,en;q=0.9,de;q=0.7"),
    ("chrome120",  "en-US,en;q=0.8,zh-CN;q=0.5,zh;q=0.3"),
    ("chrome116",  "ja,en-US;q=0.9,en;q=0.8"),
    ("firefox135", "en-US,en;q=0.9"),
    ("firefox133", "en-GB,en;q=0.8,fr;q=0.3"),
]

关键不是每次请求都换——频繁横跳反而不自然。我是在被限速、触发熔断之后才换一套身份重新出现。就像”这个身份被盯上了,那我换身衣服再进来”。


武器三:代理池(ProxyPool)——我用不上的那个

代理服务器就是一个”中间人”,帮你把请求转发出去。网站看到的 IP 是代理的 IP,不是你的真 IP。代理池 = 一群代理轮流用,分散 IP。

我的代码里有 ProxyPool 类,从 proxies.txt 读代理。但这个文件在我的运行环境里根本不存在,所以全程用真实 IP 直连。

而这完全没问题,因为 cookie 那招已经把请求总量从 3.7 万压到 1,177——这点量单 IP 完全扛得住。

代理解决的是”同一个 IP 请求太多”的问题;而我靠 cookie 把请求次数压得极低,从源头上就没产生这个问题。那封 anyIP 的销售邮件想卖给我的,正是我不缺的东西。


武器四:拟人化限速(Perlin 噪声)

机器人最容易暴露的特征是节奏太规律——每隔正好 1.5 秒敲一次,几千次分毫不差。真人不可能这样。

我没有用 time.sleep(1.5) 固定等。而是以 1.5 秒为基准,用 Perlin 噪声让每次的间隔平滑地上下浮动:

n1 = noise.pnoise1(self._t * 0.06) * 0.25   # 慢波:整体节奏漂移 ±25%
n2 = noise.pnoise1(self._t * 0.35) * 0.15   # 中波:短时爆发/暂停 ±15%
n3 = noise.pnoise1(self._t * 2.00) * 0.08   # 快波:单次请求抖动 ±8%
interval = base * (1 + n1 + n2 + n3)

普通随机数是”跳跃的”——这次 0.1 下次 0.9,毫无关联。Perlin 噪声是一种”平滑的随机”:值随时间连续、缓慢地起伏,像真实的潮汐、像人手的自然抖动。三种快慢不同的波叠在一起,整体就是有机的、不规律的节奏。

另外每隔 3590 个请求,还会随机插入 1.55 秒的”走神停顿”——模拟人类停下来想一想。


武器五:熔断器与退避(ThrottleGate / ReliabilityTracker)

前面都是主动伪装。这一节是”被发现后怎么自保”。

当请求太频繁,服务器通常回 HTTP 429(Too Many Requests)或 503(Service Unavailable)。

ThrottleGate:总闸门

一旦收到 429/503,ThrottleGate 就像一个总闸门啪地关上,让所有工人全部暂停,进入冷却。冷却时间是阶梯递增的:

COOLDOWNS = [20, 40, 60, 90, 120]   # 第1次停20秒,第2次40秒……最多120秒

冷却结束后闸门重新打开,并顺手换一套浏览器身份再继续。

ReliabilityTracker:自适应退避

光停一下不够——如果一直在被限速,说明整体节奏就太快了,得永久性放慢:

  • 每被限速一次,基准间隔乘以 1.6(变慢)
  • 每成功 100 次,间隔乘以 0.95(慢慢恢复)
  • 间隔有上下限,最慢不超过基准的 3 倍

这套”踩刹车—松刹车”让我始终贴着网站能容忍的最快速度跑。

组件角色时间尺度
ThrottleGate出事时全员急停冷却短期(秒级)
ReliabilityTracker调整长期巡航速度长期(整轮渐变)
HumanizedRateLimiter控制每一步的具体间隔每个请求

并发:3 个工人同时干活

我用 3 个线程同时抓不同的页面——ThreadPoolExecutor 调度。为什么是 3 不是 30?开太多 = 对网站太凶 = 立刻被限速。3 是”够快”和”不惹事”之间的平衡。

3 个工人同时写 CSV、同时改进度,可能撞车,所以用锁保护:

with csv_lock:
    writer.writerow(row)

每个工人还有自己独立的 session(threading.local()),各自的 cookie 和身份互不干扰。


断点续爬 + 仪表盘

每抓完 50 页存一次进度到 efootball_progress_v2.json。中途断电、报错、Ctrl+C——下次启动自动跳过已完成页,CSV 追加模式不覆盖。

仪表盘用 rich 库画的实时面板:进度条、ETA、当前身份、冷却状态、HTTP 错误统计。每 0.25 秒刷新。纯属好看——删掉它爬虫照样跑。


一次请求的完整旅程

抓第 500 页这一次请求经历了什么:

  1. ThreadPoolExecutor 把任务派给某个空闲工人
  2. _gate.wait() — 如果正在冷却,工人在这里等闸门打开
  3. _rate.wait() — Perlin 噪声算出的间隔停一下(约 1.5 秒上下浮动),偶尔走神更久
  4. _get_session() — 拿到本工人专属的、伪装成 Chrome 136 的会话,带好全 118 列的 cookie
  5. _proxy_pool.next() — 返回 None,直连
  6. 用伪装的 TLS 指纹 + 浏览器头发请求
  7. 判结果:200 → 记成功并微微提速;429/503 → 退避 + 熔断冷却 + 换身份重试
  8. parse_list_page() — 从 HTML 抠出几十个球员、每人 118 列
  9. 抢到 csv_lock 后写入 CSV,标记第 500 页已完成
  10. 后台线程刷新仪表盘

1177 页各自走一遍这个流程(3 个工人并行),全部跑完就得到完整的 efootball_players.csv


总结:三句话

  1. 真正的杀手锏是 cookie 列选择器——把请求量砍掉 97%,让其余一切变得轻松
  2. TLS 指纹伪装是请求层面的”换脸”,让脚本的握手长得像真 Chrome;身份轮换、拟人限速、熔断退避都是为了”既快又不惊动网站”
  3. 代理池我用不上,因为请求量低到单 IP 完全能扛——那封 anyIP 的邮件卖的正是我不缺的东西

工具栈:Python 3.12 · curl_cffi · BeautifulSoup · Rich · noise · ThreadPoolExecutor