Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
162 lines
6.7 KiB
Python
162 lines
6.7 KiB
Python
"""Real Playwright Computer Use surface for registration forms."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from typing import List, Optional
|
|
|
|
from models import FieldSpec
|
|
|
|
|
|
class RecoverableFillError(RuntimeError):
|
|
"""A page-specific field failure that may be reported without aborting the call."""
|
|
|
|
|
|
class RegistrationBrowser:
|
|
"""Owns a real Chromium browser/context/page and exposes form operations.
|
|
|
|
The selector assigned during discovery is generated from the element itself and
|
|
remains internal. Values are never included in screenshots or trace files.
|
|
"""
|
|
|
|
def __init__(self, url: str, *, headless: bool = False, submit: bool = False):
|
|
self.url = url
|
|
self.headless = headless
|
|
self.submit_enabled = submit
|
|
self._playwright = None
|
|
self.browser = None
|
|
self.context = None
|
|
self.page = None
|
|
self.closed = False
|
|
|
|
async def open(self) -> None:
|
|
from playwright.async_api import async_playwright
|
|
|
|
self._playwright = await async_playwright().start()
|
|
self.browser = await self._playwright.chromium.launch(headless=self.headless)
|
|
self.context = await self.browser.new_context()
|
|
self.page = await self.context.new_page()
|
|
await self.page.goto(self.url, wait_until="domcontentloaded", timeout=60_000)
|
|
|
|
async def discover_fields(self) -> List[FieldSpec]:
|
|
if self.page is None:
|
|
raise RuntimeError("browser is not open")
|
|
raw = await self.page.locator(
|
|
"input:not([type=hidden]):not([disabled]), select:not([disabled]), textarea:not([disabled])"
|
|
).evaluate_all(
|
|
"""els => els.map((el, i) => {
|
|
const id = el.id || '';
|
|
const explicit = id ? document.querySelector(`label[for="${CSS.escape(id)}"]`) : null;
|
|
const wrapping = el.closest('label');
|
|
const aria = el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') || '';
|
|
const label = (explicit?.innerText || wrapping?.innerText || aria || el.placeholder || el.name || id || `field_${i}`).trim();
|
|
const selector = ((el.type === 'radio' || el.type === 'checkbox') && el.name) ?
|
|
`input[name="${CSS.escape(el.name)}"]` : id ? `#${CSS.escape(id)}` :
|
|
(el.name ? `${el.tagName.toLowerCase()}[name="${CSS.escape(el.name)}"]` :
|
|
`${el.tagName.toLowerCase()}:nth-of-type(${i + 1})`);
|
|
return {
|
|
name: el.name || id || `field_${i}`,
|
|
label,
|
|
input_type: el.tagName === 'SELECT' ? 'select' : (el.type || el.tagName.toLowerCase()),
|
|
required: !!el.required || el.getAttribute('aria-required') === 'true',
|
|
selector,
|
|
format_hint: el.title || el.placeholder || '',
|
|
pattern: el.pattern || '',
|
|
options: el.tagName === 'SELECT' ? [...el.options].map(o => o.text.trim()).filter(Boolean) :
|
|
((el.type === 'radio' || el.type === 'checkbox') ? [el.value, label].filter(Boolean) : [])
|
|
};
|
|
})"""
|
|
)
|
|
# Radio buttons with the same name are one logical field.
|
|
fields: List[FieldSpec] = []
|
|
seen: set[str] = set()
|
|
for item in raw:
|
|
spec = FieldSpec.from_dict(item)
|
|
if spec.name in seen:
|
|
existing = next(f for f in fields if f.name == spec.name)
|
|
existing.options = list(dict.fromkeys(existing.options + spec.options))
|
|
continue
|
|
seen.add(spec.name)
|
|
fields.append(spec)
|
|
return fields
|
|
|
|
@property
|
|
async def title(self) -> str:
|
|
return await self.page.title() if self.page else ""
|
|
|
|
async def fill(self, field: FieldSpec, value: str) -> None:
|
|
if self.page is None:
|
|
raise RuntimeError("browser is not open")
|
|
from playwright.async_api import Error as PlaywrightError
|
|
|
|
try:
|
|
locator = self.page.locator(field.selector).first
|
|
await locator.scroll_into_view_if_needed()
|
|
kind = field.input_type.lower()
|
|
if kind == "select":
|
|
try:
|
|
await locator.select_option(label=value)
|
|
except PlaywrightError:
|
|
await locator.select_option(value=value)
|
|
elif kind in {"checkbox", "radio"}:
|
|
group = self.page.locator(field.selector)
|
|
wanted = value.strip().casefold()
|
|
chosen = None
|
|
for i in range(await group.count()):
|
|
item = group.nth(i)
|
|
raw_value = (await item.get_attribute("value") or "").strip()
|
|
item_id = await item.get_attribute("id")
|
|
label = ""
|
|
if item_id:
|
|
label_node = self.page.locator(f'label[for="{item_id}"]').first
|
|
if await label_node.count():
|
|
label = (await label_node.inner_text()).strip()
|
|
if wanted in {raw_value.casefold(), label.casefold()}:
|
|
chosen = item
|
|
break
|
|
if chosen is None:
|
|
raise RecoverableFillError(
|
|
f"{field.name} has no matching page option"
|
|
)
|
|
await chosen.check()
|
|
else:
|
|
await locator.fill(value)
|
|
except RecoverableFillError:
|
|
raise
|
|
except PlaywrightError as exc:
|
|
# Do not include the value or raw Playwright text: either may contain
|
|
# user-supplied form data that must not enter logs or traces.
|
|
raise RecoverableFillError(
|
|
f"browser could not fill {field.name}: {type(exc).__name__}"
|
|
) from exc
|
|
|
|
async def submit(self) -> bool:
|
|
if not self.submit_enabled:
|
|
return False
|
|
if self.page is None:
|
|
raise RuntimeError("browser is not open")
|
|
button = self.page.locator(
|
|
'button[type="submit"], input[type="submit"], button:has-text("注册"), button:has-text("Register")'
|
|
).first
|
|
if await button.count() == 0:
|
|
raise RuntimeError("页面没有可识别的提交按钮")
|
|
await button.click()
|
|
await asyncio.sleep(1)
|
|
return True
|
|
|
|
async def close(self) -> None:
|
|
if self.context:
|
|
await self.context.close()
|
|
if self.browser:
|
|
await self.browser.close()
|
|
if self._playwright:
|
|
await self._playwright.stop()
|
|
self.closed = True
|
|
|
|
async def __aenter__(self):
|
|
await self.open()
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
await self.close()
|