Files
deepdata/my_ops/mineru_pdf_parser/process.py
T
沃巴蒂 ff22ed3a8b feat(deepdata): 引入修改密码、文件预览组件与清洗算子增强,deepflow 改名为 deep-flow
本次同步 09-12 至 09-28 的代码变更,主要内容:

新增功能
- 修改密码:后端新增 ChangePasswordRequest DTO 与UserController/UserService
  对应接口,前端新增 ChangePasswordDialog 并在 Header 接入入口
- 文件预览组件重构:file-preview 目录拆分出 VideoPreview(支持 blobUrl 直传)、
  ImagePreview、AudioPreview,index.tsx 统一调度各类型预览器
- 新增 my_ops 算子模块:markitdown_parser、mineru_pdf_parser、text_quality_evaluator
- 新增 tools/minio-file-server MinIO 文件服务

功能增强
- 数据清洗:cleaning_task_service/scheduler/routes 重构,
  data_juicer_executor 与 wrapper 增强,OperatorTable/LogsTable/TaskDetail 同步调整
- 数据标注:annotation sync.py、project.py 接口与schema 更新
- 数据采集、算子市场、首页与菜单路由相关页面调整
- DataJuicer 算子接入(useOperatorOperations、dj_ops_schema)

结构调整
- deepflow/ 更名为 deep-flow/(419 个文件重命名)
- 新增 helm 部署编排、GitHub Actions 之外的自建镜像构建脚本

未纳入本次提交
- scripts-backup/、runtime/python-executor-backup/、components_backup/(备份目录)
- *.backup*、__pycache__ 产物
- zh_core_web_sm-3.8.0-py3-none-any.whl(46MB 构建产物)
- DataX/(内嵌独立仓库,远程亦未收录其源码)
2026-10-03 22:44:56 +08:00

372 lines
15 KiB
Python

#!/usr/bin/env python
# -*- coding: utf-8 -*-
import os
import sys
import json
import time
import base64
import asyncio
import aiohttp
import re
import requests
import mimetypes
from io import BytesIO
from pathlib import Path
from urllib.parse import urlparse
from typing import Dict, Any
from loguru import logger
try:
from deepdata.core.base_op import Mapper
except ImportError:
print("⚠️ 本地调试模式:未找到 'deepdata' 库,正在启用模拟 (Mock) Mapper...")
class Mapper:
def __init__(self, *args, **kwargs): pass
# 匹配绝对地址图片链接
ABSOLUTE_IMG_REGEX = re.compile(r'!\[([^\]]*)\]\((https?://[^\s)]+)\)')
# 匹配相对地址图片链接
RELATIVE_IMG_REGEX = re.compile(r'!\[([^\]]*)\]\((images/[^)]+)\)')
class MinerUPdfParser(Mapper):
"""基于远端 MinerU FastAPI 的 PDF 结构化解析算子"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.mineru_api = kwargs.get("mineruApi", "http://mineru.ailab.ccoop.cc:18585").rstrip("/")
self.server_url = kwargs.get("server_url", "")
self.backend = kwargs.get("backend", "pipeline")
lang_str = kwargs.get("lang_list", "ch")
self.lang_list = [lang.strip() for lang in lang_str.split(",") if lang.strip()]
self.parse_method = kwargs.get("parse_method", "auto")
self.formula_enable = kwargs.get("formula_enable", "true") == "true"
self.table_enable = kwargs.get("table_enable", "true") == "true"
self.return_images = kwargs.get("return_images", "true") == "true"
# 🔑 Base64 嵌入开关
self.embed_images_base64 = kwargs.get("embed_images_base64", "true") == "true"
# 🆕 下载远程图片开关
self.download_remote_images = kwargs.get("download_remote_images", "true") == "true"
# 🆕 上传到外部图床配置
self.upload_images = kwargs.get("upload_images", "false") == "true"
self.upload_api_url = kwargs.get("upload_api_url", "").strip()
# 注意:这里保留 rstrip("/"),但在拼接函数中会做更严格的清洗
self.preview_base_url = kwargs.get("preview_base_url", "").rstrip("/")
try:
self.timeout = int(kwargs.get("timeout", 900))
except (ValueError, TypeError):
self.timeout = 900
self.images_subdir = "images"
def _is_same_origin(self, url1: str, url2: str) -> bool:
"""判断两个 URL 是否同源(协议 + 域名 + 端口)"""
if not url1 or not url2:
return False
try:
parsed1 = urlparse(url1)
parsed2 = urlparse(url2)
return (parsed1.scheme == parsed2.scheme and
parsed1.hostname == parsed2.hostname and
parsed1.port == parsed2.port)
except Exception:
return False
async def _call_api_async(self, file_path: str) -> dict:
url = f"{self.mineru_api}/file_parse"
file_obj = open(file_path, "rb")
try:
form = aiohttp.FormData()
form.add_field("files", file_obj, filename=Path(file_path).name, content_type="application/pdf")
def add_field(key: str, value: Any):
if value is None: return
if isinstance(value, list):
form.add_field(key, ",".join(map(str, value)))
elif isinstance(value, bool):
form.add_field(key, str(value).lower())
else:
form.add_field(key, str(value))
add_field("backend", self.backend)
add_field("lang_list", self.lang_list)
add_field("parse_method", self.parse_method)
add_field("formula_enable", self.formula_enable)
add_field("table_enable", self.table_enable)
add_field("server_url", self.server_url)
add_field("return_md", True)
add_field("return_images", self.return_images)
add_field("response_format_zip", False)
add_field("start_page_id", 0)
add_field("end_page_id", 99999)
add_field("output_dir", "./output")
timeout = aiohttp.ClientTimeout(total=self.timeout)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(url, data=form) as resp:
resp_text = await resp.text()
if resp.status != 200:
raise Exception(f"MinerU API HTTP {resp.status}: {resp_text}")
return json.loads(resp_text)
finally:
file_obj.close()
def _upload_image_to_api(self, img_bytes: bytes, filename: str) -> str:
"""上传图片到外部 API 并返回完整预览 URL"""
if not self.upload_images or not self.upload_api_url:
return ""
ct = mimetypes.guess_type(filename)[0] or "image/jpeg"
files = {"file": (filename, BytesIO(img_bytes), ct)}
try:
resp = requests.post(self.upload_api_url, files=files, timeout=30)
resp.raise_for_status()
try:
data = resp.json()
# 尝试获取 path, url, data 字段,兼容不同返回格式
raw_path = data.get("path") or data.get("url") or data.get("data")
path = str(raw_path) if raw_path else ""
except:
path = resp.text.strip()
# 🛡️ 核心修复:强力清洗路径,防止双斜杠 //
# 1. 去除首尾的引号、空格
clean_path = path.strip('\"\' ')
# 2. 去除开头的斜杠 (因为我们会手动拼接一个分隔符)
clean_path = clean_path.lstrip('/')
# 3. 去除 Base URL 末尾的斜杠和空格
clean_base = self.preview_base_url.strip(' /')
if not clean_base or not clean_path:
raise ValueError("Base URL 或 路径 解析为空")
# 4. 标准拼接:base/path
return f"{clean_base}/{clean_path}"
except Exception as e:
logger.warning(f"⚠️ 图片上传失败 [{filename}]: {e}")
return ""
def _decode_image_data(self, img_data: Any) -> bytes:
"""将 Base64 字符串或原始数据转为 bytes"""
if isinstance(img_data, bytes):
return img_data
if isinstance(img_data, str):
try:
if img_data.startswith("data:image"):
_, b64_data = img_data.split(",", 1)
else:
b64_data = img_data
return base64.b64decode(b64_data)
except Exception:
return None
return None
def _download_image(self, url: str) -> bytes:
"""下载图片"""
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
try:
response = requests.get(url, headers=headers, timeout=30)
response.raise_for_status()
return response.content
except Exception as e:
logger.warning(f"⚠️ 下载图片失败 [{url}]: {e}")
return None
def _process_all_images(self, md_content: str, images_dict: Dict[str, Any], export_path: str) -> str:
"""统一处理所有类型图片(绝对地址 + 相对地址)"""
# ==================== 1. 处理相对地址图片 ====================
def replace_relative(match):
alt = match.group(1)
img_filename = match.group(2) # 如 "images/xxx.jpg"
img_data = images_dict.get(Path(img_filename).name) # 取文件名部分
if not img_data:
return match.group(0)
img_bytes = self._decode_image_data(img_data)
if not img_bytes:
return match.group(0)
# 🟢 优先上传
if self.upload_images and self.upload_api_url:
upload_url = self._upload_image_to_api(img_bytes, Path(img_filename).name)
if upload_url:
return f"![{alt}]({upload_url})"
# 🟡 Base64 内联
if self.embed_images_base64:
if isinstance(img_data, str) and not img_data.startswith("data:"):
ext = Path(img_filename).suffix.lstrip('.') or 'jpg'
img_data = f"data:image/{ext};base64,{img_data}"
return f"![{alt}]({img_data})"
# 🔴 本地存储
final_export_path = os.path.abspath(export_path)
images_dir = os.path.join(final_export_path, self.images_subdir)
os.makedirs(images_dir, exist_ok=True)
save_path = os.path.join(images_dir, Path(img_filename).name)
with open(save_path, "wb") as f:
f.write(img_bytes)
return f"![{alt}]({os.path.join(self.images_subdir, Path(img_filename).name)})"
md_content = RELATIVE_IMG_REGEX.sub(replace_relative, md_content)
# ==================== 2. 处理绝对地址图片 ====================
def replace_absolute(match):
alt = match.group(1)
img_url = match.group(2)
# 判断是否与预览地址同源
if self.upload_images and self.preview_base_url and self._is_same_origin(img_url, self.preview_base_url):
return match.group(0) # 同源则保留原链接
# 不同源则下载后上传
img_bytes = self._download_image(img_url)
if not img_bytes:
return match.group(0) # 下载失败保留原链接
filename = Path(urlparse(img_url).path).name or "remote_image.jpg"
# 🟢 优先上传
if self.upload_images and self.upload_api_url:
upload_url = self._upload_image_to_api(img_bytes, filename)
if upload_url:
return f"![{alt}]({upload_url})"
# 🟡 Base64 内联
if self.embed_images_base64:
mime_type = mimetypes.guess_type(img_url)[0] or "image/png"
b64_str = base64.b64encode(img_bytes).decode("utf-8")
return f"![{alt}](data:{mime_type};base64,{b64_str})"
# 🔴 本地存储
import hashlib
mime_type = mimetypes.guess_type(img_url)[0] or "image/png"
ext = mimetypes.guess_extension(mime_type) or '.jpg'
new_filename = hashlib.md5(img_url.encode()).hexdigest()[:12] + ext
final_export_path = os.path.abspath(export_path)
images_dir = os.path.join(final_export_path, self.images_subdir)
os.makedirs(images_dir, exist_ok=True)
save_path = os.path.join(images_dir, new_filename)
with open(save_path, "wb") as f:
f.write(img_bytes)
return f"![{alt}]({os.path.join(self.images_subdir, new_filename)})"
if self.download_remote_images:
md_content = ABSOLUTE_IMG_REGEX.sub(replace_absolute, md_content)
return md_content
def execute(self, sample: Dict[str, Any]) -> Dict[str, Any]:
filename = sample.get("fileName", "")
filepath = sample.get("filePath", "")
export_path = sample.get("export_path", "./output")
# 支持多种图片格式和 PDF
if not filename.lower().endswith((".png", ".jpeg", ".jpg", ".webp", ".gif", ".pdf")):
return sample
start_time = time.time()
try:
logger.info(f"🚀 开始解析: {filename}")
response = asyncio.run(self._call_api_async(filepath))
if "results" not in response:
raise ValueError("Invalid response structure: missing 'results'")
results = response["results"]
file_stem = Path(filepath).stem
task_data = results.get(file_stem)
if not task_data and results:
task_data = next(iter(results.values()))
md_content = task_data.get("md_content", "")
images_dict = task_data.get("images", {})
# 统一处理所有图片(相对地址 + 绝对地址)
md_content = self._process_all_images(md_content, images_dict, export_path)
logger.info("✅ 所有图片处理完成")
sample["text"] = md_content
sample["target_type"] = "md"
logger.info(f"✅ 解析成功: {filename} | 耗时 {time.time() - start_time:.2f}s | MD 长度 {len(md_content)}")
except Exception as e:
logger.exception(f"❌ 解析失败 {filename}: {e}")
raise
return sample
# ==========================================================
# 🧪 本地调试与测试入口
# ==========================================================
if __name__ == "__main__":
print("="*60)
print("🧪 MinerUPdfParser 本地测试")
print("="*60)
current_dir = os.path.dirname(os.path.abspath(__file__))
test_pdf = os.path.join(current_dir, "test.pdf")
if not os.path.exists(test_pdf):
print(f"❌ 找不到测试文件: {test_pdf}")
sys.exit(1)
output_dir = os.path.join(current_dir, "test_output")
os.makedirs(output_dir, exist_ok=True)
mock_sample = {
"fileName": "test.pdf",
"filePath": test_pdf,
"export_path": output_dir,
"text": "",
"target_type": ""
}
mock_kwargs = {
"mineruApi": "http://mineru.ailab.ccoop.cc:18585",
"embed_images_base64": "true",
"download_remote_images": "true",
"upload_images": "true",
"upload_api_url": "http://127.0.0.1:8000/upload/form",
"preview_base_url": "http://127.0.0.1:8000/file" # 即使这里没带斜杠,代码也会处理带斜杠的情况
}
try:
parser = MinerUPdfParser(**mock_kwargs)
result = parser.execute(mock_sample)
# 保存生成的 Markdown
md_content = result.get("text", "")
md_file = os.path.join(output_dir, "result.md")
with open(md_file, "w", encoding="utf-8") as f:
f.write(md_content)
print("\n" + "="*60)
print("✅ 测试通过!")
print(f"📂 输出目录: {result.get('export_path')}")
print(f"📄 结果文件: {md_file}")
print("="*60)
except Exception as e:
print(f"\n❌ 测试失败: {e}")
import traceback
traceback.print_exc()
sys.exit(1)