Files
sansenandClaude Code b264073f2b 大交替串加首字符预筛:convert_text 提速 3.05x,输出零差异
CPython 的 re 不对大交替串 (?:A|B|...) 做首字符预筛——460 个年号的交替串在每个
位置都要把全部分支逐个试完才移向下一位。实测这类扫描占 convert_text 总耗时的 82%
(sample_2.txt 1.03M 字符:扫描 7.05 / 总计 8.54 µs/字符),而其中多数模式只命中
0~134 处。

改法:给每个"以年号或朝代名开头"的模式前面加一个零宽前瞻 (?=[首字集合])。前瞻不
消耗字符,只在当前位置断言下一个字符属于集合。预筛字符集从与模式**同一份源数据**
推导(_dynasty_names / _dynasty_prefix / KNOWN_ERAS_ALL),CSV 加新年号时自动同步。
三种预筛:_GATE_DYNASTY(朝代组必选)/ _GATE_DYNASTY_OPT(_dynasty_opt 可空)/
_GATE_ERA(朝代组为空)。

结果为什么不变(结构性保证,非经验之谈):前瞻只拒绝「原模式根本不可能开始匹配」的
位置 → 匹配集合完全相同 → 捕获组相同 → 后续执行序列一致 → 输出逐字段一致。

不加预筛者(前提不满足):公元段(前缀整个可选)、裸干支段(以干支对开头)、
民国段(以字面量 民 开头,re 自带前缀优化)。

顺带把 2b/3/3b 段的 4 个正则从 extract_era_years 内提到模块级:只编译一次,且差分
测试能取到它们。

验证:
- 全字段快照比对:sample.txt 1766 / sample_2.txt 20188 / test/sample.txt 14 /
  test/sample_crosscheck.txt 11,全部 19 个字段逐条一致,零差异
- pytest 260 passed(新增 TestFirstCharGate 3 条)、summary.py 96/96
- 速度:sample_2.txt 8.53s → 2.80s(3.05x)、sample.txt 0.64s → 0.21s(2.99x)
- 3.8.10 下编译通过(部署目标)
- 变异测试:手动砍掉预筛里一个首字符后,差分测试立即失败并指出漏掉的 "万历二十三年"
  —— 证明这道护栏真的能挡住坏的预筛

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 17:14:13 +08:00

1051 lines
48 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import re
import pandas as pd
from opencc import OpenCC
from era_data import dynasty_alias, KNOWN_ERAS, VARIANT_MAP, TEMPLE_ALIASES, ERA_ALIASES
cc = OpenCC('t2s')
def normalize_chars(text):
"""OpenCC 之外的异体字/俗字归一化。文本与 CSV 两侧必须走同一归一化,保证一致。"""
for variant, canonical in VARIANT_MAP.items():
text = text.replace(variant, canonical)
return text
# === 中文数字转阿拉伯数字 ===
def chinese_to_num(text):
text = text.replace("卅", "三十").replace("卌", "四十").replace("拾", "十")
if text in ["元", "元年"]:
return 1
chinese_numerals = {
'零': 0, '〇': 0, '○': 0,
'一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9,
'十': 10, '百': 100, '千': 1000,
'壹': 1, '貳': 2, '贰': 2, '叁': 3, '肆': 4, '伍': 5, '陸': 6, '陆': 6, '柒': 7, '捌': 8, '玖': 9, '拾': 10,
'卄': 20, '廿': 20, '卅': 30, '卌': 40,
}
if text in chinese_numerals:
return chinese_numerals[text]
if '千' in text:
parts = text.split('千')
total = chinese_to_num(parts[0]) * 1000 if parts[0] else 1000
if len(parts) > 1 and parts[1]:
total += chinese_to_num(parts[1])
return total
if '百' in text:
parts = text.split('百')
total = chinese_to_num(parts[0]) * 100 if parts[0] else 100
if len(parts) > 1 and parts[1]:
total += chinese_to_num(parts[1])
return total
if '十' in text:
parts = text.split('十')
total = 0
if parts[0] == '':
total += 10
else:
total += chinese_to_num(parts[0]) * 10
if len(parts) > 1 and parts[1]:
total += chinese_to_num(parts[1])
return total
if '廿' in text or '卄' in text:
return 20 + (chinese_numerals.get(text[-1], 0) if len(text) > 1 else 0)
if '卅' in text:
return 30 + (chinese_numerals.get(text[-1], 0) if len(text) > 1 else 0)
# 混合数字(如"二零〇一")
total = 0
for ch in text:
total = total * 10 + chinese_numerals.get(ch, int(ch) if ch.isdigit() else 0)
return total
# 朝代别名见 era_data.py
def normalize_dynasty_name(dynasty):
if not dynasty:
return dynasty
# 先将繁体转简体
simplified = cc.convert(dynasty)
# 查别名表(简体)
for prefix, true_name in dynasty_alias.items():
if simplified.startswith(prefix):
return true_name
# 直接匹配朝代字典(简体)
for known_dynasty in era_dict.keys():
if simplified.startswith(known_dynasty) or known_dynasty.startswith(simplified):
return known_dynasty
return simplified
# === 年号表 ===
def _cc(s):
"""CSV 单元格归一化:空值→'',其余走 OpenCC 繁转简 + 异体字归一"""
if pd.isna(s):
return ''
return normalize_chars(cc.convert(str(s)))
era_df = pd.read_csv("中国年号.csv")
era_df["年号"] = era_df["年号"].map(_cc)
era_df["所属朝代"] = era_df["所属朝代"].map(_cc)
era_df["名号"] = era_df["名号"].map(_cc)
def _safe_span(v):
"""CSV 使用年数列(可能为空或非数字)"""
try:
return int(float(v))
except (TypeError, ValueError):
return None
era_dict = {}
# 年号使用年数:用于干支/年数结果的使用期校验(如庆元仅6年,期内无庚子)
era_span = {}
for _, row in era_df.iterrows():
dynasty, era, start = row["所属朝代"], row["年号"], int(row["公元纪年"])
if not era:
continue
era_dict.setdefault(dynasty, {})[era] = start
span = _safe_span(row["使用年数"])
if span is not None:
era_span.setdefault(dynasty, {}).setdefault(era, span)
# 有效年号白名单 = 手工精选 ∪ CSV 年号 ∪ 年号别名键(自动补全:白名单不应小于数据表)。
# 排除:民国(有专门匹配段)、带括号的年号(如"(后元)",防止与东汉"中元"等歧义)
_csv_eras = {
e for e in era_df["年号"]
if e and e != '民国' and '(' not in e and '(' not in e and 2 <= len(e) <= 8
}
KNOWN_ERAS_ALL = KNOWN_ERAS | _csv_eras | set(ERA_ALIASES)
def _norm_era(era):
"""年号归一:OpenCC 繁转简后套年号别名(崇正→崇祯),返回规范写法"""
era = cc.convert(era)
return ERA_ALIASES.get(era, era)
# === 庙号表 ===
# 名号列(如"高宗""太宗")表示整个在位期,公元取该名号下最早的年份(即位首年)。
# "代宗(景帝)" 等带括号的写法拆成两种叫法都收录;只收祖/宗/帝/王/后/皇结尾(或帝开头)的名号,
# 排除"拖雷""孺子婴"等非庙号条目。
def _is_temple_name(name):
return len(name) >= 2 and (name.startswith('帝') or name.endswith(('祖', '宗', '帝', '王', '后', '皇')))
temple_dict = {}
# 庙号在位年数(该名号下各年号使用年数之和):用于名号+年数结果的使用期校验
temple_span = {}
for _, row in era_df.iterrows():
name, dynasty, start = row["名号"], row["所属朝代"], int(row["公元纪年"])
if not name or not dynasty:
continue
for part in re.split(r'[()()]', name):
part = part.strip()
if not part or not _is_temple_name(part):
continue
part = TEMPLE_ALIASES.get(part, part)
if not _is_temple_name(part):
continue
temple_dict.setdefault(dynasty, {}).setdefault(part, start)
span = _safe_span(row["使用年数"])
if span is not None:
d = temple_span.setdefault(dynasty, {})
d[part] = d.get(part, 0) + span
# === 干支纪年 ===
_tiangan = '甲乙丙丁戊己庚辛壬癸'
_dizhi = '子丑寅卯辰巳午未申酉戌亥'
_ganzhi_to_offset = {}
for i in range(60):
tg = _tiangan[i % 10]
dz = _dizhi[i % 12]
_ganzhi_to_offset[tg + dz] = i # 0-based: 甲子=0
def ganzhi_to_era_year(ganzhi: str) -> int:
"""干支转年数(1-based):甲子=1, 乙丑=2, ..., 癸亥=60"""
return _ganzhi_to_offset.get(ganzhi, -1) + 1
def gregorian_to_ganzhi(year: int) -> str:
"""按项目约定将公元年转换为干支(公元4年为甲子)。"""
offset = (year - 4) % 60
return _tiangan[offset % 10] + _dizhi[offset % 12]
_gz_pair = '[' + _tiangan + '][' + _dizhi + ']'
# 干支纪年中的年号组(已知年号按长度降序;单字年号加前瞻(?![地支]),
# 防止单字年号"丙"吞掉天干"丙午"中的"丙")
_gz_era_group = r'(?P<era>' + '|'.join(
(re.escape(e) + (r'(?![子丑寅卯辰巳午未申酉戌亥])' if len(e) == 1 else ''))
for e in sorted(KNOWN_ERAS_ALL, key=len, reverse=True)
) + r')'
# === 数字转中文 ===
def arabic_to_chinese_year(num: int) -> str:
"""公元年份转中文(逐位体):1662 → 一六六二,-140 → -一四〇。
公元年份读作逐位数字,故不适用计数体规则(见 arabic_to_chinese_count)。
返回值不带"年"单位——字段"公元中文"与"年数中文"一样只给数字写法,单位由前端拼接。"""
digits = "〇一二三四五六七八九"
if num < 0:
return '-' + ''.join(digits[int(d)] for d in str(abs(num)))
return ''.join(digits[int(d)] for d in str(num))
# === 年数(纪年计数)转中文 ===
# 规则(纪年计数体,用于"年数中文"字段;只返回数字写法本身,单位"年"由前端自行拼接):
# 1. 首位 1 → "元"(元年):民国元年、康熙元年、建元元年。
# 2. 10-19 → 十、十一…十九(不写"一十")。
# 3. 20-49 且十位即最高位 → 廿/卅/卌 缩写:20→廿、21→廿一、26→廿六、35→卅五、40→卌、49→卌九。
# 更高位用全称(120→一百二十,不写"一百廿")。
# 4. 50 以上用全称:五十、六十一、九十九、一百、二百五十、一千二百三十四。
# 5. 空位补"〇"(不用"零"):105→一百〇五、1005→一千〇五、1050→一千〇五十;
# 连续空位只补一个〇(10005→一万〇五)。
# 6. 万/亿 分级同普通话:一万、十万、一百万、一千〇五十万。
_CN_UNITS = '〇一二三四五六七八九'
_CN_TENS_ABBR = {2: '廿', 3: '卅', 4: '卌'}
def _cn_under_10000(n: int, leading: bool) -> str:
"""1 ≤ n ≤ 9999 的计数体中文;leading=True 表示该段是整个数的最高位段"""
digits = [n // 1000, n % 1000 // 100, n % 100 // 10, n % 10]
names = ['千', '百', '十', '']
out = []
zero_pending = False
for idx, (d, name) in enumerate(zip(digits, names)):
if d == 0:
if out:
zero_pending = True
continue
if zero_pending:
out.append('〇')
zero_pending = False
if name == '十' and leading and idx == 2 and not out and d < 5:
# 最高位就是十位:10-19 → 十…十九(不写"一十");20-49 → 廿/卅/卌 缩写
out.append('十' if d == 1 else _CN_TENS_ABBR[d])
else:
out.append(_CN_UNITS[d] + name)
return ''.join(out)
def arabic_to_chinese_count(num: int) -> str:
"""年数(纪年计数)转中文:1→元、20→廿、26→廿六、105→一百〇五、1050→一千〇五十。
与 arabic_to_chinese_year 的逐位体不同,本函数按计数体读法书写。"""
if num < 0:
return '-' + arabic_to_chinese_count(-num)
if num == 0:
return '〇'
if num == 1:
return '元'
if num < 10000:
return _cn_under_10000(num, leading=True)
if num < 100000000:
high, low = divmod(num, 10000)
out = _cn_under_10000(high, leading=True) + '万'
else:
high, low = divmod(num, 100000000)
out = arabic_to_chinese_count(high) + '亿'
if low:
if low < 1000: # 低位段最高位低于"千"→ 中间有空位,补一个〇
out += '〇'
out += _cn_under_10000(low, leading=False)
return out
# === 返回字段全量化 ===
# 每条结果都返回同一套键(顺序固定),无内容的字段给空值:
# 字符串 ""、数值 None、布尔 False、列表 []——前端/后端按对象固定字段接收参数时不会"找不到键"。
_RESULT_FIELDS = (
"原文", "原文(简体)", "类型",
"朝代", "年号", "庙号",
"年数", "年数中文", "干支",
"公元", "公元中文",
"位置",
"干支不符", "干支推算", "超出使用期", "多候选", "候选",
)
_RESULT_DEFAULTS = {
"原文": "", "原文(简体)": "", "类型": "",
"朝代": "", "年号": "", "庙号": "",
"年数": None, "年数中文": "", "干支": "",
"公元": None, "公元中文": "",
"位置": None,
"干支不符": False, "干支推算": None, "超出使用期": False, "多候选": False,
}
def _finalize_result(result: dict) -> dict:
"""按 _RESULT_FIELDS 固定顺序输出全字段(缺的键补空值;列表字段各自独立拷贝)"""
out = {}
for key in _RESULT_FIELDS:
if key == "候选":
out[key] = list(result.get("候选") or [])
else:
out[key] = result.get(key, _RESULT_DEFAULTS[key])
return out
# === 古代年号正则 ===
# 从 era_dict 构建朝代匹配组(按长度降序排列,优先匹配长朝代名如"北宋"而非"北")
# 额外添加常见但不在CSV中的朝代简称(如"汉")
# 合并 temple_dict 的键:周/秦等年号列全空的朝代也必须有匹配能力
_extra_dynasty_names = ['汉', '晋', '宋', '周']
_dynasty_names = sorted(
list(era_dict.keys()) + list(temple_dict.keys())
+ [d for d in _extra_dynasty_names if d not in era_dict and d not in temple_dict],
key=len, reverse=True
)
_dynasty_alt = '|'.join(re.escape(d) for d in _dynasty_names)
# 常见朝代前缀(大、皇等),用于匹配"大唐""大清"等
_dynasty_prefix = '大皇前后胡'
# 非元年数字字符(排除元,避免歧义:建元元年中第一个元是年号的一部分)。
# 含阿拉伯/全角数字("康熙20年""乾隆20年")与大写数字("道光贰拾年")。
_year_digits = '〇零一二三四五六七八九十廿卄卅卌' + '01234567890123456789' + '壹貳贰叁肆伍陸陆柒捌玖拾'
# 已知年号白名单见 era_data.py
# 预编译已知年号正则片段(按长度降序,优先匹配长年号如"太平兴国"而非"太平")
_known_era_alt = '|'.join(sorted(KNOWN_ERAS_ALL, key=len, reverse=True))
_known_era_group = r'(?P<era>' + _known_era_alt + r')'
_yd = '[' + _year_digits + ']'
# 年份后缀:"年"字可省(碑谱/契约常见:"民国二十六""万历三十六",也支持后接干支
# "光绪二十六庚子")。省"年"时用**白名单前瞻**兜住误匹配——数字后只有跟下列内容才算年份:
# ① 族谱常用动词:生卒殁殇死终葬娶适嫁亡逝享寿嗣立
# ② 标点/括号/引号/空白/行尾:,。、;:,.;:!?!?""''()()[]【】{}《》<> 及 \s、$
# ③ 天干(甲乙丙丁戊己庚辛壬癸):后面就是干支,由各段自行并入并校验
# 于是这些都不再当年份:康熙六月(月)、万历三大征(大)、康熙四十二卷(卷/42卷)、
# 张景福三子(子/排行)、永明五十二都(都/地名)、万历二十万(万)、光绪二十六日(日)、
# 康熙二十天(天/20天)。白名单也天然挡住正则回溯:回退到更短数字串时其后仍是数字,不在表内。
_bare_year_after = (
'生卒殁殤殇死终終葬娶适適嫁亡逝享寿壽嗣立'
+ ',。、;:,.;:!?!?“”"\'‘’()()[]【】{}《》<>'
+ _tiangan
)
_year_suffix = r'(?:年|(?=[' + re.escape(_bare_year_after) + r'\s]|$))'
# === 首字符预筛 ===
# 大交替串((?:A|B|...) 里塞了几百个年号/朝代名)在 CPython 的 re 里没有首字符预筛:
# 引擎在每个位置都要把全部分支逐个试完才移向下一位。实测 1MB 文本上这类扫描占
# convert_text 总耗时的 82%,而其中多数模式只命中 0~134 处。
#
# 做法:在模式开头加一个**零宽前瞻** (?=[首字集合])。前瞻不消耗字符,只在当前位置断言
# "下一个字符属于该集合",断言失败就放弃这个起点。
#
# 结果为什么不变(这是结构性保证,不是经验之谈):集合从与模式**同一份源数据**推导,
# 覆盖了模式第一个被消耗字符的所有来源,因此只可能拒绝「原模式根本不可能开始匹配」的
# 位置 → 匹配集合完全相同 → 捕获组相同 → extract_era_years 后续执行序列一致 → 输出
# 逐字段一致。集合随 CSV/白名单变化自动同步(都从 _dynasty_names / KNOWN_ERAS_ALL 推导)。
#
# 适用前提(新增模式时必须逐条核对):
# ① 模式至少消耗 1 个字符、不能匹配空串——否则前瞻会挡掉零宽匹配;
# ② 第一个被消耗的字符只可能来自朝代名或年号名。
# 故以下模式**不加**预筛:
# - 公元段 (?:公元|西元|公历)?...:前缀整个可选,不满足前提①;
# - 裸干支段:以干支对开头,不满足前提②;
# - 民国段:以字面量 民 开头,re 自带前缀优化,加了也无收益。
# 三种预筛对应"朝代组"的三种写法:
# _GATE_DYNASTY 朝代组必选((?P<dynasty>(?:...|...)) 后无 `?`)
# _GATE_DYNASTY_OPT 朝代组可选(用 _dynasty_opt,见 2b/2c 段)
# _GATE_ERA 朝代组为空((?P<dynasty>) 后直接贴年号)
def _first_char_gate(names):
"""从名称集合推导首字符预筛前瞻。字符类里 ]、反斜杠、^、- 四个字符需转义。"""
chars = {n[0] for n in names if n}
body = ''.join('\\' + c if c in ']\\^-' else c for c in sorted(chars))
return '(?=[' + body + '])'
_dynasty_first = list(_dynasty_names) + list(_dynasty_prefix)
_GATE_DYNASTY = _first_char_gate(_dynasty_first)
_GATE_ERA = _first_char_gate(KNOWN_ERAS_ALL)
_GATE_DYNASTY_OPT = _first_char_gate(_dynasty_first + list(KNOWN_ERAS_ALL))
# 差分测试用:登记 (预筛, 未加预筛的模式串)。test_parser.py 逐个比对两者在语料上的
# 匹配序列(含捕获组),防止预筛与模式脱节——例如将来改了模式首部却忘了改预筛。
_GATED_PATTERNS = []
def _compile_gated(pattern, gate):
"""编译加过首字符预筛的模式,并登记 (预筛, 原串) 供差分测试比对。"""
compiled = re.compile(gate + pattern)
_GATED_PATTERNS.append((compiled, pattern, gate))
return compiled
def _build_pattern(with_dynasty=True):
"""构建年号正则:已知年号优先 + 通用兜底(仅限有朝代前缀时)"""
if with_dynasty:
# 朝代前缀模式:加(?<![一-鿿])防止"元"在"开元"中被当朝代
dynasty_part = r'(?P<dynasty>(?:(?<![一-鿿])[' + _dynasty_prefix + r'](?:' + _dynasty_alt + r'))|(?:(?<![一-鿿])' + _dynasty_alt + r'))'
boundary = ''
gate = _GATE_DYNASTY # 朝代组必选 → 首个消耗字符只能是朝代名首字
else:
dynasty_part = r'(?P<dynasty>)'
# 无朝代模式不加边界(允许年号跟在其他汉字后面,如"永乐大帝在永乐三年")
boundary = ''
gate = _GATE_ERA # 朝代组为空 → 首个消耗字符是年号首字
if with_dynasty:
# 有朝代前缀:年号后直接跟年份数字,无需额外约束
era_year = _known_era_group + r'(?P<year>' + _yd + r'+)' + _year_suffix
era_yuan = _known_era_group + r'元年'
patterns = [
_compile_gated(dynasty_part + boundary + era_year, gate),
_compile_gated(dynasty_part + boundary + era_yuan, gate),
# 通用兜底(2-8字)
_compile_gated(dynasty_part + boundary + r'(?P<era>[一-鿿]{2,8})' + r'(?P<year>' + _yd + r'+)年', gate),
_compile_gated(dynasty_part + boundary + r'(?P<era>[一-鿿]{2,8})元年', gate),
]
else:
# 无朝代前缀:年号后必须紧跟年份数字(前瞻断言),防止"永乐大帝"误匹配
_year_ahead = r'(?=' + _yd + r'+' + _year_suffix + r'|元年)'
era_year = _known_era_group + _year_ahead + r'(?P<year>' + _yd + r'+)' + _year_suffix
era_yuan = _known_era_group + _year_ahead + r'元年'
patterns = [
_compile_gated(dynasty_part + boundary + era_year, gate),
_compile_gated(dynasty_part + boundary + era_yuan, gate),
]
return patterns
# 无朝代模式只匹配已知年号(避免误匹配如"国"被当年号)
_patterns_no_dynasty = _build_pattern(with_dynasty=False)
# 有朝代模式:已知年号 + 通用兜底
_patterns_dynasty = _build_pattern(with_dynasty=True)
# === 2c. 干支+(公元对照)(如"清同治壬申(1872)十一月十四生") ===
# 干支纪年旁直接给出公元年份:合并为一条结果、对照校验干支。
# 除括号外还支持:"公元"标记(清同治壬申公元1872年)、横线(壬申-1872)、
# 无分隔连写(壬申1872年)。其余内容(如"十一月十四")不并入原文。
# 置于公元段之前(见 extract_era_years),避免对照年份被单独摘出成公元纪年。
_dynasty_opt = (r'(?P<dynasty>(?:(?<![一-鿿])[' + _dynasty_prefix + r'](?:' + _dynasty_alt + r'))'
r'|(?:(?<![一-鿿])' + _dynasty_alt + r'))?')
# 公元对照年份:阿拉伯/全角/中文数字 3-4 位,可带"年"字。
# 两端加数字边界:防止从更长数字串里截出 3-4 位当公元(数据里的标注 ID "{SPZ146356}"
# 会截出 1463、电话 13800138000 会截出 1380、编号 1987654 会截出 1987)。
_gy_edge = '0-90-9〇零一二三四五六七八九'
_gz_gy_year = (r'(?<![' + _gy_edge + r'])' + r'[〇零○O一二三四五六七八九\d0-9]{3,4}'
+ r'(?![' + _gy_edge + r'])')
# 干支与对照年份之间的连接方式(按优先级):
# 括号(1872)/ 公元标记(,公元1872年)/ 横线(-1872)/ 无分隔连写(壬申1872年)
# 空白只允许空格/制表符([ \t]*),不允许换行——行尾的 \n 不得被吞进原文
# 公元标记支持 公元/西元/公历 三种写法
_gz_gy_sep = (
r'(?:'
r'(?:[((][ \t]*(?P<gy_year>' + _gz_gy_year + r')[ \t]*年?[))])'
r'|(?:[,,、]?[ \t]*(?:公元|西元|公历)[ \t]*(?P<gy_year2>' + _gz_gy_year + r')[ \t]*年?)'
r'|(?:[-—–-~][ \t]*(?P<gy_year3>' + _gz_gy_year + r')[ \t]*年?)'
r'|(?P<gy_year4>' + _gz_gy_year + r')[ \t]*年?'
r')'
)
# 干支前的修饰词(如"光绪岁次辛丑""岁在壬戌"):碑刻/谱牒常见
_gz_mod = r'(?:岁[次在])?'
_ganzhi_gongyuan_re = _compile_gated(
_dynasty_opt
+ _gz_era_group
+ _gz_mod
+ r'(?P<ganzhi>' + _gz_pair + r')(?:年)?'
+ _gz_gy_sep,
_GATE_DYNASTY_OPT, # _dynasty_opt 可空 → 朝代首字与年号首字并集
)
# 裸干支+(对照):无年号可锚定(如"壬申(1872)"),公元取对照值
_ganzhi_bare_gongyuan_re = re.compile(
r'(?P<ganzhi>' + _gz_pair + r')(?:年)?'
+ _gz_gy_sep
)
# === 2b / 3 / 3b 段的正则 ===
# 这些原先在 extract_era_years 里每次调用都重新编译、且无法被差分测试取到;
# 提到模块级后只编译一次,并统一登记进 _GATED_PATTERNS。
# 2b. 年号+公元(如"清康熙公元一六八七年丁卯"):年号后直接给出公元年份,
# 年数 = 公元 - 年号起始 + 1,紧跟的干支用于校验年份一致性。
_era_gongyuan_re = _compile_gated(
r'(?P<dynasty>(?:(?<![一-鿿])[' + _dynasty_prefix + r'](?:' + _dynasty_alt + r'))|(?:(?<![一-鿿])' + _dynasty_alt + r'))?'
+ r'(?P<era>' + _known_era_alt + r')'
+ r'(?:公元|西元|公历)\s*(?P<gy_year>' + _gz_gy_year + r')\s*年?'
+ r'(?P<gy_ganzhi>' + _gz_pair + r')?',
_GATE_DYNASTY_OPT, # 朝代前缀可空 → 并集
)
# 3-A. 有朝代前缀的干支纪年(如"清雍正乙巳");年号组 _gz_era_group 见模块级定义
_ganzhi_dynasty_re = _compile_gated(
r'(?P<dynasty>(?:(?<![一-鿿])[' + _dynasty_prefix + r'](?:' + _dynasty_alt + r'))|(?:(?<![一-鿿])' + _dynasty_alt + r'))'
+ _gz_era_group
+ _gz_mod
+ r'(?P<ganzhi>' + _gz_pair + r')(?:年)?',
_GATE_DYNASTY, # 朝代组必选 → 朝代名首字
)
# 3-B. 无朝代前缀的干支纪年(如"嘉庆癸亥"),年号≥2字符避免天干误匹配
_long_era_names = [e for e in KNOWN_ERAS_ALL if len(e) >= 2]
_long_era_alt = '|'.join(sorted(_long_era_names, key=len, reverse=True))
_ganzhi_no_dynasty_re = _compile_gated(
r'(?P<dynasty>)'
+ r'(?P<era>' + _long_era_alt + r')'
+ _gz_mod
+ r'(?P<ganzhi>' + _gz_pair + r')(?:年)?',
_GATE_ERA, # 朝代组为空 → 年号首字
)
# 3b. 庙号纪年(如"宋高宗""清圣祖",指整个在位期,公元=即位首年)
# 庙号必须带朝代前缀("高宗"在唐/宋/清均有,单独出现有歧义)
_temple_names = sorted({t for d in temple_dict for t in temple_dict[d]}, key=len, reverse=True)
_temple_alt = '|'.join(re.escape(t) for t in _temple_names)
_temple_pattern_re = _compile_gated(
r'(?P<dynasty>(?:(?<![一-鿿])[' + _dynasty_prefix + r'](?:' + _dynasty_alt + r'))|(?:(?<![一-鿿])' + _dynasty_alt + r'))'
+ r'(?P<temple>' + _temple_alt + r')'
# 后面紧跟"X年/元年"时不按庙号匹配,交给古代纪年按年号解析(如"元泰定三年")
+ r'(?!(?:' + _yd + r'+年|元年))',
_GATE_DYNASTY, # 朝代组必选 → 朝代名首字
)
# === 主函数 ===
def extract_era_years(text):
# 标注符号([]、【】)不参与匹配,但保留在文本中——位置偏移以原始文本为准
# (含换行/空格/标注),仅在收尾时把标注符号从"原文"字段剔除
original_text = text
# 码点→UTF-16码元 映射:JS 前端字符串索引按 UTF-16 计,补充平面字符
# (如 𫓧 U+2B4E7)在 Python 算 1 个码点、在 JS 算 2 个码元,会导致其后位置漂移。
# 返回的"位置"统一用 UTF-16 码元,保证前端 text.slice(start, end) == 原文。
_utf16_index = []
_utf16_pos = 0
for ch in text:
_utf16_index.append(_utf16_pos)
_utf16_pos += 2 if ord(ch) > 0xFFFF else 1
_utf16_index.append(_utf16_pos)
simplified_text = normalize_chars(cc.convert(text))
results = []
# 覆盖集:先匹配的段落占据位置,后续段落跳过重叠(防止同一文本被多段重复摘出)
covered = set()
# 民国纪年的公元对照仅支持括号形式("民国三十八年(1949)"):
# 民国X年已是确定年份,逗号/横线/连写分隔多为并列句(如"民国三十八年,公元二〇二四年"是两个日期),不合并
_mg_gy_paren = r'(?:[((][ \t]*(?P<gy_year>' + _gz_gy_year + r')[ \t]*年?[))])?'
# === 1. 民国 ===
minguo_pattern = re.compile(
r'民[国國]\s*([〇零一二三四五六七八九十百廿卄卅卌元\d0-9壹貳贰叁肆伍陸陆柒捌玖拾]+)\s*'
+ _year_suffix
+ _mg_gy_paren
)
for m in minguo_pattern.finditer(simplified_text):
year_text = m.group(1)
year_num = chinese_to_num(year_text)
# 公元对照(如"民国三十八年(1949)"):取对照值为公元,不一致时标注
gy_year = None
for name in ("gy_year", "gy_year2", "gy_year3", "gy_year4"):
if name in m.groupdict() and m.group(name):
gy_year = chinese_to_num(m.group(name))
break
if gy_year is not None and not (1000 <= gy_year <= 2100):
continue
gregorian = gy_year if gy_year is not None else 1911 + year_num
# 年数上界:不存在"民国2000年"(→3911)、"民国146356"(→148267)这类写法,
# 换算出的公元超出支持窗口(1000–2100)直接不认——与公元段同一窗口约定
if not (1000 <= gregorian <= 2100):
continue
start, end = m.start(), m.end()
# 尾随干支校验(如"民国三十八年己丑"):并入原文同一单位(同公元段逻辑)
ganzhi = simplified_text[end:end + 2]
if ganzhi in _ganzhi_to_offset:
end += 2
else:
j = end
while j < len(simplified_text) and simplified_text[j] in '[]【】':
j += 1
ganzhi = simplified_text[j:j + 2]
if ganzhi not in _ganzhi_to_offset:
ganzhi = None
covered.update(range(start, end))
raw_fan = original_text[start:end]
result = {
"原文": raw_fan,
"原文(简体)": simplified_text[start:end],
"朝代": "民国",
"年号": "民国纪年",
"年数": year_num,
"公元": gregorian,
"位置": {"起始": start, "结束": end},
"类型": "民国纪年",
}
if ganzhi:
result["干支"] = ganzhi
if gregorian_to_ganzhi(gregorian) != ganzhi:
result["干支不符"] = True
if gy_year is not None and gy_year != 1911 + year_num:
result["干支不符"] = True
result["干支推算"] = 1911 + year_num
results.append(result)
# 1b. 民国+干支纪年(如"民国丁卯""民国甲戌"),支持(公元对照)合并
minguo_gz = re.compile(
r'民[国國]\s*(?P<ganzhi>' + _gz_pair + r')(?:年)?'
+ _mg_gy_paren
)
for m in minguo_gz.finditer(simplified_text):
ganzhi = m.group("ganzhi")
gz_offset = _ganzhi_to_offset.get(ganzhi, -1)
if gz_offset < 0:
continue
# 民国元年=1912, base_gz = (1912-4)%60 = 8 (己酉)
base_gz = (1912 - 4) % 60
year_in_era = ((gz_offset - base_gz) % 60) + 1
computed = 1912 + year_in_era - 1
# 公元对照(如"民国壬申(1932)"):取对照值为公元,不一致时标注
gy_year = None
for name in ("gy_year", "gy_year2", "gy_year3", "gy_year4"):
if name in m.groupdict() and m.group(name):
gy_year = chinese_to_num(m.group(name))
break
if gy_year is not None and not (1000 <= gy_year <= 2100):
continue
minguo_gy = gy_year if gy_year is not None else computed
if not (1000 <= minguo_gy <= 2100):
continue
start, end = m.start(), m.end()
covered.update(range(start, end))
raw_fan = original_text[start:end]
result = {
"原文": raw_fan,
"原文(简体)": m.group(0),
"朝代": "民国",
"年号": "民国纪年",
"年数": year_in_era,
"干支": ganzhi,
"公元": gy_year if gy_year is not None else computed,
"位置": {"起始": start, "结束": end},
"类型": "民国纪年(干支)",
}
if gy_year is not None and gy_year != computed:
result["干支不符"] = True
result["干支推算"] = computed
results.append(result)
# === 2b. 年号+公元(如"清康熙公元一六八七年丁卯") ===
# 年号后直接给出公元年份:年数=公元-年号起始+1,与"年号+年数"公式自洽;
# 紧跟的干支用于校验年份一致性。置于公元段之前,合并结果优先、避免重复摘出。
# 正则见模块级 _era_gongyuan_re(含首字符预筛)
for m in _era_gongyuan_re.finditer(simplified_text):
start, end = m.start(), m.end()
if any(pos in covered for pos in range(start, end)):
continue
era = _norm_era(m.group("era"))
year_num = chinese_to_num(m.group("gy_year"))
if not (1000 <= year_num <= 2100):
continue
dynasty = normalize_dynasty_name(m.group("dynasty") or "")
base = None
multi = []
if dynasty:
for d, eras in era_dict.items():
if dynasty in d or d in dynasty:
if era in eras:
base = eras[era]
dynasty = d
break
if base is None:
for d, eras in era_dict.items():
if era in eras:
multi.append((d, eras[era]))
if multi:
dynasty, base = multi[0]
if base is None:
continue
covered.update(range(start, end))
year_in_era = year_num - base + 1
ganzhi = m.group("gy_ganzhi")
result = {
"原文": original_text[start:end],
"原文(简体)": m.group(0),
"朝代": dynasty,
"年号": era,
"年数": year_in_era,
"公元": year_num,
"位置": {"起始": start, "结束": end},
"类型": "年号+公元",
}
if len(multi) > 1:
result["多候选"] = True
result["候选"] = [list(c) for c in multi]
# 公元超出年号使用期(如"康熙公元1950年")→ 标注,值保留
span = era_span.get(dynasty, {}).get(era)
if span is not None and year_num > base + span - 1:
result["超出使用期"] = True
# 紧跟的干支与公元年份不符(如"一六八九年丁卯",1689应为己巳)→ 标注
if ganzhi:
result["干支"] = ganzhi
if gregorian_to_ganzhi(year_num) != ganzhi:
result["干支不符"] = True
results.append(result)
# === 2c. 干支+(公元对照)(如"清同治壬申(1872)十一月十四生") ===
# 干支纪年旁直接给出公元对照:合并为一条结果,用对照校验干支,紧跟的月日并入字段。
# 置于公元段之前,避免"(1872)"被单独摘出成公元纪年(同 2b 的合并优先策略)。
def _process_ganzhi_gongyuan(m):
raw_dynasty = m.group("dynasty") if "dynasty" in m.groupdict() else ""
dynasty = normalize_dynasty_name(raw_dynasty or "")
era = _norm_era(m.group("era")) if "era" in m.groupdict() and m.group("era") else None
ganzhi = m.group("ganzhi")
# 对照年份可能来自四种连接方式之一(gy_year/gy_year2/gy_year3/gy_year4)
gy_year = next(
chinese_to_num(m.group(name))
for name in ("gy_year", "gy_year2", "gy_year3", "gy_year4")
if m.group(name)
)
if ganzhi not in _ganzhi_to_offset or not (1000 <= gy_year <= 2100):
return
start, end = m.start(), m.end()
if any(pos in covered for pos in range(start, end)):
return
# 查找年号基准年(与干支纪年段同逻辑:先精确朝代,后全量候选取首个并标注歧义)
base = None
multi = []
if dynasty and era:
for d, eras in era_dict.items():
if dynasty in d or d in dynasty:
if era in eras:
base = eras[era]
dynasty = d
break
if base is None and era:
for d, eras in era_dict.items():
if era in eras:
multi.append((d, eras[era]))
if multi:
dynasty, base = multi[0]
covered.update(range(start, end))
result = {
"原文": original_text[start:end],
"原文(简体)": m.group(0),
"干支": ganzhi,
"公元": gy_year,
"位置": {"起始": start, "结束": end},
"类型": "干支纪年",
}
if base is not None:
result["朝代"] = dynasty
result["年号"] = era
# 干支60年循环推算(同干支纪年段公式);与对照不一致时标注,公元仍取对照值
base_gz = (base - 4) % 60
year_in_era = ((_ganzhi_to_offset[ganzhi] - base_gz) % 60) + 1
result["年数"] = year_in_era
computed = base + year_in_era - 1
if computed != gy_year:
result["干支不符"] = True
result["干支推算"] = computed
if len(multi) > 1:
result["多候选"] = True
result["候选"] = [list(c) for c in multi]
span = era_span.get(dynasty, {}).get(era)
if span is not None and gy_year > base + span - 1:
result["超出使用期"] = True
elif gregorian_to_ganzhi(gy_year) != ganzhi:
# 裸干支+对照:无年号可锚定,直接用公元年份的干支校验(同公元段约定)
result["干支不符"] = True
results.append(result)
for m in _ganzhi_gongyuan_re.finditer(simplified_text):
_process_ganzhi_gongyuan(m)
for m in _ganzhi_bare_gongyuan_re.finditer(simplified_text):
_process_ganzhi_gongyuan(m)
# === 2. 公元(允许无"年"字)===
gongyuan_pattern = re.compile(
r'(?:公元|西元|公历)?\s*(' + _gz_gy_year + r')\s*(?:(.*?))?\s*(年)?'
)
for m in gongyuan_pattern.finditer(simplified_text):
start, end = m.start(), m.end()
if any(pos in covered for pos in range(start, end)):
continue
year_text = m.group(1)
year_num = chinese_to_num(year_text)
if not (1000 <= year_num <= 2100):
continue
# 公元年后紧跟的干支(如"公元一九六〇年庚子"):无年号可锚定时,
# 干支就是年份的干支,紧邻时并入同一日期单位——保证 位置[start:end]==原文,
# 前端可按范围整体替换;隔标注符号时(【公元一九六〇年】庚子)干支只摘入字段校验
ganzhi = simplified_text[end:end + 2]
if ganzhi in _ganzhi_to_offset:
end += 2
else:
j = end
while j < len(simplified_text) and simplified_text[j] in '[]【】':
j += 1
ganzhi = simplified_text[j:j + 2]
if ganzhi not in _ganzhi_to_offset:
ganzhi = None
covered.update(range(start, end))
raw_fan = original_text[start:end]
era_type = "公元纪年(中华人民共和国)" if year_num >= 1949 else "公元纪年(近代/其他)"
result = {
"原文": raw_fan,
"原文(简体)": simplified_text[start:end],
"类型": era_type,
"公元": year_num,
"位置": {"起始": start, "结束": end},
}
if ganzhi in _ganzhi_to_offset:
result["干支"] = ganzhi
if gregorian_to_ganzhi(year_num) != ganzhi:
result["干支不符"] = True
results.append(result)
# === 3. 干支纪年(如"清雍正乙巳十一月十二午时生")===
def _process_ganzhi_match(m):
"""处理干支纪年匹配"""
raw_dynasty = m.group("dynasty") if "dynasty" in m.groupdict() else ""
dynasty = normalize_dynasty_name(raw_dynasty or "")
era = _norm_era(m.group("era"))
ganzhi = m.group("ganzhi")
if ganzhi not in _ganzhi_to_offset:
return
# 查找朝代和基准年
base = None
multi = []
if dynasty:
for d, eras in era_dict.items():
if dynasty in d or d in dynasty:
if era in eras:
base = eras[era]
dynasty = d
break
if not base:
# 无朝代前缀:收集全部候选(如"大定"=北周581/金1161),取首个但标注歧义
for d, eras in era_dict.items():
if era in eras:
multi.append((d, eras[era]))
if multi:
dynasty, base = multi[0]
if not base:
return
# 干支60年循环:计算基准年的干支位置,再求偏移
base_gz = (base - 4) % 60 # 公元4年=甲子(0),所以偏移=4
gz_offset = _ganzhi_to_offset.get(ganzhi, 0) # 0-based
year_in_era = ((gz_offset - base_gz) % 60) + 1 # 1-based年数
gregorian = base + year_in_era - 1
start, end = m.start(), m.end()
if any(pos in covered for pos in range(start, end)):
return
covered.update(range(start, end))
raw_fan = original_text[start:end]
result = {
"原文": raw_fan,
"原文(简体)": m.group(0),
"朝代": dynasty,
"年号": era,
"年数": year_in_era,
"干支": ganzhi,
"公元": gregorian,
"位置": {"起始": start, "结束": end},
"类型": "干支纪年",
}
if len(multi) > 1:
result["多候选"] = True
result["候选"] = [list(c) for c in multi]
# 使用期校验:换算结果超出年号实际使用年数(如"庆元庚子"→1240,庆元仅1195–1200)。
# 值保留,仅标注,供人工判断(碑刻原数据常与年号实际期间不符)。
span = era_span.get(dynasty, {}).get(era)
if span is not None and gregorian > base + span - 1:
result["超出使用期"] = True
results.append(result)
# 模式A:有朝代前缀(如"清雍正乙巳");正则见模块级 _ganzhi_dynasty_re
for m in _ganzhi_dynasty_re.finditer(simplified_text):
_process_ganzhi_match(m)
# 模式B:无朝代前缀(如"嘉庆癸亥");正则见模块级 _ganzhi_no_dynasty_re
for m in _ganzhi_no_dynasty_re.finditer(simplified_text):
_process_ganzhi_match(m)
# === 3b. 庙号纪年(如"宋高宗""清圣祖",指整个在位期,公元=即位首年)===
# 庙号必须带朝代前缀("高宗"在唐/宋/清均有,单独出现有歧义)
# 正则见模块级 _temple_pattern_re
for m in _temple_pattern_re.finditer(simplified_text):
start, end = m.start(), m.end()
if any(pos in covered for pos in range(start, end)):
continue
dynasty = normalize_dynasty_name(m.group("dynasty") or "")
temple = m.group("temple")
base = None
if dynasty:
# 先精确匹配朝代,再模糊匹配(如"宋"→"南宋")
for d in temple_dict:
if d == dynasty and temple in temple_dict[d]:
dynasty, base = d, temple_dict[d][temple]
break
if base is None:
for d in temple_dict:
if (dynasty in d or d in dynasty) and temple in temple_dict[d]:
dynasty, base = d, temple_dict[d][temple]
break
if base is None:
continue
covered.update(range(start, end))
raw_fan = original_text[start:end]
results.append({
"原文": raw_fan,
"原文(简体)": m.group(0),
"朝代": dynasty,
"庙号": temple,
"公元": base,
"位置": {"起始": start, "结束": end},
"类型": "庙号纪年",
})
# === 4. 古代年号 ===
def _process_era_match(m, year_override=None):
"""处理一个年号正则匹配结果"""
raw_dynasty = m.group("dynasty") if "dynasty" in m.groupdict() else ""
dynasty = normalize_dynasty_name(raw_dynasty or "")
era = _norm_era(m.group("era"))
# 过滤误匹配:年号是单个数字字符(如"民国二十六年"中的"二")
if len(era) == 1 and era in '零一二三四五六七八九十廿卄卅卌':
return
# 过滤误匹配:年号全是数字汉字(如"元一九五二年"中的"一九五")
if era and all(ch in '零一二三四五六七八九十〇' for ch in era):
return
year_text = year_override if year_override is not None else m.group("year")
year_num = chinese_to_num(year_text)
# 年数上界:年号纪年不可能出现"洪武146356"这类(那是被当年份的数字 ID/编号),
# 年数 > 999 一律不认——否则会算出 147723 这种荒诞公元
if not (1 <= year_num <= 999):
return
base = None
multi = []
if dynasty:
for d, eras in era_dict.items():
if dynasty in d or d in dynasty:
if era in eras:
base = eras[era]
dynasty = d
break
else:
# 无朝代前缀:收集全部候选(如"建元"=西汉-140/东晋343/南齐479),取首个但标注歧义
candidates = []
for d, eras in era_dict.items():
if era in eras:
candidates.append((d, eras[era]))
if len(candidates) == 1:
dynasty, base = candidates[0]
elif len(candidates) > 1:
dynasty, base = candidates[0]
multi = candidates
if base is None:
# 名号+年数兜底(如"汉惠帝三年"→即位首年-194 + 3 - 1,指皇帝在位第N年)
if dynasty:
for d, temples in temple_dict.items():
if (dynasty in d or d in dynasty) and era in temples:
base = temples[era]
dynasty = d
break
start, end = m.start(), m.end()
# 尾随干支校验(如"光绪二十六年庚子"):并入原文同一单位(同公元段逻辑)
ganzhi = None
if base:
ganzhi = simplified_text[end:end + 2]
if ganzhi in _ganzhi_to_offset:
end += 2
else:
ganzhi = None
raw_fan = original_text[start:end]
covered.update(range(start, end))
if base:
gregorian = base + year_num - 1
result = {
"原文": raw_fan,
"原文(简体)": m.group(0) + (ganzhi or ''),
"朝代": dynasty,
"年号": era,
"年数": year_num,
"公元": gregorian,
"位置": {"起始": start, "结束": end},
"类型": "古代纪年",
}
if ganzhi:
result["干支"] = ganzhi
if gregorian_to_ganzhi(gregorian) != ganzhi:
result["干支不符"] = True
if len(multi) > 1:
result["多候选"] = True
result["候选"] = [list(c) for c in multi]
# 使用期校验:年数结果超出年号使用期(或名号在位年数)时标注,值保留
span = era_span.get(dynasty, {}).get(era)
if span is None:
span = temple_span.get(dynasty, {}).get(era)
if span is not None and gregorian > base + span - 1:
result["超出使用期"] = True
results.append(result)
else:
results.append({
"原文": raw_fan,
"原文(简体)": m.group(0),
"朝代": dynasty,
"年号": era,
"年数": year_num,
"位置": {"起始": start, "结束": end},
"类型": "古代纪年(未匹配)",
})
# 收集所有匹配,对于重叠区间保留覆盖范围更大的(如"金大定元年"优先于"大定元年")
all_matches = []
for pat in _patterns_no_dynasty:
for m in pat.finditer(simplified_text):
has_year = "year" in m.groupdict() and m.group("year") is not None
all_matches.append((m, None if has_year else "元", False))
for pat in _patterns_dynasty:
for m in pat.finditer(simplified_text):
has_year = "year" in m.groupdict() and m.group("year") is not None
all_matches.append((m, None if has_year else "元", True))
# 按覆盖范围降序排列(长的优先),同长度时朝代模式优先
all_matches.sort(key=lambda x: (x[0].end() - x[0].start(), x[2]), reverse=True)
for m, year_override, _is_dynasty in all_matches:
if not any(pos in covered for pos in range(m.start(), m.end())):
_process_era_match(m, year_override=year_override)
for result in results:
if result.get("公元") is not None:
result.setdefault("干支", gregorian_to_ganzhi(result["公元"]))
# === 收尾 ===
# 1) 派生字段:公元中文(公元年份逐位读法)、年数中文(廿/卅/卌 计数读法)——都只给数字写法,不带"年"单位。
# 在这里统一派生,任何新增的产生"公元"/"年数"的分支都会自动带上;
# 公元纪年、庙号纪年没有年数 → 年数中文 = "";未匹配年号没有公元 → 公元中文 = ""。
# 2) 原文剔除标注符号;位置换算为 UTF-16 码元(JS 前端索引)。
# 3) 字段全量化:_finalize_result 按固定顺序补齐所有键(缺内容给空值)。
for i, result in enumerate(results):
start, end = result["位置"]["起始"], result["位置"]["结束"]
result["年数中文"] = (
arabic_to_chinese_count(result["年数"]) if result.get("年数") is not None else ""
)
result["公元中文"] = (
arabic_to_chinese_year(result["公元"]) if result.get("公元") is not None else ""
)
result["位置"] = {"起始": _utf16_index[start], "结束": _utf16_index[end]}
result["原文"] = ''.join(ch for ch in original_text[start:end] if ch not in '[]【】')
result["原文(简体)"] = ''.join(ch for ch in simplified_text[start:end] if ch not in '[]【】')
results[i] = _finalize_result(result)
return results
def convert_text(text):
results = extract_era_years(text)
return results