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:
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user