refactor(parser): 重构 MinerU 文档解析器

- 重写 MinerU 解析模块,改进文档解析流程和结构化输出
- 优化表格识别、标题层级提取和多格式文档处理
- 支持 v2 格式偏好(MINERU_PREFER_V2)

🤖 Generated with [Qoder][https://qoder.com]
This commit is contained in:
lacerate551
2026-06-08 15:43:38 +08:00
parent a340eaaeee
commit 43261e9aff

View File

@@ -286,14 +286,138 @@ def parse_with_mineru_online(
raise RuntimeError(f"MinerU 在线 API 调用失败: {e}")
def _parse_v2_content_list(v2_data: list) -> list:
"""
将 MinerU v2 嵌套格式转换为 v1 兼容的扁平 content_list
v2 格式: [[page0_items], [page1_items], ...]
v1 格式: [item, item, ...] 扁平列表
转换规则:
- paragraph → text拼接 paragraph_content提取 style 信息)
- table → table保留 table_body/html
- title → text提取 level 信息)
- page_header / page_footer / page_number → 过滤掉
Args:
v2_data: v2 格式的嵌套列表
Returns:
v1 兼容的扁平列表,额外包含 _v2_styles / _v2_table_type 字段
"""
flat_list: List[Dict] = []
for page_idx, page_items in enumerate(v2_data):
if not isinstance(page_items, list):
continue
for item in page_items:
if not isinstance(item, dict):
continue
v2_type: str = item.get('type', '')
content = item.get('content', {})
# 过滤噪音类型(页眉、页脚、页码)
if v2_type in ('page_header', 'page_footer', 'page_number'):
continue
if v2_type == 'paragraph':
# 拼接段落文本,收集样式信息
text_parts: List[str] = []
has_bold = False
para_content = content.get('paragraph_content', []) if isinstance(content, dict) else []
for part in para_content:
if not isinstance(part, dict):
continue
part_text = part.get('content', '')
text_parts.append(part_text)
if 'bold' in part.get('style', []):
has_bold = True
full_text = ''.join(text_parts).strip()
if not full_text:
continue
flat_item: Dict[str, Any] = {
'type': 'text',
'text': full_text,
'content': full_text,
'page_idx': page_idx,
'bbox': item.get('bbox', []),
'text_level': 0,
'_v2_styles': ['bold'] if has_bold else [],
}
flat_list.append(flat_item)
elif v2_type == 'table':
flat_item = {
'type': 'table',
'page_idx': page_idx,
'bbox': item.get('bbox', []),
'html': content.get('html', '') if isinstance(content, dict) else '',
'table_body': content.get('html', '') if isinstance(content, dict) else '',
'caption': content.get('caption', '') if isinstance(content, dict) else '',
'_v2_table_type': content.get('table_type', '') if isinstance(content, dict) else '',
}
flat_list.append(flat_item)
elif v2_type == 'title':
# v2 title 项有 level 信息
level = content.get('level', 0) if isinstance(content, dict) else 0
text_parts = []
para_content = content.get('paragraph_content', []) if isinstance(content, dict) else []
for part in para_content:
if isinstance(part, dict):
text_parts.append(part.get('content', ''))
full_text = ''.join(text_parts).strip()
if full_text:
flat_item = {
'type': 'text',
'text': full_text,
'content': full_text,
'page_idx': page_idx,
'bbox': item.get('bbox', []),
'text_level': level,
'_v2_styles': ['bold'],
}
flat_list.append(flat_item)
elif v2_type in ('image', 'chart'):
flat_item = {
'type': v2_type,
'page_idx': page_idx,
'bbox': item.get('bbox', []),
'img_path': content.get('img_path', '') if isinstance(content, dict) else '',
'image_path': content.get('img_path', '') if isinstance(content, dict) else '',
'caption': content.get('caption', '') if isinstance(content, dict) else '',
}
flat_list.append(flat_item)
elif v2_type == 'equation':
flat_item = {
'type': 'equation',
'page_idx': page_idx,
'bbox': item.get('bbox', []),
'content': content.get('latex', '') if isinstance(content, dict) else '',
'text': content.get('latex', '') if isinstance(content, dict) else '',
'latex': content.get('latex', '') if isinstance(content, dict) else '',
'img_path': content.get('img_path', '') if isinstance(content, dict) else '',
}
flat_list.append(flat_item)
logger.info(f"v2 格式转换: {len(v2_data)} 页 → {len(flat_list)} 项(已过滤噪音类型)")
return flat_list
def _parse_mineru_online_zip(zip_content: bytes, file_path: Path) -> Dict[str, Any]:
"""
解析 MinerU 在线 API 返回的 zip 包
zip 包结构
zip 包结构:
- full.md - Markdown 解析结果
- *_content_list.json - 内容列表(扁平格式,优先使用
- *_content_list_v2.json - 内容列表(嵌套格式)
- *_content_list.json - 内容列表(v1 扁平格式)
- *_content_list_v2.json - 内容列表(v2 嵌套格式,含 style 信息
- images/ - 图片目录
Args:
@@ -306,24 +430,45 @@ def _parse_mineru_online_zip(zip_content: bytes, file_path: Path) -> Dict[str, A
import zipfile
import io
# 读取格式偏好配置
try:
from config import MINERU_PREFER_V2
except ImportError:
MINERU_PREFER_V2 = True # 默认优先 v2
with zipfile.ZipFile(io.BytesIO(zip_content), 'r') as zf:
# 列出所有文件
file_list = zf.namelist()
logger.debug(f"zip 包内容: {file_list}")
# 查找 content_list.json优先使用扁平格式
# 查找 content_list 文件v2 含 style 信息,优先使用
content_list_path = None
for f in file_list:
# 优先使用 content_list.json扁平格式
if f.endswith('_content_list.json') and not f.endswith('_v2.json'):
content_list_path = f
break
# 如果没有找到,再尝试 v2 格式
if not content_list_path:
is_v2 = False
if MINERU_PREFER_V2:
# 优先使用 v2 格式(含 style 信息)
for f in file_list:
if f.endswith('_content_list_v2.json'):
content_list_path = f
is_v2 = True
break
if not content_list_path:
for f in file_list:
if f.endswith('_content_list.json') and not f.endswith('_v2.json'):
content_list_path = f
break
else:
# 优先使用 v1 格式
for f in file_list:
if f.endswith('_content_list.json') and not f.endswith('_v2.json'):
content_list_path = f
break
if not content_list_path:
for f in file_list:
if f.endswith('_content_list_v2.json'):
content_list_path = f
is_v2 = True
break
# 查找 markdown 文件
md_path = None
@@ -337,7 +482,13 @@ def _parse_mineru_online_zip(zip_content: bytes, file_path: Path) -> Dict[str, A
if content_list_path:
with zf.open(content_list_path) as f:
content_list = json.load(f)
logger.info(f"读取 content_list: {len(content_list)} 项, 来源: {content_list_path}")
# v2 格式需要转换为 v1 兼容的扁平列表
if is_v2 and isinstance(content_list, list) and content_list and isinstance(content_list[0], list):
content_list = _parse_v2_content_list(content_list)
logger.info(f"v2 格式已转换为扁平列表,共 {len(content_list)}")
logger.info(f"读取 content_list: {len(content_list)} 项, 格式={'v2' if is_v2 else 'v1'}, 来源: {content_list_path}")
# 读取 markdown
markdown_content = ""
@@ -424,10 +575,14 @@ def _parse_mineru_online_result(result: Dict, file_path: Path) -> Dict[str, Any]
if item_type == "text":
text = item.get("content", "") or item.get("text", "")
v2_styles = item.get("_v2_styles", [])
# 启发式标题识别
if text_level == 0:
text_level = _detect_heading_level(text)
from parsers.heading_rules import get_heading_engine
engine = get_heading_engine()
detected_level, _ = engine.detect(text, style=v2_styles)
text_level = detected_level
title = ""
if text_level > 0:
@@ -471,7 +626,8 @@ def _parse_mineru_online_result(result: Dict, file_path: Path) -> Dict[str, Any]
else:
md_table = ""
table_images = extract_images_from_markdown(md_table) if md_table else []
# 从原始 HTML 提取嵌入图片md_table 经 get_text 转换后已丢失 <img> 标签)
table_images = extract_images_from_markdown(table_body) if table_body else []
chunk = MinerUChunk(
content=table_caption or "表格",
@@ -558,8 +714,14 @@ def _parse_mineru_online_result(result: Dict, file_path: Path) -> Dict[str, Any]
min_merge = 100
max_size = 1200
# 表单类型二次校正(在 _post_process_chunks 之前,因为 table 不参与合并)
_reclassify_text_chunks(chunks)
chunks = _post_process_chunks(chunks, min_merge_size=min_merge, max_chunk_size=max_size)
# 验证分类标题是否被规则引擎正确识别(安全网,仅告警不修改)
_validate_category_section_paths(chunks)
return {
'markdown': "\n".join(markdown_parts) if markdown_parts else markdown_content,
'chunks': chunks,
@@ -697,16 +859,18 @@ def parse_with_mineru(
logger.error(f"MinerU 解析失败: {e}")
raise
finally:
# 清理临时目录
清理临时目录
if cleanup_output and os.path.exists(output_dir):
shutil.rmtree(output_dir, ignore_errors=True)
def _detect_heading_level(text: str) -> int:
"""
启发式标题识别
启发式标题识别(规则引擎版)
用于 MinerU 解析 DOCX 等 Office 格式时不提供 text_level 的情况
MinerU 解析 DOCX 等 Office 格式时不提供 text_level 时使用
规则按优先级从高到低匹配,第一个命中即返回。
规则定义见 parsers/heading_rules.py可通过 config.py 覆盖。
Args:
text: 文本内容
@@ -714,57 +878,91 @@ def _detect_heading_level(text: str) -> int:
Returns:
标题级别 (0=正文, 1=h1, 2=h2, 3=h3)
"""
import re
from parsers.heading_rules import get_heading_engine
engine = get_heading_engine()
level, _ = engine.detect(text)
return level
text = text.strip()
def _reclassify_text_chunks(chunks: List[MinerUChunk]) -> None:
"""
二次校正:检测被标记为 text 但实际是表格/表单的 chunk
# 空文本
if not text:
return 0
MinerU 解析 Word 文档时,某些带下划线填空项的表单
被标记为 text 类型,需要根据内容特征修正为 table。
就地修改 chunks 列表中的 chunk_type 字段。
# 中文章节标题模式
# 第一章、第二章、... -> h1
if re.match(r'^第[一二三四五六七八九十百千万]+[章节篇部]', text):
return 1
检测依据(基于实测数据设计):
- 连续下划线 ___ (3个以上) —— Word 表单填空项
- 冒号后跟下划线 如 "日期____" —— 键值对式表单
需 >= min_indicators 个指标同时命中才校正,避免误判。
"""
try:
from config import FORM_RECLASSIFY_ENABLED
if not FORM_RECLASSIFY_ENABLED:
return
except ImportError:
pass
# 第一条、第二条、... -> h2 (条文编号)
if re.match(r'^第[一二三四五六七八九十百千万]+[条款]', text):
return 2
try:
from config import FORM_RECLASSIFY_MIN_INDICATORS
min_indicators = FORM_RECLASSIFY_MIN_INDICATORS
except ImportError:
min_indicators = 2
# 数字章节: 1. 2. 3. 或 1、2、3、
# 一级标题: 1. 2. 3. (单数字)
if re.match(r'^\d+[\.、\s]', text):
# 短文本可能是标题
if len(text) < 50:
return 1
# 表单特征指标(编译一次,避免循环内重复编译)
# 注意MinerU 输出的下划线是 Markdown 转义格式 \_\_\_
# 需要同时匹配纯下划线 ___ 和转义下划线 \_\_\_
_FORM_INDICATORS = [
re.compile(r'(?:\\_|_){3,}'), # 连续下划线3个以上含转义格式
re.compile(r'[:]\s*(?:\\_|_){2,}'), # 冒号后跟下划线(含转义格式)
]
# 二级标题: 1.1 1.2 2.1 等
if re.match(r'^\d+\.\d+[\.、\s]', text):
if len(text) < 80:
return 2
reclassified = 0
for chunk in chunks:
if chunk.chunk_type != 'text':
continue
text = (chunk.content or '').strip()
if not text:
continue
# 三级标题: 1.1.1 1.1.2 等
if re.match(r'^\d+\.\d+\.\d+[\.、\s]', text):
if len(text) < 100:
return 3
indicator_count = sum(1 for p in _FORM_INDICATORS if p.search(text))
if indicator_count >= min_indicators:
chunk.chunk_type = 'table'
reclassified += 1
logger.info(f"表单检测: text -> table, content='{text[:80]}'")
# 英文章节标题
# Chapter 1, Section 2, etc.
if re.match(r'^(Chapter|Section|Part|Chapter\s+\d+|Section\s+\d+)', text, re.IGNORECASE):
return 1
if reclassified > 0:
logger.info(f"表单类型二次校正: 共 {reclassified} 个 text -> table")
# 短文本 + 加粗标记 (**xxx**) 可能是标题
if re.match(r'^\*\*.+\*\*$', text) and len(text) < 50:
return 2
# 非常短的文本 (< 20 字符) 可能是标题
# 但需要排除常见的非标题短文本
if len(text) < 20 and not re.match(r'^[\d\s\.,;:!?,。;:!?、]+$', text):
# 排除纯数字、纯标点
if re.search(r'[\u4e00-\u9fff]', text): # 包含中文
return 2
def _validate_category_section_paths(chunks: List[MinerUChunk]) -> None:
"""
验证分类标题是否被规则引擎正确识别(安全网)
return 0
当规则引擎正确识别分类标题后section_stack 自然会更新,
不再需要后处理修改 section_path。此函数仅做验证和告警
便于发现规则引擎的遗漏。
支持的模式A1类、B2类、C1类含可选 ** 粗体标记)。
"""
cat_pattern = re.compile(r'^\*{0,2}[A-Z]\d+[类類]\*{0,2}[:]')
missed_count = 0
for chunk in chunks:
if chunk.chunk_type == 'text':
text = (chunk.content or '').strip()
if cat_pattern.match(text) and chunk.text_level == 0:
missed_count += 1
logger.warning(
f"分类标题未被识别为标题: '{text[:50]}', "
f"section_path='{chunk.section_path}'"
)
if missed_count > 0:
logger.warning(
f"发现 {missed_count} 个分类标题未被规则引擎识别,"
f"请检查 heading_rules 配置"
)
def _parse_mineru_output(file_path: Path, output_dir) -> Dict[str, Any]:
@@ -803,16 +1001,45 @@ def _parse_mineru_output(file_path: Path, output_dir) -> Dict[str, Any]:
else:
raise RuntimeError(f"MinerU 输出目录不存在: {auto_dir}{office_dir}")
# 读取 content_list.json
content_list_path = output_subdir / f"{doc_name}_content_list.json"
if not content_list_path.exists():
content_list_path = output_subdir / f"{doc_name}_content_list_v2.json"
# 读取 content_listv2 含 style 信息,优先使用)
try:
from config import MINERU_PREFER_V2
except ImportError:
MINERU_PREFER_V2 = True
v1_path = output_subdir / f"{doc_name}_content_list.json"
v2_path = output_subdir / f"{doc_name}_content_list_v2.json"
content_list_path = None
is_v2 = False
if MINERU_PREFER_V2:
# 优先使用 v2 格式
if v2_path.exists():
content_list_path = v2_path
is_v2 = True
elif v1_path.exists():
content_list_path = v1_path
else:
# 优先使用 v1 格式
if v1_path.exists():
content_list_path = v1_path
elif v2_path.exists():
content_list_path = v2_path
is_v2 = True
content_list = []
if content_list_path.exists():
if content_list_path and content_list_path.exists():
with open(content_list_path, 'r', encoding='utf-8') as f:
content_list = json.load(f)
# v2 格式需要转换为 v1 兼容的扁平列表
if is_v2 and isinstance(content_list, list) and content_list and isinstance(content_list[0], list):
content_list = _parse_v2_content_list(content_list)
logger.info(f"v2 格式已转换为扁平列表,共 {len(content_list)}")
logger.info(f"读取 content_list: {len(content_list)} 项, 格式={'v2' if is_v2 else 'v1'}")
# 读取 Markdown
md_path = output_subdir / f"{doc_name}.md"
markdown_content = ""
@@ -857,10 +1084,14 @@ def _parse_mineru_output(file_path: Path, output_dir) -> Dict[str, Any]:
if item_type == "text":
text = item.get("text", "")
v2_styles = item.get("_v2_styles", [])
# 启发式标题识别(当 text_level 为 0 时)
if text_level == 0:
text_level = _detect_heading_level(text)
from parsers.heading_rules import get_heading_engine
engine = get_heading_engine()
detected_level, _ = engine.detect(text, style=v2_styles)
text_level = detected_level
# 处理标题
title = ""
@@ -909,8 +1140,8 @@ def _parse_mineru_output(file_path: Path, output_dir) -> Dict[str, Any]:
else:
md_table = ""
# 提取表格中的嵌入图片
table_images = extract_images_from_markdown(md_table) if md_table else []
# 从原始 HTML 提取嵌入图片md_table 经 get_text 转换后已丢失 <img> 标签)
table_images = extract_images_from_markdown(table_body) if table_body else []
chunk = MinerUChunk(
content=table_caption or "表格",
@@ -1003,8 +1234,14 @@ def _parse_mineru_output(file_path: Path, output_dir) -> Dict[str, Any]:
min_merge = 100
max_size = 1200
# 表单类型二次校正(在 _post_process_chunks 之前,因为 table 不参与合并)
_reclassify_text_chunks(chunks)
chunks = _post_process_chunks(chunks, min_merge_size=min_merge, max_chunk_size=max_size)
# 验证分类标题是否被规则引擎正确识别(安全网,仅告警不修改)
_validate_category_section_paths(chunks)
return {
'markdown': "\n".join(markdown_parts),
'chunks': chunks,
@@ -1349,8 +1586,18 @@ def html_table_to_markdown(html_table: str) -> str:
del rowspan_tracker[col_idx]
col_idx += 1
# 提取单元格内容
content = cell.get_text(strip=True)
# 提取单元格内容(保留图片引用信息)
img_tags = cell.find_all('img')
if img_tags:
# 单元格包含图片,生成占位标记供 LLM 感知
text_part = cell.get_text(strip=True)
img_count = len(img_tags)
if text_part:
content = f"{text_part} [{'图片' if img_count == 1 else f'{img_count}张图片'}]"
else:
content = f"[{'图片' if img_count == 1 else f'{img_count}张图片'}]"
else:
content = cell.get_text(strip=True)
# 处理 rowspan
rowspan = int(cell.get('rowspan', 1))
@@ -1630,6 +1877,21 @@ def parse_with_mineru_persistent(
chunk.image_path = new_name
break
# 更新表格嵌入图片的路径映射images 字段)
if hasattr(chunk, 'images') and chunk.images:
for img_info in chunk.images:
if isinstance(img_info, dict) and 'id' in img_info:
old_id = img_info['id']
if old_id in image_path_map:
img_info['id'] = image_path_map[old_id]
else:
# 兼容:尝试用文件名匹配映射
old_basename = os.path.basename(old_id)
for old_path, new_name in image_path_map.items():
if old_basename == os.path.basename(old_path):
img_info['id'] = new_name
break
# 更新结果中的图片路径列表(供外部使用)
result['images'] = list(image_path_map.values())