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