Files
anti-plagiarism/scripts/parsers/tests/test_core.py
jze9 d137074ed5
All checks were successful
Deploy / test (push) Successful in 3m15s
Deploy / deploy (push) Successful in 3m43s
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>
2026-09-17 15:48:43 +05:00

157 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Юнит-тесты парсера 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("") == "" # явное «без прокси» сильнее окружения