mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-08 22:22:17 +02:00
feat(native-connector): add anonymous reddit fetch layer
This commit is contained in:
parent
1115d647cb
commit
7151465e12
1 changed files with 343 additions and 0 deletions
343
surfsense_backend/app/proprietary/scrapers/reddit/fetch.py
Normal file
343
surfsense_backend/app/proprietary/scrapers/reddit/fetch.py
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
"""Proxy-aware fetch seam for the Reddit scraper (no browser).
|
||||
|
||||
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).
|
||||
|
||||
Reddit deprecated *cold* unauthenticated ``.json`` (a bare anonymous GET now
|
||||
403s). The maintained anonymous recipe (proven live 2026-07-04, see
|
||||
``scripts/e2e_reddit_scraper.py`` step 0) is:
|
||||
|
||||
warm one anonymous session cookie (``loid``) with a plain GET to
|
||||
``old.reddit.com`` (``www.reddit.com/svc/shreddit/<slug>`` fallback), then
|
||||
GET ``www.reddit.com/<path>/.json?raw_json=1`` through that same
|
||||
Chrome-impersonated, sticky-IP session. Which warm URL mints ``loid`` is
|
||||
exit-IP dependent, so the order is a tiebreak — see :func:`warm_session`.
|
||||
|
||||
``loid`` is Reddit's equivalent of Google Maps' ``NID`` session cookie: an
|
||||
anonymous, logged-out id that unlocks the public API — no account, no browser.
|
||||
This module is a direct 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 a
|
||||
Reddit-specific :func:`warm_session` bolted on and a ``.json``-shaped
|
||||
:func:`fetch_json` instead of an InnerTube POST.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from contextvars import ContextVar
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from scrapling.fetchers import AsyncFetcher, FetcherSession
|
||||
|
||||
from app.utils.proxy import get_proxy_url
|
||||
|
||||
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
|
||||
_MAX_ROTATIONS = 3
|
||||
_MAX_BACKOFFS = 4
|
||||
_BACKOFF_BASE_S = 5.0
|
||||
|
||||
# Reddit 429s aggressively (~60-100 req/min/IP). Pace each sticky session to
|
||||
# ~1 req/s + jitter to stay under the per-IP threshold.
|
||||
_MIN_INTERVAL_S = 1.0
|
||||
_PACE_JITTER_S = 0.5
|
||||
|
||||
_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"}
|
||||
|
||||
# ``svc/shreddit`` needs *a* path to render; any always-public subreddit mints
|
||||
# ``loid`` just the same. ``r/popular`` always exists, so it's a safe default
|
||||
# regardless of which target this session ends up serving.
|
||||
_WARM_SLUG = "r/popular"
|
||||
_SHREDDIT_URL = (
|
||||
"https://www.reddit.com/svc/shreddit/{slug}"
|
||||
"?render-mode=partial&seeker-session=false"
|
||||
)
|
||||
_OLD_REDDIT_URL = "https://old.reddit.com/"
|
||||
_LOID_COOKIE = "loid"
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
"""UTC timestamp in the millisecond ISO shape the Apify actor stamps."""
|
||||
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
||||
|
||||
|
||||
def _response_cookie_names(page: Any) -> set[str]:
|
||||
"""Cookie names set by a response (best-effort across scrapling shapes)."""
|
||||
cookies = getattr(page, "cookies", None)
|
||||
if isinstance(cookies, dict):
|
||||
return set(cookies.keys())
|
||||
return set()
|
||||
|
||||
|
||||
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. 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._last_at = 0.0
|
||||
|
||||
async def _open(self) -> None:
|
||||
proxy = get_proxy_url()
|
||||
self.warmed = False
|
||||
if proxy is None:
|
||||
self._cm = self.session = None
|
||||
return
|
||||
self._cm = FetcherSession(
|
||||
proxy=proxy, stealthy_headers=True, impersonate="chrome"
|
||||
)
|
||||
self.session = await self._cm.__aenter__()
|
||||
|
||||
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)", self.rotations)
|
||||
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()
|
||||
|
||||
|
||||
async def warm_session(session: Any, *, slug: str = _WARM_SLUG) -> bool:
|
||||
"""Mint an anonymous ``loid`` cookie on a freshly opened session.
|
||||
|
||||
Returns ``True`` when a ``loid`` was issued (the session can now reach
|
||||
``.json``), else ``False`` (caller rotates the IP and retries).
|
||||
|
||||
Tries ``old.reddit`` (yt-dlp's primary), then ``svc/shreddit``. WHICH one
|
||||
mints is exit-IP dependent and roughly random: live probes 2026-07-04 saw
|
||||
both directions across sessions on the rotating residential/custom proxy
|
||||
(one IP 403s ``old.reddit`` but mints on ``shreddit``; another does the
|
||||
reverse, sometimes with an ``rdt`` bot-interstitial). So the order is a
|
||||
tiebreak, not an optimization — a fresh session pays ~one wasted warm 403
|
||||
either way. That cost is amortized: ``fan_out`` reuses one warmed session
|
||||
per worker across many jobs, so warm-up runs once per worker, not per fetch.
|
||||
The fallback is what actually matters — it preserves correctness whichever
|
||||
way a given IP leans.
|
||||
|
||||
ponytail: sequential two-source warm burns 1 wasted request on ~half of new
|
||||
sessions. A parallel warm (gather both, take whichever mints) removes the
|
||||
latency but always spends 2 requests; not worth it while warm-up is
|
||||
once-per-worker. Revisit only if session churn (not reuse) dominates.
|
||||
|
||||
Takes an already-open ``session`` (never constructs one) so tests can drive
|
||||
warm/rotate deterministically with a fake session, exactly like the youtube
|
||||
sibling's fetch-resilience tests.
|
||||
"""
|
||||
seen: set[str] = set()
|
||||
with suppress(Exception):
|
||||
page = await session.get(_OLD_REDDIT_URL, headers=_HEADERS)
|
||||
seen |= _response_cookie_names(page)
|
||||
if _LOID_COOKIE in seen:
|
||||
return True
|
||||
|
||||
# Fallback: mints loid on exit IPs where old.reddit 403s instead.
|
||||
with suppress(Exception):
|
||||
page = await session.get(_SHREDDIT_URL.format(slug=slug), headers=_HEADERS)
|
||||
seen |= _response_cookie_names(page)
|
||||
return _LOID_COOKIE in seen
|
||||
|
||||
|
||||
async def _get_page(session: Any, url: str) -> Any:
|
||||
"""GET through the warmed sticky session, or a one-shot proxied fetch."""
|
||||
if session is not None:
|
||||
return await session.get(url, headers=_HEADERS, cookies=_OVER18_COOKIES)
|
||||
return await AsyncFetcher.get(
|
||||
url,
|
||||
headers=_HEADERS,
|
||||
cookies=_OVER18_COOKIES,
|
||||
proxy=get_proxy_url(),
|
||||
stealthy_headers=True,
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
warmed_ok = await warm_session(session)
|
||||
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()
|
||||
page = await _get_page(session, url)
|
||||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue