Files
deepdata/my_ops/markitdown_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

448 lines
19 KiB
Python

#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
MarkItDown 多格式文档解析算子
基于 Microsoft MarkItDown 库,将办公文档统一转换为 Markdown 文本。
支持格式:docx, doc, xlsx, xls, pptx, ppt, txt, csv, xml, html, htm
⚠️ 图片处理优先级 (智能回退机制):
1. 【优先上传】开启 upload_images 时,上传图片到外部 API,返回可访问的网络链接
2. 【Base64 内联】开启 embed_images_base64 时,转为 Base64 嵌入 Markdown
3. 【本地存储】上述均关闭时,保存到本地 images 目录
4. 【保留原样】下载/上传失败,或同源图片保持原链接
"""
import os
import sys
import time
import base64
import re
import hashlib
import mimetypes
import requests
import subprocess
import importlib
from io import BytesIO
from pathlib import Path
from typing import Dict, Any, List
from urllib.parse import urlparse
from PIL import Image
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):
self.text_key = kwargs.get("text_key", "text")
self.data_key = kwargs.get("data_key", "data")
self.filename_key = kwargs.get("fileName_key", "fileName")
self.filetype_key = kwargs.get("fileType_key", "fileType")
self.filepath_key = kwargs.get("filePath_key", "filePath")
self.export_path_key = kwargs.get("export_path_key", "export_path")
SUPPORTED_FORMATS = {
".docx", ".doc",
".xlsx", ".xls",
".pptx", ".ppt",
".txt", ".csv", ".xml",
#".html", ".htm",
}
IMG_LINK_REGEX = re.compile(r'!\[([^\]]*)\]\((.*?)\)')
def ensure_import(module_name: str, package_name: str = None):
"""
安全导入模块,若不存在则尝试自动安装。
Args:
module_name: Python import 名 (如 'docx', 'pptx')
package_name: Pip 安装名 (如 'python-docx'), 默认与 module_name 相同
"""
pkg = package_name or module_name
try:
return importlib.import_module(module_name)
except ImportError:
logger.warning(f"⚠️ 模块 '{module_name}' 未找到,正在尝试自动安装 '{pkg}'...")
try:
subprocess.check_call([sys.executable, "-m", "pip", "install", pkg])
logger.info(f"✅ 成功安装 '{pkg}'")
return importlib.import_module(module_name)
except Exception as e:
logger.error(f"❌ 自动安装 '{pkg}' 失败: {e}")
raise ImportError(f"缺少依赖包 '{pkg}' 且无法自动安装")
def extract_docx_images_base64(docx_file: str, max_width: int = 1200) -> List[str]:
"""从 docx 提取图片列表 (按顺序)"""
docx = ensure_import("docx", "python-docx")
Document = docx.Document
b64_list = []
doc = Document(docx_file)
for rel in doc.part.rels.values():
if "image" in rel.target_ref:
try:
img_bytes = rel.target_part.blob
img = Image.open(BytesIO(img_bytes))
_resize_and_append(img, b64_list, max_width)
except: continue
return b64_list
def extract_pptx_images_base64(pptx_file: str, max_width: int = 1200) -> List[str]:
"""从 pptx 提取图片列表 (按幻灯片顺序)"""
pptx = ensure_import("pptx", "python-pptx")
Presentation = pptx.Presentation
prs = Presentation(pptx_file)
b64_list = []
for slide in prs.slides:
for shape in slide.shapes:
if hasattr(shape, "image") and shape.image is not None:
try:
img_bytes = shape.image.blob
img = Image.open(BytesIO(img_bytes))
_resize_and_append(img, b64_list, max_width)
except: continue
return b64_list
def _resize_and_append(img: Image.Image, target_list: list, max_width: int):
if img.width > max_width:
ratio = max_width / img.width
new_size = (max_width, int(img.height * ratio))
img = img.resize(new_size, Image.Resampling.LANCZOS)
buf = BytesIO()
img.save(buf, format="PNG", optimize=True)
b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
target_list.append(f"data:image/png;base64,{b64}")
def download_image(url: str) -> bytes:
headers = {"User-Agent": "Mozilla/5.0 Chrome/120.0"}
resp = requests.get(url, headers=headers, timeout=30)
resp.raise_for_status()
return resp.content
class MarkItDownParser(Mapper):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
raw_formats = kwargs.get("doc_formats", [])
if isinstance(raw_formats, str):
self.doc_formats = [f.strip().lower().lstrip(".") for f in raw_formats.split(",") if f.strip()]
elif isinstance(raw_formats, list):
self.doc_formats = [str(f).lower().lstrip(".") for f in raw_formats]
else:
self.doc_formats = [f.lstrip(".") for f in SUPPORTED_FORMATS]
self.embed_images_base64 = kwargs.get("embed_images_base64", "true") == "true"
self.download_remote_images = kwargs.get("download_remote_images", "true") == "true"
self.max_image_width = int(kwargs.get("max_image_width", 1200))
# 🆕 上传到外部图床配置
self.upload_images = kwargs.get("upload_images", "false") == "true"
self.upload_api_url = kwargs.get("upload_api_url", "").strip()
self.preview_base_url = kwargs.get("preview_base_url", "").rstrip("/")
print(f"self.upload_images={self.upload_images}")
self._converter = None
def _get_converter(self):
if self._converter is None:
markitdown = ensure_import("markitdown")
MarkItDown = markitdown.MarkItDown
self._converter = MarkItDown()
return self._converter
def _is_target_format(self, filename: str) -> bool:
ext = Path(filename).suffix.lower()
return ext.lstrip(".") in self.doc_formats if ext else False
def _load_office_image_cache(self, filepath: str, file_ext: str) -> List[str]:
"""根据文件类型加载图片缓存列表"""
logger.info("📷 正在预加载 Office 内部图片...")
if file_ext == ".docx":
return extract_docx_images_base64(filepath, self.max_image_width)
elif file_ext in (".pptx", ".ppt"):
return extract_pptx_images_base64(filepath, self.max_image_width)
return []
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()
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()
# 🛡️ 强力清洗路径,防止双斜杠 //
clean_path = path.strip('\"\' ').lstrip('/')
clean_base = self.preview_base_url.strip(' /')
if not clean_base or not clean_path:
raise ValueError("Base URL 或路径解析为空")
return f"{clean_base}/{clean_path}"
except Exception as e:
logger.warning(f"⚠️ 图片上传失败 [{filename}]: {e}")
return ""
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
def _process_all_images(self, md_content: str, export_path: str, source_dir: str, image_cache: List[str]) -> str:
"""统一处理 Markdown 中的所有图片链接"""
image_exts = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.tiff'}
def replace_func(match):
alt = match.group(1)
link = match.group(2).strip()
# 1. 处理占位符 (常见于 DOCX,从缓存取)
if link.startswith("data:image/png;base64") and "..." in link:
if image_cache:
b64_data = image_cache.pop(0)
return self._handle_image_bytes(b64_data, alt, link, export_path)
return match.group(0)
# 2. 处理已经是真实 Base64 的情况
if link.startswith("data:image/") and "..." not in link:
if self.upload_images and self.upload_api_url:
try:
if "," in link:
_, b64_data = link.split(",", 1)
else:
b64_data = link
img_bytes = base64.b64decode(b64_data)
upload_url = self._upload_image_to_api(img_bytes, "embedded_image.png")
if upload_url:
return f"![{alt}]({upload_url})"
else:
logger.warning(f"⚠️ Base64 图片上传失败,保留原 Base64: {link[:50]}...")
except Exception as e:
logger.error(f"⚠️ Base64 图片处理异常: {e}")
return match.group(0)
# 3. 处理远程链接
if link.startswith("http"):
if not self.download_remote_images:
if self.upload_images and self.preview_base_url and self._is_same_origin(link, self.preview_base_url):
return match.group(0)
return match.group(0)
try:
img_bytes = download_image(link)
if self.upload_images and self.upload_api_url:
upload_url = self._upload_image_to_api(img_bytes, Path(urlparse(link).path).name or "remote.jpg")
if upload_url:
return f"![{alt}]({upload_url})"
else:
logger.warning(f"⚠️ 远程图片上传失败 [{link}],尝试下载或保留")
return self._handle_image_bytes(img_bytes, alt, link, export_path)
except Exception as e:
logger.warning(f"⚠️ 下载失败 [{link}]: {e}")
return match.group(0)
# 4. 处理本地/相对路径
possible_paths = [
os.path.join(source_dir, link),
os.path.join(export_path, link),
os.path.join(source_dir, "media", link)
]
found_bytes = None
for p in possible_paths:
if os.path.exists(p):
with open(p, "rb") as f: found_bytes = f.read()
break
if found_bytes:
return self._handle_image_bytes(found_bytes, alt, link, export_path)
# 5. 【智能回退】文件不在磁盘上
link_ext = Path(urlparse(link).path).suffix.lower()
if link_ext in image_exts:
if image_cache:
logger.debug(f"⚠️ 文件不存在 ({link}),从缓存池中获取图片替换")
b64_data = image_cache.pop(0)
return self._handle_image_bytes(b64_data, alt, link, export_path)
return match.group(0)
return IMG_LINK_REGEX.sub(replace_func, md_content)
def _handle_image_bytes(self, img_data: Any, alt: str, original_ref: str, export_path: str) -> str:
"""处理图片二进制或 Base64,根据配置转为上传链接/Base64/本地文件"""
# 0. 优先处理:解析图片数据为 bytes
img_bytes = None
try:
if isinstance(img_data, str) and img_data.startswith("data:"):
if "," in img_data:
_, b64_data = img_data.split(",", 1)
else:
b64_data = img_data
img_bytes = base64.b64decode(b64_data)
elif isinstance(img_data, bytes):
img_bytes = img_data
except Exception as e:
logger.warning(f"⚠️ 图片数据解析失败: {e}")
return f"![{alt}]({original_ref})"
if not img_bytes:
return f"![{alt}]({original_ref})"
# 🟢 优先级 1: 尝试上传到外部 API
if self.upload_images and self.upload_api_url:
# 尝试生成一个合理的文件名
filename = "image.png"
if isinstance(original_ref, str) and not original_ref.startswith("data:"):
filename = Path(original_ref).name or "image.png"
upload_url = self._upload_image_to_api(img_bytes, filename)
if upload_url:
return f"![{alt}]({upload_url})"
logger.warning(f"⚠️ 图片上传失败 (ref: {original_ref[:30]}),回退到 Base64 或本地")
# 🟡 优先级 2: Base64 内联
if self.embed_images_base64:
try:
img = Image.open(BytesIO(img_bytes))
if img.width > self.max_image_width:
ratio = self.max_image_width / img.width
img = img.resize((self.max_image_width, int(img.height * ratio)), Image.Resampling.LANCZOS)
buf = BytesIO()
img.save(buf, format="PNG")
b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
mime = mimetypes.guess_type(original_ref)[0] or "image/png"
return f"![{alt}](data:{mime};base64,{b64})"
except Exception as e:
logger.warning(f"⚠️ Base64 转换失败: {e}")
# 🔴 优先级 3: 本地存储
else:
try:
ext = mimetypes.guess_extension(mimetypes.guess_type(original_ref)[0] or 'image/png') or '.jpg'
fname = hashlib.md5(original_ref.encode()).hexdigest()[:12] + ext
save_rel = os.path.join("images", fname)
full_save = os.path.join(export_path, save_rel)
os.makedirs(os.path.dirname(full_save), exist_ok=True)
with open(full_save, "wb") as f:
f.write(img_bytes)
return f"![{alt}]({save_rel})"
except Exception as e:
logger.warning(f"⚠️ 本地存储失败: {e}")
# 最后的兜底
return f"![{alt}]({original_ref})"
def execute(self, sample: Dict[str, Any]) -> Dict[str, Any]:
filename = sample.get(self.filename_key, "")
filepath = sample.get(self.filepath_key, "")
export_path = sample.get(self.export_path_key, "./output")
file_ext = Path(filename).suffix.lower()
if not self._is_target_format(filename):
logger.info(f"⏭️ 跳过 {filename} (不在处理列表中)")
return sample
if not filepath or not os.path.exists(filepath):
logger.warning(f"⚠️ 文件不存在: {filepath}")
return sample
start_time = time.time()
try:
logger.info(f"🚀 开始解析: {filename}")
result = self._get_converter().convert(filepath)
md_content = result.text_content
if not md_content:
return sample
# 加载 Office 图片缓存 (如果是 Office 格式)
image_cache = []
if file_ext in (".docx", ".pptx", ".ppt"):
image_cache = self._load_office_image_cache(filepath, file_ext)
if image_cache:
logger.info(f"✅ 缓存中加载到 {len(image_cache)} 张图片")
# 统一处理所有图片
md_content = self._process_all_images(md_content, export_path, os.path.dirname(filepath), image_cache)
sample[self.text_key] = md_content
sample["target_type"] = "md"
logger.info(f"✅ 解析成功: {filename} | 耗时 {time.time() - start_time:.2f}s")
except Exception as e:
logger.exception(f"❌ 解析失败 {filename}: {e}")
raise
return sample
# ==========================================================
# 🧪 本地调试与测试入口
# ==========================================================
if __name__ == "__main__":
print("=" * 60)
print("🧪 MarkItDownParser 本地测试")
print("=" * 60)
current_dir = os.path.dirname(os.path.abspath(__file__))
test_files = []
for ext in SUPPORTED_FORMATS:
test_files.extend(Path(current_dir).glob(f"*{ext}"))
if not test_files:
print(f"❌ 找不到测试文件")
sys.exit(1)
test_files = sorted(set(test_files))
print(f"📂 找到 {len(test_files)} 个测试文件:")
for f in test_files: print(f" 📄 {f.name}")
output_dir = os.path.join(current_dir, "test_output")
os.makedirs(output_dir, exist_ok=True)
mock_kwargs = {
"doc_formats": list(SUPPORTED_FORMATS),
"embed_images_base64": "true",
"download_remote_images": "true",
"max_image_width": 1200,
"upload_images": "true",
"upload_api_url": "http://mfs.ailab.ccoop.cc:18585/upload/form",
"preview_base_url": "http://mfs.ailab.ccoop.cc:18585"
}
success, fail = 0, 0
for f in test_files:
print(f"\n📄 处理: {f.name}")
sample = {"fileName": f.name, "filePath": str(f), "export_path": output_dir, "text": "", "target_type": ""}
try:
res = MarkItDownParser(**mock_kwargs).execute(sample)
md = res.get("text", "")
if md:
out = os.path.join(output_dir, f.stem + ".md")
with open(out, "w", encoding="utf-8") as fo: fo.write(md)
if "base64..." in md:
print(f" ⚠️ 成功但仍有占位符 (缓存耗尽)")
elif "data:image" in md:
print(f" ✅ 成功 (含 Base64 图片)")
else:
print(f" ✅ 成功 | MD长度: {len(md)}")
success += 1
else: fail += 1
except Exception as e:
print(f" ❌ 失败: {e}")
fail += 1
print(f"\n📊 测试完成: {success} 成功, {fail} 失败")