Compare commits

...
Author SHA1 Message Date
praisonai-triage-agent[bot]andMervin Praison 76a64958b1 fix: honor render page timeout and prevent token leak via exception chain
- fetch_rendered_html now extends the HTTP client deadline to cover the
  requested page_timeout (+30s buffer) so slow synchronous renders are not
  aborted early by the default 60s client timeout.
- _request raises the sanitized MrScraperError outside the except block so the
  original token-bearing httpx exception is not attached via __context__/__cause__.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-29 07:03:28 +00:00
riandradiva 86b97985c5 Add MrScraper client and PraisonAI callable tools example 2026-09-29 13:55:31 +07:00
6 changed files with 478 additions and 0 deletions

No files matched your search

@@ -0,0 +1,6 @@
"""MrScraper example client and agent callable tools."""
from .client import MrScraperClient, MrScraperError
from .tools import make_mrscraper_tools
__all__ = ["MrScraperClient", "MrScraperError", "make_mrscraper_tools"]
@@ -0,0 +1,276 @@
"""Small synchronous client for the MrScraper n8n operation contract.
The platform's response envelopes are passed through because they vary by API.
"""
from __future__ import annotations
import json
import os
from importlib import resources
from typing import Any, Dict, List, Mapping, Optional, Union
from urllib.parse import quote
import httpx
Json = Union[Dict[str, Any], List[Any], str, int, float, bool, None]
PLATFORM = "https://api.app.mrscraper.com"
SERP = "https://sync.scraper.mrscraper.com"
RENDER = "https://api.mrscraper.com"
class MrScraperError(RuntimeError):
"""Sanitized HTTP or transport failure; never includes a token-bearing URL."""
def __init__(self, message: str, status_code: Optional[int] = None) -> None:
super().__init__(message)
self.status_code = status_code
def _present(values: Mapping[str, Any]) -> Dict[str, Any]:
return {key: value for key, value in values.items() if value is not None}
def _patterns(values: Optional[List[str]]) -> Optional[str]:
return "|".join(values) if values is not None else None
class MrScraperClient:
"""Call MrScraper with a token from the environment or constructor.
``http_timeout`` controls the Python request deadline. API ``timeout``
options control page loading and are sent independently.
"""
def __init__(self, token: Optional[str] = None, *, http_timeout: float = 60.0,
transport: Optional[httpx.BaseTransport] = None) -> None:
self._token = token or os.environ.get("MRSCRAPER_API_TOKEN")
if not self._token:
raise ValueError("Set MRSCRAPER_API_TOKEN or pass token")
self._http = httpx.Client(timeout=http_timeout, transport=transport)
def close(self) -> None:
self._http.close()
def __enter__(self) -> "MrScraperClient":
return self
def __exit__(self, *args: object) -> None:
self.close()
def _request(self, method: str, host: str, path: str, *,
params: Optional[Mapping[str, Any]] = None,
body: Optional[Mapping[str, Any]] = None,
timeout: Optional[float] = None) -> Json:
headers = {"Accept": "application/json"}
if body is not None:
headers["Content-Type"] = "application/json"
if host == SERP:
headers["Authorization"] = f"Bearer {self._token}"
# The n8n credential adds this header to every route as well.
headers["x-api-token"] = self._token
else:
headers["x-api-token"] = self._token
request_kwargs: Dict[str, Any] = {"headers": headers, "params": params, "json": body}
if timeout is not None:
request_kwargs["timeout"] = timeout
error: Optional[MrScraperError] = None
try:
response = self._http.request(method, host + path, **request_kwargs)
response.raise_for_status()
if not response.content:
return None
if "json" in response.headers.get("Content-Type", ""):
return response.json()
return response.text
except httpx.HTTPStatusError as exc:
status = exc.response.status_code
error = MrScraperError(f"MrScraper HTTP {status} on {method} {host}{path}", status)
except (httpx.RequestError, ValueError) as exc:
# httpx errors can contain the rendered URL and its token query.
error = MrScraperError(f"MrScraper request failed on {method} {host}{path}: {type(exc).__name__}")
# Raise outside the except block so the original token-bearing exception
# is not attached via __context__/__cause__ and cannot leak the token.
raise error
def get_account_info(self) -> Json:
"""Get subscription account details from the platform host."""
return self._request("GET", PLATFORM, "/api/v1/subscription-accounts")
def create_prompt_scraper(self, url: str, message: str, *, mode: str = "Super",
proxy_country: Optional[str] = None,
output_schema: Optional[Mapping[str, Any]] = None) -> Json:
"""Create a general AI scraper; append an optional expected JSON shape to its prompt."""
if mode not in ("Super", "Cheap"):
raise ValueError("mode must be Super or Cheap")
if output_schema is not None:
message += "\n\nReturn the output as JSON matching this schema:\n" + json.dumps(output_schema)
return self._request("POST", PLATFORM, "/api/v1/scrapers-ai", body=_present({
"graph": "general", "url": url, "message": message, "mode": mode,
"proxyCountry": proxy_country}))
def extract_page_by_prompt(self, url: str, message: str, *, mode: str = "Super",
proxy_country: Optional[str] = None,
output_schema: Optional[Mapping[str, Any]] = None) -> Json:
"""Create and start the same general scraper used by prompt-based creation."""
return self.create_prompt_scraper(url, message, mode=mode,
proxy_country=proxy_country,
output_schema=output_schema)
def create_listing_scraper(self, url: str, message: str, *, max_pages: int = 1,
proxy_country: Optional[str] = None,
item_schema: Optional[Mapping[str, Any]] = None) -> Json:
"""Create a listing scraper with pagination and optional item shape."""
if item_schema is not None:
message += "\n\nReturn each item as JSON matching this schema:\n" + json.dumps(item_schema)
return self._request("POST", PLATFORM, "/api/v1/scrapers-ai", body=_present({
"graph": "listing", "url": url, "message": message,
"maxPages": max_pages, "proxyCountry": proxy_country}))
def extract_listings(self, url: str, message: str, *, max_pages: int = 1,
proxy_country: Optional[str] = None,
item_schema: Optional[Mapping[str, Any]] = None) -> Json:
"""Create and start the same listing scraper used by listing creation."""
return self.create_listing_scraper(url, message, max_pages=max_pages,
proxy_country=proxy_country,
item_schema=item_schema)
def create_crawl_scraper(self, url: str, *, max_depth: int = 2,
max_pages: int = 50, limit: int = 50,
include_patterns: Optional[List[str]] = None,
exclude_patterns: Optional[List[str]] = None) -> Json:
"""Create a map scraper that discovers URLs from a starting page."""
return self._request("POST", PLATFORM, "/api/v1/scrapers-ai", body=_present({
"graph": "map", "url": url, "maxDepth": max_depth,
"maxPages": max_pages, "limit": limit,
"includePatterns": _patterns(include_patterns),
"excludePatterns": _patterns(exclude_patterns)}))
def crawl_website_urls(self, url: str, *, max_depth: int = 2,
max_pages: int = 50, limit: int = 50,
include_patterns: Optional[List[str]] = None,
exclude_patterns: Optional[List[str]] = None) -> Json:
"""Create and start the same map scraper used by crawl creation."""
return self.create_crawl_scraper(url, max_depth=max_depth,
max_pages=max_pages, limit=limit,
include_patterns=include_patterns,
exclude_patterns=exclude_patterns)
def extract_structured_data(self, url: str, category: str, *, mode: str = "Super",
proxy_country: Optional[str] = None) -> Json:
"""Create a general scraper with a local n8n preset prompt for ``category``."""
prompt_path = resources.files("praisonai_mrscraper").joinpath("structured_data_prompts.json")
prompts = json.loads(prompt_path.read_text(encoding="utf-8"))
if category not in prompts:
raise ValueError(f"Unknown category; choose from {', '.join(sorted(prompts))}")
return self.create_prompt_scraper(url, prompts[category], mode=mode,
proxy_country=proxy_country)
def search_google_serp(self, query: str, *, region: str = "us", language: str = "en",
page: int = 1, format: str = "json",
render_js: bool = False) -> Json:
"""Search Google with MrScraper's synchronous SERP endpoint."""
if format not in ("json", "html"):
raise ValueError("format must be json or html")
return self._request("POST", SERP, "/api/google/serp/v2/sync", body={
"query": query, "region": region, "language": language,
"page": page, "format": format, "renderJs": render_js})
def fetch_rendered_html(self, url: str, *, max_retries: int = 3,
page_timeout: int = 300, geo_code: str = "us",
html: bool = True, markdown: bool = False,
screenshot: Optional[str] = None, proxy_country: str = "us",
block_resources: Optional[bool] = None,
return_cookie: Optional[bool] = None,
super_mode: Optional[bool] = None,
wait_for_selector: Optional[str] = None,
wait_until: Optional[str] = None,
home_page: Optional[str] = None,
token_cap: Optional[int] = None) -> Json:
"""Fetch a rendered page; page controls are distinct from HTTP timeout."""
if screenshot is not None and screenshot not in ("full", "top"):
raise ValueError("screenshot must be full or top")
if wait_until is not None and wait_until not in ("domcontentloaded", "load", "networkidle"):
raise ValueError("unsupported wait_until")
query = _present({"token": self._token, "browserRendering": True,
"timeout": page_timeout, "geoCode": geo_code,
"html": html, "markdown": markdown, "screenshot": screenshot,
"proxyCountry": proxy_country, "blockResources": block_resources,
"returnCookie": return_cookie, "super": super_mode,
"waitForSelector": wait_for_selector, "waitUntil": wait_until})
query = {key: str(value).lower() if isinstance(value, bool) else value
for key, value in query.items()}
# Give the synchronous render the page timeout it requested, plus a
# buffer for network transfer, so the HTTP client does not abort early.
http_timeout = float(page_timeout) + 30.0
return self._request("POST", RENDER, "/", params=query, timeout=http_timeout,
body=_present({
"url": url, "maxRetries": max_retries, "homePage": home_page,
"tokenCap": token_cap}))
def get_results(self, scraper_id: str, *, page: int = 1, page_size: int = 10,
sort: str = "createdAt", sort_order: str = "DESC") -> Json:
"""List results for a scraper with URL-encoded query parameters."""
return self._request("GET", PLATFORM, "/api/v1/results", params={
"filters[scraperId]": scraper_id, "page": page, "pageSize": page_size,
"sort": sort, "sortOrder": sort_order})
def get_latest_results(self, scraper_id: str, *, page_size: int = 10) -> Json:
"""List newest results for a scraper."""
return self.get_results(scraper_id, page=1, page_size=page_size,
sort="createdAt", sort_order="DESC")
def get_result_detail(self, result_id: str) -> Json:
"""Fetch one result by its URL-encoded ID."""
return self._request("GET", PLATFORM,
"/api/v1/results/" + quote(result_id, safe=""))
def run_existing_scraper(self, scraper_id: str, url: str, *,
scraper_type: str = "ai", agent_type: str = "general",
max_retry: int = 3, proxy_country: Optional[str] = None,
options: Optional[Mapping[str, Any]] = None) -> Json:
"""Rerun an AI or manual scraper; options are validated per scraper type."""
if scraper_type not in ("ai", "manual"):
raise ValueError("scraper_type must be ai or manual")
if agent_type not in ("general", "listing", "map"):
raise ValueError("agent_type must be general, listing, or map")
common = {"bypassProxy", "html", "markdown", "screenshot"}
general = common | {"renderJavascript", "returnCookies", "useHomePage", "waitForSelector"}
listing = general | {"maxPages", "timeout", "stream"}
mapping = general if agent_type == "general" else listing if agent_type == "listing" else {
"maxDepth", "maxPages", "limit", "includePatterns", "excludePatterns"}
manual = {"bypassProxy", "cookieJar", "cookies", "homePage", "homePageTimeout",
"html", "markdown", "paginator", "proxy", "record", "returnCookie",
"screenshot", "stream", "timeout", "tokenCap"}
provided = dict(options or {})
unknown = provided.keys() - (manual if scraper_type == "manual" else mapping)
if unknown:
raise ValueError(f"Unsupported rerun options: {', '.join(sorted(unknown))}")
if scraper_type == "ai" and agent_type == "listing":
provided = {"maxPages": 5, "timeout": 300, **provided}
if scraper_type == "ai" and agent_type == "map":
provided = {"maxDepth": 2, "maxPages": 50, "limit": 50, **provided}
for key in ("includePatterns", "excludePatterns"):
if isinstance(provided.get(key), list):
provided[key] = _patterns(provided[key])
if scraper_type == "manual" and "screenshot" in provided:
if not isinstance(provided["screenshot"], bool):
raise ValueError("manual screenshot must be a boolean")
provided["screenshot"] = str(provided["screenshot"]).lower()
route = "/api/v1/scrapers-ai-rerun" if scraper_type == "ai" else "/api/v1/scrapers-manual-rerun"
return self._request("POST", PLATFORM, route, body={**_present({
"scraperId": scraper_id, "url": url, "maxRetry": max_retry,
"proxyCountry": proxy_country}), **provided})
def run_existing_scraper_batch(self, scraper_id: str, urls: List[str], *,
scraper_type: str = "ai") -> Json:
"""Rerun an AI or manual scraper for a nonempty list of URLs."""
if scraper_type not in ("ai", "manual"):
raise ValueError("scraper_type must be ai or manual")
if not isinstance(urls, list) or not urls or any(
not isinstance(url, str) or not url for url in urls
):
raise ValueError("urls must be a nonempty list of URL strings")
route = "/api/v1/scrapers-ai-rerun/bulk" if scraper_type == "ai" else "/api/v1/scrapers-manual-rerun/bulk"
return self._request("POST", PLATFORM, route, body={"scraperId": scraper_id,
"urls": urls})
@@ -0,0 +1 @@
{"article":"\nPlease extract the data by following the schema:\n{\n \"fields\": [\n {\n \"name\": \"headline\",\n \"type\": \"string\",\n \"description\": \"Article headline or title\"\n },\n {\n \"name\": \"articleBody\",\n \"type\": \"string\",\n \"description\": \"Full text content of the article\"\n },\n {\n \"name\": \"articleBodyHtml\",\n \"type\": \"string\",\n \"description\": \"HTML markup of the article body\"\n },\n {\n \"name\": \"description\",\n \"type\": \"string\",\n \"description\": \"Short summary or description of the article\"\n },\n {\n \"name\": \"datePublished\",\n \"type\": \"string\",\n \"description\": \"Publication date in ISO 8601 format\"\n },\n {\n \"name\": \"datePublishedRaw\",\n \"type\": \"string\",\n \"description\": \"Publication date as displayed on the page\"\n },\n {\n \"name\": \"dateModified\",\n \"type\": \"string\",\n \"description\": \"Last modified date in ISO 8601 format\"\n },\n {\n \"name\": \"dateModifiedRaw\",\n \"type\": \"string\",\n \"description\": \"Last modified date as displayed on the page\"\n },\n {\n \"name\": \"authors\",\n \"type\": \"array\",\n \"item_type\": \"object\",\n \"description\": \"List of article authors\",\n \"item_structure\": {\n \"name\": {\n \"type\": \"string\",\n \"description\": \"Parsed individual author name\"\n },\n \"nameRaw\": {\n \"type\": \"string\",\n \"description\": \"Raw author name as displayed on the page\"\n }\n }\n },\n {\n \"name\": \"inLanguage\",\n \"type\": \"string\",\n \"description\": \"Language code of the article (e.g., en)\"\n },\n {\n \"name\": \"breadcrumbs\",\n \"type\": \"array\",\n \"item_type\": \"object\",\n \"description\": \"Navigation breadcrumb trail\",\n \"item_structure\": {\n \"url\": {\n \"type\": \"string\",\n \"description\": \"Breadcrumb link URL\"\n },\n \"name\": {\n \"type\": \"string\",\n \"description\": \"Breadcrumb label\"\n }\n }\n },\n {\n \"name\": \"mainImage\",\n \"type\": \"object\",\n \"structure\": {\n \"url\": {\n \"type\": \"string\",\n \"description\": \"URL of the main image\"\n }\n },\n \"description\": \"Primary image of the article\"\n },\n {\n \"name\": \"images\",\n \"type\": \"array\",\n \"item_type\": \"object\",\n \"description\": \"All images found in the article\",\n \"item_structure\": {\n \"url\": {\n \"type\": \"string\",\n \"description\": \"Image URL\"\n }\n }\n },\n {\n \"name\": \"videos\",\n \"type\": \"array\",\n \"item_type\": \"object\",\n \"description\": \"All videos found in the article\",\n \"item_structure\": {\n \"url\": {\n \"type\": \"string\",\n \"description\": \"Video URL\"\n }\n }\n },\n {\n \"name\": \"audios\",\n \"type\": \"array\",\n \"item_type\": \"object\",\n \"description\": \"All audio files found in the article\",\n \"item_structure\": {\n \"url\": {\n \"type\": \"string\",\n \"description\": \"Audio URL\"\n }\n }\n },\n {\n \"name\": \"url\",\n \"type\": \"string\",\n \"description\": \"URL of the article page\"\n },\n {\n \"name\": \"canonicalUrl\",\n \"type\": \"string\",\n \"description\": \"Canonical URL of the article\"\n }\n ]\n}\n","forumThread":"\nPlease extract the data by following the schema:\n{\n \"fields\": [\n {\n \"name\": \"topic\",\n \"type\": \"object\",\n \"structure\": {\n \"name\": {\n \"type\": \"string\",\n \"description\": \"Topic title or question\"\n }\n },\n \"description\": \"Forum thread topic\"\n },\n {\n \"name\": \"posts\",\n \"type\": \"array\",\n \"item_type\": \"object\",\n \"description\": \"List of posts in the thread\",\n \"item_structure\": {\n \"text\": {\n \"type\": \"string\",\n \"description\": \"Post text content\"\n },\n \"reactions\": {\n \"type\": \"object\",\n \"structure\": {\n \"likes\": {\n \"type\": \"number\",\n \"description\": \"Number of likes\"\n },\n \"replies\": {\n \"type\": \"number\",\n \"description\": \"Number of replies\"\n }\n },\n \"description\": \"Reaction counts for the post\"\n },\n \"datePublished\": {\n \"type\": \"string\",\n \"description\": \"Post date in ISO 8601 format\Line truncated
@@ -0,0 +1,47 @@
"""Bounded, agent-callable wrappers around MrScraperClient."""
from __future__ import annotations
import json
from typing import Callable, List
from .client import MrScraperClient
def make_mrscraper_tools(client: MrScraperClient, *, max_chars: int = 12000) -> List[Callable[..., str]]:
"""Return callable tools for rendered pages, extraction, crawl, and search.
Screenshot and cookie fields are deliberately not sent to an LLM context.
"""
if max_chars <= 0:
raise ValueError("max_chars must be positive")
def _without_sensitive(value: object) -> object:
if isinstance(value, dict):
return {key: _without_sensitive(item) for key, item in value.items()
if "screenshot" not in key.lower() and "cookie" not in key.lower()}
if isinstance(value, list):
return [_without_sensitive(item) for item in value]
return value
def _bounded(value: object) -> str:
value = _without_sensitive(value)
raw = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
return raw[:max_chars]
def read_web_page(url: str) -> str:
"""Read rendered Markdown from a public web page for a short summary."""
return _bounded(client.fetch_rendered_html(url, html=False, markdown=True))
def extract_page(url: str, prompt: str) -> str:
"""Ask MrScraper to extract information from one page using a prompt."""
return _bounded(client.extract_page_by_prompt(url, prompt))
def crawl_urls(url: str) -> str:
"""Discover URLs linked from a starting website."""
return _bounded(client.crawl_website_urls(url))
def search_google(query: str) -> str:
"""Search Google SERP with MrScraper and return bounded results."""
return _bounded(client.search_google_serp(query))
return [read_web_page, extract_page, crawl_urls, search_google]
+13
View File
@@ -0,0 +1,13 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "praisonai-mrscraper-example"
version = "0.1.0"
description = "MrScraper client and PraisonAI callable tools example"
requires-python = ">=3.9"
dependencies = ["httpx>=0.25,<1"]
[tool.setuptools.package-data]
praisonai_mrscraper = ["structured_data_prompts.json"]
+135
View File
@@ -0,0 +1,135 @@
"""Contract tests for the n8n routes, without paid API calls."""
import json
import httpx
import pytest
from praisonai_mrscraper import MrScraperClient, MrScraperError, make_mrscraper_tools
@pytest.fixture
def calls():
seen = []
def respond(request):
seen.append(request)
return httpx.Response(200, json={"data": {"sample": True}})
return seen, httpx.MockTransport(respond)
def test_hosts_auth_and_verbs(calls):
seen, transport = calls
with MrScraperClient("private-token", transport=transport) as client:
client.get_account_info()
client.search_google_serp("example")
client.fetch_rendered_html("https://example.com")
account, serp, render = seen
assert (account.method, account.url.host, account.url.path) == (
"GET", "api.app.mrscraper.com", "/api/v1/subscription-accounts")
assert account.headers["x-api-token"] == "private-token"
assert (serp.method, serp.url.host, serp.url.path) == (
"POST", "sync.scraper.mrscraper.com", "/api/google/serp/v2/sync")
assert serp.headers["Authorization"] == "Bearer private-token"
assert serp.headers["x-api-token"] == "private-token"
assert json.loads(serp.content)["renderJs"] is False
assert (render.method, render.url.host, render.url.path) == (
"POST", "api.mrscraper.com", "/")
assert render.url.params["token"] == "private-token"
assert render.url.params["browserRendering"] == "true"
assert json.loads(render.content) == {"url": "https://example.com", "maxRetries": 3}
def test_create_results_and_reruns(calls):
seen, transport = calls
with MrScraperClient("secret", transport=transport) as client:
result = client.create_prompt_scraper("https://example.com", "Extract title",
output_schema={"title": "string"})
client.create_listing_scraper("https://example.com", "Extract links")
client.crawl_website_urls("https://example.com", include_patterns=["/a", "/b"])
client.get_results("id/a+b", page=2)
client.get_result_detail("result/a+b")
client.run_existing_scraper("s", "https://example.com", agent_type="listing",
options={"stream": False})
client.run_existing_scraper("s", "https://example.com", scraper_type="manual",
options={"screenshot": True, "cookies": []})
client.run_existing_scraper_batch("s", ["https://example.com"], scraper_type="manual")
assert result == {"data": {"sample": True}}
assert json.loads(seen[0].content)["graph"] == "general"
assert "Return the output as JSON" in json.loads(seen[0].content)["message"]
assert json.loads(seen[1].content)["maxPages"] == 1
assert json.loads(seen[2].content)["includePatterns"] == "/a|/b"
assert seen[3].url.params["filters[scraperId]"] == "id/a+b"
assert seen[3].url.params["page"] == "2"
assert seen[4].url.raw_path.startswith(b"/api/v1/results/result%2Fa%2Bb")
assert json.loads(seen[5].content)["stream"] is False
assert json.loads(seen[5].content)["maxPages"] == 5
assert seen[6].url.path == "/api/v1/scrapers-manual-rerun"
assert json.loads(seen[6].content)["screenshot"] == "true"
assert json.loads(seen[7].content)["urls"] == ["https://example.com"]
def test_render_query_and_errors_are_sanitized():
def fail(request):
return httpx.Response(401, json={"error": "private-token"})
with MrScraperClient("private-token", transport=httpx.MockTransport(fail)) as client:
with pytest.raises(MrScraperError) as error:
client.fetch_rendered_html("https://example.com", html=False,
markdown=True, screenshot="top", token_cap=20)
assert error.value.status_code == 401
assert "private-token" not in str(error.value)
assert "token=" not in str(error.value)
# The token-bearing request must not leak through the exception chain.
assert error.value.__context__ is None
assert error.value.__cause__ is None
def test_render_extends_http_timeout_to_page_timeout():
seen = []
def respond(request):
seen.append(request)
return httpx.Response(200, json={"data": {"markdown": "ok"}})
with MrScraperClient("secret", http_timeout=60.0,
transport=httpx.MockTransport(respond)) as client:
client.fetch_rendered_html("https://example.com", page_timeout=300)
assert seen[0].extensions["timeout"]["read"] == pytest.approx(330.0)
def test_structured_preset_and_bounded_tools(calls):
seen, transport = calls
with MrScraperClient("secret", transport=transport) as client:
client.extract_structured_data("https://example.com", "article")
tools = make_mrscraper_tools(client, max_chars=25)
assert len(tools[0]("https://example.com")) <= 25
assert "headline" in json.loads(seen[0].content)["message"]
assert seen[1].url.params["markdown"] == "true"
assert seen[1].url.params["html"] == "false"
def test_invalid_options_do_not_make_requests(calls):
seen, transport = calls
with MrScraperClient("secret", transport=transport) as client:
with pytest.raises(ValueError):
client.run_existing_scraper_batch("s", [])
with pytest.raises(ValueError):
client.run_existing_scraper("s", "https://example.com", options={"bad": True})
with pytest.raises(ValueError):
client.extract_structured_data("https://example.com", "unknown")
assert seen == []
def test_agent_tool_removes_nested_image_and_cookie_fields():
def respond(request):
return httpx.Response(200, json={"data": {"markdown": "Hello",
"screenshot": "base64-image",
"cookies": ["session-secret"]}})
with MrScraperClient("secret", transport=httpx.MockTransport(respond)) as client:
result = make_mrscraper_tools(client)[0]("https://example.com")
assert "Hello" in result
assert "base64-image" not in result
assert "session-secret" not in result