Files
anti-plagiarism/scripts/parsers/tests/test_core.py
jze9 3d4fcecbb2
Some checks failed
Deploy / test (push) Failing after 6s
Deploy / deploy (push) Has been skipped
feat(core): подключить CORE как источник полных текстов
Оба массовых источника исчерпаны — wikipedia_ru и pmc_bulk отдают ноль,
сторож честно пишет «источник исчерпан, пропускаем», и корпус стоит.
Брать новые статьи неоткуда, а главная дыра прежняя: полный текст есть у
11% корпуса, по-русски — почти нигде (CyberLeninka отдаёт только HTML
страницы, тела статьи там нет вовсе).

CORE кладёт готовый текст прямо в выдачу поиска: его не надо ни качать
отдельно, ни извлекать из PDF. Парсер массовый, как Википедия и PMC —
отдаёт статьи генератором, пишется пачками через COPY, помнит позицию.

Что выяснено живьём и учтено в коде:

- у /v3/search/works обязателен слэш на конце, иначе 301, а на редиректе
  теряется заголовок Authorization и запрос уходит анонимным;
- offset упирается в 100 000 (под капотом Azure Search), поэтому выборка
  режется по годам, а позиция продолжения — строка «год:смещение»;
- fullText не фильтруемое поле, _exists_:fullText отвечает 500 — статьи с
  текстом приходится отбирать на своей стороне;
- CORE отдаёт одну статью под разными id из разных репозиториев, а корпус
  дедуплицируется только по ext_id: такие пары легли бы отдельными
  документами и ловились бы как заимствование друг у друга. Отсеиваем по
  хешу начала текста в пределах прогона.

Позиция указывает на страницу, из которой пришла статья, а не на
следующую: пачка может прерваться на середине страницы, и тогда прогон
перечитает её целиком. Лишний запрос дешевле потерянных статей, дубли
отсекает ON CONFLICT.

Замер на живом API: страница в 100 записей приходит за ~7с. В общем
потоке CORE кириллицы нет вовсе (0 из 48 полных текстов за 2019), язык
размечен негодно — сплошь None и «zz». Зато работает отбор по архиву:
запрос по 41 вузовскому репозиторию России и Беларуси даёт 79 полных
текстов из 100 записей, 78 из них кириллические. Список архивов живёт в
поле query источника, а не в коде — правится из админки без пересборки.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:05:34 +05:00

110 lines
4.5 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) и позицию
продолжения "год:смещение" — из-за неё выборка режется по годам.
"""
from core import 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": "2019:100",
}
def test_parse_token():
assert COREParser._parse_token("2019:4200") == (2019, 4200)
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().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"] == "2019:100"
# Автор приходит одной строкой "Фамилия, Имя Отчество"
assert t["authors"][0]["last_name"] == "Кузьмин"
assert t["authors"][0]["initials"] == "Я.В."
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)) == {}
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
def _parser_with_pages(pages):
"""Парсер, у которого выдача подменена заранее заготовленными страницами."""
p = COREParser(api_key="test")
calls = []
def fake_page(query, year, offset):
calls.append((year, 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, year_from=2019, year_to=2019))
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, year_from=2019, year_to=2019))
assert [w["resume_token"] for w in got] == ["2019:0", "2019:100"]
def test_fetch_walks_years_down_and_resumes():
p = _parser_with_pages([[], []])
list(p.fetch(limit=10, year_from=2018, year_to=2019, resume_token="2019:300"))
# Начали с года из токена и его смещения, пустой год — переход к следующему
assert p.calls == [(2019, 300), (2018, 0)]
def test_fetch_ignores_token_of_unknown_year():
p = _parser_with_pages([[]])
list(p.fetch(limit=10, year_from=2019, year_to=2019, resume_token="1800:500"))
assert p.calls == [(2019, 0)]
def test_min_chars_threshold_is_meaningful():
assert MIN_CHARS >= 1000