Compare commits

..

5 Commits

Author SHA1 Message Date
jze9
d137074ed5 fix(core): ходить в CORE через sing-box — с российских адресов ключ не работает
All checks were successful
Deploy / test (push) Successful in 3m15s
Deploy / deploy (push) Successful in 3m43s
Заливка встала после двух порций: прогоны стали заканчиваться за две минуты
с нулём документов. Выглядело как поломка сети, и на ложные следы ушло время —
MTU в норме (1472 байта проходят), IPv6 ни при чём, Cloudflare из того же
контейнера качается на 2.2 МБ/с, ключ и квота целы (с домашней машины тот же
запрос отвечает за 6с, лимит нетронут).

Разница оказалась в адресе. С прода:
  напрямую      — код 000, обрыв на 25с (соединение есть, тело не приходит)
  через sing-box — код 200 за 1.7с, 207 КБ
Анонимные короткие запросы проходят и напрямую — поэтому блокировка и
маскировалась под сетевой сбой.

CORE ведёт себя как OpenRouter, ради которого singbox-proxy и заводили,
поэтому решение то же: httpx получает proxy из CORE_PROXY_URL (умолчание —
socks5://singbox-proxy:1080, пусто = напрямую для локальных прогонов).
В образ индексатора добавлен socksio: без него httpx не умеет SOCKS5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:48:43 +05:00
jze9
9123844a87 fix(ops): не хоронить источник после одного пустого прогона
All checks were successful
Deploy / test (push) Successful in 11m54s
Deploy / deploy (push) Successful in 8s
Сторож считал источник исчерпанным, если прошлый прогон не добавил и не
выбрал ни одной статьи. Но ровно так же выглядит обрыв связи с API и прогон
на устаревшем коде парсера: сегодня CORE после единственной неудачи был
помечен исчерпанным и больше не запускался — молча, без ошибки в интерфейсе.

Теперь нужно два пустых прогона подряд. Разовый сбой переживём, а реально
кончившийся источник остановится всего на один прогон позже.

Заодно таймаут запроса к CORE снижен с 60 до 30 секунд: три попытки по
минуте отъедали 180 секунд из 1500 бюджета на одной залипшей странице —
та же грабля, что чинили в pmc_bulk. Обычный ответ приходит за 7 секунд.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 18:34:14 +05:00
jze9
263a521d63 fix(ops): перезапускать worker-indexer, когда менялись парсеры
Some checks failed
Deploy / deploy (push) Has been cancelled
Deploy / test (push) Has been cancelled
Парсеры не вшиты в образ, а смонтированы в worker-indexer, поэтому деплой
их не пересобирал — и не перезапускал контейнер, раз `services/` не менялся.
Но воркер держит модуль парсера в памяти с прошлого прогона: новый файл
лежит в контейнере, а работает старый код.

Поймано вживую на CORE: заливка после деплоя ушла ноль документов за 208
секунд, и только тело запросов в логах показало, что она всё ещё ходит по
старой схеме — с огромным OR-запросом, который на глубоком смещении трижды
упал по таймауту. Со стороны выглядело как «источник исчерпан», и сторож
чуть не выключил источник насовсем.

Теперь при изменениях в `scripts/parsers/` деплой перезапускает
worker-indexer — если образ и так не пересобирается на этом прогоне.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 18:32:11 +05:00
jze9
7adccd0362 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>
2026-09-16 18:15:38 +05:00
jze9
7348051c7b chore(ci): перезапустить деплой после чистки сервера Gitea
All checks were successful
Deploy / test (push) Successful in 8m7s
Deploy / deploy (push) Successful in 24s
Прогон №50 упал не на коде: сервер Gitea подмешивал в поток git-протокола
вывод постороннего скрипта, и клонирование рвалось с `fatal: early EOF`.
Причина устранена на сервере, клонирование проверено — пустой коммит нужен
только чтобы дать раннеру новое задание.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:56:43 +05:00
6 changed files with 195 additions and 71 deletions

View File

@@ -152,6 +152,10 @@ services:
depends_on: depends_on:
elasticsearch: elasticsearch:
condition: service_healthy condition: service_healthy
# CORE не отвечает на ключ с российских адресов — парсер ходит через
# sing-box (см. scripts/parsers/core.py)
singbox-proxy:
condition: service_started
worker-notifier: worker-notifier:
build: build:

View File

@@ -43,6 +43,7 @@ fi
REBUILD="" REBUILD=""
FRONTEND_CHANGED=0 FRONTEND_CHANGED=0
PARSERS_CHANGED=0
if [ "$FULL" = 1 ]; then if [ "$FULL" = 1 ]; then
REBUILD="$ALL_BACKEND" REBUILD="$ALL_BACKEND"
FRONTEND_CHANGED=1 FRONTEND_CHANGED=1
@@ -51,6 +52,12 @@ else
echo "$CHANGED" | grep -qE "^services/$svc/" && REBUILD="$REBUILD $svc" echo "$CHANGED" | grep -qE "^services/$svc/" && REBUILD="$REBUILD $svc"
done done
echo "$CHANGED" | grep -qE '^services/frontend/' && FRONTEND_CHANGED=1 echo "$CHANGED" | grep -qE '^services/frontend/' && FRONTEND_CHANGED=1
# Парсеры не вшиты в образ, а смонтированы в worker-indexer (см. compose),
# поэтому пересобирать нечего. Но процесс воркера держит модуль парсера в
# памяти с прошлого прогона: без перезапуска правка молча не применяется —
# заливка продолжает ходить по старому коду, и это не видно ниоткуда, кроме
# тела запросов в логах. Ловилось на CORE 16.09.2026.
echo "$CHANGED" | grep -qE '^scripts/parsers/' && PARSERS_CHANGED=1
fi fi
REBUILD="$(echo "$REBUILD" | xargs || true)" REBUILD="$(echo "$REBUILD" | xargs || true)"
@@ -111,6 +118,13 @@ echo "==> [4/6] Применение секретов к неизменивши
# shellcheck disable=SC2086 # shellcheck disable=SC2086
$COMPOSE up -d $ALL_BACKEND $COMPOSE up -d $ALL_BACKEND
# Правка парсера доезжает монтированием, но модуль уже загружен в память
# воркера — перезапускаем, если образ и так не пересобирался выше.
if [ "$PARSERS_CHANGED" = 1 ] && ! echo " $REBUILD " | grep -q " worker-indexer "; then
echo "==> [4.5/6] Парсеры менялись — перезапуск worker-indexer"
$COMPOSE restart worker-indexer
fi
echo "==> [5/6] Миграции БД (с ретраем)" echo "==> [5/6] Миграции БД (с ретраем)"
for i in $(seq 1 10); do for i in $(seq 1 10); do
$COMPOSE exec -T api alembic upgrade head && break $COMPOSE exec -T api alembic upgrade head && break

View File

@@ -115,12 +115,25 @@ def main() -> None:
started = [] started = []
for source_id, source_type, last_run_id in sources: for source_id, source_type, last_run_id in sources:
# Источник исчерпан, если прошлый прогон не добавил ни одной статьи # Источник исчерпан, если прогоны перестали добавлять статьи: дальше
# и не был отменён: дальше в дампе/бакете для нас ничего нет # в дампе/бакете/выдаче для нас ничего нет.
#
# Но ОДИН пустой прогон — ещё не приговор: ровно так же выглядят
# обрыв связи с API и прогон на устаревшем коде парсера. На CORE
# 16.09.2026 из-за этого источник был помечен исчерпанным после
# единственной неудачи и больше не запускался — молча, без ошибки.
# Поэтому ждём два пустых прогона подряд: разовый сбой переживём,
# а реально кончившийся источник остановится всего на один прогон
# позже.
if last_run_id and not args.keep_going_empty: if last_run_id and not args.keep_going_empty:
prev = s.get(ParseRun, last_run_id) last_two = s.execute(text("""
if prev and prev.status == "done" and prev.added == 0 and prev.fetched == 0: SELECT status, added, fetched FROM parse_runs
print(f"{source_type}: источник исчерпан, пропускаем") WHERE source_id = :sid ORDER BY id DESC LIMIT 2
"""), {"sid": source_id}).all()
if len(last_two) == 2 and all(
r.status == "done" and r.added == 0 and r.fetched == 0 for r in last_two
):
print(f"{source_type}: источник исчерпан (два пустых прогона), пропускаем")
continue continue
src = s.get(ParseSource, source_id) src = s.get(ParseSource, source_id)

View File

@@ -25,10 +25,20 @@
обогащать корпус нечем. обогащать корпус нечем.
Грабли API, все проверены живьём: Грабли API, все проверены живьём:
- **с российских адресов ключ не работает**: прямой запрос с прода виснет без
ошибки (соединение есть, тело ответа не приходит), через `singbox-proxy` тот
же запрос отвечает за 1.7с. Анонимные короткие запросы проходят и напрямую,
из-за чего поломка выглядит как сетевая. Адрес прокси — `CORE_PROXY_URL`;
- у `/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 +51,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,8 +65,13 @@ 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
# CORE отдаёт данные по ключу только за пределами РФ: с прода прямой запрос
# молча виснет (TCP есть, тело ответа не приходит), а тот же запрос через
# sing-box возвращается за 1.7с. Анонимные мелкие запросы проходят и напрямую,
# поэтому со стороны это выглядит как поломка сети, а не блокировка. Тот же
# приём, что для OpenRouter в worker-gpu. Пусто = ходить напрямую.
DEFAULT_PROXY = "socks5://singbox-proxy:1080"
class COREParser(BaseParser): class COREParser(BaseParser):
@@ -65,15 +80,21 @@ class COREParser(BaseParser):
source_name = "core" source_name = "core"
bulk = True bulk = True
def __init__(self, api_key: str | None = None) -> None: def __init__(self, api_key: str | None = None, proxy_url: str | None = None) -> None:
super().__init__() super().__init__()
self.api_key = api_key or os.environ.get("CORE_API_KEY", "") self.api_key = api_key or os.environ.get("CORE_API_KEY", "")
# Вне compose-сети (локальный прогон, тесты) имени singbox-proxy нет —
# там прокси отключают, выставив CORE_PROXY_URL пустым
self.proxy_url = self._proxy(proxy_url)
if not self.api_key: if not self.api_key:
logger.warning("CORE: ключ не задан (CORE_API_KEY) — API ответит 401") logger.warning("CORE: ключ не задан (CORE_API_KEY) — API ответит 401")
# Страница весит ~5 МБ, 12с как у PMC тут мало: обычный ответ ~7с. # Страница весит ~5 МБ, обычный ответ ~7с. 30с — запас на джиттер в
# 60с — с запасом на джиттер, но без риска съесть весь бюджет прогона # десять раз, но без риска съесть бюджет прогона: три попытки по 60с
# отъедали 180с из 1500с на одной залипшей странице (поймано 16.09.2026,
# та же грабля, что чинили в pmc_bulk)
self.client = httpx.Client( self.client = httpx.Client(
timeout=60, timeout=30,
proxy=self.proxy_url or None,
headers={ headers={
"Authorization": f"Bearer {self.api_key}", "Authorization": f"Bearer {self.api_key}",
"User-Agent": "AcademicHelper/1.0 (noreply@jze9.ru)", "User-Agent": "AcademicHelper/1.0 (noreply@jze9.ru)",
@@ -81,6 +102,13 @@ class COREParser(BaseParser):
) )
self.last_token: str | None = None self.last_token: str | None = None
@staticmethod
def _proxy(proxy_url: str | None) -> str:
"""Адрес прокси: явный аргумент, иначе CORE_PROXY_URL, иначе умолчание."""
if proxy_url is not None:
return proxy_url
return os.environ.get("CORE_PROXY_URL", DEFAULT_PROXY)
def fetch( # type: ignore[override] def fetch( # type: ignore[override]
self, self,
limit: int = 1000, limit: int = 1000,
@@ -91,45 +119,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 +179,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 +233,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,11 +1,15 @@
"""Юнит-тесты парсера CORE — чистая логика, без сети. """Юнит-тесты парсера CORE — чистая логика, без сети.
Стерегут то, ради чего парсер и написан иначе, чем остальные: отбор статей с Стерегут то, ради чего парсер написан иначе остальных: отбор статей с полным
полным текстом, отсев дублей CORE (одна статья под разными id) и позицию текстом, отсев дублей CORE (одна статья под разными id), нарезку выборки по
продолжения "год:смещение" — из-за неё выборка режется по годам. архивам-поставщикам и позицию продолжения «архив:смещение».
Нарезка по архивам — не украшение: связка условий через `AND` в API CORE не
работает (запрос по архивам вместе с годом отдаёт годы вперемешку), поэтому в
запрос идёт ровно одно условие, а годы отсекаются уже на нашей стороне.
""" """
from core import MIN_CHARS, COREParser from core import DEFAULT_PROXY, MIN_CHARS, COREParser
LONG = "слово " * 400 # заведомо длиннее MIN_CHARS LONG = "слово " * 400 # заведомо длиннее MIN_CHARS
@@ -20,49 +24,65 @@ 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():
t = COREParser().transform(SAMPLE) t = COREParser(proxy_url="").transform(SAMPLE)
assert t["ext_id"] == "core:123456" assert t["ext_id"] == "core:123456"
assert t["source"] == "core" assert t["source"] == "core"
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"] == "Я.В."
def test_transform_skips_short_text(): def test_transform_skips_short_text():
assert COREParser().transform(dict(SAMPLE, fullText="коротко")) == {} assert COREParser(proxy_url="").transform(dict(SAMPLE, fullText="коротко")) == {}
assert COREParser().transform(dict(SAMPLE, fullText=None)) == {} assert COREParser(proxy_url="").transform(dict(SAMPLE, fullText=None)) == {}
assert COREParser().transform(dict(SAMPLE, id=None)) == {} assert COREParser(proxy_url="").transform(dict(SAMPLE, id=None)) == {}
def test_transform_drops_undefined_language(): def test_transform_drops_undefined_language():
# zz у CORE значит «язык не определён» — в корпус такое класть незачем # zz у CORE значит «язык не определён» — в корпус такое класть незачем
assert COREParser().transform(dict(SAMPLE, language={"code": "zz"}))["lang"] is None assert COREParser(proxy_url="").transform(dict(SAMPLE, language={"code": "zz"}))["lang"] is None
assert COREParser().transform(dict(SAMPLE, language=None))["lang"] is None assert COREParser(proxy_url="").transform(dict(SAMPLE, language=None))["lang"] is None
def _parser_with_pages(pages): def _parser_with_pages(pages):
"""Парсер, у которого выдача подменена заранее заготовленными страницами.""" """Парсер, у которого выдача подменена заранее заготовленными страницами."""
p = COREParser(api_key="test") p = COREParser(api_key="test", proxy_url="")
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,22 +108,49 @@ 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():
assert MIN_CHARS >= 1000 assert MIN_CHARS >= 1000
def test_proxy_choice(monkeypatch):
# CORE не отвечает на ключ с российских адресов — по умолчанию идём через
# sing-box, но вне compose-сети прокси отключают пустым значением
monkeypatch.delenv("CORE_PROXY_URL", raising=False)
assert COREParser._proxy(None) == DEFAULT_PROXY
monkeypatch.setenv("CORE_PROXY_URL", "socks5://иной:1080")
assert COREParser._proxy(None) == "socks5://иной:1080"
assert COREParser._proxy("") == "" # явное «без прокси» сильнее окружения

View File

@@ -12,3 +12,4 @@ langdetect==1.0.9
beautifulsoup4==4.12.3 # парсер CyberLeninka: детали статьи (fetch_article_details) beautifulsoup4==4.12.3 # парсер CyberLeninka: детали статьи (fetch_article_details)
pydantic-settings==2.2.1 pydantic-settings==2.2.1
httpx==0.27.0 httpx==0.27.0
socksio==1.0.0 # SOCKS5-прокси для httpx (CORE блокирует запросы из РФ, см. scripts/parsers/core.py)