Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion skills/webcmd-adapter-author/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ Check these off step by step:
[ ] **Preferred:** use `webcmd browser recon run --stdin` for navigation, readiness, network hints, and page evidence in one Playwright-style program.
[ ] Use `webcmd browser recon snapshot --snapshot-mode tree` when structural page evidence is needed.
[ ] Use the run result as reconnaissance evidence; do not copy Playwright code into an adapter.
[ ] Choose Pattern A / B / C / D / E.
[ ] Choose Pattern A / B / C / D / E — optionally shape the run result as `PageSignals` and pipe it through `webcmd browser analyze` for an automated pattern classification plus `adapter_hints` (recommended strategy, adapter-compatible path, state hazards). See "Optional: Automated Classification + Adapter Hints" in `site-recon.md`.

[ ] 4. API discovery (`api-discovery.md`) by Pattern:
[ ] Pattern A -> section 1 network deep read.
Expand Down
49 changes: 49 additions & 0 deletions skills/webcmd-adapter-author/references/site-recon.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,55 @@ webcmd browser recon snapshot --snapshot-mode tree

Use this evidence to choose Pattern A/B/C/D/E. Do not paste the Playwright-style program into the adapter.

### Optional: Automated Classification + Adapter Hints

To skip manually reading the table below, shape the `browser run` return value as
`PageSignals` and pipe it into `webcmd browser analyze`. This is a pure JSON-in/JSON-out
command — it does not drive a live browser itself, it only scores evidence you already
captured:

```bash
webcmd browser recon run --stdin <<'JS' > /tmp/signals.json
const networkEntries = [];
page.on('response', async response => {
const contentType = response.headers()['content-type'] || '';
networkEntries.push({
url: response.url(),
status: response.status(),
contentType,
bodyPreview: /json|text\/event-stream/i.test(contentType)
? (await response.text().catch(() => '')).slice(0, 2000)
: null,
});
});

await page.goto('<url>');
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(1500);

return {
requestedUrl: '<url>',
finalUrl: page.url(),
title: await page.title(),
cookieNames: (await page.context().cookies()).map(c => c.name),
networkEntries: networkEntries.slice(0, 30),
initialState: await page.evaluate(() => ({
__INITIAL_STATE__: Boolean(window.__INITIAL_STATE__),
__NUXT__: Boolean(window.__NUXT__),
__NEXT_DATA__: Boolean(window.__NEXT_DATA__),
__APOLLO_STATE__: Boolean(window.__APOLLO_STATE__),
})),
};
JS
webcmd browser analyze --file /tmp/signals.json
```

The report includes `pattern` (A/B/C/D/E with reasoning), `anti_bot`, scored `api_candidates`,
and an `adapter_hints` object with a recommended strategy, the corresponding adapter
signature (`browser:false -> func(args)` vs `browser:true -> func(page,args)`), flagged
state hazards, and a standing reminder that this report — and any Playwright-style code —
is reconnaissance evidence, never adapter source.

## Existing-Page Diagnosis

Use this when the user already has a relevant tab open. List pages, bind the chosen page,
Expand Down
90 changes: 90 additions & 0 deletions src/browser/analyze.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { describe, it, expect } from 'vitest';
import {
analyzeSite,
buildAdapterHints,
detectAntiBot,
classifyPattern,
findNearestAdapter,
scoreEndpointEvidence,
scoreNetworkEvidence,
type PageSignals,
} from './analyze.js';
import type { CliCommand } from '../registry.js';
Expand Down Expand Up @@ -260,4 +262,92 @@ describe('analyzeSite', () => {
);
expect(report.nearest_adapter?.site).toBe('github');
});

it('always includes adapter_hints with the Playwright boundary notice', () => {
const report = analyzeSite(mkSignals(), new Map());
expect(report.adapter_hints.do_not_copy_playwright_notice).toMatch(/not adapter source/i);
expect(report.adapter_hints.network_evidence).toEqual(report.api_candidates);
});
});

describe('buildAdapterHints', () => {
it('recommends PUBLIC_API for Pattern A with no anti-bot signal', () => {
const signals = mkSignals({
networkEntries: [
{ url: 'https://x.com/api/a', status: 200, contentType: 'application/json', bodyPreview: '{"items":[{"title":"A","id":"1"}]}' },
],
});
const pattern = classifyPattern(signals);
const antiBot = detectAntiBot(signals);
const hints = buildAdapterHints(signals, pattern, antiBot, scoreNetworkEvidence(signals));
expect(hints.recommended_strategy).toBe('PUBLIC_API');
expect(hints.adapter_compatible_path).toMatch(/Strategy\.PUBLIC.*browser:false/);
expect(hints.state_hazards).toEqual([]);
});

it('recommends COOKIE_API and flags the hazard for Pattern A behind a WAF', () => {
const signals = mkSignals({
cookieNames: ['acw_sc__v2'],
networkEntries: [
{ url: 'https://x.com/api/a', status: 200, contentType: 'application/json', bodyPreview: '{"items":[{"title":"A","id":"1"}]}' },
],
});
const pattern = classifyPattern(signals);
const antiBot = detectAntiBot(signals);
const hints = buildAdapterHints(signals, pattern, antiBot, scoreNetworkEvidence(signals));
expect(hints.recommended_strategy).toBe('COOKIE_API');
expect(hints.state_hazards.some((h) => /aliyun_waf/i.test(h))).toBe(true);
});

it('recommends DOM_STATE for Pattern B', () => {
const signals = mkSignals({
initialState: { __INITIAL_STATE__: true, __NUXT__: false, __NEXT_DATA__: false, __APOLLO_STATE__: false },
});
const pattern = classifyPattern(signals);
const antiBot = detectAntiBot(signals);
const hints = buildAdapterHints(signals, pattern, antiBot, scoreNetworkEvidence(signals));
expect(hints.recommended_strategy).toBe('DOM_STATE');
expect(hints.adapter_compatible_path).toMatch(/page\.evaluate/);
});

it('recommends COOKIE_API and flags the auth hazard for Pattern D', () => {
const signals = mkSignals({
networkEntries: [
{ url: 'https://x.com/api/a', status: 401, contentType: 'application/json', bodyPreview: '' },
{ url: 'https://x.com/api/b', status: 403, contentType: 'application/json', bodyPreview: '' },
],
});
const pattern = classifyPattern(signals);
const antiBot = detectAntiBot(signals);
const hints = buildAdapterHints(signals, pattern, antiBot, scoreNetworkEvidence(signals));
expect(hints.recommended_strategy).toBe('COOKIE_API');
expect(hints.state_hazards.some((h) => /401\/403/.test(h))).toBe(true);
});

it('recommends UI_SELECTOR and points at the snapshot tool for Pattern C', () => {
const signals = mkSignals();
const pattern = classifyPattern(signals);
const antiBot = detectAntiBot(signals);
const hints = buildAdapterHints(signals, pattern, antiBot, scoreNetworkEvidence(signals));
expect(pattern.pattern).toBe('C');
expect(hints.recommended_strategy).toBe('UI_SELECTOR');
expect(hints.selector_evidence).toMatch(/browser <session> snapshot/);
});

it('recommends INTERCEPT and flags the WS hazard for Pattern E', () => {
const signals = mkSignals();
const pattern = { pattern: 'E' as const, reason: 'WS traffic observed', json_responses: 0, real_data_candidates: 0, auth_failures: 0 };
const antiBot = detectAntiBot(signals);
const hints = buildAdapterHints(signals, pattern, antiBot, scoreNetworkEvidence(signals));
expect(hints.recommended_strategy).toBe('INTERCEPT');
expect(hints.state_hazards.some((h) => /WebSocket/.test(h))).toBe(true);
});

it('always carries the fixed do-not-copy-Playwright notice regardless of strategy', () => {
const signals = mkSignals();
const pattern = classifyPattern(signals);
const antiBot = detectAntiBot(signals);
const hints = buildAdapterHints(signals, pattern, antiBot, scoreNetworkEvidence(signals));
expect(hints.do_not_copy_playwright_notice).toMatch(/never paste Playwright locators/i);
});
});
115 changes: 115 additions & 0 deletions src/browser/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,119 @@ export function findNearestAdapter(
};
}

// ── Adapter hints (recon → adapter translation) ─────────────────────────────

/**
* Discovery-time strategy label, matching the vocabulary of the strategy-note
* template in `references/adapter-template.md` — distinct from the runtime
* `Strategy` enum in `registry.ts`, which `adapter_compatible_path` maps to.
*/
export type RecommendedStrategy =
| 'PUBLIC_API'
| 'COOKIE_API'
| 'UI_SELECTOR'
| 'DOM_STATE'
| 'INTERCEPT';

export interface AdapterHints {
recommended_strategy: RecommendedStrategy;
/** How the recommended strategy maps onto the stable adapter API — never Playwright. */
adapter_compatible_path: string;
/** Same evidence as `AnalyzeReport.api_candidates`, grouped here for a self-contained hint object. */
network_evidence: EndpointEvidence[];
/** DOM selectors aren't captured by PageSignals; point at the tool that captures them instead of fabricating evidence. */
selector_evidence: string;
state_hazards: string[];
/** Fixed boundary reminder — always present so the hint object stands alone even if only this field is read. */
do_not_copy_playwright_notice: string;
}

const DO_NOT_COPY_PLAYWRIGHT_NOTICE =
'This report and any Playwright-style `browser run` code are reconnaissance evidence, not adapter source. ' +
'Implement the adapter with the existing IPage/pipeline/Node-fetch APIs (`browser:false -> func(args)` or ' +
'`browser:true -> func(page,args)`); never paste Playwright locators, page.goto, or run() code into an adapter\'s func().';

const SELECTOR_EVIDENCE_POINTER =
'Not captured by this report. For UI_SELECTOR/DOM scraping, run `webcmd browser <session> snapshot --snapshot-mode tree` ' +
'and record semantic selectors/ARIA roles for the target rows before writing the adapter.';

function recommendStrategy(
pattern: PatternVerdict,
antiBot: AntiBotVerdict,
): { strategy: RecommendedStrategy; path: string } {
switch (pattern.pattern) {
case 'D':
return {
strategy: 'COOKIE_API',
path: 'Strategy.COOKIE, browser:true -> func(page,args); read cookies with page.getCookies() and finish with Node-side fetch.',
};
case 'B':
return {
strategy: 'DOM_STATE',
path: 'browser:true -> func(page,args); read the SSR/hydration global with page.evaluate() — no API call needed.',
};
case 'A':
return antiBot.detected
? {
strategy: 'COOKIE_API',
path: 'Strategy.COOKIE, browser:true -> func(page,args); read cookies with page.getCookies() and finish with Node-side fetch.',
}
: {
strategy: 'PUBLIC_API',
path: 'Strategy.PUBLIC, browser:false -> func(args); plain Node-side fetch, no browser context required.',
};
case 'E':
return {
strategy: 'INTERCEPT',
path: 'Raw WebSocket streams are not supported by adapters — find the underlying HTTP poll/long-poll endpoint and treat it as PUBLIC_API/COOKIE_API instead.',
};
case 'C':
default:
return {
strategy: 'UI_SELECTOR',
path: 'browser:true -> func(page,args); no API/SSR-state evidence yet, so extract with IPage selectors against the rendered page.',
};
}
}

/**
* Translate recon evidence into a structured bridge toward the stable
* adapter API, so agents act on the report instead of re-deriving strategy
* choice by hand or copying Playwright-style `browser run` code into `func`.
* See issue #226.
*/
export function buildAdapterHints(
signals: PageSignals,
pattern: PatternVerdict,
antiBot: AntiBotVerdict,
apiCandidates: EndpointEvidence[],
): AdapterHints {
const { strategy, path } = recommendStrategy(pattern, antiBot);

const hazards: string[] = [];
if (antiBot.detected) {
hazards.push(`${antiBot.vendor ?? 'unknown'} anti-bot detected: ${antiBot.evidence.join('; ')}`);
}
if (pattern.auth_failures > 0) {
hazards.push(`${pattern.auth_failures} response(s) returned 401/403 — endpoint likely requires an authenticated session`);
}
if (pattern.pattern === 'E') {
hazards.push('WebSocket stream detected — raw WS is not supported by adapters; find the HTTP poll fallback.');
}
if (signals.cookieNames.length === 0 && strategy === 'COOKIE_API') {
hazards.push('Recommended strategy needs an authenticated session, but no cookies were observed — re-run recon from a signed-in session before picking a strategy.');
}

return {
recommended_strategy: strategy,
adapter_compatible_path: path,
network_evidence: apiCandidates,
selector_evidence: SELECTOR_EVIDENCE_POINTER,
state_hazards: hazards,
do_not_copy_playwright_notice: DO_NOT_COPY_PLAYWRIGHT_NOTICE,
};
}

// ── Top-level assembly ────────────────────────────────────────────────────

export interface AnalyzeReport {
Expand All @@ -483,6 +596,7 @@ export interface AnalyzeReport {
api_candidates: EndpointEvidence[];
nearest_adapter: NearestAdapter | null;
recommended_next_step: string;
adapter_hints: AdapterHints;
}

/**
Expand Down Expand Up @@ -528,5 +642,6 @@ export function analyzeSite(
api_candidates: apiCandidates,
nearest_adapter: nearest,
recommended_next_step: next,
adapter_hints: buildAdapterHints(signals, pattern, antiBot, apiCandidates),
};
}
1 change: 1 addition & 0 deletions src/browser/command-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ describe('browserCommandCatalog', () => {
expect(browserCommand().commands.map(command => command.name())).toEqual([
'init',
'verify',
'analyze',
'tabs',
'bind',
'run',
Expand Down
Loading