mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-22 23:31:12 +02:00
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:
parent
c0ebb62fb2
commit
8a5d37a4db
10 changed files with 213 additions and 22 deletions
|
|
@ -373,6 +373,14 @@ TURNSTILE_SECRET_KEY=
|
||||||
# (cyclic); server-side-rotating gateways ignore it.
|
# (cyclic); server-side-rotating gateways ignore it.
|
||||||
# PROXY_URL=http://user:pass@host:port
|
# PROXY_URL=http://user:pass@host:port
|
||||||
# PROXY_URLS=http://user:pass@host1:port,http://user:pass@host2: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 -----------------------
|
# --- Google Search scraper: scale / warm sticky-IP pool -----------------------
|
||||||
# Per-process render concurrency AND the throughput lever: ceiling =
|
# Per-process render concurrency AND the throughput lever: ceiling =
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,15 @@ from urllib.parse import urlencode
|
||||||
|
|
||||||
from scrapling.fetchers import AsyncFetcher, FetcherSession
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -68,7 +76,16 @@ _current_session: ContextVar[_RotatingSession | None] = ContextVar(
|
||||||
# different handling per status (spec section 3).
|
# different handling per status (spec section 3).
|
||||||
_ROTATE_STATUS = 403
|
_ROTATE_STATUS = 403
|
||||||
_BACKOFF_STATUS = 429
|
_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
|
_MAX_BACKOFFS = 4
|
||||||
_BACKOFF_BASE_S = 5.0
|
_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.
|
"""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
|
``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
|
the rotating gateway hands out a different residential exit IP — walking to
|
||||||
``loid`` cookie binds to the exit IP, ``rotate()`` also drops the warmed
|
the next country pool (see :func:`_country_for_rotation`) so a wholly-blocked
|
||||||
state — the next fetch re-warms on the new IP. Used sequentially within a
|
pool can't fail the flow. Because the ``loid`` cookie binds to the exit IP,
|
||||||
single flow (never shared across concurrent tasks), so no locking is needed.
|
``rotate()`` also drops the warmed state — the next fetch re-warms on the new
|
||||||
``session`` is ``None`` only when no proxy is configured.
|
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:
|
def __init__(self) -> None:
|
||||||
|
|
@ -167,11 +186,13 @@ class _RotatingSession:
|
||||||
self.session: Any | None = None
|
self.session: Any | None = None
|
||||||
self.rotations = 0
|
self.rotations = 0
|
||||||
self.warmed = False
|
self.warmed = False
|
||||||
|
self.country = ""
|
||||||
self._last_at = 0.0
|
self._last_at = 0.0
|
||||||
|
|
||||||
async def _open(self) -> None:
|
async def _open(self) -> None:
|
||||||
proxy = get_proxy_url()
|
|
||||||
self.warmed = False
|
self.warmed = False
|
||||||
|
self.country = _country_for_rotation(self.rotations)
|
||||||
|
proxy = get_geo_proxy_url(self.country)
|
||||||
if proxy is None:
|
if proxy is None:
|
||||||
self._cm = self.session = None
|
self._cm = self.session = None
|
||||||
return
|
return
|
||||||
|
|
@ -194,7 +215,11 @@ class _RotatingSession:
|
||||||
await self.close()
|
await self.close()
|
||||||
self.rotations += 1
|
self.rotations += 1
|
||||||
await self._open()
|
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
|
return self.session
|
||||||
|
|
||||||
async def pace(self) -> None:
|
async def pace(self) -> None:
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from .extraction.timestamps import now_iso
|
from .extraction.timestamps import now_iso
|
||||||
from .flows import FetchCommentsFn, FetchFn, FetchListingFn, FetchUsersFn
|
from .flows import FetchCommentsFn, FetchFn, FetchListingFn, FetchUsersFn
|
||||||
|
|
@ -31,15 +32,18 @@ from .targets.types import TikTokTarget
|
||||||
|
|
||||||
_PROFILE_URL = "https://www.tiktok.com/@{name}"
|
_PROFILE_URL = "https://www.tiktok.com/@{name}"
|
||||||
_HASHTAG_URL = "https://www.tiktok.com/tag/{tag}"
|
_HASHTAG_URL = "https://www.tiktok.com/tag/{tag}"
|
||||||
|
_SEARCH_URL = "https://www.tiktok.com/search?q={query}"
|
||||||
_EXPLORE_URL = "https://www.tiktok.com/explore"
|
_EXPLORE_URL = "https://www.tiktok.com/explore"
|
||||||
|
|
||||||
|
|
||||||
def _resolve_targets(input_model: TikTokScrapeInput) -> list[TikTokTarget]:
|
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
|
``searchQueries`` map to the same ``tiktok.com/search?q=`` targets that a raw
|
||||||
``startUrls``/``postURLs`` still resolves here and keeps its native listing
|
search URL in ``startUrls``/``postURLs`` resolves to, so both share the
|
||||||
routing; there is no keyword-search shortcut.
|
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] = []
|
targets: list[TikTokTarget] = []
|
||||||
for entry in input_model.startUrls:
|
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)))
|
targets.append(TikTokTarget("profile", name, _PROFILE_URL.format(name=name)))
|
||||||
for tag in input_model.hashtags:
|
for tag in input_model.hashtags:
|
||||||
targets.append(TikTokTarget("hashtag", tag, _HASHTAG_URL.format(tag=tag)))
|
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
|
return targets
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,12 @@ logger = logging.getLogger(__name__)
|
||||||
# 403 => IP blocked; rotate and re-warm. 429 => rate limited; back off same IP.
|
# 403 => IP blocked; rotate and re-warm. 429 => rate limited; back off same IP.
|
||||||
_ROTATE_STATUS = 403
|
_ROTATE_STATUS = 403
|
||||||
_BACKOFF_STATUS = 429
|
_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
|
_MAX_BACKOFFS = 4
|
||||||
_BACKOFF_BASE_S = 5.0
|
_BACKOFF_BASE_S = 5.0
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@ from typing import Any
|
||||||
|
|
||||||
from scrapling.fetchers import FetcherSession
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -37,9 +38,13 @@ _current_session: ContextVar[_RotatingSession | None] = ContextVar(
|
||||||
class _RotatingSession:
|
class _RotatingSession:
|
||||||
"""Owns one live ``FetcherSession`` (sticky IP); ``rotate()`` swaps the IP.
|
"""Owns one live ``FetcherSession`` (sticky IP); ``rotate()`` swaps the IP.
|
||||||
|
|
||||||
Used sequentially within a single flow (never shared across concurrent
|
Each open walks to the next country pool (see
|
||||||
tasks), so no locking is needed. ``session`` is ``None`` only when no proxy
|
:func:`app.utils.proxy.rotation.country_for_rotation`): TikTok withholds the
|
||||||
is configured.
|
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:
|
def __init__(self) -> None:
|
||||||
|
|
@ -47,11 +52,13 @@ class _RotatingSession:
|
||||||
self.session: Any | None = None
|
self.session: Any | None = None
|
||||||
self.rotations = 0
|
self.rotations = 0
|
||||||
self.warmed = False
|
self.warmed = False
|
||||||
|
self.country = ""
|
||||||
self._last_at = 0.0
|
self._last_at = 0.0
|
||||||
|
|
||||||
async def _open(self) -> None:
|
async def _open(self) -> None:
|
||||||
proxy = get_proxy_url()
|
|
||||||
self.warmed = False
|
self.warmed = False
|
||||||
|
self.country = country_for_rotation(self.rotations)
|
||||||
|
proxy = get_geo_proxy_url(self.country)
|
||||||
if proxy is None:
|
if proxy is None:
|
||||||
self._cm = self.session = None
|
self._cm = self.session = None
|
||||||
return
|
return
|
||||||
|
|
@ -74,7 +81,11 @@ class _RotatingSession:
|
||||||
await self.close()
|
await self.close()
|
||||||
self.rotations += 1
|
self.rotations += 1
|
||||||
await self._open()
|
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
|
return self.session
|
||||||
|
|
||||||
async def pace(self) -> None:
|
async def pace(self) -> None:
|
||||||
|
|
|
||||||
34
surfsense_backend/app/utils/proxy/rotation.py
Normal file
34
surfsense_backend/app/utils/proxy/rotation.py
Normal 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)]
|
||||||
|
|
@ -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
|
from app.proprietary.platforms.instagram.url_resolver import resolve_url # noqa: E402
|
||||||
|
|
||||||
_PROFILE = "natgeo"
|
# Canonical public targets. Override from the CLI to test any real-world case:
|
||||||
_SEARCH_TERM = "national geographic"
|
# 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"
|
_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:
|
async def step6_dump_fixtures(post_url: str | None) -> bool:
|
||||||
_hr("STEP 6 — dump trimmed, anonymized fixtures for offline tests")
|
_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})
|
profile = await fetch_json("api/v1/users/web_profile_info/", {"username": _PROFILE})
|
||||||
_FIXTURE_DIR.mkdir(parents=True, exist_ok=True)
|
_FIXTURE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
wrote = []
|
wrote = []
|
||||||
|
|
|
||||||
|
|
@ -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():
|
async def test_fan_out_empty_jobs_is_noop():
|
||||||
out = [x async for x in scraper.fan_out([])]
|
out = [x async for x in scraper.fan_out([])]
|
||||||
assert 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.
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,37 @@ async def test_listing_dedupes_then_caps_per_target():
|
||||||
assert [i["id"] for i in items] == ["1", "2"]
|
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():
|
async def test_empty_listing_emits_error_item():
|
||||||
# A trust-gated/empty feed (0 videos) must surface one honest ErrorItem,
|
# A trust-gated/empty feed (0 videos) must surface one honest ErrorItem,
|
||||||
# tagged with errorCode, rather than vanishing silently.
|
# tagged with errorCode, rather than vanishing silently.
|
||||||
|
|
|
||||||
53
surfsense_backend/tests/unit/utils/proxy/test_rotation.py
Normal file
53
surfsense_backend/tests/unit/utils/proxy/test_rotation.py
Normal 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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue