当目标数据需要多步 API 链式获取时(如 BVID → CID → 字幕列表 → 字幕内容),在 func() 内按步骤串联。
import { cli, Strategy } from '@jackwener/opencli/registry';
import { AuthRequiredError } from '@jackwener/opencli/errors';
import type { IPage } from '@jackwener/opencli/types';
import { apiGet } from './utils.js'; // 复用平台 SDK
cli({
site: 'bilibili',
name: 'subtitle',
strategy: Strategy.COOKIE,
args: [{ name: 'bvid', required: true }],
columns: ['index', 'from', 'to', 'content'],
func: async (page: IPage | null, kwargs: any) => {
if (!page) throw new Error('Requires browser');
// Step 1: 建立 Session
await page.goto(`https://www.bilibili.com/video/${kwargs.bvid}/`);
// Step 2: 从页面提取中间 ID(优先用 __INITIAL_STATE__,免一次 API 调用)
const cid = await page.evaluate(`(async () => {
return window.__INITIAL_STATE__?.videoData?.cid;
})()`);
if (!cid) throw new Error('无法提取 CID');
// Step 3: 用中间 ID 调用下一级 API(自动 Wbi 签名)
const payload = await apiGet(page, '/x/player/wbi/v2', {
params: { bvid: kwargs.bvid, cid },
signed: true,
});
// Step 4: 断言风控降级(空值断言)
const subtitles = payload.data?.subtitle?.subtitles || [];
const url = subtitles[0]?.subtitle_url;
if (!url) throw new AuthRequiredError('bilibili.com', 'subtitle_url is empty — possible risk-control block');
// Step 5: 拉取最终数据(CDN JSON)
const items = await page.evaluate(`(async () => {
const res = await fetch(${JSON.stringify('https:' + url)});
const json = await res.json();
return json.body || json;
})()`);
return (items as any[]).map((item, idx) => ({
index: idx + 1,
from: item.from,
to: item.to,
content: item.content,
}));
},
});| 步骤 | 注意事项 |
|---|---|
| 提取中间 ID | 优先从 __INITIAL_STATE__ 拿,避免额外 API 调用 |
| Wbi 签名 | B 站 /wbi/ 接口强制校验 w_rid,纯 fetch 会被 403 |
| 空值断言 | 即使 HTTP 200,核心字段可能为空串(风控降级) |
| CDN URL | 常以 // 开头,补 https: |
JSON.stringify |
拼接 URL 到 evaluate 时必须用它转义,避免注入 |
不要猜 store name / action name。先用
browser eval探索,再写 TS 适配器。
opencli browser eval "(() => {
const app = document.querySelector('#app')?.__vue_app__;
const pinia = app?.config?.globalProperties?\$pinia;
return JSON.stringify([...pinia._s.keys()]);
})()"
# 输出: ["user", "feed", "search", "notification", ...]故意写一个错误 action 名,tap 会返回所有可用 actions:
⚠ tap: Action not found: wrongName on store notification
💡 Available: getNotification, replyComment, getNotificationCount, reset
opencli browser network # 列出捕获的 API 请求
opencli browser network --detail <N> # 查看第 N 条的完整响应
# 找到目标 API 的 URL 子串(如 "/you/mentions"、"homefeed") ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌────────┐
│ 1. navigate │ ──▶ │ 2. 探索 store │ ──▶ │ 3. 写 TS │ ──▶ │ 4. 测试 │
│ 到目标页面 │ │ name/action │ │ interceptor │ │ 运行验证 │
└──────────────┘ └──────────────┘ └──────────────┘ └────────┘
opencli bilibili hot --limit 1 -v # 查看 pipeline 每步数据流
opencli mysite hot -f json | jq '.[0]' # 确认 JSON 可被解析
opencli mysite hot -f csv > data.csv # 确认 CSV 可导入网站会频繁修改 CSS class、webpack module ID、GraphQL queryId,导致 adapter 失效。以下是 opencli 生产验证的写法。
问题:Twitter/X 每次部署都会更新 GraphQL queryId,硬编码很快失效。
方案:优先从 JS bundle 动态扫描,用 operationName(稳定)查 queryId(易变)。参考 clis/twitter/shared.js:
const resolved = await page.evaluate(`async () => {
const operationName = 'ListsManagePinTimeline';
// Tier A: 社区维护的配置文件(更新快)
try {
const data = await fetch('https://raw.githubusercontent.com/.../placeholder.json')
.then(r => r.json());
if (data?.[operationName]?.queryId) return data[operationName].queryId;
} catch {}
// Tier B: 扫描已加载的 client-web JS bundle
const scripts = performance.getEntriesByType('resource')
.filter(r => r.name.includes('client-web') && r.name.endsWith('.js'))
.map(r => r.name);
for (const url of scripts.slice(0, 15)) {
const text = await fetch(url).then(r => r.text());
const m = text.match('queryId:"([A-Za-z0-9_-]+)"[^}]{0,200}operationName:"' + operationName + '"');
if (m) return m[1];
}
return null; // 调用方使用 hardcoded fallback
}`);原则:用业务语义字符串(operationName)定位,不用 module ID;多特征组合减少误匹配。
问题:CSS class 随前端重构随时变化。
方案:按语义元素优先级逐级降级,只在最后才用 class hint。参考 clis/web/read.js:
// 优先级 1: <article>(标准语义)
const articles = document.querySelectorAll('article');
contentEl = articles.length === 1 ? articles[0]
: [...articles].reduce((max, a) =>
(a.textContent?.length || 0) > (max.textContent?.length || 0) ? a : max
);
// 优先级 2: [role="main"](ARIA 语义)
if (!contentEl) contentEl = document.querySelector('[role="main"]');
// 优先级 3: <main>(HTML5 语义)
if (!contentEl) contentEl = document.querySelector('main');
// 优先级 4: class-based hint(最后手段)
if (!contentEl) {
const candidates = document.querySelectorAll(
'div[class*="content"], div[class*="article"], div[class*="post"]'
);
contentEl = [...candidates].reduce((max, c) =>
(c.textContent?.length || 0) > (max.textContent?.length || 0) ? c : max
);
}多元素时用文本长度启发式选最大块,不假设固定 index。
问题:UI 迭代频繁,同一个输入框的选择器在新版本可能完全不同。
方案:把选择器按优先级列成有序数组,注释变更日期和观察依据。参考 clis/xiaohongshu/publish.js:
// New creator center (2026-03) uses contenteditable for the title field.
// Placeholder observed: "填写标题会有更多赞哦"
const TITLE_SELECTORS = [
'[contenteditable="true"][placeholder*="标题"]', // 新版(2026-03)
'[contenteditable="true"][placeholder*="赞"]', // 新版备选
'[contenteditable="true"][class*="title"]', // 通用
'input[maxlength="20"]', // 旧版特征值
'input[placeholder*="标题"]', // 旧版语义
];
for (const sel of TITLE_SELECTORS) {
const el = document.querySelector(sel);
if (el) { /* 使用 el */ break; }
}注释规范:写下 UI 版本(年月)+ 观察到的具体属性值,维护者能快速判断注释是否还有效。
问题:后端 API 经常在驼峰/蛇形之间切换,或加入新字段名兼容旧客户端。
方案:用 nullish coalescing 链覆盖所有可能字段名。参考 clis/xiaohongshu/user-helpers.js:
// noteId 可能是 noteId / note_id / id,都要覆盖
const noteId = noteCard.noteId ?? noteCard.note_id ?? entry?.noteId ?? entry?.note_id ?? entry?.id;
const token = entry?.xsecToken ?? entry?.xsec_token ?? noteCard.xsecToken ?? noteCard.xsec_token;何时使用:API 字段名有下划线/驼峰混用时,或明确知道 API 历史上改过字段名时。