Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 17cc44fa21 | |||
| ff72cbf121 | |||
| 3e77889e67 |
@@ -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
@@ -25,7 +25,7 @@ CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合
|
||||
- **CLI All Electron** — 支持把所有 electron 应用(如 Antigravity Ultra)CLI 化,让 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
@@ -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
@@ -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,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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 || [];
|
||||
|
||||
@@ -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. 数据映射
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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,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,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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,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,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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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' }];
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,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,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,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,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,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { loadDoubanBookHot } from './utils.js';
|
||||
import { loadDoubanBookHot } from './shared.js';
|
||||
|
||||
cli({
|
||||
site: 'douban',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { loadDoubanMovieHot } from './utils.js';
|
||||
import { loadDoubanMovieHot } from './shared.js';
|
||||
|
||||
cli({
|
||||
site: 'douban',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { searchDouban } from './utils.js';
|
||||
import { searchDouban } from './shared.js';
|
||||
|
||||
cli({
|
||||
site: 'douban',
|
||||
|
||||
@@ -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
@@ -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,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,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,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,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,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,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,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,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,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,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { JikePost, getPostDataJs } from './utils.js';
|
||||
import { JikePost, getPostDataJs } from './shared.js';
|
||||
|
||||
/**
|
||||
* 即刻首页动态流适配器
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { JikePost, getPostDataJs } from './utils.js';
|
||||
import { JikePost, getPostDataJs } from './shared.js';
|
||||
|
||||
/**
|
||||
* 即刻搜索适配器
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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,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,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { buildMediumUserUrl, loadMediumPosts } from './utils.js';
|
||||
import { buildMediumUserUrl, loadMediumPosts } from './shared.js';
|
||||
|
||||
cli({
|
||||
site: 'medium',
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { loadSinaBlogArticle } from './utils.js';
|
||||
import { loadSinaBlogArticle } from './shared.js';
|
||||
|
||||
cli({
|
||||
site: 'sinablog',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { loadSinaBlogHot } from './utils.js';
|
||||
import { loadSinaBlogHot } from './shared.js';
|
||||
|
||||
cli({
|
||||
site: 'sinablog',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { loadSinaBlogUser } from './utils.js';
|
||||
import { loadSinaBlogUser } from './shared.js';
|
||||
|
||||
cli({
|
||||
site: 'sinablog',
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { loadSubstackArchive } from './utils.js';
|
||||
import { loadSubstackArchive } from './shared.js';
|
||||
|
||||
cli({
|
||||
site: 'substack',
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,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}`);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -521,4 +521,5 @@ describe('public commands E2E', () => {
|
||||
expect(data[0]).toHaveProperty('word', 'perfect');
|
||||
expect(data[0]).toHaveProperty('example');
|
||||
}, 30_000);
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user