Заливка встала после двух порций: прогоны стали заканчиваться за две минуты с нулём документов. Выглядело как поломка сети, и на ложные следы ушло время — 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>
157 lines
7.1 KiB
Python
157 lines
7.1 KiB
Python
"""Юнит-тесты парсера CORE — чистая логика, без сети.
|
||
|
||
Стерегут то, ради чего парсер написан иначе остальных: отбор статей с полным
|
||
текстом, отсев дублей CORE (одна статья под разными id), нарезку выборки по
|
||
архивам-поставщикам и позицию продолжения «архив:смещение».
|
||
|
||
Нарезка по архивам — не украшение: связка условий через `AND` в API CORE не
|
||
работает (запрос по архивам вместе с годом отдаёт годы вперемешку), поэтому в
|
||
запрос идёт ровно одно условие, а годы отсекаются уже на нашей стороне.
|
||
"""
|
||
|
||
from core import DEFAULT_PROXY, MIN_CHARS, COREParser
|
||
|
||
LONG = "слово " * 400 # заведомо длиннее MIN_CHARS
|
||
|
||
SAMPLE = {
|
||
"id": 123456,
|
||
"title": "Нейронные сети в медицине",
|
||
"authors": [{"name": "Кузьмин, Ярослав Вадимович"}, {"name": "Савенок А."}],
|
||
"doi": "10.1234/x",
|
||
"yearPublished": 2019,
|
||
"language": {"code": "ru"},
|
||
"journals": [{"title": "Вестник"}],
|
||
"downloadUrl": "https://core.ac.uk/download/1.pdf",
|
||
"abstract": "Аннотация",
|
||
"fullText": LONG,
|
||
"resume_token": "1298:100",
|
||
}
|
||
|
||
|
||
def test_repos_from_or_expression():
|
||
q = "(repositories.id:1298 OR repositories.id:21908 OR repositories.id:949)"
|
||
assert COREParser._repos(q) == ["1298", "21908", "949"]
|
||
|
||
|
||
def test_repos_from_plain_list():
|
||
assert COREParser._repos("1298, 21908 949") == ["1298", "21908", "949"]
|
||
|
||
|
||
def test_repos_without_numbers_falls_back_to_word_search():
|
||
# Номеров нет — единственная часть «q»: обычный поиск по словам
|
||
assert COREParser._repos("нейронные сети") == ["q"]
|
||
assert COREParser._repos("") == ["q"]
|
||
|
||
|
||
def test_parse_token():
|
||
assert COREParser._parse_token("1298:4200") == ("1298", 4200)
|
||
assert COREParser._parse_token("q:300") == ("q", 300)
|
||
assert COREParser._parse_token(None) == (None, 0)
|
||
assert COREParser._parse_token("мусор") == (None, 0)
|
||
assert COREParser._parse_token("архив:смещение") == (None, 0)
|
||
|
||
|
||
def test_transform_maps_fields():
|
||
t = COREParser(proxy_url="").transform(SAMPLE)
|
||
assert t["ext_id"] == "core:123456"
|
||
assert t["source"] == "core"
|
||
assert t["year"] == 2019 and t["lang"] == "ru"
|
||
assert t["journal"] == "Вестник"
|
||
assert t["text"] == LONG
|
||
assert t["resume_token"] == "1298:100"
|
||
# Автор приходит одной строкой «Фамилия, Имя Отчество»
|
||
assert t["authors"][0]["last_name"] == "Кузьмин"
|
||
assert t["authors"][0]["initials"] == "Я.В."
|
||
|
||
|
||
def test_transform_skips_short_text():
|
||
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(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", proxy_url="")
|
||
calls = []
|
||
|
||
def fake_page(part, query, offset):
|
||
calls.append((part, offset))
|
||
return pages.pop(0) if pages else []
|
||
|
||
p._page = fake_page # type: ignore[method-assign]
|
||
p.calls = calls # type: ignore[attr-defined]
|
||
return p
|
||
|
||
|
||
def test_fetch_drops_core_duplicates():
|
||
# Одна и та же статья под разными id — CORE так отдаёт всегда
|
||
page = [
|
||
{"id": 1, "fullText": LONG},
|
||
{"id": 2, "fullText": LONG},
|
||
{"id": 3, "fullText": LONG + "иное"},
|
||
{"id": 4, "fullText": "коротко"},
|
||
]
|
||
p = _parser_with_pages([page])
|
||
got = list(p.fetch(limit=10, query="repositories.id:1298"))
|
||
assert [w["id"] for w in got] == [1, 3]
|
||
|
||
|
||
def test_fetch_position_points_at_own_page():
|
||
# Токен обязан указывать на страницу, откуда пришла статья: пачка может
|
||
# прерваться на середине, и следующий прогон перечитает её целиком
|
||
pages = [[{"id": 1, "fullText": LONG}], [{"id": 2, "fullText": LONG + "два"}]]
|
||
p = _parser_with_pages(pages)
|
||
got = list(p.fetch(limit=10, query="repositories.id:1298"))
|
||
assert [w["resume_token"] for w in got] == ["1298:0", "1298:100"]
|
||
|
||
|
||
def test_fetch_walks_archives_and_resumes():
|
||
p = _parser_with_pages([[], []])
|
||
list(p.fetch(limit=10, query="(repositories.id:1298 OR repositories.id:949)",
|
||
resume_token="1298:300"))
|
||
# Начали с архива из токена и его смещения, пустой архив — переход к следующему
|
||
assert p.calls == [("1298", 300), ("949", 0)]
|
||
|
||
|
||
def test_fetch_ignores_token_of_unknown_archive():
|
||
p = _parser_with_pages([[]])
|
||
list(p.fetch(limit=10, query="repositories.id:1298", resume_token="99999:500"))
|
||
assert p.calls == [("1298", 0)]
|
||
|
||
|
||
def test_fetch_filters_years_on_our_side():
|
||
# Годы API не фильтрует, поэтому отсекаем сами — и только если заданы
|
||
page = [
|
||
{"id": 1, "fullText": LONG, "yearPublished": 2004},
|
||
{"id": 2, "fullText": LONG + "два", "yearPublished": 2015},
|
||
{"id": 3, "fullText": LONG + "три", "yearPublished": 2030},
|
||
]
|
||
p = _parser_with_pages([list(page)])
|
||
got = list(p.fetch(limit=10, query="repositories.id:1298", year_from=2010, year_to=2026))
|
||
assert [w["id"] for w in got] == [2]
|
||
|
||
p2 = _parser_with_pages([list(page)])
|
||
got2 = list(p2.fetch(limit=10, query="repositories.id:1298"))
|
||
assert [w["id"] for w in got2] == [1, 2, 3]
|
||
|
||
|
||
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("") == "" # явное «без прокси» сильнее окружения
|