diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 1454d76..647ea82 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -152,6 +152,10 @@ services: depends_on: elasticsearch: condition: service_healthy + # CORE не отвечает на ключ с российских адресов — парсер ходит через + # sing-box (см. scripts/parsers/core.py) + singbox-proxy: + condition: service_started worker-notifier: build: diff --git a/scripts/parsers/core.py b/scripts/parsers/core.py index 44a9d0c..9acd842 100644 --- a/scripts/parsers/core.py +++ b/scripts/parsers/core.py @@ -25,6 +25,10 @@ обогащать корпус нечем. Грабли API, все проверены живьём: +- **с российских адресов ключ не работает**: прямой запрос с прода виснет без + ошибки (соединение есть, тело ответа не приходит), через `singbox-proxy` тот + же запрос отвечает за 1.7с. Анонимные короткие запросы проходят и напрямую, + из-за чего поломка выглядит как сетевая. Адрес прокси — `CORE_PROXY_URL`; - у `/v3/search/works` обязателен слэш на конце: иначе 301, а при редиректе теряется заголовок Authorization и запрос уходит анонимным; - `offset` упирается в 100 000 (под капотом Azure Search, глубже — 400), @@ -62,6 +66,12 @@ PAGE = 100 # максимум записей за запрос MAX_OFFSET = 100_000 # потолок глубины у Azure Search MIN_CHARS = 1500 # короче — обрывок или аннотация, а не статья RETRIES = 3 +# CORE отдаёт данные по ключу только за пределами РФ: с прода прямой запрос +# молча виснет (TCP есть, тело ответа не приходит), а тот же запрос через +# sing-box возвращается за 1.7с. Анонимные мелкие запросы проходят и напрямую, +# поэтому со стороны это выглядит как поломка сети, а не блокировка. Тот же +# приём, что для OpenRouter в worker-gpu. Пусто = ходить напрямую. +DEFAULT_PROXY = "socks5://singbox-proxy:1080" class COREParser(BaseParser): @@ -70,9 +80,12 @@ class COREParser(BaseParser): source_name = "core" bulk = True - def __init__(self, api_key: str | None = None) -> None: + def __init__(self, api_key: str | None = None, proxy_url: str | None = None) -> None: super().__init__() self.api_key = api_key or os.environ.get("CORE_API_KEY", "") + # Вне compose-сети (локальный прогон, тесты) имени singbox-proxy нет — + # там прокси отключают, выставив CORE_PROXY_URL пустым + self.proxy_url = self._proxy(proxy_url) if not self.api_key: logger.warning("CORE: ключ не задан (CORE_API_KEY) — API ответит 401") # Страница весит ~5 МБ, обычный ответ ~7с. 30с — запас на джиттер в @@ -81,6 +94,7 @@ class COREParser(BaseParser): # та же грабля, что чинили в pmc_bulk) self.client = httpx.Client( timeout=30, + proxy=self.proxy_url or None, headers={ "Authorization": f"Bearer {self.api_key}", "User-Agent": "AcademicHelper/1.0 (noreply@jze9.ru)", @@ -88,6 +102,13 @@ class COREParser(BaseParser): ) self.last_token: str | None = None + @staticmethod + def _proxy(proxy_url: str | None) -> str: + """Адрес прокси: явный аргумент, иначе CORE_PROXY_URL, иначе умолчание.""" + if proxy_url is not None: + return proxy_url + return os.environ.get("CORE_PROXY_URL", DEFAULT_PROXY) + def fetch( # type: ignore[override] self, limit: int = 1000, diff --git a/scripts/parsers/tests/test_core.py b/scripts/parsers/tests/test_core.py index a46d81e..d92341d 100644 --- a/scripts/parsers/tests/test_core.py +++ b/scripts/parsers/tests/test_core.py @@ -9,7 +9,7 @@ запрос идёт ровно одно условие, а годы отсекаются уже на нашей стороне. """ -from core import MIN_CHARS, COREParser +from core import DEFAULT_PROXY, MIN_CHARS, COREParser LONG = "слово " * 400 # заведомо длиннее MIN_CHARS @@ -52,7 +52,7 @@ def test_parse_token(): def test_transform_maps_fields(): - t = COREParser().transform(SAMPLE) + t = COREParser(proxy_url="").transform(SAMPLE) assert t["ext_id"] == "core:123456" assert t["source"] == "core" assert t["year"] == 2019 and t["lang"] == "ru" @@ -65,20 +65,20 @@ def test_transform_maps_fields(): def test_transform_skips_short_text(): - assert COREParser().transform(dict(SAMPLE, fullText="коротко")) == {} - assert COREParser().transform(dict(SAMPLE, fullText=None)) == {} - assert COREParser().transform(dict(SAMPLE, id=None)) == {} + assert COREParser(proxy_url="").transform(dict(SAMPLE, fullText="коротко")) == {} + assert COREParser(proxy_url="").transform(dict(SAMPLE, fullText=None)) == {} + assert COREParser(proxy_url="").transform(dict(SAMPLE, id=None)) == {} def test_transform_drops_undefined_language(): # zz у CORE значит «язык не определён» — в корпус такое класть незачем - assert COREParser().transform(dict(SAMPLE, language={"code": "zz"}))["lang"] is None - assert COREParser().transform(dict(SAMPLE, language=None))["lang"] is None + assert COREParser(proxy_url="").transform(dict(SAMPLE, language={"code": "zz"}))["lang"] is None + assert COREParser(proxy_url="").transform(dict(SAMPLE, language=None))["lang"] is None def _parser_with_pages(pages): """Парсер, у которого выдача подменена заранее заготовленными страницами.""" - p = COREParser(api_key="test") + p = COREParser(api_key="test", proxy_url="") calls = [] def fake_page(part, query, offset): @@ -144,3 +144,13 @@ def test_fetch_filters_years_on_our_side(): def test_min_chars_threshold_is_meaningful(): assert MIN_CHARS >= 1000 + + +def test_proxy_choice(monkeypatch): + # CORE не отвечает на ключ с российских адресов — по умолчанию идём через + # sing-box, но вне compose-сети прокси отключают пустым значением + monkeypatch.delenv("CORE_PROXY_URL", raising=False) + assert COREParser._proxy(None) == DEFAULT_PROXY + monkeypatch.setenv("CORE_PROXY_URL", "socks5://иной:1080") + assert COREParser._proxy(None) == "socks5://иной:1080" + assert COREParser._proxy("") == "" # явное «без прокси» сильнее окружения diff --git a/services/worker-indexer/requirements.txt b/services/worker-indexer/requirements.txt index b3c6c26..8174903 100644 --- a/services/worker-indexer/requirements.txt +++ b/services/worker-indexer/requirements.txt @@ -12,3 +12,4 @@ langdetect==1.0.9 beautifulsoup4==4.12.3 # парсер CyberLeninka: детали статьи (fetch_article_details) pydantic-settings==2.2.1 httpx==0.27.0 +socksio==1.0.0 # SOCKS5-прокси для httpx (CORE блокирует запросы из РФ, см. scripts/parsers/core.py)