diff --git a/era_data.py b/era_data.py index b6c166d..8c874fa 100644 --- a/era_data.py +++ b/era_data.py @@ -30,6 +30,23 @@ dynasty_alias = { "武周": "武周", } +# === 异体字/俗字归一表(OpenCC 未收录的字符) === +# OpenCC 只处理标准繁简转换;古籍、碑刻、族谱中的异体字需在此补充。 +# 匹配前对文本和 CSV 两侧做相同的归一化,保证一致。 +VARIANT_MAP = { + '秊': '年', '歳': '岁', # 年、岁的异体 + '髙': '高', + '夘': '卯', '戼': '卯', # 地支"卯"的异体 + '暦': '历', '歴': '历', # 历的异体(万暦→万历) +} + +# === 庙号别名 === +# CSV 名号列与正文常用写法的差异 +TEMPLE_ALIASES = { + "高帝": "高祖", # CSV 用"高帝",正文常用"高祖" + "始皇帝": "始皇", # CSV 用"始皇帝",正文常用"秦始皇" +} + # === 常见年号白名单(优先匹配,避免正则贪心误匹配) === KNOWN_ERAS = { # 秦汉 diff --git a/parser.py b/parser.py index c8ab237..f3a09a6 100644 --- a/parser.py +++ b/parser.py @@ -3,14 +3,21 @@ import re import pandas as pd from opencc import OpenCC -from era_data import dynasty_alias, KNOWN_ERAS +from era_data import dynasty_alias, KNOWN_ERAS, VARIANT_MAP, TEMPLE_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("卌", "四十") + text = text.replace("卅", "三十").replace("卌", "四十").replace("拾", "十") if text in ["元", "元年"]: return 1 @@ -18,22 +25,36 @@ def chinese_to_num(text): '零': 0, '〇': 0, '○': 0, '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9, '十': 10, '百': 100, '千': 1000, - '壹': 1, '貳': 2, '叁': 3, '肆': 4, '伍': 5, '陸': 6, '柒': 7, '捌': 8, '玖': 9, '拾': 10, + '壹': 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_numerals.get(parts[0], 0) * 10 + total += chinese_to_num(parts[0]) * 10 if len(parts) > 1 and parts[1]: - total += chinese_numerals.get(parts[1], 0) + total += chinese_to_num(parts[1]) return total if '廿' in text or '卄' in text: @@ -68,14 +89,71 @@ def normalize_dynasty_name(dynasty): # === 年号表 === +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(lambda x: cc.convert(str(x))) -era_df["所属朝代"] = era_df["所属朝代"].map(lambda x: cc.convert(str(x))) +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 + + +# === 庙号表 === +# 名号列(如"高宗""太宗")表示整个在位期,公元取该名号下最早的年份(即位首年)。 +# "代宗(景帝)" 等带括号的写法拆成两种叫法都收录;只收祖/宗/帝/王/后/皇结尾(或帝开头)的名号, +# 排除"拖雷""孺子婴"等非庙号条目。 +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 # === 干支纪年 === @@ -111,21 +189,24 @@ def arabic_to_chinese_year(num: int) -> str: # === 古代年号正则 === # 从 era_dict 构建朝代匹配组(按长度降序排列,优先匹配长朝代名如"北宋"而非"北") # 额外添加常见但不在CSV中的朝代简称(如"汉") +# 合并 temple_dict 的键:周/秦等年号列全空的朝代也必须有匹配能力 _extra_dynasty_names = ['汉', '晋', '宋', '周'] _dynasty_names = sorted( - list(era_dict.keys()) + [d for d in _extra_dynasty_names if d not in era_dict], + 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 = '大皇前后胡' -# 非元年数字字符(排除元,避免歧义:建元元年中第一个元是年号的一部分) -_year_digits = '〇零一二三四五六七八九十廿卄卅卌' +# 非元年数字字符(排除元,避免歧义:建元元年中第一个元是年号的一部分)。 +# 含阿拉伯/全角数字("康熙20年""乾隆20年")与大写数字("道光贰拾年")。 +_year_digits = '〇零一二三四五六七八九十廿卄卅卌' + '01234567890123456789' + '壹貳贰叁肆伍陸陆柒捌玖拾' # 已知年号白名单见 era_data.py # 预编译已知年号正则片段(按长度降序,优先匹配长年号如"太平兴国"而非"太平") -_known_era_alt = '|'.join(sorted(KNOWN_ERAS, key=len, reverse=True)) +_known_era_alt = '|'.join(sorted(KNOWN_ERAS_ALL, key=len, reverse=True)) _known_era_group = r'(?P' + _known_era_alt + r')' _yd = '[' + _year_digits + ']' @@ -173,15 +254,18 @@ def extract_era_years(text): # 去除标记用的方括号(用户标注用,非原文内容) text = text.replace('[', '').replace(']', '') original_text = text - simplified_text = cc.convert(text) + simplified_text = normalize_chars(cc.convert(text)) results = [] + # 覆盖集:先匹配的段落占据位置,后续段落跳过重叠(防止同一文本被多段重复摘出) + covered = set() # === 1. 民国 === - minguo_pattern = re.compile(r'民[国國]\s*([〇零一二三四五六七八九十百廿卄卅卌元\d]+)\s*年') + minguo_pattern = re.compile(r'民[国國]\s*([〇零一二三四五六七八九十百廿卄卅卌元\d0-9壹貳贰叁肆伍陸陆柒捌玖拾]+)\s*年') for m in minguo_pattern.finditer(simplified_text): year_text = m.group(1) year_num = chinese_to_num(year_text) start, end = m.start(), m.end() + covered.update(range(start, end)) raw_fan = original_text[start:end] results.append({ "原文": raw_fan, @@ -207,6 +291,7 @@ def extract_era_years(text): year_in_era = ((gz_offset - base_gz) % 60) + 1 gregorian = 1912 + year_in_era - 1 start, end = m.start(), m.end() + covered.update(range(start, end)) raw_fan = original_text[start:end] results.append({ "原文": raw_fan, @@ -221,16 +306,82 @@ def extract_era_years(text): "类型": "民国纪年(干支)", }) + # === 2b. 年号+公元(如"清康熙公元一六八七年丁卯") === + # 年号后直接给出公元年份:年数=公元-年号起始+1,与"年号+年数"公式自洽; + # 紧跟的干支用于校验年份一致性。置于公元段之前,合并结果优先、避免重复摘出。 + _era_gongyuan = re.compile( + r'(?P(?:(?' + _known_era_alt + r')' + + r'公元\s*(?P[〇零○O一二三四五六七八九\d0-9]{3,4})\s*年?' + + r'(?P' + _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 = cc.convert(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) + # === 2. 公元(允许无"年"字)=== gongyuan_pattern = re.compile( - r'(公元)?\s*([〇零○O一二三四五六七八九\d]{3,4})\s*(?:(.*?))?\s*(年)?' + r'(公元)?\s*([〇零○O一二三四五六七八九\d0-9]{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(2) year_num = chinese_to_num(year_text) if not (1000 <= year_num <= 2100): continue - start, end = m.start(), m.end() + covered.update(range(start, end)) raw_fan = original_text[start:end] era_type = "公元纪年(中华人民共和国)" if year_num >= 1949 else "公元纪年(近代/其他)" results.append({ @@ -243,7 +394,6 @@ def extract_era_years(text): }) # === 3. 干支纪年(如"清雍正乙巳十一月十二午时生")=== - covered = set() def _process_ganzhi_match(m): """处理干支纪年匹配""" raw_dynasty = m.group("dynasty") if "dynasty" in m.groupdict() else "" @@ -255,6 +405,7 @@ def extract_era_years(text): # 查找朝代和基准年 base = None + multi = [] if dynasty: for d, eras in era_dict.items(): if dynasty in d or d in dynasty: @@ -263,11 +414,12 @@ def extract_era_years(text): dynasty = d break if not base: + # 无朝代前缀:收集全部候选(如"大定"=北周581/金1161),取首个但标注歧义 for d, eras in era_dict.items(): if era in eras: - base = eras[era] - dynasty = d - break + multi.append((d, eras[era])) + if multi: + dynasty, base = multi[0] if not base: return @@ -281,7 +433,7 @@ def extract_era_years(text): return covered.update(range(start, end)) raw_fan = original_text[start:end] - results.append({ + result = { "原文": raw_fan, "原文(简体)": m.group(0), "朝代": dynasty, @@ -292,13 +444,22 @@ def extract_era_years(text): "公元中文": f"{arabic_to_chinese_year(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:有朝代前缀(如"清雍正乙巳") # 单字年号加前瞻(?![地支]),防止单字年号"丙"吞掉天干"丙午"中的"丙" _gz_era_group = r'(?P' + '|'.join( (re.escape(e) + (r'(?![子丑寅卯辰巳午未申酉戌亥])' if len(e) == 1 else '')) - for e in sorted(KNOWN_ERAS, key=len, reverse=True) + for e in sorted(KNOWN_ERAS_ALL, key=len, reverse=True) ) + r')' ganzhi_dynasty = re.compile( r'(?P(?:(?= 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)' @@ -319,6 +480,49 @@ def extract_era_years(text): 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(?:(?' + _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): @@ -336,6 +540,7 @@ def extract_era_years(text): 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(): @@ -345,6 +550,7 @@ def extract_era_years(text): dynasty = d break else: + # 无朝代前缀:收集全部候选(如"建元"=西汉-140/东晋343/南齐479),取首个但标注歧义 candidates = [] for d, eras in era_dict.items(): if era in eras: @@ -353,13 +559,22 @@ def extract_era_years(text): 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() raw_fan = original_text[start:end] covered.update(range(start, end)) if base: gregorian = base + year_num - 1 - results.append({ + result = { "原文": raw_fan, "原文(简体)": m.group(0), "朝代": dynasty, @@ -369,7 +584,17 @@ def extract_era_years(text): "公元中文": f"{arabic_to_chinese_year(gregorian)}年", "位置": {"起始": start, "结束": end}, "类型": "古代纪年", - }) + } + 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, diff --git a/requirements.txt b/requirements.txt index 633618b..bf4ea90 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ -pytest~=9.1.1 -uvicorn~=0.50.0 -fastapi~=0.139.0 -loguru~=0.7.3 -pandas~=2.3.3 -OpenCC~=1.4.1 \ No newline at end of file +uvicorn~=0.33.0 +fastapi~=0.124.4 +loguru~=0.7.2 +pandas~=1.5.3 +opencc-python-reimplemented~=0.1.7 +python-multipart~=0.0.20 \ No newline at end of file diff --git a/中国年号.csv b/中国年号.csv index 64b75fc..79c4825 100644 --- a/中国年号.csv +++ b/中国年号.csv @@ -554,8 +554,8 @@ 中国,天命,太祖,爱新觉罗努尔哈赤,11616,11627,1616,正月,丙辰,11,清 中国,天聪,太宗,爱新觉罗皇太极,11627,11637,1627,正月,丁卯,10,清 中国,崇德,太宗,爱新觉罗皇太极,11636,11644,1636,四月,丙子,8,清 -中国,顺治,世宗,爱新觉罗福临,11644,11662,1644,正月,甲申,18,清 -中国,康熙,圣宗,爱新觉罗玄烨,11662,11723,1662,正月,壬寅,61,清 +中国,顺治,世祖,爱新觉罗福临,11644,11662,1644,正月,甲申,18,清 +中国,康熙,圣祖,爱新觉罗玄烨,11662,11723,1662,正月,壬寅,61,清 中国,雍正,世宗,爱新觉罗胤禛,11723,11736,1723,正月,癸卯,13,清 中国,乾隆,高宗,爱新觉罗弘历,11736,11796,1736,正月,丙辰,60,清 中国,嘉庆,仁宗,爱新觉罗颙琰,11796,11821,1796,正月,丙辰,25,清