| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717 |
- # -*- coding: utf-8 -*-
- """
- PDF 文本提取工具模块
- 功能:
- - 可选 MinerU 云端解析(高精度,需网络,由 MINERU_ENABLED 环境变量控制)
- - 使用 PyPDF2 (pypdf) 提取 PDF 文本内容
- - 若 PyPDF2 提取失败或内容为空,回退到 PaddleOCR 进行 OCR 识别
- - 支持上传文件保存到临时目录
- - 基于文件 MD5 哈希的缓存:同一 PDF 不重复提取
- - MinerU 与本地解析使用独立缓存,互不干扰
- 解析优先级(当 MINERU_ENABLED=true 时):
- MinerU 云端解析 → PyPDF2 本地提取 → PaddleOCR 回退
- 工具列表:
- - extract_pdf_text: 从 PDF 文件提取文本(含缓存,MinerU → PyPDF2 → PaddleOCR)
- - save_upload_file: 保存上传文件到临时目录
- - calculate_file_hash: 计算文件 MD5 哈希
- - clear_cache: 清除 PDF 提取缓存
- """
- import hashlib
- import os
- import re
- import time
- import tempfile
- from datetime import datetime
- from pathlib import Path
- from typing import Optional
- from dotenv import load_dotenv
- load_dotenv()
- # ============================================================
- # 缓存目录
- # ============================================================
- # 缓存目录:项目根目录下的 data/pdf_cache/
- _CACHE_DIR = Path(__file__).parent.parent / "data" / "pdf_cache"
- # MinerU 缓存文件前缀(与本地解析缓存隔离)
- _MINERU_CACHE_PREFIX = "mineru_"
- # ============================================================
- # 文件哈希计算
- # ============================================================
- def calculate_file_hash(file_path: str) -> str:
- """计算文件的 MD5 哈希值,作为缓存 key。
- 同一文件(相同内容)产生相同哈希,实现"同一 PDF 不重复提取"。
- Args:
- file_path: 文件路径
- Returns:
- str: 32位 MD5 十六进制字符串
- """
- try:
- with open(file_path, 'rb') as f:
- file_data = f.read()
- return hashlib.md5(file_data).hexdigest()
- except Exception as e:
- print(f"[PDF] 计算文件哈希失败: {e}")
- # 失败时用时间戳生成唯一 key,确保不误命中缓存
- return hashlib.md5(str(datetime.now()).encode()).hexdigest()
- def _get_cache_path(file_hash: str) -> Path:
- """获取缓存文件路径。
- Args:
- file_hash: 文件 MD5 哈希值
- Returns:
- Path: 缓存文件路径,如 data/pdf_cache/abc123.txt
- """
- return _CACHE_DIR / f"{file_hash}.txt"
- def _read_cache(file_hash: str) -> Optional[str]:
- """从缓存中读取已提取的文本。
- Args:
- file_hash: 文件 MD5 哈希值
- Returns:
- str | None: 缓存文本,不存在或读取失败返回 None
- """
- cache_path = _get_cache_path(file_hash)
- if not cache_path.exists():
- return None
- try:
- text = cache_path.read_text(encoding='utf-8')
- if text.strip():
- print(f"[PDF] 命中缓存: {cache_path.name} ({len(text)} 字符)")
- return text
- return None
- except Exception as e:
- print(f"[PDF] 缓存读取失败: {e}")
- return None
- def _write_cache(file_hash: str, text: str):
- """将提取的文本写入缓存。
- Args:
- file_hash: 文件 MD5 哈希值
- text: 提取的文本内容
- """
- try:
- _CACHE_DIR.mkdir(parents=True, exist_ok=True)
- cache_path = _get_cache_path(file_hash)
- cache_path.write_text(text, encoding='utf-8')
- print(f"[PDF] 已缓存: {cache_path.name} ({len(text)} 字符)")
- except Exception as e:
- print(f"[PDF] 缓存写入失败: {e}")
- def clear_cache(file_hash: Optional[str] = None):
- """清除 PDF 提取缓存(同时清除本地解析缓存和 MinerU 缓存)。
- Args:
- file_hash: 指定要清除的哈希(可选),不传则清除全部缓存
- """
- if file_hash:
- # 清除本地缓存
- local_path = _get_cache_path(file_hash)
- if local_path.exists():
- local_path.unlink()
- print(f"[PDF] 已清除本地缓存: {local_path.name}")
- # 清除 MinerU 缓存
- mineru_path = _get_mineru_cache_path(file_hash)
- if mineru_path.exists():
- mineru_path.unlink()
- print(f"[PDF] 已清除 MinerU 缓存: {mineru_path.name}")
- else:
- if _CACHE_DIR.exists():
- count = 0
- for f in _CACHE_DIR.glob("*.txt"):
- f.unlink()
- count += 1
- print(f"[PDF] 已清除全部缓存 ({count} 个文件)")
- # ============================================================
- # 上传文件保存
- # ============================================================
- async def save_upload_file(upload_file, upload_dir: Optional[str] = None) -> str:
- """保存 FastAPI UploadFile 到临时目录,返回文件路径。
- Args:
- upload_file: FastAPI UploadFile 对象
- upload_dir: 保存目录,默认为系统临时目录下的 vent_review_pdfs
- Returns:
- str: 保存后的文件绝对路径
- """
- if upload_dir is None:
- upload_dir = os.path.join(tempfile.gettempdir(), "vent_review_pdfs")
- os.makedirs(upload_dir, exist_ok=True)
- # 保留原始文件名,避免冲突加时间戳
- import time
- original_name = upload_file.filename or "upload.pdf"
- safe_name = f"{int(time.time() * 1000)}_{original_name}"
- file_path = os.path.join(upload_dir, safe_name)
- content = await upload_file.read()
- with open(file_path, "wb") as f:
- f.write(content)
- return file_path
- # ============================================================
- # PDF 文本提取 - PyPDF2 方式
- # ============================================================
- def _extract_with_pypdf(file_path: str) -> Optional[str]:
- """使用 pypdf (PyPDF2) 提取 PDF 文本。
- Args:
- file_path: PDF 文件路径
- Returns:
- str | None: 提取的文本内容,失败返回 None
- """
- try:
- from pypdf import PdfReader
- reader = PdfReader(file_path)
- pages_text = []
- for i, page in enumerate(reader.pages):
- text = page.extract_text()
- if text:
- pages_text.append(f"--- 第 {i + 1} 页 ---\n{text.strip()}")
- if pages_text:
- return "\n\n".join(pages_text)
- return None
- except ImportError:
- print("[PDF] pypdf 未安装,跳过 PyPDF2 提取")
- return None
- except Exception as e:
- print(f"[PDF] PyPDF2 提取失败: {e}")
- return None
- # ============================================================
- # PDF 文本提取 - PaddleOCR 回退方式
- # ============================================================
- def _pdf_to_images(file_path: str, dpi: int = 150) -> list:
- """将 PDF 每页转换为 PIL Image 列表。
- Args:
- file_path: PDF 文件路径
- dpi: 图片分辨率,默认 150(平衡速度与精度)
- Returns:
- list[PIL.Image]: 图片列表
- """
- try:
- from pdf2image import convert_from_path
- return convert_from_path(file_path, dpi=dpi)
- except ImportError:
- print("[PDF] pdf2image 未安装,无法转换 PDF 为图片")
- return []
- except Exception as e:
- print(f"[PDF] PDF 转图片失败: {e}")
- return []
- def _extract_with_paddleocr(file_path: str,
- progress_callback: callable = None) -> Optional[str]:
- """使用 PaddleOCR 对 PDF 进行 OCR 识别。
- 兼容 PaddleOCR v2.x (ocr/cls API) 和 v3.x (predict API)。
- 先将 PDF 每页转为图片,再逐页 OCR。
- Args:
- file_path: PDF 文件路径
- progress_callback: 进度回调,签名 callback(page_num: int, total_pages: int),每页扫描前调用
- Returns:
- str | None: OCR 识别结果,失败返回 None
- """
- try:
- from paddleocr import PaddleOCR
- # 检测 API 版本:v3.x 主要使用 predict(),ocr() 已废弃(deprecated)
- # 判断依据:v3.x 的 predict 方法签名中不含 cls 参数
- import inspect
- _is_v3 = False
- if hasattr(PaddleOCR, 'predict'):
- try:
- sig = inspect.signature(PaddleOCR.predict)
- _is_v3 = 'cls' not in sig.parameters
- except Exception:
- pass
- # 如果 predict 不存在,则为 v2.x(只有 ocr 方法)
- if not hasattr(PaddleOCR, 'predict'):
- _is_v3 = False
- # 初始化(兼容两种版本 + 多级加速策略)
- # 加速优先级:onnxruntime → paddle_dynamic+mkldnn → paddle_static+mkldnn → 纯CPU
- try:
- if _is_v3:
- # v3.x 加速参数说明:
- # - text_det_limit_side_len=960:检测时缩图边长上限,越小越快
- # - enable_mkldnn=True:Intel oneDNN 加速(paddle 引擎时有效)
- # - engine="onnxruntime":ONNX Runtime 推理,CPU 上比 paddle 快 2-5x
- _v3_base = dict(
- lang="ch",
- use_doc_orientation_classify=False,
- use_doc_unwarping=False,
- use_textline_orientation=False,
- text_det_limit_side_len=960,
- cpu_threads=10,
- )
- # 策略0: onnxruntime(最快,绕过 PIR/onednn 问题)
- try:
- ocr = PaddleOCR(**_v3_base, engine="onnxruntime")
- print("[PDF] PaddleOCR 初始化: onnxruntime")
- except Exception:
- # 策略1: paddle_dynamic + mkldnn
- try:
- ocr = PaddleOCR(**_v3_base, enable_mkldnn=True, engine="paddle_dynamic")
- print("[PDF] PaddleOCR 初始化: paddle_dynamic + mkldnn")
- except Exception:
- # 策略2: paddle_static + mkldnn(可能触发 PIR 错误)
- try:
- ocr = PaddleOCR(**_v3_base, enable_mkldnn=True)
- print("[PDF] PaddleOCR 初始化: paddle_static + mkldnn")
- except Exception:
- # 策略3: 回退到纯 CPU(稳定但慢)
- try:
- ocr = PaddleOCR(**_v3_base, enable_mkldnn=False)
- print("[PDF] PaddleOCR 初始化: paddle_static (无加速)")
- except Exception:
- ocr = PaddleOCR(**_v3_base)
- else:
- # v2.x: 传统参数
- try:
- ocr = PaddleOCR(use_angle_cls=True, lang="ch", show_log=False)
- except Exception:
- ocr = PaddleOCR(use_angle_cls=True, lang="ch")
- except Exception:
- # 最后兜底:最少参数
- ocr = PaddleOCR(lang="ch")
- # 将 PDF 转为图片
- images = _pdf_to_images(file_path)
- if not images:
- print("[PDF] PDF 转图片失败,无法进行 OCR")
- return None
- # 逐页 OCR
- total = len(images)
- pages_text = []
- for i, img in enumerate(images):
- page_num = i + 1
- # 进度回调
- if progress_callback:
- try:
- progress_callback(page_num, total)
- except Exception:
- pass # 回调异常不影响主流程
- import tempfile
- with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
- img.save(tmp.name)
- tmp_path = tmp.name
- try:
- if _is_v3:
- # v3.x: predict() → list of OCRResult,传入加速参数
- result = ocr.predict(
- tmp_path,
- text_det_limit_side_len=960,
- text_rec_score_thresh=0.5,
- )
- page_text = _extract_text_from_v3_result(result)
- else:
- # v2.x: ocr(img, cls=True) → nested list
- result = ocr.ocr(tmp_path, cls=True)
- page_text = _extract_text_from_v2_result(result)
- if page_text:
- pages_text.append(f"--- 第 {i + 1} 页 (OCR) ---\n{page_text}")
- finally:
- try:
- os.unlink(tmp_path)
- except OSError:
- pass
- if pages_text:
- return "\n\n".join(pages_text)
- return None
- except ImportError:
- print("[PDF] PaddleOCR 未安装,跳过 OCR 提取")
- return None
- except Exception as e:
- print(f"[PDF] PaddleOCR 提取失败: {e}")
- import traceback
- traceback.print_exc()
- return None
- def _extract_text_from_v2_result(result) -> str:
- """从 PaddleOCR v2.x 的 ocr() 返回结果中提取文本。
- v2 格式: [[[bbox, (text, confidence)], ...], ...]
- """
- if not result or not result[0]:
- return ""
- lines = []
- for line in result[0]:
- if line and len(line) >= 2:
- text = line[1][0] if isinstance(line[1], (list, tuple)) else str(line[1])
- lines.append(text)
- return "\n".join(lines)
- def _extract_text_from_v3_result(result) -> str:
- """从 PaddleOCR v3.x 的 predict() 返回结果中提取文本。
- v3 格式: list of OCRResult,每个 OCRResult 可通过 ["rec_texts"] 或 str() 获取文本。
- """
- if not result:
- return ""
- lines = []
- for res in result:
- # 尝试多种方式提取文本
- if hasattr(res, '__getitem__'):
- try:
- rec_texts = res["rec_texts"]
- if rec_texts:
- lines.extend(rec_texts if isinstance(rec_texts, list) else [str(rec_texts)])
- continue
- except (KeyError, TypeError):
- pass
- # 尝试 str()
- text = str(res)
- if text:
- lines.append(text)
- return "\n".join(lines)
- # ============================================================
- # PDF 页数统计
- # ============================================================
- def _count_pdf_pages(file_path: str) -> int:
- """统计 PDF 文件的总页数。
- Args:
- file_path: PDF 文件路径
- Returns:
- int: 总页数,失败返回 0
- """
- try:
- from pypdf import PdfReader
- reader = PdfReader(file_path)
- return len(reader.pages)
- except Exception as e:
- print(f"[PDF] 无法获取 PDF 页数: {e}")
- return 0
- # ============================================================
- # MinerU 缓存(独立于本地解析缓存)
- # ============================================================
- def _get_mineru_cache_path(file_hash: str) -> Path:
- """获取 MinerU 专用缓存文件路径。
- Args:
- file_hash: 文件 MD5 哈希值
- Returns:
- Path: 缓存文件路径,如 data/pdf_cache/mineru_abc123.txt
- """
- return _CACHE_DIR / f"{_MINERU_CACHE_PREFIX}{file_hash}.txt"
- def _read_mineru_cache(file_hash: str) -> Optional[str]:
- """从 MinerU 专用缓存中读取已提取的文本。
- Args:
- file_hash: 文件 MD5 哈希值
- Returns:
- str | None: 缓存文本,不存在或读取失败返回 None
- """
- cache_path = _get_mineru_cache_path(file_hash)
- if not cache_path.exists():
- return None
- try:
- text = cache_path.read_text(encoding='utf-8')
- if text.strip():
- print(f"[PDF] MinerU 缓存命中: {cache_path.name} ({len(text)} 字符)")
- return text
- return None
- except Exception as e:
- print(f"[PDF] MinerU 缓存读取失败: {e}")
- return None
- def _write_mineru_cache(file_hash: str, text: str):
- """将 MinerU 提取的文本写入专用缓存。
- Args:
- file_hash: 文件 MD5 哈希值
- text: 提取的文本内容
- """
- try:
- _CACHE_DIR.mkdir(parents=True, exist_ok=True)
- cache_path = _get_mineru_cache_path(file_hash)
- cache_path.write_text(text, encoding='utf-8')
- print(f"[PDF] MinerU 已缓存: {cache_path.name} ({len(text)} 字符)")
- except Exception as e:
- print(f"[PDF] MinerU 缓存写入失败: {e}")
- # ============================================================
- # MinerU 文本清洗
- # ============================================================
- def _clean_mineru_text(text: str) -> str:
- """清洗 MinerU 输出的文本。
- - 去除每行首尾空白
- - 合并多余空行(连续 3 个以上空行压缩为 2 个)
- Args:
- text: MinerU 原始输出文本
- Returns:
- str: 清洗后的文本
- """
- if not text:
- return ""
- text = "\n".join(line.strip() for line in text.splitlines())
- text = re.sub(r'\n{3,}', '\n\n', text)
- return text.strip()
- # ============================================================
- # MinerU 云端 PDF 解析
- # ============================================================
- def _extract_with_mineru(file_path: str,
- force_refresh: bool = False,
- use_cache: bool = True) -> Optional[str]:
- """使用 MinerU 云端服务解析 PDF(高精度,需要网络)。
- 解析流程:
- 1. 检查 MinerU 专用缓存(mineru_{hash}.txt)
- 2. 若缓存命中且非强制刷新,直接返回
- 3. 通过 langchain_mineru.MinerULoader 分片加载 PDF
- 4. 清洗每页文本并拼接
- 5. 写入 MinerU 专用缓存
- Args:
- file_path: PDF 文件路径
- force_refresh: 是否强制重新提取(忽略缓存)
- use_cache: 是否使用缓存
- Returns:
- str | None: 提取的文本内容,失败返回 None
- """
- mineru_token = os.getenv("MINERU_TOKEN", "").strip()
- mineru_max_pages = int(os.getenv("MINERU_MAX_PAGES", "50"))
- if not mineru_token:
- print("[PDF] MINERU_TOKEN 未配置,跳过 MinerU 解析")
- return None
- file_hash = calculate_file_hash(file_path)
- # ── 检查 MinerU 专用缓存 ──
- if use_cache and not force_refresh:
- cached = _read_mineru_cache(file_hash)
- if cached is not None:
- return cached
- # ── 统计总页数 ──
- total_pages = _count_pdf_pages(file_path)
- if total_pages == 0:
- print("[PDF] 无法获取 PDF 页数,跳过 MinerU 解析")
- return None
- print(f"[PDF] PDF总页数:{total_pages}(MinerU 云端解析)")
- start_time = time.time()
- all_text_parts = []
- try:
- from langchain_mineru import MinerULoader
- for start in range(1, total_pages + 1, mineru_max_pages):
- end = min(start + mineru_max_pages - 1, total_pages)
- print(f"[PDF] MinerU 正在加载分片:{start} ~ {end} 页")
- loader = MinerULoader(
- source=file_path,
- mode="precision",
- token=mineru_token,
- pages=f"{start}-{end}",
- )
- docs = loader.load()
- for idx, doc in enumerate(docs):
- cleaned = _clean_mineru_text(doc.page_content)
- if cleaned:
- all_text_parts.append(
- f"--- 第 {start + idx} 页 (MinerU) ---\n{cleaned}"
- )
- if not all_text_parts:
- print("[PDF] MinerU 未提取到任何文本内容")
- return None
- full_text = "\n\n".join(all_text_parts)
- # ── 写入 MinerU 专用缓存 ──
- if use_cache:
- _write_mineru_cache(file_hash, full_text)
- elapsed = round(time.time() - start_time, 2)
- print(f"[PDF] MinerU 解析完成 | 总页数:{total_pages} | 总耗时:{elapsed} 秒")
- return full_text
- except ImportError:
- print("[PDF] langchain-mineru 未安装,跳过 MinerU 解析")
- return None
- except Exception as e:
- print(f"[PDF] MinerU 解析失败: {e}")
- import traceback
- traceback.print_exc()
- return None
- # ============================================================
- # 主提取函数
- # ============================================================
- def extract_pdf_text(file_path: str, min_text_length: int = 50,
- use_cache: bool = True, force_refresh: bool = False,
- progress_callback: callable = None) -> str:
- """从 PDF 文件提取文本。
- 提取策略(按优先级):
- 0. 若 MINERU_ENABLED=true,优先使用 MinerU 云端解析(高精度,需网络)
- - 失败或内容不足则自动回退到本地解析
- 1. 计算文件 MD5 哈希,若本地缓存命中且非强制刷新,直接返回
- 2. 尝试 PyPDF2 (pypdf) 直接提取文本
- 3. 若提取内容为空或长度不足 min_text_length,回退到 PaddleOCR
- 4. 提取成功后将结果写入缓存
- 5. 若全部失败,返回错误信息
- Args:
- file_path: PDF 文件路径
- min_text_length: 最小有效文本长度,低于此值认为提取失败
- use_cache: 是否使用缓存,默认 True
- force_refresh: 是否强制重新提取(忽略缓存),默认 False
- progress_callback: PaddleOCR 进度回调(MinerU 不使用此回调)
- Returns:
- str: 提取的文本内容
- """
- # 验证文件存在
- if not os.path.exists(file_path):
- return f"[错误] 文件不存在: {file_path}"
- # 计算文件哈希
- file_hash = calculate_file_hash(file_path)
- # ── 步骤0: MinerU 云端解析(优先,如果启用)──
- use_mineru = os.getenv("MINERU_ENABLED", "false").strip().lower() == "true"
- if use_mineru:
- print("[PDF] MinerU 已启用,优先使用云端解析...")
- mineru_text = _extract_with_mineru(
- file_path,
- force_refresh=force_refresh,
- use_cache=use_cache,
- )
- if mineru_text and len(mineru_text.strip()) >= min_text_length:
- print(f"[PDF] MinerU 解析成功,共 {len(mineru_text)} 字符")
- return mineru_text
- print("[PDF] MinerU 解析失败或内容不足,回退到本地解析...")
- # ── 步骤1: 检查本地缓存 ──
- if use_cache and not force_refresh:
- cached = _read_cache(file_hash)
- if cached:
- return cached
- # ── 步骤2: PyPDF2 提取 ──
- print(f"[PDF] 正在使用 PyPDF2 提取: {file_path}")
- text = _extract_with_pypdf(file_path)
- if text and len(text.strip()) >= min_text_length:
- print(f"[PDF] PyPDF2 提取成功,共 {len(text)} 字符")
- if use_cache:
- _write_cache(file_hash, text)
- return text
- # ── 步骤3: PaddleOCR 回退 ──
- print(f"[PDF] PyPDF2 提取不足 ({len(text or '')} 字符),回退到 PaddleOCR...")
- ocr_text = _extract_with_paddleocr(file_path, progress_callback=progress_callback)
- if ocr_text and len(ocr_text.strip()) >= min_text_length:
- print(f"[PDF] PaddleOCR 提取成功,共 {len(ocr_text)} 字符")
- if use_cache:
- _write_cache(file_hash, ocr_text)
- return ocr_text
- # ── 步骤4: 全部失败 ──
- if text and text.strip():
- print(f"[PDF] 警告: 提取内容较短 ({len(text)} 字符),但已是全部可用内容")
- if use_cache:
- _write_cache(file_hash, text)
- return text
- return "[错误] 无法从 PDF 中提取文本内容。文件可能为扫描件且 OCR 不可用,或文件已损坏。"
- # ============================================================
- # 快速提取(仅 PyPDF2,不触发 OCR)
- # ============================================================
- def extract_pdf_text_fast(file_path: str) -> str:
- """仅使用 PyPDF2 快速提取 PDF 文本(不触发 OCR)。
- 用于 LLM 预览场景,避免 OCR 耗时。
- Args:
- file_path: PDF 文件路径
- Returns:
- str: 提取的文本内容
- """
- text = _extract_with_pypdf(file_path)
- return text or "[提示] 该 PDF 可能为扫描件,PyPDF2 无法提取文本,请使用完整 OCR 模式。"
|