Add controls inventory and truncate long data: URIs in dumps.
Agents hitting Outline-style SPAs need visible vs hidden control lists, and inlined base64 images were blowing up AI prompts and network copy.
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user