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>
This commit is contained in:
112
scripts/ops/backfill_cyberleninka_pdf.py
Executable file
112
scripts/ops/backfill_cyberleninka_pdf.py
Executable file
@@ -0,0 +1,112 @@
|
||||
#!/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()
|
||||
124
scripts/ops/backfill_pmc_fulltext.py
Executable file
124
scripts/ops/backfill_pmc_fulltext.py
Executable file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Бэкфилл полного текста для уже залитых документов PMC.
|
||||
|
||||
Зачем: PMC отдаёт тело статьи прямо в ответе efetch (40-50 тыс. символов), но
|
||||
документы, залитые до включения сохранения full_text, остались в базе с одной
|
||||
аннотацией — L1 сравнивает по ней и не видит тело статьи. Докачка PDF по url
|
||||
здесь не работает: ссылка ведёт на HTML-страницу NCBI (замер: 0 из 8). Поэтому
|
||||
текст берём тем же путём, что и парсер, — через API.
|
||||
|
||||
Замер на проде 2026-08-28: 12 368 документов PMC без полного текста.
|
||||
|
||||
Что делает для каждого документа: efetch по PMC id → извлечение текста →
|
||||
`store_full_text` (та же функция, что у задачи докачки: MinIO + пересчёт
|
||||
fingerprints по полному тексту + обновление MinHash LSH).
|
||||
|
||||
Идемпотентно: документы с уже проставленным minio_key не берутся.
|
||||
|
||||
Запуск (в контейнере worker-indexer, репозиторий внутрь не смонтирован):
|
||||
cd /home/user/anti-plagiarism
|
||||
C="docker compose -f docker-compose.prod.yml exec -T worker-indexer python -"
|
||||
$C < scripts/ops/backfill_pmc_fulltext.py # dry-run
|
||||
$C --apply --limit 40 < scripts/ops/backfill_pmc_fulltext.py # пробная порция
|
||||
$C --apply < scripts/ops/backfill_pmc_fulltext.py # всё
|
||||
|
||||
Внимание: каждый документ добавляет ~3.5 тыс. строк в fingerprints (~4.5 ГБ
|
||||
на все 12 тыс.). Перед полным прогоном стоит убедиться в свободном месте на
|
||||
сервере БД.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
|
||||
BATCH = 20 # столько id за один efetch — как в самом парсере
|
||||
|
||||
|
||||
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=0, help="взять не больше N документов (0 = все)")
|
||||
args = ap.parse_args()
|
||||
|
||||
if "/parsers" not in sys.path:
|
||||
sys.path.insert(0, "/parsers")
|
||||
|
||||
from app.db import db_session
|
||||
from app.tasks.index import store_full_text
|
||||
from sqlalchemy import text
|
||||
|
||||
with db_session() as s:
|
||||
sql = """
|
||||
SELECT id, ext_id FROM documents
|
||||
WHERE source = 'pmc' AND minio_key IS NULL AND ext_id IS NOT NULL
|
||||
ORDER BY id
|
||||
"""
|
||||
if args.limit:
|
||||
sql += f" LIMIT {int(args.limit)}"
|
||||
rows = s.execute(text(sql)).all()
|
||||
|
||||
# ext_id формата "pmc:13521573" → числовой id для efetch
|
||||
todo = {ext.split(":", 1)[-1]: doc_id for doc_id, ext in rows if ":" in ext}
|
||||
print(f"документов PMC без полного текста: {len(rows)} (пригодных ext_id: {len(todo)})")
|
||||
if not todo:
|
||||
return
|
||||
|
||||
if not args.apply:
|
||||
print(f"[dry-run] первые id: {list(todo.items())[:5]}")
|
||||
print("Запустите с --apply (рекомендуется сначала --limit 40).")
|
||||
return
|
||||
|
||||
from pmc import PMCParser
|
||||
|
||||
parser = PMCParser()
|
||||
ids = list(todo)
|
||||
done = failed = empty = 0
|
||||
chars_total = 0
|
||||
t0 = time.time()
|
||||
|
||||
for i in range(0, len(ids), BATCH):
|
||||
batch = ids[i : i + BATCH]
|
||||
try:
|
||||
resp = parser.client.get(
|
||||
"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi",
|
||||
params=parser._params(db="pmc", id=",".join(batch), rettype="full", retmode="xml"),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
articles = parser._parse_articles(resp.text)
|
||||
except Exception as e:
|
||||
failed += len(batch)
|
||||
print(f" батч {i // BATCH}: ошибка запроса: {e}")
|
||||
continue
|
||||
|
||||
for raw in articles:
|
||||
doc = parser.transform(raw)
|
||||
ext = (doc.get("ext_id") or "").split(":", 1)[-1]
|
||||
doc_id = todo.get(ext)
|
||||
full = doc.get("full_text") or ""
|
||||
if not doc_id:
|
||||
continue
|
||||
if len(full) < 1000: # аннотация вместо тела — сохранять нечего
|
||||
empty += 1
|
||||
continue
|
||||
try:
|
||||
store_full_text(doc_id, full)
|
||||
done += 1
|
||||
chars_total += len(full)
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
print(f" doc {doc_id}: не сохранён: {e}")
|
||||
|
||||
if (i // BATCH) % 10 == 0:
|
||||
speed = done / max(time.time() - t0, 1)
|
||||
print(f" обработано {i + len(batch)}/{len(ids)} · сохранено {done} · "
|
||||
f"{speed:.1f} док/с")
|
||||
time.sleep(0.35) # лимит NCBI без ключа — 3 запроса/с
|
||||
|
||||
avg = chars_total // done if done else 0
|
||||
print(f"\nГотово за {time.time() - t0:.0f}с: сохранено {done}, "
|
||||
f"без тела статьи {empty}, ошибок {failed}, средний объём {avg} симв.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user