← 返回 Day 193 正文

Day 193 · 图解

Day 193 图解 — 浏览器渲染与验证码识别

所有图都可用 Mermaid Live Editor 渲染;ASCII 图在终端里可直接看。


1. 同一条 URL 的两种命运:静态抓取 vs 浏览器渲染

                      服务器返回的原始 HTML(4257 字节)
                      ┌──────────────────────────────┐
  urllib / requests → │ <ul id="list"><!-- 空 --></ul>│ → 0 条商品
                      │ <p>booting…</p>               │
                      └──────────────────────────────┘

                      浏览器进程
  Playwright ────────→ ┌──────────┐   ┌───────────┐   ┌──────────────┐
                       │ HTML 解析 │ → │ JS 引擎    │ → │ DOM 被改写    │
                       │ 建 DOM    │   │ setTimeout│   │ 12 个 <li>    │
                       └──────────┘   │ fetch     │   └──────────────┘
                                      └───────────┘            │
                                                               ↓
                                                        page.locator() → 12 条

关键点:渲染不是"更高级的下载器"。原始 HTML 里根本不存在这些数据, 它是浏览器执行 JS 之后才被创造出来的。抓不到不是速度问题,是信息不存在。


2. 等待策略时间线(真实测量)

靶站行为:load 事件约 180ms 触发;setTimeout(700ms) 后才发 fetch; 接口 ds=700 再延迟 700ms;内容在 ≈1400ms 时才进 DOM。

0ms        200      400      600      800      1000     1200     1400     1600
│          │        │        │        │        │        │        │        │
├─ load ───┤                                                                  → 0 条 ❌
│          └─ selector-exists (#status 早就在) ─→ 0 条 ❌
│                    │                                                       
│                    └──── sleep(1s) 醒了 ────→ 0 条 ❌(差 400ms)
│                             │                                              
│                             └─ networkidle(500ms 无请求)→ 0 条 ❌
│                                                                            
└──────────────────────── 内容真正出现 ────────────────────────┬──→ 12 条 ✅
                                                               │
                                          selector-items ◄─────┤  1550ms
                                          wait-function ◄──────┘  1452ms

为什么 networkidle 会骗人:它判断的是"最近 500ms 没有网络活动"。 页面在 0–700ms 之间一个请求都没发(定时器还没到),所以"网络空闲"成立, 但它对"页面即将发请求"一无所知。实测 698ms 就返回了,此时列表还是空的。


3. 指纹一致性矩阵:破绽来自"字段之间互相矛盾"

                  A 默认无头   B 改UA/时区   C 再抹webdriver   D 无GPU
UA                 Headless     Win10        Win10            Headless
platform           Linux        Linux  ✗     Linux   ✗        Linux
timezone           Shanghai     New_York     New_York         Shanghai
screen             1280x720     1920x1080    1920x1080        1280x720
webdriver          true    ✗    true    ✗    undefined        true    ✗
WebGL renderer     SwiftShader✗ SwiftShader✗ SwiftShader  ✗   SwiftShader✗
canvas hash        37db8d7b     37db8d7b     37db8d7b         37db8d7b
fontsCount         5            5            5                5
─────────────────────────────────────────────────────────────────────────
risk               2            3 ← 更多     2                2

真实测出来的反直觉结论:只改 UA 会让风险从 2 升到 3。 因为你新增了一条 UA=Windows 但 platform=Linux 的矛盾,而原来没有这条矛盾。 一致性比"像不像人"更容易被检查。


4. 验证码识别流水线

 ┌────────────┐   ┌──────────────┐   ┌──────────────┐   ┌──────────────┐
 │ 取图        │ → │ 预处理        │ → │ OCR 推理      │ → │ 提交 + 判定   │
 │ GET /captcha│   │ gray/otsu/... │   │ ddddocr CNN  │   │ POST /login  │
 └────────────┘   └──────────────┘   └──────────────┘   └──────┬───────┘
       ↑                                                        │
       │  403(错)→ 必须换一张新图,不能重试旧图                 │
       └────────────────────────────────────────────────────────┘
                          200 → 结束;超过 max_tries → 放弃并记账

一次性是验证码的核心设计:服务端校验后立刻作废会话。 所以"重试"的语义是"重新取图 + 重新识别",不是"重新提交同一个答案"。


5. 预处理不是免费午餐(实测准确率)

  准确率
  100% ┤ 98   98                     ← clean:不处理最好
   96% ┤        96   94
   92% ┤                          92  91   ← hard:Otsu 反而最好
   88% ┤              91  91  89
   84% ┤                          86  85
   80% └───┬────┬────┬────┬────┬────┬────
          none gray otsu denoise ...
          └── clean ──┘└─ medium ─┘└─ hard ─┘

  规律:
    噪声弱  → 预处理是负担(抹掉笔画细节)
    噪声强  → 二值化能救回一部分(+6pp)
    结论    → 必须按噪声强度做 A/B,不能凭直觉固定一条流水线

6. 端到端状态机(04 脚本)

stateDiagram-v2 [*] --> Render Render --> ItemsReady: 等到 #list li 出现 ItemsReady --> GetCaptcha GetCaptcha --> OCR: 下载 PNG(新会话) OCR --> Submit: 识别 4 字符 Submit --> Done: 200 Submit --> GetCaptcha: 403 且 attempts < max_tries Submit --> GiveUp: 403 且 attempts >= max_tries Done --> [*] GiveUp --> [*]
stateDiagram-v2
    [*] --> Render
    Render --> ItemsReady: 等到 #list li 出现
    ItemsReady --> GetCaptcha
    GetCaptcha --> OCR: 下载 PNG(新会话)
    OCR --> Submit: 识别 4 字符
    Submit --> Done: 200
    Submit --> GetCaptcha: 403 且 attempts < max_tries
    Submit --> GiveUp: 403 且 attempts >= max_tries
    Done --> [*]
    GiveUp --> [*]

每一次 403 → GetCaptcha 都是一次"新图 + 新识别",并且计入 attempts。 没有这个上限,脚本会在风控眼里变成"一秒刷新 100 次验证码的机器"。


7. 本课实验拓扑

  ┌──────────────────────────── 本机 127.0.0.1:随机端口 ────────────────────────────┐
  │                                                                              │
  │  00-lab-target.py                                                             │
  │   ├── GET /            动态页(700ms 后 fetch 注入 12 条)                     │
  │   ├── GET /api/items   可注入延迟(ds=ms)                                     │
  │   ├── GET /captcha     生成 PNG + Set-Cookie: sid(一次性)                    │
  │   └── POST /login      校验答案,对错都作废 sid                                │
  │                                                                              │
  └───────────────▲──────────────────────────▲───────────────────────────────────┘
                  │ Playwright (01/02/04)    │ urllib (04 --dry-run)
         ┌────────┴────────┐        ┌────────┴─────────┐
         │ Chromium 151     │        │ http.cookiejar    │
         │ SwiftShader      │        │ ddddocr           │
         └─────────────────┘        └──────────────────┘