fix(core): резать выборку по архивам, а не по годам — AND в API не работает
Первый прогон на проде выдал документы 2008-2019 годов, хотя запрос просил `yearPublished:2026`. Проверка показала: условия в запросе CORE через `AND` не связываются. По отдельности каждое фильтрует честно (`yearPublished:2026` — ровно 2026, `repositories.id:1298` — ровно этот архив), а вместе `(архивы) AND yearPublished:2026` отдаёт 2.3 млн работ вперемешку, то есть условия объединяются по «или», и год работает лишь подсказкой ранжированию. Значит нарезка по годам не нарезала ничего: каждый «год» перебирал один и тот же набор, а в выдачу подмешивались посторонние работы нужного года — включая англоязычные, ради ухода от которых источник и заводился. Теперь в запросе ровно одно условие — номер архива, и каждый архив опрашивается отдельно. Позиция продолжения стала «архив:смещение». Это ещё и честнее по потолку: `offset` упирается в 100 000, а самый крупный из наших архивов содержит 63 749 работ, то есть влезает целиком. Годы, если заданы в источнике, отсекаются теперь на нашей стороне. Список архивов парсер принимает и простым списком номеров, и прежним выражением `(repositories.id:N OR ...)` — настройку источника менять не нужно. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,12 @@
|
||||
"""Юнит-тесты парсера CORE — чистая логика, без сети.
|
||||
|
||||
Стерегут то, ради чего парсер и написан иначе, чем остальные: отбор статей с
|
||||
полным текстом, отсев дублей CORE (одна статья под разными id) и позицию
|
||||
продолжения "год:смещение" — из-за неё выборка режется по годам.
|
||||
Стерегут то, ради чего парсер написан иначе остальных: отбор статей с полным
|
||||
текстом, отсев дублей CORE (одна статья под разными id), нарезку выборки по
|
||||
архивам-поставщикам и позицию продолжения «архив:смещение».
|
||||
|
||||
Нарезка по архивам — не украшение: связка условий через `AND` в API CORE не
|
||||
работает (запрос по архивам вместе с годом отдаёт годы вперемешку), поэтому в
|
||||
запрос идёт ровно одно условие, а годы отсекаются уже на нашей стороне.
|
||||
"""
|
||||
|
||||
from core import MIN_CHARS, COREParser
|
||||
@@ -20,15 +24,31 @@ SAMPLE = {
|
||||
"downloadUrl": "https://core.ac.uk/download/1.pdf",
|
||||
"abstract": "Аннотация",
|
||||
"fullText": LONG,
|
||||
"resume_token": "2019:100",
|
||||
"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("2019:4200") == (2019, 4200)
|
||||
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)
|
||||
assert COREParser._parse_token("архив:смещение") == (None, 0)
|
||||
|
||||
|
||||
def test_transform_maps_fields():
|
||||
@@ -38,8 +58,8 @@ def test_transform_maps_fields():
|
||||
assert t["year"] == 2019 and t["lang"] == "ru"
|
||||
assert t["journal"] == "Вестник"
|
||||
assert t["text"] == LONG
|
||||
assert t["resume_token"] == "2019:100"
|
||||
# Автор приходит одной строкой "Фамилия, Имя Отчество"
|
||||
assert t["resume_token"] == "1298:100"
|
||||
# Автор приходит одной строкой «Фамилия, Имя Отчество»
|
||||
assert t["authors"][0]["last_name"] == "Кузьмин"
|
||||
assert t["authors"][0]["initials"] == "Я.В."
|
||||
|
||||
@@ -61,8 +81,8 @@ def _parser_with_pages(pages):
|
||||
p = COREParser(api_key="test")
|
||||
calls = []
|
||||
|
||||
def fake_page(query, year, offset):
|
||||
calls.append((year, offset))
|
||||
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]
|
||||
@@ -79,7 +99,7 @@ def test_fetch_drops_core_duplicates():
|
||||
{"id": 4, "fullText": "коротко"},
|
||||
]
|
||||
p = _parser_with_pages([page])
|
||||
got = list(p.fetch(limit=10, year_from=2019, year_to=2019))
|
||||
got = list(p.fetch(limit=10, query="repositories.id:1298"))
|
||||
assert [w["id"] for w in got] == [1, 3]
|
||||
|
||||
|
||||
@@ -88,21 +108,38 @@ 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"]
|
||||
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_years_down_and_resumes():
|
||||
def test_fetch_walks_archives_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)]
|
||||
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_year():
|
||||
def test_fetch_ignores_token_of_unknown_archive():
|
||||
p = _parser_with_pages([[]])
|
||||
list(p.fetch(limit=10, year_from=2019, year_to=2019, resume_token="1800:500"))
|
||||
assert p.calls == [(2019, 0)]
|
||||
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():
|
||||
|
||||
Reference in New Issue
Block a user