From d137074ed57e832cfcf307b67899d06aec80f4b1 Mon Sep 17 00:00:00 2001 From: jze9 Date: Thu, 17 Sep 2026 15:48:43 +0500 Subject: [PATCH] =?UTF-8?q?fix(core):=20=D1=85=D0=BE=D0=B4=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B2=20CORE=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20sing-?= =?UTF-8?q?box=20=E2=80=94=20=D1=81=20=D1=80=D0=BE=D1=81=D1=81=D0=B8=D0=B9?= =?UTF-8?q?=D1=81=D0=BA=D0=B8=D1=85=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=BA=D0=BB=D1=8E=D1=87=20=D0=BD=D0=B5=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=B0=D0=B5=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Заливка встала после двух порций: прогоны стали заканчиваться за две минуты с нулём документов. Выглядело как поломка сети, и на ложные следы ушло время — MTU в норме (1472 байта проходят), IPv6 ни при чём, Cloudflare из того же контейнера качается на 2.2 МБ/с, ключ и квота целы (с домашней машины тот же запрос отвечает за 6с, лимит нетронут). Разница оказалась в адресе. С прода: напрямую — код 000, обрыв на 25с (соединение есть, тело не приходит) через sing-box — код 200 за 1.7с, 207 КБ Анонимные короткие запросы проходят и напрямую — поэтому блокировка и маскировалась под сетевой сбой. CORE ведёт себя как OpenRouter, ради которого singbox-proxy и заводили, поэтому решение то же: httpx получает proxy из CORE_PROXY_URL (умолчание — socks5://singbox-proxy:1080, пусто = напрямую для локальных прогонов). В образ индексатора добавлен socksio: без него httpx не умеет SOCKS5. Co-Authored-By: Claude Opus 5 --- docker-compose.prod.yml | 4 ++++ scripts/parsers/core.py | 23 ++++++++++++++++++++- scripts/parsers/tests/test_core.py | 26 ++++++++++++++++-------- services/worker-indexer/requirements.txt | 1 + 4 files changed, 45 insertions(+), 9 deletions(-) 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)