Ethscriptions commited on
Commit
8c6b9c4
·
verified ·
1 Parent(s): d67dd17

Update pages/monitor.py

Browse files
Files changed (1) hide show
  1. pages/monitor.py +169 -135
pages/monitor.py CHANGED
@@ -4,7 +4,6 @@ import time
4
  import json
5
  import os
6
  import threading
7
- import queue
8
  import re
9
  from datetime import datetime, timedelta, timezone, time as dt_time
10
  from collections import deque
@@ -22,12 +21,20 @@ except ImportError:
22
  # --- 1. 配置与常量 ---
23
  load_dotenv()
24
 
 
25
  PUSHOVER_TOKEN = os.getenv("PUSHOVER_TOKEN")
26
  PUSHOVER_USER = os.getenv("PUSHOVER_USER")
27
- CINEMA_ID = os.getenv("CINEMA_ID")
28
 
29
- # 新增:NTFY 配置
30
- NTFY_TOPIC = os.getenv("ntfy_code") # 获取 NTFY 主题名
 
 
 
 
 
 
 
 
31
 
32
  # 获取 Token 文件路径
33
  CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -77,34 +84,77 @@ def simplify_hall_name(raw_name):
77
  return raw_name
78
 
79
 
80
- def send_pushover(title, message, priority=0):
81
- """发送 Pushover 通知 (返回是否成功)"""
82
- if not PUSHOVER_TOKEN or not PUSHOVER_USER:
83
- return False
84
-
85
- url = "https://api.pushover.net/1/messages.json"
86
- data = {
87
- "token": PUSHOVER_TOKEN,
88
- "user": PUSHOVER_USER,
89
- "title": title,
90
- "message": message,
91
- "sound": "pushover",
92
- "priority": priority
93
- }
94
- try:
95
- response = requests.post(url, data=data, timeout=10)
96
- if response.status_code != 200:
97
- print(f"❌ Pushover 拒绝: {response.status_code}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  return False
99
- else:
100
- print(f"✅ Pushover 发送成功: {title}")
101
- return True
102
- except Exception as e:
103
- print(f"❌ Pushover 网络异常: {e}")
104
- return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
 
106
 
107
- # --- 3. API 管理模块 ---
108
  class APIManager:
109
  def __init__(self, logger):
110
  self.last_login_fail_time = 0
@@ -192,7 +242,7 @@ class APIManager:
192
  return None
193
 
194
 
195
- # --- 4. 监控服务主逻辑 ---
196
  class CinemaMonitor:
197
  def __init__(self):
198
  self.logs = deque(maxlen=50)
@@ -201,6 +251,8 @@ class CinemaMonitor:
201
  self.monitored_sessions = []
202
 
203
  self.api = APIManager(self.log)
 
 
204
  self.current_business_date = None
205
  self.daily_schedule_cache = None
206
 
@@ -216,12 +268,6 @@ class CinemaMonitor:
216
  "notify_fails": 0 # 发送通知失败次数
217
  }
218
 
219
- # --- NTFY 队列初始化 ---
220
- # 队列项结构: (title, message, priority)
221
- self.ntfy_queue = queue.Queue()
222
- self.ntfy_thread = threading.Thread(target=self._ntfy_worker, daemon=True)
223
- self.ntfy_thread.start()
224
-
225
  self.thread = threading.Thread(target=self._run_loop, daemon=True)
226
  self.thread.start()
227
 
@@ -231,87 +277,97 @@ class CinemaMonitor:
231
  print(entry)
232
  self.logs.appendleft(entry)
233
 
234
- def _ntfy_worker(self):
235
- """
236
- NTFY 后台消费者线程
237
- 负责从队列取出消息发送,避免阻塞主线程,并提供基本的缓冲
238
- """
239
- self.log("📡 NTFY 后台通知服务已启动")
240
- while True:
241
- try:
242
- # 阻塞获取任务 (title, msg, priority)
243
- item = self.ntfy_queue.get()
244
- title, message, priority = item
245
-
246
- if not NTFY_TOPIC:
247
- # 如果未配置,直接跳过
248
- self.ntfy_queue.task_done()
249
- continue
250
-
251
- try:
252
- url = f"https://ntfy.sh/{NTFY_TOPIC}"
253
-
254
- # 映射优先级:
255
- # Pushover: 0(Normal) -> NTFY: 3(Default)
256
- # Pushover: 1(High) -> NTFY: 4(High) or 5(Urgent)
257
- ntfy_priority = "default"
258
- tags = []
259
-
260
- if priority == 1:
261
- ntfy_priority = "high"
262
- # tags = ["warning", "ticket"]
263
- else:
264
- ntfy_priority = "default"
265
- # tags = ["white_check_mark"]
266
-
267
- headers = {
268
- "Title": title.encode('utf-8'),
269
- "Priority": ntfy_priority,
270
- "Tags": ",".join(tags)
271
- }
272
-
273
- # 发送请求
274
- resp = requests.post(
275
- url,
276
- data=message.encode('utf-8'),
277
- headers=headers,
278
- timeout=10
279
- )
280
-
281
- if resp.status_code == 200:
282
- print(f"✅ NTFY 发送成功")
283
- else:
284
- print(f"❌ NTFY 发送失败: {resp.status_code} - {resp.text}")
285
- self.stats["notify_fails"] += 1
286
-
287
- except Exception as e:
288
- print(f"❌ NTFY 网络异常: {e}")
289
- self.stats["notify_fails"] += 1
290
-
291
- self.ntfy_queue.task_done()
292
 
293
- # 稍微休眠一下,避免极高并发冲击
294
- time.sleep(2)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
 
296
- except Exception as e:
297
- print(f"NTFY 线程错误: {e}")
298
- time.sleep(5)
 
 
 
 
 
 
 
299
 
300
- def send_notifications(self, title, message, priority=0):
 
 
 
 
 
 
 
 
 
 
 
 
 
301
  """
302
- 统一通知入口
303
- Pushover 和 NTFY 使用完全相同的 Title 和 Message
304
  """
305
- # 1. 发送 Pushover
306
- success = send_pushover(title, message, priority)
307
- if not success:
 
 
 
 
 
 
 
 
 
 
 
308
  self.stats["notify_fails"] += 1
309
 
310
- # 2. 发送 NTFY (放入队列)
311
- self.ntfy_queue.put((title, message, priority))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
 
313
  def _run_loop(self):
314
- self.log("🚀 监控服务已启动")
315
  while True:
316
  try:
317
  now = get_beijing_now()
@@ -320,25 +376,11 @@ class CinemaMonitor:
320
 
321
  # --- 每日健康检查 (09:00) ---
322
  if now.time() >= dt_time(9, 0) and self.last_daily_report_date != today_str:
323
- # 生成报告文案
324
  report_date_str = now.strftime("%Y年%m月%d日")
325
-
326
- # 报告正文
327
- report_body = (
328
- f"{report_date_str},今日排片数据已加载,开始智能检查。\n"
329
- f"昨日情况:\n"
330
- f"0票总场次:{self.stats['zero_total']}\n"
331
- f"触发通知次数:{self.stats['alert_count']}\n"
332
- f"发送通知失败次数:{self.stats['notify_fails']}\n"
333
- f"API获取失败次数:{self.stats['api_fails']}"
334
- )
335
-
336
- report_title = "🟢 0票场次开场前10分钟内突发购票检查就绪"
337
-
338
  self.log(f"🔔 发送每日统计报告: {today_str}")
339
 
340
- # 发送报告 (Pushover 和 NTFY 内容一致)
341
- self.send_notifications(report_title, report_body, priority=0)
342
 
343
  # 重置统计数据 & 更新日期
344
  self.last_daily_report_date = today_str
@@ -443,12 +485,8 @@ class CinemaMonitor:
443
  # 统计触发次数
444
  self.stats["alert_count"] += 1
445
 
446
- # 统一文案
447
- n_title = f"🎟 发现 {hall_short} {start} 场次有票!"
448
- n_msg = f"{hall_short} {start}《{movie}》\n⚠️ 突增至 {sold} 张"
449
-
450
- # 发送通知 (Pushover + NTFY)
451
- self.send_notifications(n_title, n_msg, priority=1)
452
 
453
  self.alerted_sessions.add(unique_id)
454
  self.zero_ticket_candidates.remove(unique_id)
@@ -476,7 +514,7 @@ class CinemaMonitor:
476
  time.sleep(60)
477
 
478
 
479
- # --- 5. Streamlit 前端 ---
480
 
481
  @st.cache_resource
482
  def get_monitor():
@@ -492,10 +530,6 @@ def main():
492
 
493
  st.title("📢 零票影前十分钟内突发购票检查")
494
 
495
- # 检查 NTFY 配置
496
- if not NTFY_TOPIC:
497
- st.warning("⚠️ 检测到 ntfy_code 环境变量未配置,NTFY 通知将不可用。")
498
-
499
  c1, c2, c3 = st.columns(3)
500
  with c1:
501
  st.metric("运行状态", monitor.status_text)
 
4
  import json
5
  import os
6
  import threading
 
7
  import re
8
  from datetime import datetime, timedelta, timezone, time as dt_time
9
  from collections import deque
 
21
  # --- 1. 配置与常量 ---
22
  load_dotenv()
23
 
24
+ # Pushover 配置
25
  PUSHOVER_TOKEN = os.getenv("PUSHOVER_TOKEN")
26
  PUSHOVER_USER = os.getenv("PUSHOVER_USER")
 
27
 
28
+ # 微信配置
29
+ WX_APP_ID = os.getenv("WX_APP_ID")
30
+ WX_APP_SECRET = os.getenv("WX_APP_SECRET")
31
+ # 支持多个用户,逗号分隔
32
+ WX_USER_OPEN_IDS = os.getenv("WX_USER_OPEN_ID", "").split(',')
33
+ WX_TEMPLATE_ID_TICKET = os.getenv("WX_TEMPLATE_ID_TICKET")
34
+ WX_TEMPLATE_ID_DAILY = os.getenv("WX_TEMPLATE_ID_DAILY")
35
+
36
+ # 影城配置
37
+ CINEMA_ID = os.getenv("CINEMA_ID")
38
 
39
  # 获取 Token 文件路径
40
  CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
 
84
  return raw_name
85
 
86
 
87
+ # --- 3. 微信推送模块 ---
88
+ class WeChatPusher:
89
+ def __init__(self, app_id, app_secret):
90
+ self.app_id = app_id
91
+ self.app_secret = app_secret
92
+ self.token = None
93
+ self.token_expires_time = 0
94
+
95
+ def get_access_token(self):
96
+ """获取并缓存微信 Access Token"""
97
+ if self.token and datetime.now().timestamp() < self.token_expires_time:
98
+ return self.token
99
+
100
+ if not self.app_id or not self.app_secret:
101
+ return None
102
+
103
+ url = f"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={self.app_id}&secret={self.app_secret}"
104
+ try:
105
+ resp = requests.get(url, timeout=10)
106
+ data = resp.json()
107
+ if 'access_token' in data:
108
+ self.token = data['access_token']
109
+ # 提前 200 秒过期,防止临界点问题
110
+ self.token_expires_time = datetime.now().timestamp() + data['expires_in'] - 200
111
+ return self.token
112
+ else:
113
+ print(f"❌ 微信 Token 获取失败: {data}")
114
+ except Exception as e:
115
+ print(f"❌ 微信 API 请求异常: {e}")
116
+ return None
117
+
118
+ def send_template_message(self, user_ids, template_id, data_map):
119
+ """发送模板消息给多个用户"""
120
+ token = self.get_access_token()
121
+ if not token:
122
  return False
123
+
124
+ url = f"https://api.weixin.qq.com/cgi-bin/message/template/send?access_token={token}"
125
+
126
+ # 组装微信数据格式
127
+ formatted_data = {}
128
+ for key, value in data_map.items():
129
+ # 默认深蓝色
130
+ formatted_data[key] = {"value": value, "color": "#173177"}
131
+
132
+ # 清洗 user_ids
133
+ valid_ids = [uid.strip() for uid in user_ids if uid and uid.strip()]
134
+ success_flag = True
135
+
136
+ for user_id in valid_ids:
137
+ payload = {
138
+ "touser": user_id,
139
+ "template_id": template_id,
140
+ "data": formatted_data
141
+ }
142
+ try:
143
+ resp = requests.post(url, json=payload, timeout=10)
144
+ result = resp.json()
145
+ if result.get("errcode") != 0:
146
+ print(f"❌ 微信发送失败 [{user_id}]: {result}")
147
+ success_flag = False
148
+ else:
149
+ print(f"✅ 微信发送成功 [{user_id}]")
150
+ except Exception as e:
151
+ print(f"❌ 微信发送异常 [{user_id}]: {e}")
152
+ success_flag = False
153
+
154
+ return success_flag
155
 
156
 
157
+ # --- 4. API 管理模块 ---
158
  class APIManager:
159
  def __init__(self, logger):
160
  self.last_login_fail_time = 0
 
242
  return None
243
 
244
 
245
+ # --- 5. 监控服务主逻辑 ---
246
  class CinemaMonitor:
247
  def __init__(self):
248
  self.logs = deque(maxlen=50)
 
251
  self.monitored_sessions = []
252
 
253
  self.api = APIManager(self.log)
254
+ self.wx_pusher = WeChatPusher(WX_APP_ID, WX_APP_SECRET)
255
+
256
  self.current_business_date = None
257
  self.daily_schedule_cache = None
258
 
 
268
  "notify_fails": 0 # 发送通知失败次数
269
  }
270
 
 
 
 
 
 
 
271
  self.thread = threading.Thread(target=self._run_loop, daemon=True)
272
  self.thread.start()
273
 
 
277
  print(entry)
278
  self.logs.appendleft(entry)
279
 
280
+ def _send_pushover(self, title, message, priority=0):
281
+ """发送 Pushover 通知"""
282
+ if not PUSHOVER_TOKEN or not PUSHOVER_USER:
283
+ return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
 
285
+ url = "https://api.pushover.net/1/messages.json"
286
+ data = {
287
+ "token": PUSHOVER_TOKEN,
288
+ "user": PUSHOVER_USER,
289
+ "title": title,
290
+ "message": message,
291
+ "sound": "pushover",
292
+ "priority": priority
293
+ }
294
+ try:
295
+ response = requests.post(url, data=data, timeout=10)
296
+ if response.status_code == 200:
297
+ return True
298
+ else:
299
+ print(f"❌ Pushover 拒绝: {response.status_code}")
300
+ return False
301
+ except Exception as e:
302
+ print(f"❌ Pushover 网络异常: {e}")
303
+ return False
304
 
305
+ def send_ticket_alert(self, hall_name, movie_name, show_time, ticket_count):
306
+ """
307
+ 发送突发购票告警 (Pushover + WeChat)
308
+ """
309
+ # 1. Pushover
310
+ title = f"🎟 发现 {hall_name} {show_time} 场次有票!"
311
+ msg = f"{hall_name} {show_time}《{movie_name}》\n⚠️ 突增至 {ticket_count} 张"
312
+
313
+ if not self._send_pushover(title, msg, priority=1):
314
+ self.stats["notify_fails"] += 1
315
 
316
+ # 2. WeChat
317
+ if WX_TEMPLATE_ID_TICKET:
318
+ data = {
319
+ "first": f"发现 {hall_name} {show_time} 场次有票!\n{hall_name} {show_time}《{movie_name}》\n⚠️ 突增至 {ticket_count} 张\n",
320
+ "keyword1": hall_name,
321
+ "keyword2": movie_name,
322
+ "keyword3": show_time,
323
+ "keyword4": f"{ticket_count} 张",
324
+ "remark": "\n请尽快处理。"
325
+ }
326
+ if not self.wx_pusher.send_template_message(WX_USER_OPEN_IDS, WX_TEMPLATE_ID_TICKET, data):
327
+ self.stats["notify_fails"] += 1
328
+
329
+ def send_daily_report(self, date_str):
330
  """
331
+ 发送每日统计报告 (Pushover + WeChat)
 
332
  """
333
+ # 1. Pushover
334
+ stats_text_full = (
335
+ f"0票总场次:{self.stats['zero_total']}\n"
336
+ f"触发通知次数:{self.stats['alert_count']}\n"
337
+ f"发送通知失败次数:{self.stats['notify_fails']}\n"
338
+ f"API获取失败次数:{self.stats['api_fails']}"
339
+ )
340
+
341
+ title = "🟢 0票场次开场前10分钟内突发购票检查就绪"
342
+ msg = (
343
+ f"{date_str},今日排片数据已加载,开始智能检查。\n"
344
+ f"昨日情况:\n{stats_text_full}"
345
+ )
346
+ if not self._send_pushover(title, msg, priority=0):
347
  self.stats["notify_fails"] += 1
348
 
349
+ # 2. WeChat
350
+ if WX_TEMPLATE_ID_DAILY:
351
+ # 格式化简短的昨日数据,避免微信字段超长
352
+ stats_short = (
353
+ f"0票场次({self.stats['zero_total']}) "
354
+ f"触发({self.stats['alert_count']}) "
355
+ f"s失败({self.stats['notify_fails']}) "
356
+ f"g失败({self.stats['api_fails']})"
357
+ )
358
+
359
+ data = {
360
+ "first": f"0票场次开场前10分钟内突发购票检查就绪\n{date_str},今日排片数据已加载,开始智能检查。\n",
361
+ "keyword1": "0票场次开场前10分钟内突发购票",
362
+ "keyword2": date_str,
363
+ "keyword3": stats_short,
364
+ "remark": "\n服务正常运行中。"
365
+ }
366
+ if not self.wx_pusher.send_template_message(WX_USER_OPEN_IDS, WX_TEMPLATE_ID_DAILY, data):
367
+ self.stats["notify_fails"] += 1
368
 
369
  def _run_loop(self):
370
+ self.log("🚀 监控服务已启动 (WeChat + Pushover)")
371
  while True:
372
  try:
373
  now = get_beijing_now()
 
376
 
377
  # --- 每日健康检查 (09:00) ---
378
  if now.time() >= dt_time(9, 0) and self.last_daily_report_date != today_str:
 
379
  report_date_str = now.strftime("%Y年%m月%d日")
 
 
 
 
 
 
 
 
 
 
 
 
 
380
  self.log(f"🔔 发送每日统计报告: {today_str}")
381
 
382
+ # 发送报告
383
+ self.send_daily_report(report_date_str)
384
 
385
  # 重置统计数据 & 更新日期
386
  self.last_daily_report_date = today_str
 
485
  # 统计触发次数
486
  self.stats["alert_count"] += 1
487
 
488
+ # 发送通知 (Pushover + WeChat)
489
+ self.send_ticket_alert(hall_short, movie, start, sold)
 
 
 
 
490
 
491
  self.alerted_sessions.add(unique_id)
492
  self.zero_ticket_candidates.remove(unique_id)
 
514
  time.sleep(60)
515
 
516
 
517
+ # --- 6. Streamlit 前端 ---
518
 
519
  @st.cache_resource
520
  def get_monitor():
 
530
 
531
  st.title("📢 零票影前十分钟内突发购票检查")
532
 
 
 
 
 
533
  c1, c2, c3 = st.columns(3)
534
  with c1:
535
  st.metric("运行状态", monitor.status_text)