timmy bu gisti düzenledi 7 months ago. Düzenlemeye git
Değişiklik yok
timmy bu gisti düzenledi 7 months ago. Düzenlemeye git
4 files changed, 355 insertions
converter.py(dosya oluşturuldu)
| @@ -0,0 +1,88 @@ | |||
| 1 | + | #!/usr/bin/env python3 | |
| 2 | + | # converter.py | |
| 3 | + | # 用於西曆與農曆互轉,已封裝成類別 | |
| 4 | + | ||
| 5 | + | import os | |
| 6 | + | import json | |
| 7 | + | import argparse | |
| 8 | + | from datetime import datetime | |
| 9 | + | ||
| 10 | + | class Converter: | |
| 11 | + | """ | |
| 12 | + | 提供西曆與農曆互轉功能: | |
| 13 | + | - solar_to_lunar(date_str:str) -> dict | |
| 14 | + | - lunar_to_solar(year:int, month:int, day:int, leap:bool) -> str | |
| 15 | + | """ | |
| 16 | + | LUNAR_JSON_DIR = 'lunar_json' | |
| 17 | + | ||
| 18 | + | @classmethod | |
| 19 | + | def load_solar_to_lunar(cls, year: int) -> dict: | |
| 20 | + | path = os.path.join(cls.LUNAR_JSON_DIR, f"{year}.json") | |
| 21 | + | if not os.path.exists(path): | |
| 22 | + | raise FileNotFoundError(f"找不到 {path}") | |
| 23 | + | with open(path, encoding='utf-8') as f: | |
| 24 | + | return json.load(f) | |
| 25 | + | ||
| 26 | + | def solar_to_lunar(self, solar_date_str: str) -> dict: | |
| 27 | + | try: | |
| 28 | + | dt = datetime.strptime(solar_date_str, '%Y-%m-%d').date() | |
| 29 | + | except ValueError: | |
| 30 | + | raise ValueError("西曆日期格式應為 YYYY-MM-DD") | |
| 31 | + | data = self.load_solar_to_lunar(dt.year) | |
| 32 | + | if solar_date_str not in data: | |
| 33 | + | raise KeyError(f"沒有找到對應 {solar_date_str} 的農曆資料") | |
| 34 | + | return data[solar_date_str] | |
| 35 | + | ||
| 36 | + | def lunar_to_solar(self, lunar_year: int, lunar_month: int, lunar_day: int, leap: bool=False) -> str: | |
| 37 | + | solar_year = lunar_year + 1 if lunar_month in (11, 12) else lunar_year | |
| 38 | + | data = self.load_solar_to_lunar(solar_year) | |
| 39 | + | # 精確匹配 | |
| 40 | + | for solar, info in data.items(): | |
| 41 | + | lstr = info.get('農曆','') | |
| 42 | + | is_leap = lstr.startswith('閏') | |
| 43 | + | core = lstr[1:] if is_leap else lstr | |
| 44 | + | m_str, d_str = core.replace('月','-').replace('日','').split('-') | |
| 45 | + | if int(m_str)==lunar_month and int(d_str)==lunar_day and is_leap==leap: | |
| 46 | + | return solar | |
| 47 | + | # 回退到非閏月匹配 | |
| 48 | + | for solar, info in data.items(): | |
| 49 | + | lstr = info.get('農曆','') | |
| 50 | + | is_leap = lstr.startswith('閏') | |
| 51 | + | core = lstr[1:] if is_leap else lstr | |
| 52 | + | m_str, d_str = core.replace('月','-').replace('日','').split('-') | |
| 53 | + | if int(m_str)==lunar_month and int(d_str)==lunar_day and not is_leap: | |
| 54 | + | return solar | |
| 55 | + | raise KeyError(f"找不到對應農曆 {lunar_year}{'閏' if leap else ''}{lunar_month}月{lunar_day}日 的西曆") | |
| 56 | + | ||
| 57 | + | ||
| 58 | + | def main(): | |
| 59 | + | parser = argparse.ArgumentParser(description='西曆與農曆互轉工具') | |
| 60 | + | sub = parser.add_subparsers(dest='command', required=True) | |
| 61 | + | ||
| 62 | + | # 西曆 -> 農曆 | |
| 63 | + | p1 = sub.add_parser('solar2lunar', help='西曆轉農曆') | |
| 64 | + | p1.add_argument('date', type=str, help='西曆日期 YYYY-MM-DD') | |
| 65 | + | ||
| 66 | + | # 農曆 -> 西曆 | |
| 67 | + | p2 = sub.add_parser('lunar2solar', help='農曆轉西曆') | |
| 68 | + | p2.add_argument('date', type=str, help='農曆日期 YYYY-MM-DD') | |
| 69 | + | p2.add_argument('--leap', action='store_true', help='是否閏月') | |
| 70 | + | ||
| 71 | + | args = parser.parse_args() | |
| 72 | + | conv = Converter() | |
| 73 | + | ||
| 74 | + | if args.command == 'solar2lunar': | |
| 75 | + | info = conv.solar_to_lunar(args.date) | |
| 76 | + | print(json.dumps(info, ensure_ascii=False, indent=2)) | |
| 77 | + | elif args.command == 'lunar2solar': | |
| 78 | + | try: | |
| 79 | + | y_str, m_str, d_str = args.date.split('-',2) | |
| 80 | + | y, m, d = int(y_str), int(m_str), int(d_str) | |
| 81 | + | except Exception: | |
| 82 | + | raise ValueError("農曆日期格式應為 YYYY-MM-DD,閏月請加 --leap") | |
| 83 | + | solar = conv.lunar_to_solar(y, m, d, args.leap) | |
| 84 | + | info = conv.solar_to_lunar(solar) | |
| 85 | + | print(json.dumps(info, ensure_ascii=False, indent=2)) | |
| 86 | + | ||
| 87 | + | if __name__ == '__main__': | |
| 88 | + | main() | |
flask_api.py(dosya oluşturuldu)
| @@ -0,0 +1,76 @@ | |||
| 1 | + | # /// script | |
| 2 | + | # requires-python = ">=3.13" | |
| 3 | + | # dependencies = [ | |
| 4 | + | # "flask", | |
| 5 | + | # ] | |
| 6 | + | # /// | |
| 7 | + | import socket | |
| 8 | + | ||
| 9 | + | def find_free_port(start=5000, max_tries=100): | |
| 10 | + | """ | |
| 11 | + | 從 start 開始找一個未被監聽的 TCP 端口,最多嘗試 max_tries 次 | |
| 12 | + | """ | |
| 13 | + | port = start | |
| 14 | + | for _ in range(max_tries): | |
| 15 | + | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: | |
| 16 | + | s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) | |
| 17 | + | if s.connect_ex(('0.0.0.0', port)) != 0: | |
| 18 | + | return port | |
| 19 | + | port += 1 | |
| 20 | + | raise RuntimeError(f"在 {start}–{start+max_tries} 範圍內找不到可用端口") | |
| 21 | + | ||
| 22 | + | import json | |
| 23 | + | from flask import Flask, request, Response | |
| 24 | + | from converter import Converter | |
| 25 | + | ||
| 26 | + | app = Flask(__name__) | |
| 27 | + | conv = Converter() | |
| 28 | + | ||
| 29 | + | @app.route('/solar2lunar', methods=['GET']) | |
| 30 | + | def solar2lunar_endpoint(): | |
| 31 | + | date_str = request.args.get('date') | |
| 32 | + | if not date_str: | |
| 33 | + | return Response(json.dumps({'error': '缺少參數 date,格式 YYYY-MM-DD'}), mimetype='application/json; charset=utf-8'), 400 | |
| 34 | + | try: | |
| 35 | + | info_raw = conv.solar_to_lunar(date_str) | |
| 36 | + | info = { | |
| 37 | + | 'gregorian': info_raw['西曆'], # Gregorian date | |
| 38 | + | 'lunar': info_raw['農曆'], # Lunar date | |
| 39 | + | 'ganzhi': info_raw['干支'], # Heavenly Stem and Earthly Branch | |
| 40 | + | 'zodiac': info_raw['生肖'], # Zodiac animal | |
| 41 | + | 'solar_term': info_raw['節氣'] # Solar term | |
| 42 | + | } | |
| 43 | + | return Response(json.dumps(info, ensure_ascii=False), mimetype='application/json; charset=utf-8') | |
| 44 | + | except Exception as e: | |
| 45 | + | return Response(json.dumps({'error': str(e)}, ensure_ascii=False), mimetype='application/json; charset=utf-8'), 400 | |
| 46 | + | ||
| 47 | + | @app.route('/lunar2solar', methods=['GET']) | |
| 48 | + | def lunar2solar_endpoint(): | |
| 49 | + | date_str = request.args.get('date') | |
| 50 | + | leap_str = request.args.get('leap', 'false') | |
| 51 | + | if not date_str: | |
| 52 | + | return Response(json.dumps({'error': '缺少參數 date,格式 YYYY-MM-DD'}), mimetype='application/json; charset=utf-8'), 400 | |
| 53 | + | leap = leap_str.lower() in ('1', 'true', 'yes') | |
| 54 | + | try: | |
| 55 | + | y_str, m_str, d_str = date_str.split('-', 2) | |
| 56 | + | y, m, d = int(y_str), int(m_str), int(d_str) | |
| 57 | + | except Exception: | |
| 58 | + | return Response(json.dumps({'error': '農曆日期格式應為 YYYY-MM-DD,閏月請加 leap=true'}), mimetype='application/json; charset=utf-8'), 400 | |
| 59 | + | try: | |
| 60 | + | solar_date = conv.lunar_to_solar(y, m, d, leap) | |
| 61 | + | info_raw = conv.solar_to_lunar(solar_date) | |
| 62 | + | info = { | |
| 63 | + | 'gregorian': info_raw['西曆'], | |
| 64 | + | 'lunar': info_raw['農曆'], | |
| 65 | + | 'ganzhi': info_raw['干支'], | |
| 66 | + | 'zodiac': info_raw['生肖'], | |
| 67 | + | 'solar_term': info_raw['節氣'] | |
| 68 | + | } | |
| 69 | + | return Response(json.dumps(info, ensure_ascii=False), mimetype='application/json; charset=utf-8') | |
| 70 | + | except Exception as e: | |
| 71 | + | return Response(json.dumps({'error': str(e)}, ensure_ascii=False), mimetype='application/json; charset=utf-8'), 400 | |
| 72 | + | ||
| 73 | + | if __name__ == '__main__': | |
| 74 | + | port = find_free_port(5000) | |
| 75 | + | print(f"使用埠 {port} 啟動服務…") | |
| 76 | + | app.run(host='0.0.0.0', port=port) | |
generate_lunar_1900_2100.py(dosya oluşturuldu)
| @@ -0,0 +1,32 @@ | |||
| 1 | + | #!/usr/bin/env python3 | |
| 2 | + | # /// script | |
| 3 | + | # requires-python = ">=3.13" | |
| 4 | + | # dependencies = [ | |
| 5 | + | # "skyfield", | |
| 6 | + | # ] | |
| 7 | + | # /// | |
| 8 | + | ||
| 9 | + | # requires-python = ">=3.13" | |
| 10 | + | # dependencies = [ | |
| 11 | + | # "skyfield", | |
| 12 | + | # "jplephem", | |
| 13 | + | # "numpy", | |
| 14 | + | # ] | |
| 15 | + | import os | |
| 16 | + | ||
| 17 | + | from lunar_calendar import LunarCalendar # 假設類別存於 lunar_calendar.py | |
| 18 | + | ||
| 19 | + | def main(): | |
| 20 | + | # 輸出目錄 | |
| 21 | + | output_dir = 'lunar_json' | |
| 22 | + | os.makedirs(output_dir, exist_ok=True) | |
| 23 | + | ||
| 24 | + | # 產生 1900~2100 年,每年一個 JSON 檔:<year>.json | |
| 25 | + | for year in range(1900, 2101): | |
| 26 | + | cal = LunarCalendar(year) | |
| 27 | + | filename = os.path.join(output_dir, f"{year}.json") | |
| 28 | + | cal.save_json(filename) | |
| 29 | + | ||
| 30 | + | if __name__ == "__main__": | |
| 31 | + | main() | |
| 32 | + | ||
lunar_calendar.py(dosya oluşturuldu)
| @@ -0,0 +1,159 @@ | |||
| 1 | + | #!/usr/bin/env python3 | |
| 2 | + | # /// script | |
| 3 | + | # requires-python = ">=3.13" | |
| 4 | + | # dependencies = [ | |
| 5 | + | # "skyfield", | |
| 6 | + | # "jplephem", | |
| 7 | + | # "numpy", | |
| 8 | + | # ] | |
| 9 | + | # /// | |
| 10 | + | ||
| 11 | + | import json | |
| 12 | + | from datetime import date, datetime, timedelta | |
| 13 | + | from skyfield.api import load | |
| 14 | + | from skyfield.framelib import ecliptic_frame | |
| 15 | + | from skyfield.almanac import find_discrete, moon_phases | |
| 16 | + | ||
| 17 | + | class LunarCalendar: | |
| 18 | + | """利用 Skyfield 計算農曆、干支、生肖、節氣並輸出 JSON。""" | |
| 19 | + | ||
| 20 | + | TZ_OFFSET = timedelta(hours=8) # 台北時區 | |
| 21 | + | ||
| 22 | + | HEAVENLY_STEMS = ['甲','乙','丙','丁','戊','己','庚','辛','壬','癸'] | |
| 23 | + | EARTHLY_BRANCHES = ['子','丑','寅','卯','辰','巳','午','未','申','酉','戌','亥'] | |
| 24 | + | ZODIAC = { | |
| 25 | + | '子':'鼠','丑':'牛','寅':'虎','卯':'兔','辰':'龍','巳':'蛇', | |
| 26 | + | '午':'馬','未':'羊','申':'猴','酉':'雞','戌':'狗','亥':'豬' | |
| 27 | + | } | |
| 28 | + | SOLAR_TERMS_24 = { | |
| 29 | + | 315:'立春',330:'雨水',345:'驚蟄', 0:'春分', 15:'清明', 30:'穀雨', | |
| 30 | + | 45:'立夏', 60:'小滿', 75:'芒種', 90:'夏至',105:'小暑',120:'大暑', | |
| 31 | + | 135:'立秋',150:'處暑',165:'白露',180:'秋分',195:'寒露',210:'霜降', | |
| 32 | + | 225:'立冬',240:'小雪',255:'大雪',270:'冬至',285:'小寒',300:'大寒', | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | def __init__(self, year: int): | |
| 36 | + | self.year = year | |
| 37 | + | self.ts = load.timescale() | |
| 38 | + | # self.eph = load('de421.bsp') | |
| 39 | + | self.eph = load('de440s.bsp') | |
| 40 | + | self.earth= self.eph['earth'] | |
| 41 | + | self.sun = self.eph['sun'] | |
| 42 | + | ||
| 43 | + | def _compute_astronomy(self): | |
| 44 | + | """抓新朔與 24 節氣時間點""" | |
| 45 | + | t0 = self.ts.utc(self.year-1, 11, 1) | |
| 46 | + | t1 = self.ts.utc(self.year+1, 3, 1) | |
| 47 | + | ||
| 48 | + | # 每月朔 | |
| 49 | + | times, phases = find_discrete(t0, t1, moon_phases(self.eph)) | |
| 50 | + | new_moons = [t for t,p in zip(times, phases) if p == 0] | |
| 51 | + | ||
| 52 | + | # 每 15° 的節氣 | |
| 53 | + | def term_index(t): | |
| 54 | + | lat, lon, _ = self.earth.at(t).observe(self.sun).apparent() \ | |
| 55 | + | .frame_latlon(ecliptic_frame) | |
| 56 | + | return (lon.degrees // 15).astype(int) | |
| 57 | + | term_index.step_days = 1 | |
| 58 | + | ||
| 59 | + | st_times, st_idxs = find_discrete(t0, t1, term_index) | |
| 60 | + | zhongqi = [(t, (idx * 15) % 360) for t,idx in zip(st_times, st_idxs)] | |
| 61 | + | ||
| 62 | + | return new_moons, zhongqi | |
| 63 | + | ||
| 64 | + | def _label_months(self, new_moons, zhongqi): | |
| 65 | + | """根據冬至、中氣規則標記每段朔月的月號與閏月屬性""" | |
| 66 | + | cutoff = date(self.year,1,1) | |
| 67 | + | sols = [ | |
| 68 | + | t for t,deg in zhongqi | |
| 69 | + | if deg == 270 and (t.utc_datetime() + self.TZ_OFFSET).date() < cutoff | |
| 70 | + | ] | |
| 71 | + | solstice = max(sols) | |
| 72 | + | i0 = max(i for i,t in enumerate(new_moons) | |
| 73 | + | if t.utc_datetime() < solstice.utc_datetime()) | |
| 74 | + | ||
| 75 | + | labels = [] | |
| 76 | + | month_no = 11 | |
| 77 | + | leap_used = False | |
| 78 | + | ||
| 79 | + | for j in range(i0, len(new_moons)-1): | |
| 80 | + | start, end = new_moons[j], new_moons[j+1] | |
| 81 | + | cnt = sum(1 for t,deg in zhongqi | |
| 82 | + | if start < t < end and deg % 30 == 0) | |
| 83 | + | ||
| 84 | + | if j == i0: | |
| 85 | + | labels.append((start, end, 11, False)) | |
| 86 | + | else: | |
| 87 | + | if cnt == 0 and not leap_used: | |
| 88 | + | labels.append((start, end, month_no, True)) | |
| 89 | + | leap_used = True | |
| 90 | + | else: | |
| 91 | + | month_no = month_no % 12 + 1 | |
| 92 | + | labels.append((start, end, month_no, False)) | |
| 93 | + | ||
| 94 | + | return labels | |
| 95 | + | ||
| 96 | + | def _ganzhi_year(self, y): | |
| 97 | + | """計算指定年之干支""" | |
| 98 | + | s = self.HEAVENLY_STEMS[(y - 4) % 10] | |
| 99 | + | b = self.EARTHLY_BRANCHES[(y - 4) % 12] | |
| 100 | + | return s + b | |
| 101 | + | ||
| 102 | + | def build_calendar(self): | |
| 103 | + | """產生從 YYYY-01-01 至 YYYY-12-31 的完整農曆對照表""" | |
| 104 | + | new_moons, zhongqi = self._compute_astronomy() | |
| 105 | + | month_labels = self._label_months(new_moons, zhongqi) | |
| 106 | + | ||
| 107 | + | # 節氣快查 | |
| 108 | + | solar_terms = {} | |
| 109 | + | for t,deg in zhongqi: | |
| 110 | + | d = (t.utc_datetime() + self.TZ_OFFSET).date().isoformat() | |
| 111 | + | name = self.SOLAR_TERMS_24.get(deg) | |
| 112 | + | if name: | |
| 113 | + | solar_terms[d] = name | |
| 114 | + | ||
| 115 | + | result = {} | |
| 116 | + | day = date(self.year,1,1) | |
| 117 | + | end = date(self.year,12,31) | |
| 118 | + | ||
| 119 | + | while day <= end: | |
| 120 | + | for ts, te, m, is_leap in month_labels: | |
| 121 | + | ds = (ts.utc_datetime() + self.TZ_OFFSET).date() | |
| 122 | + | de = (te.utc_datetime() + self.TZ_OFFSET).date() | |
| 123 | + | if ds <= day < de: | |
| 124 | + | # 農曆年:11–12 月屬上一農曆年,其它屬當年 | |
| 125 | + | lunar_year = self.year if 1 <= m <= 10 else self.year-1 | |
| 126 | + | gz_year = self._ganzhi_year(lunar_year) | |
| 127 | + | zodiac = self.ZODIAC[self.EARTHLY_BRANCHES[(lunar_year - 4) % 12]] | |
| 128 | + | ||
| 129 | + | offset = (day - ds).days + 1 | |
| 130 | + | lunar_str = f"{'閏' if is_leap else ''}{m}月{offset}日" | |
| 131 | + | result[day.isoformat()] = { | |
| 132 | + | "西曆": day.isoformat(), | |
| 133 | + | "農曆": lunar_str, | |
| 134 | + | "干支": gz_year, | |
| 135 | + | "生肖": zodiac, | |
| 136 | + | "節氣": solar_terms.get(day.isoformat(), "") | |
| 137 | + | } | |
| 138 | + | break | |
| 139 | + | day += timedelta(days=1) | |
| 140 | + | ||
| 141 | + | return result | |
| 142 | + | ||
| 143 | + | def save_json(self, filename: str = None): | |
| 144 | + | """將該年日曆輸出為 JSON 檔""" | |
| 145 | + | data = self.build_calendar() | |
| 146 | + | fname = filename or f"{self.year}.json" | |
| 147 | + | with open(fname, "w", encoding="utf-8") as f: | |
| 148 | + | json.dump(data, f, ensure_ascii=False, indent=2) | |
| 149 | + | print(f"完成!已輸出:{fname}") | |
| 150 | + | ||
| 151 | + | if __name__ == "__main__": | |
| 152 | + | import sys | |
| 153 | + | if len(sys.argv) != 2 or not sys.argv[1].isdigit(): | |
| 154 | + | print("用法:python3 lunar_calendar.py <年份>") | |
| 155 | + | sys.exit(1) | |
| 156 | + | year = int(sys.argv[1]) | |
| 157 | + | cal = LunarCalendar(year) | |
| 158 | + | cal.save_json() | |
| 159 | + | ||