#!/usr/bin/env python3 """Углубить индексацию КиберЛенинки: скачать PDF статей и переиндексировать по ним. Самая большая слабость корпуса (замер 2026-08-28): 99 883 документа КиберЛенинки — 56% всей базы — имеют в среднем 30 отпечатков. Это заголовок с аннотацией, тела статьи в индексе нет. Списывание из русской статьи система не найдёт, хотя сервис рассчитан именно на русских студентов. Причина: search API отдаёт только аннотацию и короткий OCR-фрагмент (~700 символов), а `url` ведёт на HTML-страницу — докачка по нему бесполезна (замер: 0 из 8). Зато PDF доступен прямым адресом `{url}/pdf` и извлекается: проверка на 4 статьях дала 13-40 тыс. символов у трёх, у одной 0 (скан без текстового слоя — такие пропускаем). Дальше — общий путь `store_full_text`: MinIO + пересчёт отпечатков L1 по полному тексту + обновление MinHash LSH (L2). Масштаб полного прогона: ~28 часов при вежливом 1 req/s, порядка +300 млн строк в fingerprints (~30 ГБ). Запускать в фоне (nohup) и следить за местом на БД. Запуск (в контейнере worker-indexer): cd /home/user/anti-plagiarism C="docker compose -f docker-compose.prod.yml exec -T worker-indexer python -" $C < scripts/ops/backfill_cyberleninka_pdf.py # dry-run $C --apply --limit 50 < scripts/ops/backfill_cyberleninka_pdf.py # пробная порция $C --apply < scripts/ops/backfill_cyberleninka_pdf.py # всё (сутки) Идемпотентно: документы с уже проставленным minio_key пропускаются, так что прерванный прогон продолжается с того же места. """ import argparse import time RATE_LIMIT_DELAY = 1.0 # КиберЛенинка не любит частых запросов MIN_USEFUL_CHARS = 1500 # меньше — скан без текстового слоя либо обрывок MAX_PDF_BYTES = 30 * 1024 * 1024 def main() -> None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--apply", action="store_true", help="реально качать и сохранять") ap.add_argument("--limit", type=int, default=0, help="взять не больше N документов (0 = все)") args = ap.parse_args() import httpx from app.db import db_session from app.extractors.pdf import extract_text_from_pdf from app.tasks.index import store_full_text from sqlalchemy import text with db_session() as s: sql = """ SELECT id, url FROM documents WHERE source = 'cyberleninka' AND minio_key IS NULL AND url IS NOT NULL ORDER BY id """ if args.limit: sql += f" LIMIT {int(args.limit)}" rows = s.execute(text(sql)).all() print(f"документов КиберЛенинки без полного текста: {len(rows)}") if not rows: return if not args.apply: print(f"[dry-run] пример: {rows[0][1]}/pdf") print(f"оценка полного прогона: ~{len(rows) * RATE_LIMIT_DELAY / 3600:.1f} ч, " f"~{len(rows) * 3000 / 1e6:.0f} млн строк отпечатков") print("Запустите с --apply (сначала --limit 50).") return client = httpx.Client( timeout=30, follow_redirects=True, headers={"User-Agent": "Mozilla/5.0 (compatible; AcademicHelper/1.0; +noreply@jze9.ru)"}, ) done = no_text = failed = 0 chars_total = 0 t0 = time.time() for n, (doc_id, url) in enumerate(rows, 1): try: resp = client.get(url.rstrip("/") + "/pdf") if resp.status_code != 200 or "pdf" not in resp.headers.get("content-type", "").lower() or len(resp.content) > MAX_PDF_BYTES: no_text += 1 else: body = extract_text_from_pdf(resp.content) if len(body) < MIN_USEFUL_CHARS: no_text += 1 # скан без текстового слоя else: store_full_text(doc_id, body) done += 1 chars_total += len(body) except Exception as e: failed += 1 if failed <= 5: print(f" doc {doc_id}: {type(e).__name__}: {str(e)[:80]}") if n % 25 == 0: el = time.time() - t0 print(f" {n}/{len(rows)} · углублено {done} · без текста {no_text} · " f"ошибок {failed} · {n / el:.2f} док/с · осталось ~{(len(rows) - n) / max(n / el, 0.01) / 3600:.1f} ч", flush=True) time.sleep(RATE_LIMIT_DELAY) avg = chars_total // done if done else 0 print(f"\nГотово за {(time.time() - t0) / 60:.1f} мин: углублено {done}, " f"без текстового слоя {no_text}, ошибок {failed}, средний объём {avg} симв.") if __name__ == "__main__": main()