cwadayi commited on
Commit
4804f0f
·
verified ·
1 Parent(s): 363e015

Upload 8 files

Browse files
Files changed (8) hide show
  1. ai_service.py +82 -0
  2. app.py +127 -0
  3. command_handler.py +87 -0
  4. config.py +43 -0
  5. cwa_service.py +97 -0
  6. plotting_service.py +53 -0
  7. requirements.txt +10 -0
  8. usgs_service.py +65 -0
ai_service.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ai_service.py
2
+ import os
3
+ from config import (
4
+ LLM_DEVICE, LLM_THREADS, LLM_MODEL, TRANSFORMERS_CACHE,
5
+ LLM_MAX_NEW_TOKENS, LLM_TOP_K, LLM_TEMPERATURE
6
+ )
7
+
8
+ # 用於延遲載入語言模型的字典
9
+ _LLM = {"loaded": False, "ok": False, "err": None, "model": None, "tokenizer": None, "device": "cpu"}
10
+
11
+ def _ensure_llm():
12
+ """在首次使用時載入 AI 模型與分詞器。"""
13
+ if _LLM["loaded"]:
14
+ return _LLM["ok"], _LLM["err"]
15
+
16
+ _LLM["loaded"] = True
17
+ try:
18
+ import torch
19
+ from transformers import AutoTokenizer, AutoModelForCausalLM
20
+
21
+ device = LLM_DEVICE
22
+ if device not in ("cuda", "cpu"):
23
+ device = "cuda" if torch.cuda.is_available() else "cpu"
24
+ torch.set_num_threads(max(1, int(LLM_THREADS)))
25
+
26
+ tok = AutoTokenizer.from_pretrained(LLM_MODEL, cache_dir=TRANSFORMERS_CACHE)
27
+ mdl = AutoModelForCausalLM.from_pretrained(LLM_MODEL, cache_dir=TRANSFORMERS_CACHE)
28
+
29
+ try:
30
+ mdl = mdl.to(device)
31
+ except Exception:
32
+ device = "cpu"
33
+ mdl = mdl.to(device)
34
+
35
+ _LLM.update({"ok": True, "model": mdl, "tokenizer": tok, "device": device})
36
+ return True, None
37
+ except Exception as e:
38
+ _LLM["err"] = f"{e}"
39
+ _LLM["ok"] = False
40
+ return False, _LLM["err"]
41
+
42
+ def generate_ai_text(user_prompt: str) -> str:
43
+ """使用已載入的 AI 模型生成文字回應。"""
44
+ ok, err = _ensure_llm()
45
+ if not ok:
46
+ return (
47
+ "🤖 AI 尚未啟用:缺少依賴或模型未下載。\n"
48
+ "請在 requirements.txt 加入 transformers、torch、accelerate、safetensors 等。\n"
49
+ f"詳細錯誤:{err}"
50
+ )
51
+
52
+ import torch
53
+ tok = _LLM["tokenizer"]
54
+ mdl = _LLM["model"]
55
+ device = _LLM["device"]
56
+
57
+ sys_prefix = (
58
+ "你是一個地震資訊與一般問答的 LINE 助理。回答要精簡、清楚;"
59
+ "若與地震相關可加入注意事項;若無關則一般回覆。\n\n使用者:"
60
+ )
61
+ prompt = sys_prefix + user_prompt
62
+
63
+ try:
64
+ inputs = tok(prompt, return_tensors="pt").to(device)
65
+ with torch.no_grad():
66
+ output = mdl.generate(
67
+ input_ids=inputs["input_ids"],
68
+ attention_mask=inputs.get("attention_mask"),
69
+ max_new_tokens=LLM_MAX_NEW_TOKENS,
70
+ do_sample=True,
71
+ top_k=LLM_TOP_K,
72
+ temperature=LLM_TEMPERATURE,
73
+ pad_token_id=tok.eos_token_id,
74
+ )
75
+ text = tok.decode(output[0], skip_special_tokens=True)
76
+ if sys_prefix in text:
77
+ text = text.split(sys_prefix, 1)[-1]
78
+ if user_prompt in text:
79
+ text = text.split(user_prompt, 1)[-1].strip()
80
+ return (text or "(沒有產生內容)")[:1200]
81
+ except Exception as e:
82
+ return f"AI 產生發生錯誤:{e}"
app.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py
2
+ # 首先匯入 config,以設定環境變數
3
+ import config
4
+
5
+ from flask import Flask, request, abort, send_from_directory
6
+ from linebot.v3 import WebhookHandler
7
+ from linebot.v3.exceptions import InvalidSignatureError
8
+ from linebot.v3.messaging import (
9
+ Configuration, ApiClient, MessagingApi,
10
+ ReplyMessageRequest
11
+ )
12
+ from linebot.v3.webhooks import MessageEvent, TextMessageContent
13
+
14
+ # 匯入指令處理器
15
+ from command_handler import process_message
16
+
17
+ # ------------------------------------------------------------------------------
18
+ # Flask & LINE Bot 設定
19
+ # ------------------------------------------------------------------------------
20
+ app = Flask(__name__)
21
+ line_config = Configuration(access_token=config.CHANNEL_ACCESS_TOKEN)
22
+ handler = WebhookHandler(config.CHANNEL_SECRET)
23
+
24
+ # ------------------------------------------------------------------------------
25
+ # Web 伺服器路由
26
+ # ------------------------------------------------------------------------------
27
+ @app.route("/", methods=["GET"])
28
+ def home():
29
+ """渲染首頁,包含說明與狀態。"""
30
+ base = (config.HF_SPACE_URL or request.url_root).rstrip("/")
31
+ webhook_url = f"{base}/callback"
32
+ static_hint = f"{base}/static/<filename>"
33
+ channel_ok = "✅" if config.CHANNEL_ACCESS_TOKEN and config.CHANNEL_SECRET else "⚠️"
34
+ space_ok = "✅" if config.HF_SPACE_URL else "ℹ️"
35
+
36
+ return f"""
37
+ <!doctype html>
38
+ <html lang="zh-Hant"><head>
39
+ <meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
40
+ <title>地震預警 dayichen – LINE Bot Server</title>
41
+ <style>
42
+ :root{{--bg:#0f1115;--card:#151821;--text:#e6e8ef;--muted:#9aa4b2;--border:rgba(255,255,255,.08)}}
43
+ *{{box-sizing:border-box}} body{{margin:0;background:#0f1115;color:#e6e8ef;
44
+ font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans TC","PingFang TC",sans-serif;
45
+ padding:32px 16px;display:flex;justify-content:center}}
46
+ .wrap{{width:100%;max-width:980px}} .hero{{background:linear-gradient(135deg,#1f2937,#0f172a);
47
+ border:1px solid var(--border);border-radius:16px;padding:28px;margin-bottom:20px;box-shadow:0 8px 30px rgba(0,0,0,.25)}}
48
+ .title{{margin:0 0 6px;font-size:28px;font-weight:800}} .subtitle{{margin:0;color:#9aa4b2}}
49
+ .grid{{display:grid;gap:16px;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));margin-top:18px}}
50
+ .card{{background:#151821;border:1px solid var(--border);border-radius:14px;padding:16px 18px}}
51
+ h3{{margin:0 0 8px;font-size:18px}} .kbd{{padding:2px 6px;border:1px solid var(--border);border-radius:6px;background:#0b0e14}}
52
+ .mono,code{{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#e6e8ef}}
53
+ a{{color:#60a5fa;text-decoration:none}} a:hover{{text-decoration:underline}}
54
+ .badge{{display:inline-block;padding:2px 8px;border-radius:999px;background:#1f2937;border:1px solid var(--border);font-size:12px;color:#9aa4b2}}
55
+ .foot{{color:#9aa4b2;font-size:13px;margin-top:18px;text-align:center}}
56
+ </style></head>
57
+ <body><div class="wrap"><section class="hero">
58
+ <div class="badge">狀態:<span style="color:{'#86efac' if channel_ok=='✅' else '#fbbf24'}">{channel_ok}</span> LINE 金鑰 · HF Space:{space_ok}</div>
59
+ <h1 class="title">地震預警 dayichen – LINE Bot</h1>
60
+ <p class="subtitle">指令:/help、地震/quake、臺灣地震/台灣地震、臺灣地震畫圖/台灣地震畫圖、地震預警、AI(ai + 問題)。</p>
61
+ <div class="grid">
62
+ <div class="card"><h3>🚀 快速開始</h3><ul>
63
+ <li><span class="kbd">/help</span>:顯示所有指令</li>
64
+ <li><span class="kbd">地震</span>/<span class="kbd">quake</span>:全球近 24 小時 M≥5.0</li>
65
+ <li><span class="kbd">臺灣地震</span>/<span class="kbd">台灣地震</span>:今年台灣區域清單(含日期時間)</li>
66
+ <li><span class="kbd">臺灣地震畫圖</span>/<span class="kbd">台灣地震畫圖</span>:回傳地圖圖片</li>
67
+ <li><span class="kbd">地震預警</span>:CWA 地震預警(最新 5 筆)</li>
68
+ <li><span class="kbd">ai 你的問題</span>:AI 對話(模型:<span class="mono">{config.LLM_MODEL}</span>)</li>
69
+ </ul></div>
70
+ <div class="card"><h3>🛠️ Webhook / 靜態檔</h3><ul>
71
+ <li>Webhook:<span class="mono"><a href="{webhook_url}">{webhook_url}</a></span></li>
72
+ <li>靜態圖片:<span class="mono">{static_hint}</span></li>
73
+ <li>健康檢查:<span class="mono"><a href="{base}/healthz">{base}/healthz}</a></span></li>
74
+ </ul></div>
75
+ <div class="card"><h3>ℹ️ 備註</h3><ul>
76
+ <li>AI 快取位置:<span class="mono">{config.TRANSFORMERS_CACHE}</span></li>
77
+ <li>若 AI 未安裝依賴,機器人會提示安裝,不會影響其他功能。</li>
78
+ </ul></div>
79
+ </div>
80
+ <p class="foot">© {config.CURRENT_YEAR} dayichen · server: {base}</p>
81
+ </section></div></body></html>"""
82
+
83
+ @app.route("/healthz")
84
+ def healthz():
85
+ """健康檢查端點。"""
86
+ return "ok"
87
+
88
+ @app.route("/static/<path:filename>")
89
+ def serve_static(filename):
90
+ """提供���態檔案(例如,生成的地圖)。"""
91
+ return send_from_directory(config.STATIC_DIR, filename)
92
+
93
+ # ------------------------------------------------------------------------------
94
+ # LINE Webhook 處理器
95
+ # ------------------------------------------------------------------------------
96
+ @app.route("/callback", methods=["POST"])
97
+ def callback():
98
+ """處理來自 LINE 平台的傳入 Webhooks。"""
99
+ signature = request.headers.get("X-Line-Signature")
100
+ body = request.get_data(as_text=True)
101
+ try:
102
+ handler.handle(body, signature)
103
+ except InvalidSignatureError:
104
+ abort(400)
105
+ return "OK"
106
+
107
+ @handler.add(MessageEvent, message=TextMessageContent)
108
+ def handle_message(event):
109
+ """
110
+ 處理來自使用者的文字訊息並回覆。
111
+ 所有邏輯都委派給 command_handler。
112
+ """
113
+ # 決定用於生成圖片連結的基礎 URL
114
+ base_url = request.url_root.rstrip("/")
115
+
116
+ # 從處理器獲取回覆訊息
117
+ reply_messages = process_message(event.message.text, base_url)
118
+
119
+ # 發送回覆
120
+ with ApiClient(line_config) as api_client:
121
+ line_bot_api = MessagingApi(api_client)
122
+ line_bot_api.reply_message_with_http_info(
123
+ ReplyMessageRequest(
124
+ reply_token=event.reply_token,
125
+ messages=reply_messages
126
+ )
127
+ )
command_handler.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # command_handler.py
2
+ import pandas as pd
3
+ from linebot.v3.messaging import TextMessage, ImageMessage
4
+
5
+ # 匯入服務函式
6
+ from cwa_service import fetch_cwa_alarm_list
7
+ from usgs_service import fetch_global_last24h_text, fetch_taiwan_df_this_year
8
+ from plotting_service import create_and_save_map
9
+ from ai_service import generate_ai_text
10
+ from config import CURRENT_YEAR, HF_SPACE_URL
11
+
12
+ def get_help_message() -> TextMessage:
13
+ """回傳包含所有可用指令的說明訊息。"""
14
+ text = (
15
+ "📖 指令\n\n"
16
+ "• /help\n"
17
+ "• 地震 / quake(全球近24小時,含日期時間)\n"
18
+ "• 臺灣地震 / 台灣地震(今年台灣區域清單)\n"
19
+ "• 臺灣地震畫圖 / 台灣地震畫圖(今年台灣區域分佈圖)\n"
20
+ "• 地震預警(CWA 最新 5 筆)\n"
21
+ "• ai 你的問題(AI 對話)\n"
22
+ "• 你好"
23
+ )
24
+ return TextMessage(text=text)
25
+
26
+ def get_taiwan_earthquake_list() -> TextMessage:
27
+ """回傳近期的台灣地震文字列表。"""
28
+ result = fetch_taiwan_df_this_year()
29
+ if isinstance(result, pd.DataFrame):
30
+ count = len(result)
31
+ lines = [f"🇹🇼 今年 ({CURRENT_YEAR} 年) 台灣區域顯著地震 (M≥5.0),共 {count} 筆:", "-" * 20]
32
+ for _, row in result.head(15).iterrows():
33
+ t = row["time_utc"].strftime("%Y-%m-%d %H:%M")
34
+ lines.append(f"震級: {row['magnitude']:.1f} | 日期時間: {t} (UTC)\n地點: {row['place']}")
35
+ if count > 15:
36
+ lines.append(f"... (還有 {count - 15} 筆,可用「臺灣地震畫圖」查看全部)")
37
+ reply_text = "\n\n".join(lines)
38
+ else:
39
+ reply_text = result
40
+ return TextMessage(text=reply_text)
41
+
42
+ def get_taiwan_earthquake_map(base_url: str) -> list:
43
+ """產生並回傳台灣地震地圖的訊息。"""
44
+ result = fetch_taiwan_df_this_year()
45
+ if isinstance(result, pd.DataFrame):
46
+ filename = create_and_save_map(result)
47
+ # 如果 HF_SPACE_URL 存在就使用它,否則使用請求的 base_url
48
+ image_url = f"{(HF_SPACE_URL or base_url)}/static/{filename}"
49
+ return [
50
+ TextMessage(text="🗺️ 已為您繪製今年台灣區域 M≥5.0 地震分佈圖(UTC)。"),
51
+ ImageMessage(original_content_url=image_url, preview_image_url=image_url),
52
+ ]
53
+ else:
54
+ return [TextMessage(text=result)]
55
+
56
+ def process_message(user_message_raw: str, request_base_url: str) -> list:
57
+ """處理使用者的文字訊息並回傳一個包含回覆訊息的列表。"""
58
+ user_message = (user_message_raw or "").strip().lower()
59
+
60
+ if "地震預警" in user_message:
61
+ reply_text = fetch_cwa_alarm_list(limit=5)
62
+ return [TextMessage(text=reply_text)]
63
+
64
+ if "臺灣地震畫圖" in user_message or "台灣地震畫圖" in user_message:
65
+ return get_taiwan_earthquake_map(request_base_url)
66
+
67
+ if "臺灣地震" in user_message or "台灣地震" in user_message:
68
+ return [get_taiwan_earthquake_list()]
69
+
70
+ if user_message == "/help":
71
+ return [get_help_message()]
72
+
73
+ if "地震" in user_message or "quake" in user_message:
74
+ reply_text = fetch_global_last24h_text()
75
+ return [TextMessage(text=reply_text)]
76
+
77
+ if user_message.startswith("ai ") or user_message.startswith("ai:") or user_message.startswith("ai:"):
78
+ prompt = user_message_raw[2:].lstrip(" ::").strip() or "請簡要介紹你的功能。"
79
+ ai_text = generate_ai_text(prompt)
80
+ return [TextMessage(text=ai_text)]
81
+
82
+ if "你好" in user_message or "hi" in user_message:
83
+ return [TextMessage(text="👋 你好!輸入 /help 查看指令。")]
84
+
85
+ # 對於所有其他訊息,使用 AI 作為備援回覆
86
+ fallback_text = generate_ai_text(user_message_raw)
87
+ return [TextMessage(text=fallback_text)]
config.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # config.py
2
+ import os
3
+ import tempfile
4
+ from datetime import datetime
5
+
6
+ # --- 環境設定 ---
7
+ # 設定 Matplotlib 與 Hugging Face 模型的快取目錄
8
+ os.environ.setdefault("MPLCONFIGDIR", "/tmp/matplotlib")
9
+ os.environ.setdefault("TRANSFORMERS_CACHE", "/tmp/huggingface")
10
+ os.makedirs(os.environ["TRANSFORMERS_CACHE"], exist_ok=True)
11
+
12
+ # --- LINE Bot 憑證 ---
13
+ CHANNEL_ACCESS_TOKEN = os.getenv("CHANNEL_ACCESS_TOKEN")
14
+ CHANNEL_SECRET = os.getenv("CHANNEL_SECRET")
15
+
16
+ # --- Hugging Face Space URL ---
17
+ HF_SPACE_URL = os.getenv("SPACEURL")
18
+ if not HF_SPACE_URL:
19
+ sid = os.getenv("SPACE_ID")
20
+ if sid and "/" in sid:
21
+ author, name = sid.split("/", 1)
22
+ HF_SPACE_URL = f"https://{author.replace('_', '-')}-{name.replace('_', '-')}.hf.space"
23
+ else:
24
+ HF_SPACE_URL = ""
25
+
26
+ # --- 靜態檔案目錄 ---
27
+ STATIC_DIR = os.getenv("STATIC_DIR", os.path.join(tempfile.gettempdir(), "static"))
28
+ os.makedirs(STATIC_DIR, exist_ok=True)
29
+
30
+ # --- API 端點 ---
31
+ CWA_ALARM_API = "https://app-2.cwa.gov.tw/api/v1/earthquake/alarm/list"
32
+ USGS_API_BASE_URL = "https://earthquake.usgs.gov/fdsnws/event/1/query"
33
+
34
+ # --- AI 模型設定 ---
35
+ LLM_DEVICE = os.getenv("LLM_DEVICE")
36
+ LLM_THREADS = os.getenv("LLM_THREADS", "1")
37
+ LLM_MODEL = os.getenv("LLM_MODEL", "ckiplab/gpt2-base-chinese")
38
+ LLM_MAX_NEW_TOKENS = int(os.getenv("LLM_MAX_NEW_TOKENS", "120"))
39
+ LLM_TOP_K = int(os.getenv("LLM_TOP_K", "50"))
40
+ LLM_TEMPERATURE = float(os.getenv("LLM_TEMPERATURE", "0.7"))
41
+
42
+ # --- 顯示用當年年份 ---
43
+ CURRENT_YEAR = datetime.now().year
cwa_service.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # cwa_service.py
2
+ # -*- coding: utf-8 -*-
3
+ from __future__ import annotations
4
+ import requests
5
+ from datetime import datetime, timedelta, timezone
6
+ from config import CWA_ALARM_API
7
+
8
+ def _parse_cwa_time(s: str) -> tuple[str, str]:
9
+ """回傳 (台灣時間, UTC);若字串無時區,預設視為台灣時間。"""
10
+ if not s:
11
+ return ("未知", "未知")
12
+ try:
13
+ if "T" in s or s.endswith("Z") or "+" in s:
14
+ dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
15
+ else:
16
+ dt = datetime.strptime(s, "%Y-%m-%d %H:%M:%S")
17
+ dt = dt.replace(tzinfo=timezone(timedelta(hours=8)))
18
+ tw = dt.astimezone(timezone(timedelta(hours=8))).strftime("%Y-%m-%d %H:%M")
19
+ utc = dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M")
20
+ return (tw, utc)
21
+ except Exception:
22
+ return (s, "未知")
23
+
24
+ def fetch_cwa_alarm_list(limit: int = 5) -> str:
25
+ """抓 CWA 地震預警並格式化輸出。"""
26
+ try:
27
+ r = requests.get(CWA_ALARM_API, timeout=10)
28
+ r.raise_for_status()
29
+ payload = r.json()
30
+ except Exception as e:
31
+ return f"❌ 地震預警查詢失敗:{e}"
32
+
33
+ items = None
34
+ if isinstance(payload, dict):
35
+ items = payload.get("data") or payload.get("records") or payload.get("list") or payload.get("items")
36
+ if items is None and isinstance(payload, list):
37
+ items = payload
38
+ if not items:
39
+ return "✅ 目前沒有地震預警。"
40
+
41
+ def _key(it):
42
+ s = it.get("originTime") or ""
43
+ try:
44
+ if "T" in s or s.endswith("Z") or "+" in s:
45
+ dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
46
+ else:
47
+ dt = datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone(timedelta(hours=8)))
48
+ return dt.astimezone(timezone.utc)
49
+ except Exception:
50
+ return datetime.min.replace(tzinfo=timezone.utc)
51
+
52
+ try:
53
+ items = sorted(items, key=_key, reverse=True)
54
+ except Exception:
55
+ pass
56
+
57
+ def _num(x):
58
+ xs = str(x)
59
+ ok = xs.replace(".", "", 1).replace("-", "", 1).isdigit()
60
+ return float(xs) if ok else None
61
+
62
+ lines = ["🚨 地震預警(最新):", "-" * 20]
63
+ for idx, it in enumerate(items[:limit]):
64
+ identifier = it.get("identifier") or it.get("eventId") or it.get("id") or "—"
65
+ status = it.get("status") or "—"
66
+ msg_type = it.get("msgType") or "—"
67
+ msg_no = it.get("msgNo") or it.get("msgSeq") or "—"
68
+ mag = _num(it.get("magnitudeValue") or it.get("magnitude") or it.get("ml") or it.get("mw"))
69
+ mag_str = f"{mag:.1f}" if mag is not None else "—"
70
+ depth = _num(it.get("depth"))
71
+ depth_str = f"{depth:.0f}" if depth is not None else "—"
72
+ lat = _num(it.get("epicenterLat") or it.get("latitude") or it.get("lat"))
73
+ lon = _num(it.get("epicenterLon") or it.get("longitude") or it.get("lon"))
74
+ lat_str = f"{lat:.2f}" if lat is not None else "—"
75
+ lon_str = f"{lon:.2f}" if lon is not None else "—"
76
+ origin = it.get("originTime") or ""
77
+ tw_str, utc_str = _parse_cwa_time(origin)
78
+ areas = it.get("locationDesc") or it.get("areas") or it.get("alertAreas")
79
+ if isinstance(areas, list):
80
+ areas_txt = "、".join(str(a) for a in areas if a)
81
+ elif isinstance(areas, str):
82
+ areas_txt = areas
83
+ else:
84
+ areas_txt = "—"
85
+ lines.append(
86
+ f"事件: {identifier} | 狀態: {status} | 類型: {msg_type}#{msg_no}\n"
87
+ f"震級/深度: M{mag_str} / {depth_str} km\n"
88
+ f"震中: lat {lat_str}, lon {lon_str}\n"
89
+ f"時間: {tw_str}(台灣) / {utc_str}(UTC)\n"
90
+ f"預警地區: {areas_txt}"
91
+ )
92
+ lines.append("")
93
+
94
+ if len(items) > limit:
95
+ lines.append(f"... 另有 {len(items) - limit} 筆。")
96
+
97
+ return "\n".join(lines).strip()
plotting_service.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # plotting_service.py
2
+ import os
3
+ import uuid
4
+ import pandas as pd
5
+ import matplotlib
6
+ matplotlib.use("Agg") # 使用非互動式後端
7
+ import matplotlib.pyplot as plt
8
+ from matplotlib.colors import Normalize
9
+ import matplotlib.cm as cm
10
+ from matplotlib import font_manager as fm
11
+ from config import STATIC_DIR, CURRENT_YEAR
12
+
13
+ def setup_chinese_font():
14
+ """如果系統中存在,則為 Matplotlib 設定中文字體。"""
15
+ font_paths = [
16
+ "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
17
+ "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
18
+ "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
19
+ "/usr/share/fonts/truetype/arphic/ukai.ttc",
20
+ ]
21
+ for fp in font_paths:
22
+ if os.path.exists(fp):
23
+ matplotlib.rcParams["font.family"] = fm.FontProperties(fname=fp).get_name()
24
+ break
25
+
26
+ def create_and_save_map(df: pd.DataFrame) -> str:
27
+ """建立地震地圖,儲存圖片並回傳檔案名稱。"""
28
+ setup_chinese_font()
29
+ fig, ax = plt.subplots(figsize=(9, 6), dpi=150)
30
+ ax.set_xlim(118.5, 123.5)
31
+ ax.set_ylim(20.5, 26.8)
32
+ ax.set_xlabel("Longitude (°E)")
33
+ ax.set_ylabel("Latitude (°N)")
34
+ ax.set_title(f"今年 ({CURRENT_YEAR}) 台灣區域顯著地震 (M≥5.0) — UTC")
35
+ ax.grid(True, linestyle="--", linewidth=0.5, alpha=0.4)
36
+
37
+ mags = df["magnitude"].astype(float).clip(lower=0)
38
+ norm = Normalize(vmin=max(4.5, mags.min()), vmax=max(6.5, mags.max()))
39
+ cmap = cm.get_cmap("YlOrRd")
40
+ colors = cmap(norm(mags.values))
41
+ sizes = 15 + (mags - mags.min()) * 25
42
+
43
+ ax.scatter(df["longitude"].values, df["latitude"].values,
44
+ s=sizes, c=colors, edgecolor="k", linewidths=0.4, alpha=0.9)
45
+
46
+ fig.colorbar(cm.ScalarMappable(norm=norm, cmap=cmap), ax=ax, pad=0.02).set_label("Magnitude")
47
+
48
+ filename = f"map_{uuid.uuid4().hex}.png"
49
+ filepath = os.path.join(STATIC_DIR, filename)
50
+ fig.tight_layout()
51
+ fig.savefig(filepath)
52
+ plt.close(fig)
53
+ return filename
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ flask
2
+ gunicorn
3
+ line-bot-sdk>=3.0.0,<4
4
+ requests
5
+ pandas
6
+ matplotlib
7
+ transformers
8
+ torch
9
+ accelerate
10
+ safetensors
usgs_service.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # usgs_service.py
2
+ import requests
3
+ import pandas as pd
4
+ from datetime import datetime, timedelta, timezone
5
+ from config import USGS_API_BASE_URL, CURRENT_YEAR
6
+
7
+ def _iso(dt: datetime) -> str:
8
+ """將 datetime 物件格式化為 USGS API 需要的 ISO 8601 字串。"""
9
+ return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
10
+
11
+ def fetch_global_last24h_text(min_mag: float = 5.0, limit: int = 10) -> str:
12
+ """從 USGS 擷取過去 24 小時的全球顯著地震。"""
13
+ now_utc = datetime.now(timezone.utc)
14
+ since = now_utc - timedelta(hours=24)
15
+ params = {
16
+ "format": "geojson",
17
+ "starttime": _iso(since),
18
+ "endtime": _iso(now_utc),
19
+ "minmagnitude": float(min_mag),
20
+ "limit": int(limit),
21
+ "orderby": "time",
22
+ }
23
+ try:
24
+ r = requests.get(USGS_API_BASE_URL, params=params, timeout=15)
25
+ r.raise_for_status()
26
+ features = r.json().get("features", [])
27
+ if not features:
28
+ return f"✅ 過去 24 小時內,全球無規模 {min_mag} 以上的顯著地震。"
29
+ lines = [f"🚨 近 24 小時全球顯著地震 (M≥{min_mag}):", "-" * 20]
30
+ for f in features:
31
+ p = f["properties"]
32
+ t_utc = datetime.fromtimestamp(p["time"] / 1000, tz=timezone.utc)
33
+ lines.append(f"震級: {p['mag']:.1f} | 日期時間: {t_utc.strftime('%Y-%m-%d %H:%M')} (UTC)\n地點: {p.get('place','')}")
34
+ return "\n\n".join(lines)
35
+ except Exception as e:
36
+ return f"❌ 查詢失敗: {e}"
37
+
38
+ def fetch_taiwan_df_this_year(min_mag: float = 5.0) -> pd.DataFrame | str:
39
+ """擷取今年以來台灣區域的顯著地震。"""
40
+ now_utc = datetime.now(timezone.utc)
41
+ start_of_year_utc = datetime(now_utc.year, 1, 1, tzinfo=timezone.utc)
42
+ params = {
43
+ "format": "geojson", "starttime": _iso(start_of_year_utc), "endtime": _iso(now_utc),
44
+ "minmagnitude": float(min_mag),
45
+ "minlatitude": 21, "maxlatitude": 26,
46
+ "minlongitude": 119, "maxlongitude": 123,
47
+ "limit": 250, "orderby": "time",
48
+ }
49
+ try:
50
+ r = requests.get(USGS_API_BASE_URL, params=params, timeout=20)
51
+ r.raise_for_status()
52
+ features = r.json().get("features", [])
53
+ if not features:
54
+ return f"✅ 今年 ({CURRENT_YEAR} 年) 以來,台灣區域無 M≥{min_mag:.1f} 的顯著地震。"
55
+ rows = []
56
+ for f in features:
57
+ p = f["properties"]
58
+ lon, lat, *_ = f["geometry"]["coordinates"]
59
+ rows.append({
60
+ "latitude": lat, "longitude": lon, "magnitude": p["mag"],
61
+ "place": p.get("place", ""), "time_utc": datetime.fromtimestamp(p["time"]/1000, tz=timezone.utc)
62
+ })
63
+ return pd.DataFrame(rows)
64
+ except Exception as e:
65
+ return f"❌ 查詢失敗: {e}"