Ethscriptions commited on
Commit
aa2079a
·
verified ·
1 Parent(s): 48919e7

Create pages/monitor.py

Browse files
Files changed (1) hide show
  1. pages/monitor.py +398 -0
pages/monitor.py ADDED
@@ -0,0 +1,398 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import requests
3
+ import time
4
+ import json
5
+ import os
6
+ import threading
7
+ from datetime import datetime, timedelta, timezone, time as dt_time
8
+ from collections import deque
9
+ from dotenv import load_dotenv # 引入 dotenv
10
+
11
+ # --- 1. 配置与常量 ---
12
+ load_dotenv() # 加载根目录下的 .env 文件
13
+
14
+ # 从环境变量获取配置 (请确保你的 .env 文件里配置了这些)
15
+ PUSHOVER_TOKEN = os.getenv("PUSHOVER_TOKEN", "")
16
+ PUSHOVER_USER = os.getenv("PUSHOVER_USER", "")
17
+ CINEMA_ID = os.getenv("CINEMA_ID", "44001291")
18
+ # 登录信息也建议从环境变量取,这里为了逻辑独立暂时保留 APIManager 里的读取逻辑
19
+
20
+ TOKEN_FILE = 'token_data.json' # 与主程序共用同一个 Token 文件
21
+
22
+ # 页面配置
23
+ st.set_page_config(page_title="影城防撤场监控系统 Pro", page_icon="🛡️", layout="wide")
24
+
25
+ # --- 2. 核心时间处理 ---
26
+ # ... (以下代码保持你提供的内容不变) ...
27
+
28
+ def get_beijing_now():
29
+ """获取当前的北京时间 (Naive)"""
30
+ utc_now = datetime.now(timezone.utc)
31
+ beijing_now = utc_now.astimezone(timezone(timedelta(hours=8)))
32
+ return beijing_now.replace(tzinfo=None)
33
+
34
+ def get_business_date():
35
+ """获取当前的营业日日期"""
36
+ now = get_beijing_now()
37
+ if now.time() < dt_time(6, 0):
38
+ return (now - timedelta(days=1)).strftime("%Y-%m-%d")
39
+ return now.strftime("%Y-%m-%d")
40
+
41
+ def parse_show_datetime(date_str, time_str):
42
+ """解析排片时间"""
43
+ try:
44
+ show_t = datetime.strptime(time_str, "%H:%M").time()
45
+ base_date = datetime.strptime(date_str, "%Y-%m-%d")
46
+ if show_t < dt_time(6, 0):
47
+ base_date += timedelta(days=1)
48
+ return datetime.combine(base_date.date(), show_t)
49
+ except:
50
+ return None
51
+
52
+ def send_pushover(title, message, priority=0):
53
+ if not PUSHOVER_TOKEN or not PUSHOVER_USER:
54
+ return # 如果没配置 Pushover,直接跳过
55
+
56
+ url = "https://api.pushover.net/1/messages.json"
57
+ data = {
58
+ "token": PUSHOVER_TOKEN,
59
+ "user": PUSHOVER_USER,
60
+ "title": title,
61
+ "message": message,
62
+ "sound": "pushover",
63
+ "priority": priority
64
+ }
65
+ try:
66
+ requests.post(url, data=data, timeout=5)
67
+ except:
68
+ pass
69
+
70
+ # --- 3. API 管理模块 ---
71
+
72
+ class APIManager:
73
+ def __init__(self, logger):
74
+ self.last_login_fail_time = 0
75
+ self.logger = logger
76
+
77
+ def save_token(self, token_data):
78
+ try:
79
+ with open(TOKEN_FILE, 'w', encoding='utf-8') as f:
80
+ json.dump(token_data, f, ensure_ascii=False, indent=4)
81
+ except:
82
+ pass
83
+
84
+ def load_token(self):
85
+ # 优先读取本地文件,这样能复用 app2.py 登录生成的 Token
86
+ if os.path.exists(TOKEN_FILE):
87
+ try:
88
+ with open(TOKEN_FILE, 'r', encoding='utf-8') as f:
89
+ return json.load(f).get('token')
90
+ except:
91
+ pass
92
+ return None
93
+
94
+ def login(self):
95
+ if time.time() - self.last_login_fail_time < 600:
96
+ self.logger("⏳ 登录冷却中,跳过重试。")
97
+ return None
98
+
99
+ self.logger("🔄 尝试后台自动登录...")
100
+
101
+ # 使用环境变量获取账号密码
102
+ username = os.getenv("CINEMA_USERNAME")
103
+ password = os.getenv("CINEMA_PASSWORD")
104
+ res_code = os.getenv("CINEMA_RES_CODE")
105
+ device_id = os.getenv("CINEMA_DEVICE_ID")
106
+
107
+ if not all([username, password, res_code]):
108
+ self.logger("❌ 环境变量缺失,无法自动登录")
109
+ return None
110
+
111
+ session = requests.Session()
112
+ session.headers.update({'User-Agent': 'Mozilla/5.0'})
113
+ login_url = 'https://app.bi.piao51.cn/cinema-app/credential/login.action'
114
+ login_data = {
115
+ 'username': username, 'password': password, 'type': '1',
116
+ 'resCode': res_code, 'deviceid': device_id, 'dtype': 'ios',
117
+ }
118
+
119
+ try:
120
+ session.post(login_url, data=login_data, timeout=15)
121
+ resp = session.get('https://app.bi.piao51.cn/cinema-app/security/logined.action', timeout=10)
122
+ info = resp.json()
123
+ if info.get("success") and info.get("data", {}).get("token"):
124
+ self.save_token(info['data'])
125
+ self.logger("✅ 登录成功。")
126
+ return info['data']['token']
127
+ else:
128
+ raise Exception("未获取到Token")
129
+ except Exception as e:
130
+ self.last_login_fail_time = time.time()
131
+ msg = f"登录失败: {e}"
132
+ self.logger(f"❌ {msg}")
133
+ send_pushover("监控异常", msg, priority=0)
134
+ return None
135
+
136
+ def fetch_schedule(self, date_str):
137
+ token = self.load_token()
138
+ if not token:
139
+ token = self.login()
140
+ if not token: return None
141
+
142
+ url = 'https://cawapi.yinghezhong.com/showInfo/getHallShowInfo'
143
+ params = {'showDate': date_str, 'token': token, '_': int(time.time() * 1000)}
144
+ headers = {'Origin': 'https://caw.yinghezhong.com', 'User-Agent': 'Mozilla/5.0'}
145
+
146
+ try:
147
+ response = requests.get(url, params=params, headers=headers, timeout=15)
148
+ data = response.json()
149
+ if data.get('code') == 1:
150
+ return data.get('data', [])
151
+ elif data.get('code') == 500:
152
+ self.logger("⚠️ Token失效,重试中...")
153
+ token = self.login()
154
+ if token:
155
+ params['token'] = token
156
+ response = requests.get(url, params=params, headers=headers, timeout=15)
157
+ return response.json().get('data', [])
158
+ return None
159
+ except Exception as e:
160
+ self.logger(f"API请求异常: {e}")
161
+ return None
162
+
163
+ # --- 4. 监控服务主逻辑 ---
164
+ # ... (CinemaMonitor 类及后续代码完全保持不变,直接粘贴即可) ...
165
+
166
+ class CinemaMonitor:
167
+ def __init__(self):
168
+ self.logs = deque(maxlen=50)
169
+ self.status_text = "初始化中..."
170
+ self.next_wakeup = None
171
+ self.monitored_sessions = []
172
+
173
+ self.api = APIManager(self.log)
174
+ self.current_business_date = None
175
+ self.daily_schedule_cache = None
176
+
177
+ self.zero_ticket_candidates = set()
178
+ self.alerted_sessions = set()
179
+
180
+ self.last_daily_report_date = None
181
+ self.simulation_mode = False
182
+ self.simulation_data = []
183
+
184
+ self.thread = threading.Thread(target=self._run_loop, daemon=True)
185
+ self.thread.start()
186
+
187
+ def log(self, msg):
188
+ timestamp = get_beijing_now().strftime("%H:%M:%S")
189
+ entry = f"[{timestamp}] {msg}"
190
+ print(entry)
191
+ self.logs.appendleft(entry)
192
+
193
+ def _run_loop(self):
194
+ self.log("🚀 监控服务已启动")
195
+
196
+ while True:
197
+ try:
198
+ now = get_beijing_now()
199
+ biz_date = get_business_date()
200
+ today_str = now.strftime("%Y-%m-%d")
201
+
202
+ # --- 0. 每日健康检查 (09:00) ---
203
+ if now.time() >= dt_time(9, 0) and self.last_daily_report_date != today_str:
204
+ msg = f"日期: {today_str}\n状态: 监控服务正常运行中\nToken: 有效"
205
+ self.log(f"🔔 发送每日健康检查通知: {today_str}")
206
+ send_pushover("✅ 影城脚本今日运行正常", msg, priority=0)
207
+ self.last_daily_report_date = today_str
208
+
209
+ # --- 1. 非营业时间休眠 (06:00 - 09:30) ---
210
+ start_check_time = now.replace(hour=9, minute=30, second=0, microsecond=0)
211
+ is_early_morning = (dt_time(6, 0) <= now.time() < dt_time(9, 30))
212
+
213
+ if is_early_morning and not self.simulation_mode:
214
+ self.status_text = "非监控时段 (等待 09:30)"
215
+ self.next_wakeup = start_check_time
216
+ self.zero_ticket_candidates.clear()
217
+ self.alerted_sessions.clear()
218
+
219
+ sleep_sec = (start_check_time - now).total_seconds()
220
+ # 最多睡5分钟醒来一次
221
+ self.log(f"💤 系统休眠中... 预计唤醒: {start_check_time.strftime('%H:%M')}")
222
+ time.sleep(min(sleep_sec, 300))
223
+ continue
224
+
225
+ # --- 2. 缓存刷新 ---
226
+ if self.daily_schedule_cache is None or self.current_business_date != biz_date:
227
+ self.log(f"📅 获取 {biz_date} 全天排片...")
228
+ schedule = self.api.fetch_schedule(biz_date)
229
+ if schedule is None:
230
+ self.log("❌ 初始化获取失败,1分钟后重试")
231
+ time.sleep(60)
232
+ continue
233
+ self.daily_schedule_cache = schedule
234
+ self.current_business_date = biz_date
235
+ self.zero_ticket_candidates.clear()
236
+ self.alerted_sessions.clear()
237
+ self.log(f"✅ 数据已更新,共 {len(schedule)} 场")
238
+
239
+ # --- 3. 筛选窗口期场次 ---
240
+ active_check_needed = False
241
+ min_sleep_seconds = 3600
242
+ sessions_in_window = []
243
+ current_schedule = self.daily_schedule_cache + self.simulation_data
244
+
245
+ for item in current_schedule:
246
+ start_time_str = item.get('showStartTime')
247
+ if not start_time_str: continue
248
+ show_dt = parse_show_datetime(biz_date, start_time_str)
249
+ if not show_dt: continue
250
+
251
+ if now >= show_dt: continue
252
+
253
+ monitor_start_dt = show_dt - timedelta(minutes=11)
254
+
255
+ if now >= monitor_start_dt:
256
+ sessions_in_window.append({'data': item, 'dt': show_dt})
257
+ active_check_needed = True
258
+ else:
259
+ seconds_until_window = (monitor_start_dt - now).total_seconds()
260
+ if seconds_until_window < min_sleep_seconds:
261
+ min_sleep_seconds = seconds_until_window
262
+
263
+ # --- 4. 严格执行逻辑 ---
264
+ if active_check_needed:
265
+ self.status_text = "🔥 监控进行中 (高频轮询)"
266
+ self.next_wakeup = now + timedelta(seconds=60)
267
+
268
+ realtime_schedule = self.api.fetch_schedule(biz_date)
269
+
270
+ if realtime_schedule is None and not self.simulation_mode:
271
+ self.log("⚠️ 网络/API请求失败,跳过本次判定,防止误报!")
272
+ time.sleep(60)
273
+ continue
274
+
275
+ if realtime_schedule is None: realtime_schedule = []
276
+ if self.simulation_mode: realtime_schedule += self.simulation_data
277
+
278
+ realtime_map = {f"{x.get('hallId')}_{x.get('showStartTime')}": x for x in realtime_schedule}
279
+
280
+ display_monitors = []
281
+
282
+ for session in sessions_in_window:
283
+ unique_id = f"{biz_date}_{session['data'].get('hallId')}_{session['data'].get('showStartTime')}"
284
+ key_for_map = f"{session['data'].get('hallId')}_{session['data'].get('showStartTime')}"
285
+
286
+ latest = realtime_map.get(key_for_map)
287
+ if not latest: continue
288
+
289
+ movie_name = latest.get('movieName')
290
+ hall_name = latest.get('hallName')
291
+ sold = int(latest.get('soldTicketNum', 0))
292
+ start_str = latest.get('showStartTime')
293
+ mins_left = (session['dt'] - now).total_seconds() / 60
294
+
295
+ if sold == 0:
296
+ self.zero_ticket_candidates.add(unique_id)
297
+ display_monitors.append(f"👁️ {start_str} {movie_name} (0票, 监控中)")
298
+ else:
299
+ if unique_id in self.zero_ticket_candidates:
300
+ if unique_id not in self.alerted_sessions:
301
+ self.log(f"🚨 突发购票检测!{hall_name}《{movie_name}》")
302
+ send_pushover(
303
+ f"禁止撤场:{hall_name}有票!",
304
+ f"《{movie_name}》{start_str}\n倒计时 {mins_left:.1f}分\n⚠️ 从0票突增至{sold}张",
305
+ priority=0
306
+ )
307
+ self.alerted_sessions.add(unique_id)
308
+ self.zero_ticket_candidates.remove(unique_id)
309
+
310
+ display_monitors.append(f"✅ {start_str} {movie_name} (新售出{sold}张, 已报警)")
311
+ else:
312
+ display_monitors.append(f"🛡️ {start_str} {movie_name} (原有{sold}张, 忽略)")
313
+
314
+ self.monitored_sessions = display_monitors
315
+ time.sleep(60)
316
+
317
+ else:
318
+ if 0 < min_sleep_seconds < 86400:
319
+ wakeup_dt = now + timedelta(seconds=min_sleep_seconds)
320
+ self.status_text = "💤 智能休眠中"
321
+ self.next_wakeup = wakeup_dt
322
+ self.monitored_sessions = []
323
+ self.log(f"休眠 {min_sleep_seconds / 60:.1f} 分钟,直到 {wakeup_dt.strftime('%H:%M')}")
324
+ time.sleep(min_sleep_seconds)
325
+ else:
326
+ self.log("今日无更多监控场次,休眠 5 分钟")
327
+ time.sleep(300)
328
+
329
+ except Exception as e:
330
+ self.log(f"主循环异常: {e}")
331
+ time.sleep(60)
332
+
333
+
334
+ # --- 5. Streamlit 前端 ---
335
+
336
+ @st.cache_resource
337
+ def get_monitor():
338
+ return CinemaMonitor()
339
+
340
+
341
+ def main():
342
+ monitor = get_monitor()
343
+ st.title("📢 零票影前十分钟内突发购票通知")
344
+
345
+ # 计算统计数据:剩余0票场次 / 总场次
346
+ total_sessions = 0
347
+ zero_remaining = 0
348
+
349
+ if monitor.daily_schedule_cache:
350
+ total_sessions = len(monitor.daily_schedule_cache)
351
+ now = get_beijing_now()
352
+ biz_date = monitor.current_business_date or get_business_date()
353
+
354
+ for item in monitor.daily_schedule_cache:
355
+ start_str = item.get('showStartTime')
356
+ if not start_str: continue
357
+
358
+ show_dt = parse_show_datetime(biz_date, start_str)
359
+ if not show_dt: continue
360
+
361
+ # 只统计还没开场的
362
+ if show_dt > now:
363
+ # 统计票数为0的 (基于缓存数据)
364
+ if int(item.get('soldTicketNum', 0)) == 0:
365
+ zero_remaining += 1
366
+
367
+ c1, c2, c3 = st.columns(3)
368
+ with c1:
369
+ st.metric("当前状态", monitor.status_text)
370
+ with c2:
371
+ st.metric("下次唤醒", monitor.next_wakeup.strftime("%H:%M:%S") if monitor.next_wakeup else "--")
372
+ with c3:
373
+ st.metric("剩余零票场次 / 当日全部场次", f"{zero_remaining} / {total_sessions}")
374
+
375
+ st.divider()
376
+
377
+ col_logs, col_list = st.columns([3, 2])
378
+ with col_logs:
379
+ st.subheader("📜 运行日志")
380
+ # 移除按钮
381
+ st.text_area("Logs", "\n".join(list(monitor.logs)), height=450, disabled=True)
382
+ with col_list:
383
+ st.subheader("🎯 实时监控列表")
384
+ st.markdown("> 说明:当日 0 票场次会被监控中,在 0 票电影开场前 10 分钟内突发购票会触发通知。")
385
+ if monitor.monitored_sessions:
386
+ for s in monitor.monitored_sessions:
387
+ if "✅" in s:
388
+ st.success(s)
389
+ elif "👁️" in s:
390
+ st.error(s)
391
+ else:
392
+ st.caption(s)
393
+ else:
394
+ st.info("当前休眠中,无临近场次")
395
+
396
+
397
+ if __name__ == "__main__":
398
+ main()