最終更新 7 months ago

結合 Flask 建立 API 服務,提供西曆與農曆的互轉功能,並自動尋找可用端口啟動服務。

timmy revised this gist 7 months ago. Go to revision

No changes

timmy revised this gist 7 months ago. Go to revision

4 files changed, 355 insertions

converter.py(file created)

@@ -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(file created)

@@ -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(file created)

@@ -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(file created)

@@ -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 +
Newer Older