| """Tahap 2 — parsing dokumen dengan MinerU, dengan cache berbasis isi. |
| |
| Kenapa cache-nya dikunci ke ISI dokumen, bukan ke nomor run: parsing adalah |
| tahap termahal (19 dtk/halaman di CPU). Kalau cache-nya per run, tiap kali |
| normalizer diubah dan pipeline dijalankan ulang, parsing ikut terulang percuma. |
| Dengan kunci isi+pengaturan+versi, dokumen yang sama hanya diparse sekali — |
| mau normalizer diubah 20 kali sekalipun. |
| |
| Kunci cache = sha256(isi berkas) + sidik jari pengaturan + versi MinerU. |
| Versi ikut supaya angka hasil MinerU lama tidak diam-diam tercampur dengan |
| hasil versi baru — ini penting karena angkanya dipakai untuk justifikasi biaya. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import hashlib |
| import json |
| import shutil |
| import time |
| from dataclasses import dataclass |
| from pathlib import Path |
|
|
| from .config import PipelineConfig |
|
|
|
|
| @dataclass |
| class ParseResult: |
| doc_id: str |
| source: Path |
| cache_dir: Path |
| content_list: Path |
| middle_json: Path | None |
| pages: int |
| seconds: float |
| from_cache: bool |
| backend_tercatat: str | None |
| mineru_version: str | None |
|
|
|
|
| def versi_mineru() -> str: |
| try: |
| from mineru.version import __version__ |
| return str(__version__) |
| except Exception: |
| try: |
| from importlib.metadata import version |
| return version("mineru") |
| except Exception: |
| return "unknown" |
|
|
|
|
| def hash_berkas(path: Path, potong: int = 16) -> str: |
| h = hashlib.sha256() |
| with path.open("rb") as f: |
| for blok in iter(lambda: f.read(1 << 20), b""): |
| h.update(blok) |
| return h.hexdigest()[:potong] |
|
|
|
|
| def kunci_cache(source: Path, cfg: PipelineConfig) -> str: |
| return f"{hash_berkas(source)}-{cfg.fingerprint()}-{versi_mineru()}" |
|
|
|
|
| def _cari_output(root: Path) -> tuple[Path | None, Path | None]: |
| """Cari content_list & middle json di bawah root. |
| |
| Sengaja pakai glob, bukan path tetap: struktur subfolder MinerU berbeda |
| antar backend ('auto' untuk pipeline, lain untuk vlm). Glob membuat modul |
| ini tidak perlu diubah saat backend diganti. |
| """ |
| content = next(iter(sorted(root.rglob("*_content_list.json"))), None) |
| middle = next(iter(sorted(root.rglob("*_middle.json"))), None) |
| return content, middle |
|
|
|
|
| def _baca_middle(middle: Path | None) -> tuple[str | None, str | None]: |
| """Ambil backend & versi yang BENAR-BENAR dipakai, dari output MinerU.""" |
| if not middle or not middle.exists(): |
| return None, None |
| try: |
| d = json.loads(middle.read_text(encoding="utf-8")) |
| return d.get("_backend"), d.get("_version_name") |
| except Exception: |
| return None, None |
|
|
|
|
| def parse_dokumen(source: Path, cfg: PipelineConfig) -> ParseResult: |
| """Parse satu dokumen. Kalau sudah ada di cache, tidak dijalankan ulang.""" |
| doc_id = source.stem |
| tujuan = cfg.cache_dir / "parse" / kunci_cache(source, cfg) |
| penanda = tujuan / ".selesai" |
|
|
| if penanda.exists(): |
| content, middle = _cari_output(tujuan) |
| if content: |
| meta = json.loads(penanda.read_text(encoding="utf-8")) |
| backend_tercatat, versi = _baca_middle(middle) |
| return ParseResult( |
| doc_id=doc_id, source=source, cache_dir=tujuan, |
| content_list=content, middle_json=middle, |
| pages=meta.get("pages", 0), seconds=meta.get("seconds", 0.0), |
| from_cache=True, |
| backend_tercatat=backend_tercatat, mineru_version=versi, |
| ) |
| shutil.rmtree(tujuan, ignore_errors=True) |
|
|
| from mineru.cli.common import do_parse, read_fn |
|
|
| |
| |
| lang = cfg.lang_kanonik() |
|
|
| sedang = tujuan.with_suffix(".sedang") |
| shutil.rmtree(sedang, ignore_errors=True) |
| sedang.mkdir(parents=True, exist_ok=True) |
|
|
| pdf_bytes = read_fn(source) |
| mulai = time.perf_counter() |
| do_parse( |
| output_dir=str(sedang), |
| pdf_file_names=[doc_id], |
| pdf_bytes_list=[pdf_bytes], |
| p_lang_list=[lang], |
| backend=cfg.backend, |
| formula_enable=cfg.formula_enable, |
| table_enable=cfg.table_enable, |
| f_draw_layout_bbox=cfg.write_debug_pdf, |
| f_draw_span_bbox=cfg.write_debug_pdf, |
| start_page_id=cfg.start_page, |
| end_page_id=cfg.end_page, |
| **({"effort": cfg.effort} if cfg.backend == "hybrid" else {}), |
| ) |
| detik = time.perf_counter() - mulai |
|
|
| content, middle = _cari_output(sedang) |
| if content is None: |
| raise RuntimeError( |
| f"MinerU selesai tapi *_content_list.json tidak ditemukan di {sedang}" |
| ) |
|
|
| halaman = _hitung_halaman(source, cfg) |
| sedang.rename(tujuan) |
| content = tujuan / content.relative_to(sedang) |
| middle = tujuan / middle.relative_to(sedang) if middle else None |
|
|
| penanda.write_text( |
| json.dumps({"pages": halaman, "seconds": detik, "doc_id": doc_id}), |
| encoding="utf-8", |
| ) |
| backend_tercatat, versi = _baca_middle(middle) |
|
|
| return ParseResult( |
| doc_id=doc_id, source=source, cache_dir=tujuan, |
| content_list=content, middle_json=middle, |
| pages=halaman, seconds=detik, from_cache=False, |
| backend_tercatat=backend_tercatat, mineru_version=versi, |
| ) |
|
|
|
|
| def _hitung_halaman(source: Path, cfg: PipelineConfig) -> int: |
| if cfg.end_page is not None: |
| return cfg.end_page - cfg.start_page + 1 |
| try: |
| from pypdf import PdfReader |
| return len(PdfReader(str(source)).pages) |
| except Exception: |
| return 0 |
|
|