你是否遇到过这种情况:在网页上看到了一堆有用的数据(比如数据表单、排行榜、或者社交媒体评论),想要把它们保存下来分析,但手动复制粘贴太慢了?
这时候,你需要的是直接与后端服务器对话,从后端抓取数据。
很多人把这叫做“写爬虫”,但更专业的说法是API 逆向或后端数据抓取。今天,我们就抛开复杂的各种自动化浏览器工具(如 Selenium、Playwright),回归本质,聊聊如何像浏览器一样,通过代码直接向服务器索要数据。
我将以获取 PTA 排行榜 (视奸小登进度) 和 反代 Antigravity 的 API 为例。
前端是怎么和后端通信的?
HTTP 协议
浏览器和服务器之间的交流是基于 HTTP 协议(HyperText Transfer Protocol)的。
- 客户端/浏览器: “我要请求一个 API 获取数据。” —— 请求 (Request)
- 服务器: “好的,这是您的数据。” —— 响应 (Response)
WebSocket 协议
WebSocket 是一种在单个 TCP 连接上进行全双工通信的协议。它让服务器能够主动向浏览器推送数据。
- 握手阶段(HTTP):
- 客户端: “我想升级成 WebSocket 协议。”
- 服务器: “没问题,协议升级成功。”
- 通信阶段(双向):
- 客户端: “我发个消息给你。”
- 服务器: “收到,我也传给你个新消息。”
- (连接一直保持,双方可以随时互发数据,不需要重新建立连接)

核心概念
要骗过服务器,让他以为你是浏览器,你需要构造一个完美的请求。一个请求通常包含以下关键部分:
找到 API 地址——浏览器开发者工具
在你想要找某种数据之前,你需要先当一名侦探。
- 打开 Chrome 浏览器,按 F12 (或右键 -> 检查)。
- 点击 Network (网络) 标签页。
- 点击 Fetch/XHR 过滤器(这一步很重要,这能帮你过滤掉图片、CSS,只看后端返回的数据接口)。
- 刷新网页。

这时候你会看到一堆请求。逐个点击,查看 Preview 或 Response。当你看到类似
1
| { "name": "张三", "price": 100 }
|
这种 JSON 格式的数据时,恭喜你,你找到了后端接口

身份验证
Session 与 Cookie
很多数据是需要登录才能看到的。但是 HTTP 协议有一个特点:它是无状态的。
服务器不知道刚才请求“登录”的人,和现在请求“个人中心”数据的人是不是同一个。为了解决这个问题,Session 和 Cookie 诞生了。
上文的 PTA 排名接口就是通过 Session 与 Cookie 进行身份验证的。
这种方法有一个缺点,你必须定时获取最新的 Cookie 才能顺利从后端拿到数据。
Token 与 OAuth 2.0
现在的网页应用(尤其是前后端分离的 Vue/React 网站)和移动端 App,往往不再使用传统的 Cookie/Session 模式,而是使用 Token。
这里的逻辑是这样的:
- 你输入账号密码登录。
- 服务器验证通过,返回一串加密的字符串,叫做 Token(通常是 JWT 格式)。
- 以后你每次发请求,都要把这个 Token 放在请求头里,通常长这样:
Authorization: Bearer <你的Token字符串>
OAuth 2.0 (开放授权)
你一定见过“使用QQ登录”或“使用 Google 登录”。这就是 OAuth。

它涉及第三方授权。如果你要爬取的数据在通过 OAuth 保护的接口里,流程会比较复杂,你需要模拟整个重定向流程来获取最终的 Access Token。
flowchart TD
%% 节点定义
Step1["1. 生成授权 URL
https://accounts.google.com/o/..."]
Step2["2. 用户在浏览器中登录 Google"]
Step3["3. 回调到本地服务器
http://localhost:51121/oauth-callback
+ 授权码 (code)"]
Step4["4. 换取 Token
POST https://oauth2.googleapis.com/token
→ access_token, refresh_token"]
Step5["5. 获取用户信息
GET /oauth2/v1/userinfo
→ email"]
Step6["6. 🎯 发现 Project ID
POST /v1internal:loadCodeAssist
→ cloudaicompanionProject"]
Step7["7. 注册用户 (如需要)
POST /v1internal:onboardUser
→ 轮询直到完成"]
Step8["8. 调用 Antigravity API
POST /v1internal:streamGenerateContent
Headers: Authorization Bearer token
Body: ( project, model, request... )`"]
%% 流程连接
Step1 --> Step2
Step2 --> Step3
Step3 --> Step4
Step4 --> Step5
Step5 --> Step6
%% 条件分支逻辑
Step6 -- "如果没有 Project ID" --> Step7
Step7 --> Step8
Step6 -- "Project ID 已存在" --> Step8
%% 样式美化
style Step6 fill:#e1f5fe,stroke:#01579b,stroke-width:2px
style Step8 fill:#f3e5f5,stroke:#4a148c,stroke-width:2pxOAuth 2.0 是一个授权框架,允许第三方应用在无需获取用户密码的情况下访问用户资源。
传统方式的问题:
1
2
| 用户 → 输入密码 → 第三方应用 → 存储密码 → 访问资源
❌ 不安全 ❌ 泄露风险 ❌ 权限过大
|
OAuth 方式:
1
2
| 用户 → 授权同意 → 获取令牌 → 第三方应用 → 使用令牌访问资源
✅ 安全 ✅ 可撤销 ✅ 权限受限
|
sequenceDiagram
participant User as 用户
(Owner)
participant App as 客户端应用
(Your App)
participant Auth as 授权服务器
(Google)
User->>App: 1. 访问应用
App->>User: 2. 重定向到授权页面
User->>Auth: 3. 访问授权页面
Note over User,Auth: 4. 用户登录并同意授权
Auth->>User: 5. 返回授权码 (code)
User->>App: 6. 回调地址 + code
App->>Auth: 7. code 换 token
Auth->>App: 8. 返回 token
App->>Auth: 9. 使用 token 访问 API核心术语
| 术语 | 说明 | 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... (长期有效) |
实战
Cookie
按照你获取的接口,构造请求头:
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... 或者 ×tamp=169000...&token=...。
当你试图把 id 改成 101 时,服务器直接报错“签名无效”。
前端网页里有一段 JS 代码,它会把你的请求参数加上时间戳、随机数,然后用一种算法(比如 MD5、SHA256,或者网站自创的加密逻辑)计算出一个签名。
服务器端也会用同样的算法算一遍,如果对不上,就说明这个请求被篡改过,或者不是从浏览器发出的。

这是知乎的签名,没有放在参数里,放在了请求头上。
解决方法:
- 硬刚(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.

脚本底层的 SSL/TLS 握手特征和真正的 Chrome 浏览器是不一样的。Cloudflare 等防火墙能一眼识别出“你不是人,你是脚本,直接拦截返回 403。
解决方法:
- 针对 TLS 指纹: 使用
curl_cffi 或 tls_client 这种特殊的 Python 库,它们能完美模拟 Chrome 的底层指纹。 - 针对验证码: 接入打码平台
(金钱是万能的),或者使用机器学习识别。
WebSocket
股票行情、即时聊天、直播弹幕。你在 Network 的 Fetch/XHR 里怎么都找不到数据包。
但是点开 Socket 能看见一个升级协议的请求。
它们不用 HTTP 协议,而是用 WebSocket。HTTP 是你问我答的短连接,WebSocket 是一条长长的管道,连接建立后,服务器可以主动不断地推数据给你。


这是 ChatGPT,用的是 Websocket 和后端 API 通信
用对应的websocket库,分析 WebSocket 的握手过程和消息格式(JSON、二进制数据),来获得数据。
Protobuf
Response Header 里写着 content-type: application/x-protobuf。 当你打开 Preview 看数据时,全是乱码 ,根本不是人能看懂的 JSON。
这是 Google 开发的一种二进制序列化格式,比 JSON 更小更快。很多大厂(如 B站的部分接口)为了节省带宽和反爬,会用这个。

上图是 B站 的弹幕接口,content-type: application/octet-stream。
这是八位字节流,B 站为了追求极速和极致的性能,把 JSON 压缩成了只有机器能读懂的二进制。
而且,x-client-sign: 就是客户端签名。说明这个请求在发出前,B 站的 JS 脚本对请求参数进行了加密计算,生成了这个签名。如果你手动修改 URL 参数而不更新这个 sign,服务器会直接拒绝。
你需要找到前端定义的 .proto 文件,然后把这些二进制乱码翻译回可读的数据。这也属于逆向工程的一种。
写在最后:
无论是为了拿 PTA 排行榜,还是为了反代 Antigravity 的 AI 接口,“直接找后端要数据” 永远是最优雅的解法。
但是,面对反爬,我们不仅要学会“硬刚”逆向,更要学会在适当的时候“投降”使用浏览器自动化。毕竟,解决问题的代码,才是好代码,效率才是生产力 (笑)。
相关链接: