Add web.py: Dendrite integration for web perception
Provides fetch_page, take_screenshot, execute_js, search_web, and BrowserSession for multi-step interactions. Uses localhost for speed since Dendrite runs on the same box. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7afc878b3b
commit
9034536775
@ -5,3 +5,5 @@
|
||||
{"timestamp": "2026-03-19T19:56:48.576386", "claude_cli": {"status": "ok", "detail": "authenticated"}, "disk": {"status": "ok", "total": "915G", "used": "20G", "available": "849G", "use_pct": "3%"}, "api_server": {"status": "ok", "detail": "active"}, "ledger": {"calls_today": 4, "cost_today": 0.062}, "queue": {"processed": 0}, "health": "healthy"}
|
||||
{"timestamp": "2026-03-19T20:02:01.418535", "claude_cli": {"status": "ok", "detail": "authenticated"}, "disk": {"status": "ok", "total": "915G", "used": "20G", "available": "849G", "use_pct": "3%"}, "api_server": {"status": "ok", "detail": "active"}, "ledger": {"calls_today": 4, "cost_today": 0.062}, "queue": {"processed": 0}, "health": "healthy"}
|
||||
{"timestamp": "2026-03-19T20:03:15.583444", "claude_cli": {"status": "ok", "detail": "authenticated"}, "disk": {"status": "ok", "total": "915G", "used": "20G", "available": "849G", "use_pct": "3%"}, "api_server": {"status": "ok", "detail": "active"}, "ledger": {"calls_today": 4, "cost_today": 0.062}, "queue": {"processed": 0}, "skills": {"status": "clean", "changes": 0}, "health": "healthy"}
|
||||
{"timestamp": "2026-03-19T20:07:01.507403", "claude_cli": {"status": "ok", "detail": "authenticated"}, "disk": {"status": "ok", "total": "915G", "used": "20G", "available": "849G", "use_pct": "3%"}, "api_server": {"status": "ok", "detail": "active"}, "ledger": {"calls_today": 4, "cost_today": 0.062}, "queue": {"processed": 0}, "skills": {"status": "clean", "changes": 0}, "health": "healthy"}
|
||||
{"timestamp": "2026-03-19T20:12:09.328179", "claude_cli": {"status": "ok", "detail": "authenticated"}, "disk": {"status": "ok", "total": "915G", "used": "20G", "available": "849G", "use_pct": "3%"}, "api_server": {"status": "ok", "detail": "active"}, "ledger": {"calls_today": 4, "cost_today": 0.062}, "queue": {"processed": 0}, "skills": {"status": "clean", "changes": 0}, "health": "healthy"}
|
||||
|
||||
178
symbiont/web.py
Normal file
178
symbiont/web.py
Normal file
@ -0,0 +1,178 @@
|
||||
"""
|
||||
Web perception via Dendrite.
|
||||
|
||||
Thin wrapper around the Dendrite REST API running on cortex.
|
||||
Dendrite provides headless Chromium browsing — full JS execution,
|
||||
sessions, screenshots, and Readability content extraction.
|
||||
|
||||
Usage:
|
||||
from symbiont.web import fetch_page, take_screenshot, execute_js
|
||||
|
||||
page = fetch_page("https://example.com")
|
||||
print(page['content'])
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Dendrite runs on the same box, so use localhost for speed
|
||||
DENDRITE_URL = "http://localhost:3000"
|
||||
DENDRITE_KEY = "8dc5e8f7a02745ee8db90c94b2481fd9e1deeea1e2ce74420f54047859ea7edf"
|
||||
|
||||
|
||||
def _call(path: str, body: Optional[dict] = None, method: Optional[str] = None, timeout: int = 60) -> dict:
|
||||
"""Low-level Dendrite API call."""
|
||||
url = f"{DENDRITE_URL}{path}"
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode()
|
||||
req = urllib.request.Request(url, data=data, method=method or "POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
else:
|
||||
req = urllib.request.Request(url, method=method or "GET")
|
||||
req.add_header("X-API-Key", DENDRITE_KEY)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
err = json.loads(e.read())
|
||||
msg = err.get("error", e.reason)
|
||||
except Exception:
|
||||
msg = str(e.reason)
|
||||
logger.error(f"Dendrite {path} failed ({e.code}): {msg}")
|
||||
raise RuntimeError(f"Dendrite error {e.code}: {msg}")
|
||||
|
||||
|
||||
def _call_raw(path: str, body: dict, timeout: int = 60) -> bytes:
|
||||
"""Low-level Dendrite API call returning raw bytes (for screenshots)."""
|
||||
url = f"{DENDRITE_URL}{path}"
|
||||
data = json.dumps(body).encode()
|
||||
req = urllib.request.Request(url, data=data, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
req.add_header("X-API-Key", DENDRITE_KEY)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return resp.read()
|
||||
|
||||
|
||||
def health() -> dict:
|
||||
"""Check Dendrite health. No auth required."""
|
||||
return _call("/health")
|
||||
|
||||
|
||||
def fetch_page(
|
||||
url: str,
|
||||
format: str = "markdown",
|
||||
extract_main: bool = True,
|
||||
wait_for: str = "domcontentloaded",
|
||||
timeout_ms: int = 30000,
|
||||
) -> dict:
|
||||
"""
|
||||
Fetch a URL and return structured content.
|
||||
|
||||
Returns: {url, title, content, format}
|
||||
"""
|
||||
return _call("/fetch", {
|
||||
"url": url,
|
||||
"format": format,
|
||||
"extractMain": extract_main,
|
||||
"waitFor": wait_for,
|
||||
"timeout": timeout_ms,
|
||||
})
|
||||
|
||||
|
||||
def take_screenshot(
|
||||
url: str,
|
||||
full_page: bool = True,
|
||||
selector: Optional[str] = None,
|
||||
) -> bytes:
|
||||
"""Take a screenshot of a URL. Returns PNG bytes."""
|
||||
body = {
|
||||
"url": url,
|
||||
"fullPage": full_page,
|
||||
"format": "png",
|
||||
"waitFor": "networkidle",
|
||||
}
|
||||
if selector:
|
||||
body["selector"] = selector
|
||||
return _call_raw("/screenshot", body)
|
||||
|
||||
|
||||
def execute_js(url: str, script: str) -> dict:
|
||||
"""Execute JavaScript in a page context. Returns {result, url, title}."""
|
||||
return _call("/execute", {"url": url, "script": script})
|
||||
|
||||
|
||||
def search_web(query: str, num_results: int = 5) -> list[dict]:
|
||||
"""
|
||||
Search the web using Dendrite.
|
||||
Fetches Google search results and extracts links.
|
||||
Returns list of {title, url, snippet}.
|
||||
"""
|
||||
import urllib.parse
|
||||
search_url = f"https://www.google.com/search?q={urllib.parse.quote(query)}&num={num_results}"
|
||||
|
||||
result = _call("/execute", {
|
||||
"url": search_url,
|
||||
"script": """
|
||||
return Array.from(document.querySelectorAll('div.g')).map(el => {
|
||||
const a = el.querySelector('a');
|
||||
const title = el.querySelector('h3');
|
||||
const snippet = el.querySelector('.VwiC3b');
|
||||
return {
|
||||
title: title ? title.textContent : '',
|
||||
url: a ? a.href : '',
|
||||
snippet: snippet ? snippet.textContent : ''
|
||||
};
|
||||
}).filter(r => r.url && r.title);
|
||||
"""
|
||||
})
|
||||
|
||||
return result.get("result", [])
|
||||
|
||||
|
||||
# Session management for multi-step interactions
|
||||
class BrowserSession:
|
||||
"""Persistent browser session for multi-step interactions."""
|
||||
|
||||
def __init__(self, locale="en-US", timezone="America/Chicago"):
|
||||
result = _call("/session", {"locale": locale, "timezone": timezone})
|
||||
self.id = result["id"]
|
||||
logger.info(f"Browser session created: {self.id}")
|
||||
|
||||
def fetch(self, url: Optional[str] = None, **kwargs) -> dict:
|
||||
body = {"sessionId": self.id}
|
||||
if url:
|
||||
body["url"] = url
|
||||
body.update(kwargs)
|
||||
return _call("/fetch", body)
|
||||
|
||||
def click(self, selector: str, timeout: int = 5000) -> dict:
|
||||
return _call("/interact", {
|
||||
"sessionId": self.id, "action": "click",
|
||||
"selector": selector, "timeout": timeout,
|
||||
})
|
||||
|
||||
def type(self, selector: str, value: str, submit: bool = False) -> dict:
|
||||
return _call("/interact", {
|
||||
"sessionId": self.id, "action": "type",
|
||||
"selector": selector, "value": value, "submit": submit,
|
||||
})
|
||||
|
||||
def screenshot(self) -> bytes:
|
||||
return _call_raw("/screenshot", {"sessionId": self.id, "fullPage": True})
|
||||
|
||||
def close(self):
|
||||
_call(f"/session/{self.id}", method="DELETE")
|
||||
logger.info(f"Browser session closed: {self.id}")
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.close()
|
||||
Loading…
Reference in New Issue
Block a user