참고소스 수정본

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,9 @@
"""
Utility modules for v2 API client.
"""
from .http_client import HttpClient
from .error_handler import FirecrawlError, handle_response_error
from .validation import validate_scrape_options, prepare_scrape_options
__all__ = ['HttpClient', 'FirecrawlError', 'handle_response_error', 'validate_scrape_options', 'prepare_scrape_options']

View File

@@ -0,0 +1,107 @@
"""
Error handling utilities for v2 API.
"""
import requests
from typing import Dict, Any, Optional
class FirecrawlError(Exception):
"""Base exception for Firecrawl API errors."""
def __init__(self, message: str, status_code: Optional[int] = None, response: Optional[requests.Response] = None):
super().__init__(message)
self.status_code = status_code
self.response = response
class BadRequestError(FirecrawlError):
"""Raised when the request is invalid (400)."""
pass
class UnauthorizedError(FirecrawlError):
"""Raised when the request is unauthorized (401)."""
pass
class PaymentRequiredError(FirecrawlError):
"""Raised when payment is required (402)."""
pass
class WebsiteNotSupportedError(FirecrawlError):
"""Raised when website is not supported (403)."""
pass
class RequestTimeoutError(FirecrawlError):
"""Raised when request times out (408)."""
pass
class RateLimitError(FirecrawlError):
"""Raised when the rate limit is exceeded (429)."""
pass
class InternalServerError(FirecrawlError):
"""Raised when there's an internal server error (500)."""
pass
def handle_response_error(response: requests.Response, action: str) -> None:
"""
Handle API response errors and raise appropriate exceptions.
Args:
response: The HTTP response object
action: Description of the action being performed
Raises:
FirecrawlError: Appropriate error based on status code
"""
try:
response_json = response.json()
error_message = response_json.get('error', 'No error message provided.')
error_details = response_json.get('details', 'No additional error details provided.')
except:
# If we can't parse JSON, provide a helpful error message
try:
response_text = response.text[:500] # Limit to first 500 chars
if response_text.strip():
error_message = f"Server returned non-JSON response: {response_text}"
error_details = f"Full response status: {response.status_code}"
else:
error_message = f"Server returned empty response with status {response.status_code}"
error_details = "No additional details available"
except:
error_message = f"Server returned unreadable response with status {response.status_code}"
error_details = "No additional details available"
# Create appropriate error message
if response.status_code == 400:
message = f"Bad Request: Failed to {action}. {error_message} - {error_details}"
raise BadRequestError(message, response.status_code, response)
elif response.status_code == 401:
message = f"Unauthorized: Failed to {action}. {error_message} - {error_details}"
raise UnauthorizedError(message, response.status_code, response)
elif response.status_code == 402:
message = f"Payment Required: Failed to {action}. {error_message} - {error_details}"
raise PaymentRequiredError(message, response.status_code, response)
elif response.status_code == 403:
message = f"Website Not Supported: Failed to {action}. {error_message} - {error_details}"
raise WebsiteNotSupportedError(message, response.status_code, response)
elif response.status_code == 408:
message = f"Request Timeout: Failed to {action} as the request timed out. {error_message} - {error_details}"
raise RequestTimeoutError(message, response.status_code, response)
elif response.status_code == 429:
message = f"Rate Limit Exceeded: Failed to {action}. {error_message} - {error_details}"
raise RateLimitError(message, response.status_code, response)
elif response.status_code == 500:
message = f"Internal Server Error: Failed to {action}. {error_message} - {error_details}"
raise InternalServerError(message, response.status_code, response)
else:
message = f"Unexpected error during {action}: Status code {response.status_code}. {error_message} - {error_details}"
raise FirecrawlError(message, response.status_code, response)

View File

@@ -0,0 +1,15 @@
import os
import re
from pathlib import Path
def get_version():
try:
package_path = Path(__file__).parents[2]
version_file = (package_path / "__init__.py").read_text()
version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M)
if version_match:
return version_match.group(1).strip()
return "3.x.x"
except Exception as e:
print(f"Failed to get version from __init__.py: {e}")
return "3.x.x"

View File

@@ -0,0 +1,312 @@
"""
HTTP client utilities for v2 API.
"""
import time
from typing import Dict, Any, Optional
from urllib.parse import urlparse, urlunparse, urljoin
import requests
from .get_version import get_version
version = get_version()
class HttpClient:
"""HTTP client with retry logic and error handling."""
def __init__(
self,
api_key: Optional[str],
api_url: str,
timeout: Optional[float] = None,
max_retries: int = 3,
backoff_factor: float = 0.5,
):
self.api_key = api_key
self.api_url = api_url
self.timeout = timeout
self.max_retries = max_retries
self.backoff_factor = backoff_factor
def _build_url(self, endpoint: str) -> str:
base = urlparse(self.api_url)
ep = urlparse(endpoint)
# Absolute or protocol-relative (has netloc)
if ep.netloc:
# Different host: keep path/query but force base host/scheme (no token leakage)
path = ep.path or "/"
if (ep.hostname or "") != (base.hostname or ""):
return urlunparse((base.scheme or "https", base.netloc, path, "", ep.query, ""))
# Same host: normalize scheme to base
return urlunparse((base.scheme or "https", base.netloc, path, "", ep.query, ""))
# Relative (including leading slash or not)
base_str = self.api_url if self.api_url.endswith("/") else f"{self.api_url}/"
# Guard protocol-relative like //host/path slipping through as “relative”
if endpoint.startswith("//"):
ep2 = urlparse(f"https:{endpoint}")
path = ep2.path or "/"
return urlunparse((base.scheme or "https", base.netloc, path, "", ep2.query, ""))
return urljoin(base_str, endpoint)
def _prepare_headers(
self,
idempotency_key: Optional[str] = None,
include_json_content_type: bool = True,
) -> Dict[str, str]:
"""Prepare headers for API requests."""
headers: Dict[str, str] = {}
if include_json_content_type:
headers['Content-Type'] = 'application/json'
if self.api_key:
headers['Authorization'] = f'Bearer {self.api_key}'
if idempotency_key:
headers['x-idempotency-key'] = idempotency_key
return headers
def post(
self,
endpoint: str,
data: Dict[str, Any],
headers: Optional[Dict[str, str]] = None,
timeout: Optional[float] = None,
retries: Optional[int] = None,
backoff_factor: Optional[float] = None,
) -> requests.Response:
"""Make a POST request with retry logic."""
if headers is None:
headers = self._prepare_headers()
if timeout is None:
timeout = self.timeout
if retries is None:
retries = self.max_retries
if backoff_factor is None:
backoff_factor = self.backoff_factor
payload = dict(data)
payload['origin'] = f'python-sdk@{version}'
url = self._build_url(endpoint)
last_exception = None
num_attempts = max(1, retries)
for attempt in range(num_attempts):
try:
response = requests.post(
url,
headers=headers,
json=payload,
timeout=timeout
)
if response.status_code == 502:
if attempt < num_attempts - 1:
time.sleep(backoff_factor * (2 ** attempt))
continue
return response
except requests.RequestException as e:
last_exception = e
if attempt == num_attempts - 1:
raise e
time.sleep(backoff_factor * (2 ** attempt))
# This should never be reached due to the exception handling above
raise last_exception or Exception("Unexpected error in POST request")
def post_multipart(
self,
endpoint: str,
data: Dict[str, Any],
files: Dict[str, Any],
headers: Optional[Dict[str, str]] = None,
timeout: Optional[float] = None,
retries: Optional[int] = None,
backoff_factor: Optional[float] = None,
) -> requests.Response:
"""Make a multipart/form-data POST request with retry logic."""
multipart_headers = self._prepare_headers(include_json_content_type=False)
if headers:
multipart_headers.update(headers)
multipart_headers.pop("Content-Type", None)
multipart_headers.pop("content-type", None)
if timeout is None:
timeout = self.timeout
if retries is None:
retries = self.max_retries
if backoff_factor is None:
backoff_factor = self.backoff_factor
url = self._build_url(endpoint)
last_exception = None
num_attempts = max(1, retries)
for attempt in range(num_attempts):
try:
response = requests.post(
url,
headers=multipart_headers,
data=data,
files=files,
timeout=timeout,
)
if response.status_code == 502:
if attempt < num_attempts - 1:
time.sleep(backoff_factor * (2 ** attempt))
continue
return response
except requests.RequestException as e:
last_exception = e
if attempt == num_attempts - 1:
raise e
time.sleep(backoff_factor * (2 ** attempt))
raise last_exception or Exception("Unexpected error in multipart POST request")
def get(
self,
endpoint: str,
headers: Optional[Dict[str, str]] = None,
timeout: Optional[float] = None,
retries: Optional[int] = None,
backoff_factor: Optional[float] = None,
) -> requests.Response:
"""Make a GET request with retry logic."""
if headers is None:
headers = self._prepare_headers()
if timeout is None:
timeout = self.timeout
if retries is None:
retries = self.max_retries
if backoff_factor is None:
backoff_factor = self.backoff_factor
url = self._build_url(endpoint)
last_exception = None
num_attempts = max(1, retries)
for attempt in range(num_attempts):
try:
response = requests.get(
url,
headers=headers,
timeout=timeout
)
if response.status_code == 502:
if attempt < num_attempts - 1:
time.sleep(backoff_factor * (2 ** attempt))
continue
return response
except requests.RequestException as e:
last_exception = e
if attempt == num_attempts - 1:
raise e
time.sleep(backoff_factor * (2 ** attempt))
# This should never be reached due to the exception handling above
raise last_exception or Exception("Unexpected error in GET request")
def delete(
self,
endpoint: str,
headers: Optional[Dict[str, str]] = None,
timeout: Optional[float] = None,
retries: Optional[int] = None,
backoff_factor: Optional[float] = None,
) -> requests.Response:
"""Make a DELETE request with retry logic."""
if headers is None:
headers = self._prepare_headers()
if timeout is None:
timeout = self.timeout
if retries is None:
retries = self.max_retries
if backoff_factor is None:
backoff_factor = self.backoff_factor
url = self._build_url(endpoint)
last_exception = None
num_attempts = max(1, retries)
for attempt in range(num_attempts):
try:
response = requests.delete(
url,
headers=headers,
timeout=timeout
)
if response.status_code == 502:
if attempt < num_attempts - 1:
time.sleep(backoff_factor * (2 ** attempt))
continue
return response
except requests.RequestException as e:
last_exception = e
if attempt == num_attempts - 1:
raise e
time.sleep(backoff_factor * (2 ** attempt))
# This should never be reached due to the exception handling above
raise last_exception or Exception("Unexpected error in DELETE request")
def patch(
self,
endpoint: str,
data: Dict[str, Any],
headers: Optional[Dict[str, str]] = None,
timeout: Optional[float] = None,
retries: Optional[int] = None,
backoff_factor: Optional[float] = None,
) -> requests.Response:
"""Make a PATCH request with retry logic."""
if headers is None:
headers = self._prepare_headers()
if timeout is None:
timeout = self.timeout
if retries is None:
retries = self.max_retries
if backoff_factor is None:
backoff_factor = self.backoff_factor
payload = dict(data)
payload['origin'] = f'python-sdk@{version}'
url = self._build_url(endpoint)
last_exception = None
num_attempts = max(1, retries)
for attempt in range(num_attempts):
try:
response = requests.patch(
url,
json=payload,
headers=headers,
timeout=timeout
)
if response.status_code == 502 and attempt < num_attempts - 1:
time.sleep(backoff_factor * (2 ** attempt))
continue
return response
except requests.RequestException as e:
last_exception = e
if attempt == num_attempts - 1:
raise e
time.sleep(backoff_factor * (2 ** attempt))
raise last_exception or Exception("Unexpected error in PATCH request")

View File

@@ -0,0 +1,245 @@
import asyncio
import httpx
from typing import Optional, Dict, Any
from .get_version import get_version
version = get_version()
class AsyncHttpClient:
def __init__(
self,
api_key: Optional[str],
api_url: str,
timeout: Optional[float] = None,
max_retries: int = 3,
backoff_factor: float = 0.5,
):
self.api_key = api_key
self.api_url = api_url
self.timeout = timeout
self.max_retries = max_retries
self.backoff_factor = backoff_factor
headers = {}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
self._client = httpx.AsyncClient(
base_url=api_url,
headers=headers,
limits=httpx.Limits(max_keepalive_connections=0),
)
async def close(self) -> None:
await self._client.aclose()
def _headers(self, idempotency_key: Optional[str] = None) -> Dict[str, str]:
headers: Dict[str, str] = {}
if idempotency_key:
headers["x-idempotency-key"] = idempotency_key
return headers
async def post(
self,
endpoint: str,
data: Dict[str, Any],
headers: Optional[Dict[str, str]] = None,
timeout: Optional[float] = None,
retries: Optional[int] = None,
backoff_factor: Optional[float] = None,
) -> httpx.Response:
if timeout is None:
timeout = self.timeout
if retries is None:
retries = self.max_retries
if backoff_factor is None:
backoff_factor = self.backoff_factor
payload = dict(data)
payload["origin"] = f"python-sdk@{version}"
last_exception = None
num_attempts = max(1, retries)
for attempt in range(num_attempts):
try:
response = await self._client.post(
endpoint,
json=payload,
headers={**self._headers(), **(headers or {})},
timeout=timeout,
)
if response.status_code == 502:
if attempt < num_attempts - 1:
await asyncio.sleep(backoff_factor * (2 ** attempt))
continue
return response
except httpx.HTTPError as e:
last_exception = e
if attempt == num_attempts - 1:
raise e
await asyncio.sleep(backoff_factor * (2 ** attempt))
raise last_exception or Exception("Unexpected error in POST request")
async def post_multipart(
self,
endpoint: str,
data: Dict[str, Any],
files: Dict[str, Any],
headers: Optional[Dict[str, str]] = None,
timeout: Optional[float] = None,
retries: Optional[int] = None,
backoff_factor: Optional[float] = None,
) -> httpx.Response:
if timeout is None:
timeout = self.timeout
if retries is None:
retries = self.max_retries
if backoff_factor is None:
backoff_factor = self.backoff_factor
last_exception = None
num_attempts = max(1, retries)
for attempt in range(num_attempts):
try:
response = await self._client.post(
endpoint,
data=data,
files=files,
headers={**self._headers(), **(headers or {})},
timeout=timeout,
)
if response.status_code == 502:
if attempt < num_attempts - 1:
await asyncio.sleep(backoff_factor * (2 ** attempt))
continue
return response
except httpx.HTTPError as e:
last_exception = e
if attempt == num_attempts - 1:
raise e
await asyncio.sleep(backoff_factor * (2 ** attempt))
raise last_exception or Exception("Unexpected error in multipart POST request")
async def get(
self,
endpoint: str,
headers: Optional[Dict[str, str]] = None,
timeout: Optional[float] = None,
retries: Optional[int] = None,
backoff_factor: Optional[float] = None,
) -> httpx.Response:
if timeout is None:
timeout = self.timeout
if retries is None:
retries = self.max_retries
if backoff_factor is None:
backoff_factor = self.backoff_factor
last_exception = None
num_attempts = max(1, retries)
for attempt in range(num_attempts):
try:
response = await self._client.get(
endpoint,
headers={**self._headers(), **(headers or {})},
timeout=timeout,
)
if response.status_code == 502:
if attempt < num_attempts - 1:
await asyncio.sleep(backoff_factor * (2 ** attempt))
continue
return response
except httpx.HTTPError as e:
last_exception = e
if attempt == num_attempts - 1:
raise e
await asyncio.sleep(backoff_factor * (2 ** attempt))
raise last_exception or Exception("Unexpected error in GET request")
async def delete(
self,
endpoint: str,
headers: Optional[Dict[str, str]] = None,
timeout: Optional[float] = None,
retries: Optional[int] = None,
backoff_factor: Optional[float] = None,
) -> httpx.Response:
if timeout is None:
timeout = self.timeout
if retries is None:
retries = self.max_retries
if backoff_factor is None:
backoff_factor = self.backoff_factor
last_exception = None
num_attempts = max(1, retries)
for attempt in range(num_attempts):
try:
response = await self._client.delete(
endpoint,
headers={**self._headers(), **(headers or {})},
timeout=timeout,
)
if response.status_code == 502:
if attempt < num_attempts - 1:
await asyncio.sleep(backoff_factor * (2 ** attempt))
continue
return response
except httpx.HTTPError as e:
last_exception = e
if attempt == num_attempts - 1:
raise e
await asyncio.sleep(backoff_factor * (2 ** attempt))
raise last_exception or Exception("Unexpected error in DELETE request")
async def patch(
self,
endpoint: str,
data: Dict[str, Any],
headers: Optional[Dict[str, str]] = None,
timeout: Optional[float] = None,
retries: Optional[int] = None,
backoff_factor: Optional[float] = None,
) -> httpx.Response:
if timeout is None:
timeout = self.timeout
if retries is None:
retries = self.max_retries
if backoff_factor is None:
backoff_factor = self.backoff_factor
payload = dict(data)
payload["origin"] = f"python-sdk@{version}"
last_exception = None
num_attempts = max(1, retries)
for attempt in range(num_attempts):
try:
response = await self._client.patch(
endpoint,
json=payload,
headers={**self._headers(), **(headers or {})},
timeout=timeout,
)
if response.status_code == 502 and attempt < num_attempts - 1:
await asyncio.sleep(backoff_factor * (2 ** attempt))
continue
return response
except httpx.HTTPError as e:
last_exception = e
if attempt == num_attempts - 1:
raise e
await asyncio.sleep(backoff_factor * (2 ** attempt))
raise last_exception or Exception("Unexpected error in PATCH request")

View File

@@ -0,0 +1,125 @@
"""
Normalization helpers for v2 API payloads to avoid relying on Pydantic aliases.
"""
from typing import Any, Dict, List
from ..types import DocumentMetadata
def _map_metadata_keys(md: Dict[str, Any]) -> Dict[str, Any]:
"""
Convert API v2 camelCase metadata keys to snake_case expected by DocumentMetadata.
Leaves unknown keys as-is.
"""
mapping = {
# OpenGraph
"ogTitle": "og_title",
"ogDescription": "og_description",
"ogUrl": "og_url",
"ogImage": "og_image",
"ogAudio": "og_audio",
"ogDeterminer": "og_determiner",
"ogLocale": "og_locale",
"ogLocaleAlternate": "og_locale_alternate",
"ogSiteName": "og_site_name",
"ogVideo": "og_video",
# Dublin Core and misc
"dcTermsCreated": "dc_terms_created",
"dcDateCreated": "dc_date_created",
"dcDate": "dc_date",
"dcTermsType": "dc_terms_type",
"dcType": "dc_type",
"dcTermsAudience": "dc_terms_audience",
"dcTermsSubject": "dc_terms_subject",
"dcSubject": "dc_subject",
"dcDescription": "dc_description",
"dcTermsKeywords": "dc_terms_keywords",
"modifiedTime": "modified_time",
"publishedTime": "published_time",
"articleTag": "article_tag",
"articleSection": "article_section",
# Response-level
"sourceURL": "source_url",
"statusCode": "status_code",
"scrapeId": "scrape_id",
"numPages": "num_pages",
"contentType": "content_type",
"proxyUsed": "proxy_used",
"cacheState": "cache_state",
"cachedAt": "cached_at",
"creditsUsed": "credits_used",
"concurrencyLimited": "concurrency_limited",
"concurrencyQueueDurationMs": "concurrency_queue_duration_ms",
}
out: Dict[str, Any] = {}
for k, v in md.items():
snake = mapping.get(k, k)
out[snake] = v
# Light coercions where server may send strings/lists
if isinstance(out.get("status_code"), str):
try:
out["status_code"] = int(out["status_code"]) # type: ignore
except ValueError:
pass
# Preserve list values for unknown keys; only lightweight coercions above
return out
def normalize_document_input(doc: Dict[str, Any]) -> Dict[str, Any]:
"""
Normalize a raw Document dict from the API into the Python SDK's expected shape:
- Convert top-level keys rawHtml->raw_html, changeTracking->change_tracking
- Convert metadata keys from camelCase to snake_case
- Convert branding.colorScheme to branding.color_scheme
"""
normalized = dict(doc)
if "rawHtml" in normalized and "raw_html" not in normalized:
normalized["raw_html"] = normalized.pop("rawHtml")
if "changeTracking" in normalized and "change_tracking" not in normalized:
normalized["change_tracking"] = normalized.pop("changeTracking")
md = normalized.get("metadata")
if isinstance(md, dict):
mapped = _map_metadata_keys(md)
# Construct a typed DocumentMetadata; extras allowed/preserved
try:
normalized["metadata"] = DocumentMetadata.model_validate(mapped)
except Exception:
normalized["metadata"] = mapped
# Normalize branding top-level camelCase keys
branding = normalized.get("branding")
if isinstance(branding, dict):
if "colorScheme" in branding and "color_scheme" not in branding:
branding["color_scheme"] = branding.pop("colorScheme")
return normalized
def _map_search_result_keys(result: Dict[str, Any], result_type: str) -> Dict[str, Any]:
if result_type == "images":
mapping = {
"imageUrl": "image_url",
"imageWidth": "image_width",
"imageHeight": "image_height",
}
elif result_type == "news":
mapping = {
"imageUrl": "image_url",
}
elif result_type == "web":
mapping = {}
else:
mapping = {}
out: Dict[str, Any] = {}
for k, v in result.items():
snake = mapping.get(k, k)
out[snake] = v
return out

View File

@@ -0,0 +1,790 @@
"""
Shared validation functions for Firecrawl v2 API.
"""
from typing import Optional, Dict, Any, List
from ..types import ScrapeOptions, ScrapeFormats
def _convert_format_string(format_str: str) -> str:
"""
Convert format string from snake_case to camelCase.
Args:
format_str: Format string in snake_case
Returns:
Format string in camelCase
"""
format_mapping = {
"raw_html": "rawHtml",
"change_tracking": "changeTracking",
"screenshot_full_page": "screenshot@fullPage"
}
return format_mapping.get(format_str, format_str)
def normalize_schema_for_openai(schema: Any) -> Any:
"""
Normalize a schema for OpenAI compatibility by handling recursive references.
Args:
schema: Schema to normalize
Returns:
Normalized schema
"""
if not schema or not isinstance(schema, dict):
return schema
visited = set()
def normalize_object(obj: Any) -> Any:
if not isinstance(obj, dict):
if isinstance(obj, list):
return [normalize_object(item) for item in obj]
return obj
obj_id = id(obj)
if obj_id in visited:
return obj
visited.add(obj_id)
normalized = dict(obj)
# Handle $ref recursion
if "$ref" in normalized:
visited.discard(obj_id)
return normalized
if "$defs" in normalized:
defs = normalized.pop("$defs")
processed_rest = {}
for key, value in normalized.items():
if isinstance(value, dict) and "$ref" not in value:
processed_rest[key] = normalize_object(value)
else:
processed_rest[key] = value
normalized_defs = {}
for key, value in defs.items():
normalized_defs[key] = normalize_object(value)
result = {**processed_rest, "$defs": normalized_defs}
visited.discard(obj_id)
return result
if (normalized.get("type") == "object" and
"properties" in normalized and
normalized.get("additionalProperties") is True):
del normalized["additionalProperties"]
if (normalized.get("type") == "object" and
"required" in normalized and
"properties" in normalized):
if (isinstance(normalized["required"], list) and
isinstance(normalized["properties"], dict)):
valid_required = [field for field in normalized["required"]
if field in normalized["properties"]]
if valid_required:
normalized["required"] = valid_required
else:
del normalized["required"]
else:
del normalized["required"]
for key, value in list(normalized.items()):
if isinstance(value, dict) and "$ref" not in value:
normalized[key] = normalize_object(value)
elif isinstance(value, list):
normalized[key] = [normalize_object(item) if isinstance(item, dict) else item for item in value]
visited.discard(obj_id)
return normalized
return normalize_object(schema)
def validate_schema_for_openai(schema: Any) -> bool:
"""
Validate schema for OpenAI compatibility.
Args:
schema: Schema to validate
Returns:
True if schema is valid, False otherwise
"""
if not schema or not isinstance(schema, dict):
return True
visited = set()
def has_invalid_structure(obj: Any) -> bool:
if not isinstance(obj, dict):
return False
obj_id = id(obj)
if obj_id in visited:
return False
visited.add(obj_id)
if "$ref" in obj:
visited.discard(obj_id)
return False
if (obj.get("type") == "object" and
"properties" not in obj and
"patternProperties" not in obj and
obj.get("additionalProperties") is True):
visited.discard(obj_id)
return True
for value in obj.values():
if isinstance(value, dict) and "$ref" not in value:
if has_invalid_structure(value):
visited.discard(obj_id)
return True
elif isinstance(value, list):
for item in value:
if isinstance(item, dict) and "$ref" not in item:
if has_invalid_structure(item):
visited.discard(obj_id)
return True
visited.discard(obj_id)
return False
return not has_invalid_structure(schema)
OPENAI_SCHEMA_ERROR_MESSAGE = (
"Schema contains invalid structure for OpenAI: object type with no 'properties' defined "
"but 'additionalProperties: true' (schema-less dictionary not supported by OpenAI). "
"Please define specific properties for your object. Note: Recursive schemas using '$ref' are supported."
)
def _contains_recursive_ref(obj: Any, target_def_name: str, defs: Dict[str, Any], visited: Optional[set] = None) -> bool:
"""
Check if an object contains a recursive reference to a specific definition.
Args:
obj: Object to check
target_def_name: Name of the definition to check for recursion
defs: Dictionary of definitions
visited: Set of visited object keys to detect cycles
Returns:
True if recursive reference is found, False otherwise
"""
if not obj or not isinstance(obj, (dict, list)):
return False
if visited is None:
visited = set()
import json
obj_key = json.dumps(obj, sort_keys=True, default=str)
if obj_key in visited:
return False
visited.add(obj_key)
try:
if isinstance(obj, dict):
if "$ref" in obj and isinstance(obj["$ref"], str):
ref_path = obj["$ref"].split("/")
if len(ref_path) >= 3 and ref_path[0] == "#" and ref_path[1] == "$defs":
def_name = ref_path[-1]
if def_name == target_def_name:
return True
if def_name in defs:
return _contains_recursive_ref(defs[def_name], target_def_name, defs, visited)
for value in obj.values():
if _contains_recursive_ref(value, target_def_name, defs, visited):
return True
elif isinstance(obj, list):
for item in obj:
if _contains_recursive_ref(item, target_def_name, defs, visited):
return True
finally:
visited.discard(obj_key)
return False
def _check_for_circular_defs(defs: Dict[str, Any]) -> bool:
"""
Check if $defs contain circular references.
Args:
defs: Dictionary of definitions to check
Returns:
True if circular references are found, False otherwise
"""
if not defs:
return False
for def_name, def_value in defs.items():
if _contains_recursive_ref(def_value, def_name, defs):
return True
return False
def resolve_refs(obj: Any, defs: Dict[str, Any], visited: Optional[set] = None, depth: int = 0) -> Any:
"""
Resolve $ref references in a JSON schema object.
Args:
obj: Object to resolve references in
defs: Dictionary of definitions
visited: Set to track visited objects and prevent infinite recursion
depth: Current recursion depth
Returns:
Object with resolved references
"""
if not obj or not isinstance(obj, (dict, list)) or depth > 10:
return obj
if visited is None:
visited = set()
obj_id = id(obj)
if obj_id in visited:
return obj
visited.add(obj_id)
try:
if isinstance(obj, dict):
if "$ref" in obj and isinstance(obj["$ref"], str):
ref_path = obj["$ref"].split("/")
if len(ref_path) >= 3 and ref_path[0] == "#" and ref_path[1] == "$defs":
def_name = ref_path[-1]
if def_name in defs:
return resolve_refs(dict(defs[def_name]), defs, visited, depth + 1)
return obj
resolved = {}
for key, value in obj.items():
if key == "$defs":
continue
resolved[key] = resolve_refs(value, defs, visited, depth + 1)
return resolved
elif isinstance(obj, list):
return [resolve_refs(item, defs, visited, depth + 1) for item in obj]
finally:
visited.discard(obj_id)
return obj
def detect_recursive_schema(schema: Any) -> bool:
"""
Detect if a schema contains recursive references.
Args:
schema: Schema to analyze
Returns:
True if schema has recursive patterns, False otherwise
"""
if not schema or not isinstance(schema, dict):
return False
import json
schema_string = json.dumps(schema)
has_refs = (
'"$ref"' in schema_string or
"#/$defs/" in schema_string or
"#/definitions/" in schema_string
)
has_defs = bool(schema.get("$defs") or schema.get("definitions"))
return has_refs or has_defs
def select_model_for_schema(schema: Any = None) -> Dict[str, str]:
"""
Select appropriate model based on schema complexity.
Args:
schema: Schema to analyze
Returns:
Dict with modelName and reason
"""
if not schema:
return {"modelName": "gpt-4o-mini", "reason": "no_schema"}
if detect_recursive_schema(schema):
return {"modelName": "gpt-4o", "reason": "recursive_schema_detected"}
return {"modelName": "gpt-4o-mini", "reason": "simple_schema"}
def _normalize_schema(schema: Any) -> Optional[Dict[str, Any]]:
"""
Normalize a schema object which may be a dict, Pydantic BaseModel subclass,
or a Pydantic model instance into a plain dict.
"""
try:
# Pydantic v2 BaseModel subclass: has "model_json_schema"
if hasattr(schema, "model_json_schema") and callable(schema.model_json_schema):
return schema.model_json_schema()
# Pydantic v2 BaseModel instance: has "model_dump" or "model_json_schema"
if hasattr(schema, "model_dump") and callable(schema.model_dump):
# Try to get JSON schema if available on the class
mjs = getattr(schema.__class__, "model_json_schema", None)
if callable(mjs):
return schema.__class__.model_json_schema()
# Fallback to data shape (not ideal, but better than dropping)
return schema.model_dump()
# Pydantic v1 BaseModel subclass: has "schema"
if hasattr(schema, "schema") and callable(schema.schema):
return schema.schema()
# Pydantic v1 BaseModel instance
if hasattr(schema, "dict") and callable(schema.dict):
# Prefer class-level schema if present
sch = getattr(schema.__class__, "schema", None)
if callable(sch):
return schema.__class__.schema()
return schema.dict()
except Exception:
pass
# Already a dict or unsupported type
return schema if isinstance(schema, dict) else None
def _validate_json_format(format_obj: Any) -> Dict[str, Any]:
"""
Validate and prepare json format object.
Args:
format_obj: Format object that should be json type
Returns:
Validated json format dict
Raises:
ValueError: If json format is missing required fields
"""
if not isinstance(format_obj, dict):
raise ValueError("json format must be an object with 'type', 'prompt', and 'schema' fields")
if format_obj.get('type') != 'json':
raise ValueError("json format must have type='json'")
# prompt is optional in v2; only normalize when present
# schema is recommended; if provided, normalize Pydantic forms
schema = format_obj.get('schema')
normalized = dict(format_obj)
if schema is not None:
normalized_schema = _normalize_schema(schema)
if normalized_schema is not None:
# Handle schema reference resolution similar to TypeScript implementation
if isinstance(normalized_schema, dict):
defs = normalized_schema.get("$defs", {})
import json
schema_string = json.dumps(normalized_schema)
has_any_refs = (
normalized_schema.get("$defs") or
'"$ref"' in schema_string or
"#/$defs/" in schema_string
)
if has_any_refs:
try:
resolved_schema = resolve_refs(normalized_schema, defs)
resolved_string = json.dumps(resolved_schema)
has_remaining_refs = '"$ref"' in resolved_string or "#/$defs/" in resolved_string
if not has_remaining_refs:
normalized_schema = resolved_schema
# Remove $defs after successful resolution
if isinstance(normalized_schema, dict) and "$defs" in normalized_schema:
del normalized_schema["$defs"]
# If refs remain, preserve original schema
except Exception:
# Failed to resolve refs, preserve original schema
pass
else:
# No recursive references detected, resolve refs anyway
try:
normalized_schema = resolve_refs(normalized_schema, defs)
if isinstance(normalized_schema, dict) and "$defs" in normalized_schema:
del normalized_schema["$defs"]
except Exception:
pass
# Apply OpenAI normalization and validation
openai_normalized_schema = normalize_schema_for_openai(normalized_schema)
if not validate_schema_for_openai(openai_normalized_schema):
raise ValueError(OPENAI_SCHEMA_ERROR_MESSAGE)
normalized['schema'] = openai_normalized_schema
return normalized
def _validate_query_format(format_obj: Any) -> Dict[str, Any]:
"""
Validate and prepare query format object.
Args:
format_obj: Format object that should be query type
Returns:
Validated query format dict
Raises:
ValueError: If query format is missing required 'prompt' field
"""
if not isinstance(format_obj, dict):
raise ValueError("query format must be an object with 'type' and 'prompt' fields")
if not isinstance(format_obj.get('prompt'), str) or not format_obj['prompt'].strip():
raise ValueError("query format requires a non-empty 'prompt' string")
if "directQuote" in format_obj:
raise ValueError("query format uses 'mode' instead of 'directQuote'")
mode = format_obj.get("mode")
if mode is not None and mode not in ("freeform", "directQuote"):
raise ValueError("query format mode must be 'freeform' or 'directQuote'")
return format_obj
def _validate_question_format(format_obj: Any) -> Dict[str, Any]:
"""Validate and prepare question format object."""
if not isinstance(format_obj, dict):
raise ValueError("question format must be an object with 'type' and 'question' fields")
if not isinstance(format_obj.get('question'), str) or not format_obj['question'].strip():
raise ValueError("question format requires a non-empty 'question' string")
return format_obj
def _validate_highlights_format(format_obj: Any) -> Dict[str, Any]:
"""Validate and prepare highlights format object."""
if not isinstance(format_obj, dict):
raise ValueError("highlights format must be an object with 'type' and 'query' fields")
if not isinstance(format_obj.get('query'), str) or not format_obj['query'].strip():
raise ValueError("highlights format requires a non-empty 'query' string")
return format_obj
def validate_scrape_options(options: Optional[ScrapeOptions]) -> Optional[ScrapeOptions]:
"""
Validate and normalize scrape options.
Args:
options: Scraping options to validate
Returns:
Validated options or None
Raises:
ValueError: If options are invalid
"""
if options is None:
return None
# Validate timeout
if options.timeout is not None and options.timeout <= 0:
raise ValueError("Timeout must be positive")
# Validate wait_for
if options.wait_for is not None and options.wait_for < 0:
raise ValueError("wait_for must be non-negative")
return options
def prepare_scrape_options(options: Optional[ScrapeOptions]) -> Optional[Dict[str, Any]]:
"""
Prepare ScrapeOptions for API submission with manual snake_case to camelCase conversion.
Args:
options: ScrapeOptions to prepare
Returns:
Dictionary ready for API submission or None if options is None
"""
if options is None:
return None
# Validate options first
validated_options = validate_scrape_options(options)
if validated_options is None:
return None
# Apply default values for None fields
default_values = {
"only_main_content": True,
"mobile": False,
"skip_tls_verification": True,
"remove_base64_images": True,
"fast_mode": False,
"block_ads": True,
"max_age": 14400000,
"store_in_cache": True
}
# Convert to dict and handle manual snake_case to camelCase conversion
options_data = validated_options.model_dump(exclude_none=True)
# Apply defaults for None fields
for field, default_value in default_values.items():
if field not in options_data:
options_data[field] = default_value
scrape_data = {}
# Manual field mapping for snake_case to camelCase conversion
field_mappings = {
"include_tags": "includeTags",
"exclude_tags": "excludeTags",
"only_main_content": "onlyMainContent",
"wait_for": "waitFor",
"skip_tls_verification": "skipTlsVerification",
"remove_base64_images": "removeBase64Images",
"fast_mode": "fastMode",
"use_mock": "useMock",
"block_ads": "blockAds",
"store_in_cache": "storeInCache",
"max_age": "maxAge"
}
# Apply field mappings
for snake_case, camel_case in field_mappings.items():
if snake_case in options_data:
scrape_data[camel_case] = options_data.pop(snake_case)
# Handle special cases
for key, value in options_data.items():
if value is not None:
if key == "integration":
scrape_data["integration"] = (str(value).strip() or None)
continue
if key == "formats":
# Handle formats conversion
converted_formats: List[Any] = []
# Prefer using original object to detect ScrapeFormats vs list
original_formats = getattr(options, 'formats', None)
if isinstance(original_formats, ScrapeFormats):
# Include explicit list first
if original_formats.formats:
for fmt in original_formats.formats:
if isinstance(fmt, str):
if fmt == "json":
raise ValueError("json format must be an object with 'type', 'prompt', and 'schema' fields")
if fmt == "query":
raise ValueError("query format must be an object with 'type' and 'prompt' fields")
if fmt == "question":
raise ValueError("question format must be an object with 'type' and 'question' fields")
if fmt == "highlights":
raise ValueError("highlights format must be an object with 'type' and 'query' fields")
converted_formats.append(_convert_format_string(fmt))
elif isinstance(fmt, dict):
fmt_type = _convert_format_string(fmt.get('type')) if fmt.get('type') else None
if fmt_type == 'json':
validated_json = _validate_json_format({**fmt, 'type': 'json'})
converted_formats.append(validated_json)
elif fmt_type == 'question':
converted_formats.append(_validate_question_format(fmt))
elif fmt_type == 'highlights':
converted_formats.append(_validate_highlights_format(fmt))
elif fmt_type == 'query':
converted_formats.append(_validate_query_format(fmt))
elif fmt_type == 'screenshot':
# Normalize screenshot options
normalized = {**fmt, 'type': 'screenshot'}
if 'full_page' in normalized:
normalized['fullPage'] = normalized.pop('full_page')
# Normalize viewport if it's a model instance
vp = normalized.get('viewport')
if hasattr(vp, 'model_dump'):
normalized['viewport'] = vp.model_dump(exclude_none=True)
converted_formats.append(normalized)
else:
if 'type' in fmt:
fmt['type'] = fmt_type or fmt['type']
converted_formats.append(fmt)
elif hasattr(fmt, 'type'):
if fmt.type == 'json':
converted_formats.append(_validate_json_format(fmt.model_dump()))
elif fmt.type == 'question':
converted_formats.append(_validate_question_format(fmt.model_dump(exclude_none=True)))
elif fmt.type == 'highlights':
converted_formats.append(_validate_highlights_format(fmt.model_dump(exclude_none=True)))
elif fmt.type == 'query':
converted_formats.append(_validate_query_format(fmt.model_dump(exclude_none=True)))
else:
converted_formats.append(_convert_format_string(fmt.type))
else:
converted_formats.append(fmt)
# Add booleans from ScrapeFormats
if original_formats.markdown:
converted_formats.append("markdown")
if original_formats.html:
converted_formats.append("html")
if original_formats.raw_html:
converted_formats.append("rawHtml")
if original_formats.summary:
converted_formats.append("summary")
if original_formats.links:
converted_formats.append("links")
if original_formats.screenshot:
converted_formats.append("screenshot")
if original_formats.change_tracking:
converted_formats.append("changeTracking")
# Note: We intentionally do not auto-include 'json' when boolean is set,
# because JSON requires an object with schema/prompt. The caller must
# supply the full json format object explicitly.
elif isinstance(original_formats, list):
for fmt in original_formats:
if isinstance(fmt, str):
if fmt == "json":
raise ValueError("json format must be an object with 'type', 'prompt', and 'schema' fields")
if fmt == "query":
raise ValueError("query format must be an object with 'type' and 'prompt' fields")
if fmt == "question":
raise ValueError("question format must be an object with 'type' and 'question' fields")
if fmt == "highlights":
raise ValueError("highlights format must be an object with 'type' and 'query' fields")
converted_formats.append(_convert_format_string(fmt))
elif isinstance(fmt, dict):
fmt_type = _convert_format_string(fmt.get('type')) if fmt.get('type') else None
if fmt_type == 'json':
validated_json = _validate_json_format({**fmt, 'type': 'json'})
converted_formats.append(validated_json)
elif fmt_type == 'question':
converted_formats.append(_validate_question_format(fmt))
elif fmt_type == 'highlights':
converted_formats.append(_validate_highlights_format(fmt))
elif fmt_type == 'query':
converted_formats.append(_validate_query_format(fmt))
elif fmt_type == 'screenshot':
normalized = {**fmt, 'type': 'screenshot'}
if 'full_page' in normalized:
normalized['fullPage'] = normalized.pop('full_page')
vp = normalized.get('viewport')
if hasattr(vp, 'model_dump'):
normalized['viewport'] = vp.model_dump(exclude_none=True)
converted_formats.append(normalized)
else:
if 'type' in fmt:
fmt['type'] = fmt_type or fmt['type']
converted_formats.append(fmt)
elif hasattr(fmt, 'type'):
if fmt.type == 'json':
converted_formats.append(_validate_json_format(fmt.model_dump()))
elif fmt.type == 'question':
converted_formats.append(_validate_question_format(fmt.model_dump(exclude_none=True)))
elif fmt.type == 'highlights':
converted_formats.append(_validate_highlights_format(fmt.model_dump(exclude_none=True)))
elif fmt.type == 'screenshot':
normalized = {'type': 'screenshot'}
if getattr(fmt, 'full_page', None) is not None:
normalized['fullPage'] = fmt.full_page
if getattr(fmt, 'quality', None) is not None:
normalized['quality'] = fmt.quality
vp = getattr(fmt, 'viewport', None)
if vp is not None:
normalized['viewport'] = vp.model_dump(exclude_none=True) if hasattr(vp, 'model_dump') else vp
converted_formats.append(normalized)
elif fmt.type == 'query':
converted_formats.append(_validate_query_format(fmt.model_dump(exclude_none=True)))
else:
converted_formats.append(_convert_format_string(fmt.type))
else:
converted_formats.append(fmt)
else:
# Fallback: try to iterate over value if it's a list-like
try:
for fmt in value:
converted_formats.append(fmt)
except TypeError:
pass
if converted_formats:
scrape_data["formats"] = converted_formats
elif key == "actions":
# Handle actions conversion
converted_actions = []
for action in value:
if isinstance(action, dict):
# Convert action dict
converted_action = {}
for action_key, action_value in action.items():
if action_key == "full_page":
converted_action["fullPage"] = action_value
else:
converted_action[action_key] = action_value
converted_actions.append(converted_action)
else:
# Handle action objects
action_data = action.model_dump(exclude_none=True)
converted_action = {}
for action_key, action_value in action_data.items():
if action_key == "full_page":
converted_action["fullPage"] = action_value
else:
converted_action[action_key] = action_value
converted_actions.append(converted_action)
scrape_data["actions"] = converted_actions
elif key == "parsers":
converted_parsers = []
for parser in value:
if isinstance(parser, str):
converted_parsers.append(parser)
elif isinstance(parser, dict):
parser_data = dict(parser)
if "max_pages" in parser_data:
parser_data["maxPages"] = parser_data.pop("max_pages")
converted_parsers.append(parser_data)
else:
parser_data = parser.model_dump(exclude_none=True)
# Convert snake_case to camelCase for API
if "max_pages" in parser_data:
parser_data["maxPages"] = parser_data.pop("max_pages")
converted_parsers.append(parser_data)
scrape_data["parsers"] = converted_parsers
elif key == "location":
if isinstance(value, dict):
scrape_data["location"] = value
else:
scrape_data["location"] = value.model_dump(exclude_none=True)
elif key == "profile":
if isinstance(value, dict):
profile_data = {
"name": value["name"],
"saveChanges": value.get("save_changes", value.get("saveChanges", True)),
}
else:
profile_data = {
"name": value.name,
"saveChanges": getattr(value, "save_changes", getattr(value, "saveChanges", True)),
}
scrape_data["profile"] = profile_data
else:
# For fields that don't need conversion, use as-is
scrape_data[key] = value
return scrape_data