Featured image of post HTTP、Cookie与OAuth认证:API逆向与后端数据的抓取

HTTP、Cookie与OAuth认证:API逆向与后端数据的抓取

Views —

你是否遇到过这种情况:在网页上看到了一堆有用的数据(比如数据表单、排行榜、或者社交媒体评论),想要把它们保存下来分析,但手动复制粘贴太慢了?

这时候,你需要的是直接与后端服务器对话,从后端抓取数据。

很多人把这叫做“写爬虫”,但更专业的说法是API 逆向或后端数据抓取。今天,我们就抛开复杂的各种自动化浏览器工具(如 Selenium、Playwright),回归本质,聊聊如何像浏览器一样,通过代码直接向服务器索要数据。

我将以获取 PTA 排行榜 (视奸小登进度) 和 反代 Antigravity 的 API 为例。

前端是怎么和后端通信的?

HTTP 协议

浏览器和服务器之间的交流是基于 HTTP 协议(HyperText Transfer Protocol)的。

  • 客户端/浏览器: “我要请求一个 API 获取数据。” —— 请求 (Request)
  • 服务器: “好的,这是您的数据。” —— 响应 (Response)

WebSocket 协议

WebSocket 是一种在单个 TCP 连接上进行全双工通信的协议。它让服务器能够主动向浏览器推送数据。

  • 握手阶段(HTTP):
    • 客户端: “我想升级成 WebSocket 协议。”
    • 服务器: “没问题,协议升级成功。”
  • 通信阶段(双向):
    • 客户端: “我发个消息给你。”
    • 服务器: “收到,我也传给你个新消息。”
    • (连接一直保持,双方可以随时互发数据,不需要重新建立连接)

image.png

核心概念

要骗过服务器,让他以为你是浏览器,你需要构造一个完美的请求。一个请求通常包含以下关键部分:

  • URL (统一资源定位符): 你要去哪里找数据(比如 https://api.example.com/data)。

  • Method:

    • GET: 最常用。意思是“把数据拿给我看”。(比如浏览网页)。
    • POST: 意思是“把我的数据提交上去”。(比如登录、提交表单)。 image.png
  • Headers: 这部分最关键!这是你向服务器展示身份的地方。

    • Authorizaton: 一般是Bearer <token>,用来验证身份
    • User-Agent: 告诉服务器你是谁(是浏览器还是脚本?伪装这个)。
    • Referer: 告诉服务器你是从哪个页面跳转过来的(防盗链常用)。
    • Content-Type: 告诉服务器你发过去的数据格式(是 JSON 还是表单)。 image.png

找到 API 地址——浏览器开发者工具

在你想要找某种数据之前,你需要先当一名侦探。

  • 打开 Chrome 浏览器,按 F12 (或右键 -> 检查)。
  • 点击 Network (网络) 标签页。
  • 点击 Fetch/XHR 过滤器(这一步很重要,这能帮你过滤掉图片、CSS,只看后端返回的数据接口)。
  • 刷新网页。 image.png

这时候你会看到一堆请求。逐个点击,查看 Preview 或 Response。当你看到类似

1
{ "name": "张三", "price": 100 }

这种 JSON 格式的数据时,恭喜你,你找到了后端接口

身份验证

很多数据是需要登录才能看到的。但是 HTTP 协议有一个特点:它是无状态的。

服务器不知道刚才请求“登录”的人,和现在请求“个人中心”数据的人是不是同一个。为了解决这个问题,Session 和 Cookie 诞生了。

上文的 PTA 排名接口就是通过 Session 与 Cookie 进行身份验证的。 这种方法有一个缺点,你必须定时获取最新的 Cookie 才能顺利从后端拿到数据。

Token 与 OAuth 2.0

现在的网页应用(尤其是前后端分离的 Vue/React 网站)和移动端 App,往往不再使用传统的 Cookie/Session 模式,而是使用 Token。

这里的逻辑是这样的:
  1. 你输入账号密码登录。
  2. 服务器验证通过,返回一串加密的字符串,叫做 Token(通常是 JWT 格式)。
  3. 以后你每次发请求,都要把这个 Token 放在请求头里,通常长这样:
    Authorization: Bearer <你的Token字符串>
OAuth 2.0 (开放授权)

你一定见过“使用QQ登录”或“使用 Google 登录”。这就是 OAuth。

image.png

它涉及第三方授权。如果你要爬取的数据在通过 OAuth 保护的接口里,流程会比较复杂,你需要模拟整个重定向流程来获取最终的 Access Token。

OAuth 2.0 是一个授权框架,允许第三方应用在无需获取用户密码的情况下访问用户资源。

传统方式的问题:

1
2
用户 → 输入密码 → 第三方应用 → 存储密码 → 访问资源
      ❌ 不安全   ❌ 泄露风险  ❌ 权限过大

OAuth 方式:

1
2
用户 → 授权同意 → 获取令牌 → 第三方应用 → 使用令牌访问资源
       ✅ 安全   ✅ 可撤销  ✅ 权限受限
核心术语
术语说明Antigravity 示例
Client ID应用标识符YOUR_GOOGLE_OAUTH_CLIENT_ID
Client Secret应用密钥YOUR_GOOGLE_OAUTH_CLIENT_SECRET
Authorization Endpoint授权端点https://accounts.google.com/o/oauth2/v2/auth
Token Endpoint令牌端点https://oauth2.googleapis.com/token
Redirect URI回调地址http://localhost:51121/oauth-callback
Scope权限范围cloud-platform, userinfo. email
Authorization Code授权码4/0AY0e-g7...  (一次性)
Access Token访问令牌ya29.a0AfH6...  (有效期 1 小时)
Refresh Token刷新令牌1//0gxxx...  (长期有效)

实战

按照你获取的接口,构造请求头:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function buildHeaders(env) {

  const cookie = env.PTA_COOKIE || ''
  const problemSetId = env.PROBLEM_SET_ID || ''
   const xLollipop = env.X_LOLLIPOP || ''

  if (!cookie) {
    throw new Error('缺少必要的环境变量: PTA_COOKIE')
  }

  return {
    'accept': 'application/json;charset=UTF-8',
    'accept-language': 'zh-CN,zh;q=0.9',
    'content-type': 'application/json;charset=UTF-8',
    // 保留 referer,防止盗链验证拒绝访问
    'referer': `https://pintia.cn/problem-sets/${problemSetId}/exam/rankings`,
    'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36',
    //cookie 验证
    'cookie': cookie,
    //这是个安全校验参数
    'x-lollipop': xLollipop,
    'sec-fetch-site': 'same-origin',
    'sec-fetch-mode': 'cors',
    'sec-fetch-dest': 'empty'
  }
}

发送请求:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    
    // 1. 获取参数
    const page = url.searchParams.get('page') || '0';
    const limit = url.searchParams.get('limit') || '50';
    const problemSetId = url.searchParams.get('setId');

    // 2. 拼接目标 API 地址
    const ptaUrl = `https://pintia.cn/api/problem-sets/${problemSetId}/common-rankings?page=${page}&limit=${limit}`;

    // 3. 发送请求并返回结果
    const ptaResponse = await fetch(ptaUrl, {
      method: 'GET',
      headers: buildHeaders(env) // 必须保留 Header 构建,否则 PTA 会拒绝访问
    });

    return ptaResponse;
  }
}

然后你就能获得 PTA 后端传回的 Body JSON 数据。

但是这个方法有几个缺点:

  • 无法处理过期: 如果 PTA 的服务器判定 Cookie 到期(比如登录失效),它会要求重新登录。你的代码无法自动处理这个过程,只能报错,直到你手动更新 env 里的值。
  • 无法更新 Cookie: 有些服务器会在响应头里返回 Set-Cookie 来更新会话状态。这个代码忽略了 PTA 返回的新 Cookie,始终只用旧的。
  • x-lollipop : 这个字段是 PTA 的防爬/安全校验参数,具有很短的时效性。

如果你需要先登录,然后连续访问好几个页面,手动复制 Cookie 很麻烦。Python 的requests.Session() 可以自动帮你管理 Cookie,具备双向更新能力。

或者写一个额外的脚本,模拟登录流程获取新的 Cookie 和 x-lollipop。

OAuth 2.0

以 CLIProxyAPI 这个项目为例:

发现 API 端点

可以用 mitmproxy 抓 Antigragity 的包 在 mitmproxy 中寻找这些特征:

  • OAuth 授权请求
  • Token 交换请求
  • API 调用请求

如果无法抓包,可以尝试反编译 JS 或查看配置文件。

这个项目 executor 中的端点定义:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
const (
  // 多个后备 Base URL (按优先级)
  antigravityBaseURLDaily        = "https://daily-cloudcode-pa.googleapis.com"
  antigravitySandboxBaseURLDaily = "https://daily-cloudcode-pa.sandbox.googleapis.com"
  antigravityBaseURLProd         = "https://cloudcode-pa.googleapis.com"
  
  // API 路径
  antigravityCountTokensPath     = "/v1internal: countTokens"
  antigravityStreamPath          = "/v1internal:streamGenerateContent"
  antigravityGeneratePath        = "/v1internal: generateContent"
  antigravityModelsPath          = "/v1internal:fetchAvailableModels"
  
  // OAuth 配置
  antigravityClientID            = "YOUR_GOOGLE_OAUTH_CLIENT_ID"
  antigravityClientSecret        = "YOUR_GOOGLE_OAUTH_CLIENT_SECRET"
  defaultAntigravityAgent        = "antigravity/1.104.0 darwin/arm64"
  antigravityAuthType            = "antigravity"
  refreshSkew                    = 3000 * time.Second
)
搭建本地 OAuth 回调服务器

OAuth 授权流程中,授权服务器会将授权码通过重定向发送到你指定的 Redirect URI。我们需要一个 HTTP 服务器来接收这个授权码。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 回调结果
type callbackResult struct {
  Code  string
  Error string
  State string
}

// 启动回调服务器
func startAntigravityCallbackServer(port int) (*http.Server, int, <-chan callbackResult, error) {
  if port <= 0 {
    port = antigravityCallbackPort  // 默认 51121
  }
  
  addr := fmt.Sprintf(":%d", port)
  listener, err := net.Listen("tcp", addr)
  if err != nil {
    return nil, 0, nil, err
  }
  
  port = listener.Addr().(*net.TCPAddr).Port
  resultCh := make(chan callbackResult, 1)
  
  mux := http.NewServeMux()
  mux.HandleFunc("/oauth-callback", func(w http.ResponseWriter, r *http.Request) {
    q := r.URL.Query()
    res := callbackResult{
      Code:  strings.TrimSpace(q.Get("code")),
      Error: strings.TrimSpace(q. Get("error")),
      State: strings.TrimSpace(q.Get("state")),
    }
    resultCh <- res
    
    if res.Code != "" && res.Error == "" {
      _, _ = w.Write([]byte("<h1>Login successful</h1><p>You can close this window. </p>"))
    } else {
      _, _ = w.Write([]byte("<h1>Login failed</h1><p>Please check the CLI output. </p>"))
    }
  })
  
  srv := &http.Server{Handler: mux}
  go func() {
    if errServe := srv.Serve(listener); errServe != nil && ! strings.Contains(errServe. Error(), "Server closed") {
      log.Warnf("antigravity callback server error: %v", errServe)
    }
  }()
  
  return srv, port, resultCh, nil
}
实现完整 OAuth 流程
构建授权 URL
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func buildAntigravityAuthURL(redirectURI, state string) string {
  params := url.Values{}
  params.Set("access_type", "offline")         // 获取 refresh token
  params.Set("client_id", antigravityClientID)
  params.Set("prompt", "consent")              // 强制显示同意页面
  params.Set("redirect_uri", redirectURI)
  params.Set("response_type", "code")
  params.Set("scope", strings.Join(antigravityScopes, " "))
  params.Set("state", state)
  
  return "https://accounts.google.com/o/oauth2/v2/auth?" + params.Encode()
}
完整登录流程
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
func (AntigravityAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {
  if ctx == nil {
    ctx = context.Background()
  }
  
  httpClient := util.SetProxy(&cfg.SDKConfig, &http.Client{})
  
  // 生成 State (防 CSRF)
  state, err := misc.GenerateRandomState()
  if err != nil {
    return nil, fmt.Errorf("antigravity: failed to generate state:  %w", err)
  }
  
  // 启动回调服务器
  srv, port, cbChan, errServer := startAntigravityCallbackServer(antigravityCallbackPort)
  if errServer != nil {
    return nil, fmt. Errorf("antigravity:  failed to start callback server: %w", errServer)
  }
  defer func() {
    shutdownCtx, cancel := context. WithTimeout(context.Background(), 2*time.Second)
    defer cancel()
    _ = srv.Shutdown(shutdownCtx)
  }()
  
  // 构建授权 URL
  redirectURI := fmt.Sprintf("http://localhost:%d/oauth-callback", port)
  authURL := buildAntigravityAuthURL(redirectURI, state)
  
  // 打开浏览器
  fmt.Println("Opening browser for antigravity authentication")
  if browser. IsAvailable() {
    if errOpen := browser.OpenURL(authURL); errOpen != nil {
      log.Warnf("Failed to open browser: %v", errOpen)
      fmt.Printf("Visit the following URL:\n%s\n", authURL)
    }
  } else {
    fmt.Printf("Visit the following URL:\n%s\n", authURL)
  }
  
  fmt. Println("Waiting for antigravity authentication callback...")
  
  // 等待回调 (5分钟超时)
  var cbRes callbackResult
  timeoutTimer := time.NewTimer(5 * time.Minute)
  defer timeoutTimer.Stop()
  
  select {
  case res := <-cbChan: 
    cbRes = res
  case <-timeoutTimer.C:
    return nil, fmt. Errorf("antigravity:  authentication timed out")
  }
  
  // 验证结果
  if cbRes.Error != "" {
    return nil, fmt.Errorf("antigravity: authentication failed: %s", cbRes.Error)
  }
  if cbRes.State != state {
    return nil, fmt. Errorf("antigravity:  invalid state")
  }
  if cbRes.Code == "" {
    return nil, fmt.Errorf("antigravity: missing authorization code")
  }
  
  // 交换授权码获取 Token
  tokenResp, errToken := exchangeAntigravityCode(ctx, cbRes.Code, redirectURI, httpClient)
  if errToken != nil {
    return nil, fmt.Errorf("antigravity: token exchange failed: %w", errToken)
  }
  
  // 获取用户信息
  email := ""
  if tokenResp.AccessToken != "" {
    if info, errInfo := fetchAntigravityUserInfo(ctx, tokenResp.AccessToken, httpClient); errInfo == nil {
      email = strings.TrimSpace(info.Email)
    }
  }
  
  // 获取 Project ID
  // ...
  
  // 构建认证对象
  now := time.Now()
  metadata := map[string]any{
    "type":          "antigravity",
    "access_token":  tokenResp. AccessToken,
    "refresh_token": tokenResp.RefreshToken,
    "expires_in":    tokenResp. ExpiresIn,
    "timestamp":     now.UnixMilli(),
    "expired":       now.Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),
  }
  if email != "" {
    metadata["email"] = email
  }
  if projectID != "" {
    metadata["project_id"] = projectID
  }
  
  fileName := sanitizeAntigravityFileName(email)
  
  fmt. Println("Antigravity authentication successful")
  if projectID != "" {
    fmt. Printf("Using GCP project: %s\n", projectID)
  }
  
  return &coreauth.Auth{
    ID:       fileName,
    Provider: "antigravity",
    FileName: fileName,
    Label:    email,
    Metadata:  metadata,
  }, nil
}
Token 交换实现
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
func exchangeAntigravityCode(ctx context.Context, code, redirectURI string, httpClient *http.Client) (*antigravityTokenResponse, error) {
  // 构建表单数据
  data := url.Values{}
  data.Set("code", code)
  data.Set("client_id", antigravityClientID)
  data.Set("client_secret", antigravityClientSecret)
  data.Set("redirect_uri", redirectURI)
  data.Set("grant_type", "authorization_code")
  
  // 发送 POST 请求
  req, err := http.NewRequestWithContext(
    ctx,
    http.MethodPost,
    "https://oauth2.googleapis.com/token",
    strings.NewReader(data.Encode()),
  )
  if err != nil {
    return nil, err
  }
  req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
  
  resp, errDo := httpClient.Do(req)
  if errDo != nil {
    return nil, errDo
  }
  defer resp.Body.Close()
  
  // 解析响应
  var token antigravityTokenResponse
  if errDecode := json.NewDecoder(resp.Body).Decode(&token); errDecode != nil {
    return nil, errDecode
  }
  
  if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
    return nil, fmt.Errorf("oauth token exchange failed:  status %d", resp.StatusCode)
  }
  
  return &token, nil
}
调用 Antigravity API

构建 API 请求

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
func (e *AntigravityExecutor) buildRequest(
  ctx context.Context,
  auth *cliproxyauth.Auth,
  token, modelName string,
  payload []byte,
  stream bool,
  alt, baseURL string,
) (*http.Request, error) {
  
  // 选择端点路径
  base := strings.TrimSuffix(baseURL, "/")
  if base == "" {
    base = buildBaseURL(auth)
  }
  
  path := antigravityGeneratePath
  if stream {
    path = antigravityStreamPath
  }
  
  // 构建 URL
  var requestURL strings.Builder
  requestURL.WriteString(base)
  requestURL.WriteString(path)
  
  if stream {
    if alt != "" {
      requestURL. WriteString("?$alt=")
      requestURL.WriteString(url.QueryEscape(alt))
    } else {
      requestURL.WriteString("?alt=sse")
    }
  }
  
  // 提取 Project ID
  // ...
  
  // 转换请求体格式
  payload = geminiToAntigravity(modelName, payload, projectID)
  payload, _ = sjson.SetBytes(payload, "model", modelName)
  
  // 创建 HTTP 请求
  httpReq, err := http.NewRequestWithContext(
    ctx,
    http.MethodPost,
    requestURL.String(),
    bytes.NewReader(payload),
  )
  if err != nil {
    return nil, err
  }
  
  // 设置 Headers
  httpReq.Header.Set("Content-Type", "application/json")
  httpReq.Header.Set("Authorization", "Bearer "+token)
  httpReq.Header.Set("User-Agent", resolveUserAgent(auth))
  
  if stream {
    httpReq.Header.Set("Accept", "text/event-stream")
  } else {
    httpReq.Header.Set("Accept", "application/json")
  }
  
  if host := resolveHost(base); host != "" {
    httpReq.Host = host
  }
  
  return httpReq, nil
}
Token 自动刷新
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
func (e *AntigravityExecutor) refreshToken(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
  if auth == nil {
    return nil, statusErr{code: http.StatusUnauthorized, msg: "missing auth"}
  }
  
  refreshToken := metaStringValue(auth.Metadata, "refresh_token")
  if refreshToken == "" {
    return auth, statusErr{code: http.StatusUnauthorized, msg: "missing refresh token"}
  }
  
  // 构建刷新请求
  form := url.Values{}
  form.Set("client_id", antigravityClientID)
  form.Set("client_secret", antigravityClientSecret)
  form.Set("grant_type", "refresh_token")
  form.Set("refresh_token", refreshToken)
  
  httpReq, errReq := http.NewRequestWithContext(
    ctx,
    http.MethodPost,
    "https://oauth2.googleapis.com/token",
    strings.NewReader(form. Encode()),
  )
  if errReq != nil {
    return auth, errReq
  }
  
  httpReq.Header.Set("Host", "oauth2.googleapis.com")
  httpReq.Header.Set("User-Agent", defaultAntigravityAgent)
  httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
  
  httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
  httpResp, errDo := httpClient.Do(httpReq)
  if errDo != nil {
    return auth, errDo
  }
  defer httpResp.Body.Close()
  
  bodyBytes, errRead := io.ReadAll(httpResp.Body)
  if errRead != nil {
    return auth, errRead
  }
  
  if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {
    return auth, statusErr{code: httpResp.StatusCode, msg: string(bodyBytes)}
  }
  
  // 解析响应
  var tokenResp struct {
    AccessToken  string `json:"access_token"`
    RefreshToken string `json:"refresh_token"`
    ExpiresIn    int64  `json:"expires_in"`
    TokenType    string `json:"token_type"`
  }
  if errUnmarshal := json.Unmarshal(bodyBytes, &tokenResp); errUnmarshal != nil {
    return auth, errUnmarshal
  }
  
  // 更新 auth 对象
  if auth. Metadata == nil {
    auth. Metadata = make(map[string]any)
  }
  
  auth.Metadata["access_token"] = tokenResp. AccessToken
  if tokenResp.RefreshToken != "" {
    auth.Metadata["refresh_token"] = tokenResp.RefreshToken
  }
  auth.Metadata["expires_in"] = tokenResp.ExpiresIn
  
  now := time.Now()
  auth.Metadata["timestamp"] = now.UnixMilli()
  auth.Metadata["expired"] = now.Add(time. Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339)
  auth.Metadata["type"] = antigravityAuthType
  
  // 确保 project_id 存在
  // ...
  
  return auth, nil
}

其他情况

参数加密与数字签名

你在 Network 里找到了接口,比如 api.com/price?id=100。 但是 URL 后面跟了几个参数:&sign=a2f9c8... 或者 &timestamp=169000...&token=...。 当你试图把 id 改成 101 时,服务器直接报错“签名无效”。

前端网页里有一段 JS 代码,它会把你的请求参数加上时间戳、随机数,然后用一种算法(比如 MD5、SHA256,或者网站自创的加密逻辑)计算出一个签名。 服务器端也会用同样的算法算一遍,如果对不上,就说明这个请求被篡改过,或者不是从浏览器发出的。

image.png

这是知乎的签名,没有放在参数里,放在了请求头上。

解决方法:

  • 硬刚(JS 逆向): 你需要把网页的 JS 代码下载下来,硬着头皮读代码,找到那个加密函数,用 Python 把它重写出来。但是有些 JS 会有混淆,非常掉头发。
  • 投降(使用浏览器自动化): 放弃直接发 HTTP 请求,改用 Selenium 或 Playwright。这些工具会启动一个真正的 Chrome 浏览器,让浏览器去执行 JS 生成签名,你只负责从页面上拿结果。

JS 动态渲染与混淆

用 GET URL 抓回来的 HTML 源码里,只有一行字: <div id="app"></div> 或者 <script src="main.js"></script>,其他什么内容都没有。

数据不是随 HTML 一起回来的,而是页面加载完后,由 JavaScript 再次发起异步请求加载的。 或者这些数据可能不是通过标准的 JSON 接口传输,而是通过 WebSocket,或者直接把数据加密后混淆在 JS 变量里。

解决方法:

  • 普通 API: 继续在 Network 里深挖,肯定有一个 fetch/xhr 请求。
  • 数据混淆在 JS 里: 使用正则表达式在 HTML 源码中提取 JS 变量。

风控验证与指纹识别

Cloudflare 五秒盾或者 CAPTCHA.

image.png

脚本底层的 SSL/TLS 握手特征和真正的 Chrome 浏览器是不一样的。Cloudflare 等防火墙能一眼识别出“你不是人,你是脚本,直接拦截返回 403。

解决方法:

  • 针对 TLS 指纹: 使用 curl_cffi 或 tls_client 这种特殊的 Python 库,它们能完美模拟 Chrome 的底层指纹。
  • 针对验证码: 接入打码平台 (金钱是万能的),或者使用机器学习识别。

WebSocket

股票行情、即时聊天、直播弹幕。你在 Network 的 Fetch/XHR 里怎么都找不到数据包。 但是点开 Socket 能看见一个升级协议的请求。

它们不用 HTTP 协议,而是用 WebSocket。HTTP 是你问我答的短连接,WebSocket 是一条长长的管道,连接建立后,服务器可以主动不断地推数据给你。

image.png

image.png

这是 ChatGPT,用的是 Websocket 和后端 API 通信

用对应的websocket库,分析 WebSocket 的握手过程和消息格式(JSON、二进制数据),来获得数据。

Protobuf

Response Header 里写着 content-type: application/x-protobuf。 当你打开 Preview 看数据时,全是乱码 ,根本不是人能看懂的 JSON。

这是 Google 开发的一种二进制序列化格式,比 JSON 更小更快。很多大厂(如 B站的部分接口)为了节省带宽和反爬,会用这个。

image.png image.png

上图是 B站 的弹幕接口,content-type: application/octet-stream。

这是八位字节流,B 站为了追求极速和极致的性能,把 JSON 压缩成了只有机器能读懂的二进制。

而且,x-client-sign: 就是客户端签名。说明这个请求在发出前,B 站的 JS 脚本对请求参数进行了加密计算,生成了这个签名。如果你手动修改 URL 参数而不更新这个 sign,服务器会直接拒绝。

你需要找到前端定义的 .proto 文件,然后把这些二进制乱码翻译回可读的数据。这也属于逆向工程的一种。

写在最后:

无论是为了拿 PTA 排行榜,还是为了反代 Antigravity 的 AI 接口,“直接找后端要数据” 永远是最优雅的解法。

但是,面对反爬,我们不仅要学会“硬刚”逆向,更要学会在适当的时候“投降”使用浏览器自动化。毕竟,解决问题的代码,才是好代码,效率才是生产力 (笑)。

相关链接:

Page views — Unique visitors —
Built with Hugo
Theme Stack designed by Jimmy