Controls inventory and data-URI truncation #7

Merged
ilia merged 1 commits from feat/controls-inventory-datauri into master 2026-08-09 18:25:41 -05:00
12 changed files with 297 additions and 12 deletions
+6
View File
@@ -200,6 +200,12 @@ so no `main_world_eval` workaround is needed there either.
example, its DOM without this filter is 700k+ characters, almost all of
it invisible config junk). Without this check you'd be feeding an LLM
chameleon experiment payloads instead of page content.
- Long `data:` URLs (e.g. inlined images) are truncated in markdown links and
in AI-prompt network lines via `truncateDataUri`, so dumps stay usable.
- `inventoryControls()` / `inventory_controls()` lists buttons, menuitems, and
`aria-label` nodes with a **visible vs hidden** flag (capped). `buildAiPrompt`
includes an **Interesting Controls** section so agents can spot hidden Radix
twins without dumping the whole DOM.
- `build_ai_prompt()` / the extension **Copy** button cap page content at **20,000 chars**
by default (`maxChars` in JS, `max_chars=`/`--max-chars` in Python/CLI). A
full `<body>` extraction on a JS-heavy SPA can still be enormous even after
+1
View File
@@ -34,6 +34,7 @@ const session = new ExtractorSession(page); // attach BEFORE navigating
await page.goto('https://example.com', { waitUntil: 'networkidle' });
console.log(await session.buildAiPrompt()); // or session.extractMarkdown('#main')
console.log(await session.inventoryControls('#main')); // visible/hidden buttons & aria-labels
console.log(session.getStore()); // { console, errors, network }
```
+1
View File
@@ -5,5 +5,6 @@ export {
type ErrorEntry,
type NetworkEntry,
type ExtractedMarkdown,
type InterestingControl,
} from './session.js';
export { readSharedJs } from './loadJs.js';
+41 -2
View File
@@ -118,18 +118,44 @@ return extractMarkdown(el);
`;
}
function buildInventoryScript(selector: string | null | undefined): string {
const domJs = readSharedJs('dom.js');
const selJson = JSON.stringify(selector || '');
return `
(() => {
${domJs}
let el = null;
const sel = ${selJson};
if (sel) { try { el = document.querySelector(sel); } catch (_) { el = null; } }
if (!el) el = document.body;
return inventoryInterestingControls(el);
})()
`;
}
export interface InterestingControl {
tag: string;
role: string;
ariaLabel: string;
text: string;
visible: boolean;
selector: string;
}
function buildPromptScript(
meta: Pick<ExtractedMarkdown, 'url' | 'title' | 'ts' | 'selector'>,
markdown: string,
store: CaptureStore,
maxChars: number | undefined,
controls?: InterestingControl[],
): string {
const promptJs = readSharedJs('prompt.js');
const maxCharsJs = maxChars === undefined ? 'undefined' : JSON.stringify(maxChars);
const controlsJs = JSON.stringify(controls ?? []);
return `
(() => {
${promptJs}
return buildAIPrompt(${JSON.stringify(meta)}, ${JSON.stringify(markdown)}, ${JSON.stringify(store)}, ${maxCharsJs});
return buildAIPrompt(${JSON.stringify(meta)}, ${JSON.stringify(markdown)}, ${JSON.stringify(store)}, ${maxCharsJs}, ${controlsJs});
})()
`;
}
@@ -191,15 +217,28 @@ export class ExtractorSession {
};
}
/** Inventory buttons / menuitems / aria-labels under selector (default body). */
async inventoryControls(selector?: string | null): Promise<InterestingControl[]> {
const list = (await this.page.evaluate(buildInventoryScript(selector))) as InterestingControl[];
return Array.isArray(list) ? list : [];
}
async buildAiPrompt(selector?: string | null, maxChars?: number): Promise<string> {
const extracted = await this.extractMarkdown(selector);
const controls = await this.inventoryControls(selector);
const meta = {
url: extracted.url,
title: extracted.title,
ts: extracted.ts,
selector: extracted.selector,
};
const script = buildPromptScript(meta, extracted.markdown, this.getStore(), maxChars);
const script = buildPromptScript(
meta,
extracted.markdown,
this.getStore(),
maxChars,
controls,
);
return (await this.page.evaluate(script)) as string;
}
+20
View File
@@ -63,6 +63,9 @@ describe('ExtractorSession', () => {
expect(full.markdown).toContain('**world**');
expect(full.markdown).toContain('[docs](/docs)');
expect(full.markdown).toContain('- Alpha');
expect(full.markdown).toContain('[tiny-jpeg](data:image/jpeg;base64,…[');
expect(full.markdown).toContain('bytes truncated])');
expect(full.markdown).not.toMatch(/4AAQSkZJRgABAQAAAQABAAD/);
const scoped = await session.extractMarkdown('#main-content');
expect(scoped.selector).toBe('#main-content');
@@ -95,11 +98,28 @@ describe('ExtractorSession', () => {
expect(prompt.startsWith('# Page Context')).toBe(true);
expect(prompt).toContain('## Page Content');
expect(prompt).toContain('Hello **world**');
expect(prompt).toContain('## Interesting Controls');
expect(prompt).toContain('[visible]');
expect(prompt).toContain('Document options');
expect(prompt).toContain('### Hidden');
expect(/JavaScript Errors|Console Errors/.test(prompt)).toBe(true);
expect(prompt).toContain('_Extracted by Context Extractor_');
});
});
it('inventories visible and hidden controls', async () => {
await withPage(async (page) => {
const session = new ExtractorSession(page);
await page.goto(FIXTURE_URL, { waitUntil: 'domcontentloaded' });
const controls = await session.inventoryControls('#main-content');
const docs = controls.filter((c) => c.ariaLabel === 'Document options');
expect(docs.length).toBe(2);
expect(docs.some((c) => c.visible)).toBe(true);
expect(docs.some((c) => !c.visible)).toBe(true);
expect(controls.some((c) => c.ariaLabel === 'Upload file' && c.visible)).toBe(true);
});
});
it('clears the capture store, selectively and fully', async () => {
await withPage(async (page) => {
const session = new ExtractorSession(page);
+38 -5
View File
@@ -94,15 +94,35 @@ return extractMarkdown(el);
"""
def _build_inventory_script(selector: Optional[str]) -> str:
dom_js = _read_js("dom.js")
sel_json = json.dumps(selector or "")
return f"""
(() => {{
{dom_js}
let el = null;
const sel = {sel_json};
if (sel) {{ try {{ el = document.querySelector(sel); }} catch (_) {{ el = null; }} }}
if (!el) el = document.body;
return inventoryInterestingControls(el);
}})()
"""
def _build_prompt_script(
meta: dict[str, Any], markdown: str, store: dict[str, Any], max_chars: Optional[int] = None
meta: dict[str, Any],
markdown: str,
store: dict[str, Any],
max_chars: Optional[int] = None,
controls: Optional[list[Any]] = None,
) -> str:
prompt_js = _read_js("prompt.js")
max_chars_js = json.dumps(max_chars) if max_chars is not None else "undefined"
controls_js = json.dumps(controls if controls is not None else [])
return f"""
(() => {{
{prompt_js}
return buildAIPrompt({json.dumps(meta)}, {json.dumps(markdown)}, {json.dumps(store)}, {max_chars_js});
return buildAIPrompt({json.dumps(meta)}, {json.dumps(markdown)}, {json.dumps(store)}, {max_chars_js}, {controls_js});
}})()
"""
@@ -196,13 +216,19 @@ class ExtractorSession(_CaptureMixin):
"markdown": markdown or "",
}
def inventory_controls(self, selector: Optional[str] = None) -> list[Any]:
result = self.page.evaluate(_build_inventory_script(selector))
return list(result) if result else []
def build_ai_prompt(self, selector: Optional[str] = None, max_chars: Optional[int] = None) -> str:
extracted = self.extract_markdown(selector)
controls = self.inventory_controls(selector)
meta = {k: extracted[k] for k in ("url", "title", "ts", "selector")}
script = _build_prompt_script(meta, extracted["markdown"], self.get_store(), max_chars)
script = _build_prompt_script(
meta, extracted["markdown"], self.get_store(), max_chars, controls
)
return self.page.evaluate(script)
class AsyncExtractorSession(_CaptureMixin):
"""Async API. Use with `playwright.async_api` or `camoufox.AsyncCamoufox`.
@@ -236,8 +262,15 @@ class AsyncExtractorSession(_CaptureMixin):
"markdown": markdown or "",
}
async def inventory_controls(self, selector: Optional[str] = None) -> list[Any]:
result = await self.page.evaluate(_build_inventory_script(selector))
return list(result) if result else []
async def build_ai_prompt(self, selector: Optional[str] = None, max_chars: Optional[int] = None) -> str:
extracted = await self.extract_markdown(selector)
controls = await self.inventory_controls(selector)
meta = {k: extracted[k] for k in ("url", "title", "ts", "selector")}
script = _build_prompt_script(meta, extracted["markdown"], self.get_store(), max_chars)
script = _build_prompt_script(
meta, extracted["markdown"], self.get_store(), max_chars, controls
)
return await self.page.evaluate(script)
+4
View File
@@ -12,6 +12,10 @@
<main id="main-content">
<h2>Section</h2>
<p>Hello <strong>world</strong>, visit <a href="/docs">docs</a>.</p>
<p>Big inline art: <a href="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAkGBxISEhUQEhIWFhUVFRUVFRUVFRUVFRUWFxUXFhUYHSggGBolGxUVITEhJSkrLi4uFx8zODMtNygtLisBCgoKDg0OGxAQGy0lHyUtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLf/AABEIAAEAAQMBIgACEQEDEQH/xAAbAAACAwEBAQAAAAAAAAAAAAADBAECBQYAB//EABQBAQAAAAAAAAAAAAAAAAAAAAD/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIQAxAAAAGfAP/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAQUCf//EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQMBAT8Bf//EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQIBAT8Bf//Z">tiny-jpeg</a>.</p>
<button type="button" aria-label="Document options" id="doc-opts-visible"></button>
<button type="button" aria-label="Document options" id="doc-opts-hidden" style="display:none"></button>
<button type="button" aria-label="Upload file" id="upload-btn">Upload file</button>
<ul>
<li>Alpha</li>
<li>Beta</li>
+55
View File
@@ -14,7 +14,62 @@ def test_shared_js_files_exist_and_match_package():
assert (CORE / "prompt.js").is_file()
# package-side copies/symlinks must resolve to the same source
assert "function extractMarkdown" in _read_js("dom.js")
assert "function truncateDataUri" in _read_js("dom.js")
assert "function inventoryInterestingControls" in _read_js("dom.js")
assert "function buildAIPrompt" in _read_js("prompt.js")
assert "function truncateDataUri" in _read_js("prompt.js")
def test_build_ai_prompt_truncates_data_uris_in_network(page):
page.goto("about:blank")
meta = {
"url": "https://example.test/page",
"title": "T",
"ts": 1_700_000_000_000,
"selector": "body",
}
long_data = "data:image/jpeg;base64," + ("A" * 500)
store = {
"console": [],
"errors": [],
"network": [{
"type": "fetch", "method": "GET", "url": long_data,
"status": 200, "ts": 3, "duration": 12, "error": None,
}],
}
out = page.evaluate(_build_prompt_script(meta, "# Hello", store))
assert "bytes truncated" in out
assert ("A" * 100) not in out
def test_build_ai_prompt_includes_interesting_controls(page):
page.goto("about:blank")
meta = {"url": "https://example.test/page", "title": "T", "ts": 1, "selector": "body"}
controls = [
{
"tag": "button",
"role": "",
"ariaLabel": "Document options",
"text": "",
"visible": True,
"selector": "#vis",
},
{
"tag": "button",
"role": "",
"ariaLabel": "Document options",
"text": "",
"visible": False,
"selector": "#hid",
},
]
out = page.evaluate(
_build_prompt_script(meta, "# Hello", {"console": [], "errors": [], "network": []}, None, controls)
)
assert "## Interesting Controls" in out
assert "[visible]" in out
assert "### Hidden" in out
assert "Document options" in out
def test_build_ai_prompt_script_formats_errors_and_failures(page):
+15
View File
@@ -31,6 +31,8 @@ def test_extract_markdown_body_and_selector(page, fixture_url):
assert "**world**" in full["markdown"]
assert "[docs](/docs)" in full["markdown"]
assert "- Alpha" in full["markdown"]
assert "bytes truncated" in full["markdown"]
assert "4AAQSkZJRgABAQAAAQABAAD" not in full["markdown"]
scoped = session.extract_markdown("#main-content")
assert scoped["selector"] == "#main-content"
@@ -64,10 +66,23 @@ def test_build_ai_prompt_has_sections(page, fixture_url):
assert prompt.startswith("# Page Context")
assert "## Page Content" in prompt
assert "Hello **world**" in prompt
assert "## Interesting Controls" in prompt
assert "[visible]" in prompt
assert "Document options" in prompt
assert "JavaScript Errors" in prompt or "Console Errors" in prompt
assert "_Extracted by Context Extractor_" in prompt
def test_inventory_controls_visible_and_hidden(page, fixture_url):
session = ExtractorSession(page)
page.goto(fixture_url, wait_until="domcontentloaded")
controls = session.inventory_controls("#main-content")
docs = [c for c in controls if c.get("ariaLabel") == "Document options"]
assert len(docs) == 2
assert any(c.get("visible") for c in docs)
assert any(not c.get("visible") for c in docs)
def test_clear_store(page, fixture_url):
session = ExtractorSession(page)
page.goto(fixture_url, wait_until="domcontentloaded")
+63 -1
View File
@@ -62,6 +62,24 @@ function getSelector(el) {
// ---------------------------------------------------------------------------
const NEVER_RENDERED_TAGS = new Set(["script", "style", "noscript", "template", "svg", "iframe", "canvas"]);
/** Max length before a data: URL is collapsed in dumps (keeps prompts usable). */
const DATA_URI_SOFT_MAX = 120;
/**
* Collapse long data: URIs so markdown / network dumps don't blow up (e.g. Outline
* inlining base64 images). Non-data URLs are returned unchanged.
* Keep in sync with truncateDataUri in core/prompt.js.
*/
function truncateDataUri(url, softMax) {
if (typeof url !== "string" || !url.startsWith("data:")) return url || "";
const limit = softMax || DATA_URI_SOFT_MAX;
if (url.length <= limit) return url;
const comma = url.indexOf(",");
const header = comma >= 0 ? url.slice(0, Math.min(comma, 64)) : url.slice(0, 64);
const payloadLen = comma >= 0 ? url.length - comma - 1 : url.length;
return header + ",…[" + payloadLen + " bytes truncated]";
}
function isHidden(el) {
if (!el || el.nodeType !== 1) return false;
if (el.hidden) return true;
@@ -95,7 +113,7 @@ function extractMarkdown(node) {
const kids = walkInline(el);
switch (tag) {
case "a": {
const href = el.getAttribute("href") || "";
const href = truncateDataUri(el.getAttribute("href") || "");
return "[" + kids + "](" + href + ")";
}
case "strong":
@@ -184,3 +202,47 @@ function extractMarkdown(node) {
md = md.replace(/\n{3,}/g, "\n\n");
return md.trim();
}
// ---------------------------------------------------------------------------
// Interesting controls inventory (agent / SPA debugging)
// ---------------------------------------------------------------------------
const CONTROLS_CAP = 80;
const CONTROL_SELECTOR =
'button, [role="button"], [role="menuitem"], [role="option"], [aria-label]';
/**
* List interactive / labeled controls under root with visibility flags.
* Helps agents find hidden Radix twins ("Document options") without dumping
* the whole DOM.
*/
function inventoryInterestingControls(root, cap) {
const base = root && root.nodeType === 1 ? root : document.body;
if (!base || !base.querySelectorAll) return [];
const limit = cap || CONTROLS_CAP;
const nodes = base.querySelectorAll(CONTROL_SELECTOR);
const seen = new Set();
const out = [];
for (let i = 0; i < nodes.length && out.length < limit; i++) {
const el = nodes[i];
if (seen.has(el)) continue;
seen.add(el);
const tag = el.tagName ? el.tagName.toLowerCase() : "";
const role = (el.getAttribute && el.getAttribute("role")) || "";
const ariaLabel = (el.getAttribute && el.getAttribute("aria-label")) || "";
let text = "";
try {
text = (el.textContent || "").replace(/\s+/g, " ").trim().slice(0, 80);
} catch (_) {
text = "";
}
out.push({
tag: tag,
role: role,
ariaLabel: ariaLabel,
text: text,
visible: !isHidden(el),
selector: getSelector(el),
});
}
return out;
}
+52 -3
View File
@@ -16,8 +16,33 @@
// Pass a tighter selector (or a larger maxChars) instead of relying on this
// cap for real content; it exists as a safety net, not a summarizer.
const DEFAULT_MAX_MARKDOWN_CHARS = 20000;
const DATA_URI_SOFT_MAX = 120;
function buildAIPrompt(meta, markdown, store, maxChars) {
/**
* Collapse long data: URIs in network/prompt lines.
* Keep in sync with truncateDataUri in core/dom.js.
*/
function truncateDataUri(url, softMax) {
if (typeof url !== "string" || !url.startsWith("data:")) return url || "";
const limit = softMax || DATA_URI_SOFT_MAX;
if (url.length <= limit) return url;
const comma = url.indexOf(",");
const header = comma >= 0 ? url.slice(0, Math.min(comma, 64)) : url.slice(0, 64);
const payloadLen = comma >= 0 ? url.length - comma - 1 : url.length;
return header + ",…[" + payloadLen + " bytes truncated]";
}
function formatControlLine(c) {
const bits = [];
if (c.tag) bits.push(c.tag);
if (c.role) bits.push("role=" + c.role);
if (c.ariaLabel) bits.push('aria-label="' + c.ariaLabel + '"');
if (c.text) bits.push('"' + c.text + '"');
if (c.selector) bits.push("`" + c.selector + "`");
return (c.visible ? "[visible] " : "[hidden] ") + bits.join(" ");
}
function buildAIPrompt(meta, markdown, store, maxChars, controls) {
const limit = maxChars || DEFAULT_MAX_MARKDOWN_CHARS;
const parts = [];
@@ -42,6 +67,28 @@ function buildAIPrompt(meta, markdown, store, maxChars) {
parts.push("");
}
const controlList = Array.isArray(controls) ? controls : [];
if (controlList.length) {
const visible = controlList.filter((c) => c && c.visible);
const hiddenCount = controlList.length - visible.length;
parts.push("## Interesting Controls");
parts.push("");
parts.push(
`_Showing ${visible.length} visible` +
(hiddenCount ? `, ${hiddenCount} hidden` : "") +
` (of ${controlList.length} inventoried)._`,
);
parts.push("");
visible.forEach((c) => parts.push("- " + formatControlLine(c)));
if (hiddenCount) {
parts.push("");
parts.push("### Hidden");
parts.push("");
controlList.filter((c) => c && !c.visible).forEach((c) => parts.push("- " + formatControlLine(c)));
}
parts.push("");
}
const errors = (store && store.errors) || [];
if (errors.length) {
parts.push("## JavaScript Errors");
@@ -80,14 +127,16 @@ function buildAIPrompt(meta, markdown, store, maxChars) {
parts.push("");
failed.forEach((n) => {
const status = n.error ? "ERR" : n.status;
parts.push(`- ${n.method || "GET"} ${status} ${n.url}${n.duration ? " (" + n.duration + "ms)" : ""}${n.error ? " — " + n.error : ""}`);
const url = truncateDataUri(n.url || "");
parts.push(`- ${n.method || "GET"} ${status} ${url}${n.duration ? " (" + n.duration + "ms)" : ""}${n.error ? " — " + n.error : ""}`);
});
parts.push("");
} else if (network.length) {
parts.push("## Recent Requests");
parts.push("");
network.slice(-15).forEach((n) => {
parts.push(`- ${n.method || "GET"} ${n.status || ""} ${n.url}${n.duration ? " (" + n.duration + "ms)" : ""}`);
const url = truncateDataUri(n.url || "");
parts.push(`- ${n.method || "GET"} ${n.status || "—"} ${url}${n.duration ? " (" + n.duration + "ms)" : ""}`);
});
parts.push("");
}
+1 -1
View File
@@ -412,7 +412,7 @@ function wire() {
out.push("");
state.store.network.forEach((n) => {
const status = n.error ? "ERR" : (n.status || "—");
out.push(`- ${n.method || "GET"} ${status} ${n.url}${n.duration ? " (" + n.duration + "ms)" : ""}${n.error ? " — " + n.error : ""}`);
out.push(`- ${n.method || "GET"} ${status} ${truncateDataUri(n.url || "")}${n.duration ? " (" + n.duration + "ms)" : ""}${n.error ? " — " + n.error : ""}`);
});
out.push("");
out.push("---");