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

439 lines
18 KiB
Python
Raw Permalink Normal View History

2026-07-17 19:42:10 -07:00
"""Proxy-aware fetch seam for the Reddit scraper (browser-warm, HTTP-fetch).
All network I/O flows through :func:`fetch_json` and always egresses through the
residential proxy (a direct hit would expose and risk-block the server IP).
2026-07-17 19:42:10 -07:00
Reddit shut anonymous ``.json`` (~May 2026) and, crucially, put a JavaScript
challenge in front of minting the anonymous ``loid`` session cookie: a plain
HTTP GET (even Chrome-impersonated) can no longer mint ``loid`` it just 403s
on the bot wall (confirmed live 2026-07-18, and yt-dlp #16877). So the recipe is
now two-phase on ONE sticky exit IP:
1. WARM (once per session): open a real patchright-Chromium stealth browser
(:func:`warm_session`) on a sticky proxy IP, load a public HTML page so
the JS challenge runs and mints the anonymous cookie jar (incl. ``loid``).
2. FETCH (many, fast): replay that minted jar through a plain-HTTP,
Chrome-impersonated ``FetcherSession`` pinned to the SAME sticky IP,
GETting ``www.reddit.com/<path>/.json?raw_json=1``. The ``.json`` body
comes back as raw JSON (no browser HTML wrapper) because this phase is
plain HTTP, keeping the fast/cheap fan-out browser cost is paid ONCE
per warm, not per fetch.
``loid`` binds to the exit IP, so both phases share one sticky proxy session id
(see :meth:`_RotatingSession._open`); a 403/blocked IP rotates to a fresh sticky
id + country and re-warms. This module is a port of ``../youtube/innertube.py``'s
rotate-on-block sticky-session pattern (``_RotatingSession`` + ``_current_session``
ContextVar + ``open_proxy_holder``/``bind_proxy_holder``/``proxy_session``), with
the browser warm bolted on and a ``.json``-shaped :func:`fetch_json`.
"""
from __future__ import annotations
import asyncio
import json
import logging
import random
import time
2026-07-17 19:42:10 -07:00
import uuid
from contextlib import asynccontextmanager, suppress
from contextvars import ContextVar
from datetime import UTC, datetime
from typing import Any
from urllib.parse import urlencode
2026-07-17 19:42:10 -07:00
from scrapling.fetchers import AsyncFetcher, AsyncStealthySession, FetcherSession
2026-07-17 19:42:10 -07:00
from app.utils.proxy import get_proxy_url, get_sticky_proxy_url
# Shared cross-country rotation walk (also used by the TikTok sibling). Kept under
# the historical private names this module and its tests reference.
from app.utils.proxy.rotation import (
country_for_rotation as _country_for_rotation,
)
logger = logging.getLogger(__name__)
class RedditAccessBlockedError(RuntimeError):
"""Raised when every rotated IP is refused anonymous access.
This is the yt-dlp "account authentication is required" / "your IP is unable
to access the Reddit API" branch. We are anonymous-only and cannot log in,
so instead of silently returning nothing we surface it as a clear error
(mirrors google_maps' ``SignInRequiredError``). The route can turn it into a
403 later; for now it just fails loudly.
"""
# Per-flow proxy session, set by ``bind_proxy_holder`` around one continuation
# chain. Reusing one keep-alive connection pins a single sticky exit IP so the
# ``loid`` cookie (bound to that IP) stays valid across the warm-up + every
# subsequent ``.json`` fetch. A ContextVar keeps each concurrent fan-out flow on
# its own session/IP without threading a param through every call.
_current_session: ContextVar[_RotatingSession | None] = ContextVar(
"reddit_proxy_session", default=None
)
# 403 => this IP is blocked; rotate to a fresh one and re-warm. 429 => rate
# limited; back off on the SAME IP (rotating wouldn't help and burns the pool).
# Split out from youtube's combined ``_BLOCK_STATUSES`` because Reddit wants
# different handling per status (spec section 3).
_ROTATE_STATUS = 403
_BACKOFF_STATUS = 429
# Rotating an IP is cheap (close + reopen one keep-alive connection through the
# gateway + a 2-request warm ≈ a few seconds) and each rotation also walks to the
# next country pool, so we spend rotations liberally: neither a dirty IP nor a
# wholly-blocked country pool should fail a job. 8 ≥ len(_FALLBACK_COUNTRIES), so
# a job tries every country at least once before giving up. Worst case (a genuine
# global block) costs _MAX_ROTATIONS bounded warm attempts before
# RedditAccessBlockedError.
# ponytail: 8 caps that worst case at ~30s; raise if every pool gets dirty at
# once, lower if a real global block is wasting time.
_MAX_ROTATIONS = 8
_MAX_BACKOFFS = 4
_BACKOFF_BASE_S = 5.0
# Reddit 429s aggressively (~60-100 req/min/IP). Pace each sticky session so a
# fast IP can't burst past the per-IP threshold. A live probe (12 rapid fetches
# on one sticky IP => 0x429; scripts/_bench_reddit2.py) showed the natural
# ~0.85s request latency already holds a session near ~1 req/s, so the old 1.0s
# floor just ADDED ~0.4s of dead sleep to every fetch. A 0.5s floor is a no-op
# for typical fetches yet still caps a fast IP at ~2 req/s; the 429 backoff below
# is the real safety net if an IP's limit is tighter.
# ponytail: 0.5s is tuned to dataimpulse residential exits. A pool with a
# stricter per-IP cap may need it raised — watch for 429 log spam and bump it.
_MIN_INTERVAL_S = 0.5
_PACE_JITTER_S = 0.25
# curl's default is 30s, so one dead sticky IP stalled a whole run for 30-50s
# (seen live 2026-07-06). A healthy fetch lands in ~1s; cap at 10s so a dead IP
# costs one bounded wait, then the timeout falls into the generic exception
# branch of fetch_json and rotates to a fresh IP — same treatment as a 403.
_REQUEST_TIMEOUT_S = 10.0
_HEADERS = {"Accept-Language": "en-US,en;q=0.9"}
# Age-gate opt-in, sent on every ``.json`` fetch so NSFW listings aren't blanked
# (the caller filters on ``includeNSFW`` downstream). Mirrors the probe.
_OVER18_COOKIES = {"over18": "1"}
2026-07-17 19:42:10 -07:00
# The browser warm loads *a* public HTML page so Reddit's JS challenge runs and
# mints the anonymous jar; any always-public subreddit works. ``r/popular``
# always exists, so it's a safe default regardless of this session's target.
_WARM_HTML_URL = "https://www.reddit.com/r/popular/"
_LOID_COOKIE = "loid"
2026-07-17 19:42:10 -07:00
# The stealth browser warm (cold start + the page's own ``load``) lands in
# ~6-15s once the solve_cloudflare/network_idle dead-waits are dropped (see
# warm_session), so 30s is a generous ceiling that still bounds a dead exit; the
# fast HTTP ``.json`` fetches keep the tight _REQUEST_TIMEOUT_S above.
_WARM_TIMEOUT_MS = 30_000
# Bound concurrent browser warms so a wide fan-out (up to _FANOUT_CONCURRENCY=16
# workers, each warming once) can't spawn 16 Chromiums at once and OOM the box.
# Warms are staggered, not serialized: a freed slot starts the next worker's warm
# while already-warmed workers fetch over plain HTTP.
# ponytail: 4 is tuned for a ~2GB container; raise on a bigger box, lower if warm
# spikes push memory. Ceiling: a burst of >4 cold workers queues behind this.
_WARM_CONCURRENCY = 4
_warm_slots = asyncio.Semaphore(_WARM_CONCURRENCY)
def now_iso() -> str:
2026-07-04 17:45:43 +05:30
"""UTC timestamp in the millisecond ISO shape used by scraper output."""
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
2026-07-17 19:42:10 -07:00
def _browser_cookie_jar(page: Any) -> dict[str, str]:
"""Extract a ``{name: value}`` jar from a browser Response (best-effort).
Scrapling's browser Response exposes cookies as a list of ``{name, value,
...}`` dicts (patchright/playwright shape); tolerate a plain ``{name: value}``
dict too so a shape change or a fake in tests still works.
"""
cookies = getattr(page, "cookies", None)
if isinstance(cookies, dict):
2026-07-17 19:42:10 -07:00
return {str(k): str(v) for k, v in cookies.items()}
jar: dict[str, str] = {}
if isinstance(cookies, (list, tuple)):
for item in cookies:
if isinstance(item, dict) and "name" in item and "value" in item:
jar[str(item["name"])] = str(item["value"])
return jar
def _parse_json(page: Any) -> Any | None:
"""Parse a scrapling response body into JSON, or ``None``.
Prefers ``page.json()``; falls back to ``json.loads`` on the raw body when
the impersonated response hands back text. No ``<pre>`` unwrapping that was
a v1 browser artifact and does not apply to plain-HTTP ``.json``.
"""
fn = getattr(page, "json", None)
if callable(fn):
with suppress(Exception):
return fn()
for attr in ("body", "text"):
val = getattr(page, attr, None)
if isinstance(val, bytes):
val = val.decode("utf-8", "replace")
if isinstance(val, str) and val.strip():
with suppress(Exception):
return json.loads(val)
return None
return None
def _build_url(path: str, params: dict[str, Any] | None) -> str:
"""``https://www.reddit.com/<path>/.json?raw_json=1&...`` (always raw_json)."""
clean = path.strip("/")
query = {"raw_json": "1", **(params or {})}
qs = urlencode({k: v for k, v in query.items() if v is not None})
return f"https://www.reddit.com/{clean}/.json?{qs}"
class _RotatingSession:
"""Owns one live ``FetcherSession`` (sticky IP) and can swap it for a fresh one.
``rotate()`` closes the current keep-alive connection and opens a new one, so
the rotating gateway hands out a different residential exit IP walking to
the next country pool (see :func:`_country_for_rotation`) so a wholly-blocked
pool can't fail the flow. Because the ``loid`` cookie binds to the exit IP,
``rotate()`` also drops the warmed state the next fetch re-warms on the new
IP. Used sequentially within a single flow (never shared across concurrent
tasks), so no locking is needed. ``session`` is ``None`` only when no proxy
is configured.
"""
def __init__(self) -> None:
self._cm: Any | None = None
self.session: Any | None = None
self.rotations = 0
self.warmed = False
self.country = ""
2026-07-17 19:42:10 -07:00
# Sticky proxy URL shared by the browser warm AND the HTTP fetch session
# so both egress the SAME exit IP (``loid`` binds to that IP). The minted
# cookie jar is replayed on every ``.json`` fetch.
self.proxy: str | None = None
self.cookies: dict[str, str] = {}
self._last_at = 0.0
async def _open(self) -> None:
self.warmed = False
2026-07-17 19:42:10 -07:00
self.cookies = {}
self.country = _country_for_rotation(self.rotations)
2026-07-17 19:42:10 -07:00
# A fresh vendor session id per (re)open pins a distinct sticky exit IP,
# so rotating drops the dirty IP AND its dead cookie jar in one step.
session_id = uuid.uuid4().hex[:12]
self.proxy = get_sticky_proxy_url(session_id, self.country)
if self.proxy is None:
self._cm = self.session = None
return
self._cm = FetcherSession(
2026-07-17 19:42:10 -07:00
proxy=self.proxy,
stealthy_headers=True,
impersonate="chrome",
timeout=_REQUEST_TIMEOUT_S,
)
self.session = await self._cm.__aenter__()
2026-07-17 19:42:10 -07:00
async def warm(self) -> bool:
"""Browser-mint the anonymous cookie jar on this session's sticky IP.
Returns ``True`` and stores the jar (:attr:`cookies`) when ``loid`` was
minted, else ``False`` (caller rotates the IP and retries).
"""
jar = await warm_session(self.proxy)
if jar:
self.cookies = jar
return True
return False
async def close(self) -> None:
if self._cm is not None:
with suppress(Exception): # best-effort teardown
await self._cm.__aexit__(None, None, None)
self._cm = self.session = None
async def rotate(self) -> Any | None:
"""Drop the current IP and connect through a fresh one. Returns new session."""
await self.close()
self.rotations += 1
await self._open()
logger.info(
"[reddit] rotated proxy session (rotation #%d, country=%s)",
self.rotations,
self.country,
)
return self.session
async def pace(self) -> None:
"""Sleep to hold this sticky IP under Reddit's per-IP rate threshold."""
wait = _MIN_INTERVAL_S - (time.monotonic() - self._last_at)
if wait > 0:
await asyncio.sleep(wait + random.uniform(0, _PACE_JITTER_S))
self._last_at = time.monotonic()
async def open_proxy_holder() -> _RotatingSession:
"""Open a warm rotate-on-block session holder (caller owns ``close()``)."""
holder = _RotatingSession()
await holder._open()
return holder
@asynccontextmanager
async def bind_proxy_holder(holder: _RotatingSession):
"""Route this task's fetches through ``holder`` for the enclosed block.
Does NOT close the holder enables pooling warm sessions across sequential
jobs so each job skips the ~2s proxy handshake AND the ``loid`` warm-up.
"""
token = _current_session.set(holder)
try:
yield holder
finally:
_current_session.reset(token)
@asynccontextmanager
async def proxy_session():
"""Open one reused, rotate-on-block proxy session for a continuation chain."""
holder = await open_proxy_holder()
try:
async with bind_proxy_holder(holder):
yield holder
finally:
await holder.close()
2026-07-17 19:42:10 -07:00
async def warm_session(proxy: str | None) -> dict[str, str] | None:
"""Browser-mint the anonymous cookie jar (incl. ``loid``) on a sticky IP.
2026-07-17 19:42:10 -07:00
Reddit gates ``loid`` minting behind a JS challenge that a plain-HTTP client
can't clear, so this spins up patchright-Chromium ONCE per session on the
given sticky proxy IP, loads a public HTML page so the challenge runs, and
returns the minted ``{name: value}`` cookie jar. Returns ``None`` when
``loid`` wasn't minted (a dirty exit IP), so the caller rotates to a fresh
sticky IP and retries. Proven live 2026-07-18 (see e2e step 0).
2026-07-17 19:42:10 -07:00
Takes the sticky ``proxy`` URL (never the fetch session) so the browser
egresses the SAME exit IP the HTTP ``.json`` fetches will replay the jar on.
A module-level semaphore bounds concurrent browser launches under fan-out.
2026-07-17 19:42:10 -07:00
What actually clears Reddit's wall is the stealth browser + ``google_search``
referer, NOT captcha solving. ``solve_cloudflare`` and ``network_idle`` were
each ~60s of dead wait per warm (Reddit's wall isn't Cloudflare, so the solver
hunts a challenge that never appears; and its long-lived connections never go
network-idle) dropping both took the warm from ~133s to ~10s with ``loid``
still minting 3/3 across us/gb/de exits (measured 2026-07-18). A rare miss
still self-heals: :func:`fetch_json` rotates to a fresh sticky IP and re-warms.
"""
if proxy is None:
return None
async with _warm_slots:
try:
async with AsyncStealthySession(
headless=True,
google_search=True,
network_idle=False,
proxy=proxy,
timeout=_WARM_TIMEOUT_MS,
) as sess:
page = await sess.fetch(_WARM_HTML_URL)
jar = _browser_cookie_jar(page)
return jar if _LOID_COOKIE in jar else None
except Exception as e: # a browser crash must not abort the flow
logger.warning("[reddit] browser warm failed: %s", e)
return None
2026-07-17 19:42:10 -07:00
async def _get_page(session: Any, url: str, cookies: dict[str, str]) -> Any:
"""GET through the warmed sticky session, or a one-shot proxied fetch.
``cookies`` is the browser-minted jar; ``over18`` is merged so NSFW listings
aren't blanked. The one-shot path (no bound session) has no minted jar and is
a best-effort fallback only it can't clear the JS challenge.
"""
if session is not None:
2026-07-17 19:42:10 -07:00
return await session.get(
url, headers=_HEADERS, cookies={**cookies, **_OVER18_COOKIES}
)
return await AsyncFetcher.get(
url,
headers=_HEADERS,
cookies=_OVER18_COOKIES,
proxy=get_proxy_url(),
stealthy_headers=True,
timeout=_REQUEST_TIMEOUT_S,
)
async def fetch_json(path: str, params: dict[str, Any] | None = None) -> Any | None:
"""GET a Reddit ``.json`` endpoint through a ``loid``-warmed HTTP session.
Returns parsed JSON (dict or list), or ``None`` on 404 / non-block failure.
Warms the ``loid`` session once per session; rotates the residential IP and
re-warms on 403; backs off on 429. Raises :class:`RedditAccessBlockedError`
only when every rotated IP refuses anonymous access (the yt-dlp
"login required" branch, which we cannot satisfy).
"""
holder = _current_session.get()
if holder is None:
# No bound session (e.g. a direct call outside fan_out): open a
# short-lived warmed session for this one fetch, then tear it down.
async with proxy_session():
return await fetch_json(path, params)
url = _build_url(path, params)
attempt = 0
backoffs = 0
while True:
session = holder.session
try:
if session is not None and not holder.warmed:
2026-07-17 19:42:10 -07:00
warmed_ok = await holder.warm()
holder.warmed = True # attempted; don't re-warm this IP
if not warmed_ok:
if attempt < _MAX_ROTATIONS:
attempt += 1
await holder.rotate()
continue
raise RedditAccessBlockedError(
f"could not mint loid after {attempt} IP rotations for {path}"
)
await holder.pace()
2026-07-17 19:42:10 -07:00
page = await _get_page(session, url, holder.cookies)
status = page.status
if status == 200:
return _parse_json(page)
if status == 404:
return None
if status == _BACKOFF_STATUS and backoffs < _MAX_BACKOFFS:
backoffs += 1
delay = _BACKOFF_BASE_S * (2 ** (backoffs - 1))
logger.warning("[reddit] 429 on %s; backing off %.1fs", path, delay)
await asyncio.sleep(delay + random.uniform(0, 1))
continue
if status == _ROTATE_STATUS and attempt < _MAX_ROTATIONS:
attempt += 1
await holder.rotate()
continue
if status == _ROTATE_STATUS:
raise RedditAccessBlockedError(
f"Reddit refused {path} on {attempt} rotated IPs (403)"
)
logger.warning("[reddit] GET %s returned %s", path, status)
return None
except RedditAccessBlockedError:
raise
except Exception as e:
logger.warning("[reddit] GET %s failed: %s", path, e)
if attempt < _MAX_ROTATIONS:
attempt += 1
await holder.rotate()
continue
return None