|
20 | 20 | import shutil |
21 | 21 | import tempfile |
22 | 22 | import inspect |
| 23 | +import io |
| 24 | +import zipfile |
| 25 | +import time |
23 | 26 | from concurrent.futures import ThreadPoolExecutor, as_completed |
24 | 27 | from html import unescape |
25 | 28 | from datetime import datetime |
|
84 | 87 | PROJECT_ROOT = Path(__file__).resolve().parents[2] |
85 | 88 | PDF_DOWNLOAD_TIMEOUT = float(os.environ.get("READING_REPORT_PDF_TIMEOUT", "60")) |
86 | 89 | ARXIV_DETAIL_TIMEOUT = float(os.environ.get("READING_REPORT_ARXIV_TIMEOUT", "12")) |
| 90 | +MINERU_API_BASE_URL = os.environ.get("MINERU_API_BASE_URL", "https://mineru.net").strip().rstrip("/") |
| 91 | +MINERU_PARSE_TIMEOUT = float(os.environ.get("MINERU_PARSE_TIMEOUT", "300")) |
| 92 | +MINERU_PARSE_POLL_INTERVAL = float(os.environ.get("MINERU_PARSE_POLL_INTERVAL", "5")) |
| 93 | +MINERU_MODEL_VERSION = os.environ.get("MINERU_MODEL_VERSION", "vlm").strip() or "vlm" |
87 | 94 | MAX_ABSTRACT_CHARS = int(os.environ.get("READING_REPORT_ABSTRACT_CHARS", "1200")) |
88 | 95 | MAX_SECTION_CHARS = int(os.environ.get("READING_REPORT_SECTION_CHARS", "1800")) |
89 | 96 | READING_REPORT_CHUNK_CHARS = int(os.environ.get("READING_REPORT_CHUNK_CHARS", "1200")) |
@@ -2679,6 +2686,128 @@ def _parse_pdf_for_report(pdf_path: str) -> Dict[str, Any]: |
2679 | 2686 | } |
2680 | 2687 |
|
2681 | 2688 |
|
| 2689 | +def _mineru_enabled() -> bool: |
| 2690 | + return _env_flag("MINERU_PARSE_ENABLED", default=True) and bool(os.environ.get("MINERU_API_KEY", "").strip()) |
| 2691 | + |
| 2692 | + |
| 2693 | +def _mineru_headers() -> Dict[str, str]: |
| 2694 | + token = os.environ.get("MINERU_API_KEY", "").strip() |
| 2695 | + return { |
| 2696 | + "Authorization": f"Bearer {token}", |
| 2697 | + "Content-Type": "application/json", |
| 2698 | + } |
| 2699 | + |
| 2700 | + |
| 2701 | +def _mineru_extract_data(payload: Any) -> Dict[str, Any]: |
| 2702 | + if isinstance(payload, dict): |
| 2703 | + data = payload.get("data") |
| 2704 | + if isinstance(data, dict): |
| 2705 | + return data |
| 2706 | + return payload |
| 2707 | + return {} |
| 2708 | + |
| 2709 | + |
| 2710 | +def _mineru_task_id(payload: Dict[str, Any]) -> str: |
| 2711 | + data = _mineru_extract_data(payload) |
| 2712 | + for key in ("task_id", "taskId", "id"): |
| 2713 | + value = data.get(key) or payload.get(key) |
| 2714 | + if value: |
| 2715 | + return str(value) |
| 2716 | + return "" |
| 2717 | + |
| 2718 | + |
| 2719 | +def _mineru_task_state(payload: Dict[str, Any]) -> str: |
| 2720 | + data = _mineru_extract_data(payload) |
| 2721 | + return str(data.get("state") or data.get("status") or payload.get("state") or payload.get("status") or "").strip().lower() |
| 2722 | + |
| 2723 | + |
| 2724 | +def _mineru_zip_url(payload: Dict[str, Any]) -> str: |
| 2725 | + data = _mineru_extract_data(payload) |
| 2726 | + for key in ("full_zip_url", "fullZipUrl", "zip_url", "zipUrl", "result_url", "resultUrl"): |
| 2727 | + value = data.get(key) or payload.get(key) |
| 2728 | + if value: |
| 2729 | + return str(value) |
| 2730 | + return "" |
| 2731 | + |
| 2732 | + |
| 2733 | +def _download_mineru_markdown(zip_url: str) -> str: |
| 2734 | + response = requests.get(zip_url, timeout=PDF_DOWNLOAD_TIMEOUT) |
| 2735 | + response.raise_for_status() |
| 2736 | + with zipfile.ZipFile(io.BytesIO(response.content)) as archive: |
| 2737 | + names = archive.namelist() |
| 2738 | + preferred = [ |
| 2739 | + name for name in names |
| 2740 | + if name.lower().endswith("full.md") or name.lower().endswith("/full.md") |
| 2741 | + ] |
| 2742 | + candidates = preferred or [name for name in names if name.lower().endswith(".md")] |
| 2743 | + if not candidates: |
| 2744 | + raise RuntimeError("MinerU result zip did not contain markdown") |
| 2745 | + with archive.open(candidates[0]) as handle: |
| 2746 | + return handle.read().decode("utf-8", errors="replace") |
| 2747 | + |
| 2748 | + |
| 2749 | +def _sections_from_markdown(markdown: str) -> Dict[str, str]: |
| 2750 | + sections: Dict[str, List[str]] = {} |
| 2751 | + current = "full_text" |
| 2752 | + for raw_line in str(markdown or "").splitlines(): |
| 2753 | + heading = re.match(r"^\s{0,3}#{1,4}\s+(.+?)\s*$", raw_line) |
| 2754 | + if heading: |
| 2755 | + label = re.sub(r"[^a-z0-9]+", "_", heading.group(1).strip().lower()).strip("_") or "section" |
| 2756 | + current = label[:80] |
| 2757 | + sections.setdefault(current, []) |
| 2758 | + continue |
| 2759 | + sections.setdefault(current, []).append(raw_line) |
| 2760 | + return {key: _clean_text("\n".join(value)) for key, value in sections.items() if _clean_text("\n".join(value))} |
| 2761 | + |
| 2762 | + |
| 2763 | +def _parse_pdf_url_with_mineru(pdf_url: str) -> Dict[str, Any]: |
| 2764 | + if not _mineru_enabled(): |
| 2765 | + raise RuntimeError("MinerU parser is disabled or MINERU_API_KEY is not configured") |
| 2766 | + base_url = os.environ.get("MINERU_API_BASE_URL", MINERU_API_BASE_URL).strip().rstrip("/") or "https://mineru.net" |
| 2767 | + submit_url = f"{base_url}/api/v4/extract/task" |
| 2768 | + payload = { |
| 2769 | + "url": pdf_url, |
| 2770 | + "model_version": os.environ.get("MINERU_MODEL_VERSION", MINERU_MODEL_VERSION).strip() or "vlm", |
| 2771 | + "is_ocr": True, |
| 2772 | + "enable_formula": True, |
| 2773 | + "enable_table": True, |
| 2774 | + } |
| 2775 | + response = requests.post(submit_url, headers=_mineru_headers(), json=payload, timeout=PDF_DOWNLOAD_TIMEOUT) |
| 2776 | + response.raise_for_status() |
| 2777 | + task_payload = response.json() |
| 2778 | + task_id = _mineru_task_id(task_payload) |
| 2779 | + if not task_id: |
| 2780 | + raise RuntimeError(f"MinerU did not return task_id: {task_payload}") |
| 2781 | + |
| 2782 | + deadline = datetime.now().timestamp() + float(os.environ.get("MINERU_PARSE_TIMEOUT", MINERU_PARSE_TIMEOUT)) |
| 2783 | + status_url = f"{base_url}/api/v4/extract/task/{task_id}" |
| 2784 | + last_payload: Dict[str, Any] = task_payload if isinstance(task_payload, dict) else {} |
| 2785 | + while datetime.now().timestamp() < deadline: |
| 2786 | + status_response = requests.get(status_url, headers=_mineru_headers(), timeout=PDF_DOWNLOAD_TIMEOUT) |
| 2787 | + status_response.raise_for_status() |
| 2788 | + last_payload = status_response.json() |
| 2789 | + state = _mineru_task_state(last_payload) |
| 2790 | + zip_url = _mineru_zip_url(last_payload) |
| 2791 | + if zip_url and state in {"done", "success", "succeeded", "completed", "finished"}: |
| 2792 | + markdown = _download_mineru_markdown(zip_url) |
| 2793 | + full_text = _clean_text(re.sub(r"!\[[^\]]*\]\([^)]+\)", " ", markdown)) |
| 2794 | + sections = _sections_from_markdown(markdown) |
| 2795 | + abstract = sections.get("abstract", "") |
| 2796 | + return { |
| 2797 | + "source_kind": "mineru", |
| 2798 | + "parser": "mineru", |
| 2799 | + "task_id": task_id, |
| 2800 | + "markdown": markdown, |
| 2801 | + "full_text": full_text, |
| 2802 | + "sections": sections, |
| 2803 | + "abstract": abstract, |
| 2804 | + } |
| 2805 | + if state in {"failed", "error", "canceled", "cancelled"}: |
| 2806 | + raise RuntimeError(f"MinerU task failed: {last_payload}") |
| 2807 | + time.sleep(float(os.environ.get("MINERU_PARSE_POLL_INTERVAL", MINERU_PARSE_POLL_INTERVAL))) |
| 2808 | + raise TimeoutError(f"MinerU task timed out: {last_payload}") |
| 2809 | + |
| 2810 | + |
2682 | 2811 | def enrich_paper_for_reading_report( |
2683 | 2812 | paper: Dict[str, Any], |
2684 | 2813 | user_id: Optional[str] = None, |
@@ -2802,6 +2931,42 @@ def enrich_paper_for_reading_report( |
2802 | 2931 | for download_candidate in download_candidates: |
2803 | 2932 | try: |
2804 | 2933 | download_referer = _pick_download_referer(enriched, download_candidate) |
| 2934 | + mineru_parsed = False |
| 2935 | + if _mineru_enabled(): |
| 2936 | + try: |
| 2937 | + print(f" Parsing PDF with MinerU: {download_candidate[:120]}") |
| 2938 | + parsed_pdf = _parse_pdf_url_with_mineru(download_candidate) |
| 2939 | + parsed_pdf["source_kind"] = "mineru" |
| 2940 | + enriched["pdf_url"] = download_candidate |
| 2941 | + mineru_parsed = True |
| 2942 | + pdf_error = None |
| 2943 | + print(" MinerU PDF parsing completed") |
| 2944 | + except Exception as mineru_exc: |
| 2945 | + last_exception = mineru_exc |
| 2946 | + pdf_error = str(mineru_exc) |
| 2947 | + print(f" MinerU parsing failed; falling back to local PDF parser: {mineru_exc}") |
| 2948 | + |
| 2949 | + if mineru_parsed: |
| 2950 | + try: |
| 2951 | + print(f" Downloading PDF for storage: {download_candidate[:120]}") |
| 2952 | + temp_pdf_path = _download_pdf( |
| 2953 | + download_candidate, |
| 2954 | + enriched.get("title", "paper"), |
| 2955 | + referer=download_referer, |
| 2956 | + ) |
| 2957 | + stored_pdf_path = _persist_pdf_file( |
| 2958 | + temp_pdf_path, |
| 2959 | + enriched, |
| 2960 | + download_candidate, |
| 2961 | + user_id=user_id, |
| 2962 | + ) |
| 2963 | + if stored_pdf_path: |
| 2964 | + enriched["pdf_path"] = stored_pdf_path |
| 2965 | + except Exception as storage_exc: |
| 2966 | + print(f" PDF storage download failed after MinerU parse: {storage_exc}") |
| 2967 | + print(" PDF enrichment parsed successfully") |
| 2968 | + break |
| 2969 | + |
2805 | 2970 | print(f" Downloading PDF for enrichment: {download_candidate[:120]}") |
2806 | 2971 | temp_pdf_path = _download_pdf( |
2807 | 2972 | download_candidate, |
|
0 commit comments