ForcePilot/src/plugins/mineru_parser.py
Wenjie Zhang 18f641ea39 refactor(mineru_parser): 修复 zip 解析的 bug
移除对 base64 编码 ZIP 数据和 JSON 格式响应的支持,直接处理响应内容
添加调试日志记录响应状态和内容类型
优化错误处理日志记录
2025-11-27 13:18:42 +08:00

231 lines
9.1 KiB
Python

"""
MinerU 文档解析器
使用 MinerU 服务进行文档版面分析和内容提取
"""
import os
import tempfile
import time
from pathlib import Path
import requests
from src.knowledge.indexing import _process_zip_file
from src.plugins.document_processor_base import BaseDocumentProcessor, DocumentParserException
from src.utils import logger
class MinerUParser(BaseDocumentProcessor):
"""MinerU 文档解析器 - 使用 HTTP API 进行文档理解和解析"""
def __init__(self, server_url: str | None = None):
self.server_url = server_url or os.getenv("MINERU_API_URI") or "http://localhost:30001"
self.parse_endpoint = f"{self.server_url}/file_parse"
def get_service_name(self) -> str:
return "mineru_ocr"
def get_supported_extensions(self) -> list[str]:
"""MinerU 支持 PDF 和多种图像格式"""
return [".pdf", ".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".tif"]
def check_health(self) -> dict:
"""检查 MinerU 服务健康状态"""
try:
# 尝试访问 OpenAPI JSON 端点来检查服务是否可用
health_url = f"{self.server_url}/openapi.json"
response = requests.get(health_url, timeout=5)
if response.status_code == 200:
try:
openapi_data = response.json()
# 检查是否包含 file_parse 端点
has_file_parse = "/file_parse" in openapi_data.get("paths", {})
if has_file_parse:
return {
"status": "healthy",
"message": "MinerU 服务运行正常",
"details": {
"server_url": self.server_url,
"api_version": openapi_data.get("info", {}).get("version", "unknown"),
},
}
else:
return {
"status": "unhealthy",
"message": "MinerU 服务缺少必要的端点",
"details": {"server_url": self.server_url},
}
except Exception as e:
return {
"status": "unhealthy",
"message": f"MinerU 响应格式错误: {str(e)}",
"details": {"server_url": self.server_url},
}
else:
return {
"status": "unhealthy",
"message": f"MinerU 服务响应异常: {response.status_code}",
"details": {"server_url": self.server_url},
}
except requests.exceptions.ConnectionError:
return {
"status": "unavailable",
"message": "MinerU 服务无法连接,请检查服务是否启动",
"details": {"server_url": self.server_url},
}
except requests.exceptions.Timeout:
return {
"status": "timeout",
"message": "MinerU 服务连接超时",
"details": {"server_url": self.server_url},
}
except Exception as e:
return {
"status": "error",
"message": f"MinerU 健康检查失败: {str(e)}",
"details": {"server_url": self.server_url, "error": str(e)},
}
def process_file(self, file_path: str, params: dict | None = None) -> str:
"""
使用 MinerU 处理文档
Args:
file_path: 文件路径
params: 处理参数
- lang_list: 语言列表 (默认: ["ch"])
- backend: 后端类型 (默认: "pipeline", 支持 "vlm-*" 系列)
- parse_method: 解析方法 (默认: "auto")
- start_page_id: 起始页码 (默认: 0)
- end_page_id: 结束页码 (默认: 99999)
- formula_enable: 启用公式解析 (默认: True)
- table_enable: 启用表格解析 (默认: True)
- server_url: VLM 服务器地址 (vlm-http-client 时需要)
Returns:
str: 提取的 Markdown 文本
"""
if not os.path.exists(file_path):
raise DocumentParserException(f"文件不存在: {file_path}", self.get_service_name(), "file_not_found")
file_ext = Path(file_path).suffix.lower()
if not self.supports_file_type(file_ext):
raise DocumentParserException(
f"不支持的文件类型: {file_ext}", self.get_service_name(), "unsupported_file_type"
)
# 解析参数
params = params or {}
# 构建请求数据 - 只保留核心参数
data = {
"lang_list": params.get("lang_list", ["ch"]),
"backend": params.get("backend", "vlm-http-client"),
"parse_method": params.get("parse_method", "auto"),
# 固定返回 markdown 格式
"return_md": True,
# 添加图片解析支持
"response_format_zip": True,
"return_images": True,
}
# vlm-http-client 后端需要 server_url
if data["backend"] == "vlm-http-client":
mineru_vl_server = os.environ.get("MINERU_VL_SERVER")
assert mineru_vl_server, "MINERU_VL_SERVER 环境变量未配置"
data["server_url"] = mineru_vl_server
try:
start_time = time.time()
logger.info(
f"MinerU 开始处理: {os.path.basename(file_path)} (backend={data['backend']}, lang={data['lang_list']})"
)
# 打开文件并发送请求
with open(file_path, "rb") as f:
files = {"files": (os.path.basename(file_path), f, "application/octet-stream")}
# 发送 POST 请求
response = requests.post(
self.parse_endpoint,
files=files,
data=data,
timeout=300, # 5分钟超时
)
# 检查响应状态
logger.debug(f"MinerU 响应状态: {response.status_code}, Content-Type: {response.headers.get('content-type', 'unknown')}")
if response.status_code != 200:
error_detail = "未知错误"
try:
error_data = response.json()
error_detail = error_data.get("detail", str(error_data))
except Exception:
error_detail = response.text or f"HTTP {response.status_code}"
logger.error(f"MinerU HTTP错误 {response.status_code}: {error_detail}")
raise DocumentParserException(
f"MinerU 处理失败: {error_detail}",
self.get_service_name(),
f"http_{response.status_code}",
)
# 解析响应
try:
# 直接从响应内容获取 ZIP 数据
zip_data = response.content
# 保存到临时文件并处理
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp_zip:
tmp_zip.write(zip_data)
tmp_zip.flush()
try:
processed = _process_zip_file(tmp_zip.name, params.get("db_id"))
text = processed["markdown_content"]
finally:
os.unlink(tmp_zip.name)
if not text:
logger.error("MinerU 未返回任何文本内容")
raise DocumentParserException(
"MinerU 未返回任何文本内容",
self.get_service_name(),
"no_content",
)
processing_time = time.time() - start_time
logger.info(
f"MinerU 处理成功: {os.path.basename(file_path)} - {len(text)} 字符 ({processing_time:.2f}s)"
)
return text
except Exception as e:
raise DocumentParserException(
f"MinerU 响应解析失败: {str(e)}",
self.get_service_name(),
"response_parse_error",
)
except DocumentParserException:
raise
except requests.exceptions.Timeout:
error_msg = f"MinerU 处理超时 ({time.time() - start_time:.2f}s)"
logger.error(error_msg)
raise DocumentParserException(error_msg, self.get_service_name(), "timeout")
except requests.exceptions.ConnectionError:
error_msg = "MinerU 连接失败,请检查服务是否运行"
logger.error(error_msg)
raise DocumentParserException(error_msg, self.get_service_name(), "connection_error")
except Exception as e:
error_msg = f"MinerU 处理失败: {str(e)}"
logger.error(f"{error_msg} ({time.time() - start_time:.2f}s)")
raise DocumentParserException(error_msg, self.get_service_name(), "processing_failed")