Compare commits

..

3 Commits

Author SHA1 Message Date
jackwener 17cc44fa21 chore: remove stale Playwright references from comments and test fixtures
Replace "Playwright MCP" with accurate terminology (browser bridge,
snapshot engine, etc.) in comments across the codebase. Functional
stealth code that detects/cleans __playwright globals is preserved.
2026-03-24 20:22:24 +08:00
jackwener ff72cbf121 fix(ci): skip _shared internal directory in doc coverage check 2026-03-24 20:20:03 +08:00
jackwener 3e77889e67 refactor: simplify codebase by removing dead code, deduplicating types, and extracting shared desktop adapter commands
- Remove dead code: unused `promises` array in discovery.ts, unused `DEFAULT_BROWSER_SMOKE_TIMEOUT`, unused `checkFfmpeg()`, deprecated `PlaywrightMCP` alias
- Extract shared `YamlArgDefinition`/`YamlCliDefinition` into `yaml-schema.ts` (was duplicated in discovery.ts and build-manifest.ts)
- Unify `BrowserCookie` type: remove duplicate from download/index.ts, re-export from types.ts
- Create `_shared/desktop-commands.ts` with factory functions (makeScreenshotCommand, makeStatusCommand, makeNewCommand, makeDumpCommand), simplifying 11 adapter files from ~20-30 lines each to 3 lines
- Fix unnecessary dynamic imports in utils.ts

Net: +30 / -361 lines
2026-03-24 20:17:31 +08:00
87 changed files with 596 additions and 568 deletions
+2 -1
View File
@@ -23,7 +23,7 @@ Turn ANY Electron application into a CLI tool! Recombine, script, and extend app
- **CLI All Electron** — CLI-ify apps like Antigravity Ultra! Now AI can control itself natively using cc/openclaw!
- **Account-safe** — Reuses Chrome's logged-in state; your credentials never leave the browser.
- **AI Agent ready** — `explore` discovers APIs, `synthesize` generates adapters, `cascade` finds auth strategies.
- **External CLI Hub** — Discover, auto-install, and passthrough commands to any external CLI (gh, obsidian, docker, etc). Zero setup.
- **External CLI Hub** — Discover, auto-install, and passthrough commands to any external CLI (gh, obsidian, docker, kubectl, etc). Zero setup.
- **Self-healing setup** — `opencli doctor` diagnoses and auto-starts the daemon, extension, and live browser connectivity.
- **Dynamic Loader** — Simply drop `.ts` or `.yaml` adapters into the `clis/` folder for auto-registration.
- **Dual-Engine Architecture** — Supports both YAML declarative data pipelines and robust browser runtime TypeScript injections.
@@ -186,6 +186,7 @@ OpenCLI acts as a universal hub for your existing command-line tools. It provide
| **gh** | GitHub CLI | `opencli gh pr list --limit 5` |
| **obsidian** | Obsidian vault management | `opencli obsidian search query="AI"` |
| **docker** | Docker command-line interface | `opencli docker ps` |
| **kubectl** | Kubernetes command-line tool | `opencli kubectl get pods` |
| **readwise** | Readwise & Reader CLI | `opencli readwise login` |
| **gws** | Google Workspace CLI — Docs, Sheets, Drive, Gmail, Calendar | `opencli gws docs list` |
+2 -1
View File
@@ -25,7 +25,7 @@ CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合
- **CLI All Electron** — 支持把所有 electron 应用(如 Antigravity UltraCLI 化,让 AI 控制自己!
- **多站点覆盖** — 覆盖 B站、知乎、小红书、Twitter、Reddit,以及多种桌面应用
- **零风控** — 复用 Chrome 登录态,无需存储任何凭证
- **外部 CLI 枢纽** — 统一发现、自动安装、透传执行 `gh``docker` 等本地 CLI
- **外部 CLI 枢纽** — 统一发现、自动安装、透传执行 `gh``docker``kubectl` 等本地 CLI
- **自修复配置** — `opencli doctor` 自动启动 daemon,诊断扩展和浏览器连接状态
- **AI 原生** — `explore` 自动发现 API`synthesize` 生成适配器,`cascade` 探测认证策略
- **动态加载引擎** — 声明式的 `.yaml` 或者底层定制的 `.ts` 适配器,放入 `clis/` 文件夹即可自动注册生效
@@ -188,6 +188,7 @@ OpenCLI 也可以作为你现有命令行工具的统一入口,负责发现、
| **gh** | GitHub CLI | `opencli gh pr list --limit 5` |
| **obsidian** | Obsidian 仓库管理 | `opencli obsidian search query="AI"` |
| **docker** | Docker 命令行工具 | `opencli docker ps` |
| **kubectl** | Kubernetes CLI | `opencli kubectl get pods` |
| **readwise** | Readwise / Reader CLI | `opencli readwise login` |
| **gws** | Google Workspace CLI — Docs, Sheets, Drive, Gmail, Calendar | `opencli gws docs list` |
-169
View File
@@ -1,169 +0,0 @@
/**
* Shared API analysis helpers used by both explore.ts and record.ts.
*
* Extracts common logic for:
* - URL pattern normalization
* - Array path discovery in JSON responses
* - Field role detection
* - Auth indicator inference
* - Capability name inference
* - Strategy inference
*/
import {
VOLATILE_PARAMS,
SEARCH_PARAMS,
PAGINATION_PARAMS,
FIELD_ROLES,
} from './constants.js';
// ── URL pattern normalization ───────────────────────────────────────────────
/** Normalize a full URL into a pattern (replace IDs, strip volatile params). */
export function urlToPattern(url: string): string {
try {
const p = new URL(url);
const pathNorm = p.pathname
.replace(/\/\d+/g, '/{id}')
.replace(/\/[0-9a-fA-F]{8,}/g, '/{hex}')
.replace(/\/BV[a-zA-Z0-9]{10}/g, '/{bvid}');
const params: string[] = [];
p.searchParams.forEach((_v, k) => { if (!VOLATILE_PARAMS.has(k)) params.push(k); });
return `${p.host}${pathNorm}${params.length ? '?' + params.sort().map(k => `${k}={}`).join('&') : ''}`;
} catch { return url; }
}
// ── Array discovery in JSON responses ───────────────────────────────────────
export interface ArrayDiscovery {
path: string;
items: unknown[];
}
/** Find the best (largest) array of objects in a JSON response body. */
export function findArrayPath(obj: unknown, depth = 0): ArrayDiscovery | null {
if (depth > 5 || !obj || typeof obj !== 'object') return null;
if (Array.isArray(obj)) {
if (obj.length >= 2 && obj.some(i => i && typeof i === 'object' && !Array.isArray(i))) {
return { path: '', items: obj };
}
return null;
}
let best: ArrayDiscovery | null = null;
for (const [key, val] of Object.entries(obj as Record<string, unknown>)) {
const found = findArrayPath(val, depth + 1);
if (found) {
const fullPath = found.path ? `${key}.${found.path}` : key;
const candidate = { path: fullPath, items: found.items };
if (!best || candidate.items.length > best.items.length) best = candidate;
}
}
return best;
}
// ── Field flattening & role detection ───────────────────────────────────────
/** Flatten nested object keys up to maxDepth. */
export function flattenFields(obj: unknown, prefix: string, maxDepth: number): string[] {
if (maxDepth <= 0 || !obj || typeof obj !== 'object') return [];
const names: string[] = [];
const record = obj as Record<string, unknown>;
for (const key of Object.keys(record)) {
const full = prefix ? `${prefix}.${key}` : key;
names.push(full);
const val = record[key];
if (val && typeof val === 'object' && !Array.isArray(val)) names.push(...flattenFields(val, full, maxDepth - 1));
}
return names;
}
/** Detect semantic field roles (title, url, author, etc.) from sample fields. */
export function detectFieldRoles(sampleFields: string[]): Record<string, string> {
const detectedFields: Record<string, string> = {};
for (const [role, aliases] of Object.entries(FIELD_ROLES)) {
for (const f of sampleFields) {
if (aliases.includes(f.split('.').pop()?.toLowerCase() ?? '')) {
detectedFields[role] = f;
break;
}
}
}
return detectedFields;
}
// ── Capability name inference ───────────────────────────────────────────────
/** Infer a CLI capability name from a URL. */
export function inferCapabilityName(url: string, goal?: string): string {
if (goal) return goal;
const u = url.toLowerCase();
if (u.includes('hot') || u.includes('popular') || u.includes('ranking') || u.includes('trending')) return 'hot';
if (u.includes('search')) return 'search';
if (u.includes('feed') || u.includes('timeline') || u.includes('dynamic')) return 'feed';
if (u.includes('comment') || u.includes('reply')) return 'comments';
if (u.includes('history')) return 'history';
if (u.includes('profile') || u.includes('userinfo') || u.includes('/me')) return 'me';
if (u.includes('favorite') || u.includes('collect') || u.includes('bookmark')) return 'favorite';
try {
const segs = new URL(url).pathname
.split('/')
.filter(s => s && !s.match(/^\d+$/) && !s.match(/^[0-9a-f]{8,}$/i) && !s.match(/^v\d+$/));
if (segs.length) return segs[segs.length - 1].replace(/[^a-z0-9]/gi, '_').toLowerCase();
} catch {}
return 'data';
}
// ── Strategy inference ──────────────────────────────────────────────────────
/** Infer auth strategy from detected indicators. */
export function inferStrategy(authIndicators: string[]): string {
if (authIndicators.includes('signature')) return 'intercept';
if (authIndicators.includes('bearer') || authIndicators.includes('csrf')) return 'header';
return 'cookie';
}
// ── Auth indicator detection ────────────────────────────────────────────────
/** Detect auth indicators from HTTP headers. */
export function detectAuthFromHeaders(headers?: Record<string, string>): string[] {
if (!headers) return [];
const indicators: string[] = [];
const keys = Object.keys(headers).map(k => k.toLowerCase());
if (keys.some(k => k === 'authorization')) indicators.push('bearer');
if (keys.some(k => k.startsWith('x-csrf') || k.startsWith('x-xsrf'))) indicators.push('csrf');
if (keys.some(k => k.startsWith('x-s') || k === 'x-t' || k === 'x-s-common')) indicators.push('signature');
return indicators;
}
/** Detect auth indicators from URL and response body (heuristic). */
export function detectAuthFromContent(url: string, body: unknown): string[] {
const indicators: string[] = [];
if (body && typeof body === 'object') {
const keys = Object.keys(body as object).map(k => k.toLowerCase());
if (keys.some(k => k.includes('sign') || k === 'w_rid' || k.includes('token'))) {
indicators.push('signature');
}
}
if (url.includes('/wbi/') || url.includes('w_rid=')) indicators.push('signature');
if (url.includes('bearer') || url.includes('access_token')) indicators.push('bearer');
return indicators;
}
// ── Query param classification ──────────────────────────────────────────────
/** Extract non-volatile query params and classify them. */
export function classifyQueryParams(url: string): {
params: string[];
hasSearch: boolean;
hasPagination: boolean;
hasLimit: boolean;
} {
const params: string[] = [];
try { new URL(url).searchParams.forEach((_v, k) => { if (!VOLATILE_PARAMS.has(k)) params.push(k); }); } catch {}
return {
params,
hasSearch: params.some(p => SEARCH_PARAMS.has(p)),
hasPagination: params.some(p => PAGINATION_PARAMS.has(p)),
hasLimit: params.some(p => SEARCH_PARAMS.has(p)),
};
}
+2 -2
View File
@@ -14,11 +14,11 @@ describe('browser helpers', () => {
it('extracts tab entries from MCP markdown format', () => {
const entries = __test__.extractTabEntries(
'- 0: (current) [Playwright MCP extension](chrome-extension://abc/connect.html)\n- 1: [知乎 - 首页](https://www.zhihu.com/)'
'- 0: (current) [Browser Bridge extension](chrome-extension://abc/connect.html)\n- 1: [知乎 - 首页](https://www.zhihu.com/)'
);
expect(entries).toEqual([
{ index: 0, identity: '(current) [Playwright MCP extension](chrome-extension://abc/connect.html)' },
{ index: 0, identity: '(current) [Browser Bridge extension](chrome-extension://abc/connect.html)' },
{ index: 1, identity: '[知乎 - 首页](https://www.zhihu.com/)' },
]);
});
+1 -4
View File
@@ -1,8 +1,5 @@
/**
* Daemon discovery — simplified from MCP server path discovery.
*
* Only needs to check if the daemon is running. No more file system
* scanning for @playwright/mcp locations.
* Daemon discovery — checks if the daemon is running.
*/
import { DEFAULT_DAEMON_PORT } from '../constants.js';
+2 -2
View File
@@ -26,7 +26,7 @@
// ─── Types ───────────────────────────────────────────────────────────
export interface DomSnapshotOptions {
export interface SnapshotOptions {
/** Extra pixels beyond viewport to include (default 800) */
viewportExpand?: number;
/** Maximum DOM depth to traverse (default 50) */
@@ -175,7 +175,7 @@ export function getFormStateJs(): string {
* - `|iframe|` — iframe content
* - `|table|` — markdown table rendering
*/
export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string {
export function generateSnapshotJs(opts: SnapshotOptions = {}): string {
const viewportExpand = opts.viewportExpand ?? 800;
const maxDepth = Math.max(1, Math.min(opts.maxDepth ?? 50, 200));
const interactiveOnly = opts.interactiveOnly ?? false;
+1 -1
View File
@@ -11,7 +11,7 @@ export { CDPBridge } from './cdp.js';
export { isDaemonRunning } from './daemon-client.js';
export { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
export { generateStealthJs } from './stealth.js';
export type { DomSnapshotOptions } from './dom-snapshot.js';
export type { SnapshotOptions } from './dom-snapshot.js';
import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
import { __test__ as cdpTest } from './cdp.js';
+1 -1
View File
@@ -21,7 +21,7 @@ export function extractTabEntries(raw: unknown): Array<{ index: number; identity
.map(line => line.trim())
.filter(Boolean)
.map(line => {
// Match actual Playwright MCP format: "- 0: (current) [title](url)" or "- 1: [title](url)"
// Match tab list format: "- 0: (current) [title](url)" or "- 1: [title](url)"
const mcpMatch = line.match(/^-\s+(\d+):\s*(.*)$/);
if (mcpMatch) {
return {
+2 -3
View File
@@ -1,5 +1,4 @@
import { cli, Strategy } from '../../registry.js';
import { CommandExecutionError } from '../../errors.js';
import type { IPage } from '../../types.js';
import { fetchJson, getSelfUid, resolveUid } from './utils.js';
@@ -15,7 +14,7 @@ cli({
],
columns: ['mid', 'name', 'sign', 'following', 'fans'],
func: async (page: IPage | null, kwargs: any) => {
if (!page) throw new CommandExecutionError('Browser session required for bilibili following');
if (!page) throw new Error('Requires browser');
// 1. Resolve UID (default to self)
const uid = kwargs.uid
@@ -31,7 +30,7 @@ cli({
);
if (payload.code !== 0) {
throw new CommandExecutionError(`获取关注列表失败: ${payload.message} (${payload.code})`);
throw new Error(`获取关注列表失败: ${payload.message} (${payload.code})`);
}
const list = payload.data?.list || [];
+7 -8
View File
@@ -1,5 +1,4 @@
import { cli, Strategy } from '../../registry.js';
import { AuthRequiredError, CommandExecutionError, EmptyResultError, SelectorError } from '../../errors.js';
import type { IPage } from '../../types.js';
import { apiGet } from './utils.js';
@@ -14,7 +13,7 @@ cli({
],
columns: ['index', 'from', 'to', 'content'],
func: async (page: IPage | null, kwargs: any) => {
if (!page) throw new CommandExecutionError('Browser session required for bilibili subtitle');
if (!page) throw new Error('Requires browser');
// 1. 先前往视频详情页 (建立有鉴权的 Session,且这里不需要加载完整个视频)
await page.goto(`https://www.bilibili.com/video/${kwargs.bvid}/`);
@@ -25,7 +24,7 @@ cli({
})()`);
if (!cid) {
throw new SelectorError('videoData.cid', '无法在页面中提取到当前视频的 CID,请检查页面是否正常加载。');
throw new Error('无法在页面中提取到当前视频的 CID,请检查页面是否正常加载。');
}
// 3. 在 Node 端使用 apiGet 获取带 Wbi 签名的字幕列表
@@ -36,12 +35,12 @@ cli({
});
if (payload.code !== 0) {
throw new CommandExecutionError(`获取视频播放信息失败: ${payload.message} (${payload.code})`);
throw new Error(`获取视频播放信息失败: ${payload.message} (${payload.code})`);
}
const subtitles = payload.data?.subtitle?.subtitles || [];
if (subtitles.length === 0) {
throw new EmptyResultError('bilibili subtitle', '此视频没有发现外挂或智能字幕。');
throw new Error('此视频没有发现外挂或智能字幕。');
}
// 4. 选择目标字幕语言
@@ -51,7 +50,7 @@ cli({
const targetSubUrl = target.subtitle_url;
if (!targetSubUrl || targetSubUrl === '') {
throw new AuthRequiredError('bilibili.com', '[风控拦截/未登录] 获取到的 subtitle_url 为空!请确保 CLI 已成功登录且风控未封锁此账号。');
throw new Error('[风控拦截/未登录] 获取到的 subtitle_url 为空!请确保 CLI 已成功登录且风控未封锁此账号。');
}
const finalUrl = targetSubUrl.startsWith('//') ? 'https:' + targetSubUrl : targetSubUrl;
@@ -82,12 +81,12 @@ cli({
const items = await page.evaluate(fetchJs);
if (items?.error) {
throw new CommandExecutionError(`字幕获取失败: ${items.error}${items.text ? ' — ' + items.text : ''}`);
throw new Error(`字幕获取失败: ${items.error}${items.text ? ' — ' + items.text : ''}`);
}
const finalItems = items?.data || [];
if (!Array.isArray(finalItems)) {
throw new CommandExecutionError('解析到的字幕列表对象不符合数组格式');
throw new Error('解析到的字幕列表对象不符合数组格式');
}
// 6. 数据映射
+2 -2
View File
@@ -3,7 +3,7 @@
*/
import type { IPage } from '../../types.js';
import { AuthRequiredError, EmptyResultError } from '../../errors.js';
import { AuthRequiredError } from '../../errors.js';
const MIXIN_KEY_ENC_TAB = [
46,47,18,2,53,8,23,32,15,50,10,31,58,3,45,35,27,43,5,49,
@@ -112,5 +112,5 @@ export async function resolveUid(page: IPage, input: string): Promise<string> {
});
const results = payload?.data?.result ?? [];
if (results.length > 0) return String(results[0].mid);
throw new EmptyResultError(`bilibili user search: ${input}`, 'User may not exist or username may have changed.');
throw new Error(`Cannot resolve UID for: ${input}`);
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { cli, Strategy } from '../../registry.js';
import {
requirePage, navigateToChat, fetchRecommendList,
clickCandidateInList, typeAndSendMessage, verbose,
} from './utils.js';
} from './common.js';
cli({
site: 'boss',
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { requirePage, navigateToChat, fetchFriendList } from './utils.js';
import { requirePage, navigateToChat, fetchFriendList } from './common.js';
cli({
site: 'boss',
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { requirePage, navigateToChat, bossFetch, findFriendByUid } from './utils.js';
import { requirePage, navigateToChat, bossFetch, findFriendByUid } from './common.js';
cli({
site: 'boss',
+1 -1
View File
@@ -2,7 +2,7 @@
* BOSS直聘 job detail — fetch full job posting details via browser cookie API.
*/
import { cli, Strategy } from '../../registry.js';
import { requirePage, navigateTo, bossFetch, verbose } from './utils.js';
import { requirePage, navigateTo, bossFetch, verbose } from './common.js';
cli({
site: 'boss',
+1 -1
View File
@@ -2,7 +2,7 @@
* BOSS直聘 exchange — request phone/wechat exchange with a candidate.
*/
import { cli, Strategy } from '../../registry.js';
import { requirePage, navigateToChat, bossFetch, findFriendByUid, verbose } from './utils.js';
import { requirePage, navigateToChat, bossFetch, findFriendByUid, verbose } from './common.js';
cli({
site: 'boss',
+1 -1
View File
@@ -5,7 +5,7 @@ import { cli, Strategy } from '../../registry.js';
import {
requirePage, navigateToChat, findFriendByUid,
clickCandidateInList, typeAndSendMessage, verbose,
} from './utils.js';
} from './common.js';
cli({
site: 'boss',
+1 -1
View File
@@ -2,7 +2,7 @@
* BOSS直聘 invite — send interview invitation to a candidate.
*/
import { cli, Strategy } from '../../registry.js';
import { requirePage, navigateToChat, bossFetch, findFriendByUid, verbose } from './utils.js';
import { requirePage, navigateToChat, bossFetch, findFriendByUid, verbose } from './common.js';
cli({
site: 'boss',
+1 -1
View File
@@ -2,7 +2,7 @@
* BOSS直聘 job list — list my published jobs via boss API.
*/
import { cli, Strategy } from '../../registry.js';
import { requirePage, navigateToChat, bossFetch, verbose } from './utils.js';
import { requirePage, navigateToChat, bossFetch, verbose } from './common.js';
cli({
site: 'boss',
+2 -3
View File
@@ -7,7 +7,6 @@
*/
import { cli, Strategy } from '../../registry.js';
import { requirePage, navigateToChat, bossFetch, findFriendByUid, verbose } from './common.js';
import { ArgumentError, EmptyResultError } from '../../errors.js';
const LABEL_MAP: Record<string, number> = {
'新招呼': 1, '沟通中': 2, '已约面': 3, '已获取简历': 4,
@@ -45,7 +44,7 @@ cli({
if (entry) {
labelId = entry[1];
} else {
throw new ArgumentError(`未知标签: ${labelInput}。可用标签: ${Object.keys(LABEL_MAP).join(', ')}`);
throw new Error(`未知标签: ${labelInput}。可用标签: ${Object.keys(LABEL_MAP).join(', ')}`);
}
}
@@ -54,7 +53,7 @@ cli({
await navigateToChat(page);
const friend = await findFriendByUid(page, kwargs.uid, { checkGreetList: true });
if (!friend) throw new EmptyResultError('boss candidate search');
if (!friend) throw new Error('未找到该候选人');
const friendName = friend.name || '候选人';
const action = remove ? 'deleteMark' : 'addMark';
+1 -1
View File
@@ -2,7 +2,7 @@
* BOSS直聘 recommend — view recommended candidates (新招呼/greet sort list).
*/
import { cli, Strategy } from '../../registry.js';
import { requirePage, navigateToChat, bossFetch, fetchRecommendList, verbose } from './utils.js';
import { requirePage, navigateToChat, bossFetch, fetchRecommendList, verbose } from './common.js';
cli({
site: 'boss',
+1 -1
View File
@@ -10,7 +10,7 @@
* .position-content → job being discussed + expectation
*/
import { cli, Strategy } from '../../registry.js';
import { requirePage, navigateToChat, findFriendByUid, clickCandidateInList } from './utils.js';
import { requirePage, navigateToChat, findFriendByUid, clickCandidateInList } from './common.js';
cli({
site: 'boss',
+1 -1
View File
@@ -2,7 +2,7 @@
* BOSS直聘 job search — browser cookie API.
*/
import { cli, Strategy } from '../../registry.js';
import { requirePage, navigateTo, bossFetch, assertOk, verbose } from './utils.js';
import { requirePage, navigateTo, bossFetch, assertOk, verbose } from './common.js';
/** City name → BOSS Zhipin city code mapping */
const CITY_CODES: Record<string, string> = {
+3 -4
View File
@@ -9,7 +9,6 @@ import {
requirePage, navigateToChat, findFriendByUid,
clickCandidateInList, typeAndSendMessage,
} from './common.js';
import { EmptyResultError, SelectorError } from '../../errors.js';
cli({
site: 'boss',
@@ -30,21 +29,21 @@ cli({
await navigateToChat(page, 3);
const friend = await findFriendByUid(page, kwargs.uid, { maxPages: 5 });
if (!friend) throw new EmptyResultError('boss candidate search', '请确认 uid 是否正确');
if (!friend) throw new Error('未找到该候选人,请确认 uid 是否正确');
const numericUid = friend.uid;
const friendName = friend.name || '候选人';
const clicked = await clickCandidateInList(page, numericUid);
if (!clicked) {
throw new SelectorError('聊天列表中的用户', '请确认聊天列表中有此人');
throw new Error('无法在聊天列表中找到该用户,请确认聊天列表中有此人');
}
await page.wait({ time: 2 });
const sent = await typeAndSendMessage(page, kwargs.text);
if (!sent) {
throw new SelectorError('消息输入框', '聊天页面 UI 可能已改变');
throw new Error('找不到消息输入框');
}
await page.wait({ time: 1 });
+1 -1
View File
@@ -2,7 +2,7 @@
* BOSS直聘 stats — job statistics overview.
*/
import { cli, Strategy } from '../../registry.js';
import { requirePage, navigateToChat, bossFetch, fetchFriendList, verbose } from './utils.js';
import { requirePage, navigateToChat, bossFetch, fetchFriendList, verbose } from './common.js';
cli({
site: 'boss',
+1 -2
View File
@@ -1,6 +1,5 @@
import { execSync, spawnSync } from 'node:child_process';
import { cli, Strategy } from '../../registry.js';
import { ConfigError } from '../../errors.js';
import type { IPage } from '../../types.js';
import { getVisibleChatMessages } from './ax.js';
@@ -18,7 +17,7 @@ export const askCommand = cli({
columns: ['Role', 'Text'],
func: async (page: IPage | null, kwargs: any) => {
if (process.platform !== 'darwin') {
throw new ConfigError('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)');
throw new Error('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)');
}
const text = kwargs.text as string;
+1 -2
View File
@@ -1,6 +1,5 @@
import { execSync } from 'node:child_process';
import { cli, Strategy } from '../../registry.js';
import { ConfigError } from '../../errors.js';
import type { IPage } from '../../types.js';
export const newCommand = cli({
@@ -14,7 +13,7 @@ export const newCommand = cli({
columns: ['Status'],
func: async (page: IPage | null) => {
if (process.platform !== 'darwin') {
throw new ConfigError('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)');
throw new Error('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)');
}
try {
+2 -3
View File
@@ -1,6 +1,5 @@
import { execSync } from 'node:child_process';
import { cli, Strategy } from '../../registry.js';
import { CommandExecutionError, ConfigError } from '../../errors.js';
import type { IPage } from '../../types.js';
import { getVisibleChatMessages } from './ax.js';
@@ -15,7 +14,7 @@ export const readCommand = cli({
columns: ['Role', 'Text'],
func: async (page: IPage | null) => {
if (process.platform !== 'darwin') {
throw new ConfigError('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)');
throw new Error('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)');
}
try {
@@ -29,7 +28,7 @@ export const readCommand = cli({
return [{ Role: 'Assistant', Text: messages[messages.length - 1] }];
} catch (err: any) {
throw new CommandExecutionError("Failed to read from ChatGPT: " + err.message);
throw new Error("Failed to read from ChatGPT: " + err.message);
}
},
});
+2 -3
View File
@@ -1,6 +1,5 @@
import { execSync } from 'node:child_process';
import { cli, Strategy } from '../../registry.js';
import { CommandExecutionError, ConfigError } from '../../errors.js';
import type { IPage } from '../../types.js';
export const statusCommand = cli({
@@ -14,14 +13,14 @@ export const statusCommand = cli({
columns: ['Status'],
func: async (page: IPage | null) => {
if (process.platform !== 'darwin') {
throw new ConfigError('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)');
throw new Error('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)');
}
try {
const output = execSync("osascript -e 'application \"ChatGPT\" is running'", { encoding: 'utf-8' }).trim();
return [{ Status: output === 'true' ? 'Running' : 'Stopped' }];
} catch {
throw new CommandExecutionError('Error querying ChatGPT application state');
return [{ Status: 'Error querying application state' }];
}
},
});
+2 -5
View File
@@ -1,5 +1,4 @@
import { cli, Strategy } from '../../registry.js';
import { SelectorError } from '../../errors.js';
import type { IPage } from '../../types.js';
export const askCommand = cli({
@@ -27,14 +26,14 @@ export const askCommand = cli({
`);
// Send message
const injected = await page.evaluate(`
await page.evaluate(`
(function(text) {
let composer = document.querySelector('textarea');
if (!composer) {
const editables = Array.from(document.querySelectorAll('[contenteditable="true"]'));
composer = editables.length > 0 ? editables[editables.length - 1] : null;
}
if (!composer) return false;
if (!composer) throw new Error('Could not find input');
composer.focus();
if (composer.tagName === 'TEXTAREA') {
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
@@ -43,10 +42,8 @@ export const askCommand = cli({
} else {
document.execCommand('insertText', false, text);
}
return true;
})(${JSON.stringify(text)})
`);
if (!injected) throw new SelectorError('ChatWise input element');
await page.wait(0.5);
await page.pressKey('Enter');
+3 -5
View File
@@ -1,5 +1,4 @@
import { cli, Strategy } from '../../registry.js';
import { SelectorError } from '../../errors.js';
import type { IPage } from '../../types.js';
export const modelCommand = cli({
@@ -45,7 +44,7 @@ export const modelCommand = cli({
return [{ Status: 'Active', Model: currentModel }];
} else {
// Try to switch model
const opened = await page.evaluate(`
await page.evaluate(`
(function(target) {
const selectors = [
'[class*="model"]',
@@ -55,12 +54,11 @@ export const modelCommand = cli({
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el) { el.click(); return true; }
if (el) { el.click(); return; }
}
return false;
throw new Error('Could not find model selector');
})(${JSON.stringify(desiredModel)})
`);
if (!opened) throw new SelectorError('ChatWise model selector');
await page.wait(0.5);
+2 -5
View File
@@ -1,5 +1,4 @@
import { cli, Strategy } from '../../registry.js';
import { SelectorError } from '../../errors.js';
import type { IPage } from '../../types.js';
export const sendCommand = cli({
@@ -14,7 +13,7 @@ export const sendCommand = cli({
func: async (page: IPage, kwargs: any) => {
const text = kwargs.text as string;
const injected = await page.evaluate(`
await page.evaluate(`
(function(text) {
// ChatWise input can be textarea or contenteditable
let composer = document.querySelector('textarea');
@@ -23,7 +22,7 @@ export const sendCommand = cli({
composer = editables.length > 0 ? editables[editables.length - 1] : null;
}
if (!composer) return false;
if (!composer) throw new Error('Could not find ChatWise input element');
composer.focus();
@@ -35,10 +34,8 @@ export const sendCommand = cli({
} else {
document.execCommand('insertText', false, text);
}
return true;
})(${JSON.stringify(text)})
`);
if (!injected) throw new SelectorError('ChatWise input element');
await page.wait(0.5);
await page.pressKey('Enter');
+2 -5
View File
@@ -1,5 +1,4 @@
import { cli, Strategy } from '../../registry.js';
import { SelectorError } from '../../errors.js';
import type { IPage } from '../../types.js';
export const askCommand = cli({
@@ -27,17 +26,15 @@ export const askCommand = cli({
`);
// Inject and send
const injected = await page.evaluate(`
await page.evaluate(`
(function(text) {
const editables = Array.from(document.querySelectorAll('[contenteditable="true"]'));
const composer = editables.length > 0 ? editables[editables.length - 1] : document.querySelector('textarea');
if (!composer) return false;
if (!composer) throw new Error('Could not find Codex input');
composer.focus();
document.execCommand('insertText', false, text);
return true;
})(${JSON.stringify(text)})
`);
if (!injected) throw new SelectorError('Codex input element');
await page.wait(0.5);
await page.pressKey('Enter');
+1 -1
View File
@@ -23,7 +23,7 @@ export const modelCommand = cli({
let m = document.querySelector('[title*="Model"], [aria-label*="Model"], .model-selector, [class*="ModelPicker"]');
if (!m && document.querySelector('webview, iframe')) {
// Not directly in main DOM, might be in a webview, but Playwright evaluate doesn't cross origin boundaries easily without frames[].
// Not directly in main DOM, might be in a webview evaluate doesn't cross origin boundaries without frames[].
return 'Unknown (Likely inside a WebView, please focus the Chat tab)';
}
return m ? (m.textContent || m.getAttribute('title') || m.getAttribute('aria-label')).trim() : 'Unknown or Not Found';
+4 -5
View File
@@ -1,5 +1,4 @@
import { cli, Strategy } from '../../registry.js';
import { SelectorError } from '../../errors.js';
import type { IPage } from '../../types.js';
export const sendCommand = cli({
@@ -14,7 +13,7 @@ export const sendCommand = cli({
func: async (page: IPage, kwargs: any) => {
const textToInsert = kwargs.text as string;
const injected = await page.evaluate(`
await page.evaluate(`
(function(text) {
let composer = document.querySelector('textarea, [contenteditable="true"]');
@@ -23,14 +22,14 @@ export const sendCommand = cli({
composer = editables[editables.length - 1];
}
if (!composer) return false;
if (!composer) {
throw new Error('Could not find Composer input element in Codex UI');
}
composer.focus();
document.execCommand('insertText', false, text);
return true;
})(${JSON.stringify(textToInsert)})
`);
if (!injected) throw new SelectorError('Codex Composer input element');
// Wait for the UI to register the input
await page.wait(0.5);
+1 -2
View File
@@ -1,5 +1,4 @@
import { cli, Strategy } from '../../registry.js';
import { SelectorError } from '../../errors.js';
import type { IPage } from '../../types.js';
export const askCommand = cli({
@@ -34,7 +33,7 @@ export const askCommand = cli({
})(${JSON.stringify(text)})`
);
if (!injected) throw new SelectorError('Cursor input element');
if (!injected) throw new Error('Could not find input element.');
await page.wait(0.5);
await page.pressKey('Enter');
+1 -2
View File
@@ -1,5 +1,4 @@
import { cli, Strategy } from '../../registry.js';
import { SelectorError } from '../../errors.js';
import type { IPage } from '../../types.js';
export const composerCommand = cli({
@@ -34,7 +33,7 @@ export const composerCommand = cli({
);
if (!typed) {
throw new SelectorError('Cursor Composer input element', 'Could not find Cursor Composer input element after pressing Cmd+I.');
throw new Error('Could not find Cursor Composer input element after pressing Cmd+I.');
}
await page.wait(0.5);
+1 -2
View File
@@ -1,5 +1,4 @@
import { cli, Strategy } from '../../registry.js';
import { EmptyResultError } from '../../errors.js';
import type { IPage } from '../../types.js';
export const readCommand = cli({
@@ -40,7 +39,7 @@ export const readCommand = cli({
`);
if (!history || history.length === 0) {
throw new EmptyResultError('cursor read', 'No conversation history found in Cursor.');
throw new Error('No conversation history found in Cursor.');
}
return history;
+1 -2
View File
@@ -1,5 +1,4 @@
import { cli, Strategy } from '../../registry.js';
import { SelectorError } from '../../errors.js';
import type { IPage } from '../../types.js';
export const sendCommand = cli({
@@ -30,7 +29,7 @@ export const sendCommand = cli({
);
if (!injected) {
throw new SelectorError('Cursor Composer input element');
throw new Error('Could not find Cursor Composer input element.');
}
// Submit the command. In Cursor, Enter usually submits the chat.
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { loadDoubanBookHot } from './utils.js';
import { loadDoubanBookHot } from './shared.js';
cli({
site: 'douban',
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { loadDoubanMovieHot } from './utils.js';
import { loadDoubanMovieHot } from './shared.js';
cli({
site: 'douban',
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { searchDouban } from './utils.js';
import { searchDouban } from './shared.js';
cli({
site: 'douban',
+165
View File
@@ -0,0 +1,165 @@
import { CliError } from '../../errors.js';
import type { IPage } from '../../types.js';
function clampLimit(limit: number): number {
return Math.max(1, Math.min(limit || 20, 50));
}
async function ensureDoubanReady(page: IPage): Promise<void> {
const state = await page.evaluate(`
(() => {
const title = (document.title || '').trim();
const href = (location.href || '').trim();
const blocked = href.includes('sec.douban.com') || /登录跳转/.test(title) || /异常请求/.test(document.body?.innerText || '');
return { blocked, title, href };
})()
`);
if (state?.blocked) {
throw new CliError(
'AUTH_REQUIRED',
'Douban requires a logged-in browser session before these commands can load data.',
'Please sign in to douban.com in the browser that opencli reuses, then rerun the command.',
);
}
}
export async function loadDoubanBookHot(page: IPage, limit: number): Promise<any[]> {
const safeLimit = clampLimit(limit);
await page.goto('https://book.douban.com/chart');
await page.wait(4);
await ensureDoubanReady(page);
const data = await page.evaluate(`
(() => {
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
const books = [];
for (const el of Array.from(document.querySelectorAll('.media.clearfix'))) {
try {
const titleEl = el.querySelector('h2 a[href*="/subject/"]');
const title = normalize(titleEl?.textContent);
let url = titleEl?.getAttribute('href') || '';
if (!title || !url) continue;
if (!url.startsWith('http')) url = 'https://book.douban.com' + url;
const info = normalize(el.querySelector('.subject-abstract, .pl, .pub')?.textContent);
const infoParts = info.split('/').map((part) => part.trim()).filter(Boolean);
const ratingText = normalize(el.querySelector('.subject-rating .font-small, .rating_nums, .rating')?.textContent);
const quote = Array.from(el.querySelectorAll('.subject-tags .tag'))
.map((node) => normalize(node.textContent))
.filter(Boolean)
.join(' / ');
books.push({
rank: parseInt(normalize(el.querySelector('.green-num-box')?.textContent), 10) || books.length + 1,
title,
rating: parseFloat(ratingText) || 0,
quote,
author: infoParts[0] || '',
publisher: infoParts.find((part) => /出版社|出版公司|Press/i.test(part)) || infoParts[2] || '',
year: infoParts.find((part) => /\\d{4}(?:-\\d{1,2})?/.test(part))?.match(/\\d{4}/)?.[0] || '',
price: infoParts.find((part) => /元|USD|\\$|¥/.test(part)) || '',
url,
cover: el.querySelector('img')?.getAttribute('src') || '',
});
} catch {}
}
return books.slice(0, ${safeLimit});
})()
`);
return Array.isArray(data) ? data : [];
}
export async function loadDoubanMovieHot(page: IPage, limit: number): Promise<any[]> {
const safeLimit = clampLimit(limit);
await page.goto('https://movie.douban.com/chart');
await page.wait(4);
await ensureDoubanReady(page);
const data = await page.evaluate(`
(() => {
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
const results = [];
for (const el of Array.from(document.querySelectorAll('.item'))) {
const titleEl = el.querySelector('.pl2 a');
const title = normalize(titleEl?.textContent);
let url = titleEl?.getAttribute('href') || '';
if (!title || !url) continue;
if (!url.startsWith('http')) url = 'https://movie.douban.com' + url;
const info = normalize(el.querySelector('.pl2 p')?.textContent);
const infoParts = info.split('/').map((part) => part.trim()).filter(Boolean);
const releaseIndex = (() => {
for (let i = infoParts.length - 1; i >= 0; i -= 1) {
if (/\\d{4}-\\d{2}-\\d{2}|\\d{4}\\/\\d{2}\\/\\d{2}/.test(infoParts[i])) return i;
}
return -1;
})();
const directorPart = releaseIndex >= 1 ? infoParts[releaseIndex - 1] : '';
const regionPart = releaseIndex >= 2 ? infoParts[releaseIndex - 2] : '';
const yearMatch = info.match(/\\b(19|20)\\d{2}\\b/);
results.push({
rank: results.length + 1,
title,
rating: parseFloat(normalize(el.querySelector('.rating_nums')?.textContent)) || 0,
quote: normalize(el.querySelector('.inq')?.textContent),
director: directorPart.replace(/^导演:\\s*/, ''),
year: yearMatch?.[0] || '',
region: regionPart,
url,
cover: el.querySelector('img')?.getAttribute('src') || '',
});
if (results.length >= ${safeLimit}) break;
}
return results;
})()
`);
return Array.isArray(data) ? data : [];
}
export async function searchDouban(page: IPage, type: string, keyword: string, limit: number): Promise<any[]> {
const safeLimit = clampLimit(limit);
await page.goto(`https://search.douban.com/${encodeURIComponent(type)}/subject_search?search_text=${encodeURIComponent(keyword)}`);
await page.wait(2);
await ensureDoubanReady(page);
const data = await page.evaluate(`
(async () => {
const type = ${JSON.stringify(type)};
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
const seen = new Set();
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
for (let i = 0; i < 20; i += 1) {
if (document.querySelector('.item-root .title-text, .item-root .title a')) break;
await sleep(300);
}
const items = Array.from(document.querySelectorAll('.item-root'));
const results = [];
for (const el of items) {
const titleEl = el.querySelector('.title-text, .title a, a[title]');
const title = normalize(titleEl?.textContent) || normalize(titleEl?.getAttribute('title'));
let url = titleEl?.getAttribute('href') || '';
if (!title || !url) continue;
if (!url.startsWith('http')) url = 'https://search.douban.com' + url;
if (!url.includes('/subject/') || seen.has(url)) continue;
seen.add(url);
const ratingText = normalize(el.querySelector('.rating_nums')?.textContent);
const abstract = normalize(
el.querySelector('.meta.abstract, .meta, .abstract, p')?.textContent,
);
results.push({
rank: results.length + 1,
id: url.match(/subject\\/(\\d+)/)?.[1] || '',
type,
title,
rating: ratingText.includes('.') ? parseFloat(ratingText) : 0,
abstract: abstract.slice(0, 100) + (abstract.length > 100 ? '...' : ''),
url,
cover: el.querySelector('img')?.getAttribute('src') || '',
});
if (results.length >= ${safeLimit}) break;
}
return results;
})()
`);
return Array.isArray(data) ? data : [];
}
+1 -165
View File
@@ -1,173 +1,9 @@
/**
* Douban adapter utilities.
* Douban movie adapter utilities.
*/
import { CliError } from '../../errors.js';
import type { IPage } from '../../types.js';
function clampLimit(limit: number): number {
return Math.max(1, Math.min(limit || 20, 50));
}
async function ensureDoubanReady(page: IPage): Promise<void> {
const state = await page.evaluate(`
(() => {
const title = (document.title || '').trim();
const href = (location.href || '').trim();
const blocked = href.includes('sec.douban.com') || /登录跳转/.test(title) || /异常请求/.test(document.body?.innerText || '');
return { blocked, title, href };
})()
`);
if (state?.blocked) {
throw new CliError(
'AUTH_REQUIRED',
'Douban requires a logged-in browser session before these commands can load data.',
'Please sign in to douban.com in the browser that opencli reuses, then rerun the command.',
);
}
}
export async function loadDoubanBookHot(page: IPage, limit: number): Promise<any[]> {
const safeLimit = clampLimit(limit);
await page.goto('https://book.douban.com/chart');
await page.wait(4);
await ensureDoubanReady(page);
const data = await page.evaluate(`
(() => {
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
const books = [];
for (const el of Array.from(document.querySelectorAll('.media.clearfix'))) {
try {
const titleEl = el.querySelector('h2 a[href*="/subject/"]');
const title = normalize(titleEl?.textContent);
let url = titleEl?.getAttribute('href') || '';
if (!title || !url) continue;
if (!url.startsWith('http')) url = 'https://book.douban.com' + url;
const info = normalize(el.querySelector('.subject-abstract, .pl, .pub')?.textContent);
const infoParts = info.split('/').map((part) => part.trim()).filter(Boolean);
const ratingText = normalize(el.querySelector('.subject-rating .font-small, .rating_nums, .rating')?.textContent);
const quote = Array.from(el.querySelectorAll('.subject-tags .tag'))
.map((node) => normalize(node.textContent))
.filter(Boolean)
.join(' / ');
books.push({
rank: parseInt(normalize(el.querySelector('.green-num-box')?.textContent), 10) || books.length + 1,
title,
rating: parseFloat(ratingText) || 0,
quote,
author: infoParts[0] || '',
publisher: infoParts.find((part) => /出版社|出版公司|Press/i.test(part)) || infoParts[2] || '',
year: infoParts.find((part) => /\\d{4}(?:-\\d{1,2})?/.test(part))?.match(/\\d{4}/)?.[0] || '',
price: infoParts.find((part) => /元|USD|\\$|¥/.test(part)) || '',
url,
cover: el.querySelector('img')?.getAttribute('src') || '',
});
} catch {}
}
return books.slice(0, ${safeLimit});
})()
`);
return Array.isArray(data) ? data : [];
}
export async function loadDoubanMovieHot(page: IPage, limit: number): Promise<any[]> {
const safeLimit = clampLimit(limit);
await page.goto('https://movie.douban.com/chart');
await page.wait(4);
await ensureDoubanReady(page);
const data = await page.evaluate(`
(() => {
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
const results = [];
for (const el of Array.from(document.querySelectorAll('.item'))) {
const titleEl = el.querySelector('.pl2 a');
const title = normalize(titleEl?.textContent);
let url = titleEl?.getAttribute('href') || '';
if (!title || !url) continue;
if (!url.startsWith('http')) url = 'https://movie.douban.com' + url;
const info = normalize(el.querySelector('.pl2 p')?.textContent);
const infoParts = info.split('/').map((part) => part.trim()).filter(Boolean);
const releaseIndex = (() => {
for (let i = infoParts.length - 1; i >= 0; i -= 1) {
if (/\\d{4}-\\d{2}-\\d{2}|\\d{4}\\/\\d{2}\\/\\d{2}/.test(infoParts[i])) return i;
}
return -1;
})();
const directorPart = releaseIndex >= 1 ? infoParts[releaseIndex - 1] : '';
const regionPart = releaseIndex >= 2 ? infoParts[releaseIndex - 2] : '';
const yearMatch = info.match(/\\b(19|20)\\d{2}\\b/);
results.push({
rank: results.length + 1,
title,
rating: parseFloat(normalize(el.querySelector('.rating_nums')?.textContent)) || 0,
quote: normalize(el.querySelector('.inq')?.textContent),
director: directorPart.replace(/^导演:\\s*/, ''),
year: yearMatch?.[0] || '',
region: regionPart,
url,
cover: el.querySelector('img')?.getAttribute('src') || '',
});
if (results.length >= ${safeLimit}) break;
}
return results;
})()
`);
return Array.isArray(data) ? data : [];
}
export async function searchDouban(page: IPage, type: string, keyword: string, limit: number): Promise<any[]> {
const safeLimit = clampLimit(limit);
await page.goto(`https://search.douban.com/${encodeURIComponent(type)}/subject_search?search_text=${encodeURIComponent(keyword)}`);
await page.wait(2);
await ensureDoubanReady(page);
const data = await page.evaluate(`
(async () => {
const type = ${JSON.stringify(type)};
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
const seen = new Set();
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
for (let i = 0; i < 20; i += 1) {
if (document.querySelector('.item-root .title-text, .item-root .title a')) break;
await sleep(300);
}
const items = Array.from(document.querySelectorAll('.item-root'));
const results = [];
for (const el of items) {
const titleEl = el.querySelector('.title-text, .title a, a[title]');
const title = normalize(titleEl?.textContent) || normalize(titleEl?.getAttribute('title'));
let url = titleEl?.getAttribute('href') || '';
if (!title || !url) continue;
if (!url.startsWith('http')) url = 'https://search.douban.com' + url;
if (!url.includes('/subject/') || seen.has(url)) continue;
seen.add(url);
const ratingText = normalize(el.querySelector('.rating_nums')?.textContent);
const abstract = normalize(
el.querySelector('.meta.abstract, .meta, .abstract, p')?.textContent,
);
results.push({
rank: results.length + 1,
id: url.match(/subject\\/(\\d+)/)?.[1] || '',
type,
title,
rating: ratingText.includes('.') ? parseFloat(ratingText) : 0,
abstract: abstract.slice(0, 100) + (abstract.length > 100 ? '...' : ''),
url,
cover: el.querySelector('img')?.getAttribute('src') || '',
});
if (results.length >= ${safeLimit}) break;
}
return results;
})()
`);
return Array.isArray(data) ? data : [];
}
/**
* Get current user's Douban ID from movie.douban.com/mine page
*/
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { SEL, injectTextScript, clickSendScript, pollResponseScript } from './utils.js';
import { SEL, injectTextScript, clickSendScript, pollResponseScript } from './common.js';
export const askCommand = cli({
site: 'doubao-app',
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { clickNewChatScript } from './utils.js';
import { clickNewChatScript } from './common.js';
export const newCommand = cli({
site: 'doubao-app',
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { readMessagesScript } from './utils.js';
import { readMessagesScript } from './common.js';
export const readCommand = cli({
site: 'doubao-app',
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { injectTextScript, clickSendScript } from './utils.js';
import { injectTextScript, clickSendScript } from './common.js';
export const sendCommand = cli({
site: 'doubao-app',
+1 -1
View File
@@ -1,6 +1,6 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import { DOUBAO_DOMAIN, getDoubaoTranscriptLines, getDoubaoVisibleTurns, sendDoubaoMessage, waitForDoubaoResponse } from './utils.js';
import { DOUBAO_DOMAIN, getDoubaoTranscriptLines, getDoubaoVisibleTurns, sendDoubaoMessage, waitForDoubaoResponse } from './common.js';
export const askCommand = cli({
site: 'doubao',
+1 -1
View File
@@ -1,6 +1,6 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import { DOUBAO_DOMAIN, DOUBAO_CHAT_URL, startNewDoubaoChat } from './utils.js';
import { DOUBAO_DOMAIN, DOUBAO_CHAT_URL, startNewDoubaoChat } from './common.js';
export const newCommand = cli({
site: 'doubao',
+1 -1
View File
@@ -1,6 +1,6 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import { DOUBAO_DOMAIN, getDoubaoVisibleTurns } from './utils.js';
import { DOUBAO_DOMAIN, getDoubaoVisibleTurns } from './common.js';
export const readCommand = cli({
site: 'doubao',
+1 -1
View File
@@ -1,6 +1,6 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import { DOUBAO_DOMAIN, DOUBAO_CHAT_URL, sendDoubaoMessage } from './utils.js';
import { DOUBAO_DOMAIN, DOUBAO_CHAT_URL, sendDoubaoMessage } from './common.js';
export const sendCommand = cli({
site: 'doubao',
+1 -1
View File
@@ -1,6 +1,6 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import { DOUBAO_DOMAIN, DOUBAO_CHAT_URL, getDoubaoPageState } from './utils.js';
import { DOUBAO_DOMAIN, DOUBAO_CHAT_URL, getDoubaoPageState } from './common.js';
export const statusCommand = cli({
site: 'doubao',
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { JikePost, getPostDataJs } from './utils.js';
import { JikePost, getPostDataJs } from './shared.js';
/**
* 即刻首页动态流适配器
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { JikePost, getPostDataJs } from './utils.js';
import { JikePost, getPostDataJs } from './shared.js';
/**
* 即刻搜索适配器
+4 -5
View File
@@ -1,6 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import { ArgumentError, CommandExecutionError } from '../../errors.js';
// ── Filter value mappings ──────────────────────────────────────────────
@@ -65,7 +64,7 @@ function mapFilterValues(input: unknown, mapping: Record<string, string>, label:
const resolved = values.map(value => {
const key = value.toLowerCase();
const mapped = mapping[key];
if (!mapped) throw new ArgumentError(`Unsupported ${label}: ${value}`);
if (!mapped) throw new Error(`Unsupported ${label}: ${value}`);
return mapped;
});
return [...new Set(resolved)];
@@ -215,7 +214,7 @@ async function resolveCompanyIds(page: IPage, input: unknown): Promise<string[]>
}
if (unresolved.length) {
throw new ArgumentError(`Could not resolve LinkedIn company filter: ${unresolved.join(', ')}`);
throw new Error(`Could not resolve LinkedIn company filter: ${unresolved.join(', ')}`);
}
return [...ids];
@@ -253,7 +252,7 @@ async function fetchJobCards(
})()`);
if (!batch || batch.error) {
throw new CommandExecutionError(batch?.error || 'LinkedIn search returned an unexpected response');
throw new Error(batch?.error || 'LinkedIn search returned an unexpected response');
}
const elements: any[] = Array.isArray(batch?.elements) ? batch.elements : [];
@@ -388,7 +387,7 @@ cli({
const location = (kwargs.location ?? '').trim();
const keywords = String(kwargs.query ?? '').trim();
if (!keywords) throw new ArgumentError('query is required');
if (!keywords) throw new Error('query is required');
const searchParams = new URLSearchParams({ keywords });
if (location) searchParams.set('location', location);
+2 -3
View File
@@ -1,6 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import { AuthRequiredError, EmptyResultError } from '../../errors.js';
interface TimelinePost {
rank?: number;
@@ -511,11 +510,11 @@ cli({
}
if (sawLoginWall && posts.length === 0) {
throw new AuthRequiredError('linkedin.com', 'LinkedIn timeline requires an active signed-in browser session');
throw new Error('LinkedIn timeline requires an active signed-in browser session');
}
if (posts.length === 0) {
throw new EmptyResultError('linkedin timeline', 'Make sure your LinkedIn home feed is visible in the browser.');
throw new Error('No LinkedIn timeline posts found. Make sure your LinkedIn home feed is visible in the browser.');
}
return posts.slice(0, limit).map((post, index) => ({
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { buildMediumTagUrl, loadMediumPosts } from './utils.js';
import { buildMediumTagUrl, loadMediumPosts } from './shared.js';
cli({
site: 'medium',
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { buildMediumSearchUrl, loadMediumPosts } from './utils.js';
import { buildMediumSearchUrl, loadMediumPosts } from './shared.js';
cli({
site: 'medium',
@@ -1,4 +1,3 @@
import { CommandExecutionError } from '../../errors.js';
import type { IPage } from '../../types.js';
export function buildMediumTagUrl(topic?: string): string {
@@ -14,7 +13,7 @@ export function buildMediumUserUrl(username: string): string {
}
export async function loadMediumPosts(page: IPage, url: string, limit: number): Promise<any[]> {
if (!page) throw new CommandExecutionError('Browser session required for medium posts');
if (!page) throw new Error('Requires browser session');
await page.goto(url);
await page.wait(5);
const data = await page.evaluate(`
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { buildMediumUserUrl, loadMediumPosts } from './utils.js';
import { buildMediumUserUrl, loadMediumPosts } from './shared.js';
cli({
site: 'medium',
+3 -4
View File
@@ -7,7 +7,6 @@
* - Indented output showing conversation threads
*/
import { cli, Strategy } from '../../registry.js';
import { CommandExecutionError } from '../../errors.js';
cli({
site: 'reddit',
@@ -177,9 +176,9 @@ cli({
})()
`);
if (!data || typeof data !== 'object') throw new CommandExecutionError('Failed to fetch post data');
if (!Array.isArray(data) && data.error) throw new CommandExecutionError(data.error);
if (!Array.isArray(data)) throw new CommandExecutionError('Unexpected response');
if (!data || typeof data !== 'object') throw new Error('Failed to fetch post data');
if (!Array.isArray(data) && data.error) throw new Error(data.error);
if (!Array.isArray(data)) throw new Error('Unexpected response');
return data;
},
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { loadSinaBlogArticle } from './utils.js';
import { loadSinaBlogArticle } from './shared.js';
cli({
site: 'sinablog',
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { loadSinaBlogHot } from './utils.js';
import { loadSinaBlogHot } from './shared.js';
cli({
site: 'sinablog',
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { loadSinaBlogUser } from './utils.js';
import { loadSinaBlogUser } from './shared.js';
cli({
site: 'sinablog',
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { buildSubstackBrowseUrl, loadSubstackFeed } from './utils.js';
import { buildSubstackBrowseUrl, loadSubstackFeed } from './shared.js';
cli({
site: 'substack',
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { loadSubstackArchive } from './utils.js';
import { loadSubstackArchive } from './shared.js';
cli({
site: 'substack',
+2 -3
View File
@@ -1,5 +1,4 @@
import { cli, Strategy } from '../../registry.js';
import { AuthRequiredError, CommandExecutionError } from '../../errors.js';
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
const BOOKMARKS_QUERY_ID = 'Fy0QMy4q_aZCpkO0PnyLYw';
@@ -138,7 +137,7 @@ cli({
const ct0 = await page.evaluate(`() => {
return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
}`);
if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
if (!ct0) throw new Error('Not logged into x.com (no ct0 cookie)');
const queryId = await page.evaluate(`async () => {
try {
@@ -186,7 +185,7 @@ cli({
}`);
if (data?.error) {
if (allTweets.length === 0) throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch bookmarks. queryId may have expired.`);
if (allTweets.length === 0) throw new Error(`HTTP ${data.error}: Failed to fetch bookmarks. queryId may have expired.`);
break;
}
+1 -2
View File
@@ -1,5 +1,4 @@
import { cli, Strategy } from '../../registry.js';
import { CommandExecutionError } from '../../errors.js';
import type { IPage } from '../../types.js';
cli({
@@ -14,7 +13,7 @@ cli({
],
columns: ['status', 'message'],
func: async (page: IPage | null, kwargs: any) => {
if (!page) throw new CommandExecutionError('Browser session required for twitter delete');
if (!page) throw new Error('Requires browser');
await page.goto(kwargs.url);
await page.wait(5); // Wait for tweet to load completely
+2 -3
View File
@@ -1,5 +1,4 @@
import { cli, Strategy } from '../../registry.js';
import { AuthRequiredError, EmptyResultError } from '../../errors.js';
// ── Twitter GraphQL constants ──────────────────────────────────────────
@@ -38,7 +37,7 @@ cli({
const ct0 = await page.evaluate(`(() => {
return document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1] || null;
})()`);
if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
if (!ct0) throw new Error('Not logged into x.com (no ct0 cookie)');
// Try legacy guide.json API first (faster than DOM scraping)
let trends: TrendItem[] = [];
@@ -106,7 +105,7 @@ cli({
}
if (trends.length === 0) {
throw new EmptyResultError('twitter trending', 'API may have changed or login may be required.');
throw new Error('No trending data found. API may have changed or login may be required.');
}
return trends.slice(0, limit);
+1 -2
View File
@@ -1,5 +1,4 @@
import { cli, Strategy } from '../../registry.js';
import { CommandExecutionError } from '../../errors.js';
import type { IPage } from '../../types.js';
cli({
@@ -14,7 +13,7 @@ cli({
],
columns: ['status', 'message'],
func: async (page: IPage | null, kwargs: any) => {
if (!page) throw new CommandExecutionError('Browser session required for twitter unfollow');
if (!page) throw new Error('Requires browser');
const username = kwargs.username.replace(/^@/, '');
await page.goto(`https://x.com/${username}`);
+4 -5
View File
@@ -17,7 +17,6 @@ import {
type RawSegment,
type Chapter,
} from './transcript-group.js';
import { CommandExecutionError, EmptyResultError } from '../../errors.js';
cli({
site: 'youtube',
@@ -92,10 +91,10 @@ cli({
`);
if (!captionData || typeof captionData === 'string') {
throw new CommandExecutionError(`Failed to get caption info: ${typeof captionData === 'string' ? captionData : 'null response'}`);
throw new Error(`Failed to get caption info: ${typeof captionData === 'string' ? captionData : 'null response'}`);
}
if (captionData.error) {
throw new CommandExecutionError(`${captionData.error}${captionData.available ? ' (available: ' + captionData.available.join(', ') + ')' : ''}`);
throw new Error(`${captionData.error}${captionData.available ? ' (available: ' + captionData.available.join(', ') + ')' : ''}`);
}
// Warn if --lang was specified but not matched
@@ -177,10 +176,10 @@ cli({
`);
if (!Array.isArray(segments)) {
throw new CommandExecutionError((segments as any)?.error || 'Failed to parse caption segments');
throw new Error((segments as any)?.error || 'Failed to parse caption segments');
}
if (segments.length === 0) {
throw new EmptyResultError('youtube transcript');
throw new Error('No caption segments found');
}
// Step 3: Fetch chapters (for grouped mode)
+2 -3
View File
@@ -3,7 +3,6 @@
*/
import { cli, Strategy } from '../../registry.js';
import { parseVideoId } from './utils.js';
import { CommandExecutionError } from '../../errors.js';
cli({
site: 'youtube',
@@ -105,8 +104,8 @@ cli({
})()
`);
if (!data || typeof data !== 'object') throw new CommandExecutionError('Failed to extract video metadata from page');
if (data.error) throw new CommandExecutionError(data.error);
if (!data || typeof data !== 'object') throw new Error('Failed to extract video metadata from page');
if (data.error) throw new Error(data.error);
// Return as field/value pairs for table display
return Object.entries(data).map(([field, value]) => ({
+91 -22
View File
@@ -10,22 +10,12 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { DEFAULT_BROWSER_EXPLORE_TIMEOUT, browserSession, runWithTimeout } from './runtime.js';
import type { IBrowserFactory } from './runtime.js';
import { LIMIT_PARAMS } from './constants.js';
import { VOLATILE_PARAMS, SEARCH_PARAMS, PAGINATION_PARAMS, LIMIT_PARAMS, FIELD_ROLES } from './constants.js';
import { detectFramework } from './scripts/framework.js';
import { discoverStores } from './scripts/store.js';
import { interactFuzz } from './scripts/interact.js';
import type { IPage } from './types.js';
import { log } from './logger.js';
import {
urlToPattern,
findArrayPath,
flattenFields,
detectFieldRoles,
inferCapabilityName,
inferStrategy,
detectAuthFromHeaders,
classifyQueryParams,
} from './analysis.js';
// ── Site name detection ────────────────────────────────────────────────────
@@ -58,6 +48,10 @@ export function slugify(value: string): string {
return value.trim().toLowerCase().replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-|-$/g, '') || 'site';
}
// ── Field & capability inference ───────────────────────────────────────────
// (constants now imported from constants.ts)
// ── Network analysis ───────────────────────────────────────────────────────
interface NetworkEntry {
@@ -135,7 +129,7 @@ export interface ExploreBundle {
}
/**
* Parse raw network output from Playwright MCP.
* Parse raw network output from the browser bridge.
* Handles text format: [GET] url => [200]
*/
function parseNetworkRequests(raw: unknown): NetworkEntry[] {
@@ -166,16 +160,68 @@ function parseNetworkRequests(raw: unknown): NetworkEntry[] {
return [];
}
function urlToPattern(url: string): string {
try {
const p = new URL(url);
const pathNorm = p.pathname.replace(/\/\d+/g, '/{id}').replace(/\/[0-9a-fA-F]{8,}/g, '/{hex}').replace(/\/BV[a-zA-Z0-9]{10}/g, '/{bvid}');
const params: string[] = [];
p.searchParams.forEach((_v, k) => { if (!VOLATILE_PARAMS.has(k)) params.push(k); });
return `${p.host}${pathNorm}${params.length ? '?' + params.sort().map(k => `${k}={}`).join('&') : ''}`;
} catch { return url; }
}
function detectAuthIndicators(headers?: Record<string, string>): string[] {
if (!headers) return [];
const indicators: string[] = [];
const keys = Object.keys(headers).map(k => k.toLowerCase());
if (keys.some(k => k === 'authorization')) indicators.push('bearer');
if (keys.some(k => k.startsWith('x-csrf') || k.startsWith('x-xsrf'))) indicators.push('csrf');
if (keys.some(k => k.startsWith('x-s') || k === 'x-t' || k === 'x-s-common')) indicators.push('signature');
return indicators;
}
function analyzeResponseBody(body: unknown): AnalyzedEndpoint['responseAnalysis'] {
if (!body || typeof body !== 'object') return null;
const result = findArrayPath(body);
if (!result) return null;
const candidates: Array<{ path: string; items: unknown[] }> = [];
const sample = result.items[0];
function findArrays(obj: unknown, path: string, depth: number) {
if (depth > 4) return;
if (Array.isArray(obj) && obj.length >= 2 && obj.some(item => item && typeof item === 'object' && !Array.isArray(item))) {
candidates.push({ path, items: obj });
}
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
for (const [key, val] of Object.entries(obj)) findArrays(val, path ? `${path}.${key}` : key, depth + 1);
}
}
findArrays(body, '', 0);
if (!candidates.length) return null;
candidates.sort((a, b) => b.items.length - a.items.length);
const best = candidates[0];
const sample = best.items[0];
const sampleFields = sample && typeof sample === 'object' ? flattenFields(sample, '', 2) : [];
const detectedFields = detectFieldRoles(sampleFields);
return { itemPath: result.path || null, itemCount: result.items.length, detectedFields, sampleFields };
const detectedFields: Record<string, string> = {};
for (const [role, aliases] of Object.entries(FIELD_ROLES)) {
for (const f of sampleFields) {
if (aliases.includes(f.split('.').pop()?.toLowerCase() ?? '')) { detectedFields[role] = f; break; }
}
}
return { itemPath: best.path || null, itemCount: best.items.length, detectedFields, sampleFields };
}
function flattenFields(obj: unknown, prefix: string, maxDepth: number): string[] {
if (maxDepth <= 0 || !obj || typeof obj !== 'object') return [];
const names: string[] = [];
const record = obj as Record<string, unknown>;
for (const key of Object.keys(record)) {
const full = prefix ? `${prefix}.${key}` : key;
names.push(full);
const val = record[key];
if (val && typeof val === 'object' && !Array.isArray(val)) names.push(...flattenFields(val, full, maxDepth - 1));
}
return names;
}
function isBooleanRecord(value: unknown): value is Record<string, boolean> {
@@ -197,6 +243,28 @@ function scoreEndpoint(ep: { contentType: string; responseAnalysis: AnalyzedEndp
return s;
}
function inferCapabilityName(url: string, goal?: string): string {
if (goal) return goal;
const u = url.toLowerCase();
if (u.includes('hot') || u.includes('popular') || u.includes('ranking') || u.includes('trending')) return 'hot';
if (u.includes('search')) return 'search';
if (u.includes('feed') || u.includes('timeline') || u.includes('dynamic')) return 'feed';
if (u.includes('comment') || u.includes('reply')) return 'comments';
if (u.includes('history')) return 'history';
if (u.includes('profile') || u.includes('userinfo') || u.includes('/me')) return 'me';
if (u.includes('favorite') || u.includes('collect') || u.includes('bookmark')) return 'favorite';
try {
const segs = new URL(url).pathname.split('/').filter(s => s && !s.match(/^\d+$/) && !s.match(/^[0-9a-f]{8,}$/i));
if (segs.length) return segs[segs.length - 1].replace(/[^a-z0-9]/gi, '_').toLowerCase();
} catch {}
return 'data';
}
function inferStrategy(authIndicators: string[]): string {
if (authIndicators.includes('signature')) return 'intercept';
if (authIndicators.includes('bearer') || authIndicators.includes('csrf')) return 'header';
return 'cookie';
}
// ── Framework detection ────────────────────────────────────────────────────
@@ -232,14 +300,15 @@ function analyzeEndpoints(networkEntries: NetworkEntry[]): { analyzed: AnalyzedE
const key = `${entry.method}:${pattern}`;
if (seen.has(key)) continue;
const { params: qp, hasSearch, hasPagination, hasLimit } = classifyQueryParams(entry.url);
const qp: string[] = [];
try { new URL(entry.url).searchParams.forEach((_v, k) => { if (!VOLATILE_PARAMS.has(k)) qp.push(k); }); } catch {}
const ep: AnalyzedEndpoint = {
pattern, method: entry.method, url: entry.url, status: entry.status, contentType: ct,
queryParams: qp, hasSearchParam: hasSearch,
hasPaginationParam: hasPagination,
hasLimitParam: hasLimit || qp.some(p => LIMIT_PARAMS.has(p)),
authIndicators: detectAuthFromHeaders(entry.requestHeaders),
queryParams: qp, hasSearchParam: qp.some(p => SEARCH_PARAMS.has(p)),
hasPaginationParam: qp.some(p => PAGINATION_PARAMS.has(p)),
hasLimitParam: qp.some(p => LIMIT_PARAMS.has(p)),
authIndicators: detectAuthIndicators(entry.requestHeaders),
responseAnalysis: entry.responseBody ? analyzeResponseBody(entry.responseBody) : null,
score: 0,
};
+8
View File
@@ -22,6 +22,14 @@
install:
default: "npm install -g @readwiseio/readwise-cli"
- name: kubectl
binary: kubectl
description: "Kubernetes command-line tool"
homepage: "https://kubernetes.io/docs/reference/kubectl/"
tags: [kubernetes, k8s, devops]
install:
mac: "brew install kubectl"
- name: docker
binary: docker
description: "Docker command-line interface"
+82 -10
View File
@@ -19,15 +19,12 @@ import chalk from 'chalk';
import yaml from 'js-yaml';
import { sendCommand } from './browser/daemon-client.js';
import type { IPage } from './types.js';
import { SEARCH_PARAMS, PAGINATION_PARAMS, FIELD_ROLES } from './constants.js';
import {
urlToPattern,
findArrayPath,
inferCapabilityName,
inferStrategy,
detectAuthFromContent,
classifyQueryParams,
} from './analysis.js';
VOLATILE_PARAMS,
SEARCH_PARAMS,
PAGINATION_PARAMS,
FIELD_ROLES,
} from './constants.js';
// ── Types ──────────────────────────────────────────────────────────────────
@@ -145,6 +142,78 @@ function generateReadRecordedJs(): string {
// ── Analysis helpers ───────────────────────────────────────────────────────
function urlToPattern(url: string): string {
try {
const p = new URL(url);
const pathNorm = p.pathname
.replace(/\/\d+/g, '/{id}')
.replace(/\/[0-9a-fA-F]{8,}/g, '/{hex}')
.replace(/\/BV[a-zA-Z0-9]{10}/g, '/{bvid}');
const params: string[] = [];
p.searchParams.forEach((_v, k) => { if (!VOLATILE_PARAMS.has(k)) params.push(k); });
return `${p.host}${pathNorm}${params.length ? '?' + params.sort().map(k => `${k}={}`).join('&') : ''}`;
} catch { return url; }
}
function detectAuthIndicators(url: string, body: unknown): string[] {
const indicators: string[] = [];
// Heuristic: if body contains sign/w_rid fields, it's likely signed
if (body && typeof body === 'object') {
const keys = Object.keys(body as object).map(k => k.toLowerCase());
if (keys.some(k => k.includes('sign') || k === 'w_rid' || k.includes('token'))) {
indicators.push('signature');
}
}
// Check URL for common auth patterns
if (url.includes('/wbi/') || url.includes('w_rid=')) indicators.push('signature');
if (url.includes('bearer') || url.includes('access_token')) indicators.push('bearer');
return indicators;
}
function findArrayPath(obj: unknown, depth = 0): { path: string; items: unknown[] } | null {
if (depth > 5 || !obj || typeof obj !== 'object') return null;
if (Array.isArray(obj)) {
if (obj.length >= 2 && obj.some(i => i && typeof i === 'object' && !Array.isArray(i))) {
return { path: '', items: obj };
}
return null;
}
let best: { path: string; items: unknown[] } | null = null;
for (const [key, val] of Object.entries(obj as Record<string, unknown>)) {
const found = findArrayPath(val, depth + 1);
if (found) {
const fullPath = found.path ? `${key}.${found.path}` : key;
const candidate = { path: fullPath, items: found.items };
if (!best || candidate.items.length > best.items.length) best = candidate;
}
}
return best;
}
function inferCapabilityName(url: string): string {
const u = url.toLowerCase();
if (u.includes('hot') || u.includes('popular') || u.includes('ranking') || u.includes('trending')) return 'hot';
if (u.includes('search')) return 'search';
if (u.includes('feed') || u.includes('timeline') || u.includes('dynamic')) return 'feed';
if (u.includes('comment') || u.includes('reply')) return 'comments';
if (u.includes('history')) return 'history';
if (u.includes('profile') || u.includes('me')) return 'me';
if (u.includes('favorite') || u.includes('collect') || u.includes('bookmark')) return 'favorite';
try {
const segs = new URL(url).pathname
.split('/')
.filter(s => s && !s.match(/^\d+$/) && !s.match(/^[0-9a-f]{8,}$/i) && !s.match(/^v\d+$/));
if (segs.length) return segs[segs.length - 1].replace(/[^a-z0-9]/gi, '_').toLowerCase();
} catch {}
return 'data';
}
function inferStrategy(authIndicators: string[]): string {
if (authIndicators.includes('signature')) return 'intercept';
if (authIndicators.includes('bearer') || authIndicators.includes('csrf')) return 'header';
return 'cookie';
}
function scoreRequest(req: RecordedRequest, arrayResult: ReturnType<typeof findArrayPath> | null): number {
let s = 0;
if (arrayResult) {
@@ -198,7 +267,10 @@ function buildRecordedYaml(
: itemPath.split('.').map(p => `?.${p}`).join('');
// Detect search/limit/page params (must be before fetch URL building to use hasSearch/hasPage)
const { hasSearch, hasPagination: hasPage } = classifyQueryParams(req.url);
const qp: string[] = [];
try { new URL(req.url).searchParams.forEach((_v, k) => { if (!VOLATILE_PARAMS.has(k)) qp.push(k); }); } catch {}
const hasSearch = qp.some(p => SEARCH_PARAMS.has(p));
const hasPage = qp.some(p => PAGINATION_PARAMS.has(p));
// Build evaluate script
const mapLines = Object.entries(detectedFields)
@@ -460,7 +532,7 @@ function analyzeAndWrite(
const scored: ScoredEntry[] = [];
for (const [pattern, req] of seen) {
const arrayResult = findArrayPath(req.body);
const authIndicators = detectAuthFromContent(req.url, req.body);
const authIndicators = detectAuthIndicators(req.url, req.body);
const score = scoreRequest(req, arrayResult);
if (score > 0) {
scored.push({ req, pattern, arrayResult, authIndicators, score });
+3
View File
@@ -112,4 +112,7 @@ export function registerCommand(cmd: CliCommand): void {
_registry.set(fullName(cmd), cmd);
}
// Re-export serialization helpers from their dedicated module
export { serializeArg, serializeCommand, formatArgSummary, formatRegistryHelpText } from './serialization.js';
export type { SerializedArg } from './serialization.js';
+2 -2
View File
@@ -1,5 +1,5 @@
/**
* Tests for snapshotFormatter.ts: Playwright MCP snapshot tree filtering.
* Tests for snapshotFormatter.ts: accessibility snapshot tree filtering.
*
* Uses sanitized excerpts from real websites (GitHub, Bilibili, Twitter)
* to validate noise filtering, annotation stripping, and output quality.
@@ -9,7 +9,7 @@ import { describe, it, expect } from 'vitest';
import { formatSnapshot } from './snapshotFormatter.js';
// ---------------------------------------------------------------------------
// Fixtures: sanitized excerpts from real Playwright MCP snapshots
// Fixtures: sanitized excerpts from real accessibility snapshots
// ---------------------------------------------------------------------------
/** GitHub dashboard navigation bar (generic-heavy, refs, /url: lines) */
+135 -57
View File
@@ -1,14 +1,21 @@
/**
* Aria snapshot formatter: parses Playwright MCP snapshot text into clean format.
* Aria snapshot formatter: parses accessibility snapshot text into clean format.
*
* 4-pass pipeline:
* 1. Parse & filter: strip annotations, metadata, noise, ads, boilerplate subtrees
* 2. Deduplicate: generic/text parent match, heading+link, nested identical links
* 3. Prune: empty containers (iterative bottom-up)
* 4. Collapse: single-child containers
* Multi-pass pipeline:
* 1. Parse & filter: strip annotations, metadata, noise roles, ads, decorators
* 2. Deduplicate: generic/text child matching parent label
* 3. Deduplicate: heading + link with identical labels
* 4. Deduplicate: nested identical links
* 5. Prune: empty containers (iterative bottom-up)
* 6. Collapse: single-child containers
*/
import type { SnapshotOptions } from './types.js';
export interface FormatOptions {
interactive?: boolean;
compact?: boolean;
maxDepth?: number;
maxTextLength?: number;
}
const DEFAULT_MAX_TEXT_LENGTH = 200;
@@ -62,10 +69,10 @@ const BOILERPLATE_LABELS = [
/**
* Parse role and text from a trimmed snapshot line.
* Handles quoted labels and trailing text after colon correctly,
* including lines wrapped in single quotes by Playwright.
* including lines wrapped in single quotes by the snapshot engine.
*/
function parseLine(trimmed: string): { role: string; text: string; hasText: boolean; trailingText: string } {
// Unwrap outer single quotes if present (Playwright wraps lines with special chars)
// Unwrap outer single quotes if present (snapshot engine wraps lines with special chars)
let line = trimmed;
if (line.startsWith("'") && line.endsWith("':")) {
line = line.slice(1, -2) + ':';
@@ -107,7 +114,7 @@ function parseLine(trimmed: string): { role: string; text: string; hasText: bool
/**
* Strip ALL bracket annotations from a content line, preserving quoted strings.
* Handles both double-quoted and outer single-quoted lines from Playwright.
* Handles both double-quoted and outer single-quoted lines from the snapshot engine.
*/
function stripAnnotations(content: string): string {
// Unwrap outer single quotes first
@@ -192,18 +199,19 @@ interface Entry {
trailingText: string;
isInteractive: boolean;
isLandmark: boolean;
isSubtreeSkip: boolean; // ad nodes or boilerplate — skip entire subtree
}
export function formatSnapshot(raw: string, opts: SnapshotOptions = {}): string {
export function formatSnapshot(raw: string, opts: FormatOptions = {}): string {
if (!raw || typeof raw !== 'string') return '';
const maxTextLen = opts.maxTextLength ?? DEFAULT_MAX_TEXT_LENGTH;
const lines = raw.split('\n');
// === Pass 1: Parse, filter, and collect entries (merged with ad/boilerplate subtree skip) ===
const parsed: Entry[] = [];
// === Pass 1: Parse, filter, and collect entries ===
const entries: Entry[] = [];
let refCounter = 0;
let skipUntilDepth = -1;
let skipUntilDepth = -1; // When >= 0, skip all nodes at depth > this value
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
@@ -212,88 +220,148 @@ export function formatSnapshot(raw: string, opts: SnapshotOptions = {}): string
const indent = line.length - line.trimStart().length;
const depth = Math.floor(indent / 2);
// Subtree skip zone (noise roles, ads, boilerplate)
// If we're in a subtree skip zone, check depth
if (skipUntilDepth >= 0) {
if (depth > skipUntilDepth) continue;
skipUntilDepth = -1;
if (depth > skipUntilDepth) continue; // still inside subtree
skipUntilDepth = -1; // exited subtree
}
let content = line.trimStart();
if (content.startsWith('- ')) content = content.slice(2);
// Strip leading "- "
if (content.startsWith('- ')) {
content = content.slice(2);
}
// Skip metadata lines
if (isMetadataLine(content)) continue;
// Apply maxDepth filter
if (opts.maxDepth !== undefined && depth > opts.maxDepth) continue;
const { role, text, hasText, trailingText } = parseLine(content);
// Skip noise nodes
if (isNoiseNode(role, hasText, text, trailingText)) continue;
// Subtree noise roles (contentinfo footer, etc.)
if (SUBTREE_NOISE_ROLES.has(role)) { skipUntilDepth = depth; continue; }
// Ads and boilerplate — skip entire subtree (merged from old Pass 2)
if (isAdNode(text, trailingText) || isBoilerplateNode(text)) { skipUntilDepth = depth; continue; }
// Skip subtree noise roles (contentinfo footer, etc.) — skip entire subtree
if (SUBTREE_NOISE_ROLES.has(role)) {
skipUntilDepth = depth;
continue;
}
// Strip annotations
content = stripAnnotations(content);
// Check if node should trigger subtree skip (ads, boilerplate)
const isSubtreeSkip = isAdNode(text, trailingText) || isBoilerplateNode(text);
// Interactive mode filter
const isInteractive = INTERACTIVE_ROLES.has(role);
const isLandmark = LANDMARK_ROLES.has(role);
if (opts.interactive && !isInteractive && !isLandmark && !hasText) continue;
// Compact mode
if (opts.compact) {
content = content.replace(/\s*\[.*?\]\s*/g, ' ').replace(/\s+/g, ' ').trim();
content = content
.replace(/\s*\[.*?\]\s*/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
// Text truncation
if (maxTextLen > 0 && content.length > maxTextLen) {
content = content.slice(0, maxTextLen) + '…';
}
// Assign refs to interactive elements
if (isInteractive) {
refCounter++;
content = `[@${refCounter}] ${content}`;
}
parsed.push({ depth, content, role, text, trailingText, isInteractive, isLandmark });
entries.push({ depth, content, role, text, trailingText, isInteractive, isLandmark, isSubtreeSkip });
}
// === Pass 2: Deduplicate (merged: generic/text parent match + heading+link + nested links) ===
const deduped: Entry[] = [];
for (let i = 0; i < parsed.length; i++) {
const entry = parsed[i];
// === Pass 2: Remove subtree-skip nodes (ads, boilerplate, contentinfo) ===
let noAds: Entry[] = [];
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (entry.isSubtreeSkip) {
const skipDepth = entry.depth;
i++;
while (i < entries.length && entries[i].depth > skipDepth) {
i++;
}
i--;
continue;
}
noAds.push(entry);
}
// === Pass 3: Deduplicate child generic/text matching parent label ===
let deduped: Entry[] = [];
for (let i = 0; i < noAds.length; i++) {
const entry = noAds[i];
// Dedup: generic/text child matching parent label
if (entry.role === 'generic' || entry.role === 'text') {
let parent: Entry | undefined;
for (let j = deduped.length - 1; j >= 0; j--) {
if (deduped[j].depth < entry.depth) { parent = deduped[j]; break; }
if (deduped[j].depth < entry.depth) {
parent = deduped[j];
break;
}
if (deduped[j].depth === entry.depth) break;
}
if (parent) {
const childText = entry.trailingText || entry.text;
if (childText && parent.text && childText === parent.text) continue;
}
}
// Dedup: heading + child link with identical label
if (entry.role === 'heading' && entry.text) {
const next = parsed[i + 1];
if (next && next.role === 'link' && next.text === entry.text && next.depth === entry.depth + 1) {
deduped.push(entry);
i++; // skip the link, preserve its children
continue;
}
}
// Dedup: nested identical links (skip parent, keep child)
if (entry.role === 'link' && entry.text) {
const next = parsed[i + 1];
if (next && next.role === 'link' && next.text === entry.text && next.depth === entry.depth + 1) {
continue;
if (childText && parent.text && childText === parent.text) {
continue;
}
}
}
deduped.push(entry);
}
// === Pass 3: Iteratively prune empty containers (bottom-up) ===
let current = deduped;
// === Pass 4: Deduplicate heading + child link with identical label ===
// Pattern: heading "Title": → link "Title": (same text) → skip the link
const deduped2: Entry[] = [];
for (let i = 0; i < deduped.length; i++) {
const entry = deduped[i];
if (entry.role === 'heading' && entry.text) {
const next = deduped[i + 1];
if (next && next.role === 'link' && next.text === entry.text && next.depth === entry.depth + 1) {
// Keep the heading, skip the link. But preserve link's children re-parented.
deduped2.push(entry);
i++; // skip the link
continue;
}
}
deduped2.push(entry);
}
// === Pass 5: Deduplicate nested identical links ===
const deduped3: Entry[] = [];
for (let i = 0; i < deduped2.length; i++) {
const entry = deduped2[i];
if (entry.role === 'link' && entry.text) {
const next = deduped2[i + 1];
if (next && next.role === 'link' && next.text === entry.text && next.depth === entry.depth + 1) {
continue; // Skip parent, keep child
}
}
deduped3.push(entry);
}
// === Pass 6: Iteratively prune empty containers (bottom-up) ===
let current = deduped3;
let changed = true;
while (changed) {
changed = false;
@@ -304,16 +372,22 @@ export function formatSnapshot(raw: string, opts: SnapshotOptions = {}): string
let hasChildren = false;
for (let j = i + 1; j < current.length; j++) {
if (current[j].depth <= entry.depth) break;
if (current[j].depth > entry.depth) { hasChildren = true; break; }
if (current[j].depth > entry.depth) {
hasChildren = true;
break;
}
}
if (!hasChildren) {
changed = true;
continue;
}
if (!hasChildren) { changed = true; continue; }
}
next.push(entry);
}
current = next;
}
// === Pass 4: Collapse single-child containers ===
// === Pass 7: Collapse single-child containers ===
const collapsed: Entry[] = [];
for (let i = 0; i < current.length; i++) {
const entry = current[i];
@@ -334,13 +408,17 @@ export function formatSnapshot(raw: string, opts: SnapshotOptions = {}): string
let hasGrandchildren = false;
for (let j = childIdx + 1; j < current.length; j++) {
if (current[j].depth <= child.depth) break;
if (current[j].depth > child.depth) { hasGrandchildren = true; break; }
if (current[j].depth > child.depth) {
hasGrandchildren = true;
break;
}
}
if (!hasGrandchildren) {
const mergedContent = entry.content.replace(/:$/, '') + ' > ' + child.content;
collapsed.push({
...entry,
content: entry.content.replace(/:$/, '') + ' > ' + child.content,
content: mergedContent,
role: child.role,
text: child.text,
trailingText: child.trailingText,
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Page interface: type-safe abstraction over Playwright MCP browser page.
* Page interface: type-safe abstraction over the browser page.
*
* All pipeline steps and CLI adapters should use this interface
* instead of `any` for browser interactions.
+1
View File
@@ -521,4 +521,5 @@ describe('public commands E2E', () => {
expect(data[0]).toHaveProperty('word', 'perfect');
expect(data[0]).toHaveProperty('example');
}, 30_000);
}, 30_000);
});