Last active 7 months ago

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

converter.py Raw
1#!/usr/bin/env python3
2# converter.py
3# 用於西曆與農曆互轉,已封裝成類別
4
5import os
6import json
7import argparse
8from datetime import datetime
9
10class 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
58def 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
87if __name__ == '__main__':
88 main()
89
flask_api.py Raw
1# /// script
2# requires-python = ">=3.13"
3# dependencies = [
4# "flask",
5# ]
6# ///
7import socket
8
9def 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
22import json
23from flask import Flask, request, Response
24from converter import Converter
25
26app = Flask(__name__)
27conv = Converter()
28
29@app.route('/solar2lunar', methods=['GET'])
30def 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'])
48def 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
73if __name__ == '__main__':
74 port = find_free_port(5000)
75 print(f"使用埠 {port} 啟動服務…")
76 app.run(host='0.0.0.0', port=port)
77
generate_lunar_1900_2100.py Raw
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# ]
15import os
16
17from lunar_calendar import LunarCalendar # 假設類別存於 lunar_calendar.py
18
19def 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
30if __name__ == "__main__":
31 main()
32
33
lunar_calendar.py Raw
1#!/usr/bin/env python3
2# /// script
3# requires-python = ">=3.13"
4# dependencies = [
5# "skyfield",
6# "jplephem",
7# "numpy",
8# ]
9# ///
10
11import json
12from datetime import date, datetime, timedelta
13from skyfield.api import load
14from skyfield.framelib import ecliptic_frame
15from skyfield.almanac import find_discrete, moon_phases
16
17class 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
151if __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
160