Initial commit: Lunar calendar converter project

- 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>
This commit is contained in:
2026-03-09 13:51:45 +08:00
commit ae21a0ae66
19 changed files with 1977 additions and 0 deletions

174
converter_with_eras.py Normal file
View File

@@ -0,0 +1,174 @@
#!/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()