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