fix(core): резать выборку по архивам, а не по годам — AND в API не работает
All checks were successful
Deploy / test (push) Successful in 7m41s
Deploy / deploy (push) Successful in 4s

Первый прогон на проде выдал документы 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:
jze9
2026-09-16 18:15:38 +05:00
parent 7348051c7b
commit 7adccd0362
2 changed files with 113 additions and 54 deletions

View File

@@ -28,7 +28,13 @@
- у `/v3/search/works` обязателен слэш на конце: иначе 301, а при редиректе - у `/v3/search/works` обязателен слэш на конце: иначе 301, а при редиректе
теряется заголовок Authorization и запрос уходит анонимным; теряется заголовок Authorization и запрос уходит анонимным;
- `offset` упирается в 100 000 (под капотом Azure Search, глубже — 400), - `offset` упирается в 100 000 (под капотом Azure Search, глубже — 400),
поэтому выборка режется по годам: в каждом году свой отсчёт; поэтому выборка режется на части — по одному архиву-поставщику на запрос;
- **условия через `AND` не связываются**: `(repositories.id:...) AND yearPublished:2026`
отдаёт 2.3 млн работ вперемешку по годам, то есть условия объединяются по
«или» и год работает лишь как подсказка ранжированию. По отдельности каждое
условие фильтрует честно (`yearPublished:2026` — ровно 2026, `repositories.id:1298`
— ровно этот архив). Поэтому в запрос идёт РОВНО ОДНО условие: номер архива.
Годы, если заданы, отсекаются уже на нашей стороне;
- `fullText` — не фильтруемое поле, `_exists_:fullText` отвечает 500. - `fullText` — не фильтруемое поле, `_exists_:fullText` отвечает 500.
Отбирать статьи с текстом приходится на своей стороне, оплачивая трафиком; Отбирать статьи с текстом приходится на своей стороне, оплачивая трафиком;
- CORE агрегирует репозитории и отдаёт одну статью несколько раз под разными - CORE агрегирует репозитории и отдаёт одну статью несколько раз под разными
@@ -41,9 +47,9 @@
import hashlib import hashlib
import logging import logging
import os import os
import re
import time import time
from collections.abc import Iterator from collections.abc import Iterator
from datetime import UTC, datetime
from typing import Any from typing import Any
import httpx import httpx
@@ -55,7 +61,6 @@ API_URL = "https://api.core.ac.uk/v3/search/works/" # слэш обязате
PAGE = 100 # максимум записей за запрос PAGE = 100 # максимум записей за запрос
MAX_OFFSET = 100_000 # потолок глубины у Azure Search MAX_OFFSET = 100_000 # потолок глубины у Azure Search
MIN_CHARS = 1500 # короче — обрывок или аннотация, а не статья MIN_CHARS = 1500 # короче — обрывок или аннотация, а не статья
DEFAULT_YEAR_FROM = 2010
RETRIES = 3 RETRIES = 3
@@ -91,45 +96,47 @@ class COREParser(BaseParser):
progress_cb: ProgressCallback | None = None, progress_cb: ProgressCallback | None = None,
**_ignored: Any, **_ignored: Any,
) -> Iterator[dict[str, Any]]: ) -> Iterator[dict[str, Any]]:
"""Статьи с полным текстом, год за годом от свежих к старым. """Статьи с полным текстом, архив за архивом.
Args: Args:
limit: сколько статей с текстом отдать за прогон limit: сколько статей с текстом отдать за прогон
query: поисковый запрос CORE; пустой — всё за год query: список архивов — номера через запятую/пробел или выражение
year_from: нижняя граница годов (включительно) `(repositories.id:N OR ...)`. Пустой — обычный поиск по словам
year_to: верхняя граница; по умолчанию текущий год year_from: отсекать статьи старше этого года (необязательно)
resume_token: позиция вида "2019:4200" — год и смещение в нём year_to: отсекать статьи новее этого года (необязательно)
resume_token: позиция вида "1298:4200" — архив и смещение в нём
progress_cb: см. base.ProgressCallback progress_cb: см. base.ProgressCallback
""" """
top = year_to or datetime.now(UTC).year parts = self._repos(query)
bottom = year_from or DEFAULT_YEAR_FROM start_part, start_offset = self._parse_token(resume_token)
years = list(range(top, bottom - 1, -1)) if start_part in parts:
parts = parts[parts.index(start_part):]
start_year, start_offset = self._parse_token(resume_token)
if start_year in years:
years = years[years.index(start_year):]
else: else:
start_offset = 0 start_offset = 0
given = 0 given = 0
seen: set[str] = set() seen: set[str] = set()
for year in years: for part in parts:
offset = start_offset offset = start_offset
start_offset = 0 # смещение относится только к году из токена start_offset = 0 # смещение относится только к архиву из токена
while given < limit and offset < MAX_OFFSET: while given < limit and offset < MAX_OFFSET:
results = self._page(query, year, offset) results = self._page(part, query, offset)
if results is None: # API не ответил — прогон закончен if results is None: # API не ответил — прогон закончен
return return
if not results: if not results:
logger.info("CORE: год %d исчерпан на смещении %d", year, offset) logger.info("CORE: архив %s исчерпан на смещении %d", part, offset)
break break
token = f"{year}:{offset}" token = f"{part}:{offset}"
for work in results: for work in results:
text = work.get("fullText") or "" text = work.get("fullText") or ""
if len(text) < MIN_CHARS: if len(text) < MIN_CHARS:
continue continue
year = work.get("yearPublished")
if year and ((year_from and year < year_from)
or (year_to and year > year_to)):
continue
# Дубли CORE: один текст под разными id (см. докстринг). # Дубли CORE: один текст под разными id (см. докстринг).
# Хеша начала текста хватает — совпадение первых 5 тыс. # Хеша начала текста хватает — совпадение первых 5 тыс.
# символов у разных статей практически исключено # символов у разных статей практически исключено
@@ -149,30 +156,45 @@ class COREParser(BaseParser):
break break
offset += PAGE offset += PAGE
self.last_token = f"{year}:{offset}" self.last_token = f"{part}:{offset}"
if progress_cb and not progress_cb(given): if progress_cb and not progress_cb(given):
logger.info("CORE: выборка остановлена по запросу (%d)", given) logger.info("CORE: выборка остановлена по запросу (%d)", given)
return return
logger.info("CORE: годы %d-%d пройдены, отдано %d", bottom, top, given) logger.info("CORE: пройдено архивов: %d, отдано %d", len(parts), given)
@staticmethod @staticmethod
def _parse_token(token: str | None) -> tuple[int | None, int]: def _repos(query: str) -> list[str]:
"""Разобрать позицию "год:смещение"; мусор — начать сначала.""" """Номера архивов из настройки источника.
Принимает и выражение `(repositories.id:1298 OR ...)`, и простой список
«1298, 21908». Если номеров нет вовсе — единственная часть с именем
`q`: тогда парсер просто ищет по словам запроса.
"""
ids = re.findall(r"repositories\.id:(\d+)", query or "")
if not ids:
ids = re.findall(r"\b\d{2,7}\b", query or "")
return ids or ["q"]
@staticmethod
def _parse_token(token: str | None) -> tuple[str | None, int]:
"""Разобрать позицию "архив:смещение"; мусор — начать сначала."""
if not token or ":" not in token: if not token or ":" not in token:
return None, 0 return None, 0
year, _, offset = token.partition(":") part, _, offset = token.rpartition(":")
try: try:
return int(year), int(offset) return part, int(offset)
except ValueError: except ValueError:
logger.warning("CORE: непонятная позиция %r, начинаем сначала", token) logger.warning("CORE: непонятная позиция %r, начинаем сначала", token)
return None, 0 return None, 0
def _page(self, query: str, year: int, offset: int) -> list[dict[str, Any]] | None: def _page(self, part: str, query: str, offset: int) -> list[dict[str, Any]] | None:
"""Одна страница выдачи; None — API не отвечает, прогон пора кончать.""" """Одна страница выдачи; None — API не отвечает, прогон пора кончать.
q = f"yearPublished:{year}"
if query: В запросе РОВНО одно условие: связка через AND в этом API не работает
q = f"({query}) AND {q}" (см. докстринг модуля), поэтому годы отсекаются уже после выборки.
"""
q = f"repositories.id:{part}" if part != "q" else (query or "*")
params = {"q": q, "limit": PAGE, "offset": offset} params = {"q": q, "limit": PAGE, "offset": offset}
for attempt in range(RETRIES): for attempt in range(RETRIES):
@@ -188,12 +210,12 @@ class COREParser(BaseParser):
return resp.json().get("results") or [] return resp.json().get("results") or []
except Exception as e: except Exception as e:
logger.warning( logger.warning(
"CORE: страница %d:%d не удалась (%d/%d): %s", "CORE: страница %s:%d не удалась (%d/%d): %s",
year, offset, attempt + 1, RETRIES, e, part, offset, attempt + 1, RETRIES, e,
) )
time.sleep(2 * (attempt + 1)) time.sleep(2 * (attempt + 1))
logger.error("CORE: страница %d:%d не далась за %d попыток", year, offset, RETRIES) logger.error("CORE: страница %s:%d не далась за %d попыток", part, offset, RETRIES)
return None return None
def transform(self, raw: dict[str, Any]) -> dict[str, Any]: def transform(self, raw: dict[str, Any]) -> dict[str, Any]:

View File

@@ -1,8 +1,12 @@
"""Юнит-тесты парсера CORE — чистая логика, без сети. """Юнит-тесты парсера CORE — чистая логика, без сети.
Стерегут то, ради чего парсер и написан иначе, чем остальные: отбор статей с Стерегут то, ради чего парсер написан иначе остальных: отбор статей с полным
полным текстом, отсев дублей CORE (одна статья под разными id) и позицию текстом, отсев дублей CORE (одна статья под разными id), нарезку выборки по
продолжения "год:смещение" — из-за неё выборка режется по годам. архивам-поставщикам и позицию продолжения «архив:смещение».
Нарезка по архивам — не украшение: связка условий через `AND` в API CORE не
работает (запрос по архивам вместе с годом отдаёт годы вперемешку), поэтому в
запрос идёт ровно одно условие, а годы отсекаются уже на нашей стороне.
""" """
from core import MIN_CHARS, COREParser from core import MIN_CHARS, COREParser
@@ -20,15 +24,31 @@ SAMPLE = {
"downloadUrl": "https://core.ac.uk/download/1.pdf", "downloadUrl": "https://core.ac.uk/download/1.pdf",
"abstract": "Аннотация", "abstract": "Аннотация",
"fullText": LONG, "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(): 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) == (None, 0)
assert COREParser._parse_token("мусор") == (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(): def test_transform_maps_fields():
@@ -38,8 +58,8 @@ def test_transform_maps_fields():
assert t["year"] == 2019 and t["lang"] == "ru" assert t["year"] == 2019 and t["lang"] == "ru"
assert t["journal"] == "Вестник" assert t["journal"] == "Вестник"
assert t["text"] == LONG 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]["last_name"] == "Кузьмин"
assert t["authors"][0]["initials"] == "Я.В." assert t["authors"][0]["initials"] == "Я.В."
@@ -61,8 +81,8 @@ def _parser_with_pages(pages):
p = COREParser(api_key="test") p = COREParser(api_key="test")
calls = [] calls = []
def fake_page(query, year, offset): def fake_page(part, query, offset):
calls.append((year, offset)) calls.append((part, offset))
return pages.pop(0) if pages else [] return pages.pop(0) if pages else []
p._page = fake_page # type: ignore[method-assign] p._page = fake_page # type: ignore[method-assign]
@@ -79,7 +99,7 @@ def test_fetch_drops_core_duplicates():
{"id": 4, "fullText": "коротко"}, {"id": 4, "fullText": "коротко"},
] ]
p = _parser_with_pages([page]) 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] 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 + "два"}]] pages = [[{"id": 1, "fullText": LONG}], [{"id": 2, "fullText": LONG + "два"}]]
p = _parser_with_pages(pages) p = _parser_with_pages(pages)
got = list(p.fetch(limit=10, year_from=2019, year_to=2019)) got = list(p.fetch(limit=10, query="repositories.id:1298"))
assert [w["resume_token"] for w in got] == ["2019:0", "2019:100"] 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([[], []]) p = _parser_with_pages([[], []])
list(p.fetch(limit=10, year_from=2018, year_to=2019, resume_token="2019:300")) list(p.fetch(limit=10, query="(repositories.id:1298 OR repositories.id:949)",
# Начали с года из токена и его смещения, пустой год — переход к следующему resume_token="1298:300"))
assert p.calls == [(2019, 300), (2018, 0)] # Начали с архива из токена и его смещения, пустой архив — переход к следующему
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([[]]) p = _parser_with_pages([[]])
list(p.fetch(limit=10, year_from=2019, year_to=2019, resume_token="1800:500")) list(p.fetch(limit=10, query="repositories.id:1298", resume_token="99999:500"))
assert p.calls == [(2019, 0)] 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(): def test_min_chars_threshold_is_meaningful():