edit agent.py

This commit is contained in:
a2a-cloud
2026-06-08 22:41:15 +00:00
parent 69992a9fa0
commit 69cc656afb

391
agent.py
View File

@@ -5,6 +5,12 @@ selector mapping, pagination limits, browser options, robots.txt behavior, and
output format. It never attempts to bypass CAPTCHAs, anti-bot challenges, output format. It never attempts to bypass CAPTCHAs, anti-bot challenges,
access controls, paywalls, login gates, robots.txt restrictions, or terms of access controls, paywalls, login gates, robots.txt restrictions, or terms of
service restrictions. service restrictions.
This module now also exposes an LLM-powered iterative skill that can plan and
refine safe scraping runs using the base browser skill as an execution step.
It uses a platform-provided LLM grant (ctx.llm) with strict safety rails and
never attempts to evade protections. The deep agent proposes selector/URL
adjustments limited to the same origin and stops on any challenge.
""" """
from __future__ import annotations from __future__ import annotations
@@ -14,6 +20,7 @@ import textwrap
import urllib.parse import urllib.parse
import urllib.request import urllib.request
import urllib.robotparser import urllib.robotparser
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Literal from typing import Any, Literal
@@ -35,6 +42,7 @@ from a2a_pack import (
OUTPUT_DIR = "outputs/seleniumbase-website-scraper" OUTPUT_DIR = "outputs/seleniumbase-website-scraper"
RUNTIME_SKILLS_DIR = "seleniumbase-website-scraper/.deepagents/skills/" RUNTIME_SKILLS_DIR = "seleniumbase-website-scraper/.deepagents/skills/"
DEEPAGENTS_RECURSION_LIMIT = 400
class SeleniumbaseWebsiteScraperConfig(BaseModel): class SeleniumbaseWebsiteScraperConfig(BaseModel):
@@ -49,7 +57,7 @@ class SeleniumbaseWebsiteScraper(A2AAgent[SeleniumbaseWebsiteScraperConfig, NoAu
"automation with robots.txt checks, same-domain pagination safeguards, " "automation with robots.txt checks, same-domain pagination safeguards, "
"screenshots, HTML capture, JSON/CSV output, retries, and polite rate limiting." "screenshots, HTML capture, JSON/CSV output, retries, and polite rate limiting."
) )
version = "1.0.3" version = "1.1.0"
config_model = SeleniumbaseWebsiteScraperConfig config_model = SeleniumbaseWebsiteScraperConfig
auth_model = NoAuth auth_model = NoAuth
@@ -59,13 +67,13 @@ class SeleniumbaseWebsiteScraper(A2AAgent[SeleniumbaseWebsiteScraperConfig, NoAu
price_per_call_usd=0.0, price_per_call_usd=0.0,
caller_pays_llm=True, caller_pays_llm=True,
notes=( notes=(
"Browser scraping is deterministic; any future LLM use must read the " "Browser scraping is deterministic; any LLM use reads the caller-provided ctx.llm credential. "
"caller-provided ctx.llm credential. The caller pays any forwarded LLM cost." "The caller pays any forwarded LLM cost."
), ),
) )
resources = Resources(cpu="2", memory="2Gi", max_runtime_seconds=900) resources = Resources(cpu="2", memory="2Gi", max_runtime_seconds=900)
egress = EgressPolicy(deny_internet_by_default=False) egress = EgressPolicy(deny_internet_by_default=False)
tools_used = ("seleniumbase", "selenium", "chromium", "browser-automation") tools_used = ("seleniumbase", "selenium", "chromium", "browser-automation", "deepagents", "langchain")
workspace_access = WorkspaceAccess.dynamic( workspace_access = WorkspaceAccess.dynamic(
max_files=256, max_files=256,
allowed_modes=(WorkspaceMode.READ_ONLY, WorkspaceMode.READ_WRITE_OVERLAY), allowed_modes=(WorkspaceMode.READ_ONLY, WorkspaceMode.READ_WRITE_OVERLAY),
@@ -227,6 +235,293 @@ class SeleniumbaseWebsiteScraper(A2AAgent[SeleniumbaseWebsiteScraperConfig, NoAu
"robots": {"checked": bool(respect_robots)}, "robots": {"checked": bool(respect_robots)},
} }
@skill(
description=(
"Iteratively plan and run compliant scraping passes until a goal is met, "
"using a platform-provided LLM with strict safety rails. The deep agent may "
"refine CSS selectors and pagination hints within the same origin only, and will stop "
"on any CAPTCHA/anti-bot/login/paywall/access-control indicator."
),
timeout_seconds=900,
cost_class="llm-medium",
grant_outputs_prefix=OUTPUT_DIR,
grant_write_prefixes=(OUTPUT_DIR,),
grant_run_timeout_seconds=900,
)
async def iterate_scrape_goal(
self,
ctx: RunContext[NoAuth],
goal: str,
url: str,
selectors: dict[str, str] | None = None,
min_items: int = 10,
max_iterations: int = 5,
per_pass_max_pages: int = 1,
wait_seconds: float = 3.0,
output_format: Literal["json", "csv"] = "json",
headless: bool = True,
user_agent: str = "",
respect_robots: bool = True,
rate_limit_seconds: float = 1.0,
screenshot: bool = False,
allow_selector_updates: bool = True,
) -> dict[str, Any]:
"""Use an LLM planner to refine selectors/pagination until the goal is met or a stop condition occurs."""
start_url = str(url or "").strip()
if not start_url:
return {"status": "invalid_input", "items": [], "saved_paths": [], "warnings": ["url is required"], "screenshots": []}
# Initial validation (if selectors are provided)
if selectors:
v = _validate_inputs(
url=start_url,
selectors=selectors,
max_pages=per_pass_max_pages,
max_pages_limit=self.config.max_pages_limit,
wait_seconds=wait_seconds,
output_format=output_format,
user_agent=user_agent,
rate_limit_seconds=rate_limit_seconds,
)
if v["errors"]:
return {"status": "invalid_input", "items": [], "saved_paths": [], "warnings": v["errors"], "screenshots": []}
creds = ctx.llm
same_origin_safeguard = _same_origin
await ctx.emit_progress("starting deep-agent iterative scrape planner")
# Planner system prompt with strict safety policy
system_prompt = (
"You are a cautious scraping planner for a compliant browser scraper. "
"Your actions are limited to proposing: (a) CSS selectors to extract fields; "
"(b) a same-origin URL (relative or absolute) for the next pass; (c) a reason to stop.\n"
"Hard safety rules:\n"
"- Never suggest evading CAPTCHAs, anti-bot, or access controls.\n"
"- Never suggest login, credential use, or paywall bypass.\n"
"- Always respect robots.txt and same-origin restriction with the initial URL's scheme+host.\n"
"- Do NOT use stealth, proxies, or automation-evasion.\n"
"Output only compact JSON with keys: action ('scrape'|'stop'), reason, selectors (object, optional), url (string, optional), confidence (0..1)."
)
decisions: list[dict[str, Any]] = []
all_items: list[dict[str, Any]] = []
all_pages: list[str] = []
all_htmls: list[str] = []
all_screens: list[str] = []
warnings: list[str] = []
saved_paths: list[str] = []
if not creds.api_key:
# Deterministic fallback: run one pass with provided selectors (or trivial title extraction)
await ctx.emit_progress("LLM creds unavailable; running deterministic single-pass fallback")
sel = selectors or {"title": "title"}
one = await self.scrape_website(
ctx=ctx,
url=start_url,
selectors=sel,
max_pages=per_pass_max_pages,
wait_seconds=wait_seconds,
output_format=output_format,
headless=headless,
user_agent=user_agent,
respect_robots=respect_robots,
rate_limit_seconds=rate_limit_seconds,
screenshot=screenshot,
)
return {
"status": one.get("status") or "ok",
"items": one.get("items") or [],
"saved_paths": one.get("saved_paths") or [],
"warnings": one.get("warnings") or [],
"screenshots": one.get("screenshots") or [],
"pages_visited": one.get("pages_visited") or [],
"html_captures": one.get("html_captures") or [],
"iterations": 1,
"decisions": [],
}
# Build deep agent planner
planner = self._build_deep_agent(ctx=ctx, creds=creds, system_prompt=system_prompt)
current_url = start_url
current_selectors = selectors or {"title": "title"}
for iteration in range(1, int(max_iterations) + 1):
# Execute one browser pass
run = await self.scrape_website(
ctx=ctx,
url=current_url,
selectors=current_selectors,
max_pages=per_pass_max_pages,
wait_seconds=wait_seconds,
output_format=output_format,
headless=headless,
user_agent=user_agent,
respect_robots=respect_robots,
rate_limit_seconds=rate_limit_seconds,
screenshot=screenshot,
)
status = str(run.get("status") or "ok")
items = list(run.get("items") or [])
all_items.extend(items)
all_pages.extend(run.get("pages_visited") or [])
all_htmls.extend(run.get("html_captures") or [])
all_screens.extend(run.get("screenshots") or [])
warnings.extend(run.get("warnings") or [])
saved_paths.extend(run.get("saved_paths") or [])
if status in {"challenge_detected", "manual_intervention_required", "blocked", "blocked_by_robots_txt", "browser_runtime_unavailable"}:
warnings.append(f"iteration {iteration} stopped due to status={status}")
break
if len(all_items) >= int(min_items):
decisions.append({"iteration": iteration, "action": "stop", "reason": "min_items reached", "confidence": 1.0})
break
# Ask planner what to do next
summary = {
"iteration": iteration,
"goal": str(goal or "").strip(),
"start_url": start_url,
"current_url": current_url,
"same_origin": same_origin_safeguard(start_url, current_url),
"items_this_pass": len(items),
"items_total": len(all_items),
"example_item": items[0] if items else None,
"current_selectors": current_selectors,
"status": status,
}
prompt = json.dumps(
{
"instruction": "Propose next step using only same-origin URL and safe CSS selectors, or stop.",
"schema": {
"action": "scrape|stop",
"reason": "string",
"selectors": {"field": "CSS"},
"url": "relative or absolute same-origin URL",
"confidence": 0.0,
},
"context": summary,
},
ensure_ascii=False,
)
state = await planner.ainvoke(
{"messages": [{"role": "user", "content": prompt}]},
config={"recursion_limit": DEEPAGENTS_RECURSION_LIMIT},
)
raw = _last_message_text(state)
try:
decision = _extract_json_object(raw)
except Exception as exc: # noqa: BLE001
warnings.append("planner response was not JSON; stopping: " + _friendly_error(exc))
break
action = str(decision.get("action") or "").strip().lower()
reason = str(decision.get("reason") or "").strip()
next_url = str(decision.get("url") or current_url).strip() or current_url
proposed_selectors = decision.get("selectors") or {}
confidence = float(decision.get("confidence") or 0.0)
# Enforce safety: same-origin only, and only allow selector updates if permitted
if not same_origin_safeguard(start_url, next_url):
decisions.append({
"iteration": iteration,
"action": "stop",
"reason": "planner suggested cross-origin URL; stopping for safety",
"confidence": confidence,
})
break
if action == "stop":
decisions.append({"iteration": iteration, "action": "stop", "reason": reason, "confidence": confidence})
break
if action != "scrape":
decisions.append({
"iteration": iteration,
"action": "stop",
"reason": "planner returned unknown action",
"confidence": confidence,
})
break
# Adopt next plan
current_url = next_url
if allow_selector_updates and isinstance(proposed_selectors, dict) and proposed_selectors:
# ensure keys/values are strings
safe_map: dict[str, str] = {}
for k, v in proposed_selectors.items():
k2 = str(k).strip()
v2 = str(v).strip()
if k2 and v2:
safe_map[k2] = v2
if safe_map:
current_selectors = safe_map
decisions.append({
"iteration": iteration,
"action": "scrape",
"reason": reason or "refining selectors/pagination",
"confidence": confidence,
"url": current_url,
"selectors": current_selectors,
})
# Deduplicate items by URL+page_index if present
seen_keys: set[tuple[Any, Any]] = set()
unique_items: list[dict[str, Any]] = []
for it in all_items:
key = (it.get("url"), it.get("page_index"))
if key in seen_keys:
continue
seen_keys.add(key)
unique_items.append(it)
# Persist a small run manifest for traceability
manifest_path = await _write_iter_manifest(ctx, {
"goal": goal,
"start_url": start_url,
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"iterations": len(decisions) or min(int(max_iterations), 1),
"decisions": decisions,
"items_total": len(unique_items),
})
if manifest_path:
saved_paths.append(manifest_path)
return {
"status": "ok" if unique_items else "no_items",
"items": unique_items,
"saved_paths": saved_paths,
"warnings": warnings,
"screenshots": all_screens,
"pages_visited": all_pages,
"html_captures": all_htmls,
"iterations": len(decisions) or 0,
"decisions": decisions,
}
def _build_deep_agent(self, *, ctx: RunContext[NoAuth], creds: Any, system_prompt: str):
from a2a_pack.deepagents import create_a2a_deep_agent
from langchain.agents.middleware import wrap_model_call
@wrap_model_call
async def safe_model_call(request: Any, handler: Any) -> Any:
messages = request.state.get("messages", [])
print(
"[seleniumbase-website-scraper] planner model_call "
f"model={creds.model} source={creds.source} messages={len(messages)}"
)
return await handler(request)
backend = ctx.workspace_backend()
skill_sources = _seed_runtime_skills(backend, ctx)
return create_a2a_deep_agent(
ctx,
creds=creds,
backend=backend,
skills=skill_sources or None,
middleware=[safe_model_call],
system_prompt=system_prompt,
)
def _validate_inputs( def _validate_inputs(
*, *,
@@ -556,6 +851,11 @@ def _parse_last_json(stdout: str) -> dict[str, Any] | None:
return None return None
def _same_origin(a: str, b: str) -> bool:
pa, pb = urllib.parse.urlparse(a), urllib.parse.urlparse(b)
return pa.scheme == pb.scheme and pa.netloc.lower() == pb.netloc.lower()
async def _emit_workspace_artifact_if_available(ctx: RunContext[Any], path: str) -> None: async def _emit_workspace_artifact_if_available(ctx: RunContext[Any], path: str) -> None:
try: try:
reader = getattr(ctx.workspace, "read_bytes", None) reader = getattr(ctx.workspace, "read_bytes", None)
@@ -575,3 +875,86 @@ async def _emit_workspace_artifact_if_available(ctx: RunContext[Any], path: str)
await ctx.emit_artifact(ref) await ctx.emit_artifact(ref)
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
return return
def _runtime_skills_root(ctx: RunContext[Any]) -> str:
workspace = getattr(ctx, "_workspace", None)
prefixes = tuple(getattr(workspace, "write_prefixes", ()) or ())
if not prefixes:
outputs_prefix = getattr(workspace, "outputs_prefix", None)
prefixes = (outputs_prefix or "outputs/",)
prefix = str(prefixes[0]).strip("/")
return f"/{prefix}/{RUNTIME_SKILLS_DIR}" if prefix else f"/{RUNTIME_SKILLS_DIR}"
def _seed_runtime_skills(backend: Any, ctx: RunContext[Any]) -> list[str]:
# Seed any packaged runtime skills if present
root = Path(__file__).parent / "skills"
if not root.exists():
return []
runtime_root = _runtime_skills_root(ctx)
uploads: list[tuple[str, bytes]] = []
for path in root.rglob("*"):
if path.is_file():
rel = path.relative_to(root).as_posix()
uploads.append((runtime_root + rel, path.read_bytes()))
if uploads:
backend.upload_files(uploads)
return [runtime_root]
return []
def _extract_json_object(raw: str) -> dict[str, Any]:
text = str(raw or "").strip()
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text)
try:
parsed = json.loads(text)
except json.JSONDecodeError:
start = text.find("{")
end = text.rfind("}")
if start < 0 or end <= start:
raise
parsed = json.loads(text[start : end + 1])
if not isinstance(parsed, dict):
raise ValueError("planner result was not a JSON object")
return parsed
def _last_message_text(state: dict[str, Any]) -> str:
messages = state.get("messages") or []
if not messages:
return json.dumps(state, default=str)
content = getattr(messages[-1], "content", None)
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for item in content:
if isinstance(item, dict):
text = item.get("text") or item.get("content")
if text:
parts.append(str(text))
elif item:
parts.append(str(item))
return "\n".join(parts)
return str(content or messages[-1])
def _friendly_error(exc: Exception) -> str:
message = str(exc) or exc.__class__.__name__
message = re.sub(r"(?i)(password|passwd|pwd|secret|token|key)=\S+", r"\1=<redacted>", message)
message = re.sub(r"(?i)(auth(?:entication)? failed).*", r"\1", message)
return message[:400]
async def _write_iter_manifest(ctx: RunContext[Any], manifest: dict[str, Any]) -> str | "":
try:
out_dir = OUTPUT_DIR
name = f"iter-plan-" + datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + ".json"
path = f"{out_dir}/{name}"
await ctx.workspace.write(path, json.dumps(manifest, ensure_ascii=False, indent=2).encode("utf-8"))
return path
except Exception: # noqa: BLE001
return ""