Files
anti-plagiarism/scripts/ops/backfill_cyberleninka_pdf.py
jze9 d7c004af76 feat(ops): углубление индексации КиберЛенинки + общий store_full_text
Проверка глубины вскрыла главную слабость корпуса: 99 883 документа
КиберЛенинки (56% базы) имеют в среднем 30 отпечатков — заголовок с
аннотацией. Списывание из тела русской статьи L1 не находит, хотя сервис
рассчитан именно на русских студентов. У PMC и arXiv глубина 97% и 94%.

Отдельно: мерить глубину по minio_key оказалось неверно — PMC кладёт тело
статьи в отпечатки при заливке, не сохраняя файл, поэтому «27.5% с полным
текстом» занижало картину для одних источников и скрывало провал у другой.
Честный показатель — число отпечатков на документ, по нему всё и пересчитано.

- backfill_cyberleninka_pdf.py: PDF берётся прямым адресом {url}/pdf (докачка
  по обычному url бесполезна — там HTML, замер 0 из 8). Проверено на 50:
  углублено 44, в среднем 23 тыс. символов, глубина 30 → 1453 отпечатка;
- backfill_pmc_fulltext.py: сохранение тела статьи из API в MinIO (отпечатки
  там уже глубокие — скрипт нужен для отчётов и подсветки, не для детекции);
- store_full_text вынесен из index.enrich_full_text: один путь «текст получен →
  MinIO + пересчёт L1 + обновление L2» для задачи докачки и для бэкфиллов.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:45:48 +05:00

113 lines
5.6 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()