#!/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()