참고소스 수정본

This commit is contained in:
LASTA_DEV01\lasta
2026-05-12 19:40:31 +09:00
parent 0f34a451fc
commit 2e9204243d
8708 changed files with 3259488 additions and 869 deletions

View File

@@ -0,0 +1 @@
# Async (aio) method modules for v2

View File

@@ -0,0 +1,154 @@
from typing import Any, Dict, List, Literal, Optional, Union
import asyncio
from ...types import AgentResponse, AgentWebhookConfig
from ...utils.http_client_async import AsyncHttpClient
from ...utils.validation import _normalize_schema
def _prepare_agent_request(
urls: Optional[List[str]],
*,
prompt: str,
schema: Optional[Any] = None,
integration: Optional[str] = None,
max_credits: Optional[int] = None,
strict_constrain_to_urls: Optional[bool] = None,
model: Optional[Literal["spark-1-pro", "spark-1-mini"]] = None,
webhook: Optional[Union[str, AgentWebhookConfig]] = None,
) -> Dict[str, Any]:
body: Dict[str, Any] = {}
if urls is not None:
body["urls"] = urls
body["prompt"] = prompt
if schema is not None:
normalized_schema = _normalize_schema(schema)
if normalized_schema is not None:
body["schema"] = normalized_schema
else:
raise ValueError(
f"Invalid schema type: {type(schema).__name__}. "
"Schema must be a dict, Pydantic BaseModel class, or Pydantic model instance."
)
if integration is not None and str(integration).strip():
body["integration"] = str(integration).strip()
if max_credits is not None and max_credits > 0:
body["maxCredits"] = max_credits
if strict_constrain_to_urls is not None and strict_constrain_to_urls:
body["strictConstrainToURLs"] = strict_constrain_to_urls
if model is not None:
body["model"] = model
if webhook is not None:
if isinstance(webhook, str):
body["webhook"] = webhook
else:
body["webhook"] = webhook.model_dump(exclude_none=True)
return body
def _normalize_agent_response_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
out = dict(payload)
if "expiresAt" in out and "expires_at" not in out:
out["expires_at"] = out["expiresAt"]
if "creditsUsed" in out and "credits_used" not in out:
out["credits_used"] = out["creditsUsed"]
return out
async def start_agent(
client: AsyncHttpClient,
urls: Optional[List[str]],
*,
prompt: str,
schema: Optional[Any] = None,
integration: Optional[str] = None,
max_credits: Optional[int] = None,
strict_constrain_to_urls: Optional[bool] = None,
model: Optional[Literal["spark-1-pro", "spark-1-mini"]] = None,
webhook: Optional[Union[str, AgentWebhookConfig]] = None,
) -> AgentResponse:
body = _prepare_agent_request(
urls,
prompt=prompt,
schema=schema,
integration=integration,
max_credits=max_credits,
strict_constrain_to_urls=strict_constrain_to_urls,
model=model,
webhook=webhook,
)
resp = await client.post("/v2/agent", body)
payload = _normalize_agent_response_payload(resp.json())
return AgentResponse(**payload)
async def get_agent_status(client: AsyncHttpClient, job_id: str) -> AgentResponse:
resp = await client.get(f"/v2/agent/{job_id}")
payload = _normalize_agent_response_payload(resp.json())
return AgentResponse(**payload)
async def wait_agent(
client: AsyncHttpClient,
job_id: str,
*,
poll_interval: int = 2,
timeout: Optional[int] = None,
) -> AgentResponse:
start_ts = asyncio.get_event_loop().time()
while True:
status = await get_agent_status(client, job_id)
if status.status in ("completed", "failed", "cancelled"):
return status
if timeout is not None and (asyncio.get_event_loop().time() - start_ts) > timeout:
return status
await asyncio.sleep(max(1, poll_interval))
async def agent(
client: AsyncHttpClient,
urls: Optional[List[str]],
*,
prompt: str,
schema: Optional[Any] = None,
integration: Optional[str] = None,
poll_interval: int = 2,
timeout: Optional[int] = None,
max_credits: Optional[int] = None,
strict_constrain_to_urls: Optional[bool] = None,
model: Optional[Literal["spark-1-pro", "spark-1-mini"]] = None,
webhook: Optional[Union[str, AgentWebhookConfig]] = None,
) -> AgentResponse:
started = await start_agent(
client,
urls,
prompt=prompt,
schema=schema,
integration=integration,
max_credits=max_credits,
strict_constrain_to_urls=strict_constrain_to_urls,
model=model,
webhook=webhook,
)
job_id = getattr(started, "id", None)
if not job_id:
return started
return await wait_agent(client, job_id, poll_interval=poll_interval, timeout=timeout)
async def cancel_agent(client: AsyncHttpClient, job_id: str) -> bool:
"""
Cancel a running agent job.
Args:
client: Async HTTP client instance
job_id: ID of the agent job to cancel
Returns:
bool: True if the agent was cancelled, False otherwise
Raises:
Exception: If the cancellation fails
"""
resp = await client.delete(f"/v2/agent/{job_id}")
return resp.json().get("success", False)

View File

@@ -0,0 +1,240 @@
from typing import Optional, List, Dict, Any
from ...types import ScrapeOptions, WebhookConfig, Document, BatchScrapeResponse, BatchScrapeJob, PaginationConfig
from ...utils.http_client_async import AsyncHttpClient
from ...utils.validation import prepare_scrape_options
from ...utils.error_handler import handle_response_error
from ...utils.normalize import normalize_document_input
from ...methods.batch import validate_batch_urls
import time
def _parse_batch_scrape_documents(data_list: Optional[List[Any]]) -> List[Document]:
documents: List[Document] = []
for doc in data_list or []:
if isinstance(doc, dict):
normalized = normalize_document_input(doc)
documents.append(Document(**normalized))
return documents
def _parse_batch_scrape_status_response(body: Dict[str, Any]) -> Dict[str, Any]:
if not body.get("success"):
raise Exception(body.get("error", "Unknown error occurred"))
return {
"status": body.get("status"),
"completed": body.get("completed", 0),
"total": body.get("total", 0),
"credits_used": body.get("creditsUsed"),
"expires_at": body.get("expiresAt"),
"next": body.get("next"),
"data": _parse_batch_scrape_documents(body.get("data", []) or []),
}
def _prepare(urls: List[str], *, options: Optional[ScrapeOptions] = None, **kwargs) -> Dict[str, Any]:
if not urls:
raise ValueError("URLs list cannot be empty")
validated_urls = validate_batch_urls([u.strip() if isinstance(u, str) else u for u in urls])
payload: Dict[str, Any] = {"urls": validated_urls}
if options:
opts = prepare_scrape_options(options)
if opts:
payload.update(opts)
if (w := kwargs.get("webhook")) is not None:
payload["webhook"] = w if isinstance(w, str) else w.model_dump(exclude_none=True)
if (v := kwargs.get("append_to_id")) is not None:
payload["appendToId"] = v
if (v := kwargs.get("ignore_invalid_urls")) is not None:
payload["ignoreInvalidURLs"] = v
if (v := kwargs.get("max_concurrency")) is not None:
payload["maxConcurrency"] = v
if (v := kwargs.get("zero_data_retention")) is not None:
payload["zeroDataRetention"] = v
if (v := kwargs.get("integration")) is not None:
trimmed_integration = str(v).strip()
if trimmed_integration:
payload["integration"] = trimmed_integration
return payload
async def start_batch_scrape(client: AsyncHttpClient, urls: List[str], **kwargs) -> BatchScrapeResponse:
payload = _prepare(urls, **kwargs)
response = await client.post("/v2/batch/scrape", payload)
if response.status_code >= 400:
handle_response_error(response, "start batch scrape")
body = response.json()
if not body.get("success"):
raise Exception(body.get("error", "Unknown error occurred"))
return BatchScrapeResponse(id=body.get("id"), url=body.get("url"), invalid_urls=body.get("invalidURLs"))
async def get_batch_scrape_status(
client: AsyncHttpClient,
job_id: str,
pagination_config: Optional[PaginationConfig] = None
) -> BatchScrapeJob:
"""
Get the status of a batch scrape job.
Args:
client: Async HTTP client instance
job_id: ID of the batch scrape job
pagination_config: Optional configuration for pagination behavior
Returns:
BatchScrapeJob containing job status and data
Raises:
Exception: If the status check fails
"""
response = await client.get(f"/v2/batch/scrape/{job_id}")
if response.status_code >= 400:
handle_response_error(response, "get batch scrape status")
body = response.json()
payload = _parse_batch_scrape_status_response(body)
docs = payload["data"]
# Handle pagination if requested
auto_paginate = pagination_config.auto_paginate if pagination_config else True
if auto_paginate and payload["next"]:
docs = await _fetch_all_batch_pages_async(
client,
payload["next"],
docs,
pagination_config
)
return BatchScrapeJob(
status=payload["status"],
completed=payload["completed"],
total=payload["total"],
credits_used=payload["credits_used"],
expires_at=payload["expires_at"],
next=payload["next"] if not auto_paginate else None,
data=docs,
)
async def get_batch_scrape_status_page(
client: AsyncHttpClient,
next_url: str,
*,
request_timeout: Optional[float] = None,
) -> BatchScrapeJob:
"""
Fetch a single page of batch scrape results using the provided next URL.
Args:
client: Async HTTP client instance
next_url: Opaque next URL from a prior batch scrape status response
request_timeout: Timeout (in seconds) for the HTTP request
Returns:
BatchScrapeJob with the page data and next URL (if any)
Raises:
Exception: If the request fails or returns an error response
"""
response = await client.get(next_url, timeout=request_timeout)
if response.status_code >= 400:
handle_response_error(response, "get batch scrape status page")
body = response.json()
payload = _parse_batch_scrape_status_response(body)
return BatchScrapeJob(
status=payload["status"],
completed=payload["completed"],
total=payload["total"],
credits_used=payload["credits_used"],
expires_at=payload["expires_at"],
next=payload["next"],
data=payload["data"],
)
async def _fetch_all_batch_pages_async(
client: AsyncHttpClient,
next_url: str,
initial_documents: List[Document],
pagination_config: Optional[PaginationConfig] = None
) -> List[Document]:
"""
Fetch all pages of batch scrape results asynchronously.
Args:
client: Async HTTP client instance
next_url: URL for the next page
initial_documents: Documents from the first page
pagination_config: Optional configuration for pagination limits
Returns:
List of all documents from all pages
"""
documents = initial_documents.copy()
current_url = next_url
page_count = 0
# Apply pagination limits
max_pages = pagination_config.max_pages if pagination_config else None
max_results = pagination_config.max_results if pagination_config else None
max_wait_time = pagination_config.max_wait_time if pagination_config else None
start_time = time.monotonic()
while current_url:
# Check pagination limits
if (max_pages is not None) and (page_count >= max_pages):
break
if (max_wait_time is not None) and (time.monotonic() - start_time) > max_wait_time:
break
# Fetch next page
response = await client.get(current_url)
if response.status_code >= 400:
# Log error but continue with what we have
import logging
logger = logging.getLogger("firecrawl")
logger.warning(f"Failed to fetch next page: {response.status_code}")
break
page_data = response.json()
try:
page_payload = _parse_batch_scrape_status_response(page_data)
except Exception:
break
# Add documents from this page
for document in page_payload["data"]:
# Check max_results limit
if (max_results is not None) and (len(documents) >= max_results):
break
documents.append(document)
# Check if we hit max_results limit
if (max_results is not None) and (len(documents) >= max_results):
break
# Get next URL
current_url = page_payload["next"]
page_count += 1
return documents
async def cancel_batch_scrape(client: AsyncHttpClient, job_id: str) -> bool:
response = await client.delete(f"/v2/batch/scrape/{job_id}")
if response.status_code >= 400:
handle_response_error(response, "cancel batch scrape")
body = response.json()
return body.get("status") == "cancelled"
async def get_batch_scrape_errors(client: AsyncHttpClient, job_id: str) -> Dict[str, Any]:
response = await client.get(f"/v2/batch/scrape/{job_id}/errors")
if response.status_code >= 400:
handle_response_error(response, "get batch scrape errors")
body = response.json()
if not body.get("success"):
raise Exception(body.get("error", "Unknown error occurred"))
return body

View File

@@ -0,0 +1,179 @@
"""
Async browser session methods for Firecrawl v2 API.
Provides async create, execute, delete, and list operations for browser sessions.
"""
from typing import Any, Dict, Literal, Optional
from ...types import (
BrowserCreateResponse,
BrowserExecuteResponse,
BrowserDeleteResponse,
BrowserListResponse,
)
from ...utils.http_client_async import AsyncHttpClient
def _normalize_browser_create_response(payload: Dict[str, Any]) -> Dict[str, Any]:
out = dict(payload)
if "cdpUrl" in out and "cdp_url" not in out:
out["cdp_url"] = out["cdpUrl"]
if "liveViewUrl" in out and "live_view_url" not in out:
out["live_view_url"] = out["liveViewUrl"]
if "interactiveLiveViewUrl" in out and "interactive_live_view_url" not in out:
out["interactive_live_view_url"] = out["interactiveLiveViewUrl"]
if "expiresAt" in out and "expires_at" not in out:
out["expires_at"] = out["expiresAt"]
return out
def _normalize_browser_list_response(payload: Dict[str, Any]) -> Dict[str, Any]:
out = dict(payload)
if "sessions" in out and isinstance(out["sessions"], list):
normalized_sessions = []
for s in out["sessions"]:
ns = dict(s)
if "cdpUrl" in ns and "cdp_url" not in ns:
ns["cdp_url"] = ns["cdpUrl"]
if "liveViewUrl" in ns and "live_view_url" not in ns:
ns["live_view_url"] = ns["liveViewUrl"]
if "interactiveLiveViewUrl" in ns and "interactive_live_view_url" not in ns:
ns["interactive_live_view_url"] = ns["interactiveLiveViewUrl"]
if "streamWebView" in ns and "stream_web_view" not in ns:
ns["stream_web_view"] = ns["streamWebView"]
if "createdAt" in ns and "created_at" not in ns:
ns["created_at"] = ns["createdAt"]
if "lastActivity" in ns and "last_activity" not in ns:
ns["last_activity"] = ns["lastActivity"]
normalized_sessions.append(ns)
out["sessions"] = normalized_sessions
return out
async def browser(
client: AsyncHttpClient,
*,
ttl: Optional[int] = None,
activity_ttl: Optional[int] = None,
stream_web_view: Optional[bool] = None,
profile: Optional[Dict[str, Any]] = None,
) -> BrowserCreateResponse:
"""Create a new browser session.
Args:
client: Async HTTP client instance
ttl: Total time-to-live in seconds (30-3600, default 300)
activity_ttl: Inactivity TTL in seconds (10-3600)
stream_web_view: Whether to enable webview streaming
profile: Profile config with ``name`` (str) and
optional ``save_changes`` (bool, default ``True``)
Returns:
BrowserCreateResponse with session id and CDP URL
"""
body: Dict[str, Any] = {}
if ttl is not None:
body["ttl"] = ttl
if activity_ttl is not None:
body["activityTtl"] = activity_ttl
if stream_web_view is not None:
body["streamWebView"] = stream_web_view
if profile is not None:
body["profile"] = {
"name": profile["name"],
"saveChanges": profile.get("save_changes", True),
}
resp = await client.post("/v2/browser", body)
payload = _normalize_browser_create_response(resp.json())
return BrowserCreateResponse(**payload)
def _normalize_browser_execute_response(payload: Dict[str, Any]) -> Dict[str, Any]:
out = dict(payload)
if "exitCode" in out and "exit_code" not in out:
out["exit_code"] = out["exitCode"]
return out
def _normalize_browser_delete_response(payload: Dict[str, Any]) -> Dict[str, Any]:
out = dict(payload)
if "sessionDurationMs" in out and "session_duration_ms" not in out:
out["session_duration_ms"] = out["sessionDurationMs"]
if "creditsBilled" in out and "credits_billed" not in out:
out["credits_billed"] = out["creditsBilled"]
return out
async def browser_execute(
client: AsyncHttpClient,
session_id: str,
code: str,
*,
language: Literal["python", "node", "bash"] = "bash",
timeout: Optional[int] = None,
) -> BrowserExecuteResponse:
"""Execute code in a browser session.
Args:
client: Async HTTP client instance
session_id: Browser session ID
code: Code to execute
language: Programming language ("python", "node", or "bash")
timeout: Execution timeout in seconds (1-300, default 30)
Returns:
BrowserExecuteResponse with execution result
"""
body: Dict[str, Any] = {
"code": code,
"language": language,
}
if timeout is not None:
body["timeout"] = timeout
resp = await client.post(f"/v2/browser/{session_id}/execute", body)
payload = _normalize_browser_execute_response(resp.json())
return BrowserExecuteResponse(**payload)
async def delete_browser(
client: AsyncHttpClient,
session_id: str,
) -> BrowserDeleteResponse:
"""Delete a browser session.
Args:
client: Async HTTP client instance
session_id: Browser session ID
Returns:
BrowserDeleteResponse
"""
resp = await client.delete(f"/v2/browser/{session_id}")
payload = _normalize_browser_delete_response(resp.json())
return BrowserDeleteResponse(**payload)
async def list_browsers(
client: AsyncHttpClient,
*,
status: Optional[Literal["active", "destroyed"]] = None,
) -> BrowserListResponse:
"""List browser sessions.
Args:
client: Async HTTP client instance
status: Filter by session status ("active" or "destroyed")
Returns:
BrowserListResponse with list of sessions
"""
endpoint = "/v2/browser"
if status is not None:
endpoint = f"{endpoint}?status={status}"
resp = await client.get(endpoint)
payload = _normalize_browser_list_response(resp.json())
return BrowserListResponse(**payload)

View File

@@ -0,0 +1,414 @@
from typing import Optional, Dict, Any, List
from ...types import (
CrawlRequest,
CrawlJob,
CrawlResponse,
Document,
CrawlParamsRequest,
CrawlParamsData,
WebhookConfig,
CrawlErrorsResponse,
ActiveCrawlsResponse,
ActiveCrawl,
PaginationConfig,
)
from ...utils.error_handler import handle_response_error
from ...utils.validation import prepare_scrape_options
from ...utils.http_client_async import AsyncHttpClient
from ...utils.normalize import normalize_document_input
import time
def _prepare_crawl_request(request: CrawlRequest) -> dict:
if not request.url or not request.url.strip():
raise ValueError("URL cannot be empty")
if request.limit is not None and request.limit <= 0:
raise ValueError("Limit must be positive")
data = {"url": request.url}
if request.prompt:
data["prompt"] = request.prompt
if request.scrape_options is not None:
opts = prepare_scrape_options(request.scrape_options)
if opts:
data["scrapeOptions"] = opts
# Webhook conversion
if request.webhook is not None:
if isinstance(request.webhook, str):
data["webhook"] = request.webhook
else:
data["webhook"] = request.webhook.model_dump(exclude_none=True)
request_data = request.model_dump(exclude_none=True, exclude_unset=True)
request_data.pop("url", None)
request_data.pop("prompt", None)
request_data.pop("scrape_options", None)
field_mappings = {
"include_paths": "includePaths",
"exclude_paths": "excludePaths",
"max_discovery_depth": "maxDiscoveryDepth",
"sitemap": "sitemap",
"ignore_query_parameters": "ignoreQueryParameters",
"deduplicate_similar_urls": "deduplicateSimilarURLs",
"crawl_entire_domain": "crawlEntireDomain",
"allow_external_links": "allowExternalLinks",
"allow_subdomains": "allowSubdomains",
"ignore_robots_txt": "ignoreRobotsTxt",
"robots_user_agent": "robotsUserAgent",
"delay": "delay",
"max_concurrency": "maxConcurrency",
"regex_on_full_url": "regexOnFullURL",
"zero_data_retention": "zeroDataRetention",
}
for snake, camel in field_mappings.items():
if snake in request_data:
data[camel] = request_data.pop(snake)
data.update(request_data)
if getattr(request, "integration", None) is not None:
data["integration"] = str(getattr(request, "integration")).strip()
return data
def _parse_crawl_documents(data_list: Optional[List[Any]]) -> List[Document]:
documents: List[Document] = []
for doc_data in data_list or []:
if isinstance(doc_data, dict):
normalized = normalize_document_input(doc_data)
documents.append(Document(**normalized))
return documents
def _parse_crawl_status_response(body: Dict[str, Any]) -> Dict[str, Any]:
if not body.get("success"):
raise Exception(body.get("error", "Unknown error occurred"))
return {
"status": body.get("status"),
"completed": body.get("completed", 0),
"total": body.get("total", 0),
"credits_used": body.get("creditsUsed", 0),
"expires_at": body.get("expiresAt"),
"next": body.get("next"),
"data": _parse_crawl_documents(body.get("data", [])),
}
async def start_crawl(client: AsyncHttpClient, request: CrawlRequest) -> CrawlResponse:
"""
Start a crawl job for a website.
Args:
client: Async HTTP client instance
request: CrawlRequest containing URL and options
Returns:
CrawlResponse with job information
Raises:
ValueError: If request is invalid
Exception: If the crawl operation fails to start
"""
payload = _prepare_crawl_request(request)
response = await client.post("/v2/crawl", payload)
if response.status_code >= 400:
handle_response_error(response, "start crawl")
body = response.json()
if body.get("success"):
return CrawlResponse(id=body.get("id"), url=body.get("url"))
raise Exception(body.get("error", "Unknown error occurred"))
async def get_crawl_status(
client: AsyncHttpClient,
job_id: str,
pagination_config: Optional[PaginationConfig] = None,
*,
request_timeout: Optional[float] = None,
) -> CrawlJob:
"""
Get the status of a crawl job.
Args:
client: Async HTTP client instance
job_id: ID of the crawl job
pagination_config: Optional configuration for pagination limits
request_timeout: Timeout (in seconds) for each individual HTTP request. When auto-pagination
is enabled (default) and there are multiple pages of results, this timeout applies to
each page request separately, not to the entire operation
Returns:
CrawlJob with job information
Raises:
Exception: If the status check fails
"""
response = await client.get(f"/v2/crawl/{job_id}", timeout=request_timeout)
if response.status_code >= 400:
handle_response_error(response, "get crawl status")
body = response.json()
payload = _parse_crawl_status_response(body)
documents = payload["data"]
# Handle pagination if requested
auto_paginate = pagination_config.auto_paginate if pagination_config else True
if auto_paginate and payload["next"]:
documents = await _fetch_all_pages_async(
client,
payload["next"],
documents,
pagination_config,
request_timeout=request_timeout,
)
return CrawlJob(
status=payload["status"],
completed=payload["completed"],
total=payload["total"],
credits_used=payload["credits_used"],
expires_at=payload["expires_at"],
next=payload["next"] if not auto_paginate else None,
data=documents,
)
async def get_crawl_status_page(
client: AsyncHttpClient,
next_url: str,
*,
request_timeout: Optional[float] = None,
) -> CrawlJob:
"""
Fetch a single page of crawl results using the provided next URL.
Args:
client: Async HTTP client instance
next_url: Opaque next URL from a prior crawl status response
request_timeout: Timeout (in seconds) for the HTTP request
Returns:
CrawlJob with the page data and next URL (if any)
Raises:
Exception: If the request fails or returns an error response
"""
response = await client.get(next_url, timeout=request_timeout)
if response.status_code >= 400:
handle_response_error(response, "get crawl status page")
body = response.json()
payload = _parse_crawl_status_response(body)
return CrawlJob(
status=payload["status"],
completed=payload["completed"],
total=payload["total"],
credits_used=payload["credits_used"],
expires_at=payload["expires_at"],
next=payload["next"],
data=payload["data"],
)
async def _fetch_all_pages_async(
client: AsyncHttpClient,
next_url: str,
initial_documents: List[Document],
pagination_config: Optional[PaginationConfig] = None,
*,
request_timeout: Optional[float] = None,
) -> List[Document]:
"""
Fetch all pages of crawl results asynchronously.
Args:
client: Async HTTP client instance
next_url: URL for the next page
initial_documents: Documents from the first page
pagination_config: Optional configuration for pagination limits
request_timeout: Optional timeout (in seconds) for the underlying HTTP request
Returns:
List of all documents from all pages
"""
documents = initial_documents.copy()
current_url = next_url
page_count = 0
# Apply pagination limits
max_pages = pagination_config.max_pages if pagination_config else None
max_results = pagination_config.max_results if pagination_config else None
max_wait_time = pagination_config.max_wait_time if pagination_config else None
start_time = time.monotonic()
while current_url:
# Check pagination limits (treat 0 as a valid limit)
if (max_pages is not None) and page_count >= max_pages:
break
if (max_wait_time is not None) and (time.monotonic() - start_time) > max_wait_time:
break
# Fetch next page
response = await client.get(current_url, timeout=request_timeout)
if response.status_code >= 400:
# Log error but continue with what we have
import logging
logger = logging.getLogger("firecrawl")
logger.warning("Failed to fetch next page", extra={"status_code": response.status_code})
break
page_data = response.json()
try:
page_payload = _parse_crawl_status_response(page_data)
except Exception:
break
# Add documents from this page
for document in page_payload["data"]:
# Check max_results limit
if (max_results is not None) and (len(documents) >= max_results):
break
documents.append(document)
# Check if we hit max_results limit
if (max_results is not None) and (len(documents) >= max_results):
break
# Get next URL
current_url = page_payload["next"]
page_count += 1
return documents
async def cancel_crawl(client: AsyncHttpClient, job_id: str) -> bool:
"""
Cancel a crawl job.
Args:
client: Async HTTP client instance
job_id: ID of the crawl job
Returns:
True if cancellation was successful
Raises:
Exception: If the cancellation operation fails
"""
response = await client.delete(f"/v2/crawl/{job_id}")
if response.status_code >= 400:
handle_response_error(response, "cancel crawl")
body = response.json()
return body.get("status") == "cancelled"
async def crawl_params_preview(client: AsyncHttpClient, request: CrawlParamsRequest) -> CrawlParamsData:
"""
Preview crawl parameters before starting a crawl job.
Args:
client: Async HTTP client instance
request: CrawlParamsRequest containing URL and prompt
Returns:
CrawlParamsData containing crawl configuration
Raises:
ValueError: If request is invalid
Exception: If the parameter preview fails
"""
if not request.url or not request.url.strip():
raise ValueError("URL cannot be empty")
if not request.prompt or not request.prompt.strip():
raise ValueError("Prompt cannot be empty")
payload = {"url": request.url, "prompt": request.prompt}
response = await client.post("/v2/crawl/params-preview", payload)
if response.status_code >= 400:
handle_response_error(response, "crawl params preview")
body = response.json()
if not body.get("success"):
raise Exception(body.get("error", "Unknown error occurred"))
params_data = body.get("data", {})
converted: Dict[str, Any] = {}
mapping = {
"includePaths": "include_paths",
"excludePaths": "exclude_paths",
"maxDiscoveryDepth": "max_discovery_depth",
"sitemap": "sitemap",
"ignoreQueryParameters": "ignore_query_parameters",
"deduplicateSimilarURLs": "deduplicate_similar_urls",
"crawlEntireDomain": "crawl_entire_domain",
"allowExternalLinks": "allow_external_links",
"allowSubdomains": "allow_subdomains",
"ignoreRobotsTxt": "ignore_robots_txt",
"robotsUserAgent": "robots_user_agent",
"maxConcurrency": "max_concurrency",
"scrapeOptions": "scrape_options",
"zeroDataRetention": "zero_data_retention",
}
for camel, snake in mapping.items():
if camel in params_data:
converted[snake] = params_data[camel]
if "webhook" in params_data:
wk = params_data["webhook"]
converted["webhook"] = wk
if "warning" in body:
converted["warning"] = body["warning"]
return CrawlParamsData(**converted)
async def get_crawl_errors(client: AsyncHttpClient, crawl_id: str) -> CrawlErrorsResponse:
"""
Get errors from a crawl job.
Args:
client: Async HTTP client instance
crawl_id: ID of the crawl job
Returns:
CrawlErrorsResponse with errors and robots blocked
Raises:
Exception: If the error check operation fails
"""
response = await client.get(f"/v2/crawl/{crawl_id}/errors")
if response.status_code >= 400:
handle_response_error(response, "check crawl errors")
body = response.json()
payload = body.get("data", body)
normalized = {
"errors": payload.get("errors", []),
"robots_blocked": payload.get("robotsBlocked", payload.get("robots_blocked", [])),
}
return CrawlErrorsResponse(**normalized)
async def get_active_crawls(client: AsyncHttpClient) -> ActiveCrawlsResponse:
"""
Get active crawl jobs.
Args:
client: Async HTTP client instance
Returns:
ActiveCrawlsResponse with active crawl jobs
Raises:
Exception: If the active crawl jobs operation fails
"""
response = await client.get("/v2/crawl/active")
if response.status_code >= 400:
handle_response_error(response, "get active crawls")
body = response.json()
if not body.get("success"):
raise Exception(body.get("error", "Unknown error occurred"))
crawls_in = body.get("crawls", [])
normalized = []
for c in crawls_in:
if isinstance(c, dict):
normalized.append({
"id": c.get("id"),
"team_id": c.get("teamId", c.get("team_id")),
"url": c.get("url"),
"options": c.get("options"),
})
return ActiveCrawlsResponse(success=True, crawls=[ActiveCrawl(**nc) for nc in normalized])

View File

@@ -0,0 +1,164 @@
from typing import Any, Dict, List, Optional
import asyncio
import warnings
from ...types import ExtractResponse, ScrapeOptions
from ...utils.http_client_async import AsyncHttpClient
from ...utils.validation import prepare_scrape_options
_EXTRACT_DEPRECATION_MSG = (
"The extract endpoint is in maintenance mode and its use is discouraged. "
"Review https://docs.firecrawl.dev/developer-guides/usage-guides/choosing-the-data-extractor "
"to find a replacement."
)
def _prepare_extract_request(
urls: Optional[List[str]],
*,
prompt: Optional[str] = None,
schema: Optional[Dict[str, Any]] = None,
system_prompt: Optional[str] = None,
allow_external_links: Optional[bool] = None,
enable_web_search: Optional[bool] = None,
show_sources: Optional[bool] = None,
scrape_options: Optional[ScrapeOptions] = None,
ignore_invalid_urls: Optional[bool] = None,
integration: Optional[str] = None,
) -> Dict[str, Any]:
body: Dict[str, Any] = {}
if urls is not None:
body["urls"] = urls
if prompt is not None:
body["prompt"] = prompt
if schema is not None:
body["schema"] = schema
if system_prompt is not None:
body["systemPrompt"] = system_prompt
if allow_external_links is not None:
body["allowExternalLinks"] = allow_external_links
if enable_web_search is not None:
body["enableWebSearch"] = enable_web_search
if show_sources is not None:
body["showSources"] = show_sources
if ignore_invalid_urls is not None:
body["ignoreInvalidURLs"] = ignore_invalid_urls
if scrape_options is not None:
prepared = prepare_scrape_options(scrape_options)
if prepared:
body["scrapeOptions"] = prepared
if integration is not None and str(integration).strip():
body["integration"] = str(integration).strip()
return body
async def start_extract(
client: AsyncHttpClient,
urls: Optional[List[str]],
*,
prompt: Optional[str] = None,
schema: Optional[Dict[str, Any]] = None,
system_prompt: Optional[str] = None,
allow_external_links: Optional[bool] = None,
enable_web_search: Optional[bool] = None,
show_sources: Optional[bool] = None,
scrape_options: Optional[ScrapeOptions] = None,
ignore_invalid_urls: Optional[bool] = None,
integration: Optional[str] = None,
) -> ExtractResponse:
"""Start an extract job (non-blocking, async).
.. deprecated::
The extract endpoint is in maintenance mode and its use is discouraged.
Review https://docs.firecrawl.dev/developer-guides/usage-guides/choosing-the-data-extractor
to find a replacement.
"""
warnings.warn(_EXTRACT_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
body = _prepare_extract_request(
urls,
prompt=prompt,
schema=schema,
system_prompt=system_prompt,
allow_external_links=allow_external_links,
enable_web_search=enable_web_search,
show_sources=show_sources,
scrape_options=scrape_options,
ignore_invalid_urls=ignore_invalid_urls,
integration=integration,
)
resp = await client.post("/v2/extract", body)
return ExtractResponse(**resp.json())
async def get_extract_status(client: AsyncHttpClient, job_id: str) -> ExtractResponse:
"""Get the current status of an extract job (async).
.. deprecated::
The extract endpoint is in maintenance mode and its use is discouraged.
Review https://docs.firecrawl.dev/developer-guides/usage-guides/choosing-the-data-extractor
to find a replacement.
"""
warnings.warn(_EXTRACT_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
resp = await client.get(f"/v2/extract/{job_id}")
return ExtractResponse(**resp.json())
async def wait_extract(
client: AsyncHttpClient,
job_id: str,
*,
poll_interval: int = 2,
timeout: Optional[int] = None,
) -> ExtractResponse:
start_ts = asyncio.get_event_loop().time()
while True:
status = await get_extract_status(client, job_id)
if status.status in ("completed", "failed", "cancelled"):
return status
if timeout is not None and (asyncio.get_event_loop().time() - start_ts) > timeout:
return status
await asyncio.sleep(max(1, poll_interval))
async def extract(
client: AsyncHttpClient,
urls: Optional[List[str]],
*,
prompt: Optional[str] = None,
schema: Optional[Dict[str, Any]] = None,
system_prompt: Optional[str] = None,
allow_external_links: Optional[bool] = None,
enable_web_search: Optional[bool] = None,
show_sources: Optional[bool] = None,
scrape_options: Optional[ScrapeOptions] = None,
ignore_invalid_urls: Optional[bool] = None,
poll_interval: int = 2,
timeout: Optional[int] = None,
integration: Optional[str] = None,
) -> ExtractResponse:
"""Extract structured data and wait until completion (async).
.. deprecated::
The extract endpoint is in maintenance mode and its use is discouraged.
Review https://docs.firecrawl.dev/developer-guides/usage-guides/choosing-the-data-extractor
to find a replacement.
"""
warnings.warn(_EXTRACT_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
started = await start_extract(
client,
urls,
prompt=prompt,
schema=schema,
system_prompt=system_prompt,
allow_external_links=allow_external_links,
enable_web_search=enable_web_search,
show_sources=show_sources,
scrape_options=scrape_options,
ignore_invalid_urls=ignore_invalid_urls,
integration=integration,
)
job_id = getattr(started, "id", None)
if not job_id:
return started
return await wait_extract(client, job_id, poll_interval=poll_interval, timeout=timeout)

View File

@@ -0,0 +1,67 @@
from typing import Optional, Dict, Any
from ...types import MapOptions, MapData, LinkResult
from ...utils.http_client_async import AsyncHttpClient
from ...utils.error_handler import handle_response_error
def _prepare_map_request(url: str, options: Optional[MapOptions] = None) -> Dict[str, Any]:
if not url or not url.strip():
raise ValueError("URL cannot be empty")
payload: Dict[str, Any] = {"url": url.strip()}
if options is not None:
data: Dict[str, Any] = {}
if getattr(options, "sitemap", None) is not None:
data["sitemap"] = options.sitemap
if options.search is not None:
data["search"] = options.search
if options.include_subdomains is not None:
data["includeSubdomains"] = options.include_subdomains
if options.ignore_query_parameters is not None:
data["ignoreQueryParameters"] = options.ignore_query_parameters
if options.limit is not None:
if options.limit <= 0:
raise ValueError("Limit must be positive")
data["limit"] = options.limit
if options.timeout is not None:
data["timeout"] = options.timeout
if options.integration is not None:
data["integration"] = options.integration.strip()
if options.location is not None:
data["location"] = options.location.model_dump(exclude_none=True)
payload.update(data)
return payload
async def map(client: AsyncHttpClient, url: str, options: Optional[MapOptions] = None) -> MapData:
request_data = _prepare_map_request(url, options)
response = await client.post("/v2/map", request_data)
if response.status_code >= 400:
handle_response_error(response, "map")
body = response.json()
if not body.get("success"):
raise Exception(body.get("error", "Unknown error occurred"))
# data = body.get("data", {})
# result_links: list[LinkResult] = []
# for item in data.get("links", []):
# if isinstance(item, dict):
# result_links.append(
# LinkResult(
# url=item.get("url", ""),
# title=item.get("title"),
# description=item.get("description"),
# )
# )
# elif isinstance(item, str):
# result_links.append(LinkResult(url=item))
result_links: list[LinkResult] = []
for item in body.get("links", []):
if isinstance(item, dict):
result_links.append(LinkResult(url=item.get("url", ""), title=item.get("title"), description=item.get("description")))
elif isinstance(item, str):
result_links.append(LinkResult(url=item))
return MapData(links=result_links)

View File

@@ -0,0 +1,215 @@
import time
from typing import Any, Dict, List, Optional
from pydantic import BaseModel
from ...types import (
Monitor,
MonitorCheck,
MonitorCheckDetail,
MonitorCheckPage,
MonitorCreateRequest,
PaginationConfig,
MonitorTarget,
MonitorUpdateRequest,
ScrapeOptions,
)
from ...utils.error_handler import handle_response_error
from ...utils.http_client_async import AsyncHttpClient
from ...utils.validation import prepare_scrape_options
def _dump(value: Any) -> Any:
if isinstance(value, ScrapeOptions):
return prepare_scrape_options(value)
if isinstance(value, MonitorTarget):
data = value.model_dump(exclude_none=True, by_alias=True)
if isinstance(value.scrape_options, ScrapeOptions):
data["scrapeOptions"] = prepare_scrape_options(value.scrape_options)
return _prepare_target(data)
if isinstance(value, BaseModel):
return value.model_dump(exclude_none=True, by_alias=True)
if isinstance(value, list):
return [_dump(item) for item in value]
if isinstance(value, dict):
return {key: _dump(item) for key, item in value.items() if item is not None}
return value
def _prepare_target(target: Dict[str, Any]) -> Dict[str, Any]:
prepared = dict(target)
if "scrapeOptions" in prepared and isinstance(prepared["scrapeOptions"], ScrapeOptions):
prepared["scrapeOptions"] = prepare_scrape_options(prepared["scrapeOptions"])
if "crawlOptions" in prepared:
prepared["crawlOptions"] = _dump(prepared["crawlOptions"])
return prepared
def _prepare_payload(request: Any) -> Dict[str, Any]:
payload = _dump(request)
if not isinstance(payload, dict):
raise ValueError("Monitor request must be an object")
if "targets" in payload:
payload["targets"] = [
_prepare_target(_dump(target))
for target in payload.get("targets", [])
]
return payload
async def _data_or_error(response, action: str) -> Any:
if response.status_code >= 400:
handle_response_error(response, action)
body = response.json()
if not body.get("success"):
raise Exception(body.get("error", "Unknown error occurred"))
return body.get("data")
async def _monitor_check_data_or_error(response, action: str) -> Dict[str, Any]:
if response.status_code >= 400:
handle_response_error(response, action)
body = response.json()
if not body.get("success"):
raise Exception(body.get("error", "Unknown error occurred"))
data = body.get("data") or {}
if body.get("next") is not None:
data["next"] = body.get("next")
return data
async def _fetch_all_monitor_check_pages(
client: AsyncHttpClient,
next_url: str,
initial_pages: List[MonitorCheckPage],
pagination_config: Optional[PaginationConfig] = None,
) -> List[MonitorCheckPage]:
pages = initial_pages.copy()
current_url = next_url
page_count = 0
max_pages = pagination_config.max_pages if pagination_config else None
max_results = pagination_config.max_results if pagination_config else None
max_wait_time = pagination_config.max_wait_time if pagination_config else None
start_time = time.monotonic()
while current_url:
if max_pages is not None and page_count >= max_pages:
break
if max_wait_time is not None and (time.monotonic() - start_time) > max_wait_time:
break
response = await client.get(current_url)
if response.status_code >= 400:
break
try:
data = await _monitor_check_data_or_error(response, "get monitor check page")
except Exception:
break
for page in data.get("pages") or []:
if max_results is not None and len(pages) >= max_results:
break
pages.append(MonitorCheckPage(**page))
if max_results is not None and len(pages) >= max_results:
break
current_url = data.get("next")
page_count += 1
return pages
async def create_monitor(client: AsyncHttpClient, request: MonitorCreateRequest) -> Monitor:
data = await _data_or_error(await client.post("/v2/monitor", _prepare_payload(request)), "create monitor")
return Monitor(**data)
async def list_monitors(client: AsyncHttpClient, *, limit: Optional[int] = None, offset: Optional[int] = None) -> List[Monitor]:
params = []
if limit is not None:
params.append(f"limit={limit}")
if offset is not None:
params.append(f"offset={offset}")
suffix = f"?{'&'.join(params)}" if params else ""
data = await _data_or_error(await client.get(f"/v2/monitor{suffix}"), "list monitors")
return [Monitor(**item) for item in data or []]
async def get_monitor(client: AsyncHttpClient, monitor_id: str) -> Monitor:
data = await _data_or_error(await client.get(f"/v2/monitor/{monitor_id}"), "get monitor")
return Monitor(**data)
async def update_monitor(client: AsyncHttpClient, monitor_id: str, request: MonitorUpdateRequest) -> Monitor:
data = await _data_or_error(await client.patch(f"/v2/monitor/{monitor_id}", _prepare_payload(request)), "update monitor")
return Monitor(**data)
async def delete_monitor(client: AsyncHttpClient, monitor_id: str) -> bool:
response = await client.delete(f"/v2/monitor/{monitor_id}")
if response.status_code >= 400:
handle_response_error(response, "delete monitor")
body = response.json()
if not body.get("success"):
raise Exception(body.get("error", "Unknown error occurred"))
return True
async def run_monitor(client: AsyncHttpClient, monitor_id: str) -> MonitorCheck:
data = await _data_or_error(await client.post(f"/v2/monitor/{monitor_id}/run", {}), "run monitor")
return MonitorCheck(**data)
async def list_monitor_checks(
client: AsyncHttpClient,
monitor_id: str,
*,
limit: Optional[int] = None,
offset: Optional[int] = None,
) -> List[MonitorCheck]:
params = []
if limit is not None:
params.append(f"limit={limit}")
if offset is not None:
params.append(f"offset={offset}")
suffix = f"?{'&'.join(params)}" if params else ""
data = await _data_or_error(await client.get(f"/v2/monitor/{monitor_id}/checks{suffix}"), "list monitor checks")
return [MonitorCheck(**item) for item in data or []]
async def get_monitor_check(
client: AsyncHttpClient,
monitor_id: str,
check_id: str,
*,
limit: Optional[int] = None,
skip: Optional[int] = None,
status: Optional[str] = None,
pagination_config: Optional[PaginationConfig] = None,
) -> MonitorCheckDetail:
params = []
if limit is not None:
params.append(f"limit={limit}")
if skip is not None:
params.append(f"skip={skip}")
if status is not None:
params.append(f"status={status}")
suffix = f"?{'&'.join(params)}" if params else ""
data = await _monitor_check_data_or_error(await client.get(f"/v2/monitor/{monitor_id}/checks/{check_id}{suffix}"), "get monitor check")
detail = MonitorCheckDetail(**data)
auto_paginate = pagination_config.auto_paginate if pagination_config else True
if auto_paginate and detail.next and not (
pagination_config
and pagination_config.max_results is not None
and len(detail.pages) >= pagination_config.max_results
):
detail.pages = await _fetch_all_monitor_check_pages(
client,
detail.next,
detail.pages,
pagination_config,
)
detail.next = None
return detail

View File

@@ -0,0 +1,63 @@
import asyncio
from functools import partial
import json
from typing import Optional, Dict, Any, Tuple
from ...types import Document, ParseOptions
from ...utils.normalize import normalize_document_input
from ...utils.error_handler import handle_response_error
from ...utils.http_client_async import AsyncHttpClient
from ..parse import (
ParseFileInput,
_prepare_file_payload,
_prepare_parse_options_payload,
)
async def _prepare_parse_request(
file: ParseFileInput,
options: Optional[ParseOptions] = None,
*,
filename: Optional[str] = None,
content_type: Optional[str] = None,
) -> Tuple[Dict[str, Any], Dict[str, Tuple[str, bytes, str]]]:
request_data = _prepare_parse_options_payload(options)
multipart_fields = {"options": json.dumps(request_data)}
loop = asyncio.get_running_loop()
multipart_files = await loop.run_in_executor(
None,
partial(
_prepare_file_payload,
file,
filename=filename,
content_type=content_type,
),
)
return multipart_fields, multipart_files
async def parse(
client: AsyncHttpClient,
file: ParseFileInput,
options: Optional[ParseOptions] = None,
*,
filename: Optional[str] = None,
content_type: Optional[str] = None,
) -> Document:
fields, files = await _prepare_parse_request(
file,
options,
filename=filename,
content_type=content_type,
)
response = await client.post_multipart("/v2/parse", data=fields, files=files)
if response.status_code >= 400:
handle_response_error(response, "parse")
body = response.json()
if not body.get("success"):
raise Exception(body.get("error", "Unknown error occurred"))
document_data = body.get("data", {})
normalized = normalize_document_input(document_data)
return Document(**normalized)

View File

@@ -0,0 +1,144 @@
from typing import Optional, Dict, Any, Literal
from ...types import (
ScrapeOptions,
Document,
BrowserExecuteResponse,
BrowserDeleteResponse,
)
from ...utils.normalize import normalize_document_input
from ...utils.error_handler import handle_response_error
from ...utils.validation import prepare_scrape_options, validate_scrape_options
from ...utils.http_client_async import AsyncHttpClient
async def _prepare_scrape_request(url: str, options: Optional[ScrapeOptions] = None) -> Dict[str, Any]:
if not url or not url.strip():
raise ValueError("URL cannot be empty")
payload: Dict[str, Any] = {"url": url.strip()}
if options is not None:
validated = validate_scrape_options(options)
if validated is not None:
opts = prepare_scrape_options(validated)
if opts:
payload.update(opts)
return payload
async def scrape(client: AsyncHttpClient, url: str, options: Optional[ScrapeOptions] = None) -> Document:
payload = await _prepare_scrape_request(url, options)
response = await client.post("/v2/scrape", payload)
if response.status_code >= 400:
handle_response_error(response, "scrape")
body = response.json()
if not body.get("success"):
raise Exception(body.get("error", "Unknown error occurred"))
document_data = body.get("data", {})
normalized = normalize_document_input(document_data)
return Document(**normalized)
async def interact(
client: AsyncHttpClient,
job_id: str,
code: Optional[str] = None,
*,
prompt: Optional[str] = None,
language: Literal["python", "node", "bash"] = "node",
timeout: Optional[int] = None,
origin: Optional[str] = None,
) -> BrowserExecuteResponse:
if not job_id or not job_id.strip():
raise ValueError("Job ID cannot be empty")
has_code = code and code.strip()
has_prompt = prompt and prompt.strip()
if not has_code and not has_prompt:
raise ValueError("Either 'code' or 'prompt' must be provided")
payload: Dict[str, Any] = {
"language": language,
}
if has_code:
payload["code"] = code
if has_prompt:
payload["prompt"] = prompt
if timeout is not None:
payload["timeout"] = timeout
if origin is not None:
payload["origin"] = origin
response = await client.post(f"/v2/scrape/{job_id}/interact", payload)
if response.status_code >= 400:
handle_response_error(response, "interact with scrape browser")
body = response.json()
if not body.get("success"):
raise Exception(body.get("error", "Unknown error occurred"))
normalized = dict(body)
if "exitCode" in normalized and "exit_code" not in normalized:
normalized["exit_code"] = normalized["exitCode"]
if "liveViewUrl" in normalized and "live_view_url" not in normalized:
normalized["live_view_url"] = normalized["liveViewUrl"]
if "interactiveLiveViewUrl" in normalized and "interactive_live_view_url" not in normalized:
normalized["interactive_live_view_url"] = normalized["interactiveLiveViewUrl"]
return BrowserExecuteResponse(**normalized)
async def stop_interaction(
client: AsyncHttpClient,
job_id: str,
) -> BrowserDeleteResponse:
if not job_id or not job_id.strip():
raise ValueError("Job ID cannot be empty")
response = await client.delete(f"/v2/scrape/{job_id}/interact")
if response.status_code >= 400:
handle_response_error(response, "stop interaction")
body = response.json()
normalized = dict(body)
if "sessionDurationMs" in normalized and "session_duration_ms" not in normalized:
normalized["session_duration_ms"] = normalized["sessionDurationMs"]
if "creditsBilled" in normalized and "credits_billed" not in normalized:
normalized["credits_billed"] = normalized["creditsBilled"]
return BrowserDeleteResponse(**normalized)
async def stop_interactive_browser(
client: AsyncHttpClient,
job_id: str,
) -> BrowserDeleteResponse:
"""Deprecated alias for stop_interaction()."""
return await stop_interaction(client, job_id)
async def scrape_execute(
client: AsyncHttpClient,
job_id: str,
code: Optional[str] = None,
*,
prompt: Optional[str] = None,
language: Literal["python", "node", "bash"] = "node",
timeout: Optional[int] = None,
origin: Optional[str] = None,
) -> BrowserExecuteResponse:
"""Deprecated alias for interact()."""
return await interact(
client,
job_id,
code,
prompt=prompt,
language=language,
timeout=timeout,
origin=origin,
)
async def delete_scrape_browser(
client: AsyncHttpClient,
job_id: str,
) -> BrowserDeleteResponse:
"""Deprecated alias for stop_interaction()."""
return await stop_interaction(client, job_id)

View File

@@ -0,0 +1,178 @@
from typing import Dict, Any, Union, List, TypeVar, Type
from ...types import (
SearchRequest,
SearchData,
Document,
SearchResultWeb,
SearchResultNews,
SearchResultImages,
)
from ...utils.http_client_async import AsyncHttpClient
from ...utils.error_handler import handle_response_error
from ...utils.normalize import normalize_document_input
from ...utils.validation import validate_scrape_options, prepare_scrape_options
T = TypeVar("T")
async def search(
client: AsyncHttpClient,
request: SearchRequest
) -> SearchData:
"""
Async search for documents.
Args:
client: Async HTTP client instance
request: Search request
Returns:
SearchData with search results grouped by source type
Raises:
FirecrawlError: If the search operation fails
"""
request_data = _prepare_search_request(request)
try:
response = await client.post("/v2/search", request_data)
if response.status_code != 200:
handle_response_error(response, "search")
response_data = response.json()
if not response_data.get("success"):
handle_response_error(response, "search")
data = response_data.get("data", {}) or {}
out = SearchData()
if "web" in data:
out.web = _transform_array(data["web"], SearchResultWeb)
if "news" in data:
out.news = _transform_array(data["news"], SearchResultNews)
if "images" in data:
out.images = _transform_array(data["images"], SearchResultImages)
return out
except Exception as err:
if hasattr(err, "response"):
handle_response_error(getattr(err, "response"), "search")
raise err
def _transform_array(arr: List[Any], result_type: Type[T]) -> List[Union[T, Document]]:
"""
Transforms an array of items into a list of result_type or Document.
If the item dict contains any of the special keys, it is treated as a Document.
Otherwise, it is treated as result_type.
If the item is not a dict, it is wrapped as result_type with url=item.
"""
results: List[Union[T, Document]] = []
for item in arr:
if item and isinstance(item, dict):
if (
"markdown" in item or
"html" in item or
"rawHtml" in item or
"links" in item or
"screenshot" in item or
"changeTracking" in item or
"summary" in item or
"json" in item
):
results.append(Document(**normalize_document_input(item)))
else:
results.append(result_type(**item))
else:
results.append(result_type(url=item))
return results
def _validate_search_request(request: SearchRequest) -> SearchRequest:
"""
Validate and normalize search request.
Args:
request: Search request to validate
Returns:
Validated request
Raises:
ValueError: If request is invalid
"""
if not request.query or not request.query.strip():
raise ValueError("Query cannot be empty")
if request.limit is not None:
if request.limit <= 0:
raise ValueError("Limit must be positive")
if request.limit > 100:
raise ValueError("Limit cannot exceed 100")
if request.timeout is not None:
if request.timeout <= 0:
raise ValueError("Timeout must be positive")
if request.timeout > 300000:
raise ValueError("Timeout cannot exceed 300000ms (5 minutes)")
if request.sources is not None:
valid_sources = {"web", "news", "images"}
for source in request.sources:
if isinstance(source, str):
if source not in valid_sources:
raise ValueError(f"Invalid source type: {source}. Valid types: {valid_sources}")
elif hasattr(source, 'type'):
if source.type not in valid_sources:
raise ValueError(f"Invalid source type: {source.type}. Valid types: {valid_sources}")
if request.include_domains and request.exclude_domains:
raise ValueError(
"include_domains and exclude_domains cannot both be specified"
)
if request.location is not None:
if not isinstance(request.location, str) or len(request.location.strip()) == 0:
raise ValueError("Location must be a non-empty string")
if request.tbs is not None:
if not isinstance(request.tbs, str) or len(request.tbs.strip()) == 0:
raise ValueError("tbs must be a non-empty string")
if request.scrape_options is not None:
validate_scrape_options(request.scrape_options)
return request
def _prepare_search_request(request: SearchRequest) -> Dict[str, Any]:
"""
Prepare a search request payload.
Args:
request: Search request
Returns:
Request payload dictionary
"""
validated_request = _validate_search_request(request)
data = validated_request.model_dump(exclude_none=True, by_alias=True)
if "limit" not in data and validated_request.limit is not None:
data["limit"] = validated_request.limit
if "timeout" not in data and validated_request.timeout is not None:
data["timeout"] = validated_request.timeout
if validated_request.ignore_invalid_urls is not None:
data["ignoreInvalidURLs"] = validated_request.ignore_invalid_urls
data.pop("ignore_invalid_urls", None)
if validated_request.include_domains is not None:
data["includeDomains"] = validated_request.include_domains
data.pop("include_domains", None)
if validated_request.exclude_domains is not None:
data["excludeDomains"] = validated_request.exclude_domains
data.pop("exclude_domains", None)
if validated_request.scrape_options is not None:
scrape_data = prepare_scrape_options(validated_request.scrape_options)
if scrape_data:
data["scrapeOptions"] = scrape_data
data.pop("scrape_options", None)
if (v := getattr(validated_request, "integration", None)) is not None and str(v).strip():
data["integration"] = str(validated_request.integration).strip()
return data

View File

@@ -0,0 +1,89 @@
from ...utils.http_client_async import AsyncHttpClient
from ...utils.error_handler import handle_response_error
from ...types import ConcurrencyCheck, CreditUsage, TokenUsage, CreditUsageHistoricalResponse, TokenUsageHistoricalResponse, QueueStatusResponse
async def get_concurrency(client: AsyncHttpClient) -> ConcurrencyCheck:
resp = await client.get("/v2/concurrency-check")
if resp.status_code >= 400:
handle_response_error(resp, "get concurrency")
body = resp.json()
if not body.get("success"):
raise Exception(body.get("error", "Unknown error"))
data = body.get("data", body)
return ConcurrencyCheck(
concurrency=data.get("concurrency"),
max_concurrency=data.get("maxConcurrency", data.get("max_concurrency")),
)
async def get_credit_usage(client: AsyncHttpClient) -> CreditUsage:
resp = await client.get("/v2/team/credit-usage")
if resp.status_code >= 400:
handle_response_error(resp, "get credit usage")
body = resp.json()
if not body.get("success"):
raise Exception(body.get("error", "Unknown error"))
data = body.get("data", body)
return CreditUsage(
remaining_credits=data.get("remainingCredits", data.get("remaining_credits", 0)),
plan_credits=data.get("planCredits", data.get("plan_credits")),
billing_period_start=data.get("billingPeriodStart", data.get("billing_period_start")),
billing_period_end=data.get("billingPeriodEnd", data.get("billing_period_end")),
)
async def get_token_usage(client: AsyncHttpClient) -> TokenUsage:
resp = await client.get("/v2/team/token-usage")
if resp.status_code >= 400:
handle_response_error(resp, "get token usage")
body = resp.json()
if not body.get("success"):
raise Exception(body.get("error", "Unknown error"))
data = body.get("data", body)
return TokenUsage(
remaining_tokens=data.get("remainingTokens", data.get("remaining_tokens", 0)),
plan_tokens=data.get("planTokens", data.get("plan_tokens")),
billing_period_start=data.get("billingPeriodStart", data.get("billing_period_start")),
billing_period_end=data.get("billingPeriodEnd", data.get("billing_period_end")),
)
async def get_queue_status(client: AsyncHttpClient) -> QueueStatusResponse:
resp = await client.get("/v2/team/queue-status")
if resp.status_code >= 400:
handle_response_error(resp, "get queue status")
body = resp.json()
if not body.get("success"):
raise Exception(body.get("error", "Unknown error"))
data = body.get("data", body)
return QueueStatusResponse(
jobs_in_queue=data.get("jobsInQueue", 0),
active_jobs_in_queue=data.get("activeJobsInQueue", 0),
waiting_jobs_in_queue=data.get("waitingJobsInQueue", 0),
max_concurrency=data.get("maxConcurrency", 0),
most_recent_success=data.get("mostRecentSuccess", None),
)
async def get_credit_usage_historical(client: AsyncHttpClient, by_api_key: bool = False) -> CreditUsageHistoricalResponse:
query = "?byApiKey=true" if by_api_key else ""
resp = await client.get(f"/v2/team/credit-usage/historical{query}")
if resp.status_code >= 400:
handle_response_error(resp, "get credit usage historical")
body = resp.json()
if not body.get("success"):
raise Exception(body.get("error", "Unknown error"))
return CreditUsageHistoricalResponse(**body)
async def get_token_usage_historical(client: AsyncHttpClient, by_api_key: bool = False) -> TokenUsageHistoricalResponse:
query = "?byApiKey=true" if by_api_key else ""
resp = await client.get(f"/v2/team/token-usage/historical{query}")
if resp.status_code >= 400:
handle_response_error(resp, "get token usage historical")
body = resp.json()
if not body.get("success"):
raise Exception(body.get("error", "Unknown error"))
return TokenUsageHistoricalResponse(**body)