SurfSense/surfsense_backend/app/proprietary/platforms/walmart/fetch.py

171 lines
5.5 KiB
Python

"""Network access for public Walmart pages.
Mirrors the Amazon fetch layer: every request goes through the configured
residential proxy (pinned to the US, since Walmart geo-locks inventory), and any
response that looks like an anti-bot interstitial is retried on a fresh proxy
exit. Ordinary HTTP failures are returned to the caller for domain-specific
handling.
Walmart runs Akamai (edge/TLS) + PerimeterX/HUMAN (behavioral JS). Two Walmart
specifics differ from Amazon:
* Walmart serves CAPTCHA with an HTTP ``200`` body ("Robot or human?"), so block
detection scans the body, never the status alone.
* ``412`` is PerimeterX's rejection code and is treated as blocked → rotate.
``ponytail:`` MVP hits only the server-rendered ``__NEXT_DATA__`` pages, which
TLS impersonation + residential proxies clear without seeding PerimeterX cookies.
If block rates on those pages climb, the upgrade path is a warmed sticky session
(seed ``_px3``/``_pxhd``/``ACID`` from a homepage fetch, reuse exit + cookies) —
the same shape as Amazon's ``get_location_session``.
"""
from __future__ import annotations
import asyncio
import logging
import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
from scrapling.fetchers import AsyncFetcher
from app.utils.proxy import get_geo_proxy_url, get_sticky_proxy_url
logger = logging.getLogger(__name__)
_MAX_IP_ATTEMPTS = 6
_REQUEST_TIMEOUT_S = 30
_HEADERS = {
"Accept-Language": "en-US,en;q=0.9",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
}
_BLOCK_MARKERS = (
"robot or human",
"px-captcha",
"/blocked",
"verify you are a human",
"access to this page has been denied",
)
@dataclass(frozen=True)
class FetchResult:
"""The response details needed by scraper flows."""
status: int
html: str
url: str
cookies: dict[str, str]
headers: dict[str, str] = field(default_factory=dict)
async def gather_bounded[T](
factories: list[Callable[[], Awaitable[T]]], *, concurrency: int
) -> list[T]:
"""Run async factories concurrently while preserving input order."""
if not factories:
return []
semaphore = asyncio.Semaphore(max(1, concurrency))
async def run(factory: Callable[[], Awaitable[T]]) -> T:
async with semaphore:
return await factory()
return await asyncio.gather(*(run(factory) for factory in factories))
def is_blocked(
html: str | None, status: int, headers: dict[str, str] | None = None
) -> bool:
"""Return whether a response is a Walmart anti-bot interstitial.
``412`` is PerimeterX's rejection; ``429``/``503`` are throttles. Walmart also
serves CAPTCHA with a ``200`` body, so the body is scanned regardless of
status.
"""
if status in {412, 429, 503}:
return True
text = (html or "")[:200_000].lower()
return any(marker in text for marker in _BLOCK_MARKERS)
def _response_url(page: Any, fallback: str) -> str:
value = getattr(page, "url", None)
return str(value) if value else fallback
def _response_cookies(page: Any) -> dict[str, str]:
cookies = getattr(page, "cookies", None)
return dict(cookies) if isinstance(cookies, dict) else {}
def _response_headers(page: Any) -> dict[str, str]:
headers = getattr(page, "headers", None)
return dict(headers) if isinstance(headers, dict) else {}
def _selected_proxy(
proxy: str | None, country: str, attempt: int, url: str
) -> str | None:
if proxy is not None:
return proxy
if attempt > 1:
session_id = f"walmart-{attempt}-{abs(hash((url, time.time_ns()))):x}"
return get_sticky_proxy_url(session_id, country)
return get_geo_proxy_url(country)
async def fetch_page(
url: str,
*,
cookies: dict[str, str] | None = None,
proxy: str | None = None,
country: str = "us",
rotate_on_block: bool = True,
) -> FetchResult | None:
"""Fetch a page and retry blocked responses with fresh proxy exits."""
attempts = _MAX_IP_ATTEMPTS if rotate_on_block else 1
for attempt in range(1, attempts + 1):
selected_proxy = _selected_proxy(proxy, country, attempt, url)
started = time.perf_counter()
try:
page = await AsyncFetcher.get(
url,
headers={**_HEADERS},
cookies=cookies or {},
proxy=selected_proxy,
stealthy_headers=True,
timeout=_REQUEST_TIMEOUT_S,
)
except Exception as exc:
logger.warning("Walmart request failed for %s: %s", url, exc)
if proxy is not None:
return None
continue
status = int(getattr(page, "status", 0) or 0)
html = getattr(page, "html_content", None) or ""
response_headers = _response_headers(page)
logger.info(
"[walmart][perf] status=%s attempt=%s fetch_ms=%.1f url=%s",
status,
attempt,
(time.perf_counter() - started) * 1000,
url,
)
if rotate_on_block and is_blocked(html, status, response_headers):
logger.info(
"Walmart blocked proxy attempt %s/%s for %s", attempt, attempts, url
)
continue
return FetchResult(
status=status,
html=html,
url=_response_url(page, url),
cookies=_response_cookies(page),
headers=response_headers,
)
logger.warning("Walmart exhausted %s proxy attempts for %s", attempts, url)
return None