mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
34a9bff2b3
- NEW: src/interceptor.ts — unified XHR/Fetch interceptor (was duplicated 3x) - NEW: src/version.ts — centralized PKG_VERSION (was duplicated 2x) - NEW: src/constants.ts — shared VOLATILE_PARAMS, FIELD_ROLES etc. - NEW: src/engine.test.ts, src/registry.test.ts — 14 new unit tests - browser.ts: use shared normalizeEval, interceptor, withTimeoutMs, PKG_VERSION - intercept.ts, tap.ts: use shared interceptor generators - cascade.ts: extract shared buildFetchProbeJs (90% dedup) - engine.ts: use InternalCliCommand (no more 'as any' casts) - executor.ts: remove all 15 'as StepHandler' type casts - runtime.ts: add withTimeoutMs, IBrowserFactory interface - registry.ts: add InternalCliCommand type for internal fields - validate.ts: add pipeline step name validation - output.ts: remove 'null as any' and '.filter(() => true)' hacks - explore.ts, synthesize.ts: use shared constants - docs: fix V2EX commands (3→6), SKILL.md version, verify example Tests: 88 passed (was 74), tsc --noEmit: 0 errors
191 lines
5.7 KiB
TypeScript
191 lines
5.7 KiB
TypeScript
/**
|
|
* Strategy Cascade: automatic strategy downgrade chain.
|
|
*
|
|
* Probes an API endpoint starting from the simplest strategy (PUBLIC)
|
|
* and automatically downgrades through the strategy tiers until one works:
|
|
*
|
|
* PUBLIC → COOKIE → HEADER → INTERCEPT → UI
|
|
*
|
|
* This eliminates the need for manual strategy selection — the system
|
|
* automatically finds the minimum-privilege strategy that works.
|
|
*/
|
|
|
|
import { Strategy } from './registry.js';
|
|
import type { IPage } from './types.js';
|
|
|
|
/** Strategy cascade order (simplest → most complex) */
|
|
const CASCADE_ORDER: Strategy[] = [
|
|
Strategy.PUBLIC,
|
|
Strategy.COOKIE,
|
|
Strategy.HEADER,
|
|
Strategy.INTERCEPT,
|
|
Strategy.UI,
|
|
];
|
|
|
|
interface ProbeResult {
|
|
strategy: Strategy;
|
|
success: boolean;
|
|
statusCode?: number;
|
|
hasData?: boolean;
|
|
error?: string;
|
|
responsePreview?: string;
|
|
}
|
|
|
|
interface CascadeResult {
|
|
bestStrategy: Strategy;
|
|
probes: ProbeResult[];
|
|
confidence: number;
|
|
}
|
|
|
|
/**
|
|
* Build the JavaScript source for a fetch probe.
|
|
* Shared logic for PUBLIC, COOKIE, and HEADER strategies.
|
|
*/
|
|
function buildFetchProbeJs(url: string, opts: {
|
|
credentials?: boolean;
|
|
extractCsrf?: boolean;
|
|
}): string {
|
|
const credentialsLine = opts.credentials ? `credentials: 'include',` : '';
|
|
const headerSetup = opts.extractCsrf
|
|
? `
|
|
const cookies = document.cookie.split(';').map(c => c.trim());
|
|
const csrf = cookies.find(c => c.startsWith('ct0=') || c.startsWith('csrf_token=') || c.startsWith('_csrf='))?.split('=').slice(1).join('=');
|
|
const headers = {};
|
|
if (csrf) { headers['X-Csrf-Token'] = csrf; headers['X-XSRF-Token'] = csrf; }
|
|
`
|
|
: 'const headers = {};';
|
|
|
|
return `
|
|
async () => {
|
|
try {
|
|
${headerSetup}
|
|
const resp = await fetch(${JSON.stringify(url)}, {
|
|
${credentialsLine}
|
|
headers
|
|
});
|
|
const status = resp.status;
|
|
if (!resp.ok) return { status, ok: false };
|
|
const text = await resp.text();
|
|
let hasData = false;
|
|
try {
|
|
const json = JSON.parse(text);
|
|
hasData = !!json && (Array.isArray(json) ? json.length > 0 :
|
|
typeof json === 'object' && Object.keys(json).length > 0);
|
|
// Check for API-level error codes (common in Chinese sites)
|
|
if (json.code !== undefined && json.code !== 0) hasData = false;
|
|
} catch {}
|
|
return { status, ok: true, hasData, preview: text.slice(0, 200) };
|
|
} catch (e) { return { ok: false, error: e.message }; }
|
|
}
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Probe an endpoint with a specific strategy.
|
|
* Returns whether the probe succeeded and basic response info.
|
|
*/
|
|
export async function probeEndpoint(
|
|
page: IPage,
|
|
url: string,
|
|
strategy: Strategy,
|
|
_opts: { timeout?: number } = {},
|
|
): Promise<ProbeResult> {
|
|
const result: ProbeResult = { strategy, success: false };
|
|
|
|
try {
|
|
switch (strategy) {
|
|
case Strategy.PUBLIC: {
|
|
const resp = await page.evaluate(buildFetchProbeJs(url, {}));
|
|
result.statusCode = resp?.status;
|
|
result.success = resp?.ok && resp?.hasData;
|
|
result.hasData = resp?.hasData;
|
|
result.responsePreview = resp?.preview;
|
|
break;
|
|
}
|
|
|
|
case Strategy.COOKIE: {
|
|
const resp = await page.evaluate(buildFetchProbeJs(url, { credentials: true }));
|
|
result.statusCode = resp?.status;
|
|
result.success = resp?.ok && resp?.hasData;
|
|
result.hasData = resp?.hasData;
|
|
result.responsePreview = resp?.preview;
|
|
break;
|
|
}
|
|
|
|
case Strategy.HEADER: {
|
|
const resp = await page.evaluate(buildFetchProbeJs(url, { credentials: true, extractCsrf: true }));
|
|
result.statusCode = resp?.status;
|
|
result.success = resp?.ok && resp?.hasData;
|
|
result.hasData = resp?.hasData;
|
|
result.responsePreview = resp?.preview;
|
|
break;
|
|
}
|
|
|
|
case Strategy.INTERCEPT:
|
|
case Strategy.UI:
|
|
// These require specific implementation per-site
|
|
result.success = false;
|
|
result.error = `Strategy ${strategy} requires site-specific implementation`;
|
|
break;
|
|
}
|
|
} catch (err: any) {
|
|
result.success = false;
|
|
result.error = err.message ?? String(err);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Run the cascade: try each strategy in order until one works.
|
|
* Returns the simplest working strategy.
|
|
*/
|
|
export async function cascadeProbe(
|
|
page: IPage,
|
|
url: string,
|
|
opts: { maxStrategy?: Strategy; timeout?: number } = {},
|
|
): Promise<CascadeResult> {
|
|
const maxIdx = opts.maxStrategy
|
|
? CASCADE_ORDER.indexOf(opts.maxStrategy)
|
|
: CASCADE_ORDER.indexOf(Strategy.HEADER); // Don't auto-try INTERCEPT/UI
|
|
|
|
const probes: ProbeResult[] = [];
|
|
|
|
for (let i = 0; i <= Math.min(maxIdx, CASCADE_ORDER.length - 1); i++) {
|
|
const strategy = CASCADE_ORDER[i];
|
|
const probe = await probeEndpoint(page, url, strategy, opts);
|
|
probes.push(probe);
|
|
|
|
if (probe.success) {
|
|
return {
|
|
bestStrategy: strategy,
|
|
probes,
|
|
confidence: 1.0 - (i * 0.1), // Higher confidence for simpler strategies
|
|
};
|
|
}
|
|
}
|
|
|
|
// None worked — default to COOKIE (most common for logged-in sites)
|
|
return {
|
|
bestStrategy: Strategy.COOKIE,
|
|
probes,
|
|
confidence: 0.3,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Render cascade results for display.
|
|
*/
|
|
export function renderCascadeResult(result: CascadeResult): string {
|
|
const lines = [
|
|
`Strategy Cascade: ${result.bestStrategy} (${(result.confidence * 100).toFixed(0)}% confidence)`,
|
|
];
|
|
for (const probe of result.probes) {
|
|
const icon = probe.success ? '✅' : '❌';
|
|
const status = probe.statusCode ? ` [${probe.statusCode}]` : '';
|
|
const err = probe.error ? ` — ${probe.error}` : '';
|
|
lines.push(` ${icon} ${probe.strategy}${status}${err}`);
|
|
}
|
|
return lines.join('\n');
|
|
}
|