Оба массовых источника исчерпаны — 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>
110 lines
4.5 KiB
Python
110 lines
4.5 KiB
Python
"""Юнит-тесты парсера 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
|