Files
ancient_date_parser/parser.py
T

851 lines
37 KiB
Python
Raw 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:
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))
# === 古代年号正则 ===
# 从 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 + ']'
def _build_pattern(with_dynasty=True):
"""构建年号正则:已知年号优先 + 通用兜底(仅限有朝代前缀时)"""
if with_dynasty:
# 朝代前缀模式:加(?<![一-鿿])防止"元"在"开元"中被当朝代
dynasty_part = r'(?P<dynasty>(?:(?<![一-鿿])[' + _dynasty_prefix + r'](?:' + _dynasty_alt + r'))|(?:(?<![一-鿿])' + _dynasty_alt + r'))'
boundary = ''
else:
dynasty_part = r'(?P<dynasty>)'
# 无朝代模式不加边界(允许年号跟在其他汉字后面,如"永乐大帝在永乐三年")
boundary = ''
if with_dynasty:
# 有朝代前缀:年号后直接跟年份数字,无需额外约束
era_year = _known_era_group + r'(?P<year>' + _yd + r'+)年'
era_yuan = _known_era_group + r'元年'
patterns = [
re.compile(dynasty_part + boundary + era_year),
re.compile(dynasty_part + boundary + era_yuan),
# 通用兜底(2-8字)
re.compile(dynasty_part + boundary + r'(?P<era>[一-鿿]{2,8})' + r'(?P<year>' + _yd + r'+)年'),
re.compile(dynasty_part + boundary + r'(?P<era>[一-鿿]{2,8})元年'),
]
else:
# 无朝代前缀:年号后必须紧跟年份数字(前瞻断言),防止"永乐大帝"误匹配
_year_ahead = r'(?=' + _yd + r'+年|元年)'
era_year = _known_era_group + _year_ahead + r'(?P<year>' + _yd + r'+)年'
era_yuan = _known_era_group + _year_ahead + r'元年'
patterns = [
re.compile(dynasty_part + boundary + era_year),
re.compile(dynasty_part + boundary + era_yuan),
]
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 位,可带"年"字
_gz_gy_year = r'[〇零○O一二三四五六七八九\d-]{3,4}'
# 干支与对照年份之间的连接方式(按优先级):
# 括号(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 = re.compile(
_dynasty_opt
+ _gz_era_group
+ _gz_mod
+ r'(?P<ganzhi>' + _gz_pair + r')(?:年)?'
+ _gz_gy_sep
)
# 裸干支+(对照):无年号可锚定(如"壬申(1872)"),公元取对照值
_ganzhi_bare_gongyuan_re = re.compile(
r'(?P<ganzhi>' + _gz_pair + r')(?:年)?'
+ _gz_gy_sep
)
# === 主函数 ===
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*年'
+ _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
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]
gregorian = gy_year if gy_year is not None else 1911 + year_num
result = {
"原文": raw_fan,
"原文(简体)": simplified_text[start:end],
"朝代": "民国",
"年号": "民国纪年",
"年数": year_num,
"公元": gregorian,
"公元中文": f"{arabic_to_chinese_year(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
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,
"公元中文": f"{arabic_to_chinese_year(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.compile(
r'(?P<dynasty>(?:(?<![一-鿿])[' + _dynasty_prefix + r'](?:' + _dynasty_alt + r'))|(?:(?<![一-鿿])' + _dynasty_alt + r'))?'
+ r'(?P<era>' + _known_era_alt + r')'
+ r'(?:公元|西元|公历)\s*(?P<gy_year>[〇零○O一二三四五六七八九\d-]{3,4})\s*年?'
+ r'(?P<gy_ganzhi>' + _gz_pair + r')?'
)
for m in _era_gongyuan.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,
"公元中文": f"{arabic_to_chinese_year(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,
"公元中文": f"{arabic_to_chinese_year(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*([〇零○O一二三四五六七八九\d-]{3,4})\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,
"公元中文": f"{arabic_to_chinese_year(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,
"公元中文": f"{arabic_to_chinese_year(gregorian)}年",
"位置": {"起始": start, "结束": end},
"类型": "干支纪年",
}
if len(multi) > 1:
result["多候选"] = True
result["候选"] = [list(c) for c in multi]
# 使用期校验:换算结果超出年号实际使用年数(如"庆元庚子"→1240,庆元仅11951200)。
# 值保留,仅标注,供人工判断(碑刻原数据常与年号实际期间不符)。
span = era_span.get(dynasty, {}).get(era)
if span is not None and gregorian > base + span - 1:
result["超出使用期"] = True
results.append(result)
# 模式A:有朝代前缀(如"清雍正乙巳");年号组 _gz_era_group 见模块级定义
ganzhi_dynasty = re.compile(
r'(?P<dynasty>(?:(?<![一-鿿])[' + _dynasty_prefix + r'](?:' + _dynasty_alt + r'))|(?:(?<![一-鿿])' + _dynasty_alt + r'))'
+ _gz_era_group
+ _gz_mod
+ r'(?P<ganzhi>' + _gz_pair + r')(?:年)?'
)
for m in ganzhi_dynasty.finditer(simplified_text):
_process_ganzhi_match(m)
# 模式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(
r'(?P<dynasty>)'
+ r'(?P<era>' + _long_era_alt + r')'
+ _gz_mod
+ r'(?P<ganzhi>' + _gz_pair + r')(?:年)?'
)
for m in ganzhi_no_dynasty.finditer(simplified_text):
_process_ganzhi_match(m)
# === 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(
r'(?P<dynasty>(?:(?<![一-鿿])[' + _dynasty_prefix + r'](?:' + _dynasty_alt + r'))|(?:(?<![一-鿿])' + _dynasty_alt + r'))'
+ r'(?P<temple>' + _temple_alt + r')'
# 后面紧跟"X年/元年"时不按庙号匹配,交给古代纪年按年号解析(如"元泰定三年")
+ r'(?!(?:' + _yd + r'+年|元年))'
)
for m in _temple_pattern.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,
"公元中文": f"{arabic_to_chinese_year(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)
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,
"公元中文": f"{arabic_to_chinese_year(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["公元"]))
# === 收尾:原文剔除标注符号;位置换算为 UTF-16 码元(JS 前端索引) ===
for result in results:
start, end = result["位置"]["起始"], result["位置"]["结束"]
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 '[]【】')
return results
def convert_text(text):
results = extract_era_years(text)
return results