- Add lunar calendar astronomical calculation engine (lunar_calendar.py) - Add converter utilities for solar/lunar date conversion - Add historical era name conversions (chrono_converter.py, converter_with_eras.py) - Add FastAPI REST API for date conversion services - Add age calculator using lunar calendar - Add batch generation script for lunar calendar JSON data - Add project documentation (CLAUDE.md, pyproject.toml) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
175 lines
7.3 KiB
Python
175 lines
7.3 KiB
Python
#!/usr/bin/env python3
|
|
# converter_with_eras.py
|
|
#
|
|
# 結合「西曆↔農曆轉換」與「歷代年號 ↔ 西曆」功能
|
|
# 支援:太平天國、民國、中國明清諸年號(農曆基準)、日本各元號(西曆基準)
|
|
|
|
import os
|
|
import sys
|
|
import json
|
|
import argparse
|
|
from datetime import datetime, date
|
|
from typing import Dict
|
|
|
|
# 中國明清年號對應的西曆範圍及基準年 (起始日, 結束日, 基年)
|
|
CHINESE_ERAS_SOLAR = {
|
|
# 明朝
|
|
"萬曆": (date(1573, 1, 1), date(1620, 12, 31), 1572),
|
|
"天啟": (date(1621, 1, 1), date(1627, 12, 31), 1620),
|
|
"崇禎": (date(1628, 1, 1), date(1644, 4, 25), 1627), # 崇禎最後一天 1644-04-25
|
|
# 清朝
|
|
"順治": (date(1644, 2, 8), date(1662, 2, 17), 1643), # 順治 1年 1644-02-08 至 1662-02-17
|
|
"康熙": (date(1662, 2, 18), date(1723, 2, 8), 1661), # 康熙 1年 1662-02-18 至 1723-02-08
|
|
"雍正": (date(1723, 2, 9), date(1736, 1, 28), 1722), # 雍正 1年 1723-02-09 至 1736-01-28
|
|
"乾隆": (date(1736, 1, 29), date(1796, 2, 17), 1735), # 乾隆 1年 1736-01-29 至 1796-02-17
|
|
"嘉慶": (date(1796, 2, 18), date(1821, 2, 6), 1795), # 嘉慶 1年 1796-02-18 至 1821-02-06
|
|
"道光": (date(1821, 2, 7), date(1851, 1, 27), 1820), # 道光 1年 1821-02-07 至 1851-01-27
|
|
"咸豐": (date(1851, 1, 28), date(1862, 1, 16), 1850), # 咸豐 1年 1851-01-28 至 1862-01-16
|
|
"同治": (date(1862, 1, 17), date(1875, 2, 5), 1861), # 同治 1年 1862-01-17 至 1875-02-05
|
|
"光緒": (date(1875, 2, 6), date(1909, 1, 22), 1874), # 光緒 1年 1875-02-06 至 1909-01-22
|
|
"宣統": (date(1909, 1, 23), date(1912, 2, 18), 1908), # 宣統 1年 1909-01-23 至 1912-02-18
|
|
}
|
|
|
|
class Converter:
|
|
"""
|
|
提供西曆與農曆互轉功能:
|
|
- solar_to_lunar(date_str: str) -> dict
|
|
- lunar_to_solar(year: int, month: int, day: int, leap: bool=False) -> str
|
|
"""
|
|
LUNAR_JSON_DIR = 'lunar_json'
|
|
|
|
@classmethod
|
|
def load_solar_to_lunar(cls, year: int) -> Dict:
|
|
path = os.path.join(cls.LUNAR_JSON_DIR, f"{year}.json")
|
|
if not os.path.exists(path):
|
|
raise FileNotFoundError(f"找不到 {path}")
|
|
with open(path, encoding='utf-8') as f:
|
|
return json.load(f)
|
|
|
|
def solar_to_lunar(self, solar_date_str: str) -> Dict:
|
|
# 允許輸入 "YYYY-M-D",並自動標準化為 ISO 格式 "YYYY-MM-DD"
|
|
parts = solar_date_str.split('-')
|
|
if len(parts) != 3:
|
|
raise ValueError(f"日期格式錯誤,請使用 YYYY-MM-DD 或類似格式:{solar_date_str}")
|
|
y, m, d = map(int, parts)
|
|
dt = date(y, m, d)
|
|
key = dt.isoformat()
|
|
try:
|
|
data = self.load_solar_to_lunar(dt.year)
|
|
except FileNotFoundError:
|
|
raise FileNotFoundError(f"無法取得 {dt.year} 年的農曆資料,請先生成對應的 lunar_json/{dt.year}.json 檔案")
|
|
if key not in data:
|
|
raise KeyError(f"沒有找到對應 {key} 的農曆資料")
|
|
return data[key]
|
|
|
|
def lunar_to_solar(self, lunar_year: int, lunar_month: int, lunar_day: int, leap: bool=False) -> str:
|
|
solar_year = lunar_year + 1 if lunar_month in (11, 12) else lunar_year
|
|
data = self.load_solar_to_lunar(solar_year)
|
|
# 精確匹配
|
|
for solar, info in data.items():
|
|
lstr = info.get('農曆','')
|
|
is_leap = lstr.startswith('閏')
|
|
core = lstr[1:] if is_leap else lstr
|
|
m_str, d_str = core.replace('月','-').replace('日','').split('-')
|
|
if int(m_str)==lunar_month and int(d_str)==lunar_day and is_leap==leap:
|
|
return solar
|
|
# 回退匹配非閏月
|
|
for solar, info in data.items():
|
|
lstr = info.get('農曆','')
|
|
is_leap = lstr.startswith('閏')
|
|
core = lstr[1:] if is_leap else lstr
|
|
m_str, d_str = core.replace('月','-').replace('日','').split('-')
|
|
if int(m_str)==lunar_month and int(d_str)==lunar_day and not is_leap:
|
|
return solar
|
|
raise KeyError(f"找不到對應農曆 {lunar_year}{'閏' if leap else ''}{lunar_month}月{lunar_day}日 的西曆")
|
|
|
|
class ChronoConverter:
|
|
"""
|
|
統一各種紀年 ↔ 西曆 的轉換
|
|
"""
|
|
# 日本元號對應的西曆範圍及基準年 (起始日, 結束日, 基年)
|
|
JAPANESE_ERAS = {
|
|
"慶應": (date(1865,3,18), date(1868,10,22), 1864),
|
|
"明治": (date(1868,10,23), date(1912,7,30), 1867),
|
|
"大正": (date(1912,7,30), date(1926,12,24), 1911),
|
|
"昭和": (date(1926,12,25), date(1989,1,7), 1925),
|
|
"平成": (date(1989,1,8), date(2019,4,30), 1988),
|
|
"令和": (date(2019,5,1), None, 2018),
|
|
}
|
|
|
|
@staticmethod
|
|
def _lunar_year(y: int, m: int, d: int) -> int:
|
|
conv = Converter()
|
|
dt = date(y, m, d)
|
|
for ly in (y, y-1):
|
|
try:
|
|
start = conv.lunar_to_solar(ly, 1, 1)
|
|
nxt = conv.lunar_to_solar(ly+1, 1, 1)
|
|
except Exception:
|
|
continue
|
|
d0, d1 = date.fromisoformat(start), date.fromisoformat(nxt)
|
|
if d0 <= dt < d1:
|
|
return ly
|
|
return y
|
|
|
|
@classmethod
|
|
def to_era(cls, y: int, m: int, d: int) -> Dict:
|
|
dt = date(y, m, d)
|
|
ly = cls._lunar_year(y, m, d)
|
|
res: Dict[str, str] = {}
|
|
|
|
if date(1851,2,1) <= dt <= date(1872,7,5):
|
|
res["太平天國"] = f"太平天國 {y-1850} 年"
|
|
if y >= 1912:
|
|
res["民國"] = f"民國 {y-1911} 年"
|
|
for era, (st, ed, base) in CHINESE_ERAS_SOLAR.items():
|
|
if st <= dt <= ed:
|
|
res[era] = f"{era} {dt.year - base} 年"
|
|
for era, (st, ed, base) in cls.JAPANESE_ERAS.items():
|
|
if dt >= st and (ed is None or dt <= ed):
|
|
res[era] = f"{era} {dt.year-base} 年"
|
|
res["星期"] = dt.strftime("%A")
|
|
return res
|
|
|
|
|
|
def convert_date(y: int, m: int, d: int) -> Dict:
|
|
return ChronoConverter.to_era(y, m, d)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="西曆↔農曆+歷代年號+日本元號 轉換工具")
|
|
sub = parser.add_subparsers(dest='cmd', required=True)
|
|
|
|
p1 = sub.add_parser('solar2lunar', help='西曆轉農曆')
|
|
p1.add_argument('date', help='YYYY-MM-DD 或 YYYY-M-D')
|
|
|
|
p2 = sub.add_parser('lunar2solar', help='農曆轉西曆')
|
|
p2.add_argument('date', help='YYYY-MM-DD')
|
|
p2.add_argument('--leap', action='store_true', help='閏月')
|
|
|
|
p3 = sub.add_parser('era', help='西曆轉歷代年號+日本元號')
|
|
p3.add_argument('date', help='YYYY-MM-DD')
|
|
|
|
args = parser.parse_args()
|
|
if args.cmd == 'solar2lunar':
|
|
try:
|
|
result = Converter().solar_to_lunar(args.date)
|
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
except (FileNotFoundError, KeyError, ValueError) as e:
|
|
print(e)
|
|
sys.exit(1)
|
|
elif args.cmd == 'lunar2solar':
|
|
try:
|
|
y, m, d = map(int, args.date.split('-',2))
|
|
solar = Converter().lunar_to_solar(y, m, d, args.leap)
|
|
print(json.dumps(Converter().solar_to_lunar(solar), ensure_ascii=False, indent=2))
|
|
except Exception as e:
|
|
print(e)
|
|
sys.exit(1)
|
|
elif args.cmd == 'era':
|
|
y, m, d = map(int, args.date.split('-',2))
|
|
print(json.dumps(convert_date(y, m, d), ensure_ascii=False, indent=2))
|
|
|
|
if __name__ == '__main__':
|
|
main()
|