Files
wehub-resource-sync 9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

296 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# API Discovery
**Layer 2:这个站的目标数据 endpoint 是什么?** 已经分完类(`site-recon.md`)再进来。
五种手段。按优先级降级用,命中即走。
---
## §0 进入 §1 之前:先看两条红线
这两条不看清楚,后面的 endpoint 验证会一直在错的前提下兜圈子。
### 0.1 反爬厂商 → 决定 fetch 能不能从 Node 走
`opencli browser analyze <url>``anti_bot` 字段给答案;手查看 cookies 也行:
| cookie / body 信号 | 厂商 | 裸 Node fetch / curl 结果 | 策略 |
|------------------|------|-----|-----|
| `acw_sc__v2` / `acw_tc` / `ssxmod_itna`body 含 `arg1 = '32-HEX'``/ntc_captcha/` | **Aliyun WAF** | 返回 slider HTML,不是真数据 | 先在浏览器上下文里验证 endpointHTML 型 COOKIE adapter 最终仍走 Node-side fetch + `page.getCookies()` |
| `__cf_bm` / `cf_clearance` / `__cfduid`body 含 `Cloudflare Ray ID` / `Checking your browser` | **Cloudflare** | TLS 指纹被标记,失败 | 同上:先 browser-context probe,最终 adapter 仍按模板选 fetch 路线 |
| `_abck` / `bm_sz` / `bm_sv` | **Akamai** | 即使带 cookie 也常被挡 | 同上 |
| body 含 `geetest` / `gt_captcha` | **Geetest** | 滑块/拼图,程序无解 | 超出 skill 范围,放弃或 UI 策略 |
**规则**:看到上面四种任一个,先不要拿**裸** Node fetch 做 endpoint 验证。先用 browser-context probe 或目标 origin 页面确认接口能通;最终 adapter 的 fetch 路线仍按 `adapter-template.md` 选,HTML 型 COOKIE adapter 继续走 Node-side fetch + `page.getCookies()`
### 0.2 跨 subdomain = CORS 默认关
`jobs.51job.com` 页面 fetch `cupid.51job.com` 的 API,默认会被浏览器 CORS 预检挡住——除非目标接口回了 `Access-Control-Allow-Origin`
判断:
```bash
opencli browser eval "fetch('https://<target-subdomain>/api/...', {credentials:'include'}).then(r=>r.status).catch(e=>'cors:'+e.message)"
```
- 返回 status 数字 → CORS 通,继续
- 返回 `cors:...``TypeError: Failed to fetch` → 挡住了
**挡住时**:不要把 `credentials:'include'` 当万能药——这只解决"带 cookie",不解决"跨 origin"。降级路径:
1. 换同 origin 的 endpoint(同一个 subdomain 下的 API 往往更宽松)
2.`opencli browser open https://<target-subdomain>/`,让页面在目标 subdomain 本身打开,再 fetch 相对路径
3. 真跨域且无替代 → 走 `§5 intercept`,从页面自身发的请求里抓响应
---
## §1 network 精读(首选,Pattern A / D 命中率最高)
### 拿候选
```bash
opencli browser network
```
默认输出是 JSON,每个候选都带:
- `key` — 稳定引用(GraphQL 的 `operationName``METHOD host+pathname`
- `shape` — response body 的路径→类型映射(不含原 body,省 token)
- `status / url / method / ct / size`
静态资源 / 埋点 / 追踪默认已过滤。默认会保留 JSON / XML / plain text / `text/javascript` 这类 API 响应;如果你确定浏览器 DevTools 里有目标请求但这里缺失,用 `--all` 查一遍是否被 content-type 或 URL 噪音过滤挡掉。
如果是冷启动,先看 `opencli browser analyze <url>` 里的 `api_candidates`
- `verdict: "likely_data"`:优先 replay 这条,拿 status / content-type / sample shape 填 strategy note
- `verdict: "maybe_data"`:可以试,但必须人工核对字段是否是目标业务数据
- `verdict: "noise"`:多半是 analytics / beacon / personalization,不要因为 XHR 数量多就判 Pattern A
- `verdict: "blocked"`401/403;先排 cookie / token / CSRF,别直接退到 selector
`real_data_score` 是证据,不是自动 strategy。最终仍要在 strategy note 里写 replay 结果和降级理由。
### 按 shape 初筛
`key` 里含业务词(`list / detail / Timeline / User / Tweets / Quote`)的优先看 `shape`
- `$.data``object` 且下面出现 `array(N)` / `total` / `page` → 基本是它
- 路径里出现 `nickname / avatar / title / price / tweets / items` → 就是它
- shape 只有 `$: string` 或全是 HTML 噪音 → 下一条
### 按期望字段反查(`--filter`
已经知道目标 body 该含哪些字段就直接让 CLI 把列表筛到只剩候选,不用自己 scroll 翻 shape
```bash
opencli browser network --filter author,text,likes
```
- 字段以英文逗号分隔;AND 语义,必须每个字段都作为 shape 路径的**任意一段**出现才保留(`$.data.items[0].author` 命中 `author``items``data` 都算)
- 区分大小写(JSON key 本来就 case-sensitive
- 输出 envelope 新增 `filter` / `filter_dropped``count` 是过滤后数量
- 0 命中不是 error,返回 `entries: []`;说明字段组合不对,换一组或去掉约束再试
- 不要跟 `--detail` 一起用——`--detail` 按 key 取单条、`--filter` 是列表缩窄,组合会报 `invalid_args`
- 空值 / `,,,``invalid_filter` 结构化错误
- capture 依然按全量持久化,后续 `--detail <key>` 能找到被过滤掉的条目
### 拉完整 body
候选定了再拉完整 body(by key,不是 index — 数组顺序会随每次 capture 变):
```bash
opencli browser network --detail <key>
```
capture 会持久化到 `~/.opencli/cache/browser-network/<session>.json`(默认 TTL 24h),所以 `--detail` 即使跨多条其他命令也还在。
### 关键 request headers
`browser network` 当前只抓响应(body + status + ct),抓不到请求头。要看请求头就在 DevTools Network 面板里点这条 request,或用 `browser eval` 手动 `fetch(url)` 复现一次观察浏览器发出去的头:
| 看到 | 含义 | 对应策略 |
|------|------|---------|
| 只有 `Cookie` | 登录态靠 cookie | `Strategy.COOKIE` |
| `Authorization: Bearer xxx` | token 鉴权 | 先找 token 来源(localStorage / cookie / bundle 硬编码) |
| `X-Csrf-Token: xxx` 同时存在 cookie 里 | CSRF 防护 | `Strategy.COOKIE`,从 cookie 读 ct0 类字段拼头 |
| `X-Workspace-Id / X-Tenant-Id` | 多租户业务头 | 先调 `/workspaces` 拿 ID,缓存下来 |
| 啥自定义头都没有 | 匿名接口 | `Strategy.PUBLIC` |
### 触发懒加载接口
默认页加载完后滚动 / 点击才会出的接口不在首屏 network 里。需要:
```bash
# 滚到底(虚拟列表)
opencli browser eval "window.scrollTo(0, document.body.scrollHeight)"
opencli browser wait time 2
opencli browser network
# 点某个按钮
opencli browser click <N>
opencli browser wait time 2
opencli browser network
```
---
## §2 `__INITIAL_STATE__` / inline HTMLPattern B
首屏数据常挂在这几个全局变量上:
```bash
opencli browser eval "Object.keys(window).filter(k=>k.startsWith('__'))"
```
命中的常见名:
| 全局 | 框架 |
|------|-----|
| `__NEXT_DATA__` | Next.js |
| `__NUXT__` | Nuxt.js |
| `__INITIAL_STATE__` | 自定义 Vue / React SSR |
| `__PRELOADED_STATE__` | Redux SSR |
| `__REMIX_CONTEXT__` | Remix |
取数据:
```bash
opencli browser eval "JSON.stringify(window.__NEXT_DATA__).slice(0, 3000)"
```
**关键**inline state 只覆盖首屏的一部分(通常是 SEO 相关字段)。分页 / 评论 / 懒加载还是得回 §1 抓 API。
把首屏 state 当作 adapter 的兜底数据源:公开访问时 state 里有 → 直接 parse;数据更新快 / 分页 → 回到 API。
---
## §3 JS bundle / script src 搜索(Pattern C,也是 A/D 的降级)
### 扫 script src
```bash
opencli browser eval "[...document.querySelectorAll('script[src]')].map(s=>s.src).filter(s=>!/\\.(css|png|jpg|svg|woff|mp4)$/.test(s)&&!/googletagmanager|crazyegg|sentry|doubleclick|amazon-adsystem|cloudflare/.test(s))"
```
看结果里的 hostname
- 明显像 API 的域名(`api.xxx / push.xxx / data.xxx / gateway.xxx`)→ 直接去试
- 主 bundle`main.js / app.js / index.xxx.js`)→ 继续下一步下载 bundle 搜 baseURL
### 搜 bundle 里的 baseURL
```bash
opencli browser eval "(async()=>{const s=[...document.querySelectorAll('script[src]')].map(e=>e.src).find(s=>/main|app|index|bundle|chunk/.test(s));if(!s)return'no bundle';const t=await fetch(s).then(r=>r.text());const patterns=['baseURL','baseUrl','BASE_URL','apiHost','apiBase','API_HOST','API_BASE'];const hits=[];for(const p of patterns){let i=-1;while((i=t.indexOf(p,i+1))>-1&&hits.length<5)hits.push(t.slice(Math.max(0,i-5),i+80));}return hits})()"
```
命中 `baseURL:"https://api.foo.com"` 直接拿 host 拼 endpoint。
### 直接试候选 endpoint
像 eastmoney 这种经验 endpoint 可以直接喂:
```bash
opencli browser eval "fetch('https://push2.eastmoney.com/api/qt/clist/get?fs=m:1+t:2&pn=1&pz=5&fltt=2&fid=f3&po=1&fields=f2,f3,f12,f14').then(r=>r.json())"
```
200 且数据对得上就认。
### URL 后缀探测
有些站直接在 URL 加 `.json` 就是 REST
- `https://www.reddit.com/r/rust.json` — Reddit 全覆盖
- `https://xueqiu.com/S/SH600000.json` — 雪球部分页
```bash
# 当前页加 .json 试
opencli browser eval "fetch(location.pathname.replace(/\\/$/,'')+'.json').then(r=>r.ok?r.json():'no')"
```
---
## §4 Token / CSRF 来源排查(Pattern D
已经在 network 里看到请求带自定义头,怎么拿到那个值:
### Cookie 里
```bash
opencli browser eval "document.cookie.split('; ').reduce((o,x)=>{const[k,v]=x.split('=');o[k]=v;return o},{})"
```
常见 token cookie 名:`ct0`Twitter CSRF)、`xq_a_token`(雪球)、`SESSDATA`B 站)、`_csrf / csrfToken`(通用)。
**`document.cookie` 只能看到 non-HttpOnly 的 cookie。** 上面那条命令侦察阶段够用,真写 adapter 时 auth 经常是 HttpOnly,一定要用 `page.getCookies(...)` 从 CDP cookie jar 拿——见 `adapter-template.md` 的 "COOKIE adapter 骨架"。
论坛 / BBS 引擎(Discuz!X / phpBB / vBulletin)还多一坑:auth cookie 设在**根域** `.example.com`(不是 `www.example.com`),且 HttpOnly。要查 `{ domain: '.<root>' }` **和** `{ domain: 'www.<root>' }` 两次,否则 adapter 在有 cookie 的前提下仍然 401。
### localStorage / sessionStorage 里
```bash
opencli browser eval "Object.keys(localStorage).map(k=>k+' => '+localStorage.getItem(k).slice(0,50))"
```
`token / auth / jwt / bearer` 关键字。
### Bundle 硬编码
有些站的 Bearer 是全站一个常量(Twitter 的匿名 Bearer)。在 bundle 里搜:
```bash
opencli browser eval "(async()=>{const s=[...document.querySelectorAll('script[src]')].map(e=>e.src).find(s=>/main|app|bundle/.test(s));const t=await fetch(s).then(r=>r.text());const m=t.match(/Bearer\\s+[\\w-]{20,}/g);return m?.slice(0,3)||'not found'})()"
```
### Store action 绕签名
Vue + Pinia / Redux / React Context 有时直接调 store method 能绕过签名逻辑(method 内部会自己拿签名再发请求):
```bash
# Pinia
opencli browser eval "typeof __pinia !== 'undefined' ? Object.keys(__pinia.state.value) : 'no pinia'"
# 直接调 store action(每个站点具体 action 名要查)
opencli browser eval "window.__pinia.state.value.someStore.someMethod({...})"
```
---
## §5 installInterceptor(最后降级)
所有手段都试过还拿不到请求签名时,让页面自己发请求,adapter 做 MITM 拦截响应:
```javascript
// func 里
await page.evaluateWithArgs(installInterceptorCode, {
config: { domain: 'api.xxx.com', path: '/foo' },
});
await page.goto('https://xxx.com/trigger-page');
// 等页面自己发那条请求
const intercepted = await page.evaluate('window.__opencli_intercepted');
return intercepted.response;
```
代价是要等页面真的触发请求,慢、不稳。只在 §1-4 都不行时用。
---
## 诊断不出来怎么办
按这个顺序试到命中:
```
§1 network ──→ 命中?yes → 走
│ no
§2 state ──→ 命中?yes → 走
│ no
§3 bundle ──→ 命中?yes → 走
│ no
§4 token ──→ 401 解除?yes → 走
│ no
§5 intercept → 让页面自己发
```
**四条都命不中的站(罕见)**:多半是视觉化渲染(canvas / webgl),数据不以 HTTP/JSON 形式存在。这种放弃或换源。