mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-22 23:31:12 +02:00
171 lines
5.5 KiB
Python
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
|