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

12 KiB
Raw Permalink Blame History

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_itnabody 含 arg1 = '32-HEX'/ntc_captcha/ Aliyun WAF 返回 slider HTML,不是真数据 先在浏览器上下文里验证 endpointHTML 型 COOKIE adapter 最终仍走 Node-side fetch + page.getCookies()
__cf_bm / cf_clearance / __cfduidbody 含 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

判断:

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 命中率最高)

拿候选

opencli browser network

默认输出是 JSON,每个候选都带:

  • key — 稳定引用(GraphQL 的 operationNameMETHOD 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

  • $.dataobject 且下面出现 array(N) / total / page → 基本是它
  • 路径里出现 nickname / avatar / title / price / tweets / items → 就是它
  • shape 只有 $: string 或全是 HTML 噪音 → 下一条

按期望字段反查(--filter

已经知道目标 body 该含哪些字段就直接让 CLI 把列表筛到只剩候选,不用自己 scroll 翻 shape

opencli browser network --filter author,text,likes
  • 字段以英文逗号分隔;AND 语义,必须每个字段都作为 shape 路径的任意一段出现才保留($.data.items[0].author 命中 authoritemsdata 都算)
  • 区分大小写(JSON key 本来就 case-sensitive
  • 输出 envelope 新增 filter / filter_droppedcount 是过滤后数量
  • 0 命中不是 error,返回 entries: [];说明字段组合不对,换一组或去掉约束再试
  • 不要跟 --detail 一起用——--detail 按 key 取单条、--filter 是列表缩窄,组合会报 invalid_args
  • 空值 / ,,,invalid_filter 结构化错误
  • capture 依然按全量持久化,后续 --detail <key> 能找到被过滤掉的条目

拉完整 body

候选定了再拉完整 body(by key,不是 index — 数组顺序会随每次 capture 变):

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 里。需要:

# 滚到底(虚拟列表)
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

首屏数据常挂在这几个全局变量上:

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

取数据:

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

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)→ 直接去试
  • 主 bundlemain.js / app.js / index.xxx.js)→ 继续下一步下载 bundle 搜 baseURL

搜 bundle 里的 baseURL

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 可以直接喂:

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 — 雪球部分页
# 当前页加 .json 试
opencli browser eval "fetch(location.pathname.replace(/\\/$/,'')+'.json').then(r=>r.ok?r.json():'no')"

§4 Token / CSRF 来源排查(Pattern D

已经在 network 里看到请求带自定义头,怎么拿到那个值:

opencli browser eval "document.cookie.split('; ').reduce((o,x)=>{const[k,v]=x.split('=');o[k]=v;return o},{})"

常见 token cookie 名:ct0Twitter CSRF)、xq_a_token(雪球)、SESSDATAB 站)、_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 里

opencli browser eval "Object.keys(localStorage).map(k=>k+' => '+localStorage.getItem(k).slice(0,50))"

token / auth / jwt / bearer 关键字。

Bundle 硬编码

有些站的 Bearer 是全站一个常量(Twitter 的匿名 Bearer)。在 bundle 里搜:

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 内部会自己拿签名再发请求):

# 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 拦截响应:

// 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 形式存在。这种放弃或换源。