Files
opencli/src/explore.ts
T
jackwener d35d78d836 docs: polish README and SKILL for v0.1.0
README:
- Cleaner structure, accurate 25 commands / 13 sites
- Correct strategy labels (v2ex=public, not cookie)
- Explore artifacts documentation
- Better formatting and English/Chinese mix

SKILL:
- 5-tier authentication strategy table
- Public API YAML pattern example
- TS escape hatch guidance
- intercept pipeline step docs
- Troubleshooting section
2026-03-15 07:18:17 +08:00

487 lines
22 KiB
TypeScript

/**
* Deep Explore: intelligent API discovery with response analysis.
*
* Navigates to the target URL, auto-scrolls to trigger lazy loading,
* captures network traffic, analyzes JSON responses, and automatically
* infers CLI capabilities from discovered API endpoints.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { DEFAULT_BROWSER_EXPLORE_TIMEOUT, browserSession, runWithTimeout } from './runtime.js';
// ── Site name detection ────────────────────────────────────────────────────
const KNOWN_SITE_ALIASES: Record<string, string> = {
'x.com': 'twitter', 'twitter.com': 'twitter',
'news.ycombinator.com': 'hackernews',
'www.zhihu.com': 'zhihu', 'www.bilibili.com': 'bilibili',
'search.bilibili.com': 'bilibili',
'www.v2ex.com': 'v2ex', 'www.reddit.com': 'reddit',
'www.xiaohongshu.com': 'xiaohongshu', 'www.douban.com': 'douban',
'www.weibo.com': 'weibo', 'www.bbc.com': 'bbc',
};
export function detectSiteName(url: string): string {
try {
const host = new URL(url).hostname.toLowerCase();
if (host in KNOWN_SITE_ALIASES) return KNOWN_SITE_ALIASES[host];
const parts = host.split('.').filter(p => p && p !== 'www');
if (parts.length >= 2) {
if (['uk', 'jp', 'cn', 'com'].includes(parts[parts.length - 1]) && parts.length >= 3) {
return slugify(parts[parts.length - 3]);
}
return slugify(parts[parts.length - 2]);
}
return parts[0] ? slugify(parts[0]) : 'site';
} catch { return 'site'; }
}
export function slugify(value: string): string {
return value.trim().toLowerCase().replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-|-$/g, '') || 'site';
}
// ── Field & capability inference ───────────────────────────────────────────
const FIELD_ROLES: Record<string, string[]> = {
title: ['title', 'name', 'text', 'content', 'desc', 'description', 'headline', 'subject'],
url: ['url', 'uri', 'link', 'href', 'permalink', 'jump_url', 'web_url', 'share_url'],
author: ['author', 'username', 'user_name', 'nickname', 'nick', 'owner', 'creator', 'up_name', 'uname'],
score: ['score', 'hot', 'heat', 'likes', 'like_count', 'view_count', 'views', 'play', 'favorite_count', 'reply_count'],
time: ['time', 'created_at', 'publish_time', 'pub_time', 'date', 'ctime', 'mtime', 'pubdate', 'created'],
id: ['id', 'aid', 'bvid', 'mid', 'uid', 'oid', 'note_id', 'item_id'],
cover: ['cover', 'pic', 'image', 'thumbnail', 'poster', 'avatar'],
category: ['category', 'tag', 'type', 'tname', 'channel', 'section'],
};
const SEARCH_PARAMS = new Set(['q', 'query', 'keyword', 'search', 'wd', 'kw', 'search_query', 'w']);
const PAGINATION_PARAMS = new Set(['page', 'pn', 'offset', 'cursor', 'next', 'page_num']);
const LIMIT_PARAMS = new Set(['limit', 'count', 'size', 'per_page', 'page_size', 'ps', 'num']);
const VOLATILE_PARAMS = new Set(['w_rid', 'wts', '_', 'callback', 'timestamp', 't', 'nonce', 'sign']);
// ── Network analysis ───────────────────────────────────────────────────────
interface NetworkEntry {
method: string; url: string; status: number | null;
contentType: string; responseBody?: any; requestHeaders?: Record<string, string>;
}
interface AnalyzedEndpoint {
pattern: string; method: string; url: string; status: number | null;
contentType: string; queryParams: string[]; score: number;
hasSearchParam: boolean; hasPaginationParam: boolean; hasLimitParam: boolean;
authIndicators: string[];
responseAnalysis: { itemPath: string | null; itemCount: number; detectedFields: Record<string, string>; sampleFields: string[] } | null;
}
interface InferredCapability {
name: string; description: string; strategy: string; confidence: number;
endpoint: string; itemPath: string | null;
recommendedColumns: string[];
recommendedArgs: Array<{ name: string; type: string; required: boolean; default?: any }>;
}
/**
* Parse raw network output from Playwright MCP.
* Handles text format: [GET] url => [200]
*/
function parseNetworkRequests(raw: any): NetworkEntry[] {
if (typeof raw === 'string') {
const entries: NetworkEntry[] = [];
for (const line of raw.split('\n')) {
// Format: [GET] URL => [200]
const m = line.match(/\[?(GET|POST|PUT|DELETE|PATCH|OPTIONS)\]?\s+(\S+)\s*(?:=>|→)\s*\[?(\d+)\]?/i);
if (m) {
const [, method, url, status] = m;
entries.push({
method: method.toUpperCase(), url, status: status ? parseInt(status) : null,
contentType: (url.includes('/api/') || url.includes('/x/') || url.endsWith('.json')) ? 'application/json' : '',
});
}
}
return entries;
}
if (Array.isArray(raw)) {
return raw.filter(e => e && typeof e === 'object').map(e => ({
method: (e.method ?? 'GET').toUpperCase(),
url: String(e.url ?? e.request?.url ?? e.requestUrl ?? ''),
status: e.status ?? e.statusCode ?? null,
contentType: e.contentType ?? e.response?.contentType ?? '',
responseBody: e.responseBody, requestHeaders: e.requestHeaders,
}));
}
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: any): AnalyzedEndpoint['responseAnalysis'] {
if (!body || typeof body !== 'object') return null;
const candidates: Array<{ path: string; items: any[] }> = [];
function findArrays(obj: any, 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: 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: any, prefix: string, maxDepth: number): string[] {
if (maxDepth <= 0 || !obj || typeof obj !== 'object') return [];
const names: string[] = [];
for (const key of Object.keys(obj)) {
const full = prefix ? `${prefix}.${key}` : key;
names.push(full);
if (obj[key] && typeof obj[key] === 'object' && !Array.isArray(obj[key])) names.push(...flattenFields(obj[key], full, maxDepth - 1));
}
return names;
}
function scoreEndpoint(ep: { contentType: string; responseAnalysis: any; pattern: string; status: number | null; hasSearchParam: boolean; hasPaginationParam: boolean; hasLimitParam: boolean }): number {
let s = 0;
if (ep.contentType.includes('json')) s += 10;
if (ep.responseAnalysis) { s += 5; s += Math.min(ep.responseAnalysis.itemCount, 10); s += Object.keys(ep.responseAnalysis.detectedFields).length * 2; }
if (ep.pattern.includes('/api/') || ep.pattern.includes('/x/')) s += 3;
if (ep.hasSearchParam) s += 3;
if (ep.hasPaginationParam) s += 2;
if (ep.hasLimitParam) s += 2;
if (ep.status === 200) s += 2;
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 ────────────────────────────────────────────────────
const FRAMEWORK_DETECT_JS = `
() => {
const r = {};
try {
const app = document.querySelector('#app');
r.vue3 = !!(app && app.__vue_app__);
r.vue2 = !!(app && app.__vue__);
r.react = !!window.__REACT_DEVTOOLS_GLOBAL_HOOK__ || !!document.querySelector('[data-reactroot]');
r.nextjs = !!window.__NEXT_DATA__;
r.nuxt = !!window.__NUXT__;
if (r.vue3 && app.__vue_app__) { const gp = app.__vue_app__.config?.globalProperties; r.pinia = !!(gp && gp.$pinia); r.vuex = !!(gp && gp.$store); }
} catch {}
return r;
}
`;
// ── Store discovery ────────────────────────────────────────────────────────
const STORE_DISCOVER_JS = `
() => {
const stores = [];
try {
const app = document.querySelector('#app');
if (!app?.__vue_app__) return stores;
const gp = app.__vue_app__.config?.globalProperties;
// Pinia stores
const pinia = gp?.$pinia;
if (pinia?._s) {
pinia._s.forEach((store, id) => {
const actions = [];
const stateKeys = [];
for (const k in store) {
try {
if (k.startsWith('$') || k.startsWith('_')) continue;
if (typeof store[k] === 'function') actions.push(k);
else stateKeys.push(k);
} catch {}
}
stores.push({ type: 'pinia', id, actions: actions.slice(0, 20), stateKeys: stateKeys.slice(0, 15) });
});
}
// Vuex store modules
const vuex = gp?.$store;
if (vuex?._modules?.root?._children) {
const children = vuex._modules.root._children;
for (const [modName, mod] of Object.entries(children)) {
const actions = Object.keys(mod._rawModule?.actions ?? {}).slice(0, 20);
const stateKeys = Object.keys(mod.state ?? {}).slice(0, 15);
stores.push({ type: 'vuex', id: modName, actions, stateKeys });
}
}
} catch {}
return stores;
}
`;
export interface DiscoveredStore {
type: 'pinia' | 'vuex';
id: string;
actions: string[];
stateKeys: string[];
}
// ── Main explore function ──────────────────────────────────────────────────
export async function exploreUrl(
url: string,
opts: {
BrowserFactory: new () => any;
site?: string; goal?: string; authenticated?: boolean;
outDir?: string; waitSeconds?: number; query?: string;
clickLabels?: string[]; auto?: boolean;
},
): Promise<Record<string, any>> {
const waitSeconds = opts.waitSeconds ?? 3.0;
const exploreTimeout = Math.max(DEFAULT_BROWSER_EXPLORE_TIMEOUT, 45.0 + waitSeconds * 8.0);
return browserSession(opts.BrowserFactory, async (page) => {
return runWithTimeout((async () => {
// Step 1: Navigate
await page.goto(url);
await page.wait(waitSeconds);
// Step 2: Auto-scroll to trigger lazy loading (use keyboard since page.scroll may not exist)
for (let i = 0; i < 3; i++) { try { await page.pressKey('End'); } catch {} await page.wait(1); }
// Step 3: Read page metadata
const metadata = await readPageMetadata(page);
// Step 4: Capture network traffic
const rawNetwork = await page.networkRequests(false);
const networkEntries = parseNetworkRequests(rawNetwork);
// Step 5: For JSON endpoints, re-fetch response body in-browser
const jsonEndpoints = networkEntries.filter(e => e.contentType.includes('json') && e.method === 'GET' && e.status === 200);
for (const ep of jsonEndpoints.slice(0, 10)) {
try {
const body = await page.evaluate(`async () => { try { const r = await fetch(${JSON.stringify(ep.url)}, {credentials:'include'}); if (!r.ok) return null; const d = await r.json(); return JSON.stringify(d).slice(0,10000); } catch { return null; } }`);
if (body && typeof body === 'string') { try { ep.responseBody = JSON.parse(body); } catch {} }
else if (body && typeof body === 'object') ep.responseBody = body;
} catch {}
}
// Step 6: Detect framework
let framework: Record<string, boolean> = {};
try { const fw = await page.evaluate(FRAMEWORK_DETECT_JS); if (fw && typeof fw === 'object') framework = fw; } catch {}
// Step 6.5: Discover stores (Pinia / Vuex)
let stores: DiscoveredStore[] = [];
if (framework.pinia || framework.vuex) {
try {
const raw = await page.evaluate(STORE_DISCOVER_JS);
if (Array.isArray(raw)) stores = raw;
} catch {}
}
// Step 7: Analyze endpoints
const seen = new Map<string, AnalyzedEndpoint>();
for (const entry of networkEntries) {
if (!entry.url) continue;
const ct = entry.contentType.toLowerCase();
if (ct.includes('image/') || ct.includes('font/') || ct.includes('css') || ct.includes('javascript') || ct.includes('wasm')) continue;
if (entry.status && entry.status >= 400) continue;
const pattern = urlToPattern(entry.url);
const key = `${entry.method}:${pattern}`;
if (seen.has(key)) continue;
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: 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,
};
ep.score = scoreEndpoint(ep);
seen.set(key, ep);
}
const analyzedEndpoints = [...seen.values()].filter(ep => ep.score >= 5).sort((a, b) => b.score - a.score);
// Step 8: Infer capabilities
const capabilities: InferredCapability[] = [];
const usedNames = new Set<string>();
for (const ep of analyzedEndpoints.slice(0, 8)) {
let capName = inferCapabilityName(ep.url, opts.goal);
if (usedNames.has(capName)) {
const suffix = ep.pattern.split('/').filter(s => s && !s.startsWith('{') && !s.includes('.')).pop();
capName = suffix ? `${capName}_${suffix}` : `${capName}_${usedNames.size}`;
}
usedNames.add(capName);
const cols: string[] = [];
if (ep.responseAnalysis) {
for (const role of ['title', 'url', 'author', 'score', 'time']) {
if (ep.responseAnalysis.detectedFields[role]) cols.push(role);
}
}
const args: InferredCapability['recommendedArgs'] = [];
if (ep.hasSearchParam) args.push({ name: 'keyword', type: 'str', required: true });
args.push({ name: 'limit', type: 'int', required: false, default: 20 });
if (ep.hasPaginationParam) args.push({ name: 'page', type: 'int', required: false, default: 1 });
// Link store actions to capabilities when store-action strategy is recommended
const epStrategy = inferStrategy(ep.authIndicators);
let storeHint: { store: string; action: string } | undefined;
if ((epStrategy === 'intercept' || ep.authIndicators.includes('signature')) && stores.length > 0) {
// Try to find a store/action that matches this endpoint's purpose
for (const s of stores) {
const matchingAction = s.actions.find(a =>
capName.split('_').some(part => a.toLowerCase().includes(part)) ||
a.toLowerCase().includes('fetch') || a.toLowerCase().includes('get')
);
if (matchingAction) {
storeHint = { store: s.id, action: matchingAction };
break;
}
}
}
capabilities.push({
name: capName, description: `${opts.site ?? detectSiteName(url)} ${capName}`,
strategy: storeHint ? 'store-action' : epStrategy,
confidence: Math.min(ep.score / 20, 1.0), endpoint: ep.pattern,
itemPath: ep.responseAnalysis?.itemPath ?? null,
recommendedColumns: cols.length ? cols : ['title', 'url'],
recommendedArgs: args,
...(storeHint ? { storeHint } : {}),
});
}
// Step 9: Determine overall auth strategy
const allAuth = new Set(analyzedEndpoints.flatMap(ep => ep.authIndicators));
const topStrategy = allAuth.has('signature') ? 'intercept' : allAuth.has('bearer') || allAuth.has('csrf') ? 'header' : allAuth.size === 0 ? 'public' : 'cookie';
const siteName = opts.site ?? detectSiteName(metadata.url || url);
const targetDir = opts.outDir ?? path.join('.opencli', 'explore', siteName);
fs.mkdirSync(targetDir, { recursive: true });
const result = {
site: siteName, target_url: url, final_url: metadata.url, title: metadata.title,
framework, stores, top_strategy: topStrategy,
endpoint_count: analyzedEndpoints.length + [...seen.values()].filter(ep => ep.score < 5).length,
api_endpoint_count: analyzedEndpoints.length,
capabilities, auth_indicators: [...allAuth],
};
// Write artifacts
fs.writeFileSync(path.join(targetDir, 'manifest.json'), JSON.stringify({
site: siteName, target_url: url, final_url: metadata.url, title: metadata.title,
framework, stores: stores.map(s => ({ type: s.type, id: s.id, actions: s.actions })),
top_strategy: topStrategy, explored_at: new Date().toISOString(),
}, null, 2));
fs.writeFileSync(path.join(targetDir, 'endpoints.json'), JSON.stringify(analyzedEndpoints.map(ep => ({
pattern: ep.pattern, method: ep.method, url: ep.url, status: ep.status,
contentType: ep.contentType, score: ep.score, queryParams: ep.queryParams,
itemPath: ep.responseAnalysis?.itemPath ?? null, itemCount: ep.responseAnalysis?.itemCount ?? 0,
detectedFields: ep.responseAnalysis?.detectedFields ?? {}, authIndicators: ep.authIndicators,
})), null, 2));
fs.writeFileSync(path.join(targetDir, 'capabilities.json'), JSON.stringify(capabilities, null, 2));
fs.writeFileSync(path.join(targetDir, 'auth.json'), JSON.stringify({
top_strategy: topStrategy, indicators: [...allAuth], framework,
}, null, 2));
if (stores.length > 0) {
fs.writeFileSync(path.join(targetDir, 'stores.json'), JSON.stringify(stores, null, 2));
}
return { ...result, out_dir: targetDir };
})(), { timeout: exploreTimeout, label: `Explore ${url}` });
});
}
export function renderExploreSummary(result: Record<string, any>): string {
const lines = [
'opencli probe: OK', `Site: ${result.site}`, `URL: ${result.target_url}`,
`Title: ${result.title || '(none)'}`, `Strategy: ${result.top_strategy}`,
`Endpoints: ${result.endpoint_count} total, ${result.api_endpoint_count} API`,
`Capabilities: ${result.capabilities?.length ?? 0}`,
];
for (const cap of (result.capabilities ?? []).slice(0, 5)) {
const storeInfo = cap.storeHint ? ` → ${cap.storeHint.store}.${cap.storeHint.action}()` : '';
lines.push(` • ${cap.name} (${cap.strategy}, ${(cap.confidence * 100).toFixed(0)}%)${storeInfo}`);
}
const fw = result.framework ?? {};
const fwNames = Object.entries(fw).filter(([, v]) => v).map(([k]) => k);
if (fwNames.length) lines.push(`Framework: ${fwNames.join(', ')}`);
const stores: DiscoveredStore[] = result.stores ?? [];
if (stores.length) {
lines.push(`Stores: ${stores.length}`);
for (const s of stores.slice(0, 5)) {
lines.push(` • ${s.type}/${s.id}: ${s.actions.slice(0, 5).join(', ')}${s.actions.length > 5 ? '...' : ''}`);
}
}
lines.push(`Output: ${result.out_dir}`);
return lines.join('\n');
}
async function readPageMetadata(page: any): Promise<{ url: string; title: string }> {
try {
const result = await page.evaluate(`() => ({ url: window.location.href, title: document.title || '' })`);
if (result && typeof result === 'object') return { url: String(result.url ?? ''), title: String(result.title ?? '') };
} catch {}
return { url: '', title: '' };
}