988 lines
42 KiB
Python
988 lines
42 KiB
Python
"""Private compliant website scraper using SeleniumBase/browser automation.
|
|
|
|
The public skill is intentionally narrow: it accepts one starting URL, a CSS
|
|
selector mapping, pagination limits, browser options, robots.txt behavior, and
|
|
output format. It never attempts to bypass CAPTCHAs, anti-bot challenges,
|
|
access controls, paywalls, login gates, robots.txt restrictions, or terms of
|
|
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
|
|
|
|
import json
|
|
import re
|
|
import textwrap
|
|
import urllib.parse
|
|
import urllib.request
|
|
import urllib.robotparser
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Literal
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
from a2a_pack import (
|
|
A2AAgent,
|
|
EgressPolicy,
|
|
LLMProvisioning,
|
|
NoAuth,
|
|
Pricing,
|
|
Resources,
|
|
RunContext,
|
|
WorkspaceAccess,
|
|
WorkspaceMode,
|
|
skill,
|
|
)
|
|
|
|
|
|
OUTPUT_DIR = "outputs/seleniumbase-website-scraper"
|
|
RUNTIME_SKILLS_DIR = "seleniumbase-website-scraper/.deepagents/skills/"
|
|
DEEPAGENTS_RECURSION_LIMIT = 500
|
|
|
|
|
|
class SeleniumbaseWebsiteScraperConfig(BaseModel):
|
|
max_pages_limit: int = Field(default=50, ge=1, le=200)
|
|
default_user_agent: str = "A2A seleniumbase-website-scraper/1.0 (+authorized public/user-owned scraping only)"
|
|
|
|
|
|
class SeleniumbaseWebsiteScraper(A2AAgent[SeleniumbaseWebsiteScraperConfig, NoAuth]):
|
|
name = "seleniumbase-website-scraper"
|
|
description = (
|
|
"Private compliant website scraping agent using SeleniumBase/browser "
|
|
"automation with robots.txt checks, same-domain pagination safeguards, "
|
|
"screenshots, HTML capture, JSON/CSV output, retries, and polite rate limiting."
|
|
)
|
|
version = "1.1.0"
|
|
|
|
config_model = SeleniumbaseWebsiteScraperConfig
|
|
auth_model = NoAuth
|
|
|
|
llm_provisioning = LLMProvisioning.PLATFORM
|
|
pricing = Pricing(
|
|
price_per_call_usd=0.0,
|
|
caller_pays_llm=True,
|
|
notes=(
|
|
"Browser scraping is deterministic; any LLM use reads the caller-provided ctx.llm credential. "
|
|
"The caller pays any forwarded LLM cost."
|
|
),
|
|
)
|
|
resources = Resources(cpu="2", memory="2Gi", max_runtime_seconds=900)
|
|
egress = EgressPolicy(deny_internet_by_default=False)
|
|
tools_used = ("seleniumbase", "selenium", "chromium", "browser-automation", "deepagents", "langchain")
|
|
workspace_access = WorkspaceAccess.dynamic(
|
|
max_files=256,
|
|
allowed_modes=(WorkspaceMode.READ_ONLY, WorkspaceMode.READ_WRITE_OVERLAY),
|
|
require_reason=False,
|
|
max_total_size_bytes=250 * 1024 * 1024,
|
|
)
|
|
|
|
@skill(
|
|
description=(
|
|
"Authorized website scraping with SeleniumBase/browser automation, "
|
|
"robots.txt checks, selector extraction, same-domain pagination, "
|
|
"screenshots, and JSON/CSV output. Stops on CAPTCHA, anti-bot, login, "
|
|
"paywall, or access-control challenges."
|
|
),
|
|
timeout_seconds=900,
|
|
cost_class="browser-heavy",
|
|
grant_outputs_prefix=OUTPUT_DIR,
|
|
grant_write_prefixes=(OUTPUT_DIR,),
|
|
grant_run_timeout_seconds=900,
|
|
)
|
|
async def scrape_website(
|
|
self,
|
|
ctx: RunContext[NoAuth],
|
|
url: str,
|
|
selectors: dict[str, str],
|
|
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,
|
|
) -> dict[str, Any]:
|
|
"""Scrape authorized public/user-owned pages and save structured output."""
|
|
creds = ctx.llm # Read platform-forwarded LLM metadata; this skill does not construct a model.
|
|
await ctx.emit_progress(f"starting compliant scrape; llm credential source={creds.source}")
|
|
|
|
validation = _validate_inputs(
|
|
url=url,
|
|
selectors=selectors,
|
|
max_pages=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 validation["errors"]:
|
|
return {
|
|
"status": "invalid_input",
|
|
"items": [],
|
|
"saved_paths": [],
|
|
"warnings": validation["errors"],
|
|
"screenshots": [],
|
|
}
|
|
|
|
start_url = validation["url"]
|
|
effective_user_agent = user_agent.strip() or self.config.default_user_agent
|
|
warnings: list[str] = list(validation["warnings"])
|
|
|
|
if respect_robots:
|
|
robots = _robots_allowed(start_url, effective_user_agent)
|
|
warnings.extend(robots.get("warnings", []))
|
|
if not robots.get("allowed", False):
|
|
return {
|
|
"status": "blocked_by_robots_txt",
|
|
"items": [],
|
|
"saved_paths": [],
|
|
"warnings": warnings + ["robots.txt disallows fetching the requested URL for this user agent."],
|
|
"screenshots": [],
|
|
"robots": robots,
|
|
}
|
|
|
|
script_payload = {
|
|
"url": start_url,
|
|
"selectors": selectors,
|
|
"max_pages": max_pages,
|
|
"wait_seconds": wait_seconds,
|
|
"output_format": output_format,
|
|
"headless": headless,
|
|
"user_agent": effective_user_agent,
|
|
"respect_robots": respect_robots,
|
|
"rate_limit_seconds": rate_limit_seconds,
|
|
"screenshot": screenshot,
|
|
"output_dir": f"/workspace/{OUTPUT_DIR}",
|
|
}
|
|
|
|
script = _render_scraper_script(script_payload)
|
|
try:
|
|
result = await ctx.workspace_python(
|
|
script,
|
|
image="seleniumbase/seleniumbase:latest",
|
|
timeout_seconds=900,
|
|
memory_mib=2048,
|
|
cpus=2,
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
await ctx.emit_error(str(exc), code="browser_runtime_unavailable")
|
|
return {
|
|
"status": "browser_runtime_unavailable",
|
|
"items": [],
|
|
"saved_paths": [],
|
|
"warnings": warnings
|
|
+ [
|
|
"Browser sandbox could not be started. The platform must allow the SeleniumBase image, "
|
|
"or rerun where browser automation is available.",
|
|
f"runtime error: {type(exc).__name__}: {exc}",
|
|
],
|
|
"screenshots": [],
|
|
}
|
|
|
|
stdout = (result.stdout or "").strip()
|
|
stderr = (result.stderr or "").strip()
|
|
parsed = _parse_last_json(stdout)
|
|
if not parsed:
|
|
return {
|
|
"status": "error",
|
|
"items": [],
|
|
"saved_paths": [],
|
|
"warnings": warnings
|
|
+ [
|
|
"Browser script did not return a parseable JSON result.",
|
|
f"returncode={getattr(result, 'exit_code', None)}",
|
|
f"stdout_tail={stdout[-2000:]}",
|
|
f"stderr_tail={stderr[-2000:]}",
|
|
],
|
|
"screenshots": [],
|
|
}
|
|
|
|
parsed_warnings = list(parsed.get("warnings") or [])
|
|
if getattr(result, "exit_code", 0) not in (0, None):
|
|
parsed_warnings.append(
|
|
f"browser command returned nonzero exit code {getattr(result, 'exit_code', None)}; usable emitted files were preserved when present"
|
|
)
|
|
if stderr:
|
|
parsed_warnings.append(f"stderr_tail={stderr[-2000:]}")
|
|
|
|
saved_paths = [str(path).replace("/workspace/", "") for path in (parsed.get("saved_paths") or [])]
|
|
screenshot_paths = [str(path).replace("/workspace/", "") for path in (parsed.get("screenshots") or [])]
|
|
|
|
for path in saved_paths[:5] + screenshot_paths[:5]:
|
|
await _emit_workspace_artifact_if_available(ctx, path)
|
|
|
|
status = str(parsed.get("status") or "ok")
|
|
if status in {"challenge_detected", "manual_intervention_required", "blocked"}:
|
|
await ctx.emit_error("Scrape stopped because a CAPTCHA, anti-bot, login, paywall, or access-control challenge was detected.", code=status)
|
|
else:
|
|
await ctx.emit_progress(f"scrape finished with status={status}; items={len(parsed.get('items') or [])}")
|
|
|
|
return {
|
|
"status": status,
|
|
"items": parsed.get("items") or [],
|
|
"saved_paths": saved_paths,
|
|
"warnings": warnings + parsed_warnings,
|
|
"screenshots": screenshot_paths,
|
|
"pages_visited": parsed.get("pages_visited") or [],
|
|
"html_captures": [str(path).replace("/workspace/", "") for path in (parsed.get("html_captures") or [])],
|
|
"robots": {"checked": bool(respect_robots)},
|
|
}
|
|
|
|
@skill(
|
|
name="iterate_scrape_goal",
|
|
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 iterative_scrape_goal(
|
|
self,
|
|
ctx: RunContext[NoAuth],
|
|
goal: str,
|
|
start_url: str,
|
|
allowed_domains: list[str] | None = None,
|
|
seed_selectors: dict[str, str] | None = None,
|
|
max_steps: int = 2,
|
|
max_pages: int = 1,
|
|
min_items: int = 1,
|
|
max_runtime_seconds: int = 600,
|
|
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,
|
|
) -> dict[str, Any]:
|
|
"""Use an LLM planner to refine selectors/pagination until the goal is met or a stop condition occurs."""
|
|
start_url = str(start_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)
|
|
selectors = seed_selectors or {"title": "title"}
|
|
if selectors:
|
|
v = _validate_inputs(
|
|
url=start_url,
|
|
selectors=selectors,
|
|
max_pages=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")
|
|
|
|
# Domain guard
|
|
def _allowed_origin(url: str) -> bool:
|
|
if not allowed_domains:
|
|
return same_origin_safeguard(start_url, url)
|
|
host = urllib.parse.urlparse(url).netloc.lower()
|
|
return any(host.endswith(dom.lower()) for dom in allowed_domains)
|
|
|
|
# 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]] = []
|
|
step_logs: 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=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
|
|
try:
|
|
planner = self._build_deep_agent(ctx=ctx, creds=creds, system_prompt=system_prompt)
|
|
except Exception as exc: # noqa: BLE001
|
|
warnings.append("planner_unavailable: " + _friendly_error(exc))
|
|
planner = None
|
|
current_url = start_url
|
|
current_selectors = selectors or {"title": "title"}
|
|
|
|
hard_cap_steps = max(1, min(int(max_steps), 3))
|
|
hard_cap_runtime = max(30, min(int(max_runtime_seconds), 840))
|
|
started_at = datetime.now(timezone.utc)
|
|
|
|
for step in range(1, hard_cap_steps + 1):
|
|
# Runtime budget check
|
|
if (datetime.now(timezone.utc) - started_at).total_seconds() > hard_cap_runtime:
|
|
warnings.append("max_runtime_seconds reached; stopping")
|
|
break
|
|
# Execute one browser pass
|
|
plan_note = "initial pass" if step == 1 else "refined pass"
|
|
run = await self.scrape_website(
|
|
ctx=ctx,
|
|
url=current_url,
|
|
selectors=current_selectors,
|
|
max_pages=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"step {step} stopped due to status={status}")
|
|
break
|
|
if len(all_items) >= int(min_items):
|
|
decisions.append({"step": step, "action": "stop", "reason": "min_items reached", "confidence": 1.0})
|
|
break
|
|
|
|
# Ask planner what to do next
|
|
summary = {
|
|
"step": step,
|
|
"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,
|
|
)
|
|
if planner is None:
|
|
decisions.append({"step": step, "action": "stop", "reason": "planner unavailable", "confidence": 0.0})
|
|
break
|
|
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: allowed_domains and same-origin only
|
|
if not _allowed_origin(next_url):
|
|
decisions.append({
|
|
"step": step,
|
|
"action": "stop",
|
|
"reason": "planner suggested URL outside allowed domains; stopping",
|
|
"confidence": confidence,
|
|
})
|
|
break
|
|
|
|
if action == "stop":
|
|
decisions.append({"step": step, "action": "stop", "reason": reason, "confidence": confidence})
|
|
break
|
|
|
|
if action != "scrape":
|
|
decisions.append({
|
|
"step": step,
|
|
"action": "stop",
|
|
"reason": "planner returned unknown action",
|
|
"confidence": confidence,
|
|
})
|
|
break
|
|
|
|
# Adopt next plan
|
|
current_url = next_url
|
|
if isinstance(proposed_selectors, dict) and proposed_selectors:
|
|
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({
|
|
"step": step,
|
|
"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"),
|
|
"steps": len(decisions) or min(int(max_steps), 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,
|
|
"steps": 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(
|
|
*,
|
|
url: str,
|
|
selectors: dict[str, str],
|
|
max_pages: int,
|
|
max_pages_limit: int,
|
|
wait_seconds: float,
|
|
output_format: str,
|
|
user_agent: str,
|
|
rate_limit_seconds: float,
|
|
) -> dict[str, Any]:
|
|
errors: list[str] = []
|
|
warnings: list[str] = []
|
|
clean_url = str(url or "").strip()
|
|
parsed = urllib.parse.urlparse(clean_url)
|
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
errors.append("url must be an absolute http(s) URL.")
|
|
if parsed.username or parsed.password:
|
|
errors.append("url must not contain embedded credentials.")
|
|
if not selectors:
|
|
errors.append("selectors mapping must contain at least one CSS selector.")
|
|
for key, value in selectors.items():
|
|
if not isinstance(key, str) or not key.strip():
|
|
errors.append("selector field names must be non-empty strings.")
|
|
if not isinstance(value, str) or not value.strip():
|
|
errors.append(f"selector for {key!r} must be a non-empty string.")
|
|
if re.search(r"password|token|secret|credential", key, re.I):
|
|
warnings.append(f"selector field {key!r} looks sensitive; do not scrape secrets or credentials.")
|
|
if max_pages < 1 or max_pages > max_pages_limit:
|
|
errors.append(f"max_pages must be between 1 and {max_pages_limit}.")
|
|
if wait_seconds < 0 or wait_seconds > 60:
|
|
errors.append("wait_seconds must be between 0 and 60.")
|
|
if output_format not in {"json", "csv"}:
|
|
errors.append("output_format must be 'json' or 'csv'.")
|
|
if "\n" in user_agent or "\r" in user_agent:
|
|
errors.append("user_agent must be a single-line string.")
|
|
if rate_limit_seconds < 0 or rate_limit_seconds > 120:
|
|
errors.append("rate_limit_seconds must be between 0 and 120.")
|
|
return {"errors": errors, "warnings": warnings, "url": clean_url}
|
|
|
|
|
|
def _robots_allowed(url: str, user_agent: str) -> dict[str, Any]:
|
|
parsed = urllib.parse.urlparse(url)
|
|
robots_url = urllib.parse.urlunparse((parsed.scheme, parsed.netloc, "/robots.txt", "", "", ""))
|
|
rp = urllib.robotparser.RobotFileParser()
|
|
rp.set_url(robots_url)
|
|
try:
|
|
with urllib.request.urlopen(
|
|
urllib.request.Request(robots_url, headers={"User-Agent": user_agent}),
|
|
timeout=10,
|
|
) as response:
|
|
body = response.read(2000000).decode("utf-8", errors="ignore")
|
|
rp.parse(body.splitlines())
|
|
return {"allowed": bool(rp.can_fetch(user_agent, url)), "robots_url": robots_url, "warnings": []}
|
|
except urllib.error.HTTPError as exc:
|
|
if exc.code in {401, 403}:
|
|
return {
|
|
"allowed": False,
|
|
"robots_url": robots_url,
|
|
"warnings": [f"robots.txt returned HTTP {exc.code}; treating as disallow for safety."],
|
|
}
|
|
if exc.code == 404:
|
|
return {"allowed": True, "robots_url": robots_url, "warnings": ["robots.txt not found; proceeding because no robots rules were published."]}
|
|
return {
|
|
"allowed": False,
|
|
"robots_url": robots_url,
|
|
"warnings": [f"robots.txt check failed with HTTP {exc.code}; treating as disallow for safety."],
|
|
}
|
|
except Exception as exc: # noqa: BLE001
|
|
return {
|
|
"allowed": False,
|
|
"robots_url": robots_url,
|
|
"warnings": [f"robots.txt could not be checked ({type(exc).__name__}: {exc}); treating as disallow for safety."],
|
|
}
|
|
|
|
|
|
def _render_scraper_script(payload: dict[str, Any]) -> str:
|
|
payload_json = json.dumps(payload, ensure_ascii=False)
|
|
return textwrap.dedent(
|
|
f"""
|
|
import csv
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import time
|
|
import traceback
|
|
import urllib.parse
|
|
import urllib.request
|
|
import urllib.robotparser
|
|
from datetime import datetime, timezone
|
|
|
|
PAYLOAD = json.loads({payload_json!r})
|
|
CHALLENGE_PATTERNS = [
|
|
r"captcha", r"recaptcha", r"hcaptcha", r"cf-challenge", r"cloudflare", r"turnstile",
|
|
r"are you human", r"verify you are human", r"bot detection", r"automated traffic",
|
|
r"access denied", r"temporarily blocked", r"unusual traffic", r"login required",
|
|
r"sign in to continue", r"subscribe to continue", r"paywall", r"forbidden",
|
|
]
|
|
PAGINATION_KEYS = {{"next", "_next", "__next__", "pagination_next", "next_page"}}
|
|
|
|
def emit(payload):
|
|
print(json.dumps(payload, ensure_ascii=False))
|
|
|
|
def safe_name(value):
|
|
value = re.sub(r"[^a-zA-Z0-9._-]+", "-", value).strip("-._")
|
|
return value[:80] or "page"
|
|
|
|
def same_origin(a, b):
|
|
pa, pb = urllib.parse.urlparse(a), urllib.parse.urlparse(b)
|
|
return pa.scheme == pb.scheme and pa.netloc.lower() == pb.netloc.lower()
|
|
|
|
def robots_allowed(url, user_agent):
|
|
parsed = urllib.parse.urlparse(url)
|
|
robots_url = urllib.parse.urlunparse((parsed.scheme, parsed.netloc, "/robots.txt", "", "", ""))
|
|
rp = urllib.robotparser.RobotFileParser()
|
|
rp.set_url(robots_url)
|
|
try:
|
|
req = urllib.request.Request(robots_url, headers={{"User-Agent": user_agent}})
|
|
with urllib.request.urlopen(req, timeout=10) as response:
|
|
body = response.read(2000000).decode("utf-8", errors="ignore")
|
|
rp.parse(body.splitlines())
|
|
return bool(rp.can_fetch(user_agent, url)), None
|
|
except Exception as exc:
|
|
return False, "robots.txt could not be checked for pagination URL {{0}}: {{1}}: {{2}}".format(url, type(exc).__name__, exc)
|
|
|
|
def split_selector(selector):
|
|
selector = selector.strip()
|
|
m = re.search(r"::attr\\(([^)]+)\\)\\s*$", selector)
|
|
if m:
|
|
return selector[:m.start()].strip(), "attr", m.group(1).strip()
|
|
if selector.endswith("::text"):
|
|
return selector[:-6].strip(), "text", None
|
|
if selector.endswith("::html"):
|
|
return selector[:-6].strip(), "html", None
|
|
return selector, "text", None
|
|
|
|
def detect_challenge(page_source, title, current_url):
|
|
haystack = "\\n".join([title or "", current_url or "", page_source[:50000] or ""]).lower()
|
|
matched = [pat for pat in CHALLENGE_PATTERNS if re.search(pat, haystack, re.I)]
|
|
if matched:
|
|
return "manual_intervention_required", "challenge/access-control indicator detected: " + ", ".join(sorted(set(matched))[:8])
|
|
return None, None
|
|
|
|
def extract_with_bs4(page_source, selectors):
|
|
from bs4 import BeautifulSoup
|
|
soup = BeautifulSoup(page_source, "html.parser")
|
|
item = {{}}
|
|
next_url = None
|
|
for key, raw_selector in selectors.items():
|
|
css, mode, attr = split_selector(str(raw_selector))
|
|
try:
|
|
elements = soup.select(css)
|
|
except Exception as exc:
|
|
item[key] = None
|
|
item.setdefault("_selector_warnings", []).append("selector {{0}}={{1!r}} failed: {{2}}".format(key, raw_selector, exc))
|
|
continue
|
|
values = []
|
|
for el in elements:
|
|
if mode == "attr":
|
|
values.append(el.get(attr))
|
|
elif mode == "html":
|
|
values.append(el.decode_contents())
|
|
else:
|
|
values.append(el.get_text(" ", strip=True))
|
|
values = [v for v in values if v is not None]
|
|
if key in PAGINATION_KEYS:
|
|
next_url = values[0] if values else None
|
|
else:
|
|
item[key] = values if len(values) != 1 else values[0] if values else None
|
|
return item, next_url
|
|
|
|
def write_outputs(items, pages_visited, html_captures, screenshots, warnings, status):
|
|
out_dir = PAYLOAD["output_dir"]
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
base = safe_name(urllib.parse.urlparse(PAYLOAD["url"]).netloc) + "-" + stamp
|
|
metadata = {{
|
|
"status": status,
|
|
"items": items,
|
|
"pages_visited": pages_visited,
|
|
"html_captures": html_captures,
|
|
"screenshots": screenshots,
|
|
"warnings": warnings,
|
|
"safety": "Authorized public/user-owned scraping only. No CAPTCHA, anti-bot, login, paywall, robots.txt, ToS, or access-control circumvention attempted.",
|
|
}}
|
|
json_path = os.path.join(out_dir, base + ".json")
|
|
with open(json_path, "w", encoding="utf-8") as f:
|
|
json.dump(metadata, f, ensure_ascii=False, indent=2)
|
|
saved_paths = [json_path]
|
|
if PAYLOAD["output_format"] == "csv":
|
|
csv_path = os.path.join(out_dir, base + ".csv")
|
|
fieldnames = sorted({{k for item in items for k in item.keys() if k != "_selector_warnings"}})
|
|
with open(csv_path, "w", newline="", encoding="utf-8") as f:
|
|
writer = csv.DictWriter(f, fieldnames=fieldnames or ["url"])
|
|
writer.writeheader()
|
|
for item in items:
|
|
row = {{k: (json.dumps(v, ensure_ascii=False) if isinstance(v, (list, dict)) else v) for k, v in item.items() if k in (fieldnames or ["url"])}}
|
|
writer.writerow(row)
|
|
saved_paths.append(csv_path)
|
|
return saved_paths
|
|
|
|
def run():
|
|
warnings = []
|
|
items = []
|
|
pages_visited = []
|
|
html_captures = []
|
|
screenshots = []
|
|
status = "ok"
|
|
out_dir = PAYLOAD["output_dir"]
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
current = PAYLOAD["url"]
|
|
start = current
|
|
|
|
try:
|
|
from seleniumbase import SB
|
|
browser_kwargs = {{
|
|
"headless": bool(PAYLOAD["headless"]),
|
|
"uc": False,
|
|
"test": False,
|
|
"locale_code": "en",
|
|
}}
|
|
if PAYLOAD.get("user_agent"):
|
|
browser_kwargs["agent"] = PAYLOAD["user_agent"]
|
|
with SB(**browser_kwargs) as sb:
|
|
for page_index in range(int(PAYLOAD["max_pages"])):
|
|
if not same_origin(start, current):
|
|
warnings.append("same-domain safeguard stopped pagination to " + str(current))
|
|
break
|
|
if PAYLOAD.get("respect_robots"):
|
|
allowed, robot_warning = robots_allowed(current, PAYLOAD.get("user_agent") or "*")
|
|
if robot_warning:
|
|
warnings.append(robot_warning)
|
|
if not allowed:
|
|
status = "blocked_by_robots_txt"
|
|
warnings.append("robots.txt disallows pagination URL " + str(current))
|
|
break
|
|
if page_index > 0 and float(PAYLOAD["rate_limit_seconds"]) > 0:
|
|
time.sleep(float(PAYLOAD["rate_limit_seconds"]))
|
|
|
|
sb.open(current)
|
|
if float(PAYLOAD["wait_seconds"]) > 0:
|
|
sb.sleep(float(PAYLOAD["wait_seconds"]))
|
|
current_url = sb.get_current_url()
|
|
title = sb.get_title()
|
|
page_source = sb.get_page_source()
|
|
pages_visited.append(current_url)
|
|
|
|
challenge_status, challenge_warning = detect_challenge(page_source, title, current_url)
|
|
if challenge_status:
|
|
status = challenge_status
|
|
warnings.append(challenge_warning)
|
|
html_path = os.path.join(out_dir, "page-{{0}}-challenge.html".format(page_index + 1))
|
|
with open(html_path, "w", encoding="utf-8") as f:
|
|
f.write(page_source)
|
|
html_captures.append(html_path)
|
|
if PAYLOAD.get("screenshot"):
|
|
shot_path = os.path.join(out_dir, "page-{{0}}-challenge.png".format(page_index + 1))
|
|
try:
|
|
sb.save_screenshot(shot_path)
|
|
screenshots.append(shot_path)
|
|
except Exception as exc:
|
|
warnings.append("screenshot failed: " + str(exc))
|
|
break
|
|
|
|
html_path = os.path.join(out_dir, "page-{{0}}.html".format(page_index + 1))
|
|
with open(html_path, "w", encoding="utf-8") as f:
|
|
f.write(page_source)
|
|
html_captures.append(html_path)
|
|
if PAYLOAD.get("screenshot"):
|
|
shot_path = os.path.join(out_dir, "page-{{0}}.png".format(page_index + 1))
|
|
try:
|
|
sb.save_screenshot(shot_path)
|
|
screenshots.append(shot_path)
|
|
except Exception as exc:
|
|
warnings.append("screenshot failed: " + str(exc))
|
|
|
|
item, next_raw = extract_with_bs4(page_source, PAYLOAD["selectors"])
|
|
item["url"] = current_url
|
|
item["page_index"] = page_index + 1
|
|
if item.get("_selector_warnings"):
|
|
warnings.extend(item.pop("_selector_warnings"))
|
|
items.append(item)
|
|
|
|
if page_index >= int(PAYLOAD["max_pages"]) - 1 or not next_raw:
|
|
break
|
|
next_url = urllib.parse.urljoin(current_url, str(next_raw))
|
|
if not same_origin(start, next_url):
|
|
warnings.append("same-domain safeguard refused pagination URL " + str(next_url))
|
|
break
|
|
current = next_url
|
|
except Exception as exc:
|
|
status = "error"
|
|
warnings.append("browser automation failed: {{0}}: {{1}}".format(type(exc).__name__, exc))
|
|
warnings.append(traceback.format_exc()[-4000:])
|
|
|
|
saved_paths = write_outputs(items, pages_visited, html_captures, screenshots, warnings, status)
|
|
emit({{
|
|
"status": status,
|
|
"items": items,
|
|
"saved_paths": saved_paths,
|
|
"screenshots": screenshots,
|
|
"html_captures": html_captures,
|
|
"warnings": warnings,
|
|
"pages_visited": pages_visited,
|
|
}})
|
|
return 0 if status in {{"ok", "blocked_by_robots_txt", "manual_intervention_required"}} else 1
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(run())
|
|
"""
|
|
)
|
|
|
|
|
|
def _parse_last_json(stdout: str) -> dict[str, Any] | None:
|
|
for line in reversed(stdout.splitlines()):
|
|
line = line.strip()
|
|
if not line.startswith("{"):
|
|
continue
|
|
try:
|
|
value = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if isinstance(value, dict):
|
|
return value
|
|
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:
|
|
try:
|
|
reader = getattr(ctx.workspace, "read_bytes", None)
|
|
if reader is None:
|
|
return
|
|
data = reader(path)
|
|
if not isinstance(data, (bytes, bytearray)):
|
|
return
|
|
mime = "application/json"
|
|
if path.endswith(".csv"):
|
|
mime = "text/csv"
|
|
elif path.endswith(".png"):
|
|
mime = "image/png"
|
|
elif path.endswith(".html"):
|
|
mime = "text/html"
|
|
ref = await ctx.write_artifact(Path(path).name, bytes(data), mime)
|
|
await ctx.emit_artifact(ref)
|
|
except Exception: # noqa: BLE001
|
|
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}"
|
|
ctx.workspace.write_bytes(path, json.dumps(manifest, ensure_ascii=False, indent=2).encode("utf-8"))
|
|
return path
|
|
except Exception: # noqa: BLE001
|
|
return ""
|