fix(parsers): починить CyberLeninka + подготовить русскую заливку корпуса
Корпус на 99.4% английский, 0 русских источников — при том что сервис для русских студентов. Корень: парсер CyberLeninka был сломан (слал GET на /api/search → HTTP 405) и ни разу не наполнял базу. - cyberleninka.py: GET→POST с JSON-телом (mode=articles); authors теперь из списка (API отдаёт список, не строку); чистка <b>-подсветки и HTML-сущностей ("). Проверено вживую: 5/5 студенческих тем возвращают реальные русские статьи. - Юнит-тесты парсера (scripts/parsers/tests/, 5 шт.) + обвязка; run_tests.sh обобщён на пути → парсеры теперь в тест-гейте CI. Всего тестов: 87. - scripts/seed_ru_sources.py: сидер parse_sources по 30 студенческим дисциплинам (dry-run по умолчанию, --apply для записи). НЕ запускает заливку — готовит задания. - docs/INGESTION.md: runbook (текущее состояние, шаги запуска, проверка, масштаб). Прод-путь index.run_parser уже поддерживает cyberleninka и openalex(lang=ru). Заливку не запускал — это отдельный go (ресурсоёмко: GPU-эмбеддинги, рост БД). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
52
docs/INGESTION.md
Normal file
52
docs/INGESTION.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Наполнение корпуса — runbook
|
||||||
|
|
||||||
|
## Текущее состояние (на 2026-08-12)
|
||||||
|
|
||||||
|
- ~**42K документов**, из них **99.4% английские, 0 русских** (см. `documents`).
|
||||||
|
- Заливка **встала 2026-08-07**. Корпус — seed из ~30 английских тем OpenAlex/arXiv.
|
||||||
|
- Для сервиса под русских студентов это главный дефект: русские работы проверять
|
||||||
|
не с чем.
|
||||||
|
|
||||||
|
## Что подготовлено
|
||||||
|
|
||||||
|
- **Парсер CyberLeninka починен** (`scripts/parsers/cyberleninka.py`): раньше слал GET
|
||||||
|
на `/api/search` → HTTP 405; теперь POST с JSON-телом (`mode=articles`), authors из
|
||||||
|
списка, чистка `<b>`/HTML-сущностей. Проверено вживую (5/5 тем) + юнит-тесты
|
||||||
|
(`scripts/parsers/tests/`), в гейте CI.
|
||||||
|
- **Прод-путь готов**: `index.run_parser(source_id)` уже умеет `cyberleninka` и
|
||||||
|
`openalex` c `lang=ru`.
|
||||||
|
- **Сидер источников**: `scripts/seed_ru_sources.py` — 30 студенческих дисциплин.
|
||||||
|
|
||||||
|
## Запуск русской заливки
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. (на app-хосте / в контейнере worker-indexer, где есть psycopg2 и прод-.env)
|
||||||
|
# Посмотреть план:
|
||||||
|
python scripts/seed_ru_sources.py
|
||||||
|
# Создать источники в parse_sources (лимит на дисциплину):
|
||||||
|
python scripts/seed_ru_sources.py --apply --limit 500
|
||||||
|
|
||||||
|
# 2. Проверить пару источников на темпе/качестве, затем запустить заливку:
|
||||||
|
# • Админ-панель → «Источники» → «Запустить», ЛИБО
|
||||||
|
# • Celery: index.run_parser.delay(source_id) по каждому id
|
||||||
|
```
|
||||||
|
|
||||||
|
Заливка сама: fetch (rate-limit 1 req/s) → `add_document` (дедуп по `ext_id`,
|
||||||
|
fingerprints L1, MinHash L2) → батч-эмбеддинги `gpu.embed_documents` (L3). ~30 тем ×
|
||||||
|
500 ≈ 15K русских документов на первый заход.
|
||||||
|
|
||||||
|
## Проверка результата
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT lang, count(*) FROM documents GROUP BY lang ORDER BY 2 DESC; -- должен появиться ru
|
||||||
|
SELECT source, count(*) FROM documents WHERE source='cyberleninka'; -- > 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Масштаб (следующий уровень)
|
||||||
|
|
||||||
|
- Больше тем + выше `--limit`; добавить OpenAlex `lang=ru` (качество ниже — англ.
|
||||||
|
заголовки с меткой ru).
|
||||||
|
- Для миллионов — **bulk** (снапшот OpenAlex на S3), а не постраничный API.
|
||||||
|
- На масштабе обязателен `VECTOR_BACKEND=qdrant` (FAISS flat не тянет), а таблица
|
||||||
|
`fingerprints` (уже ~29M строк на 42K доков) потребует партиционирования. См.
|
||||||
|
[ARCHITECTURE.md](ARCHITECTURE.md) и [DR-HA.md](DR-HA.md).
|
||||||
6
scripts/parsers/conftest.py
Normal file
6
scripts/parsers/conftest.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Добавляет каталог парсеров в sys.path (импорты вида `from base import ...`)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
@@ -3,11 +3,13 @@
|
|||||||
КиберЛенинка — крупнейший русскоязычный открытый репозиторий научных статей.
|
КиберЛенинка — крупнейший русскоязычный открытый репозиторий научных статей.
|
||||||
Сайт: https://cyberleninka.ru
|
Сайт: https://cyberleninka.ru
|
||||||
|
|
||||||
Используем парсинг HTML страниц со строгим rate limiting (1 req/sec),
|
Поиск — через JSON-API `POST /api/search` (`{"mode":"articles", "q", "size", "from"}`)
|
||||||
а не недокументированное API.
|
со строгим rate limiting (1 req/sec). Ответ подсвечивает совпадения тегами <b>…</b>
|
||||||
|
в name/annotation — чистим при трансформации.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import html
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
@@ -63,13 +65,16 @@ class CyberLeninkaParser(BaseParser):
|
|||||||
|
|
||||||
while len(results) < limit:
|
while len(results) < limit:
|
||||||
try:
|
try:
|
||||||
params: dict[str, Any] = {
|
# /api/search КиберЛенинки — это POST с JSON-телом (GET даёт 405).
|
||||||
|
# mode=articles обязателен, иначе поиск не по статьям.
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"mode": "articles",
|
||||||
"q": query,
|
"q": query,
|
||||||
"size": min(10, limit - len(results)),
|
"size": min(10, limit - len(results)),
|
||||||
"from": page * 10,
|
"from": page * 10,
|
||||||
}
|
}
|
||||||
|
|
||||||
response = self.client.get(SEARCH_URL, params=params)
|
response = self.client.post(SEARCH_URL, json=payload)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
@@ -114,9 +119,13 @@ class CyberLeninkaParser(BaseParser):
|
|||||||
if not ext_id:
|
if not ext_id:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
# Авторы (строка вида "Иванов И.И., Петров П.П.")
|
# Авторы: API отдаёт список строк "Фамилия И. О." (иногда — одну строку)
|
||||||
authors_str = raw.get("authors", "") or ""
|
raw_authors = raw.get("authors") or []
|
||||||
authors = _parse_cyberleninka_authors(authors_str)
|
authors = (
|
||||||
|
_parse_cyberleninka_authors(raw_authors)
|
||||||
|
if isinstance(raw_authors, str)
|
||||||
|
else _authors_from_list(raw_authors)
|
||||||
|
)
|
||||||
|
|
||||||
# Год
|
# Год
|
||||||
year_raw = raw.get("year")
|
year_raw = raw.get("year")
|
||||||
@@ -133,7 +142,7 @@ class CyberLeninkaParser(BaseParser):
|
|||||||
"source": self.source_name,
|
"source": self.source_name,
|
||||||
"ext_id": ext_id,
|
"ext_id": ext_id,
|
||||||
"doi": raw.get("doi") or None,
|
"doi": raw.get("doi") or None,
|
||||||
"title": (raw.get("name") or "").strip() or None,
|
"title": _clean(raw.get("name")) or None,
|
||||||
"authors": authors,
|
"authors": authors,
|
||||||
"year": year,
|
"year": year,
|
||||||
"lang": "ru", # КиберЛенинка — только русскоязычные
|
"lang": "ru", # КиберЛенинка — только русскоязычные
|
||||||
@@ -141,7 +150,7 @@ class CyberLeninkaParser(BaseParser):
|
|||||||
"volume": raw.get("volume") or None,
|
"volume": raw.get("volume") or None,
|
||||||
"issue": raw.get("number") or None,
|
"issue": raw.get("number") or None,
|
||||||
"pages": raw.get("pages") or None,
|
"pages": raw.get("pages") or None,
|
||||||
"abstract": (raw.get("annotation") or "").strip() or None,
|
"abstract": _clean(raw.get("annotation")) or None,
|
||||||
"url": url,
|
"url": url,
|
||||||
"full_text": None,
|
"full_text": None,
|
||||||
}
|
}
|
||||||
@@ -189,6 +198,26 @@ class CyberLeninkaParser(BaseParser):
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(text: str | None) -> str:
|
||||||
|
"""Убрать HTML-теги подсветки (<b>…</b>), декодировать сущности ("), обрезать."""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
return html.unescape(re.sub(r"<[^>]+>", "", str(text))).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _authors_from_list(items: list) -> list[dict[str, str]]:
|
||||||
|
"""Авторы из списка строк 'Фамилия И. О.' → [{last_name, initials}, ...]."""
|
||||||
|
authors: list[dict[str, str]] = []
|
||||||
|
for item in items:
|
||||||
|
name = _clean(str(item))
|
||||||
|
words = name.split()
|
||||||
|
if not words:
|
||||||
|
continue
|
||||||
|
initials = " ".join(words[1:]) if len(words) >= 2 else ""
|
||||||
|
authors.append({"last_name": words[0], "initials": initials})
|
||||||
|
return authors
|
||||||
|
|
||||||
|
|
||||||
def _parse_cyberleninka_authors(authors_str: str) -> list[dict[str, str]]:
|
def _parse_cyberleninka_authors(authors_str: str) -> list[dict[str, str]]:
|
||||||
"""
|
"""
|
||||||
Разбить строку авторов КиберЛенинки на список.
|
Разбить строку авторов КиберЛенинки на список.
|
||||||
|
|||||||
3
scripts/parsers/pytest.ini
Normal file
3
scripts/parsers/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
addopts = -q
|
||||||
4
scripts/parsers/requirements-test.txt
Normal file
4
scripts/parsers/requirements-test.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Зависимости для юнит-тестов парсеров (чистый transform, без сети).
|
||||||
|
pytest==8.2.0
|
||||||
|
httpx==0.27.0
|
||||||
|
beautifulsoup4==4.12.3
|
||||||
53
scripts/parsers/tests/test_cyberleninka.py
Normal file
53
scripts/parsers/tests/test_cyberleninka.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""Юнит-тесты парсера CyberLeninka — чистая логика transform (без сети).
|
||||||
|
|
||||||
|
Стерегут исправления реальных багов: JSON-API отдаёт authors списком, а name/
|
||||||
|
annotation — с HTML-подсветкой (<b>) и сущностями (").
|
||||||
|
"""
|
||||||
|
|
||||||
|
from cyberleninka import CyberLeninkaParser, _authors_from_list, _clean
|
||||||
|
|
||||||
|
# Форма ответа POST /api/search КиберЛенинки
|
||||||
|
SAMPLE = {
|
||||||
|
"name": "СОДЕРЖАНИЕ <b>ЭКОНОМИЧЕСКОГО</b> <b>АНАЛИЗА</b> "X"",
|
||||||
|
"annotation": "В статье <b>рассматривается</b> вопрос",
|
||||||
|
"authors": ["Вишникина А. Д.", "Клопова А. А."],
|
||||||
|
"journal": "Экономика и социум",
|
||||||
|
"year": 2024,
|
||||||
|
"link": "/article/n/soderzhanie",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_strips_tags_and_entities():
|
||||||
|
assert _clean('<b>Тест</b> "X"') == 'Тест "X"'
|
||||||
|
assert _clean(None) == ""
|
||||||
|
assert _clean(" чисто ") == "чисто"
|
||||||
|
|
||||||
|
|
||||||
|
def test_authors_from_list():
|
||||||
|
a = _authors_from_list(["Вишникина А. Д.", "Иванов И."])
|
||||||
|
assert a[0] == {"last_name": "Вишникина", "initials": "А. Д."}
|
||||||
|
assert a[1] == {"last_name": "Иванов", "initials": "И."}
|
||||||
|
assert _authors_from_list([]) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_transform_cleans_and_maps_fields():
|
||||||
|
t = CyberLeninkaParser().transform(SAMPLE)
|
||||||
|
assert "<b>" not in t["title"] and """ not in t["title"]
|
||||||
|
assert t["title"].startswith("СОДЕРЖАНИЕ ЭКОНОМИЧЕСКОГО АНАЛИЗА")
|
||||||
|
assert t["lang"] == "ru"
|
||||||
|
assert t["year"] == 2024
|
||||||
|
assert t["journal"] == "Экономика и социум"
|
||||||
|
assert t["url"] == "https://cyberleninka.ru/article/n/soderzhanie"
|
||||||
|
assert t["source"] == "cyberleninka"
|
||||||
|
assert t["authors"][0]["last_name"] == "Вишникина"
|
||||||
|
assert "<b>" not in t["abstract"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_transform_handles_string_authors():
|
||||||
|
raw = dict(SAMPLE, authors="Иванов И.И., Петров П.П.")
|
||||||
|
t = CyberLeninkaParser().transform(raw)
|
||||||
|
assert len(t["authors"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_transform_empty_without_id_or_link():
|
||||||
|
assert CyberLeninkaParser().transform({"name": "x"}) == {}
|
||||||
@@ -17,22 +17,24 @@ MIRROR="${PIP_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/simple/}"
|
|||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
IMAGE="python:3.11-slim"
|
IMAGE="python:3.11-slim"
|
||||||
|
|
||||||
# service:apt-пакеты (нужны faiss-cpu → libgomp1)
|
# относительный-путь:apt-пакеты (нужны faiss-cpu → libgomp1)
|
||||||
SERVICES=(
|
SERVICES=(
|
||||||
"worker-indexer:"
|
"services/worker-indexer:"
|
||||||
"worker-gost:"
|
"services/worker-gost:"
|
||||||
"worker-gpu:libgomp1"
|
"services/worker-gpu:libgomp1"
|
||||||
|
"scripts/parsers:"
|
||||||
)
|
)
|
||||||
|
|
||||||
run_service() {
|
run_service() {
|
||||||
local svc="$1" apt_pkgs="$2"
|
local dir_rel="$1" apt_pkgs="$2"
|
||||||
local dir="$ROOT/services/$svc"
|
local dir="$ROOT/$dir_rel"
|
||||||
|
local name="${dir_rel##*/}"
|
||||||
if [[ ! -f "$dir/requirements-test.txt" ]]; then
|
if [[ ! -f "$dir/requirements-test.txt" ]]; then
|
||||||
echo "⚠ $svc: нет requirements-test.txt — пропуск"
|
echo "⚠ $name: нет requirements-test.txt — пропуск"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
echo "──────────────────────────────────────────────"
|
echo "──────────────────────────────────────────────"
|
||||||
echo "▶ Тесты: $svc"
|
echo "▶ Тесты: $name"
|
||||||
echo "──────────────────────────────────────────────"
|
echo "──────────────────────────────────────────────"
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
-v "$dir":/app -w /app \
|
-v "$dir":/app -w /app \
|
||||||
@@ -49,12 +51,13 @@ run_service() {
|
|||||||
FILTER="${1:-}"
|
FILTER="${1:-}"
|
||||||
failed=0
|
failed=0
|
||||||
for entry in "${SERVICES[@]}"; do
|
for entry in "${SERVICES[@]}"; do
|
||||||
svc="${entry%%:*}"
|
dir_rel="${entry%%:*}"
|
||||||
apt="${entry#*:}"
|
apt="${entry#*:}"
|
||||||
[[ -n "$FILTER" && "$FILTER" != "$svc" ]] && continue
|
name="${dir_rel##*/}"
|
||||||
if ! run_service "$svc" "$apt"; then
|
[[ -n "$FILTER" && "$FILTER" != "$name" ]] && continue
|
||||||
|
if ! run_service "$dir_rel" "$apt"; then
|
||||||
failed=1
|
failed=1
|
||||||
echo "✗ $svc: тесты упали"
|
echo "✗ $name: тесты упали"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|||||||
121
scripts/seed_ru_sources.py
Normal file
121
scripts/seed_ru_sources.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Подготовка русскоязычной заливки корпуса — сидинг parse_sources (CyberLeninka).
|
||||||
|
|
||||||
|
Зачем: корпус на ~99% английский (0 русских источников), хотя сервис — для русских
|
||||||
|
студентов. Этот скрипт создаёт в таблице `parse_sources` набор заданий CyberLeninka
|
||||||
|
по основным студенческим дисциплинам. Он НЕ запускает заливку — только готовит
|
||||||
|
задания; их затем триггерят (кнопка «Запустить» в админке или Celery-задача
|
||||||
|
`index.run_parser(source_id)`).
|
||||||
|
|
||||||
|
Идемпотентно: источник с таким `name` повторно не создаётся.
|
||||||
|
|
||||||
|
Запуск:
|
||||||
|
# dry-run — показать план, ничего не писать:
|
||||||
|
python scripts/seed_ru_sources.py
|
||||||
|
# создать источники в БД (лимит на каждую дисциплину):
|
||||||
|
python scripts/seed_ru_sources.py --apply --limit 500
|
||||||
|
|
||||||
|
Креды PG берутся из .env (POSTGRES_HOST/PORT/DB/USER/PASSWORD) или окружения.
|
||||||
|
ВНИМАНИЕ: локальный .env может указывать на старый PG — для прода используйте
|
||||||
|
POSTGRES_HOST=192.168.1.38 (см. вывод по наполненности базы).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Студенческие дисциплины → поисковый запрос в CyberLeninka (русскоязычный).
|
||||||
|
DISCIPLINES = [
|
||||||
|
"экономический анализ", "менеджмент организации", "бухгалтерский учёт",
|
||||||
|
"финансы и кредит", "маркетинг", "гражданское право", "уголовное право",
|
||||||
|
"конституционное право", "трудовое право", "педагогика", "психология личности",
|
||||||
|
"социология", "философия науки", "история России", "политология",
|
||||||
|
"информационные технологии", "программирование", "базы данных",
|
||||||
|
"математический анализ", "физика", "химия", "биология", "медицина",
|
||||||
|
"экология", "лингвистика", "литературоведение", "государственное управление",
|
||||||
|
"международные отношения", "журналистика", "культурология",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_env() -> None:
|
||||||
|
"""Подтянуть переменные из .env репозитория, если файл есть."""
|
||||||
|
env = Path(__file__).resolve().parent.parent / ".env"
|
||||||
|
if not env.exists():
|
||||||
|
return
|
||||||
|
for line in env.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, _, val = line.partition("=")
|
||||||
|
os.environ.setdefault(key.strip(), val.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _pg_dsn() -> dict:
|
||||||
|
return {
|
||||||
|
"host": os.environ.get("POSTGRES_HOST", "localhost"),
|
||||||
|
"port": int(os.environ.get("POSTGRES_PORT", "5432")),
|
||||||
|
"dbname": os.environ.get("POSTGRES_DB", "antiplagiator"),
|
||||||
|
"user": os.environ.get("POSTGRES_USER", "antiplagiator"),
|
||||||
|
"password": os.environ.get("POSTGRES_PASSWORD", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ap.add_argument("--apply", action="store_true", help="реально записать в БД (иначе dry-run)")
|
||||||
|
ap.add_argument("--limit", type=int, default=500, help="лимит документов на дисциплину")
|
||||||
|
ap.add_argument("--source-type", default="cyberleninka", help="тип парсера (по умолчанию cyberleninka)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
_load_env()
|
||||||
|
rows = [(f"ru:{d}", args.source_type, d) for d in DISCIPLINES]
|
||||||
|
|
||||||
|
print(f"Дисциплин: {len(rows)} | тип: {args.source_type} | лимит/шт: {args.limit}")
|
||||||
|
print(f"Потенциальный объём: ~{len(rows) * args.limit} документов")
|
||||||
|
if not args.apply:
|
||||||
|
print("\n[dry-run] будут созданы источники (name → query):")
|
||||||
|
for name, _, q in rows:
|
||||||
|
print(f" {name:38} → {q!r}")
|
||||||
|
print("\nЗапустите с --apply, чтобы записать в parse_sources.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
import psycopg2
|
||||||
|
except ImportError:
|
||||||
|
sys.exit("Нужен psycopg2 (есть в образах воркеров). Запускайте на app-хосте/в контейнере.")
|
||||||
|
|
||||||
|
dsn = _pg_dsn()
|
||||||
|
print(f"\nПодключение к PG {dsn['host']}:{dsn['port']}/{dsn['dbname']} ...")
|
||||||
|
conn = psycopg2.connect(**dsn)
|
||||||
|
conn.autocommit = True
|
||||||
|
created, skipped, ids = 0, 0, []
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
for name, stype, query in rows:
|
||||||
|
cur.execute("SELECT id FROM parse_sources WHERE name = %s", (name,))
|
||||||
|
existing = cur.fetchone()
|
||||||
|
if existing:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
cur.execute(
|
||||||
|
'INSERT INTO parse_sources '
|
||||||
|
'(source_type, name, query, lang, "limit", enabled, last_status, docs_added) '
|
||||||
|
"VALUES (%s, %s, %s, 'ru', %s, TRUE, 'idle', 0) RETURNING id",
|
||||||
|
(stype, name, query, args.limit),
|
||||||
|
)
|
||||||
|
ids.append(cur.fetchone()[0])
|
||||||
|
created += 1
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print(f"Создано: {created}, пропущено (уже были): {skipped}")
|
||||||
|
if ids:
|
||||||
|
print(f"ID новых источников: {ids}")
|
||||||
|
print("\nЗапуск заливки (после проверки источников):")
|
||||||
|
print(" • Админка → раздел «Источники» → «Запустить» у каждого, ЛИБО")
|
||||||
|
print(" • Celery: for i in ids: index.run_parser.delay(i)")
|
||||||
|
print("Рекомендация: сначала запустить 1-2 для проверки темпа/качества, потом остальные.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user