refactor(parser): 重构 MinerU 文档解析器
- 重写 MinerU 解析模块,改进文档解析流程和结构化输出 - 优化表格识别、标题层级提取和多格式文档处理 - 支持 v2 格式偏好(MINERU_PREFER_V2) 🤖 Generated with [Qoder][https://qoder.com]
This commit is contained in:
@@ -286,14 +286,138 @@ def parse_with_mineru_online(
|
|||||||
raise RuntimeError(f"MinerU 在线 API 调用失败: {e}")
|
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]:
|
def _parse_mineru_online_zip(zip_content: bytes, file_path: Path) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
解析 MinerU 在线 API 返回的 zip 包
|
解析 MinerU 在线 API 返回的 zip 包
|
||||||
|
|
||||||
zip 包结构:
|
zip 包结构:
|
||||||
- full.md - Markdown 解析结果
|
- full.md - Markdown 解析结果
|
||||||
- *_content_list.json - 内容列表(扁平格式,优先使用)
|
- *_content_list.json - 内容列表(v1 扁平格式)
|
||||||
- *_content_list_v2.json - 内容列表(嵌套格式)
|
- *_content_list_v2.json - 内容列表(v2 嵌套格式,含 style 信息)
|
||||||
- images/ - 图片目录
|
- images/ - 图片目录
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -306,24 +430,45 @@ def _parse_mineru_online_zip(zip_content: bytes, file_path: Path) -> Dict[str, A
|
|||||||
import zipfile
|
import zipfile
|
||||||
import io
|
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:
|
with zipfile.ZipFile(io.BytesIO(zip_content), 'r') as zf:
|
||||||
# 列出所有文件
|
# 列出所有文件
|
||||||
file_list = zf.namelist()
|
file_list = zf.namelist()
|
||||||
logger.debug(f"zip 包内容: {file_list}")
|
logger.debug(f"zip 包内容: {file_list}")
|
||||||
|
|
||||||
# 查找 content_list.json(优先使用扁平格式)
|
# 查找 content_list 文件(v2 含 style 信息,优先使用)
|
||||||
content_list_path = None
|
content_list_path = None
|
||||||
for f in file_list:
|
is_v2 = False
|
||||||
# 优先使用 content_list.json(扁平格式)
|
|
||||||
if f.endswith('_content_list.json') and not f.endswith('_v2.json'):
|
if MINERU_PREFER_V2:
|
||||||
content_list_path = f
|
# 优先使用 v2 格式(含 style 信息)
|
||||||
break
|
|
||||||
# 如果没有找到,再尝试 v2 格式
|
|
||||||
if not content_list_path:
|
|
||||||
for f in file_list:
|
for f in file_list:
|
||||||
if f.endswith('_content_list_v2.json'):
|
if f.endswith('_content_list_v2.json'):
|
||||||
content_list_path = f
|
content_list_path = f
|
||||||
|
is_v2 = True
|
||||||
break
|
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 文件
|
# 查找 markdown 文件
|
||||||
md_path = None
|
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:
|
if content_list_path:
|
||||||
with zf.open(content_list_path) as f:
|
with zf.open(content_list_path) as f:
|
||||||
content_list = json.load(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
|
||||||
markdown_content = ""
|
markdown_content = ""
|
||||||
@@ -424,10 +575,14 @@ def _parse_mineru_online_result(result: Dict, file_path: Path) -> Dict[str, Any]
|
|||||||
|
|
||||||
if item_type == "text":
|
if item_type == "text":
|
||||||
text = item.get("content", "") or item.get("text", "")
|
text = item.get("content", "") or item.get("text", "")
|
||||||
|
v2_styles = item.get("_v2_styles", [])
|
||||||
|
|
||||||
# 启发式标题识别
|
# 启发式标题识别
|
||||||
if 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 = ""
|
title = ""
|
||||||
if text_level > 0:
|
if text_level > 0:
|
||||||
@@ -471,7 +626,8 @@ def _parse_mineru_online_result(result: Dict, file_path: Path) -> Dict[str, Any]
|
|||||||
else:
|
else:
|
||||||
md_table = ""
|
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(
|
chunk = MinerUChunk(
|
||||||
content=table_caption or "表格",
|
content=table_caption or "表格",
|
||||||
@@ -558,8 +714,14 @@ def _parse_mineru_online_result(result: Dict, file_path: Path) -> Dict[str, Any]
|
|||||||
min_merge = 100
|
min_merge = 100
|
||||||
max_size = 1200
|
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)
|
chunks = _post_process_chunks(chunks, min_merge_size=min_merge, max_chunk_size=max_size)
|
||||||
|
|
||||||
|
# 验证分类标题是否被规则引擎正确识别(安全网,仅告警不修改)
|
||||||
|
_validate_category_section_paths(chunks)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'markdown': "\n".join(markdown_parts) if markdown_parts else markdown_content,
|
'markdown': "\n".join(markdown_parts) if markdown_parts else markdown_content,
|
||||||
'chunks': chunks,
|
'chunks': chunks,
|
||||||
@@ -697,16 +859,18 @@ def parse_with_mineru(
|
|||||||
logger.error(f"MinerU 解析失败: {e}")
|
logger.error(f"MinerU 解析失败: {e}")
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
# 清理临时目录
|
清理临时目录
|
||||||
if cleanup_output and os.path.exists(output_dir):
|
if cleanup_output and os.path.exists(output_dir):
|
||||||
shutil.rmtree(output_dir, ignore_errors=True)
|
shutil.rmtree(output_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
def _detect_heading_level(text: str) -> int:
|
def _detect_heading_level(text: str) -> int:
|
||||||
"""
|
"""
|
||||||
启发式标题识别
|
启发式标题识别(规则引擎版)
|
||||||
|
|
||||||
用于 MinerU 解析 DOCX 等 Office 格式时不提供 text_level 的情况。
|
当 MinerU 解析 DOCX 等 Office 格式时不提供 text_level 时使用。
|
||||||
|
规则按优先级从高到低匹配,第一个命中即返回。
|
||||||
|
|
||||||
|
规则定义见 parsers/heading_rules.py,可通过 config.py 覆盖。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: 文本内容
|
text: 文本内容
|
||||||
@@ -714,57 +878,91 @@ def _detect_heading_level(text: str) -> int:
|
|||||||
Returns:
|
Returns:
|
||||||
标题级别 (0=正文, 1=h1, 2=h2, 3=h3)
|
标题级别 (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
|
||||||
|
|
||||||
# 空文本
|
MinerU 解析 Word 文档时,某些带下划线填空项的表单
|
||||||
if not text:
|
被标记为 text 类型,需要根据内容特征修正为 table。
|
||||||
return 0
|
就地修改 chunks 列表中的 chunk_type 字段。
|
||||||
|
|
||||||
# 中文章节标题模式
|
检测依据(基于实测数据设计):
|
||||||
# 第一章、第二章、... -> h1
|
- 连续下划线 ___ (3个以上) —— Word 表单填空项
|
||||||
if re.match(r'^第[一二三四五六七八九十百千万]+[章节篇部]', text):
|
- 冒号后跟下划线 如 "日期:____" —— 键值对式表单
|
||||||
return 1
|
需 >= min_indicators 个指标同时命中才校正,避免误判。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from config import FORM_RECLASSIFY_ENABLED
|
||||||
|
if not FORM_RECLASSIFY_ENABLED:
|
||||||
|
return
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
# 第一条、第二条、... -> h2 (条文编号)
|
try:
|
||||||
if re.match(r'^第[一二三四五六七八九十百千万]+[条款]', text):
|
from config import FORM_RECLASSIFY_MIN_INDICATORS
|
||||||
return 2
|
min_indicators = FORM_RECLASSIFY_MIN_INDICATORS
|
||||||
|
except ImportError:
|
||||||
|
min_indicators = 2
|
||||||
|
|
||||||
# 数字章节: 1. 2. 3. 或 1、2、3、
|
# 表单特征指标(编译一次,避免循环内重复编译)
|
||||||
# 一级标题: 1. 2. 3. (单数字)
|
# 注意:MinerU 输出的下划线是 Markdown 转义格式 \_\_\_,
|
||||||
if re.match(r'^\d+[\.、\s]', text):
|
# 需要同时匹配纯下划线 ___ 和转义下划线 \_\_\_
|
||||||
# 短文本可能是标题
|
_FORM_INDICATORS = [
|
||||||
if len(text) < 50:
|
re.compile(r'(?:\\_|_){3,}'), # 连续下划线(3个以上,含转义格式)
|
||||||
return 1
|
re.compile(r'[::]\s*(?:\\_|_){2,}'), # 冒号后跟下划线(含转义格式)
|
||||||
|
]
|
||||||
|
|
||||||
# 二级标题: 1.1 1.2 2.1 等
|
reclassified = 0
|
||||||
if re.match(r'^\d+\.\d+[\.、\s]', text):
|
for chunk in chunks:
|
||||||
if len(text) < 80:
|
if chunk.chunk_type != 'text':
|
||||||
return 2
|
continue
|
||||||
|
text = (chunk.content or '').strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
|
||||||
# 三级标题: 1.1.1 1.1.2 等
|
indicator_count = sum(1 for p in _FORM_INDICATORS if p.search(text))
|
||||||
if re.match(r'^\d+\.\d+\.\d+[\.、\s]', text):
|
if indicator_count >= min_indicators:
|
||||||
if len(text) < 100:
|
chunk.chunk_type = 'table'
|
||||||
return 3
|
reclassified += 1
|
||||||
|
logger.info(f"表单检测: text -> table, content='{text[:80]}'")
|
||||||
|
|
||||||
# 英文章节标题
|
if reclassified > 0:
|
||||||
# Chapter 1, Section 2, etc.
|
logger.info(f"表单类型二次校正: 共 {reclassified} 个 text -> table")
|
||||||
if re.match(r'^(Chapter|Section|Part|Chapter\s+\d+|Section\s+\d+)', text, re.IGNORECASE):
|
|
||||||
return 1
|
|
||||||
|
|
||||||
# 短文本 + 加粗标记 (**xxx**) 可能是标题
|
|
||||||
if re.match(r'^\*\*.+\*\*$', text) and len(text) < 50:
|
|
||||||
return 2
|
|
||||||
|
|
||||||
# 非常短的文本 (< 20 字符) 可能是标题
|
def _validate_category_section_paths(chunks: List[MinerUChunk]) -> None:
|
||||||
# 但需要排除常见的非标题短文本
|
"""
|
||||||
if len(text) < 20 and not re.match(r'^[\d\s\.,;:!?,。;:!?、]+$', text):
|
验证分类标题是否被规则引擎正确识别(安全网)
|
||||||
# 排除纯数字、纯标点
|
|
||||||
if re.search(r'[\u4e00-\u9fff]', text): # 包含中文
|
|
||||||
return 2
|
|
||||||
|
|
||||||
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]:
|
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:
|
else:
|
||||||
raise RuntimeError(f"MinerU 输出目录不存在: {auto_dir} 或 {office_dir}")
|
raise RuntimeError(f"MinerU 输出目录不存在: {auto_dir} 或 {office_dir}")
|
||||||
|
|
||||||
# 读取 content_list.json
|
# 读取 content_list(v2 含 style 信息,优先使用)
|
||||||
content_list_path = output_subdir / f"{doc_name}_content_list.json"
|
try:
|
||||||
if not content_list_path.exists():
|
from config import MINERU_PREFER_V2
|
||||||
content_list_path = output_subdir / f"{doc_name}_content_list_v2.json"
|
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 = []
|
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:
|
with open(content_list_path, 'r', encoding='utf-8') as f:
|
||||||
content_list = json.load(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
|
# 读取 Markdown
|
||||||
md_path = output_subdir / f"{doc_name}.md"
|
md_path = output_subdir / f"{doc_name}.md"
|
||||||
markdown_content = ""
|
markdown_content = ""
|
||||||
@@ -857,10 +1084,14 @@ def _parse_mineru_output(file_path: Path, output_dir) -> Dict[str, Any]:
|
|||||||
|
|
||||||
if item_type == "text":
|
if item_type == "text":
|
||||||
text = item.get("text", "")
|
text = item.get("text", "")
|
||||||
|
v2_styles = item.get("_v2_styles", [])
|
||||||
|
|
||||||
# 启发式标题识别(当 text_level 为 0 时)
|
# 启发式标题识别(当 text_level 为 0 时)
|
||||||
if 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 = ""
|
title = ""
|
||||||
@@ -909,8 +1140,8 @@ def _parse_mineru_output(file_path: Path, output_dir) -> Dict[str, Any]:
|
|||||||
else:
|
else:
|
||||||
md_table = ""
|
md_table = ""
|
||||||
|
|
||||||
# 提取表格中的嵌入图片
|
# 从原始 HTML 提取嵌入图片(md_table 经 get_text 转换后已丢失 <img> 标签)
|
||||||
table_images = extract_images_from_markdown(md_table) if md_table else []
|
table_images = extract_images_from_markdown(table_body) if table_body else []
|
||||||
|
|
||||||
chunk = MinerUChunk(
|
chunk = MinerUChunk(
|
||||||
content=table_caption or "表格",
|
content=table_caption or "表格",
|
||||||
@@ -1003,8 +1234,14 @@ def _parse_mineru_output(file_path: Path, output_dir) -> Dict[str, Any]:
|
|||||||
min_merge = 100
|
min_merge = 100
|
||||||
max_size = 1200
|
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)
|
chunks = _post_process_chunks(chunks, min_merge_size=min_merge, max_chunk_size=max_size)
|
||||||
|
|
||||||
|
# 验证分类标题是否被规则引擎正确识别(安全网,仅告警不修改)
|
||||||
|
_validate_category_section_paths(chunks)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'markdown': "\n".join(markdown_parts),
|
'markdown': "\n".join(markdown_parts),
|
||||||
'chunks': chunks,
|
'chunks': chunks,
|
||||||
@@ -1349,8 +1586,18 @@ def html_table_to_markdown(html_table: str) -> str:
|
|||||||
del rowspan_tracker[col_idx]
|
del rowspan_tracker[col_idx]
|
||||||
col_idx += 1
|
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
|
||||||
rowspan = int(cell.get('rowspan', 1))
|
rowspan = int(cell.get('rowspan', 1))
|
||||||
@@ -1630,6 +1877,21 @@ def parse_with_mineru_persistent(
|
|||||||
chunk.image_path = new_name
|
chunk.image_path = new_name
|
||||||
break
|
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())
|
result['images'] = list(image_path_map.values())
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user