From 9c1593a5e496d515fde9850a465b35e26721078f Mon Sep 17 00:00:00 2001 From: lacerate551 <128470311+lacerate551@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:43:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(parser):=20=E6=96=B0=E5=A2=9E=E6=A0=87?= =?UTF-8?q?=E9=A2=98=E8=AF=86=E5=88=AB=E8=A7=84=E5=88=99=E5=BC=95=E6=93=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 parsers/heading_rules.py,支持可配置的标题层级检测规则 - 支持短中文文本标题识别的独立开关控制 🤖 Generated with [Qoder][https://qoder.com] --- parsers/heading_rules.py | 347 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 347 insertions(+) create mode 100644 parsers/heading_rules.py diff --git a/parsers/heading_rules.py b/parsers/heading_rules.py new file mode 100644 index 0000000..2bba751 --- /dev/null +++ b/parsers/heading_rules.py @@ -0,0 +1,347 @@ +# -*- coding: utf-8 -*- +""" +标题识别规则引擎 + +将 _detect_heading_level 的硬编码正则提取为可配置的规则列表。 +规则按优先级从高到低排序,第一个匹配即返回。 + +MinerU 解析 DOCX 等 Office 格式时通常不提供 text_level(全部为 0), +此时需要启发式识别标题层级。本模块提供可配置的规则引擎替代原来的 +硬编码 if-elif 链。 + +设计要点: +- HeadingRule 数据类支持正向匹配(pattern)和反向排除(exclude_pattern) +- 长度约束(min_length / max_length)可精确控制匹配范围 +- 规则可单独禁用(enabled=False),便于调试 +- 全局单例通过 config.py 覆盖默认值 + +MinerU v2 格式备注: + content_list_v2.json 中的 paragraph_content 包含 style=["bold"] 信息, + layout.json 中的 spans 也有 style 信息。这些信息比正则匹配 **加粗** 更可靠, + 但当前代码使用 v1 格式(content_list.json),暂不利用 v2 的 style。 + HeadingRuleEngine.detect() 签名预留了 style 参数,未来切换到 v2 格式后 + 可直接利用 style 信息辅助判断。 +""" + +import re +import logging +from dataclasses import dataclass +from typing import Optional, List, Tuple + +logger = logging.getLogger(__name__) + + +@dataclass +class HeadingRule: + """ + 标题识别规则 + + 每条规则定义一个文本模式到标题级别的映射。 + 规则引擎按列表顺序逐条匹配,第一个命中即返回。 + + Attributes: + pattern: 编译后的正则(match 语义,从文本开头匹配) + level: 匹配时返回的标题级别 (1=h1, 2=h2, 3=h3) + name: 规则名称(用于日志和配置覆盖) + max_length: 文本最大长度,0=不限 + min_length: 文本最小长度,0=不限 + enabled: 是否启用 + exclude_pattern: 匹配此模式则排除(反向过滤) + + Example: + >>> rule = HeadingRule( + ... pattern=re.compile(r'^第[一二三四五六七八九十百千万]+[章节篇部]'), + ... level=1, + ... name="chinese_chapter", + ... ) + >>> rule.match("第一章 总则") + 1 + >>> rule.match("这是正文") + 0 + """ + + pattern: re.Pattern + level: int + name: str + max_length: int = 0 + min_length: int = 0 + enabled: bool = True + exclude_pattern: Optional[re.Pattern] = None + + def match(self, text: str) -> int: + """ + 检查文本是否匹配此规则 + + Args: + text: 待检测文本(调用前应已 strip) + + Returns: + 标题级别,0 表示不匹配 + """ + if not self.enabled: + return 0 + if self.min_length > 0 and len(text) < self.min_length: + return 0 + if self.max_length > 0 and len(text) > self.max_length: + return 0 + if self.exclude_pattern and self.exclude_pattern.search(text): + return 0 + if self.pattern.match(text): + return self.level + return 0 + + +# 默认规则列表(按优先级从高到低) +# +# 注意事项: +# - 数字三级标题 (1.1.1) 必须在二级 (1.1) 之前,因为 1.1.1 也匹配 ^\d+\.\d+ +# - short_chinese_heading 是最宽泛的规则,放在最后作为兜底 +# - 第 9 条规则相比原版增加了 exclude_pattern,排除以句末标点结尾的短文本 +DEFAULT_HEADING_RULES: List[HeadingRule] = [ + # 1. 中文章节标题 -> h1 + # 匹配:第一章、第二章、第十节、第三篇 等 + HeadingRule( + pattern=re.compile(r'^第[一二三四五六七八九十百千万]+[章节篇部]'), + level=1, + name="chinese_chapter", + ), + # 2. 中文条款编号 -> h2 + # 匹配:第一条、第三款 等 + HeadingRule( + pattern=re.compile(r'^第[一二三四五六七八九十百千万]+[条款]'), + level=2, + name="chinese_article", + ), + # 3. 数字三级标题 -> h3(必须在二级之前匹配) + # 匹配:1.1.1 背景、2.3.4 方案 等 + HeadingRule( + pattern=re.compile(r'^\d+\.\d+\.\d+[\.、\s]'), + level=3, + name="numeric_level3", + max_length=100, + ), + # 4. 数字二级标题 -> h2(必须在一级之前匹配) + # 匹配:1.1 背景、2.3 方案 等 + HeadingRule( + pattern=re.compile(r'^\d+\.\d+[\.、\s]'), + level=2, + name="numeric_level2", + max_length=80, + ), + # 5. 数字一级标题 -> h1 + # 匹配:1. 概述、2、背景 等 + HeadingRule( + pattern=re.compile(r'^\d+[\.、\s]'), + level=1, + name="numeric_level1", + max_length=50, + ), + # 6. 英文章节标题 -> h1 + # 匹配:Chapter 1、Section 2、Part 3 等 + HeadingRule( + pattern=re.compile(r'^(Chapter|Section|Part|Chapter\s+\d+|Section\s+\d+)', re.IGNORECASE), + level=1, + name="english_chapter", + ), + # 7. 分类标题 -> h3(必须在 bold_short_text 之前,否则 **A2类:** 会被加粗规则抢先匹配) + # 匹配:A1类:公园、**A2类**:各类卫生医疗机构、**B1类:** 道路 等 + HeadingRule( + pattern=re.compile(r'^\*{0,2}[A-Z]\d+[类類]\*{0,2}[::]'), + level=3, + name="category_heading", + ), + # 8. 加粗短文本 -> h2 + # 匹配:**重要通知**、**概述** 等(Markdown 加粗标记) + # 注意:**A2类:** 已被分类标题规则优先匹配,不会误判为 h2 + HeadingRule( + pattern=re.compile(r'^\*\*.+\*\*$'), + level=2, + name="bold_short_text", + max_length=50, + ), + # 9. 短中文文本 -> h2(替代原"任何 <20 字符含中文"规则) + # 关键改进:排除以句末标点结尾的文本 + # 原规则将 "这是一段正文。" 也识别为 h2,导致大量误判 + # 新规则:包含中文 + 长度 2-20 + 不以句末标点结尾 → h2 + HeadingRule( + pattern=re.compile(r'[一-鿿]'), + level=2, + name="short_chinese_heading", + max_length=20, + min_length=2, + exclude_pattern=re.compile(r'[。!?;…]$'), + enabled=True, + ), +] + + +class HeadingRuleEngine: + """ + 标题识别规则引擎 + + 按规则列表顺序逐条匹配,第一个命中即返回标题级别。 + 支持从 config.py 加载自定义规则或覆盖默认规则参数。 + + Example: + >>> engine = HeadingRuleEngine() + >>> engine.detect("第一章 总则") + (1, 'chinese_chapter') + >>> engine.detect("这是普通正文。") + (0, None) + """ + + def __init__(self, rules: Optional[List[HeadingRule]] = None) -> None: + """ + Args: + rules: 规则列表,None 则使用默认规则的深拷贝 + """ + if rules is not None: + self.rules: List[HeadingRule] = rules + else: + import copy + self.rules = copy.deepcopy(DEFAULT_HEADING_RULES) + + def detect(self, text: str, style: Optional[List[str]] = None) -> Tuple[int, Optional[str]]: + """ + 检测文本的标题级别 + + Args: + text: 待检测文本 + style: MinerU v2 格式中的 style 信息(如 ["bold"]), + 当文本标记为 bold 且较短时,可直接判定为标题, + 无需依赖 Markdown **...** 标记。 + + Returns: + (level, rule_name): 标题级别和匹配的规则名 + level=0 表示不是标题 + """ + text = text.strip() + if not text: + return 0, None + + # v2 style 信息:如果文本标记为 bold 且较短,优先尝试加粗规则 + if style and 'bold' in style and 2 <= len(text) <= 50: + # 先检查是否匹配更高优先级的分类标题规则 + for rule in self.rules: + if rule.name == 'category_heading' and rule.enabled: + level = rule.match(text) + if level > 0: + logger.debug(f"标题识别(v2 style): '{text[:30]}' -> h{level} (规则: {rule.name})") + return level, rule.name + + # 再检查是否匹配中文章节/条款等高优先级规则 + for rule in self.rules: + if rule.name in ('chinese_chapter', 'chinese_article', 'numeric_level3', + 'numeric_level2', 'numeric_level1', 'english_chapter') and rule.enabled: + level = rule.match(text) + if level > 0: + logger.debug(f"标题识别(v2 style): '{text[:30]}' -> h{level} (规则: {rule.name})") + return level, rule.name + + # 否则作为加粗短文本 → h2(与 bold_short_text 规则对齐,但不依赖 **...** 标记) + logger.debug(f"标题识别(v2 style): '{text[:30]}' -> h2 (规则: bold_short_text_via_style)") + return 2, 'bold_short_text' + + # 常规规则匹配(v1 格式或无 style 信息时) + for rule in self.rules: + level = rule.match(text) + if level > 0: + logger.debug(f"标题识别: '{text[:30]}' -> h{level} (规则: {rule.name})") + return level, rule.name + + return 0, None + + +# ==================== 全局单例 ==================== + +_engine: Optional[HeadingRuleEngine] = None + + +def get_heading_engine() -> HeadingRuleEngine: + """获取全局标题识别引擎(延迟初始化,线程安全)""" + global _engine + if _engine is None: + _engine = _create_engine_from_config() + return _engine + + +def _create_engine_from_config() -> HeadingRuleEngine: + """ + 从 config 创建引擎(支持配置覆盖) + + 优先级: + 1. config.HEADING_RULES_CONFIG 不为 None → 使用自定义规则 + 2. config 细粒度参数覆盖默认规则(如 HEADING_SHORT_TEXT_ENABLED) + 3. 使用默认规则 + """ + # 尝试加载完整自定义规则 + try: + from config import HEADING_RULES_CONFIG + if HEADING_RULES_CONFIG is not None: + rules = _build_rules_from_config(HEADING_RULES_CONFIG) + logger.info(f"使用自定义标题规则: {len(rules)} 条") + return HeadingRuleEngine(rules) + except ImportError: + pass + + # 使用默认规则,应用细粒度配置覆盖 + rules = list(DEFAULT_HEADING_RULES) + try: + from config import HEADING_SHORT_TEXT_ENABLED + for rule in rules: + if rule.name == "short_chinese_heading": + rule.enabled = HEADING_SHORT_TEXT_ENABLED + logger.debug(f"配置覆盖: short_chinese_heading.enabled={HEADING_SHORT_TEXT_ENABLED}") + except ImportError: + pass + + try: + from config import HEADING_SHORT_TEXT_MAX_LENGTH + for rule in rules: + if rule.name == "short_chinese_heading": + rule.max_length = HEADING_SHORT_TEXT_MAX_LENGTH + logger.debug(f"配置覆盖: short_chinese_heading.max_length={HEADING_SHORT_TEXT_MAX_LENGTH}") + except ImportError: + pass + + return HeadingRuleEngine(rules) + + +def _build_rules_from_config(config: list) -> List[HeadingRule]: + """ + 从配置字典列表构建规则列表 + + Args: + config: 规则配置列表,每项为 dict,包含: + - pattern (str): 正则表达式字符串 + - level (int): 标题级别 + - name (str): 规则名称 + - max_length (int, 可选): 文本最大长度 + - min_length (int, 可选): 文本最小长度 + - enabled (bool, 可选): 是否启用 + - exclude_pattern (str, 可选): 排除正则 + + Returns: + 规则列表 + """ + rules = [] + for item in config: + exclude = None + if 'exclude_pattern' in item: + exclude = re.compile(item['exclude_pattern']) + rules.append(HeadingRule( + pattern=re.compile(item['pattern']), + level=item['level'], + name=item['name'], + max_length=item.get('max_length', 0), + min_length=item.get('min_length', 0), + enabled=item.get('enabled', True), + exclude_pattern=exclude, + )) + return rules + + +def reset_heading_engine() -> None: + """重置引擎(用于测试)""" + global _engine + _engine = None