Ostatnio aktywny 10 months ago

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

timmy zrewidował ten Gist 10 months ago. Przejdź do rewizji

Brak zmian

timmy zrewidował ten Gist 10 months ago. Przejdź do rewizji

4 files changed, 355 insertions

converter.py(stworzono plik)

@@ -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(stworzono plik)

@@ -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(stworzono plik)

@@ -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(stworzono plik)

@@ -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 +
Nowsze Starsze