Проверка глубины вскрыла главную слабость корпуса: 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>
125 lines
5.5 KiB
Python
Executable File
125 lines
5.5 KiB
Python
Executable File
#!/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()
|