refactor: implement cross-country proxy rotation for Reddit and TikTok scrapers, enhancing resilience against IP blocks and improving search query handling

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-07-17 16:30:26 -07:00
parent c0ebb62fb2
commit 8a5d37a4db
10 changed files with 213 additions and 22 deletions

View file

@ -373,6 +373,14 @@ TURNSTILE_SECRET_KEY=
# (cyclic); server-side-rotating gateways ignore it.
# PROXY_URL=http://user:pass@host:port
# PROXY_URLS=http://user:pass@host1:port,http://user:pass@host2:port
#
# NOTE (dataimpulse): its default *worldwide* pool (a PROXY_URL with no
# "__cr.<country>" suffix) hands out exit IPs some targets hard-block — Reddit
# 403s every one, TikTok withholds its ttwid cookie — so those scrapers
# pin/rotate exit countries on their own (no config needed). Other scrapers
# (e.g. Google SERP) deliberately prefer the worldwide pool, so leave PROXY_URL
# unpinned unless you have a reason; pin a country only for your whole install by
# adding a "__cr.<country>" suffix above.
# --- Google Search scraper: scale / warm sticky-IP pool -----------------------
# Per-process render concurrency AND the throughput lever: ceiling =

View file

@ -37,7 +37,15 @@ from urllib.parse import urlencode
from scrapling.fetchers import AsyncFetcher, FetcherSession
from app.utils.proxy import get_proxy_url
from app.utils.proxy import get_geo_proxy_url, get_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 (
FALLBACK_COUNTRIES as _FALLBACK_COUNTRIES,
country_for_rotation as _country_for_rotation,
rotation_countries as _rotation_countries,
)
logger = logging.getLogger(__name__)
@ -68,7 +76,16 @@ _current_session: ContextVar[_RotatingSession | None] = ContextVar(
# different handling per status (spec section 3).
_ROTATE_STATUS = 403
_BACKOFF_STATUS = 429
_MAX_ROTATIONS = 3
# 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
@ -155,11 +172,13 @@ 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.
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:
@ -167,11 +186,13 @@ class _RotatingSession:
self.session: Any | None = None
self.rotations = 0
self.warmed = False
self.country = ""
self._last_at = 0.0
async def _open(self) -> None:
proxy = get_proxy_url()
self.warmed = False
self.country = _country_for_rotation(self.rotations)
proxy = get_geo_proxy_url(self.country)
if proxy is None:
self._cm = self.session = None
return
@ -194,7 +215,11 @@ class _RotatingSession:
await self.close()
self.rotations += 1
await self._open()
logger.info("[reddit] rotated proxy session (rotation #%d)", self.rotations)
logger.info(
"[reddit] rotated proxy session (rotation #%d, country=%s)",
self.rotations,
self.country,
)
return self.session
async def pace(self) -> None:

View file

@ -10,6 +10,7 @@ from __future__ import annotations
from collections.abc import AsyncIterator
from typing import Any
from urllib.parse import quote
from .extraction.timestamps import now_iso
from .flows import FetchCommentsFn, FetchFn, FetchListingFn, FetchUsersFn
@ -31,15 +32,18 @@ from .targets.types import TikTokTarget
_PROFILE_URL = "https://www.tiktok.com/@{name}"
_HASHTAG_URL = "https://www.tiktok.com/tag/{tag}"
_SEARCH_URL = "https://www.tiktok.com/search?q={query}"
_EXPLORE_URL = "https://www.tiktok.com/explore"
def _resolve_targets(input_model: TikTokScrapeInput) -> list[TikTokTarget]:
"""Build the target list from the URL/profile/hashtag sources.
"""Build the target list from every input source.
A raw ``tiktok.com/search?...`` URL passed explicitly in
``startUrls``/``postURLs`` still resolves here and keeps its native listing
routing; there is no keyword-search shortcut.
``searchQueries`` map to the same ``tiktok.com/search?q=`` targets that a raw
search URL in ``startUrls``/``postURLs`` resolves to, so both share the
listing flow's parse/dedupe/cap/empty-ErrorItem contract (the anonymous
search feed often withholds results, degrading to one honest ErrorItem rather
than a silent empty).
"""
targets: list[TikTokTarget] = []
for entry in input_model.startUrls:
@ -55,6 +59,10 @@ def _resolve_targets(input_model: TikTokScrapeInput) -> list[TikTokTarget]:
targets.append(TikTokTarget("profile", name, _PROFILE_URL.format(name=name)))
for tag in input_model.hashtags:
targets.append(TikTokTarget("hashtag", tag, _HASHTAG_URL.format(tag=tag)))
for query in input_model.searchQueries:
resolved = resolve_target(_SEARCH_URL.format(query=quote(query)))
if resolved is not None:
targets.append(resolved)
return targets

View file

@ -26,7 +26,12 @@ logger = logging.getLogger(__name__)
# 403 => IP blocked; rotate and re-warm. 429 => rate limited; back off same IP.
_ROTATE_STATUS = 403
_BACKOFF_STATUS = 429
_MAX_ROTATIONS = 3
# Each rotation walks to the next country pool (see session/proxy.py). The bare
# worldwide pool never mints ``ttwid`` (proven live 2026-07-17), so warming
# relies on reaching a good country pool — budget enough rotations to try every
# one at least once (>= len(rotation FALLBACK_COUNTRIES)). Rotating is cheap
# (reopen one keep-alive connection + a 1-request warm), so spend liberally.
_MAX_ROTATIONS = 8
_MAX_BACKOFFS = 4
_BACKOFF_BASE_S = 5.0

View file

@ -18,7 +18,8 @@ from typing import Any
from scrapling.fetchers import FetcherSession
from app.utils.proxy import get_proxy_url
from app.utils.proxy import get_geo_proxy_url
from app.utils.proxy.rotation import country_for_rotation
logger = logging.getLogger(__name__)
@ -37,9 +38,13 @@ _current_session: ContextVar[_RotatingSession | None] = ContextVar(
class _RotatingSession:
"""Owns one live ``FetcherSession`` (sticky IP); ``rotate()`` swaps the 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.
Each open walks to the next country pool (see
:func:`app.utils.proxy.rotation.country_for_rotation`): TikTok withholds the
anonymous ``ttwid`` cookie from the provider's default worldwide pool but
mints it on country-pinned exits, so re-drawing within the same pool never
warms spreading rotations across country pools does. 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:
@ -47,11 +52,13 @@ class _RotatingSession:
self.session: Any | None = None
self.rotations = 0
self.warmed = False
self.country = ""
self._last_at = 0.0
async def _open(self) -> None:
proxy = get_proxy_url()
self.warmed = False
self.country = country_for_rotation(self.rotations)
proxy = get_geo_proxy_url(self.country)
if proxy is None:
self._cm = self.session = None
return
@ -74,7 +81,11 @@ class _RotatingSession:
await self.close()
self.rotations += 1
await self._open()
logger.info("[tiktok] rotated proxy session (rotation #%d)", self.rotations)
logger.info(
"[tiktok] rotated proxy session (rotation #%d, country=%s)",
self.rotations,
self.country,
)
return self.session
async def pace(self) -> None:

View file

@ -0,0 +1,34 @@
"""Cross-country exit rotation for warm-session scrapers.
Some targets (Reddit's ``loid``, TikTok's ``ttwid``) silently withhold their
anonymous session cookie from the provider's *default worldwide* pool but hand
it out freely on **country-pinned** exits (proven live: a bare-pool homepage
hit returns 200 with an empty cookie jar, while a us/gb/de/nl-pinned hit mints
the cookie every time). A warm-on-block flow that only re-draws from the same
worldwide pool therefore burns every rotation on cookie-less IPs and fails.
Walking a spread of country pools instead lets the flow escape a wholly-blocked
pool. The provider's configured country leads (so an operator's choice is
honoured first); the fallbacks are large, reliable residential pools. Non-geo
providers (e.g. the custom single-URL provider) ignore the country and re-draw
their one URL, so this is a harmless no-op there.
"""
from __future__ import annotations
from app.utils.proxy.registry import get_active_provider
# Walk order after the configured country. Ordered by pool size / reliability.
FALLBACK_COUNTRIES = ("us", "gb", "de", "ca", "nl", "fr")
def rotation_countries() -> tuple[str, ...]:
"""Ordered, de-duplicated exit countries with the configured one leading."""
lead = get_active_provider().get_location()
return tuple(dict.fromkeys(c for c in (lead, *FALLBACK_COUNTRIES) if c))
def country_for_rotation(n: int) -> str:
"""Exit country for rotation index ``n`` (cycles the list, wrapping around)."""
countries = rotation_countries()
return countries[n % len(countries)]

View file

@ -51,8 +51,14 @@ from app.proprietary.platforms.instagram.fetch import ( # noqa: E402
)
from app.proprietary.platforms.instagram.url_resolver import resolve_url # noqa: E402
_PROFILE = "natgeo"
_SEARCH_TERM = "national geographic"
# Canonical public targets. Override from the CLI to test any real-world case:
# python scripts/e2e_instagram_scraper.py <profile> [search term]
# Note: web_profile_info intermittently 400s for *business/creator* accounts
# (IG server bug on the ig_business_category_subvertical schema); a regular
# public account is the reliable smoke target.
_DEFAULT_PROFILE = "natgeo"
_PROFILE = sys.argv[1] if len(sys.argv) > 1 else _DEFAULT_PROFILE
_SEARCH_TERM = sys.argv[2] if len(sys.argv) > 2 else "national geographic"
_FIXTURE_DIR = _BACKEND_ROOT / "tests" / "unit" / "platforms" / "instagram" / "fixtures"
@ -179,6 +185,12 @@ async def step5_search() -> bool:
async def step6_dump_fixtures(post_url: str | None) -> bool:
_hr("STEP 6 — dump trimmed, anonymized fixtures for offline tests")
if _PROFILE != _DEFAULT_PROFILE:
return _check(
"dumped fixtures",
True,
f"skipped (custom profile {_PROFILE!r} would clobber committed fixtures)",
)
profile = await fetch_json("api/v1/users/web_profile_info/", {"username": _PROFILE})
_FIXTURE_DIR.mkdir(parents=True, exist_ok=True)
wrote = []

View file

@ -266,3 +266,7 @@ async def test_fan_out_closes_all_sessions_on_early_stop(monkeypatch):
async def test_fan_out_empty_jobs_is_noop():
out = [x async for x in scraper.fan_out([])]
assert out == []
# Cross-country rotation lives in app.utils.proxy.rotation and is shared with the
# TikTok sibling; its unit tests live in tests/unit/utils/proxy/test_rotation.py.

View file

@ -169,6 +169,37 @@ async def test_listing_dedupes_then_caps_per_target():
assert [i["id"] for i in items] == ["1", "2"]
async def test_search_query_resolves_to_listing_target():
# searchQueries must produce a search target (not be silently dropped): a
# query with results flows through the listing parse/dedupe/cap path.
async def fake_listing(url: str, _count: int) -> list[dict]:
assert "search?q=meal%20prep" in url # query wired into the search URL
return [{"id": "1", "author": {"uniqueId": "a"}}]
items = await scrape_tiktok(
TikTokScrapeInput(searchQueries=["meal prep"], resultsPerPage=5),
fetch=_no_html,
fetch_listing=fake_listing,
)
assert [i["id"] for i in items] == ["1"]
async def test_empty_search_query_degrades_to_error_item():
# A withheld anonymous search feed must surface one honest ErrorItem tagged
# with the query, never a silent empty.
async def fake_listing(_url: str, _count: int) -> list[dict]:
return []
items = await scrape_tiktok(
TikTokScrapeInput(searchQueries=["meal prep"], resultsPerPage=5),
fetch=_no_html,
fetch_listing=fake_listing,
)
assert len(items) == 1
assert items[0]["errorCode"] == "no_items"
assert items[0]["input"] == "meal prep"
async def test_empty_listing_emits_error_item():
# A trust-gated/empty feed (0 videos) must surface one honest ErrorItem,
# tagged with errorCode, rather than vanishing silently.

View file

@ -0,0 +1,53 @@
"""Unit tests for the shared cross-country exit rotation helper.
Offline: the provider is faked via monkeypatch, so no network/proxy is touched.
"""
from __future__ import annotations
from app.utils.proxy import rotation
class _Prov:
def __init__(self, location: str) -> None:
self._location = location
def get_location(self) -> str:
return self._location
def test_lead_country_leads_and_dedupes(monkeypatch):
# The configured/default exit country leads and isn't duplicated in the walk.
monkeypatch.setattr(rotation, "get_active_provider", lambda: _Prov("gb"))
countries = rotation.rotation_countries()
assert countries[0] == "gb"
assert len(countries) == len(set(countries)) # de-duplicated
assert set(rotation.FALLBACK_COUNTRIES) <= set(countries) # fallbacks kept
def test_no_configured_country_uses_fallbacks_only(monkeypatch):
# A bare PROXY_URL (no country) leaves just the fallback pools, in order.
monkeypatch.setattr(rotation, "get_active_provider", lambda: _Prov(""))
assert rotation.rotation_countries() == rotation.FALLBACK_COUNTRIES
def test_walk_covers_every_country_and_wraps(monkeypatch):
# A whole-pool block can't stall the walk: every country is reached, and the
# index cycles (wraps) rather than running off the end.
monkeypatch.setattr(rotation, "get_active_provider", lambda: _Prov("us"))
countries = rotation.rotation_countries()
tried = {rotation.country_for_rotation(n) for n in range(len(countries))}
assert tried == set(countries)
assert rotation.country_for_rotation(len(countries)) == countries[0] # wraps
def test_caller_budgets_cover_every_country():
# Each warm-on-block caller must budget enough rotations to try every pool at
# least once, else a wholly-blocked lead pool could fail a job prematurely.
from app.proprietary.platforms.reddit import fetch as reddit_fetch
from app.proprietary.platforms.tiktok.session import client as tiktok_client
assert reddit_fetch._MAX_ROTATIONS >= len(rotation.FALLBACK_COUNTRIES)
assert tiktok_client._MAX_ROTATIONS >= len(rotation.FALLBACK_COUNTRIES)