|
# retoor <retoor@molodetz.nl>
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import re
|
|
import ssl
|
|
from dataclasses import dataclass, field
|
|
from os import environ
|
|
from pathlib import Path
|
|
from typing import Iterable, Mapping, Sequence
|
|
from urllib.parse import unquote, urlsplit
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger("stealth")
|
|
|
|
OUTBOUND_PROXY_URL: str | None = environ.get("DEVPLACE_OUTBOUND_PROXY_URL", "").strip() or None
|
|
|
|
|
|
def _supported_accept_encoding() -> str:
|
|
encodings = ["gzip", "deflate"]
|
|
try:
|
|
import brotli # noqa: F401
|
|
encodings.append("br")
|
|
except ImportError:
|
|
logger.debug("brotli not installed; not advertising 'br'")
|
|
try:
|
|
import zstandard # noqa: F401
|
|
encodings.append("zstd")
|
|
except ImportError:
|
|
logger.debug("zstandard not installed; not advertising 'zstd'")
|
|
return ", ".join(encodings)
|
|
|
|
|
|
ACCEPT_ENCODING: str = _supported_accept_encoding()
|
|
|
|
|
|
CHROME_CIPHER_SUITES: str = ":".join(
|
|
[
|
|
"TLS_AES_128_GCM_SHA256",
|
|
"TLS_AES_256_GCM_SHA384",
|
|
"TLS_CHACHA20_POLY1305_SHA256",
|
|
"ECDHE-ECDSA-AES128-GCM-SHA256",
|
|
"ECDHE-RSA-AES128-GCM-SHA256",
|
|
"ECDHE-ECDSA-AES256-GCM-SHA384",
|
|
"ECDHE-RSA-AES256-GCM-SHA384",
|
|
"ECDHE-ECDSA-CHACHA20-POLY1305",
|
|
"ECDHE-RSA-CHACHA20-POLY1305",
|
|
"ECDHE-RSA-AES128-SHA",
|
|
"ECDHE-RSA-AES256-SHA",
|
|
"AES128-GCM-SHA256",
|
|
"AES256-GCM-SHA384",
|
|
"AES128-SHA",
|
|
"AES256-SHA",
|
|
]
|
|
)
|
|
|
|
ALPN_PROTOCOLS: tuple[str, ...] = ("h2", "http/1.1")
|
|
|
|
DEFAULT_CHROME_VERSION: str = "146"
|
|
|
|
DEFAULT_CHUNK_SIZE: int = 65536
|
|
|
|
IN_MEMORY_DOWNLOAD_CAP: int = 100 * 1024 * 1024
|
|
|
|
IMAGE_EXTENSIONS: tuple[str, ...] = (
|
|
".jpg",
|
|
".jpeg",
|
|
".png",
|
|
".gif",
|
|
".webp",
|
|
".avif",
|
|
".bmp",
|
|
".svg",
|
|
".ico",
|
|
".tiff",
|
|
)
|
|
|
|
MAX_FILENAME_LENGTH: int = 200
|
|
|
|
MAX_CONCURRENT_DOWNLOADS: int = 64
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ChromeProfile:
|
|
version: str = DEFAULT_CHROME_VERSION
|
|
platform: str = "Windows"
|
|
platform_token: str = "Windows NT 10.0; Win64; x64"
|
|
accept_language: str = "en-US,en;q=0.9"
|
|
|
|
@property
|
|
def user_agent(self) -> str:
|
|
return (
|
|
f"Mozilla/5.0 ({self.platform_token}) AppleWebKit/537.36 "
|
|
f"(KHTML, like Gecko) Chrome/{self.version}.0.0.0 Safari/537.36"
|
|
)
|
|
|
|
@property
|
|
def sec_ch_ua(self) -> str:
|
|
return (
|
|
f'"Google Chrome";v="{self.version}", '
|
|
f'"Chromium";v="{self.version}", '
|
|
f'"Not)A;Brand";v="24"'
|
|
)
|
|
|
|
@classmethod
|
|
def windows(cls, version: str = DEFAULT_CHROME_VERSION) -> "ChromeProfile":
|
|
return cls(version=version, platform="Windows", platform_token="Windows NT 10.0; Win64; x64")
|
|
|
|
@classmethod
|
|
def macos(cls, version: str = DEFAULT_CHROME_VERSION) -> "ChromeProfile":
|
|
return cls(
|
|
version=version,
|
|
platform="macOS",
|
|
platform_token="Macintosh; Intel Mac OS X 10_15_7",
|
|
)
|
|
|
|
@classmethod
|
|
def linux(cls, version: str = DEFAULT_CHROME_VERSION) -> "ChromeProfile":
|
|
return cls(version=version, platform="Linux", platform_token="X11; Linux x86_64")
|
|
|
|
|
|
@dataclass
|
|
class DownloadResult:
|
|
url: str
|
|
path: Path | None
|
|
status_code: int | None
|
|
bytes_written: int
|
|
ok: bool
|
|
error: str | None = None
|
|
|
|
|
|
@dataclass
|
|
class BytesResult:
|
|
url: str
|
|
final_url: str
|
|
status_code: int
|
|
content_type: str
|
|
data: bytes
|
|
truncated: bool
|
|
error: str | None = None
|
|
|
|
def as_dict(self) -> dict[str, object]:
|
|
return {
|
|
"url": self.url,
|
|
"final_url": self.final_url,
|
|
"status_code": self.status_code,
|
|
"content_type": self.content_type,
|
|
"data": self.data,
|
|
"truncated": self.truncated,
|
|
"error": self.error,
|
|
}
|
|
|
|
|
|
def resource_kind_for_url(url: str) -> str:
|
|
path = urlsplit(url).path.lower()
|
|
return "image" if path.endswith(IMAGE_EXTENSIONS) else "empty"
|
|
|
|
|
|
CONSENT_GATE_MARKERS: tuple[str, ...] = ("privacy gate", "privacy-gate", "cmpproperties", "consent.js")
|
|
|
|
CONSENT_GATE_CALLBACK_RE = re.compile(r"decodeURIComponent\(\s*['\"]([^'\"]+)['\"]\s*\)")
|
|
|
|
|
|
def detect_consent_gate(html: str) -> str | None:
|
|
lowered = html.lower()
|
|
if not any(marker in lowered for marker in CONSENT_GATE_MARKERS):
|
|
return None
|
|
match = CONSENT_GATE_CALLBACK_RE.search(html)
|
|
if not match:
|
|
return None
|
|
candidate = unquote(match.group(1))
|
|
if not candidate.startswith(("http://", "https://")):
|
|
return None
|
|
return candidate
|
|
|
|
|
|
def build_chrome_ssl_context() -> ssl.SSLContext:
|
|
context = ssl.create_default_context()
|
|
context.check_hostname = True
|
|
context.verify_mode = ssl.CERT_REQUIRED
|
|
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
context.maximum_version = ssl.TLSVersion.TLSv1_3
|
|
context.set_ciphers(CHROME_CIPHER_SUITES)
|
|
context.set_alpn_protocols(list(ALPN_PROTOCOLS))
|
|
context.options |= ssl.OP_NO_COMPRESSION
|
|
logger.debug("Built Chrome-aligned SSL context with %d ciphers", CHROME_CIPHER_SUITES.count(":") + 1)
|
|
return context
|
|
|
|
|
|
_SHARED_SSL_CONTEXT: ssl.SSLContext | None = None
|
|
|
|
DEFAULT_PROFILE: ChromeProfile = ChromeProfile.windows()
|
|
|
|
try:
|
|
from devplacepy.curl_transport import CurlTransport, IMPERSONATE_TARGET
|
|
|
|
CURL_AVAILABLE: bool = True
|
|
logger.info("Stealth transport: curl_cffi impersonation active (%s)", IMPERSONATE_TARGET)
|
|
except ImportError as error:
|
|
CurlTransport = None
|
|
IMPERSONATE_TARGET = ""
|
|
CURL_AVAILABLE = False
|
|
logger.warning("curl_cffi unavailable (%s); falling back to httpx stealth transport", error)
|
|
|
|
|
|
def curl_active() -> bool:
|
|
return CURL_AVAILABLE
|
|
|
|
|
|
def chrome_ssl_context() -> ssl.SSLContext:
|
|
global _SHARED_SSL_CONTEXT
|
|
if _SHARED_SSL_CONTEXT is None:
|
|
_SHARED_SSL_CONTEXT = build_chrome_ssl_context()
|
|
return _SHARED_SSL_CONTEXT
|
|
|
|
|
|
def chrome_base_headers(profile: ChromeProfile | None = None) -> dict[str, str]:
|
|
active = profile or DEFAULT_PROFILE
|
|
return {
|
|
"sec-ch-ua": active.sec_ch_ua,
|
|
"sec-ch-ua-mobile": "?0",
|
|
"sec-ch-ua-platform": f'"{active.platform}"',
|
|
"user-agent": active.user_agent,
|
|
"accept-encoding": ACCEPT_ENCODING,
|
|
"accept-language": active.accept_language,
|
|
}
|
|
|
|
|
|
def configured_proxy_url() -> str | None:
|
|
try:
|
|
from devplacepy.database import get_setting
|
|
|
|
configured = get_setting("outbound_proxy_url", "").strip()
|
|
except ImportError:
|
|
configured = ""
|
|
return configured or OUTBOUND_PROXY_URL
|
|
|
|
|
|
def stealth_transport(
|
|
profile: ChromeProfile | None = None,
|
|
*,
|
|
proxy: str | None = None,
|
|
**kwargs: object,
|
|
) -> httpx.AsyncBaseTransport:
|
|
effective_proxy = proxy or configured_proxy_url()
|
|
if CURL_AVAILABLE:
|
|
return CurlTransport(proxy=effective_proxy)
|
|
return httpx.AsyncHTTPTransport(verify=chrome_ssl_context(), http2=True, proxy=effective_proxy, **kwargs)
|
|
|
|
|
|
def stealth_async_client(
|
|
*,
|
|
profile: ChromeProfile | None = None,
|
|
headers: Mapping[str, str] | None = None,
|
|
transport: httpx.AsyncBaseTransport | None = None,
|
|
proxy: str | None = None,
|
|
**kwargs: object,
|
|
) -> httpx.AsyncClient:
|
|
merged_headers: dict[str, str] = {} if CURL_AVAILABLE else chrome_base_headers(profile)
|
|
if headers:
|
|
merged_headers.update({key.lower(): value for key, value in headers.items()})
|
|
if transport is None:
|
|
transport = stealth_transport(profile, proxy=proxy)
|
|
return httpx.AsyncClient(
|
|
transport=transport,
|
|
trust_env=True,
|
|
headers=merged_headers,
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
def stealth_sync_client(
|
|
*,
|
|
profile: ChromeProfile | None = None,
|
|
headers: Mapping[str, str] | None = None,
|
|
proxy: str | None = None,
|
|
**kwargs: object,
|
|
) -> httpx.Client:
|
|
merged_headers = chrome_base_headers(profile)
|
|
if headers:
|
|
merged_headers.update({key.lower(): value for key, value in headers.items()})
|
|
return httpx.Client(
|
|
http2=True,
|
|
verify=chrome_ssl_context(),
|
|
trust_env=True,
|
|
headers=merged_headers,
|
|
proxy=proxy or configured_proxy_url(),
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
def slugify_filename(name: str) -> str:
|
|
name = unquote(name).strip()
|
|
name = name.replace("\\", "/").split("/")[-1]
|
|
name = name.split("?")[0].split("#")[0]
|
|
name = re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip("-._")
|
|
if not name:
|
|
name = "download"
|
|
if len(name) > MAX_FILENAME_LENGTH:
|
|
stem, _, suffix = name.rpartition(".")
|
|
if stem and len(suffix) <= 16:
|
|
keep = MAX_FILENAME_LENGTH - len(suffix) - 1
|
|
name = f"{stem[:keep]}.{suffix}"
|
|
else:
|
|
name = name[:MAX_FILENAME_LENGTH]
|
|
return name
|
|
|
|
|
|
def resolve_destination(directory: Path, url: str, override_name: str | None) -> Path:
|
|
directory = directory.resolve()
|
|
raw_name = override_name if override_name else Path(urlsplit(url).path).name
|
|
safe_name = slugify_filename(raw_name)
|
|
candidate = (directory / safe_name).resolve()
|
|
if directory != candidate.parent:
|
|
raise ValueError(f"Refusing path traversal for {url!r} -> {candidate}")
|
|
return candidate
|
|
|
|
|
|
class ChromeStealthClient:
|
|
|
|
def __init__(
|
|
self,
|
|
profile: ChromeProfile | None = None,
|
|
*,
|
|
http2: bool = True,
|
|
timeout: float = 30.0,
|
|
follow_redirects: bool = True,
|
|
proxy: str | None = None,
|
|
trust_env: bool = True,
|
|
max_connections: int = 100,
|
|
max_keepalive_connections: int = 20,
|
|
) -> None:
|
|
self.profile = profile or ChromeProfile.windows()
|
|
self._ssl_context = build_chrome_ssl_context()
|
|
limits = httpx.Limits(
|
|
max_connections=max_connections,
|
|
max_keepalive_connections=max_keepalive_connections,
|
|
)
|
|
self._client = httpx.AsyncClient(
|
|
http2=http2,
|
|
verify=self._ssl_context,
|
|
timeout=httpx.Timeout(timeout),
|
|
follow_redirects=follow_redirects,
|
|
limits=limits,
|
|
proxy=proxy,
|
|
trust_env=trust_env,
|
|
headers=self._base_headers(),
|
|
)
|
|
logger.info(
|
|
"ChromeStealthClient ready: Chrome %s on %s, http2=%s, proxy=%s",
|
|
self.profile.version,
|
|
self.profile.platform,
|
|
http2,
|
|
bool(proxy),
|
|
)
|
|
|
|
def _base_headers(self) -> dict[str, str]:
|
|
return {
|
|
"sec-ch-ua": self.profile.sec_ch_ua,
|
|
"sec-ch-ua-mobile": "?0",
|
|
"sec-ch-ua-platform": f'"{self.profile.platform}"',
|
|
"user-agent": self.profile.user_agent,
|
|
"accept-encoding": ACCEPT_ENCODING,
|
|
"accept-language": self.profile.accept_language,
|
|
}
|
|
|
|
def _navigation_headers(self, referer: str | None) -> dict[str, str]:
|
|
headers = {
|
|
"upgrade-insecure-requests": "1",
|
|
"accept": (
|
|
"text/html,application/xhtml+xml,application/xml;q=0.9,"
|
|
"image/avif,image/webp,image/apng,*/*;q=0.8,"
|
|
"application/signed-exchange;v=b3;q=0.7"
|
|
),
|
|
"sec-fetch-site": "same-origin" if referer else "none",
|
|
"sec-fetch-mode": "navigate",
|
|
"sec-fetch-user": "?1",
|
|
"sec-fetch-dest": "document",
|
|
}
|
|
if referer:
|
|
headers["referer"] = referer
|
|
return headers
|
|
|
|
def _resource_headers(self, dest: str, referer: str | None) -> dict[str, str]:
|
|
accept = {
|
|
"image": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
|
"empty": "*/*",
|
|
}.get(dest, "*/*")
|
|
headers = {
|
|
"accept": accept,
|
|
"sec-fetch-site": "same-origin" if referer else "cross-site",
|
|
"sec-fetch-mode": "no-cors" if dest == "image" else "cors",
|
|
"sec-fetch-dest": dest,
|
|
}
|
|
if referer:
|
|
headers["referer"] = referer
|
|
return headers
|
|
|
|
def _api_headers(self, referer: str | None) -> dict[str, str]:
|
|
headers = {
|
|
"accept": "application/json, text/plain, */*",
|
|
"sec-fetch-site": "same-origin" if referer else "cross-site",
|
|
"sec-fetch-mode": "cors",
|
|
"sec-fetch-dest": "empty",
|
|
}
|
|
if referer:
|
|
headers["referer"] = referer
|
|
return headers
|
|
|
|
@staticmethod
|
|
def _json_or_error(response: httpx.Response) -> dict[str, object]:
|
|
if response.status_code >= 400:
|
|
return {
|
|
"error": f"HTTP {response.status_code}",
|
|
"status_code": response.status_code,
|
|
"body": response.text[:5000],
|
|
}
|
|
try:
|
|
return response.json()
|
|
except ValueError:
|
|
return {
|
|
"error": "Invalid JSON response",
|
|
"status_code": response.status_code,
|
|
"body": response.text[:5000],
|
|
}
|
|
|
|
async def __aenter__(self) -> "ChromeStealthClient":
|
|
return self
|
|
|
|
async def __aexit__(self, *exc: object) -> None:
|
|
await self.aclose()
|
|
|
|
async def aclose(self) -> None:
|
|
await self._client.aclose()
|
|
logger.debug("ChromeStealthClient closed")
|
|
|
|
async def _send(self, method: str, url: str, headers: dict[str, str], **kwargs: object) -> httpx.Response:
|
|
try:
|
|
return await self._client.request(method, url, headers=headers, **kwargs)
|
|
except httpx.DecodingError:
|
|
logger.warning("content decode failed for %s; retrying without br/zstd", url)
|
|
retry_headers = dict(headers)
|
|
retry_headers["accept-encoding"] = "gzip, deflate"
|
|
return await self._client.request(method, url, headers=retry_headers, **kwargs)
|
|
|
|
async def request(
|
|
self,
|
|
method: str,
|
|
url: str,
|
|
*,
|
|
referer: str | None = None,
|
|
headers: Mapping[str, str] | None = None,
|
|
**kwargs: object,
|
|
) -> httpx.Response:
|
|
merged = self._navigation_headers(referer)
|
|
if headers:
|
|
merged.update({key.lower(): value for key, value in headers.items()})
|
|
logger.info("%s %s", method.upper(), url)
|
|
response = await self._send(method, url, merged, **kwargs)
|
|
logger.debug("%s %s -> %s (%s)", method.upper(), url, response.status_code, response.http_version)
|
|
return response
|
|
|
|
async def get(self, url: str, *, referer: str | None = None, **kwargs: object) -> httpx.Response:
|
|
return await self.request("GET", url, referer=referer, **kwargs)
|
|
|
|
async def post(self, url: str, *, referer: str | None = None, **kwargs: object) -> httpx.Response:
|
|
return await self.request("POST", url, referer=referer, **kwargs)
|
|
|
|
def stream(
|
|
self,
|
|
method: str,
|
|
url: str,
|
|
*,
|
|
referer: str | None = None,
|
|
dest: str = "empty",
|
|
headers: Mapping[str, str] | None = None,
|
|
**kwargs: object,
|
|
):
|
|
merged = self._resource_headers(dest, referer)
|
|
if headers:
|
|
merged.update({key.lower(): value for key, value in headers.items()})
|
|
return self._client.stream(method, url, headers=merged, **kwargs)
|
|
|
|
async def download_file(
|
|
self,
|
|
url: str,
|
|
destination: str | Path,
|
|
*,
|
|
filename: str | None = None,
|
|
referer: str | None = None,
|
|
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
|
dest_type: str = "empty",
|
|
) -> DownloadResult:
|
|
directory = Path(destination)
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
target = resolve_destination(directory, url, filename)
|
|
except ValueError as error:
|
|
logger.error("Destination rejected for %s: %s", url, error)
|
|
return DownloadResult(url=url, path=None, status_code=None, bytes_written=0, ok=False, error=str(error))
|
|
|
|
written = 0
|
|
try:
|
|
async with self.stream("GET", url, referer=referer, dest=dest_type) as response:
|
|
response.raise_for_status()
|
|
with target.open("wb") as handle:
|
|
async for chunk in response.aiter_bytes(chunk_size):
|
|
handle.write(chunk)
|
|
written += len(chunk)
|
|
logger.info("Downloaded %s -> %s (%d bytes)", url, target, written)
|
|
return DownloadResult(
|
|
url=url,
|
|
path=target,
|
|
status_code=response.status_code,
|
|
bytes_written=written,
|
|
ok=True,
|
|
)
|
|
except httpx.HTTPStatusError as error:
|
|
logger.error("HTTP %s downloading %s", error.response.status_code, url)
|
|
return DownloadResult(
|
|
url=url,
|
|
path=None,
|
|
status_code=error.response.status_code,
|
|
bytes_written=written,
|
|
ok=False,
|
|
error=f"HTTP {error.response.status_code}",
|
|
)
|
|
except httpx.HTTPError as error:
|
|
logger.error("Transport error downloading %s: %s", url, error)
|
|
return DownloadResult(url=url, path=None, status_code=None, bytes_written=written, ok=False, error=str(error))
|
|
|
|
async def download_files(
|
|
self,
|
|
urls: Iterable[str] | Mapping[str, str],
|
|
destination: str | Path,
|
|
*,
|
|
concurrency: int = 8,
|
|
referer: str | None = None,
|
|
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
|
dest_type: str = "empty",
|
|
) -> list[DownloadResult]:
|
|
if isinstance(urls, Mapping):
|
|
jobs: Sequence[tuple[str, str | None]] = [(url, name) for url, name in urls.items()]
|
|
else:
|
|
jobs = [(url, None) for url in urls]
|
|
|
|
bounded = max(1, min(concurrency, MAX_CONCURRENT_DOWNLOADS))
|
|
semaphore = asyncio.Semaphore(bounded)
|
|
logger.info("Starting bulk download of %d files with concurrency %d", len(jobs), bounded)
|
|
|
|
async def worker(url: str, name: str | None) -> DownloadResult:
|
|
async with semaphore:
|
|
return await self.download_file(
|
|
url,
|
|
destination,
|
|
filename=name,
|
|
referer=referer,
|
|
chunk_size=chunk_size,
|
|
dest_type=dest_type,
|
|
)
|
|
|
|
results = await asyncio.gather(*(worker(url, name) for url, name in jobs))
|
|
succeeded = sum(1 for result in results if result.ok)
|
|
logger.info("Bulk download finished: %d succeeded, %d failed", succeeded, len(results) - succeeded)
|
|
return list(results)
|
|
|
|
async def download_bytes(
|
|
self,
|
|
url: str,
|
|
*,
|
|
referer: str | None = None,
|
|
max_bytes: int = IN_MEMORY_DOWNLOAD_CAP,
|
|
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
|
dest_type: str | None = None,
|
|
) -> BytesResult:
|
|
kind = dest_type if dest_type else resource_kind_for_url(url)
|
|
buffer = bytearray()
|
|
try:
|
|
async with self.stream("GET", url, referer=referer, dest=kind) as response:
|
|
content_type = response.headers.get("content-type", "")
|
|
async for chunk in response.aiter_bytes(chunk_size):
|
|
buffer.extend(chunk)
|
|
if len(buffer) > max_bytes:
|
|
break
|
|
final_url = str(response.url)
|
|
truncated = len(buffer) > max_bytes
|
|
data = bytes(buffer[:max_bytes])
|
|
logger.debug("download_bytes %s -> %d bytes (status %s)", url, len(data), response.status_code)
|
|
return BytesResult(
|
|
url=url,
|
|
final_url=final_url,
|
|
status_code=response.status_code,
|
|
content_type=content_type,
|
|
data=data,
|
|
truncated=truncated,
|
|
)
|
|
except httpx.HTTPError as error:
|
|
logger.error("download_bytes failed for %s: %s", url, error)
|
|
return BytesResult(
|
|
url=url,
|
|
final_url=url,
|
|
status_code=0,
|
|
content_type="",
|
|
data=b"",
|
|
truncated=False,
|
|
error=str(error),
|
|
)
|
|
|
|
async def post_json(
|
|
self,
|
|
url: str,
|
|
payload: object,
|
|
*,
|
|
headers: Mapping[str, str] | None = None,
|
|
referer: str | None = None,
|
|
timeout: float | None = None,
|
|
) -> dict[str, object]:
|
|
request_headers = self._api_headers(referer)
|
|
if headers:
|
|
request_headers.update({key.lower(): value for key, value in headers.items()})
|
|
kwargs: dict[str, object] = {"json": payload}
|
|
if timeout is not None:
|
|
kwargs["timeout"] = httpx.Timeout(timeout)
|
|
logger.info("POST(json) %s", url)
|
|
try:
|
|
response = await self._send("POST", url, request_headers, **kwargs)
|
|
except httpx.HTTPError as error:
|
|
logger.error("post_json transport error for %s: %s", url, error)
|
|
return {"error": f"{type(error).__name__}: {error}"}
|
|
return self._json_or_error(response)
|
|
|
|
async def get_json(
|
|
self,
|
|
url: str,
|
|
*,
|
|
referer: str | None = None,
|
|
timeout: float | None = None,
|
|
) -> dict[str, object]:
|
|
kwargs: dict[str, object] = {}
|
|
if timeout is not None:
|
|
kwargs["timeout"] = httpx.Timeout(timeout)
|
|
logger.info("GET(json) %s", url)
|
|
try:
|
|
response = await self._send("GET", url, self._api_headers(referer), **kwargs)
|
|
except httpx.HTTPError as error:
|
|
logger.error("get_json transport error for %s: %s", url, error)
|
|
return {"error": f"{type(error).__name__}: {error}"}
|
|
return self._json_or_error(response)
|
|
|
|
|
|
__all__ = [
|
|
"ChromeStealthClient",
|
|
"ChromeProfile",
|
|
"DownloadResult",
|
|
"BytesResult",
|
|
"build_chrome_ssl_context",
|
|
"chrome_ssl_context",
|
|
"chrome_base_headers",
|
|
"stealth_transport",
|
|
"stealth_async_client",
|
|
"stealth_sync_client",
|
|
"curl_active",
|
|
"resource_kind_for_url",
|
|
"slugify_filename",
|
|
"detect_consent_gate",
|
|
"configured_proxy_url",
|
|
]
|