File size: 5,784 Bytes
5840d20 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 | """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 # folder berisi output MinerU apa adanya
content_list: Path # *_content_list.json
middle_json: Path | None
pages: int
seconds: float
from_cache: bool
backend_tercatat: str | None # dibaca dari _middle.json, BUKAN dari config
mineru_version: str | None
def versi_mineru() -> str:
try:
from mineru.version import __version__ # type: ignore
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) # cache rusak, ulangi
from mineru.cli.common import do_parse, read_fn
# Playground melakukan ini sebelum parsing; alias seperti "latin"/"en"
# diterjemahkan ke kode kanonik. Disamakan supaya hasilnya identik.
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
|