zt p commited on
Commit
80c4080
·
0 Parent(s):

Overwrite Space with local hengdian app

Browse files
.dockerignore ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .gitignore
3
+ .DS_Store
4
+ __pycache__/
5
+ *.py[cod]
6
+
7
+ .env
8
+ .env.*
9
+ .streamlit/secrets.toml
10
+ .claude/
11
+ **/.claude/
12
+
13
+ .venv/
14
+ venv/
15
+ cinema_cache/
16
+ token_data.json
17
+
18
+ test/
19
+ tests/
20
+ API-test/
.gitattributes ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar filter=lfs diff=lfs merge=lfs -text
28
+ *.tflite filter=lfs diff=lfs merge=lfs -text
29
+ *.tgz filter=lfs diff=lfs merge=lfs -text
30
+ *.wasm filter=lfs diff=lfs merge=lfs -text
31
+ *.xz filter=lfs diff=lfs merge=lfs -text
32
+ *.zip filter=lfs diff=lfs merge=lfs -text
33
+ *.zst filter=lfs diff=lfs merge=lfs -text
34
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
35
+ *.otf filter=lfs diff=lfs merge=lfs -text
36
+ *.ttf filter=lfs diff=lfs merge=lfs -text
37
+ *.xlsx filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .DS_Store
2
+ __pycache__/
3
+ *.py[cod]
4
+ .venv/
5
+ venv/
6
+
7
+ .env
8
+ .env.*
9
+ !.env.example
10
+ .streamlit/secrets.toml
11
+ .claude/
12
+ **/.claude/
13
+
14
+ cinema_cache/
15
+ !cinema_cache/.gitkeep
16
+ token_data.json
17
+
18
+ test/
19
+ tests/
20
+ API-test/
Dockerfile ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ PIP_NO_CACHE_DIR=1 \
6
+ STREAMLIT_SERVER_HEADLESS=true \
7
+ STREAMLIT_BROWSER_GATHER_USAGE_STATS=false \
8
+ PORT=7860
9
+
10
+ WORKDIR /app
11
+
12
+ RUN apt-get update \
13
+ && apt-get install -y --no-install-recommends \
14
+ chromium \
15
+ fontconfig \
16
+ fonts-noto-cjk \
17
+ && rm -rf /var/lib/apt/lists/*
18
+
19
+ COPY requirements.txt .
20
+ RUN pip install --upgrade pip \
21
+ && pip install -r requirements.txt
22
+
23
+ COPY . .
24
+
25
+ EXPOSE 7860
26
+
27
+ CMD streamlit run app.py --server.address=0.0.0.0 --server.port=${PORT}
README.md ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: 影城工作便捷工具
3
+ emoji: 🔧
4
+ colorFrom: pink
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # 影城工作便捷工具
12
+
13
+ Streamlit app packaged as a Docker Space.
app.py ADDED
The diff for this file is too large to render. See raw diff
 
cinema_api_client.py ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import time
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import Dict, List, Optional, Tuple
7
+
8
+ import requests
9
+ from dotenv import load_dotenv
10
+
11
+
12
+ load_dotenv()
13
+
14
+ ROOT_DIR = Path(__file__).resolve().parent
15
+ TOKEN_FILE = ROOT_DIR / "token_data.json"
16
+ CINEMA_ID = os.getenv("CINEMA_ID")
17
+ SCHEDULE_API_TIMEOUT_SECONDS = 30
18
+ SCHEDULE_API_RETRY_ATTEMPTS = 2
19
+ API_BASE_URL = "https://cawapi.yinghezhong.com"
20
+ COMMON_HEADERS = {
21
+ "Host": "cawapi.yinghezhong.com",
22
+ "Accept": "*/*",
23
+ "Origin": "https://caw.yinghezhong.com",
24
+ "Connection": "keep-alive",
25
+ "Sec-Fetch-Mode": "cors",
26
+ "Sec-Fetch-Site": "same-site",
27
+ "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
28
+ "Referer": "https://caw.yinghezhong.com/",
29
+ "Sec-Fetch-Dest": "empty",
30
+ "Accept-Language": "zh-CN,zh-Hans;q=0.9",
31
+ }
32
+
33
+
34
+ class RetryableAPIError(RuntimeError):
35
+ """适合重试的接口异常。"""
36
+
37
+
38
+ def load_token() -> Optional[dict]:
39
+ if not TOKEN_FILE.exists():
40
+ return None
41
+ try:
42
+ return json.loads(TOKEN_FILE.read_text(encoding="utf-8"))
43
+ except (json.JSONDecodeError, OSError):
44
+ return None
45
+
46
+
47
+ def save_token(token_data: dict) -> bool:
48
+ try:
49
+ TOKEN_FILE.write_text(json.dumps(token_data, ensure_ascii=False, indent=2), encoding="utf-8")
50
+ return True
51
+ except OSError:
52
+ return False
53
+
54
+
55
+ def login_and_get_token() -> dict:
56
+ username = os.getenv("CINEMA_USERNAME")
57
+ password = os.getenv("CINEMA_PASSWORD")
58
+ res_code = os.getenv("CINEMA_RES_CODE")
59
+ device_id = os.getenv("CINEMA_DEVICE_ID")
60
+
61
+ if not all([username, password, res_code]):
62
+ raise RuntimeError("未配置 CINEMA_USERNAME / CINEMA_PASSWORD / CINEMA_RES_CODE。")
63
+
64
+ session = requests.Session()
65
+ session.headers.update(
66
+ {
67
+ "Host": "app.bi.piao51.cn",
68
+ "Accept": "application/json, text/javascript, */*; q=0.01",
69
+ "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
70
+ }
71
+ )
72
+
73
+ login_url = "https://app.bi.piao51.cn/cinema-app/credential/login.action"
74
+ login_headers = {
75
+ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
76
+ "Origin": "https://app.bi.piao51.cn",
77
+ }
78
+ login_data = {
79
+ "username": username,
80
+ "password": password,
81
+ "type": "1",
82
+ "resCode": res_code,
83
+ "deviceid": device_id,
84
+ "dtype": "ios",
85
+ }
86
+
87
+ try:
88
+ response_login = session.post(
89
+ login_url,
90
+ headers=login_headers,
91
+ data=login_data,
92
+ allow_redirects=False,
93
+ timeout=15,
94
+ )
95
+ if not (300 <= response_login.status_code < 400 and "token" in session.cookies):
96
+ raise RetryableAPIError(f"票务登录失败,状态码:{response_login.status_code}")
97
+
98
+ user_info_url = "https://app.bi.piao51.cn/cinema-app/security/logined.action"
99
+ response_user_info = session.get(user_info_url, timeout=10)
100
+ response_user_info.raise_for_status()
101
+ user_info = response_user_info.json()
102
+
103
+ if user_info.get("success") and user_info.get("data", {}).get("token"):
104
+ token_data = user_info["data"]
105
+ save_token(token_data)
106
+ return token_data
107
+ raise RetryableAPIError(f"票务登录未获取到 Token:{user_info.get('msg')}")
108
+ except RetryableAPIError:
109
+ raise
110
+ except requests.exceptions.RequestException as exc:
111
+ raise RetryableAPIError(f"票务登录接口异常:{exc}") from exc
112
+ except Exception as exc:
113
+ raise RetryableAPIError(f"票务登录处理异常:{exc}") from exc
114
+
115
+
116
+ def get_valid_cinema_token() -> str:
117
+ token_data = load_token()
118
+ token = token_data.get("token") if token_data else None
119
+ if token:
120
+ return token
121
+ token_data = login_and_get_token()
122
+ token = token_data.get("token") if token_data else None
123
+ if not token:
124
+ raise RetryableAPIError("未获取到票务 Token。")
125
+ return token
126
+
127
+
128
+ def fetch_hall_info(token: str) -> Dict[str, int]:
129
+ url = "https://cawapi.yinghezhong.com/showInfo/getShowHallInfo"
130
+ params = {"token": token, "_": int(time.time() * 1000)}
131
+ headers = {"Origin": "https://caw.yinghezhong.com", "User-Agent": "Mozilla/5.0"}
132
+
133
+ try:
134
+ response = requests.get(url, params=params, headers=headers, timeout=10)
135
+ response.raise_for_status()
136
+ payload = response.json()
137
+ except requests.exceptions.RequestException as exc:
138
+ raise RetryableAPIError(f"获取影厅信息接口异常:{exc}") from exc
139
+ except Exception as exc:
140
+ raise RetryableAPIError(f"获取影厅信息处理异常:{exc}") from exc
141
+
142
+ if payload.get("code") == 1 and payload.get("data") is not None:
143
+ return {str(item["hallId"]): int(item.get("seatNum") or 0) for item in payload["data"]}
144
+ if payload.get("code") == 500:
145
+ raise ValueError("Token 可能已失效")
146
+ raise RetryableAPIError(f"获取影厅信息失败:{payload.get('msg', '未知错误')}")
147
+
148
+
149
+ def fetch_schedule_data(token: str, show_date: str) -> List[dict]:
150
+ url = "https://cawapi.yinghezhong.com/showInfo/getHallShowInfo"
151
+ params = {"showDate": show_date, "token": token, "_": int(time.time() * 1000)}
152
+ headers = {"Origin": "https://caw.yinghezhong.com", "User-Agent": "Mozilla/5.0"}
153
+ last_request_error = None
154
+
155
+ for attempt in range(1, SCHEDULE_API_RETRY_ATTEMPTS + 1):
156
+ try:
157
+ response = requests.get(
158
+ url,
159
+ params=params,
160
+ headers=headers,
161
+ timeout=SCHEDULE_API_TIMEOUT_SECONDS,
162
+ )
163
+ response.raise_for_status()
164
+ payload = response.json()
165
+ except requests.exceptions.RequestException as exc:
166
+ last_request_error = exc
167
+ if attempt < SCHEDULE_API_RETRY_ATTEMPTS:
168
+ time.sleep(2)
169
+ continue
170
+ raise RetryableAPIError(f"获取 {show_date} 排片接口异常:{exc}") from exc
171
+ except Exception as exc:
172
+ raise RetryableAPIError(f"获取 {show_date} 排片处理异常:{exc}") from exc
173
+
174
+ if payload.get("code") == 1:
175
+ return payload.get("data", [])
176
+ if payload.get("code") == 500:
177
+ raise ValueError("Token 可能已失效")
178
+ if attempt < SCHEDULE_API_RETRY_ATTEMPTS:
179
+ time.sleep(2)
180
+ continue
181
+ raise RetryableAPIError(f"获取 {show_date} 排片失败:{payload.get('msg', '未知错误')}")
182
+
183
+ raise RetryableAPIError(f"获取 {show_date} 排片接口异常:{last_request_error}")
184
+
185
+
186
+ def fetch_schedule_data_by_cinema(token: str, cinema_id: str, show_date: str) -> List[dict]:
187
+ cinema_id = str(cinema_id or "").strip()
188
+ self_cinema_id = (CINEMA_ID or "").strip()
189
+ if self_cinema_id and cinema_id == self_cinema_id:
190
+ return fetch_schedule_data(token, show_date)
191
+
192
+ url = f"{API_BASE_URL}/competition/play/showInfoList"
193
+ params = {
194
+ "date": datetime.strptime(show_date, "%Y-%m-%d").strftime("%Y/%m/%d"),
195
+ "cinemaNum": cinema_id,
196
+ "token": token,
197
+ "_": str(int(time.time() * 1000)),
198
+ }
199
+ last_request_error = None
200
+
201
+ for attempt in range(1, SCHEDULE_API_RETRY_ATTEMPTS + 1):
202
+ try:
203
+ response = requests.get(
204
+ url,
205
+ params=params,
206
+ headers=COMMON_HEADERS,
207
+ timeout=SCHEDULE_API_TIMEOUT_SECONDS,
208
+ )
209
+ response.raise_for_status()
210
+ payload = response.json()
211
+ except requests.exceptions.RequestException as exc:
212
+ last_request_error = exc
213
+ if attempt < SCHEDULE_API_RETRY_ATTEMPTS:
214
+ time.sleep(2)
215
+ continue
216
+ raise RetryableAPIError(f"获取影院 {cinema_id} 在 {show_date} 的竞对排片接口异常:{exc}") from exc
217
+ except Exception as exc:
218
+ raise RetryableAPIError(f"获取影院 {cinema_id} 在 {show_date} 的竞对排片处理异常:{exc}") from exc
219
+
220
+ if payload.get("code") == 1:
221
+ return payload.get("data", []) or []
222
+ if payload.get("code") == 500:
223
+ raise ValueError("Token 可能已失效")
224
+ if attempt < SCHEDULE_API_RETRY_ATTEMPTS:
225
+ time.sleep(2)
226
+ continue
227
+ raise RetryableAPIError(
228
+ f"获取影院 {cinema_id} 在 {show_date} 的竞对排片失败:{payload.get('msg', '未知错误')}"
229
+ )
230
+
231
+ raise RetryableAPIError(f"获取影院 {cinema_id} 在 {show_date} 的竞对排片接口异常:{last_request_error}")
232
+
233
+
234
+ def fetch_available_movie_info(token: str, show_date: str) -> List[dict]:
235
+ url = "https://cawapi.yinghezhong.com/show/getMovieInfo"
236
+ params = {
237
+ "showDate": show_date,
238
+ "token": token,
239
+ "_": int(time.time() * 1000),
240
+ }
241
+ headers = {
242
+ "Origin": "https://caw.yinghezhong.com",
243
+ "Referer": "https://caw.yinghezhong.com/",
244
+ "User-Agent": "Mozilla/5.0",
245
+ }
246
+
247
+ try:
248
+ response = requests.get(url, params=params, headers=headers, timeout=15)
249
+ response.raise_for_status()
250
+ payload = response.json()
251
+ except requests.exceptions.RequestException as exc:
252
+ raise RetryableAPIError(f"获取可排片池接口异常:{exc}") from exc
253
+ except Exception as exc:
254
+ raise RetryableAPIError(f"获取可排片池处理异常:{exc}") from exc
255
+
256
+ if payload.get("code") == 1:
257
+ return payload.get("data", [])
258
+ if payload.get("code") == 500:
259
+ raise ValueError("Token 可能已失效")
260
+ raise RetryableAPIError(f"获取可排片池失败:{payload.get('msg', '未知错误')}")
261
+
262
+
263
+ def fetch_realtime_box_office(token: str, date_str: str) -> dict:
264
+ url = "https://app.bi.piao51.cn/cinema-app/market/realtimeDailyBoxOffice.action"
265
+ params = {
266
+ "qTime": date_str,
267
+ "token": token,
268
+ }
269
+ headers = {
270
+ "Host": "app.bi.piao51.cn",
271
+ "X-Requested-With": "XMLHttpRequest",
272
+ "jwt": "0",
273
+ "Accept": "application/json, text/javascript, */*; q=0.01",
274
+ "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
275
+ }
276
+
277
+ try:
278
+ response = requests.get(url, params=params, headers=headers, timeout=15)
279
+ response.raise_for_status()
280
+ payload = response.json()
281
+ except requests.exceptions.RequestException as exc:
282
+ raise RetryableAPIError(f"获取 {date_str} 全国大盘接口异常:{exc}") from exc
283
+ except Exception as exc:
284
+ raise RetryableAPIError(f"获取 {date_str} 全国大盘处理异常:{exc}") from exc
285
+
286
+ if payload.get("code") == "A00000":
287
+ return payload.get("results", {}) or {}
288
+ if "login" in response.text or payload.get("code") in {500, "500"}:
289
+ raise ValueError("Token 可能已失效")
290
+ raise RetryableAPIError(f"获取 {date_str} 全国大盘失败:{payload.get('msg', '未知错误')}")
291
+
292
+
293
+ def fetch_canonical_movie_names(token: str, date_str: str) -> List[str]:
294
+ if not CINEMA_ID:
295
+ return []
296
+
297
+ url = "https://app.bi.piao51.cn/cinema-app/mycinema/movieSellGross.action"
298
+ params = {
299
+ "token": token,
300
+ "startDate": date_str,
301
+ "endDate": date_str,
302
+ "dateType": "day",
303
+ "cinemaId": CINEMA_ID,
304
+ }
305
+ headers = {
306
+ "Host": "app.bi.piao51.cn",
307
+ "X-Requested-With": "XMLHttpRequest",
308
+ "jwt": "0",
309
+ "Accept": "application/json, text/javascript, */*; q=0.01",
310
+ "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
311
+ }
312
+
313
+ try:
314
+ response = requests.get(url, params=params, headers=headers, timeout=10)
315
+ response.raise_for_status()
316
+ data = response.json()
317
+ if data.get("code") == "A00000" and data.get("results"):
318
+ return [item["movieName"] for item in data["results"] if item.get("movieName") and item["movieName"] != "总计"]
319
+ except Exception:
320
+ return []
321
+ return []
322
+
323
+
324
+ def get_schedule_and_hall_info(show_date: str) -> Tuple[List[dict], Dict[str, int], str]:
325
+ token = get_valid_cinema_token()
326
+ try:
327
+ schedule = fetch_schedule_data(token, show_date)
328
+ hall_map = fetch_hall_info(token)
329
+ return schedule, hall_map, token
330
+ except ValueError:
331
+ token_data = login_and_get_token()
332
+ token = token_data.get("token") if token_data else None
333
+ if not token:
334
+ raise RetryableAPIError("重新登录后仍未获取到票务 Token。")
335
+ schedule = fetch_schedule_data(token, show_date)
336
+ hall_map = fetch_hall_info(token)
337
+ return schedule, hall_map, token
338
+
339
+
340
+ def get_schedule_by_cinema_with_token_management(cinema_id: str, show_date: str) -> Tuple[List[dict], str]:
341
+ token = get_valid_cinema_token()
342
+ try:
343
+ return fetch_schedule_data_by_cinema(token, cinema_id, show_date), token
344
+ except ValueError:
345
+ token_data = login_and_get_token()
346
+ token = token_data.get("token") if token_data else None
347
+ if not token:
348
+ raise RetryableAPIError("重新登录后仍未获取到票务 Token。")
349
+ return fetch_schedule_data_by_cinema(token, cinema_id, show_date), token
350
+
351
+
352
+ def get_available_movies_with_token_management(show_date: str) -> Tuple[List[dict], str]:
353
+ token = get_valid_cinema_token()
354
+ try:
355
+ return fetch_available_movie_info(token, show_date), token
356
+ except ValueError:
357
+ token_data = login_and_get_token()
358
+ token = token_data.get("token") if token_data else None
359
+ if not token:
360
+ raise RetryableAPIError("重新登录后仍未获取到票务 Token。")
361
+ return fetch_available_movie_info(token, show_date), token
362
+
363
+
364
+ def get_realtime_box_office_with_token_management(date_str: str) -> Tuple[dict, str]:
365
+ token = get_valid_cinema_token()
366
+ try:
367
+ return fetch_realtime_box_office(token, date_str), token
368
+ except ValueError:
369
+ token_data = login_and_get_token()
370
+ token = token_data.get("token") if token_data else None
371
+ if not token:
372
+ raise RetryableAPIError("重新登录后仍未获取到票务 Token。")
373
+ return fetch_realtime_box_office(token, date_str), token
data/efficiency_at_each_time_point.json ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "09:45": 0.894733,
3
+ "09:50": 0.524336,
4
+ "09:55": 0.398518,
5
+ "10:00": 0.436228,
6
+ "10:05": 0.326204,
7
+ "10:10": 0.400775,
8
+ "10:15": 0.368501,
9
+ "10:20": 0.378284,
10
+ "10:25": 0.341553,
11
+ "10:30": 0.520815,
12
+ "10:35": 0.3353,
13
+ "10:40": 0.377738,
14
+ "10:45": 0.374873,
15
+ "10:50": 0.36036,
16
+ "10:55": 0.305426,
17
+ "11:00": 0.358998,
18
+ "11:05": 0.301232,
19
+ "11:10": 0.348796,
20
+ "11:15": 0.426774,
21
+ "11:20": 0.338455,
22
+ "11:25": 0.31732,
23
+ "11:30": 0.399561,
24
+ "11:35": 0.313701,
25
+ "11:40": 0.367346,
26
+ "11:45": 0.305961,
27
+ "11:50": 0.336273,
28
+ "11:55": 0.227869,
29
+ "12:00": 0.396251,
30
+ "12:05": 0.292967,
31
+ "12:10": 0.340811,
32
+ "12:15": 0.338903,
33
+ "12:20": 0.359726,
34
+ "12:25": 0.334413,
35
+ "12:30": 0.552494,
36
+ "12:35": 0.343258,
37
+ "12:40": 0.438833,
38
+ "12:45": 0.398147,
39
+ "12:50": 0.425803,
40
+ "12:55": 0.4308,
41
+ "13:00": 0.639481,
42
+ "13:05": 0.43584,
43
+ "13:10": 0.585951,
44
+ "13:15": 0.607589,
45
+ "13:20": 0.814758,
46
+ "13:25": 0.622502,
47
+ "13:30": 0.939956,
48
+ "13:35": 0.655399,
49
+ "13:40": 0.753667,
50
+ "13:45": 0.697589,
51
+ "13:50": 0.925888,
52
+ "13:55": 0.825692,
53
+ "14:00": 1.112837,
54
+ "14:05": 0.939885,
55
+ "14:10": 1.139797,
56
+ "14:15": 1.15655,
57
+ "14:20": 1.201916,
58
+ "14:25": 1.093155,
59
+ "14:30": 1.337946,
60
+ "14:35": 1.014206,
61
+ "14:40": 1.322562,
62
+ "14:45": 1.119861,
63
+ "14:50": 1.205953,
64
+ "14:55": 1.076064,
65
+ "15:00": 1.436359,
66
+ "15:05": 1.167116,
67
+ "15:10": 1.258112,
68
+ "15:15": 1.282796,
69
+ "15:20": 1.180998,
70
+ "15:25": 1.099845,
71
+ "15:30": 1.264543,
72
+ "15:35": 1.176416,
73
+ "15:40": 1.1762,
74
+ "15:45": 1.103772,
75
+ "15:50": 1.047339,
76
+ "15:55": 0.959803,
77
+ "16:00": 0.898111,
78
+ "16:05": 0.822474,
79
+ "16:10": 0.928322,
80
+ "16:15": 0.879823,
81
+ "16:20": 0.862768,
82
+ "16:25": 0.731169,
83
+ "16:30": 0.837509,
84
+ "16:35": 0.8335,
85
+ "16:40": 0.893883,
86
+ "16:45": 0.789401,
87
+ "16:50": 0.705589,
88
+ "16:55": 0.645867,
89
+ "17:00": 0.553473,
90
+ "17:05": 0.632969,
91
+ "17:10": 0.669639,
92
+ "17:15": 0.53453,
93
+ "17:20": 0.626554,
94
+ "17:25": 0.542131,
95
+ "17:30": 0.621175,
96
+ "17:35": 0.528622,
97
+ "17:40": 0.497968,
98
+ "17:45": 0.486565,
99
+ "17:50": 0.652411,
100
+ "17:55": 0.345271,
101
+ "18:00": 0.613171,
102
+ "18:05": 0.653778,
103
+ "18:10": 0.604403,
104
+ "18:15": 0.645896,
105
+ "18:20": 0.656057,
106
+ "18:25": 0.616838,
107
+ "18:30": 0.705481,
108
+ "18:35": 0.836565,
109
+ "18:40": 0.861882,
110
+ "18:45": 0.759541,
111
+ "18:50": 0.740935,
112
+ "18:55": 0.790371,
113
+ "19:00": 1.360146,
114
+ "19:05": 1.104657,
115
+ "19:10": 1.332044,
116
+ "19:15": 1.41697,
117
+ "19:20": 1.383817,
118
+ "19:25": 1.354397,
119
+ "19:30": 1.859736,
120
+ "19:35": 1.500996,
121
+ "19:40": 1.61648,
122
+ "19:45": 1.383858,
123
+ "19:50": 1.519501,
124
+ "19:55": 1.730771,
125
+ "20:00": 2.510784,
126
+ "20:05": 1.51142,
127
+ "20:10": 2.019814,
128
+ "20:15": 1.954568,
129
+ "20:20": 2.177169,
130
+ "20:25": 2.067442,
131
+ "20:30": 2.53507,
132
+ "20:35": 1.94052,
133
+ "20:40": 2.593103,
134
+ "20:45": 2.222822,
135
+ "20:50": 2.369975,
136
+ "20:55": 2.626353,
137
+ "21:00": 1.893128,
138
+ "21:05": 1.755495,
139
+ "21:10": 2.216455,
140
+ "21:15": 1.558653,
141
+ "21:20": 2.00998,
142
+ "21:25": 1.510278,
143
+ "21:30": 1.903993,
144
+ "21:35": 1.659127,
145
+ "21:40": 1.622959,
146
+ "21:45": 1.544068,
147
+ "21:50": 1.666697,
148
+ "21:55": 1.452239,
149
+ "22:00": 1.053099,
150
+ "22:05": 0.926646,
151
+ "22:10": 1.147177,
152
+ "22:15": 1.007091,
153
+ "22:20": 1.217177,
154
+ "22:25": 1.013732,
155
+ "22:30": 0.934037,
156
+ "22:35": 0.846258,
157
+ "22:40": 1.009769,
158
+ "22:45": 0.935017,
159
+ "22:50": 1.022347,
160
+ "22:55": 0.898928,
161
+ "23:00": 0.712869,
162
+ "23:05": 0.56042,
163
+ "23:10": 0.758855,
164
+ "23:15": 0.876,
165
+ "23:20": 0.747069,
166
+ "23:25": 0.929663,
167
+ "23:30": 0.828234,
168
+ "23:35": 0.641205
169
+ }
gantt_export.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 将甘特图完整 HTML 文档渲染为 PNG(headless Chrome),再转 JPG,并写入本地缓存目录供下载。
3
+ 依赖:html2image(需本机已安装 Chrome/Chromium)、Pillow。
4
+ """
5
+ import io
6
+ import os
7
+ from datetime import datetime
8
+ from typing import Optional, Tuple
9
+
10
+
11
+ def _build_export_html(html_document: str) -> str:
12
+ """注入导出专用样式:关闭滚动裁切,避免截图只保留可视区域。"""
13
+ export_css = """
14
+ <style>
15
+ html, body {
16
+ margin: 0 !important;
17
+ padding: 0 !important;
18
+ background: #fff !important;
19
+ width: max-content !important;
20
+ display: inline-block !important;
21
+ }
22
+ .scroll-wrapper { overflow: visible !important; width: max-content !important; }
23
+ .schedule-container { width: max-content !important; }
24
+ </style>
25
+ """
26
+ if "</head>" in html_document:
27
+ return html_document.replace("</head>", f"{export_css}\n</head>", 1)
28
+ return f"{export_css}\n{html_document}"
29
+
30
+
31
+ def _trim_png_whitespace(png_bytes: bytes, threshold: int = 250, padding: int = 8) -> bytes:
32
+ """裁切四周近白空白区域,让图像内容尽量铺满画面。"""
33
+ from PIL import Image
34
+
35
+ im = Image.open(io.BytesIO(png_bytes)).convert("RGB")
36
+ gray = im.convert("L")
37
+ mask = gray.point(lambda x: 255 if x < threshold else 0)
38
+ bbox = mask.getbbox()
39
+ if not bbox:
40
+ return png_bytes
41
+
42
+ left, top, right, bottom = bbox
43
+ left = max(0, left - padding)
44
+ top = max(0, top - padding)
45
+ right = min(im.width, right + padding)
46
+ bottom = min(im.height, bottom + padding)
47
+
48
+ cropped = im.crop((left, top, right, bottom))
49
+ out = io.BytesIO()
50
+ cropped.save(out, format="PNG", optimize=True)
51
+ return out.getvalue()
52
+
53
+
54
+ def html_document_to_png_bytes(html_document: str, width: int = 1680, height: int = 5000) -> bytes:
55
+ """使用 html2image 将完整 HTML 页面渲染为高分辨率 PNG 字节。"""
56
+ import tempfile
57
+
58
+ from html2image import Html2Image
59
+
60
+ prepared_html = _build_export_html(html_document)
61
+
62
+ with tempfile.TemporaryDirectory() as td:
63
+ hti = Html2Image(
64
+ output_path=td,
65
+ size=(width, height),
66
+ custom_flags=[
67
+ "--hide-scrollbars",
68
+ "--force-device-scale-factor=3",
69
+ "--default-background-color=ffffff",
70
+ ],
71
+ )
72
+ hti.screenshot(html_str=prepared_html, save_as="gantt.png")
73
+ path = os.path.join(td, "gantt.png")
74
+ with open(path, "rb") as f:
75
+ raw = f.read()
76
+ return _trim_png_whitespace(raw)
77
+
78
+
79
+ def png_bytes_to_jpeg_bytes(png_bytes: bytes, quality: int = 95) -> bytes:
80
+ from PIL import Image
81
+
82
+ im = Image.open(io.BytesIO(png_bytes)).convert("RGB")
83
+ buf = io.BytesIO()
84
+ im.save(buf, format="JPEG", quality=quality, optimize=True, subsampling=0)
85
+ return buf.getvalue()
86
+
87
+
88
+ def save_png_jpeg_to_cache(
89
+ png_bytes: bytes,
90
+ jpg_bytes: bytes,
91
+ base_name: str,
92
+ cache_dir: Optional[str] = None,
93
+ ) -> Tuple[str, str]:
94
+ """后台保存 PNG/JPG 到 cinema_cache/gantt_exports/,返回文件路径。"""
95
+ if cache_dir is None:
96
+ cache_dir = os.path.join("cinema_cache", "gantt_exports")
97
+ os.makedirs(cache_dir, exist_ok=True)
98
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
99
+ safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in base_name)[:80]
100
+ png_path = os.path.join(cache_dir, f"{safe}_{ts}.png")
101
+ jpg_path = os.path.join(cache_dir, f"{safe}_{ts}.jpg")
102
+ with open(png_path, "wb") as f:
103
+ f.write(png_bytes)
104
+ with open(jpg_path, "wb") as f:
105
+ f.write(jpg_bytes)
106
+ return png_path, jpg_path
107
+
108
+
109
+ def build_png_and_jpeg_for_gantt(html_document: str, num_hall_rows: int) -> Tuple[bytes, bytes]:
110
+ """
111
+ 根据影厅行数估算截图尺寸,生成更清晰且内容占满画面的 PNG/JPG 字节。
112
+ """
113
+ nrows = max(1, num_hall_rows)
114
+ width = 1680
115
+ height = min(22000, max(2200, 360 + nrows * 120))
116
+ png = html_document_to_png_bytes(html_document, width=width, height=height)
117
+ jpg = png_bytes_to_jpeg_bytes(png)
118
+ return png, jpg
historical_sessions.py ADDED
@@ -0,0 +1,408 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ from datetime import date, datetime, time as dt_time, timedelta
4
+ from pathlib import Path
5
+ from typing import Iterable, List, Optional
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+
10
+ from cinema_api_client import fetch_canonical_movie_names
11
+
12
+
13
+ ROOT_DIR = Path(__file__).resolve().parent
14
+ STATE_DIR = ROOT_DIR / "cinema_cache"
15
+ LOCAL_HISTORY_FILE = STATE_DIR / "historical_sessions.csv"
16
+ LOCAL_HISTORY_MANIFEST_FILE = STATE_DIR / "historical_sessions_manifest.json"
17
+ LEGACY_HISTORY_FILE = ROOT_DIR / "persistent_data.csv"
18
+
19
+ HISTORY_COLUMNS = [
20
+ "showId",
21
+ "影片名称",
22
+ "影片名称_清理后",
23
+ "放映日期",
24
+ "放映时间",
25
+ "影厅",
26
+ "座位数",
27
+ "总收入",
28
+ "总人次",
29
+ "场次",
30
+ "影片时长(分钟)",
31
+ "影片时长档位",
32
+ "影片时长类型",
33
+ "影片编码",
34
+ "影片语言",
35
+ "影片制式",
36
+ ]
37
+
38
+
39
+ def ensure_state_dir() -> None:
40
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
41
+
42
+
43
+ def clean_movie_title(raw_title, canonical_names=None):
44
+ if not isinstance(raw_title, str):
45
+ return raw_title
46
+
47
+ base_name = None
48
+ if canonical_names:
49
+ sorted_names = sorted(canonical_names, key=len, reverse=True)
50
+ for name in sorted_names:
51
+ if name in raw_title:
52
+ base_name = name
53
+ break
54
+
55
+ if not base_name:
56
+ base_name = raw_title.split(" ", 1)[0]
57
+
58
+ raw_upper = raw_title.upper()
59
+ suffix = ""
60
+ if "HDR LED" in raw_upper:
61
+ suffix = "(HDR LED)"
62
+ elif "CINITY" in raw_upper:
63
+ suffix = "(CINITY)"
64
+ elif "杜比" in raw_upper or "DOLBY" in raw_upper:
65
+ suffix = "(杜比视界)"
66
+ elif "IMAX" in raw_upper:
67
+ suffix = "(数字IMAX3D)" if "3D" in raw_upper else "(数字IMAX)"
68
+ elif "巨幕" in raw_upper:
69
+ suffix = "(中国巨幕立体)" if "立体" in raw_upper else "(中国巨幕)"
70
+ elif "3D" in raw_upper:
71
+ suffix = "(数字3D)"
72
+
73
+ if suffix and suffix not in base_name:
74
+ return f"{base_name}{suffix}"
75
+ return base_name
76
+
77
+
78
+ def round_minutes_to_10min(minutes):
79
+ numeric_value = pd.to_numeric(minutes, errors="coerce")
80
+ if pd.isna(numeric_value) or float(numeric_value) <= 0:
81
+ return np.nan
82
+ return int(np.floor((float(numeric_value) + 5) / 10) * 10)
83
+
84
+
85
+ def create_duration_label(minutes):
86
+ if pd.isna(minutes):
87
+ return np.nan
88
+ return f"{int(minutes)}分钟档"
89
+
90
+
91
+ def create_empty_history_df() -> pd.DataFrame:
92
+ data = {column: pd.Series(dtype="object") for column in HISTORY_COLUMNS}
93
+ data["放映日期"] = pd.Series(dtype="datetime64[ns]")
94
+ data["座位数"] = pd.Series(dtype="int64")
95
+ data["总收入"] = pd.Series(dtype="float64")
96
+ data["总人次"] = pd.Series(dtype="int64")
97
+ data["场次"] = pd.Series(dtype="int64")
98
+ data["影片时长(分钟)"] = pd.Series(dtype="float64")
99
+ data["影片时长档位"] = pd.Series(dtype="float64")
100
+ return pd.DataFrame(data)
101
+
102
+
103
+ def normalize_time_value(value):
104
+ if pd.isna(value):
105
+ return None
106
+ if isinstance(value, datetime):
107
+ return value.time().replace(second=0, microsecond=0)
108
+ if isinstance(value, dt_time):
109
+ return value.replace(second=0, microsecond=0)
110
+
111
+ numeric_value = pd.to_numeric(pd.Series([value]), errors="coerce").iloc[0]
112
+ if pd.notna(numeric_value) and 0 <= float(numeric_value) < 1:
113
+ total_minutes = int(round(float(numeric_value) * 24 * 60)) % (24 * 60)
114
+ return (datetime.min + timedelta(minutes=total_minutes)).time()
115
+
116
+ parsed = pd.to_datetime(str(value), errors="coerce")
117
+ if pd.isna(parsed):
118
+ return None
119
+ return parsed.time().replace(second=0, microsecond=0)
120
+
121
+
122
+ def _normalize_history_df(df: Optional[pd.DataFrame]) -> pd.DataFrame:
123
+ if df is None or df.empty:
124
+ return create_empty_history_df()
125
+
126
+ normalized = df.copy()
127
+ for column in HISTORY_COLUMNS:
128
+ if column not in normalized.columns:
129
+ normalized[column] = np.nan
130
+
131
+ normalized["影片名称"] = normalized["影片名称"].astype(str).str.strip()
132
+ normalized = normalized[normalized["影片名称"].ne("") & normalized["影片名称"].ne("nan")].copy()
133
+ normalized["影片名称_清理后"] = normalized["影片名称_清理后"].where(
134
+ normalized["影片名称_清理后"].notna(),
135
+ normalized["影片名称"].apply(clean_movie_title),
136
+ )
137
+ normalized["影片名称_清理后"] = normalized["影片名称_清理后"].astype(str).str.strip()
138
+
139
+ normalized["放映日期"] = pd.to_datetime(normalized["放映日期"], errors="coerce").dt.normalize()
140
+ normalized["放映时间"] = normalized["放映时间"].apply(normalize_time_value)
141
+
142
+ for column in ["座位数", "总人次", "场次"]:
143
+ normalized[column] = pd.to_numeric(normalized[column], errors="coerce").fillna(0).round().astype(int)
144
+ normalized["总收入"] = pd.to_numeric(normalized["总收入"], errors="coerce").fillna(0.0).astype(float)
145
+ normalized["影片时长(分钟)"] = pd.to_numeric(normalized["影片时长(分钟)"], errors="coerce")
146
+ normalized = normalized[
147
+ (normalized["影片时长(分钟)"].isna()) |
148
+ ((normalized["影片时长(分钟)"] > 0) & (normalized["影片时长(分钟)"] <= 400))
149
+ ].copy()
150
+ normalized["影片时长档位"] = normalized["影片时长(分钟)"].apply(round_minutes_to_10min)
151
+ normalized["影片时长类型"] = normalized["影片时长档位"].apply(create_duration_label)
152
+
153
+ normalized["影厅"] = normalized["影厅"].fillna("").astype(str).str.strip()
154
+ normalized["showId"] = normalized["showId"].fillna("").astype(str).str.strip()
155
+ normalized["影片编码"] = normalized["影片编码"].fillna("").astype(str).str.strip()
156
+ normalized["影片语言"] = normalized["影片语言"].fillna("").astype(str).str.strip()
157
+ normalized["影片制式"] = normalized["影片制式"].fillna("").astype(str).str.strip()
158
+
159
+ normalized = normalized.dropna(subset=["放映日期", "放映时间"]).copy()
160
+ normalized["放映时间_str"] = normalized["放映时间"].apply(lambda value: value.strftime("%H:%M:%S") if isinstance(value, dt_time) else "")
161
+
162
+ with_show_id = normalized[normalized["showId"].ne("")].copy()
163
+ without_show_id = normalized[normalized["showId"].eq("")].copy()
164
+
165
+ if not with_show_id.empty:
166
+ with_show_id = with_show_id.drop_duplicates(subset=["showId"], keep="last")
167
+ if not without_show_id.empty:
168
+ without_show_id = without_show_id.drop_duplicates(
169
+ subset=["影片名称", "放映日期", "放映时间_str", "影厅"],
170
+ keep="last",
171
+ )
172
+
173
+ normalized = pd.concat([with_show_id, without_show_id], ignore_index=True)
174
+ normalized = normalized.sort_values(["放映日期", "放映时间_str", "影厅", "影片名称"]).reset_index(drop=True)
175
+ normalized.drop(columns=["放映时间_str"], inplace=True)
176
+ return normalized[HISTORY_COLUMNS]
177
+
178
+
179
+ def load_history_df() -> pd.DataFrame:
180
+ ensure_state_dir()
181
+
182
+ if LOCAL_HISTORY_FILE.exists():
183
+ try:
184
+ return _normalize_history_df(pd.read_csv(LOCAL_HISTORY_FILE))
185
+ except Exception:
186
+ return create_empty_history_df()
187
+
188
+ if LEGACY_HISTORY_FILE.exists():
189
+ try:
190
+ legacy_df = pd.read_csv(LEGACY_HISTORY_FILE)
191
+ history_df = _normalize_history_df(legacy_df)
192
+ save_history_df(history_df)
193
+ return history_df
194
+ except Exception:
195
+ return create_empty_history_df()
196
+
197
+ return create_empty_history_df()
198
+
199
+
200
+ def save_history_df(df: pd.DataFrame) -> pd.DataFrame:
201
+ ensure_state_dir()
202
+ normalized = _normalize_history_df(df)
203
+ normalized.to_csv(LOCAL_HISTORY_FILE, index=False)
204
+ return normalized
205
+
206
+
207
+ def merge_history_df(existing_df: Optional[pd.DataFrame], new_df: Optional[pd.DataFrame]) -> pd.DataFrame:
208
+ frames = []
209
+ if existing_df is not None and not existing_df.empty:
210
+ frames.append(existing_df)
211
+ if new_df is not None and not new_df.empty:
212
+ frames.append(new_df)
213
+ merged = pd.concat(frames, ignore_index=True) if frames else create_empty_history_df()
214
+ return save_history_df(merged)
215
+
216
+
217
+ def prepare_manual_report_history_df(raw_df: pd.DataFrame) -> pd.DataFrame:
218
+ if raw_df is None or raw_df.empty:
219
+ return create_empty_history_df()
220
+
221
+ prepared = raw_df.copy()
222
+ prepared["场次"] = 1
223
+ prepared.rename(
224
+ columns={
225
+ 0: "影片名称",
226
+ 1: "放映日期",
227
+ 2: "放映时间",
228
+ 5: "总人次",
229
+ 6: "总收入",
230
+ 7: "座位数",
231
+ },
232
+ inplace=True,
233
+ )
234
+ required_cols = ["影片名称", "放映日期", "放映时间", "座位数", "总收入", "总人次", "场次"]
235
+ prepared = prepared[required_cols]
236
+ prepared.dropna(subset=["影片名称", "放映日期", "放映时间"], inplace=True)
237
+ prepared["影片名称_清理后"] = prepared["影片名称"].apply(clean_movie_title)
238
+ prepared["影厅"] = ""
239
+ prepared["showId"] = ""
240
+ prepared["影片编码"] = ""
241
+ prepared["影片语言"] = ""
242
+ prepared["影片制式"] = ""
243
+ prepared["影片时长(分钟)"] = np.nan
244
+ prepared["影片时长档位"] = np.nan
245
+ prepared["影片时长类型"] = np.nan
246
+ return _normalize_history_df(prepared)
247
+
248
+
249
+ def prepare_history_df_from_schedule(schedule_list: List[dict], show_date: str, hall_seat_map=None, token: Optional[str] = None) -> pd.DataFrame:
250
+ if not schedule_list:
251
+ return create_empty_history_df()
252
+
253
+ hall_seat_map = {str(key): value for key, value in (hall_seat_map or {}).items()}
254
+ canonical_names = fetch_canonical_movie_names(token, show_date) if token else []
255
+ rows = []
256
+
257
+ for item in schedule_list:
258
+ movie_name = item.get("movieName")
259
+ start_time = item.get("showStartTime")
260
+ if not movie_name or not start_time:
261
+ continue
262
+
263
+ movie_length = pd.to_numeric(item.get("movieLength"), errors="coerce")
264
+ cleaned_name = clean_movie_title(movie_name, canonical_names if canonical_names else None)
265
+ hall_id = str(item.get("hallId") or "").strip()
266
+ rows.append(
267
+ {
268
+ "showId": str(item.get("showId") or "").strip(),
269
+ "影片名称": cleaned_name,
270
+ "影片名称_清理后": cleaned_name,
271
+ "放映日期": show_date,
272
+ "放映时间": start_time,
273
+ "影厅": item.get("hallName") or "",
274
+ "座位数": hall_seat_map.get(hall_id, 0),
275
+ "总收入": pd.to_numeric(item.get("soldBoxOffice"), errors="coerce"),
276
+ "总人次": pd.to_numeric(item.get("soldTicketNum"), errors="coerce"),
277
+ "场次": 1,
278
+ "影片时长(分钟)": movie_length,
279
+ "影片时长档位": round_minutes_to_10min(movie_length),
280
+ "影片时长类型": create_duration_label(round_minutes_to_10min(movie_length)),
281
+ "影片编码": str(item.get("movieNum") or "").strip(),
282
+ "影片语言": str(item.get("movieLanguage") or "").strip(),
283
+ "影片制式": str(item.get("movieMediaType") or "").strip(),
284
+ }
285
+ )
286
+
287
+ return _normalize_history_df(pd.DataFrame(rows))
288
+
289
+
290
+ def get_available_date_set(df: Optional[pd.DataFrame]) -> set:
291
+ if df is None or df.empty or "放映日期" not in df.columns:
292
+ return set()
293
+ return {value.date() for value in pd.to_datetime(df["放映日期"], errors="coerce").dropna()}
294
+
295
+
296
+ def find_missing_dates(df: Optional[pd.DataFrame], start_date: date, end_date: date) -> List[date]:
297
+ if start_date > end_date:
298
+ return []
299
+ existing_dates = get_available_date_set(df)
300
+ missing_dates = []
301
+ current = start_date
302
+ while current <= end_date:
303
+ if current not in existing_dates:
304
+ missing_dates.append(current)
305
+ current += timedelta(days=1)
306
+ return missing_dates
307
+
308
+
309
+ def build_duration_reference_from_history(df: Optional[pd.DataFrame]) -> pd.DataFrame:
310
+ if df is None or df.empty or "影片时长(分钟)" not in df.columns:
311
+ return pd.DataFrame(
312
+ columns=["影片", "影片名称_清理后", "影片时长(分钟)", "影片时长档位", "影片时长类型", "记录场次"]
313
+ )
314
+
315
+ duration_df = df.copy()
316
+ duration_df["影片时长(分钟)"] = pd.to_numeric(duration_df["影片时长(分钟)"], errors="coerce")
317
+ duration_df = duration_df.dropna(subset=["影片名称_清理后", "影片时长(分钟)"]).copy()
318
+ if duration_df.empty:
319
+ return pd.DataFrame(
320
+ columns=["影片", "影片名称_清理后", "影片时长(分钟)", "影片时长档位", "影片时长类型", "记录场次"]
321
+ )
322
+
323
+ duration_df["影片时长(分钟)"] = duration_df["影片时长(分钟)"].round().astype(int)
324
+ duration_df["影片时长档位"] = duration_df["影片时长(分钟)"].apply(round_minutes_to_10min)
325
+ duration_df["影片时长类型"] = duration_df["影片时长档位"].apply(create_duration_label)
326
+ duration_df["影片"] = duration_df["影片名称"]
327
+
328
+ summary = (
329
+ duration_df.groupby(["影片名称_清理后", "影片时长(分钟)", "影片时长档位", "影片时长类型"], as_index=False)
330
+ .agg(影片=("影片", "first"), 记录场次=("场次", "sum"))
331
+ .sort_values(["影片名称_清理后", "影片时长(分钟)"])
332
+ .reset_index(drop=True)
333
+ )
334
+ return summary[["影片", "影片名称_清理后", "影片时长(分钟)", "影片时长档位", "影片时长类型", "记录场次"]]
335
+
336
+
337
+ def summarize_total_box_office_by_movies(df: Optional[pd.DataFrame], movie_names: Iterable[str]) -> pd.DataFrame:
338
+ requested_names = [str(name).strip() for name in (movie_names or []) if str(name).strip()]
339
+ if not requested_names:
340
+ return pd.DataFrame(columns=["影片", "总票房"])
341
+
342
+ if df is None or df.empty:
343
+ return pd.DataFrame({"影片": requested_names, "总票房": [0.0] * len(requested_names)})
344
+
345
+ history_df = df.copy()
346
+ history_df["总收入"] = pd.to_numeric(history_df["总收入"], errors="coerce").fillna(0.0)
347
+ totals = history_df.groupby("影片名称_清理后")["总收入"].sum().to_dict()
348
+ output_df = pd.DataFrame(
349
+ {
350
+ "影片": requested_names,
351
+ "总票房": [float(totals.get(name, 0.0)) for name in requested_names],
352
+ }
353
+ )
354
+ return output_df.sort_values(["总票房", "影片"], ascending=[False, True]).reset_index(drop=True)
355
+
356
+
357
+ def default_history_manifest() -> dict:
358
+ return {
359
+ "synced_dates": [],
360
+ "updated_at": "",
361
+ "last_successful_target_date": "",
362
+ }
363
+
364
+
365
+ def load_history_manifest() -> dict:
366
+ ensure_state_dir()
367
+ manifest = default_history_manifest()
368
+
369
+ if LOCAL_HISTORY_MANIFEST_FILE.exists():
370
+ try:
371
+ payload = json.loads(LOCAL_HISTORY_MANIFEST_FILE.read_text(encoding="utf-8"))
372
+ if isinstance(payload, dict):
373
+ manifest.update(payload)
374
+ except Exception:
375
+ pass
376
+
377
+ synced_dates = manifest.get("synced_dates", [])
378
+ if not isinstance(synced_dates, list):
379
+ synced_dates = []
380
+ manifest["synced_dates"] = sorted({str(item).strip() for item in synced_dates if str(item).strip()})
381
+ return manifest
382
+
383
+
384
+ def save_history_manifest(manifest: Optional[dict]) -> dict:
385
+ ensure_state_dir()
386
+ final_manifest = default_history_manifest()
387
+ if isinstance(manifest, dict):
388
+ final_manifest.update(manifest)
389
+
390
+ synced_dates = final_manifest.get("synced_dates", [])
391
+ if not isinstance(synced_dates, list):
392
+ synced_dates = []
393
+ final_manifest["synced_dates"] = sorted({str(item).strip() for item in synced_dates if str(item).strip()})
394
+
395
+ LOCAL_HISTORY_MANIFEST_FILE.write_text(
396
+ json.dumps(final_manifest, ensure_ascii=False, indent=2),
397
+ encoding="utf-8",
398
+ )
399
+ return final_manifest
400
+
401
+
402
+ def get_synced_date_set(manifest: Optional[dict]) -> set:
403
+ if not isinstance(manifest, dict):
404
+ return set()
405
+ synced_dates = manifest.get("synced_dates", [])
406
+ if not isinstance(synced_dates, list):
407
+ return set()
408
+ return {str(item).strip() for item in synced_dates if str(item).strip()}
legacy_pages/tms-2.py ADDED
@@ -0,0 +1,372 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import pandas as pd
3
+ import requests
4
+ import time
5
+ import os
6
+ import re
7
+ import urllib3
8
+ from collections import defaultdict
9
+ from dotenv import load_dotenv
10
+ from tms_proxy import TMS_ORIGIN, build_tms_url, get_tms_proxy_base_url, tms_verify_ssl, with_tms_proxy_headers
11
+
12
+ # --- 基础配置 ---
13
+ st.set_page_config(page_title="TMS 影片查询", page_icon="🎬", layout="wide")
14
+
15
+ # 屏蔽 HTTPS 证书警告
16
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
17
+
18
+ # 加载环境变量
19
+ load_dotenv()
20
+
21
+ # --- 工具函数 ---
22
+
23
+ def get_circled_number(hall_name):
24
+ """
25
+ 将影厅数字转换为带圈数字,例如 1 -> ①
26
+ """
27
+ mapping = {'1': '①', '2': '②', '3': '③', '4': '④', '5': '⑤', '6': '⑥', '7': '⑦', '8': '⑧', '9': '⑨'}
28
+ # 提取字符串中的数字
29
+ num_str = ''.join(filter(str.isdigit, str(hall_name)))
30
+ return mapping.get(num_str, num_str)
31
+
32
+
33
+ def format_play_time(time_str):
34
+ """
35
+ 格式化时长字符串,例如 "01:30" -> 90
36
+ """
37
+ if not time_str or not isinstance(time_str, str): return None
38
+ try:
39
+ parts = time_str.split(':')
40
+ hours = int(parts[0])
41
+ minutes = int(parts[1])
42
+ return hours * 60 + minutes
43
+ except (ValueError, IndexError):
44
+ return None
45
+
46
+
47
+ def format_content_name_with_explanation(content_name):
48
+ raw = str(content_name or '').strip()
49
+ if not raw:
50
+ return ''
51
+
52
+ lang_map = {
53
+ 'CMN': '国语/普通话', 'YUE': '粤语', 'EN': '英语', 'JP': '日语/或简化命名中的加密标记',
54
+ 'KO': '韩语', 'FR': '法语', 'ES': '西班牙语', 'TH': '泰语', 'HI': '印地语', 'RU': '俄语',
55
+ 'PTH': '普通话', 'GDH': '广东话', 'YS': '原声', 'YZ': '译制', 'SCH': '四川话',
56
+ 'NAN': '闽南语', 'WU': '吴语/上海话', 'XX': '无字幕', 'QMS': '简中字幕',
57
+ 'QMT': '繁中字幕', 'CCAP': '听障字幕'
58
+ }
59
+ audio_map = {'20': '2.0', '51': '5.1', '71': '7.1', 'ATMOS': 'Dolby Atmos', 'DTSX': 'DTS:X'}
60
+ type_map = {'FTR': '正片', 'TLR': '预告片', 'TSR': '先导预告'}
61
+ pack_map = {'OV': '原始版本包', 'VF': '版本增量包'}
62
+
63
+ notes = []
64
+ parts = raw.split('_')
65
+ first_tokens = parts[0].split('-') if parts else []
66
+ if first_tokens:
67
+ notes.append(f"[片名/标识:{first_tokens[0]}]")
68
+ for token in first_tokens[1:]:
69
+ up = token.upper()
70
+ if up in type_map:
71
+ notes.append(f"[内容类型:{type_map[up]}({token})]")
72
+ elif up in {'2D', '3D'}:
73
+ notes.append(f"[制式:{up}]")
74
+ elif up in {'4FL', '24FPS', '48FPS', '60FPS', '120FPS'}:
75
+ notes.append(f"[技术参数:{token}]")
76
+ elif re.fullmatch(r'\d+', up):
77
+ notes.append(f"[版本号:{token}]")
78
+ else:
79
+ notes.append(f"[{token}]")
80
+
81
+ for token in parts[1:]:
82
+ up = token.upper()
83
+ if '-' in up:
84
+ a, b = up.split('-', 1)
85
+ if a in lang_map and b in lang_map:
86
+ notes.append(f"[音频:{lang_map[a]}({a})]")
87
+ notes.append(f"[字幕:{lang_map[b]}({b})]")
88
+ continue
89
+ if up in {'F', 'S', 'C', 'F-178', 'C-19', '235', '185'}:
90
+ notes.append(f"[画幅:{token}]")
91
+ elif re.fullmatch(r'\d{2,3}M', up):
92
+ notes.append(f"[时长:{token}]")
93
+ elif up in audio_map:
94
+ notes.append(f"[音效:{audio_map[up]}({token})]")
95
+ elif up in {'2K', '4K'}:
96
+ notes.append(f"[分辨率:{up}]")
97
+ elif up in {'SMPTE', 'IOP'}:
98
+ notes.append(f"[封装标准:{up}]")
99
+ elif re.fullmatch(r'\d{8}', up):
100
+ notes.append(f"[打包日期:{token}]")
101
+ elif re.fullmatch(r'\d{4}', up):
102
+ notes.append(f"[月日批次:{token}]")
103
+ elif up in pack_map:
104
+ notes.append(f"[包类型:{pack_map[up]}({up})]")
105
+ elif up in lang_map:
106
+ notes.append(f"[语言/标记:{lang_map[up]}({up})]")
107
+ elif up.startswith('CN'):
108
+ notes.append(f"[地区/分级:{token}]")
109
+ else:
110
+ notes.append(f"[{token}]")
111
+
112
+ return f"{raw} / {' '.join(notes)}"
113
+
114
+
115
+ def clean_movie_title(raw_title, canonical_names=None):
116
+ """
117
+ 电影名称标准化清洗函数
118
+ """
119
+ if not isinstance(raw_title, str):
120
+ return raw_title
121
+
122
+ base_name = None
123
+
124
+ # 1. 尝试匹配标准名称
125
+ if canonical_names:
126
+ # 按长度倒序排序,确保最长匹配优先
127
+ sorted_names = sorted(canonical_names, key=len, reverse=True)
128
+ for name in sorted_names:
129
+ if name in raw_title:
130
+ base_name = name
131
+ break
132
+
133
+ # 2. 回退逻辑:如果没传列表或没匹配到,使用空格分割
134
+ if not base_name:
135
+ base_name = raw_title.split(' ', 1)[0]
136
+
137
+ # 3. 后缀追加逻辑
138
+ raw_upper = raw_title.upper()
139
+ suffix = ""
140
+
141
+ if "HDR LED" in raw_upper:
142
+ suffix = "(HDR LED)"
143
+ elif "CINITY" in raw_upper:
144
+ suffix = "(CINITY)"
145
+ elif "杜比" in raw_upper or "DOLBY" in raw_upper:
146
+ suffix = "(杜比视界)"
147
+ elif "IMAX" in raw_upper:
148
+ if "3D" in raw_upper:
149
+ suffix = "(数字IMAX3D)"
150
+ else:
151
+ suffix = "(数字IMAX)"
152
+ elif "巨幕" in raw_upper:
153
+ if "立体" in raw_upper:
154
+ suffix = "(中国巨幕立体)"
155
+ else:
156
+ suffix = "(中国巨幕)"
157
+ elif "3D" in raw_upper:
158
+ suffix = "(数字3D)"
159
+
160
+ # 只有当 base_name 自身不包含该后缀时才添加
161
+ if suffix and suffix not in base_name:
162
+ return f"{base_name}{suffix}"
163
+
164
+ return base_name
165
+
166
+
167
+ # --- 核心功能模块 ---
168
+
169
+ # @st.cache_data(show_spinner=False, ttl=600)
170
+ def fetch_and_process_server_movies(priority_movie_titles=None):
171
+ if priority_movie_titles is None: priority_movie_titles = []
172
+ tms_proxy_base_url = get_tms_proxy_base_url()
173
+
174
+ # 获取环境变量
175
+ app_secret = os.getenv("TMS_APP_SECRET")
176
+ ticket = os.getenv("TMS_TICKET")
177
+ theater_id_str = os.getenv("TMS_THEATER_ID")
178
+ x_session_id = os.getenv("TMS_X_SESSION_ID")
179
+
180
+ # 转换 ID 为整数
181
+ try:
182
+ theater_id = int(theater_id_str) if theater_id_str else 0
183
+ except ValueError:
184
+ st.error("环境变量 TMS_THEATER_ID 格式错误,应为数字。")
185
+ return {}, []
186
+
187
+ token_headers = {
188
+ 'Accept': 'application/json, text/javascript, */*; q=0.01',
189
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7,zh-TW;q=0.6',
190
+ 'Content-Type': 'application/json',
191
+ 'Cookie': f'JSESSIONID={x_session_id}',
192
+ 'DNT': '1',
193
+ 'Origin': 'https://tms.hengdianfilm.com',
194
+ 'Priority': 'u=0, i',
195
+ 'Referer': f'https://tms.hengdianfilm.com/hd/oalogin?ticket={ticket}',
196
+ 'Sec-CH-UA': '"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"',
197
+ 'Sec-CH-UA-Mobile': '?0',
198
+ 'Sec-CH-UA-Platform': '"macOS"',
199
+ 'Sec-Fetch-Dest': 'empty',
200
+ 'Sec-Fetch-Mode': 'cors',
201
+ 'Sec-Fetch-Site': 'same-origin',
202
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36',
203
+ 'X-Requested-With': 'XMLHttpRequest',
204
+ }
205
+
206
+ # 使用变量
207
+ token_json_data = {'appId': 'hd', 'appSecret': app_secret, 'timeStamp': int(time.time() * 1000)}
208
+ # 动态构建 URL
209
+ token_url = build_tms_url(
210
+ f'{TMS_ORIGIN}/cinema-api/admin/generateToken?token=hd&murl=?token=hd&murl=ticket={ticket}',
211
+ tms_proxy_base_url,
212
+ )
213
+ token_headers = with_tms_proxy_headers(token_headers, tms_proxy_base_url)
214
+
215
+ try:
216
+ response = requests.post(token_url, headers=token_headers, json=token_json_data, timeout=10)
217
+ response.raise_for_status()
218
+ token_data = response.json()
219
+ if token_data.get('error_code') != '0000':
220
+ raise Exception(f"获取Token失败: {token_data.get('error_desc')}")
221
+ auth_token = token_data['param']
222
+ except Exception as e:
223
+ st.error(f"连接 TMS 认证服务失败: {e}")
224
+ return {}, []
225
+
226
+ all_movies, page_index = [], 1
227
+ while True:
228
+ list_headers = {
229
+ 'Accept': 'application/json, text/javascript, */*; q=0.01',
230
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7,zh-TW;q=0.6',
231
+ 'Content-Type': 'application/json; charset=UTF-8',
232
+ 'Cookie': f'JSESSIONID={x_session_id}',
233
+ 'DNT': '1',
234
+ 'Origin': 'https://tms.hengdianfilm.com',
235
+ 'Priority': 'u=1, i',
236
+ 'Referer': f'https://tms.hengdianfilm.com/hd/index?ContentMovie&THEATER_ID={theater_id}&SOURCE=SERVER&ASSERT_TYPE=2&PAGE_CAPACITY=20&PAGE_INDEX=1',
237
+ 'Sec-CH-UA': '"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"',
238
+ 'Sec-CH-UA-Mobile': '?0',
239
+ 'Sec-CH-UA-Platform': '"macOS"',
240
+ 'Sec-Fetch-Dest': 'empty',
241
+ 'Sec-Fetch-Mode': 'cors',
242
+ 'Sec-Fetch-Site': 'same-origin',
243
+ 'Token': auth_token,
244
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36',
245
+ 'X-Requested-With': 'XMLHttpRequest',
246
+ 'X-SESSIONID': x_session_id,
247
+ }
248
+ list_params = {'token': 'hd', 'murl': 'ContentMovie'}
249
+ list_json_data = {'THEATER_ID': theater_id, 'SOURCE': 'SERVER', 'ASSERT_TYPE': 2, 'PAGE_CAPACITY': 20,
250
+ 'PAGE_INDEX': page_index}
251
+
252
+ list_url = build_tms_url(f'{TMS_ORIGIN}/cinema-api/cinema/server/dcp/list', tms_proxy_base_url)
253
+ list_headers = with_tms_proxy_headers(list_headers, tms_proxy_base_url)
254
+ try:
255
+ response = requests.post(
256
+ list_url,
257
+ params=list_params,
258
+ headers=list_headers,
259
+ json=list_json_data,
260
+ verify=tms_verify_ssl(default=False, proxy_url=tms_proxy_base_url),
261
+ timeout=15,
262
+ )
263
+ response.raise_for_status()
264
+ movie_data = response.json()
265
+ if movie_data.get("RSPCD") != "000000":
266
+ raise Exception(f"获取影片列表失败: {movie_data.get('RSPMSG')}")
267
+
268
+ body = movie_data.get("BODY", {})
269
+ movies_on_page = body.get("LIST", [])
270
+ if not movies_on_page: break
271
+ all_movies.extend(movies_on_page)
272
+ if len(all_movies) >= body.get("COUNT", 0): break
273
+ page_index += 1
274
+ time.sleep(0.5)
275
+ except Exception as e:
276
+ st.error(f"获取影片列表页 {page_index} 失败: {e}")
277
+ break
278
+
279
+ # 处理数据
280
+ movie_details = {m.get('CONTENT_NAME'): {'assert_name': m.get('ASSERT_NAME'),
281
+ 'halls': sorted([h.get('HALL_NAME') for h in m.get('HALL_INFO', [])]),
282
+ 'play_time': m.get('PLAY_TIME')} for m in all_movies if
283
+ m.get('CONTENT_NAME')}
284
+
285
+ by_hall = defaultdict(list)
286
+ for content_name, details in movie_details.items():
287
+ for hall_name in details['halls']:
288
+ by_hall[hall_name].append({'content_name': content_name, 'details': details})
289
+
290
+ for hall_name in by_hall:
291
+ by_hall[hall_name].sort(
292
+ key=lambda item: (item['details']['assert_name'] is None or item['details']['assert_name'] == '',
293
+ item['details']['assert_name'] or item['content_name']))
294
+
295
+ view2_list = [{'assert_name': d['assert_name'], 'content_name': c, 'halls': d['halls'], 'play_time': d['play_time']}
296
+ for c, d in movie_details.items() if d.get('assert_name')]
297
+
298
+ priority_list = [item for item in view2_list if
299
+ any(p_title in item['assert_name'] for p_title in priority_movie_titles)]
300
+ other_list_items = [item for item in view2_list if item not in priority_list]
301
+
302
+ priority_list.sort(key=lambda x: x['assert_name'])
303
+ other_list_items.sort(key=lambda x: x['assert_name'])
304
+ final_sorted_list = priority_list + other_list_items
305
+
306
+ return dict(sorted(by_hall.items())), final_sorted_list
307
+
308
+
309
+ # --- 主界面 ---
310
+
311
+ def main():
312
+ st.title("🎬 TMS 服务器影片内容查询")
313
+ st.info("查询 TMS 服务器上的 DCP 内容及分布情况。")
314
+
315
+ # 尝试从 Session State 获取优先显示的影片(如果在主页加载了排片)
316
+ priority_titles = []
317
+ if 'api_df' in st.session_state and not st.session_state.api_df.empty:
318
+ df = st.session_state.api_df
319
+ if '影片名称_清理后' in df.columns:
320
+ priority_titles = df['影片名称_清理后'].unique().tolist()
321
+ elif '影片名称' in df.columns:
322
+ priority_titles = df['影片名称'].apply(lambda x: clean_movie_title(x)).unique().tolist()
323
+
324
+ # 也可以检查 file_df
325
+ elif 'file_df' in st.session_state and not st.session_state.file_df.empty:
326
+ df = st.session_state.file_df
327
+ if '影片名称_清理后' in df.columns:
328
+ priority_titles = df['影片名称_清理后'].unique().tolist()
329
+ elif '影片名称' in df.columns:
330
+ priority_titles = df['影片名称'].apply(lambda x: clean_movie_title(x)).unique().tolist()
331
+
332
+ if st.button('点击查询 TMS 服务器', key="query_tms", type="primary", icon="🔍"):
333
+ with st.spinner("正在从 TMS 服务器获取数据中..."):
334
+ try:
335
+ halls_data, movie_list_sorted = fetch_and_process_server_movies(priority_titles)
336
+
337
+ if not movie_list_sorted:
338
+ st.warning("未获取到任何影片数据,请检查 TMS 连接配置。")
339
+ else:
340
+ st.success("TMS 服务器数据获取成功!")
341
+
342
+ # 1. 按影片查看
343
+ st.markdown("### 🎥 按影片查看所在影厅")
344
+ view2_data = [{'影片名称': item['assert_name'],
345
+ '所在影厅': " ".join(sorted([get_circled_number(h) for h in item['halls']])),
346
+ '时长(分钟)': format_play_time(item['play_time']),
347
+ '文件名': format_content_name_with_explanation(item['content_name'])}
348
+ for item in movie_list_sorted]
349
+ st.dataframe(pd.DataFrame(view2_data), hide_index=True, width="stretch")
350
+
351
+ st.divider()
352
+
353
+ # 2. 按影厅查看
354
+ st.markdown("### 🏢 按影厅查看影片内容")
355
+ if halls_data:
356
+ hall_tabs = st.tabs(list(halls_data.keys()))
357
+ for tab, hall_name in zip(hall_tabs, halls_data.keys()):
358
+ with tab:
359
+ view1_data = [{'影片名称': item['details']['assert_name'],
360
+ '所在影厅': " ".join(sorted([get_circled_number(h) for h in item['details']['halls']])),
361
+ '时长(分钟)': format_play_time(item['details']['play_time']),
362
+ '文件名': format_content_name_with_explanation(item['content_name'])} for item in
363
+ halls_data[hall_name]]
364
+ st.dataframe(pd.DataFrame(view1_data), hide_index=True, width="stretch")
365
+ else:
366
+ st.info("暂无影厅数据。")
367
+
368
+ except Exception as e:
369
+ st.error(f"查询 TMS 服务器时出错: {e}")
370
+
371
+ if __name__ == "__main__":
372
+ main()
movie_duration_data.csv ADDED
The diff for this file is too large to render. See raw diff
 
new_api_proxy.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from urllib.parse import urlsplit, urlunsplit
3
+
4
+
5
+ NEW_API_PROXY_URL_ENV = "NEW_API_PROXY_URL"
6
+ NEW_API_PROXY_TOKEN_ENV = "NEW_API_PROXY_TOKEN"
7
+ NEW_API_PROXY_AUTH_QUERY_ENV = "NEW_API_PROXY_AUTH_QUERY"
8
+ NEW_API_PROXY_DIRECT_FALLBACK_ENV = "NEW_API_PROXY_DIRECT_FALLBACK"
9
+ NEW_API_PROXY_TOKEN_HEADER = "X-New-API-Proxy-Token"
10
+
11
+
12
+ def get_new_api_proxy_base_url(proxy_url=None):
13
+ raw = proxy_url if proxy_url is not None else os.getenv(NEW_API_PROXY_URL_ENV, "")
14
+ raw = str(raw or "").strip().strip('"').strip("'")
15
+ if not raw:
16
+ return ""
17
+ if "://" not in raw:
18
+ raw = f"https://{raw}"
19
+ return raw.rstrip("/")
20
+
21
+
22
+ def build_new_api_proxy_url(path, proxy_url=None):
23
+ proxy_base_url = get_new_api_proxy_base_url(proxy_url)
24
+ if not proxy_base_url:
25
+ return ""
26
+
27
+ path = str(path or "")
28
+ if not path.startswith("/"):
29
+ path = f"/{path}"
30
+
31
+ base_parts = urlsplit(proxy_base_url)
32
+ path_part, _, path_query = path.partition("?")
33
+ base_path = base_parts.path.rstrip("/")
34
+ if base_path and base_path != "/" and not base_path.endswith(path_part.rstrip("/")):
35
+ final_path = f"{base_path}{path_part}"
36
+ elif base_path and base_path.endswith(path_part.rstrip("/")):
37
+ final_path = base_path
38
+ else:
39
+ final_path = path_part
40
+
41
+ extra_query = os.getenv(NEW_API_PROXY_AUTH_QUERY_ENV, "").strip().lstrip("?")
42
+ query_parts = [q for q in (base_parts.query, extra_query, path_query) if q]
43
+ return urlunsplit(
44
+ (
45
+ base_parts.scheme,
46
+ base_parts.netloc,
47
+ final_path,
48
+ "&".join(query_parts),
49
+ base_parts.fragment,
50
+ )
51
+ )
52
+
53
+
54
+ def with_new_api_proxy_headers(headers=None, proxy_url=None):
55
+ headers = dict(headers or {})
56
+ if get_new_api_proxy_base_url(proxy_url):
57
+ proxy_token = os.getenv(NEW_API_PROXY_TOKEN_ENV, "").strip()
58
+ if proxy_token:
59
+ headers[NEW_API_PROXY_TOKEN_HEADER] = proxy_token
60
+ return headers
61
+
62
+
63
+ def new_api_proxy_direct_fallback_enabled():
64
+ raw = os.getenv(NEW_API_PROXY_DIRECT_FALLBACK_ENV, "1").strip().lower()
65
+ return raw not in {"0", "false", "no", "off"}
pages/⏰ 历史场次自动同步监控.py ADDED
The diff for this file is too large to render. See raw diff
 
pages/🆕 新API卖品销售总额测试.py ADDED
@@ -0,0 +1,1028 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """新 API(center.hengdianfilm.com / jmu/jmreport/show)卖品销售总额测试页面。
2
+
3
+ 完整鉴权链路(与浏览器抓包一致):
4
+ 1. **OA SSO 取 ticket**:`GET oa.hengdianfilm.com/seeyon/thirdpartyController.do?method=show&id=<固定应用ID>&pageUrl=...`
5
+ - 需要 OA 已登录(携带 OA 的 `JSESSIONID` cookie)
6
+ - 响应里会带回跳 URL 或 HTML,其中包含 `ticket=<一次性凭证>` 或旧版 `mobile=<一次性凭证>`
7
+ 2. **换 Bearer Token**:`POST center.hengdianfilm.com/auth/oauth/token?mobile=<ticket>&code=1234&grant_type=oa`
8
+ - Header 必须带 `Authorization: Basic b2E6b2E=` 和 `tenant-id: 1`
9
+ 3. **调报表**:`POST center.hengdianfilm.com/jimu/jmreport/show`
10
+ - Token 通过 `token` / `x-access-token` Header 和 `bruts_token` Cookie 同时传递
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import re
18
+ from datetime import date as dt_date, datetime, timedelta
19
+ from typing import Any, Optional
20
+ from urllib.parse import parse_qs, urljoin, urlparse
21
+
22
+ import pandas as pd
23
+ import requests
24
+ import streamlit as st
25
+ from dotenv import load_dotenv
26
+ from new_api_proxy import (
27
+ NEW_API_PROXY_AUTH_QUERY_ENV,
28
+ NEW_API_PROXY_DIRECT_FALLBACK_ENV,
29
+ NEW_API_PROXY_TOKEN_ENV,
30
+ NEW_API_PROXY_URL_ENV,
31
+ build_new_api_proxy_url,
32
+ get_new_api_proxy_base_url,
33
+ new_api_proxy_direct_fallback_enabled,
34
+ with_new_api_proxy_headers,
35
+ )
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # 页面配置 & 环境变量
40
+ # ---------------------------------------------------------------------------
41
+ load_dotenv(override=True)
42
+
43
+ st.set_page_config(layout="wide", page_title="🆕 新 API 卖品销售总额测试")
44
+ st.title("🆕 新 API 卖品销售总额测试")
45
+ st.caption(
46
+ "测试 OA SSO → ticket → bearer token → jmreport 完整链路。"
47
+ "用于核对当日卖品 **实付金额(real_amount)** 总额。"
48
+ )
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # 常量
53
+ # ---------------------------------------------------------------------------
54
+ CENTER_BASE_URL = "https://center.hengdianfilm.com"
55
+ TOKEN_URL = f"{CENTER_BASE_URL}/auth/oauth/token"
56
+ JMREPORT_URL = f"{CENTER_BASE_URL}/jimu/jmreport/show"
57
+ GOODS_SALES_REPORT_ID = "1153597763885797376" # 售卖员报表 2
58
+
59
+ OA_BASE_URL = os.getenv("OA_BASE_URL", "https://oa.hengdianfilm.com")
60
+ SSO_APP_ID = os.getenv("CENTER_SSO_APP_ID", "-28746303884891748910")
61
+ SSO_PAGE_URL = os.getenv("CENTER_SSO_PAGE_URL", "https://center.hengdianfilm.com/")
62
+ SSO_URL = f"{OA_BASE_URL}/seeyon/thirdpartyController.do"
63
+
64
+ DEFAULT_OA_JSESSIONID = os.getenv("OA_JSESSIONID", "")
65
+ DEFAULT_OA_COOKIE = os.getenv("OA_COOKIE", "")
66
+ DEFAULT_OAUTH_CODE = os.getenv("CENTER_OAUTH_CODE", "1234")
67
+ DEFAULT_OAUTH_GRANT = os.getenv("CENTER_OAUTH_GRANT_TYPE", "oa")
68
+ DEFAULT_OAUTH_BASIC = os.getenv("CENTER_OAUTH_BASIC", "Basic b2E6b2E=")
69
+
70
+ USER_AGENT = (
71
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
72
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"
73
+ )
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Session State
78
+ # ---------------------------------------------------------------------------
79
+ def _init_state() -> None:
80
+ defaults = {
81
+ "sales_token": "",
82
+ "sales_token_meta": {},
83
+ "sales_token_fetched_at": None,
84
+ "sales_ticket": "",
85
+ "sales_ticket_fetched_at": None,
86
+ "sales_query_date": dt_date.today(),
87
+ "sales_last_payload": None,
88
+ "sales_last_summary": None,
89
+ }
90
+ for k, v in defaults.items():
91
+ st.session_state.setdefault(k, v)
92
+
93
+
94
+ _init_state()
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # 工具函数
99
+ # ---------------------------------------------------------------------------
100
+ TICKET_PATTERNS = [
101
+ re.compile(r"(?:mobile|ticket)=(-?\d{6,})"),
102
+ re.compile(r'"(?:mobile|ticket)"\s*:\s*"(-?\d{6,})"'),
103
+ re.compile(r"name=['\"](?:mobile|ticket)['\"][^>]*value=['\"](-?\d{6,})['\"]"),
104
+ re.compile(r"value=['\"](-?\d{6,})['\"][^>]*name=['\"](?:mobile|ticket)['\"]"),
105
+ ]
106
+
107
+
108
+ def _format_sso_ticket_proxy_error(data: dict) -> Optional[str]:
109
+ if not isinstance(data, dict) or data.get("error") != "ticket-not-found":
110
+ return None
111
+
112
+ debug = data.get("debug") or {}
113
+ body_preview = str(debug.get("body_preview") or "")
114
+ upstream_status = data.get("upstreamStatus") or debug.get("status") or "-"
115
+ location = debug.get("location") or "-"
116
+ if "请重新登录" in body_preview:
117
+ return (
118
+ "OA 登录态已失效:OA 返回“请重新登录后再试”。"
119
+ "请在浏览器重新登录 OA,复制最新完整 Cookie 写入 `.env` 的 `OA_COOKIE`"
120
+ "(或至少更新 `OA_JSESSIONID`),然后重启 Streamlit。"
121
+ f"上游状态码={upstream_status}。"
122
+ )
123
+ return (
124
+ "未能从 OA SSO 响应中提取到 ticket。"
125
+ f"上游状态���={upstream_status},Location={location},响应预览={body_preview or data}"
126
+ )
127
+
128
+
129
+ def _cookie_names(cookies: dict[str, str]) -> list[str]:
130
+ """只暴露 cookie 名称,避免调试信息泄露会话值。"""
131
+ return sorted(cookies.keys())
132
+
133
+
134
+ def _domain_cookies(cookie_jar: requests.cookies.RequestsCookieJar, domain: str) -> dict[str, str]:
135
+ """从 Session CookieJar 中提取指定域的 cookies。"""
136
+ return {
137
+ cookie.name: cookie.value
138
+ for cookie in cookie_jar
139
+ if cookie.domain and domain in cookie.domain
140
+ }
141
+
142
+
143
+ class NewApiProxyPlatformError(RuntimeError):
144
+ """EdgeOne Pages 外层鉴权错误;此时请求还没有进入 edge-functions。"""
145
+
146
+ def __init__(self, step: str, message: str) -> None:
147
+ super().__init__(
148
+ f"{step} 被 EdgeOne Pages 外层鉴权拦截:{message}。"
149
+ "请求没有进入 edge-functions。请关闭该 Pages 域名的 URL 鉴权/访问保护,"
150
+ f"或把 Preview 链接中的 `eo_time` 等签名参数放入 `{NEW_API_PROXY_URL_ENV}` / "
151
+ f"`{NEW_API_PROXY_AUTH_QUERY_ENV}`,也可以改用没有 `eo_time` 鉴权的专用代理域名。"
152
+ )
153
+
154
+
155
+ class NewApiProxyMethodError(RuntimeError):
156
+ """代理函数收到非 POST 请求;常见于 Preview 跳转把 POST 改成 GET。"""
157
+
158
+ def __init__(self, step: str, message: str) -> None:
159
+ super().__init__(
160
+ f"{step} 到达了 edge-functions,但方法不对:{message}。"
161
+ "通常是 EdgeOne Preview 链接发生 302/303 跳转后,HTTP 客户端把 POST 改成了 GET;"
162
+ "也可能是直接在浏览器地址栏打开了代理接口。"
163
+ )
164
+
165
+
166
+ class NewApiProxyFunctionMissingError(RuntimeError):
167
+ """代理路径没有命中 edge-functions,通常是新增函数还没部署。"""
168
+
169
+ def __init__(self, step: str, path: str) -> None:
170
+ super().__init__(
171
+ f"{step} 没有命中 EdgeOne edge-functions:{path}。"
172
+ "请把 `new-api-edgeone-pages` 重新部署到 EdgeOne Pages,确认线上包含这个函数路径。"
173
+ )
174
+
175
+
176
+ def _raise_for_new_api_proxy_platform_error(resp: requests.Response, step: str) -> None:
177
+ eop_msg = resp.headers.get("X-EOP-MSG")
178
+ server = resp.headers.get("Server", "")
179
+ looks_like_edgeone_gate = "edgeone-pages" in server.lower()
180
+ if resp.status_code in {401, 403} and (eop_msg or looks_like_edgeone_gate):
181
+ raise NewApiProxyPlatformError(step, eop_msg or "Access Restricted or Authentication Expired")
182
+
183
+
184
+ def _raise_for_new_api_proxy_method_error(resp: requests.Response, step: str) -> None:
185
+ if resp.status_code == 405 and "method-not-allowed" in (resp.text or ""):
186
+ try:
187
+ data = resp.json()
188
+ except ValueError:
189
+ data = {}
190
+ seen_method = data.get("method") or resp.headers.get("X-New-API-Request-Method") or "非 POST"
191
+ raise NewApiProxyMethodError(step, f"代理函数收到 {seen_method}")
192
+
193
+
194
+ def _raise_for_new_api_proxy_missing_function(resp: requests.Response, step: str, path: str) -> None:
195
+ has_proxy_header = bool(resp.headers.get("X-New-API-Proxy"))
196
+ body = resp.text or ""
197
+ if not has_proxy_header and resp.status_code in {404, 405} and (
198
+ "<Code>MethodNotAllowed</Code>" in body or "<Code>NoSuchKey</Code>" in body
199
+ ):
200
+ raise NewApiProxyFunctionMissingError(step, path)
201
+
202
+
203
+ def _direct_fallback_note(exc: NewApiProxyPlatformError) -> str:
204
+ return (
205
+ f"{exc}\n\n已自动改用本机直连继续请求。"
206
+ f"如需禁用自动回退,可设置 `{NEW_API_PROXY_DIRECT_FALLBACK_ENV}=0`。"
207
+ )
208
+
209
+
210
+ def _method_fallback_note(exc: NewApiProxyMethodError) -> str:
211
+ return (
212
+ f"{exc}\n\n已自动改用本机直连继续请求。"
213
+ "如果你只是在浏览器里打开代理 URL 测试,看到 405 是正常的;"
214
+ "OAuth 中转必须由页面用 POST 调用。"
215
+ )
216
+
217
+
218
+ def _post_new_api_proxy_json(
219
+ path: str,
220
+ proxy_base_url: str,
221
+ payload: dict[str, Any],
222
+ timeout: int,
223
+ ) -> requests.Response:
224
+ """POST 到 EdgeOne Pages 代理,并在 Preview 跳转时保持 POST 方法和预览 Cookie。"""
225
+ headers = with_new_api_proxy_headers({"accept": "application/json"}, proxy_base_url)
226
+ session = requests.Session()
227
+ resp = session.post(
228
+ build_new_api_proxy_url(path, proxy_base_url),
229
+ headers=headers,
230
+ json=payload,
231
+ timeout=timeout,
232
+ allow_redirects=False,
233
+ )
234
+ if resp.status_code in {301, 302, 303, 307, 308} and resp.headers.get("Location"):
235
+ resp = session.post(
236
+ urljoin(resp.url, resp.headers["Location"]),
237
+ headers=headers,
238
+ json=payload,
239
+ timeout=timeout,
240
+ allow_redirects=False,
241
+ )
242
+ return resp
243
+
244
+
245
+ def _fetch_with_proxy_recoverable_fallback(
246
+ proxy_call,
247
+ direct_call,
248
+ ) -> tuple[dict, Optional[str]]:
249
+ try:
250
+ return proxy_call(), None
251
+ except NewApiProxyPlatformError as exc:
252
+ if not new_api_proxy_direct_fallback_enabled():
253
+ raise
254
+ return direct_call(), _direct_fallback_note(exc)
255
+ except NewApiProxyMethodError as exc:
256
+ if not new_api_proxy_direct_fallback_enabled():
257
+ raise
258
+ return direct_call(), _method_fallback_note(exc)
259
+
260
+
261
+ def fetch_sso_ticket(
262
+ jsessionid: str,
263
+ app_id: str = SSO_APP_ID,
264
+ page_url: str = SSO_PAGE_URL,
265
+ proxy_url: str = "",
266
+ ) -> tuple[str, dict, dict[str, str]]:
267
+ """通过 OA SSO 获取一次性 ticket。
268
+
269
+ 返回 (ticket, debug_info, center_cookies)。debug_info 包含响应链路 / 状态码 / 命中模式等信息。
270
+ """
271
+ if not jsessionid.strip() and not DEFAULT_OA_COOKIE.strip():
272
+ raise ValueError("OA_JSESSIONID / OA_COOKIE 至少需要配置一个。")
273
+
274
+ proxy_base_url = get_new_api_proxy_base_url(proxy_url)
275
+ if proxy_base_url:
276
+ payload = {
277
+ "jsessionid": jsessionid.strip(),
278
+ "oaCookie": DEFAULT_OA_COOKIE.strip(),
279
+ "appId": app_id,
280
+ "pageUrl": page_url,
281
+ }
282
+ resp = _post_new_api_proxy_json("/new-api/sso-ticket", proxy_base_url, payload, timeout=25)
283
+ _raise_for_new_api_proxy_platform_error(resp, "OA SSO ticket 中转")
284
+ _raise_for_new_api_proxy_missing_function(resp, "OA SSO ticket 中转", "/new-api/sso-ticket")
285
+ _raise_for_new_api_proxy_method_error(resp, "OA SSO ticket 中转")
286
+ if not resp.ok:
287
+ try:
288
+ error_data = resp.json()
289
+ except ValueError:
290
+ error_data = {}
291
+ error_message = _format_sso_ticket_proxy_error(error_data)
292
+ if error_message:
293
+ raise RuntimeError(error_message)
294
+ resp.raise_for_status()
295
+ data = resp.json()
296
+ ticket = str(data.get("ticket") or "").strip()
297
+ if not ticket:
298
+ error_message = _format_sso_ticket_proxy_error(data)
299
+ if error_message:
300
+ raise RuntimeError(error_message)
301
+ debug = data.get("debug") or {}
302
+ raise RuntimeError(
303
+ "未能从 OA SSO 代理响应中提取到 ticket。"
304
+ f"代理状态码={resp.status_code},上游状态码={data.get('upstreamStatus') or debug.get('status') or '-'},"
305
+ f"响应预览={debug.get('body_preview') or data}"
306
+ )
307
+ return ticket, data.get("debug") or {}, data.get("centerCookies") or {}
308
+
309
+ headers = {
310
+ "accept": (
311
+ "text/html,application/xhtml+xml,application/xml;q=0.9,"
312
+ "image/avif,image/webp,image/apng,*/*;q=0.8"
313
+ ),
314
+ "accept-language": "zh-CN,zh;q=0.9",
315
+ "referer": f"{OA_BASE_URL}/seeyon/main.do?method=main",
316
+ "upgrade-insecure-requests": "1",
317
+ "user-agent": USER_AGENT,
318
+ }
319
+ session = requests.Session()
320
+ if DEFAULT_OA_COOKIE.strip():
321
+ headers["cookie"] = DEFAULT_OA_COOKIE.strip()
322
+ else:
323
+ session.cookies.set("JSESSIONID", jsessionid.strip(), domain="oa.hengdianfilm.com", path="/")
324
+ session.cookies.set("login_locale", "zh_CN", domain="oa.hengdianfilm.com", path="/")
325
+ params = {"method": "show", "id": app_id, "pageUrl": page_url}
326
+
327
+ # 不自动跟随跳转:ticket 通常出现在 302 的 Location 中
328
+ resp = session.get(
329
+ SSO_URL,
330
+ headers=headers,
331
+ params=params,
332
+ allow_redirects=False,
333
+ timeout=15,
334
+ )
335
+
336
+ debug: dict[str, Any] = {
337
+ "status": resp.status_code,
338
+ "location": resp.headers.get("Location"),
339
+ "history": [],
340
+ "matched_via": None,
341
+ "proxy_enabled": False,
342
+ "proxy_note": "OA SSO uses local direct request because NEW_API_PROXY_URL is empty.",
343
+ "center_cookie_names": [],
344
+ "body_preview": (resp.text[:600] if resp.text else ""),
345
+ }
346
+
347
+ candidate_strings: list[str] = []
348
+ if debug["location"]:
349
+ candidate_strings.append(debug["location"])
350
+
351
+ # 如果是 3xx 重定向,手动跟一次链;有些环境会 200 直接返回 HTML
352
+ if 300 <= resp.status_code < 400 and debug["location"]:
353
+ try:
354
+ follow_url = urljoin(resp.url, debug["location"])
355
+ follow_headers = dict(headers)
356
+ if follow_url.startswith(CENTER_BASE_URL):
357
+ follow_headers.pop("cookie", None)
358
+ follow = session.get(
359
+ follow_url,
360
+ headers=follow_headers,
361
+ allow_redirects=False,
362
+ timeout=15,
363
+ )
364
+ debug["history"].append(
365
+ {
366
+ "url": follow_url,
367
+ "status": follow.status_code,
368
+ "location": follow.headers.get("Location"),
369
+ }
370
+ )
371
+ if follow.headers.get("Location"):
372
+ candidate_strings.append(follow.headers["Location"])
373
+ if follow.text:
374
+ candidate_strings.append(follow.text)
375
+ except requests.RequestException as exc:
376
+ debug["history"].append({"error": str(exc)})
377
+ if resp.text:
378
+ candidate_strings.append(resp.text)
379
+
380
+ ticket: Optional[str] = None
381
+ for source in candidate_strings:
382
+ for pattern in TICKET_PATTERNS:
383
+ m = pattern.search(source)
384
+ if m:
385
+ ticket = m.group(1)
386
+ debug["matched_via"] = pattern.pattern
387
+ break
388
+ if ticket:
389
+ break
390
+
391
+ # 尝试解析 query string / hash fragment 形式:
392
+ # https://center.../?mobile=xxxxx 或 https://center.../#/loginOa?ticket=xxxxx
393
+ if not ticket:
394
+ for source in candidate_strings:
395
+ try:
396
+ parsed = urlparse(source)
397
+ fragment_query = ""
398
+ if "?" in (parsed.fragment or ""):
399
+ fragment_query = parsed.fragment.split("?", 1)[1]
400
+ for raw_query in (parsed.query, fragment_query):
401
+ qs = parse_qs(raw_query or "")
402
+ for key in ("mobile", "ticket"):
403
+ if key in qs and qs[key]:
404
+ ticket = qs[key][0]
405
+ debug["matched_via"] = f"urlparse:{key}"
406
+ break
407
+ if ticket:
408
+ break
409
+ if ticket:
410
+ break
411
+ except Exception: # noqa: BLE001
412
+ continue
413
+
414
+ if not ticket:
415
+ body_preview = re.sub(r"\s+", " ", debug["body_preview"] or "").strip()
416
+ raise RuntimeError(
417
+ "未能从 OA SSO 响应中提取到 ticket。"
418
+ f"OA 状态码={debug['status']},Location={debug['location'] or '-'},"
419
+ f"响应预览={body_preview or '-'}"
420
+ )
421
+ center_cookies = _domain_cookies(session.cookies, "center.hengdianfilm.com")
422
+ debug["center_cookie_names"] = _cookie_names(center_cookies)
423
+ return ticket, debug, center_cookies
424
+
425
+
426
+ def _fetch_oauth_token_direct(
427
+ mobile: str,
428
+ code: str,
429
+ grant_type: str,
430
+ basic_auth: str,
431
+ cookies: Optional[dict[str, str]] = None,
432
+ ) -> dict:
433
+ headers = {
434
+ "accept": "application/json, text/plain, */*",
435
+ "accept-language": "zh-CN,zh;q=0.9",
436
+ "authorization": basic_auth,
437
+ "channel": "4",
438
+ "content-length": "0",
439
+ "istoken": "false",
440
+ "origin": CENTER_BASE_URL,
441
+ "tenant-id": "1",
442
+ "user-agent": USER_AGENT,
443
+ }
444
+ params = {"mobile": mobile, "code": code, "grant_type": grant_type}
445
+ resp = requests.post(
446
+ TOKEN_URL,
447
+ headers=headers,
448
+ params=params,
449
+ cookies=cookies,
450
+ timeout=15,
451
+ )
452
+ resp.raise_for_status()
453
+ return resp.json()
454
+
455
+
456
+ def _fetch_oauth_token_via_proxy(
457
+ mobile: str,
458
+ code: str,
459
+ grant_type: str,
460
+ basic_auth: str,
461
+ cookies: Optional[dict[str, str]],
462
+ proxy_base_url: str,
463
+ ) -> dict:
464
+ payload: dict[str, Any] = {
465
+ "mobile": mobile,
466
+ "code": code,
467
+ "grantType": grant_type,
468
+ "basicAuth": basic_auth,
469
+ }
470
+ if cookies:
471
+ payload["cookies"] = cookies
472
+
473
+ resp = _post_new_api_proxy_json("/new-api/oauth-token", proxy_base_url, payload, timeout=20)
474
+ _raise_for_new_api_proxy_platform_error(resp, "OAuth token 中转")
475
+ _raise_for_new_api_proxy_method_error(resp, "OAuth token 中转")
476
+ resp.raise_for_status()
477
+ return resp.json()
478
+
479
+
480
+ def fetch_oauth_token(
481
+ mobile: str,
482
+ code: str,
483
+ grant_type: str,
484
+ basic_auth: str,
485
+ cookies: Optional[dict[str, str]] = None,
486
+ proxy_url: str = "",
487
+ ) -> tuple[dict, Optional[str]]:
488
+ """用 ticket(mobile 参数)换取 Bearer Token。"""
489
+ proxy_base_url = get_new_api_proxy_base_url(proxy_url)
490
+ if proxy_base_url:
491
+ return _fetch_with_proxy_recoverable_fallback(
492
+ proxy_call=lambda: _fetch_oauth_token_via_proxy(
493
+ mobile=mobile,
494
+ code=code,
495
+ grant_type=grant_type,
496
+ basic_auth=basic_auth,
497
+ cookies=cookies,
498
+ proxy_base_url=proxy_base_url,
499
+ ),
500
+ direct_call=lambda: _fetch_oauth_token_direct(
501
+ mobile=mobile,
502
+ code=code,
503
+ grant_type=grant_type,
504
+ basic_auth=basic_auth,
505
+ cookies=cookies,
506
+ ),
507
+ )
508
+
509
+ return (
510
+ _fetch_oauth_token_direct(
511
+ mobile=mobile,
512
+ code=code,
513
+ grant_type=grant_type,
514
+ basic_auth=basic_auth,
515
+ cookies=cookies,
516
+ ),
517
+ None,
518
+ )
519
+
520
+
521
+ def _fetch_jmreport_show_direct(
522
+ token: str,
523
+ start: str,
524
+ end: str,
525
+ page_no: int = 1,
526
+ page_size: int = 100,
527
+ ) -> dict:
528
+ headers = {
529
+ "accept": "application/json, text/plain, */*",
530
+ "accept-language": "zh-CN,zh;q=0.9",
531
+ "content-type": "application/json;charset=UTF-8",
532
+ "origin": CENTER_BASE_URL,
533
+ "referer": (
534
+ f"{CENTER_BASE_URL}/jimu/jmreport/shareView/{GOODS_SALES_REPORT_ID}"
535
+ f"?token={token}"
536
+ ),
537
+ "token": token,
538
+ "user-agent": USER_AGENT,
539
+ "x-access-token": token,
540
+ }
541
+ cookies = {"bruts_token": token}
542
+ inner_params = {
543
+ "token": token,
544
+ "pageNo": page_no,
545
+ "pageSize": page_size,
546
+ "start": start,
547
+ "end": end,
548
+ "customTableTitleSorts": [],
549
+ }
550
+ body = {
551
+ "id": GOODS_SALES_REPORT_ID,
552
+ "apiUrl": "",
553
+ "jmRecordId": "",
554
+ "sheetId": "",
555
+ "params": json.dumps(inner_params, ensure_ascii=False),
556
+ }
557
+ resp = requests.post(
558
+ JMREPORT_URL,
559
+ headers=headers,
560
+ cookies=cookies,
561
+ json=body,
562
+ timeout=20,
563
+ )
564
+ resp.raise_for_status()
565
+ return resp.json()
566
+
567
+
568
+ def _fetch_jmreport_show_via_proxy(
569
+ token: str,
570
+ start: str,
571
+ end: str,
572
+ page_no: int,
573
+ page_size: int,
574
+ proxy_base_url: str,
575
+ ) -> dict:
576
+ resp = _post_new_api_proxy_json(
577
+ "/new-api/jmreport-show",
578
+ proxy_base_url,
579
+ {
580
+ "token": token,
581
+ "reportId": GOODS_SALES_REPORT_ID,
582
+ "start": start,
583
+ "end": end,
584
+ "pageNo": page_no,
585
+ "pageSize": page_size,
586
+ },
587
+ timeout=30,
588
+ )
589
+ _raise_for_new_api_proxy_platform_error(resp, "jmreport 中转")
590
+ _raise_for_new_api_proxy_method_error(resp, "jmreport 中转")
591
+ resp.raise_for_status()
592
+ return resp.json()
593
+
594
+
595
+ def fetch_jmreport_show(
596
+ token: str,
597
+ start: str,
598
+ end: str,
599
+ page_no: int = 1,
600
+ page_size: int = 100,
601
+ proxy_url: str = "",
602
+ ) -> tuple[dict, Optional[str]]:
603
+ """调用 jmreport/show 拉取售卖员报表 2 数据。
604
+
605
+ 注意:该接口的鉴权方式 **不是** `Authorization: Bearer`,而是:
606
+ - 请求头 `token` / `x-access-token`
607
+ - Cookie 中的 `bruts_token`
608
+ - 请求体 params 内嵌的 `token`
609
+ """
610
+ proxy_base_url = get_new_api_proxy_base_url(proxy_url)
611
+ if proxy_base_url:
612
+ return _fetch_with_proxy_recoverable_fallback(
613
+ proxy_call=lambda: _fetch_jmreport_show_via_proxy(
614
+ token=token,
615
+ start=start,
616
+ end=end,
617
+ page_no=page_no,
618
+ page_size=page_size,
619
+ proxy_base_url=proxy_base_url,
620
+ ),
621
+ direct_call=lambda: _fetch_jmreport_show_direct(
622
+ token=token,
623
+ start=start,
624
+ end=end,
625
+ page_no=page_no,
626
+ page_size=page_size,
627
+ ),
628
+ )
629
+
630
+ return (
631
+ _fetch_jmreport_show_direct(
632
+ token=token,
633
+ start=start,
634
+ end=end,
635
+ page_no=page_no,
636
+ page_size=page_size,
637
+ ),
638
+ None,
639
+ )
640
+
641
+
642
+ def _to_float(value: Any, default: float = 0.0) -> float:
643
+ try:
644
+ if value is None or value == "":
645
+ return default
646
+ return float(str(value).replace(",", "").strip())
647
+ except (TypeError, ValueError):
648
+ return default
649
+
650
+
651
+ def summarize_payload(payload: dict) -> dict:
652
+ """从 jmreport 返回数据中提取 real_amount 等汇总信息。"""
653
+ summary: dict[str, Any] = {
654
+ "report_total_real_amount": None,
655
+ "report_total_sales_amount": None,
656
+ "report_total_sales_num": None,
657
+ "list_total_real_amount": 0.0,
658
+ "list_total_sales_amount": 0.0,
659
+ "list_total_sales_num": 0.0,
660
+ "list_count": 0,
661
+ "page_total_records": 0,
662
+ "page_total_real_count": 0,
663
+ "payment_breakdown": {},
664
+ "items": pd.DataFrame(),
665
+ }
666
+
667
+ if not isinstance(payload, dict):
668
+ return summary
669
+
670
+ result = payload.get("result") or {}
671
+ data_list = result.get("dataList") or {}
672
+ exp_data = data_list.get("expData") or {}
673
+
674
+ # expData 的 key 大小写不一致,做规范化处理
675
+ normalized_exp = {str(k).lower(): v for k, v in exp_data.items()}
676
+
677
+ def _exp(key: str):
678
+ return normalized_exp.get(key.lower())
679
+
680
+ summary["report_total_real_amount"] = _to_float(_exp("=DBSUM(#{goods_sales_2.real_amount})"))
681
+ summary["report_total_sales_amount"] = _to_float(_exp("=DBSUM(#{goods_sales_2.sales_amount})"))
682
+ summary["report_total_sales_num"] = _to_float(_exp("=DBSUM(#{goods_sales_2.sales_num})"))
683
+
684
+ pay_keys = {
685
+ "现金": "cash_pay",
686
+ "网络支付": "online_pay",
687
+ "扫码支付": "scan_pay",
688
+ "银行卡": "bank_pay",
689
+ "数字人民币": "dcep_pay",
690
+ "会员卡": "card_pay",
691
+ "券": "quan_pay",
692
+ "收银券": "cashier_quan_pay",
693
+ "支付宝": "ali_pay",
694
+ "微信": "wx_pay",
695
+ "总部聚合": "zb_jh_pay",
696
+ "影城聚合": "yc_jh_pay",
697
+ "绿云会员": "ly_pay",
698
+ "其他支付": "other_pay",
699
+ }
700
+ for label, field in pay_keys.items():
701
+ summary["payment_breakdown"][label] = _to_float(
702
+ _exp(f"=DBSUM(#{{goods_sales_2.{field}}})")
703
+ )
704
+
705
+ goods_sales = data_list.get("goods_sales_2") or {}
706
+ items = goods_sales.get("list") or []
707
+ summary["page_total_records"] = int(_to_float(goods_sales.get("count")))
708
+ summary["page_total_real_count"] = int(_to_float(goods_sales.get("total")))
709
+
710
+ if items:
711
+ df = pd.DataFrame(items)
712
+ summary["items"] = df
713
+ summary["list_count"] = len(df)
714
+ if "real_amount" in df.columns:
715
+ summary["list_total_real_amount"] = pd.to_numeric(df["real_amount"], errors="coerce").fillna(0).sum()
716
+ if "sales_amount" in df.columns:
717
+ summary["list_total_sales_amount"] = pd.to_numeric(df["sales_amount"], errors="coerce").fillna(0).sum()
718
+ if "sales_num" in df.columns:
719
+ summary["list_total_sales_num"] = pd.to_numeric(df["sales_num"], errors="coerce").fillna(0).sum()
720
+
721
+ return summary
722
+
723
+
724
+ def build_business_window(query_date: dt_date) -> tuple[str, str]:
725
+ """卖品报表口径:当日 06:00 - 次日 06:00。"""
726
+ start = datetime.combine(query_date, datetime.min.time()).replace(hour=6)
727
+ end = start + timedelta(days=1)
728
+ fmt = "%Y-%m-%d %H:%M:%S"
729
+ return start.strftime(fmt), end.strftime(fmt)
730
+
731
+
732
+ # ---------------------------------------------------------------------------
733
+ # UI - 步骤 1:OA JSESSIONID + 自动换 Token
734
+ # ---------------------------------------------------------------------------
735
+ st.subheader("① 通过 OA SSO 自动获取 Bearer Token")
736
+
737
+ with st.expander("🔑 如何获取 OA 的 JSESSIONID(一次粘贴,用一整天)", expanded=False):
738
+ st.markdown(
739
+ """
740
+ 1. 浏览器登录 [https://oa.hengdianfilm.com/seeyon/main.do?method=main](https://oa.hengdianfilm.com/seeyon/main.do?method=main)。
741
+ 2. 按 **F12** 打开开发者工具 → **Application(应用)** → **Cookies** → 选 `https://oa.hengdianfilm.com`。
742
+ 3. 找到 `JSESSIONID`(一串大写字母数字),写入系统环境变量或 `.env` 的 `OA_JSESSIONID`。
743
+ 如中转仍提示重新登录,可改用完整 Cookie 字符串写入 `OA_COOKIE`。
744
+ 4. 页面只读取系统变量,不再接受手动输入;修改后请重启 Streamlit 让变量生效。
745
+ """
746
+ )
747
+
748
+ proxy_base_url = get_new_api_proxy_base_url()
749
+ proxy_display_url = proxy_base_url.split("?", 1)[0] + ("?..." if "?" in proxy_base_url else "")
750
+ if proxy_base_url:
751
+ fallback_label = "开启" if new_api_proxy_direct_fallback_enabled() else "关闭"
752
+ st.caption(
753
+ f"当前 OA SSO / OAuth / jmreport 全链路都将通过专用中转代理访问:{proxy_display_url};"
754
+ f"OAuth 和 jmreport 的本机直连回退:{fallback_label}。境外服务器建议关闭回退。"
755
+ )
756
+ if not os.getenv(NEW_API_PROXY_TOKEN_ENV, "").strip():
757
+ st.warning(f"已配置 `{NEW_API_PROXY_URL_ENV}`,但未配置 `{NEW_API_PROXY_TOKEN_ENV}`,中转请求会被代理拒绝。")
758
+ if os.getenv(NEW_API_PROXY_AUTH_QUERY_ENV, "").strip():
759
+ st.info(f"已配置 `{NEW_API_PROXY_AUTH_QUERY_ENV}`,代理请求会自动附加 EdgeOne URL 鉴权参数。")
760
+ else:
761
+ st.caption(f"当前 OA SSO / OAuth / jmreport 均为直连;配置 `{NEW_API_PROXY_URL_ENV}` 后会自动启用全链路中转代理。")
762
+
763
+ with st.expander("⚙️ SSO / OAuth 高级参数(一般无需改动)", expanded=False):
764
+ col_a, col_b = st.columns(2)
765
+ with col_a:
766
+ sso_app_id_input = st.text_input("SSO 应用 ID(id 参数,固定)", value=SSO_APP_ID, key="sales_sso_app_id")
767
+ sso_page_url_input = st.text_input("SSO 回跳 URL(pageUrl 参数)", value=SSO_PAGE_URL, key="sales_sso_page_url")
768
+ with col_b:
769
+ oauth_code_input = st.text_input("oauth code", value=DEFAULT_OAUTH_CODE, key="sales_oauth_code")
770
+ oauth_grant_input = st.text_input("oauth grant_type", value=DEFAULT_OAUTH_GRANT, key="sales_oauth_grant")
771
+ oauth_basic_input = st.text_input("oauth Basic Auth", value=DEFAULT_OAUTH_BASIC, key="sales_oauth_basic")
772
+
773
+ env_jsessionid = DEFAULT_OA_JSESSIONID.strip()
774
+ env_oa_cookie = DEFAULT_OA_COOKIE.strip()
775
+ if env_oa_cookie:
776
+ st.success("已从系统变量 `OA_COOKIE` 读取完整 OA Cookie。")
777
+ elif env_jsessionid:
778
+ st.success("已从系统变量 `OA_JSESSIONID` 读取 OA 会话。")
779
+ else:
780
+ st.error("未配置系统变量 `OA_JSESSIONID` 或 `OA_COOKIE`,无法自动获取 Token。")
781
+
782
+ col_btn, col_info = st.columns([1, 3])
783
+ with col_btn:
784
+ go_clicked = st.button("🚀 一键获取 Token", type="primary", use_container_width=True)
785
+
786
+ if go_clicked:
787
+ jsessionid = env_jsessionid
788
+ if not jsessionid and not env_oa_cookie:
789
+ st.error("请先配置系统变量 `OA_JSESSIONID` 或 `OA_COOKIE`。")
790
+ else:
791
+ # ① SSO 取 ticket
792
+ with st.spinner("Step 1/2 调用 OA SSO 获取 ticket…"):
793
+ try:
794
+ ticket, sso_debug, center_cookies = fetch_sso_ticket(
795
+ jsessionid,
796
+ app_id=sso_app_id_input.strip() or SSO_APP_ID,
797
+ page_url=sso_page_url_input.strip() or SSO_PAGE_URL,
798
+ proxy_url=proxy_base_url,
799
+ )
800
+ except requests.HTTPError as exc:
801
+ st.error(f"SSO HTTP 错误:{exc.response.status_code} {exc.response.reason}")
802
+ if exc.response is not None:
803
+ st.code(exc.response.text[:1000])
804
+ ticket = None
805
+ sso_debug = None
806
+ center_cookies = {}
807
+ except Exception as exc: # noqa: BLE001
808
+ st.error(f"SSO 调用失败:{exc}")
809
+ ticket = None
810
+ sso_debug = None
811
+ center_cookies = {}
812
+
813
+ if ticket:
814
+ st.session_state.sales_ticket = ticket
815
+ st.session_state.sales_ticket_fetched_at = datetime.now()
816
+ with st.expander("🔍 SSO 调试信息", expanded=False):
817
+ st.json(sso_debug)
818
+
819
+ # ② 用 ticket 换 token
820
+ with st.spinner("Step 2/2 用 ticket 换 Bearer Token…"):
821
+ try:
822
+ token_data, proxy_warning = fetch_oauth_token(
823
+ mobile=ticket,
824
+ code=oauth_code_input.strip(),
825
+ grant_type=oauth_grant_input.strip(),
826
+ basic_auth=oauth_basic_input.strip(),
827
+ cookies=center_cookies,
828
+ proxy_url=proxy_base_url,
829
+ )
830
+ if proxy_warning:
831
+ st.warning(proxy_warning)
832
+ except requests.HTTPError as exc:
833
+ st.error(f"OAuth HTTP 错误:{exc.response.status_code} {exc.response.reason}")
834
+ if exc.response is not None:
835
+ st.code(exc.response.text[:500])
836
+ token_data = None
837
+ except Exception as exc: # noqa: BLE001
838
+ st.error(f"OAuth 调用失败:{exc}")
839
+ token_data = None
840
+
841
+ if token_data:
842
+ access_token = token_data.get("access_token") or ""
843
+ if access_token:
844
+ st.session_state.sales_token = access_token
845
+ st.session_state.sales_token_meta = token_data
846
+ st.session_state.sales_token_fetched_at = datetime.now()
847
+ st.toast("Token 获取成功 ✅")
848
+ else:
849
+ st.error("OAuth 响应中未找到 access_token。")
850
+ st.json(token_data)
851
+
852
+ with col_info:
853
+ if st.session_state.sales_token:
854
+ meta = st.session_state.sales_token_meta or {}
855
+ fetched_at = st.session_state.sales_token_fetched_at
856
+ ticket_at = st.session_state.sales_ticket_fetched_at
857
+ expires_in = int(meta.get("expires_in") or 0)
858
+ token_preview = st.session_state.sales_token[:8] + "…" + st.session_state.sales_token[-4:]
859
+ ticket_preview = (st.session_state.sales_ticket or "")[:6] + "…"
860
+ info_lines = [
861
+ f"**Token**:`{token_preview}`",
862
+ f"**Ticket**:`{ticket_preview}`(一次性,已用完)",
863
+ f"**用户**:{meta.get('name', '-')}({meta.get('username', '-')})",
864
+ f"**影城**:{meta.get('userdomainname', '-')}",
865
+ ]
866
+ if fetched_at:
867
+ info_lines.append(f"**Token 获取时间**:{fetched_at.strftime('%Y-%m-%d %H:%M:%S')}")
868
+ if expires_in:
869
+ info_lines.append(f"**Token 有效期**:约 {expires_in // 3600} 小时 {expires_in % 3600 // 60} 分钟")
870
+ st.success("\n\n".join(info_lines))
871
+ elif st.session_state.sales_ticket:
872
+ st.warning(f"已拿到 ticket({st.session_state.sales_ticket[:6]}…),但 token 换取失败。")
873
+ else:
874
+ st.info("尚未获取 Token,确认 `OA_JSESSIONID` 已配置后点左侧按钮。")
875
+
876
+
877
+ # ---------------------------------------------------------------------------
878
+ # UI - 步骤 2:拉取销售数据
879
+ # ---------------------------------------------------------------------------
880
+ st.divider()
881
+ st.subheader("② 查询当日卖品销售数据")
882
+
883
+ col_date, col_size, col_btn = st.columns([2, 1, 1])
884
+ with col_date:
885
+ query_date = st.date_input(
886
+ "营业日(06:00 ~ 次日 06:00)",
887
+ value=st.session_state.sales_query_date,
888
+ key="sales_date_input",
889
+ )
890
+ with col_size:
891
+ page_size = st.number_input("pageSize", min_value=10, max_value=500, value=100, step=10)
892
+ with col_btn:
893
+ st.write("")
894
+ fetch_clicked = st.button("📊 查询销售总额", type="primary", use_container_width=True)
895
+
896
+ start_str, end_str = build_business_window(query_date)
897
+ st.caption(f"时间范围:`{start_str}` → `{end_str}`")
898
+
899
+ if fetch_clicked:
900
+ if not st.session_state.sales_token:
901
+ st.error("请先完成步骤 ① 获取 Token。")
902
+ else:
903
+ st.session_state.sales_query_date = query_date
904
+ with st.spinner("调用 jmreport 接口中…"):
905
+ try:
906
+ payload, proxy_warning = fetch_jmreport_show(
907
+ token=st.session_state.sales_token,
908
+ start=start_str,
909
+ end=end_str,
910
+ page_no=1,
911
+ page_size=int(page_size),
912
+ proxy_url=proxy_base_url,
913
+ )
914
+ if proxy_warning:
915
+ st.warning(proxy_warning)
916
+ except requests.HTTPError as exc:
917
+ st.error(f"HTTP 错误:{exc.response.status_code} {exc.response.reason}")
918
+ if exc.response is not None:
919
+ st.code(exc.response.text[:1000])
920
+ payload = None
921
+ except Exception as exc: # noqa: BLE001
922
+ st.error(f"调用失败:{exc}")
923
+ payload = None
924
+
925
+ if payload:
926
+ if not payload.get("success", True):
927
+ st.error(f"接口业务失败:{payload.get('message') or payload}")
928
+ st.session_state.sales_last_payload = payload
929
+ st.session_state.sales_last_summary = summarize_payload(payload)
930
+
931
+
932
+ # ---------------------------------------------------------------------------
933
+ # UI - 步骤 3:展示结果
934
+ # ---------------------------------------------------------------------------
935
+ summary = st.session_state.sales_last_summary
936
+ if summary:
937
+ st.divider()
938
+ st.subheader("③ 销售汇总结果")
939
+
940
+ real_amount = summary["report_total_real_amount"] or 0.0
941
+ sales_amount = summary["report_total_sales_amount"] or 0.0
942
+ sales_num = int(summary["report_total_sales_num"] or 0)
943
+
944
+ st.markdown(
945
+ f"""
946
+ <div style="margin: 12px 0 18px; padding: 18px 22px; border-left: 6px solid #D83B01;
947
+ background: #FFF4ED; border-radius: 4px;">
948
+ <div style="font-size: 16px; color: #5C1F00; margin-bottom: 6px;">
949
+ {query_date.strftime('%Y-%m-%d')} 卖品 <b>实付金额</b> 总额
950
+ </div>
951
+ <span style="font-size: 38px; font-weight: 800; color: #D83B01;">¥ {real_amount:,.2f}</span>
952
+ </div>
953
+ """,
954
+ unsafe_allow_html=True,
955
+ )
956
+
957
+ m1, m2, m3, m4 = st.columns(4)
958
+ m1.metric("实付金额(汇总)", f"¥ {real_amount:,.2f}")
959
+ m2.metric("销售额(柜台价)", f"¥ {sales_amount:,.2f}")
960
+ m3.metric("销售件数", f"{sales_num:,}")
961
+ m4.metric(
962
+ "本页明细行数",
963
+ f"{summary['list_count']} / {summary['page_total_records']}",
964
+ help="当前页明细行数 / 全量记录数",
965
+ )
966
+
967
+ list_real = summary["list_total_real_amount"]
968
+ if summary["list_count"]:
969
+ diff = real_amount - list_real
970
+ st.caption(
971
+ f"本页明细 real_amount 累计:¥ {list_real:,.2f} | 与汇总差额:¥ {diff:,.2f}"
972
+ "(差额非 0 表示当日数据超出当前页,可调大 pageSize 或翻页)"
973
+ )
974
+
975
+ pay_df = (
976
+ pd.DataFrame(
977
+ [
978
+ {"支付方式": label, "金额": amount}
979
+ for label, amount in summary["payment_breakdown"].items()
980
+ ]
981
+ )
982
+ .sort_values("金额", ascending=False)
983
+ .reset_index(drop=True)
984
+ )
985
+ pay_df_nonzero = pay_df[pay_df["金额"] > 0]
986
+
987
+ col_pay, col_chart = st.columns([1, 1])
988
+ with col_pay:
989
+ st.markdown("**支付方式拆分(全量)**")
990
+ if pay_df_nonzero.empty:
991
+ st.info("无支付方式数据。")
992
+ else:
993
+ st.dataframe(
994
+ pay_df_nonzero.style.format({"金额": "¥ {:,.2f}"}),
995
+ hide_index=True,
996
+ width="stretch",
997
+ )
998
+ with col_chart:
999
+ if not pay_df_nonzero.empty:
1000
+ st.markdown("**金额占比**")
1001
+ st.bar_chart(pay_df_nonzero.set_index("支付方式")["金额"], height=240)
1002
+
1003
+ items_df = summary["items"]
1004
+ if isinstance(items_df, pd.DataFrame) and not items_df.empty:
1005
+ with st.expander(f"📋 当前页明细({summary['list_count']} 行)", expanded=False):
1006
+ preferred_cols = [
1007
+ "sales_time",
1008
+ "order_code",
1009
+ "channel",
1010
+ "sales_address",
1011
+ "goods_name",
1012
+ "goods_category_level1",
1013
+ "sales_num",
1014
+ "counter_price",
1015
+ "sales_amount",
1016
+ "real_amount",
1017
+ "pay_type",
1018
+ "pay_detail",
1019
+ "salesman",
1020
+ ]
1021
+ cols = [c for c in preferred_cols if c in items_df.columns]
1022
+ display_df = items_df[cols] if cols else items_df
1023
+ st.dataframe(display_df, hide_index=True, width="stretch")
1024
+
1025
+ with st.expander("🐛 原始 payload(result.dataList 结构)", expanded=False):
1026
+ st.json(st.session_state.sales_last_payload)
1027
+ else:
1028
+ st.info("尚无查询结果。请完成上面两步后查看汇总。")
pages/🆕 新API影片映出日累计报表测试.py ADDED
@@ -0,0 +1,767 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """新 API(center.hengdianfilm.com / cinema/movieshow/page)测试页面。
2
+
3
+ 复用 app.py 的「影片映出日累计报表」生成逻辑,但数据来源切换为:
4
+ 1. 直接调用新 API 拉取(需 Bearer Token)
5
+ 2. 手动粘贴 API 返回 JSON 文本
6
+
7
+ 最终生成同样格式的报表,并支持下载 XLSX。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import io
13
+ import json
14
+ import os
15
+ import re
16
+ from datetime import date as dt_date, datetime, time as dt_time, timedelta
17
+ from pathlib import Path
18
+
19
+ import numpy as np
20
+ import pandas as pd
21
+ import requests
22
+ import streamlit as st
23
+ from dotenv import load_dotenv
24
+ from new_api_proxy import (
25
+ NEW_API_PROXY_AUTH_QUERY_ENV,
26
+ NEW_API_PROXY_TOKEN_ENV,
27
+ NEW_API_PROXY_URL_ENV,
28
+ build_new_api_proxy_url,
29
+ get_new_api_proxy_base_url,
30
+ with_new_api_proxy_headers,
31
+ )
32
+
33
+
34
+ load_dotenv(override=True)
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # 页面配置
39
+ # ---------------------------------------------------------------------------
40
+ st.set_page_config(layout="wide", page_title="🆕 新 API 影片映出日累计报表测试")
41
+ st.title("🆕 新 API 影片映出日累计报表测试")
42
+ st.caption(
43
+ "测试 `https://center.hengdianfilm.com/cinema/movieshow/page` 接口,"
44
+ "数据可由 API 抓取或直接粘贴 JSON 返回。生成与 `app.py` 一致格式的「影片映出日累计报表」。"
45
+ )
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # 常量与路径
50
+ # ---------------------------------------------------------------------------
51
+ ROOT_DIR = Path(__file__).resolve().parent.parent
52
+ CINEMA_CACHE_DIR = ROOT_DIR / "cinema_cache"
53
+ MOVIE_NUM_NAME_MAP_FILE = CINEMA_CACHE_DIR / "movie_num_name_map.json"
54
+ NEW_API_URL = "https://center.hengdianfilm.com/cinema/movieshow/page"
55
+ DEFAULT_PAGE_SIZE = 200
56
+ DEFAULT_BEARER_TOKEN = ""
57
+
58
+ SSO_APP_ID = os.getenv("CENTER_SSO_APP_ID", "-28746303884891748910")
59
+ SSO_PAGE_URL = os.getenv("CENTER_SSO_PAGE_URL", "https://center.hengdianfilm.com/")
60
+ DEFAULT_OA_JSESSIONID = os.getenv("OA_JSESSIONID", "")
61
+ DEFAULT_OA_COOKIE = os.getenv("OA_COOKIE", "")
62
+ DEFAULT_OAUTH_CODE = os.getenv("CENTER_OAUTH_CODE", "1234")
63
+ DEFAULT_OAUTH_GRANT = os.getenv("CENTER_OAUTH_GRANT_TYPE", "oa")
64
+ DEFAULT_OAUTH_BASIC = os.getenv("CENTER_OAUTH_BASIC", "Basic b2E6b2E=")
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # 工具函数(与 app.py 保持一致;为保持本页独立,做了最小复制)
69
+ # ---------------------------------------------------------------------------
70
+ def _format_sso_ticket_proxy_error(data):
71
+ if not isinstance(data, dict) or data.get("error") != "ticket-not-found":
72
+ return None
73
+
74
+ debug = data.get("debug") or {}
75
+ body_preview = str(debug.get("body_preview") or "")
76
+ upstream_status = data.get("upstreamStatus") or debug.get("status") or "-"
77
+ location = debug.get("location") or "-"
78
+ if "请重新登录" in body_preview:
79
+ return (
80
+ "OA 登录态已失效:OA 返回“请重新登录后再试”。"
81
+ "请在浏览器重新登录 OA,复制最新完整 Cookie 写入 `.env` 的 `OA_COOKIE`"
82
+ "(或至少更新 `OA_JSESSIONID`),然后重启 Streamlit。"
83
+ f"上游状态码={upstream_status}。"
84
+ )
85
+ return (
86
+ "未能从 OA SSO 响应中提取到 ticket。"
87
+ f"上游状态码={upstream_status},Location={location},响应预览={body_preview or data}"
88
+ )
89
+
90
+
91
+ def _normalize_movie_num_key(movie_num) -> str:
92
+ return re.sub(r"[^A-Z0-9]", "", str(movie_num or "").strip().upper())
93
+
94
+
95
+ @st.cache_data(show_spinner=False, ttl=120)
96
+ def _load_movie_num_name_map() -> dict:
97
+ """读取 cinema_cache/movie_num_name_map.json,返回 {规范化 movieNum: 官方名称}。"""
98
+ if not MOVIE_NUM_NAME_MAP_FILE.exists():
99
+ return {}
100
+ try:
101
+ with open(MOVIE_NUM_NAME_MAP_FILE, "r", encoding="utf-8") as f:
102
+ payload = json.load(f)
103
+ except Exception:
104
+ return {}
105
+ movie_num_map = payload.get("movie_num_map", {})
106
+ if not isinstance(movie_num_map, dict):
107
+ return {}
108
+ result = {}
109
+ for movie_num, entry in movie_num_map.items():
110
+ key = _normalize_movie_num_key(movie_num)
111
+ if not key:
112
+ continue
113
+ if isinstance(entry, str):
114
+ name = entry.strip()
115
+ elif isinstance(entry, dict):
116
+ name = str(entry.get("official_name") or "").strip()
117
+ else:
118
+ continue
119
+ if name:
120
+ result[key] = name
121
+ return result
122
+
123
+
124
+ def clean_movie_title(raw_title, canonical_names=None):
125
+ """与 app.py 同名函数完全一致的精简版。"""
126
+ if not isinstance(raw_title, str):
127
+ return raw_title
128
+ base_name = None
129
+ if canonical_names:
130
+ sorted_names = sorted(canonical_names, key=len, reverse=True)
131
+ for name in sorted_names:
132
+ if name in raw_title:
133
+ base_name = name
134
+ break
135
+ if not base_name:
136
+ base_name = raw_title.split(" ", 1)[0]
137
+
138
+ raw_upper = raw_title.upper()
139
+ suffix = ""
140
+ if "HDR LED" in raw_upper:
141
+ suffix = "(HDR LED)"
142
+ elif "CINITY" in raw_upper:
143
+ suffix = "(CINITY)"
144
+ elif "杜比" in raw_upper or "DOLBY" in raw_upper:
145
+ suffix = "(杜比视界)"
146
+ elif "IMAX" in raw_upper:
147
+ suffix = "(数字IMAX3D)" if "3D" in raw_upper else "(数字IMAX)"
148
+ elif "巨幕" in raw_upper:
149
+ suffix = "(中国巨幕立体)" if "立体" in raw_upper else "(中国巨幕)"
150
+ elif "3D" in raw_upper:
151
+ suffix = "(数字3D)"
152
+
153
+ if suffix and suffix not in base_name:
154
+ return f"{base_name}{suffix}"
155
+ return base_name
156
+
157
+
158
+ def resolve_movie_name_from_schedule_item(raw_movie_name, movie_num=None, canonical_names=None):
159
+ """与 app.py 同名函数一致:优先用 movieNum 映射,回退到 canonical_names / 空格分割。"""
160
+ if not isinstance(raw_movie_name, str):
161
+ return raw_movie_name
162
+ if movie_num:
163
+ key = _normalize_movie_num_key(movie_num)
164
+ if key:
165
+ official = _load_movie_num_name_map().get(key)
166
+ if official:
167
+ return official
168
+
169
+ if canonical_names:
170
+ for name in sorted(canonical_names, key=len, reverse=True):
171
+ if name in raw_movie_name:
172
+ return name
173
+ return raw_movie_name.split(" ", 1)[0]
174
+
175
+
176
+ def normalize_report_time_value(value):
177
+ if pd.isna(value):
178
+ return None
179
+ if isinstance(value, datetime):
180
+ return value.time().replace(second=0, microsecond=0)
181
+ if isinstance(value, dt_time):
182
+ return value.replace(second=0, microsecond=0)
183
+ parsed = pd.to_datetime(str(value).strip(), errors="coerce")
184
+ if pd.isna(parsed):
185
+ return None
186
+ return parsed.time().replace(second=0, microsecond=0)
187
+
188
+
189
+ def infer_daily_report_date(date_series, fallback_date_str=None):
190
+ parsed = pd.to_datetime(date_series, errors="coerce").dropna()
191
+ if not parsed.empty:
192
+ return parsed.dt.date.mode().iloc[0]
193
+ if fallback_date_str:
194
+ fb = pd.to_datetime(fallback_date_str, errors="coerce")
195
+ if pd.notna(fb):
196
+ return fb.date()
197
+ return None
198
+
199
+
200
+ def build_daily_report_from_source_df(source_df, selected_date_str=None, canonical_names=None):
201
+ """与 app.py 中 build_daily_report_from_source_df 一致,返回 (df, display_date)。"""
202
+ if source_df is None or source_df.empty:
203
+ return pd.DataFrame(), infer_daily_report_date(pd.Series(dtype=object), selected_date_str)
204
+
205
+ required_cols = ["影片名称", "放映时间", "影厅名称", "总人次", "座位数"]
206
+ missing = [c for c in required_cols if c not in source_df.columns]
207
+ if missing:
208
+ st.error(f"数据缺少必要列: {', '.join(missing)}")
209
+ return pd.DataFrame(), None
210
+
211
+ df = source_df.copy()
212
+ if "放映日期" not in df.columns:
213
+ df["放映日期"] = selected_date_str
214
+
215
+ display_date = infer_daily_report_date(df["放映日期"], selected_date_str)
216
+ display_date_str = display_date.strftime("%Y-%m-%d") if display_date else selected_date_str
217
+
218
+ df["影片名称"] = df["影片名称"].astype(str).str.strip()
219
+ df["影厅名称"] = df["影厅名称"].fillna("未知影厅").astype(str).str.strip()
220
+ df["总人次"] = pd.to_numeric(df["总人次"], errors="coerce").fillna(0).round().astype(int)
221
+ df["座位数"] = pd.to_numeric(df["座位数"], errors="coerce").fillna(0).round().astype(int)
222
+ df["_放映时间对象"] = df["放映时间"].apply(normalize_report_time_value)
223
+ df["放映日期"] = pd.to_datetime(df["放映日期"], errors="coerce").dt.strftime("%Y-%m-%d")
224
+ if display_date_str:
225
+ df["放映日期"] = df["放映日期"].fillna(display_date_str)
226
+
227
+ df.dropna(subset=["影片名称", "_放映时间对象"], inplace=True)
228
+ df = df[df["影片名称"].ne("") & df["影片名称"].ne("nan")].copy()
229
+ df = df[df["总人次"] > 0].copy()
230
+
231
+ if df.empty:
232
+ st.info("所有场次的观影人数均为 0,没有可显示的数据。")
233
+ return pd.DataFrame(), display_date
234
+
235
+ if "movieNum" in df.columns:
236
+ df["影片"] = df.apply(
237
+ lambda row: resolve_movie_name_from_schedule_item(
238
+ row["影片名称"],
239
+ movie_num=row.get("movieNum"),
240
+ canonical_names=canonical_names,
241
+ ),
242
+ axis=1,
243
+ )
244
+ else:
245
+ df["影片"] = df["影片名称"].apply(lambda x: clean_movie_title(x, canonical_names))
246
+ df["影厅"] = df["影厅名称"]
247
+ df["人数合计"] = df["总人次"]
248
+
249
+ with np.errstate(divide="ignore", invalid="ignore"):
250
+ df["上座率%"] = np.divide(df["人数合计"], df["座位数"]) * 100
251
+ df["上座率%"] = df["上座率%"].replace([np.inf, -np.inf], 0).fillna(0)
252
+
253
+ df["放映时间"] = df["_放映时间对象"].apply(lambda x: x.strftime("%H:%M:%S"))
254
+ result = df[["影片", "放映日期", "放映时间", "影厅", "人数合计", "座位数", "上座率%"]].copy()
255
+ result = result.sort_values(by=["放映日期", "放映时间", "影厅", "影片"]).reset_index(drop=True)
256
+ return result, display_date
257
+
258
+
259
+ # ---------------------------------------------------------------------------
260
+ # 新 API 数据获取与解析
261
+ # ---------------------------------------------------------------------------
262
+ class NewApiProxyPlatformError(RuntimeError):
263
+ def __init__(self, step: str, message: str) -> None:
264
+ super().__init__(
265
+ f"{step} 被 EdgeOne Pages 外层鉴权拦截:{message}。"
266
+ f"请确认 `{NEW_API_PROXY_AUTH_QUERY_ENV}` 里的 `eo_time` / `eo_token` 仍有效。"
267
+ )
268
+
269
+
270
+ class NewApiProxyFunctionMissingError(RuntimeError):
271
+ def __init__(self, step: str, path: str) -> None:
272
+ super().__init__(
273
+ f"{step} 没有命中 EdgeOne edge-functions:{path}。"
274
+ "请把 `new-api-edgeone-pages` 重新部署到 EdgeOne Pages。"
275
+ )
276
+
277
+
278
+ def _raise_for_proxy_platform_error(resp: requests.Response, step: str) -> None:
279
+ eop_msg = resp.headers.get("X-EOP-MSG")
280
+ server = resp.headers.get("Server", "")
281
+ if resp.status_code in {401, 403} and (eop_msg or "edgeone-pages" in server.lower()):
282
+ raise NewApiProxyPlatformError(step, eop_msg or "Access Restricted or Authentication Expired")
283
+
284
+
285
+ def _raise_for_proxy_missing_function(resp: requests.Response, step: str, path: str) -> None:
286
+ body = resp.text or ""
287
+ if not resp.headers.get("X-New-API-Proxy") and resp.status_code in {404, 405} and (
288
+ "<Code>MethodNotAllowed</Code>" in body or "<Code>NoSuchKey</Code>" in body
289
+ ):
290
+ raise NewApiProxyFunctionMissingError(step, path)
291
+
292
+
293
+ def _post_new_api_proxy_json(path: str, payload: dict, timeout: int = 25) -> requests.Response:
294
+ proxy_base_url = get_new_api_proxy_base_url()
295
+ if not proxy_base_url:
296
+ raise RuntimeError(f"未配置 `{NEW_API_PROXY_URL_ENV}`,无法通过中转代理访问境内接口。")
297
+
298
+ headers = with_new_api_proxy_headers({"accept": "application/json"}, proxy_base_url)
299
+ session = requests.Session()
300
+ resp = session.post(
301
+ build_new_api_proxy_url(path, proxy_base_url),
302
+ headers=headers,
303
+ json=payload,
304
+ timeout=timeout,
305
+ allow_redirects=False,
306
+ )
307
+ if resp.status_code in {301, 302, 303, 307, 308} and resp.headers.get("Location"):
308
+ resp = session.post(
309
+ requests.compat.urljoin(resp.url, resp.headers["Location"]),
310
+ headers=headers,
311
+ json=payload,
312
+ timeout=timeout,
313
+ allow_redirects=False,
314
+ )
315
+ return resp
316
+
317
+
318
+ def fetch_center_token_auto() -> tuple[str, dict]:
319
+ """通过 EdgeOne 中转自动完成 OA SSO -> ticket -> OAuth token。"""
320
+ if not DEFAULT_OA_COOKIE.strip() and not DEFAULT_OA_JSESSIONID.strip():
321
+ raise ValueError("请先在 `.env` 配置 `OA_COOKIE` 或 `OA_JSESSIONID`。")
322
+
323
+ sso_payload = {
324
+ "jsessionid": DEFAULT_OA_JSESSIONID.strip(),
325
+ "oaCookie": DEFAULT_OA_COOKIE.strip(),
326
+ "appId": SSO_APP_ID,
327
+ "pageUrl": SSO_PAGE_URL,
328
+ }
329
+ sso_resp = _post_new_api_proxy_json("/new-api/sso-ticket", sso_payload, timeout=25)
330
+ _raise_for_proxy_platform_error(sso_resp, "OA SSO ticket 中转")
331
+ _raise_for_proxy_missing_function(sso_resp, "OA SSO ticket 中转", "/new-api/sso-ticket")
332
+ if not sso_resp.ok:
333
+ try:
334
+ error_data = sso_resp.json()
335
+ except ValueError:
336
+ error_data = {}
337
+ error_message = _format_sso_ticket_proxy_error(error_data)
338
+ if error_message:
339
+ raise RuntimeError(error_message)
340
+ sso_resp.raise_for_status()
341
+ sso_data = sso_resp.json()
342
+ ticket = str(sso_data.get("ticket") or "").strip()
343
+ if not ticket:
344
+ error_message = _format_sso_ticket_proxy_error(sso_data)
345
+ if error_message:
346
+ raise RuntimeError(error_message)
347
+ raise RuntimeError(f"OA SSO 中转未返回 ticket:{sso_data}")
348
+
349
+ oauth_payload = {
350
+ "mobile": ticket,
351
+ "code": DEFAULT_OAUTH_CODE,
352
+ "grantType": DEFAULT_OAUTH_GRANT,
353
+ "basicAuth": DEFAULT_OAUTH_BASIC,
354
+ "cookies": sso_data.get("centerCookies") or {},
355
+ }
356
+ oauth_resp = _post_new_api_proxy_json("/new-api/oauth-token", oauth_payload, timeout=25)
357
+ _raise_for_proxy_platform_error(oauth_resp, "OAuth token 中转")
358
+ _raise_for_proxy_missing_function(oauth_resp, "OAuth token 中转", "/new-api/oauth-token")
359
+ oauth_resp.raise_for_status()
360
+ token_data = oauth_resp.json()
361
+ access_token = str(token_data.get("access_token") or "").strip()
362
+ if not access_token:
363
+ raise RuntimeError(f"OAuth 响应中未找到 access_token:{token_data}")
364
+
365
+ debug = {
366
+ "sso": sso_data.get("debug") or {},
367
+ "oauth": {
368
+ k: v
369
+ for k, v in token_data.items()
370
+ if k not in {"access_token", "refresh_token"}
371
+ },
372
+ }
373
+ return access_token, debug
374
+
375
+
376
+ def _build_request_headers(bearer_token: str) -> dict:
377
+ return {
378
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:115.0) Gecko/20100101 Firefox/115.0",
379
+ "Accept": "application/json, text/plain, */*",
380
+ "Accept-Language": "zh-CN,zh;q=0.9",
381
+ "Content-Type": "application/json;charset=utf-8",
382
+ "Authorization": f"Bearer {bearer_token.strip()}",
383
+ "TENANT-ID": "1",
384
+ "Channel": "4",
385
+ "Origin": "https://center.hengdianfilm.com",
386
+ "Referer": "https://center.hengdianfilm.com/",
387
+ "Connection": "keep-alive",
388
+ }
389
+
390
+
391
+ def fetch_movieshow_page(bearer_token: str, show_date: str, current: int = 1, size: int = DEFAULT_PAGE_SIZE):
392
+ """单次拉取一页排片数据。返回原始 JSON dict。"""
393
+ payload = {
394
+ "size": size,
395
+ "current": current,
396
+ "entity": {"cinemaMovieShowDate": show_date},
397
+ }
398
+ proxy_base_url = get_new_api_proxy_base_url()
399
+ if proxy_base_url:
400
+ resp = _post_new_api_proxy_json(
401
+ "/new-api/movieshow-page",
402
+ {
403
+ "token": bearer_token,
404
+ "showDate": show_date,
405
+ "current": current,
406
+ "size": size,
407
+ },
408
+ timeout=25,
409
+ )
410
+ _raise_for_proxy_platform_error(resp, "movieshow/page 中转")
411
+ _raise_for_proxy_missing_function(resp, "movieshow/page 中转", "/new-api/movieshow-page")
412
+ resp.raise_for_status()
413
+ return resp.json()
414
+
415
+ resp = requests.post(
416
+ NEW_API_URL,
417
+ headers=_build_request_headers(bearer_token),
418
+ data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
419
+ timeout=20,
420
+ )
421
+ resp.raise_for_status()
422
+ return resp.json()
423
+
424
+
425
+ def fetch_all_movieshow(bearer_token: str, show_date: str, size: int = DEFAULT_PAGE_SIZE) -> list:
426
+ """自动翻页拉取所有场次记录。"""
427
+ all_records: list = []
428
+ current = 1
429
+ while True:
430
+ data = fetch_movieshow_page(bearer_token, show_date, current=current, size=size)
431
+ if data.get("code") != 0:
432
+ raise RuntimeError(f"接口返回失败:{data.get('msg') or data}")
433
+ body = data.get("data") or {}
434
+ records = body.get("records") or []
435
+ all_records.extend(records)
436
+ total = int(body.get("total") or 0)
437
+ if len(all_records) >= total or not records:
438
+ break
439
+ current += 1
440
+ if current > 50: # 安全阈值
441
+ break
442
+ return all_records
443
+
444
+
445
+ def parse_pasted_response(raw_text: str) -> list:
446
+ """解析用户粘贴的 JSON 响应文本,返回 records 列表。"""
447
+ text = (raw_text or "").strip()
448
+ if not text:
449
+ raise ValueError("粘贴内容为空。")
450
+ try:
451
+ payload = json.loads(text)
452
+ except json.JSONDecodeError as exc:
453
+ raise ValueError(f"JSON 解析失败:{exc}") from exc
454
+
455
+ # 用户既可能粘贴整个响应,也可能直接粘贴 records 列表
456
+ if isinstance(payload, list):
457
+ return payload
458
+ if not isinstance(payload, dict):
459
+ raise ValueError("无法识别的 JSON 结构,期望对象或数组。")
460
+
461
+ if "data" in payload and isinstance(payload["data"], dict):
462
+ records = payload["data"].get("records")
463
+ if isinstance(records, list):
464
+ return records
465
+ if "records" in payload and isinstance(payload["records"], list):
466
+ return payload["records"]
467
+ raise ValueError("未在 JSON 中找到 records 数组。请粘贴接口完整返回或 records 列表。")
468
+
469
+
470
+ def records_to_source_df(records: list) -> pd.DataFrame:
471
+ """将新 API 返回的场次列表转换成 build_daily_report_from_source_df 期望的列结构。"""
472
+ if not records:
473
+ return pd.DataFrame()
474
+ rows = []
475
+ for item in records:
476
+ if not isinstance(item, dict):
477
+ continue
478
+ rows.append(
479
+ {
480
+ "影片名称": item.get("cinemaMovieName") or "",
481
+ "放映时间": item.get("cinemaMovieShowStartTime") or "",
482
+ "影厅名称": item.get("cinemaHallName") or "",
483
+ "总人次": item.get("cinemaMovieShowSoldNum") or 0,
484
+ "座位数": item.get("cinemaHallSeatNum") or 0,
485
+ "放映日期": item.get("cinemaMovieShowDate") or "",
486
+ "movieNum": item.get("cinemaMovieNum") or "",
487
+ }
488
+ )
489
+ return pd.DataFrame(rows)
490
+
491
+
492
+ # ---------------------------------------------------------------------------
493
+ # Session State 初始化
494
+ # ---------------------------------------------------------------------------
495
+ def _init_state():
496
+ defaults = {
497
+ "new_api_bearer_token": os.getenv("HENGDIAN_CENTER_TOKEN", DEFAULT_BEARER_TOKEN),
498
+ "new_api_token_debug": {},
499
+ "new_api_token_fetched_at": None,
500
+ "new_api_query_date": dt_date.today() + timedelta(days=1),
501
+ "new_api_records": [],
502
+ "new_api_report_df": pd.DataFrame(),
503
+ "new_api_display_date": None,
504
+ "new_api_data_source": "",
505
+ "new_api_paste_text": "",
506
+ }
507
+ for key, value in defaults.items():
508
+ st.session_state.setdefault(key, value)
509
+
510
+
511
+ _init_state()
512
+
513
+
514
+ # ---------------------------------------------------------------------------
515
+ # UI
516
+ # ---------------------------------------------------------------------------
517
+ with st.expander("📘 接口说明", expanded=False):
518
+ st.markdown(
519
+ f"""
520
+ - **接口**:`POST {NEW_API_URL}`
521
+ - **必备 Header**:`Authorization: Bearer <token>`、`TENANT-ID: 1`、`Channel: 4`、`Content-Type: application/json;charset=utf-8`
522
+ - **请求体**:`{{"size": 100, "current": 1, "entity": {{"cinemaMovieShowDate": "YYYY-MM-DD"}}}}`
523
+ - **关键返回字段**:
524
+ - `cinemaMovieName` → 影片名称(含制式)
525
+ - `cinemaMovieNum` → 影片编号(用于 `movie_num_name_map.json` 标准名映射)
526
+ - `cinemaMovieShowStartTime` → 放映开始时间
527
+ - `cinemaHallName` / `cinemaHallSeatNum` → 影厅名称 / 座位数
528
+ - `cinemaMovieShowSoldNum` → 已售人次
529
+ """
530
+ )
531
+
532
+ with st.expander("🔑 手动 Bearer Token 备用获取方式", expanded=False):
533
+ st.markdown(
534
+ """
535
+ > 本页默认会通过 OA SSO 自动获取 Token;下面的方法只用于自动获取失败时临时排查。
536
+ > Token 是登录后由浏览器临时保存的身份凭证,**有效期通常只有几小时到一天**,失效后需要重新抓取。
537
+
538
+ #### 方法 A:从「Network(网络)」面板抓取(推荐,最准确)
539
+
540
+ 1. 用 Chrome / Edge / Firefox 打开 [https://center.hengdianfilm.com/](https://center.hengdianfilm.com/) 并**正常登录**。
541
+ 2. 按 **F12**(或右键 → **检查 / Inspect**)打开开发者工具,切换到 **Network(网络)** 面板。
542
+ 3. 勾选 **Preserve log(保留日志)**,在过滤框里输入关键字 `movieshow` 或 `cinema/`,过滤出业务接口。
543
+ 4. 在网页上**点击任意会触发刷新数据的操作**(例如切换日期、点查询),让接口重新发一次请求。
544
+ 5. 在过滤结果里点击一条形如 `movieshow/page` 的请求 → 切到 **Headers(标头)** 子面板 → 找到 **Request Headers(请求标头)**。
545
+ 6. 复制 `Authorization:` 这一行后面的内容,**去掉开头的 `Bearer `**,只保留后面那串形如 `40ea061e-598e-4e6f-8f98-...` 的字符串,粘贴到下方输入框即可。
546
+
547
+ #### 方法 B:从「Application / 存储」面板里直接读取
548
+
549
+ 1. 同样打开开发者工具,切到 **Application(应用)** 面板(Firefox 叫 **Storage / 存储**)。
550
+ 2. 左侧依次点开 **Local Storage** 和 **Cookies**,目标域名选 `https://center.hengdianfilm.com`。
551
+ 3. 在右侧的键值对里搜索关键字: `token`、`access_token`、`Authorization`、`auth`、`satoken`。
552
+ 4. 找到形如 UUID(`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`)的值,复制粘贴到下方输入框。
553
+
554
+ #### 方法 C:Console(控制台)一键提取
555
+
556
+ 打开 **Console(控制台)** 面板,粘贴下面任意一行回车,即可直接打印出 token:
557
+
558
+ ```js
559
+ // 如果存在 localStorage 里
560
+ copy(localStorage.getItem('Authorization') || localStorage.getItem('token') || localStorage.getItem('access_token'));
561
+ ```
562
+
563
+ ```js
564
+ // 一键查找所有可能键
565
+ Object.keys(localStorage).filter(k => /token|auth/i.test(k)).forEach(k => console.log(k, '=', localStorage.getItem(k)));
566
+ ```
567
+
568
+ 执行 `copy(...)` 后 token 会被复制到剪贴板,直接粘贴即可。
569
+
570
+ #### 常见问题
571
+
572
+ - **过期 / 401 Unauthorized** → 重新登录 hengdianfilm 后台,再次按上面步骤抓取。
573
+ - **找不到 `Authorization` 头** → 确认你已经登录,并且过滤的是业务接口(URL 含 `/cinema/`),而不是登录接口本身。
574
+ - **复制时多了 `Bearer ` 前缀** → 输入框里**不要**带 `Bearer `,代码里会自动拼接。
575
+ """
576
+ )
577
+
578
+ proxy_base_url = get_new_api_proxy_base_url()
579
+ if proxy_base_url:
580
+ proxy_display_url = proxy_base_url.split("?", 1)[0] + ("?..." if "?" in proxy_base_url else "")
581
+ st.caption(f"当前自动 token 与 movieshow/page 均通过中转代理访问:{proxy_display_url}")
582
+ if not os.getenv(NEW_API_PROXY_TOKEN_ENV, "").strip():
583
+ st.warning(f"已配置 `{NEW_API_PROXY_URL_ENV}`,但未配置 `{NEW_API_PROXY_TOKEN_ENV}`,中转请求会被代理拒绝。")
584
+ if os.getenv(NEW_API_PROXY_AUTH_QUERY_ENV, "").strip():
585
+ st.info(f"已配置 `{NEW_API_PROXY_AUTH_QUERY_ENV}`,代理请求会自动附加 EdgeOne Preview 鉴权参数。")
586
+ else:
587
+ st.warning(f"未配置 `{NEW_API_PROXY_URL_ENV}`。境外服务器需要配置代理后才能自动获取 token 并拉取 API。")
588
+
589
+ input_tab_api, input_tab_paste = st.tabs(["🌐 API 拉取", "📋 粘贴 JSON"])
590
+
591
+
592
+ with input_tab_api:
593
+ col_auth, col_date = st.columns([3, 2])
594
+ with col_auth:
595
+ auto_token = st.checkbox("自动通过 OA SSO 获取 Token", value=True, key="new_api_auto_token")
596
+ token_value = st.session_state.new_api_bearer_token or ""
597
+ if token_value:
598
+ token_preview = token_value[:8] + "..." + token_value[-4:]
599
+ fetched_at = st.session_state.new_api_token_fetched_at
600
+ fetched_label = fetched_at.strftime("%Y-%m-%d %H:%M:%S") if fetched_at else "本次会话"
601
+ st.success(f"当前 Token:`{token_preview}`({fetched_label})")
602
+ else:
603
+ st.info("当前会话尚未获取 Token。点击下方按钮会自动获取。")
604
+
605
+ if st.button("🔐 只获取 / 刷新 Token", use_container_width=False):
606
+ with st.spinner("正在通过代理完成 OA SSO -> OAuth token..."):
607
+ try:
608
+ token, debug = fetch_center_token_auto()
609
+ except Exception as exc: # noqa: BLE001
610
+ st.error(f"自动获取 Token 失败:{exc}")
611
+ else:
612
+ st.session_state.new_api_bearer_token = token
613
+ st.session_state.new_api_token_debug = debug
614
+ st.session_state.new_api_token_fetched_at = datetime.now()
615
+ st.toast("Token 获取成功", icon="✅")
616
+
617
+ with st.expander("手动 Token 备用输入", expanded=False):
618
+ manual_token = st.text_input(
619
+ "Bearer Token",
620
+ value=st.session_state.new_api_bearer_token,
621
+ help="通常不需要填写;自动获取失败时可临时手动输入。",
622
+ key="new_api_bearer_input",
623
+ )
624
+ if manual_token.strip() != st.session_state.new_api_bearer_token:
625
+ st.session_state.new_api_bearer_token = manual_token.strip()
626
+
627
+ if st.session_state.new_api_token_debug:
628
+ with st.expander("自动获取 Token 调试信息", expanded=False):
629
+ st.json(st.session_state.new_api_token_debug)
630
+
631
+ with col_date:
632
+ query_date = st.date_input(
633
+ "查询排片日期",
634
+ value=st.session_state.new_api_query_date,
635
+ key="new_api_date_input",
636
+ )
637
+
638
+ page_size = st.slider("每页大小(自动翻页直到取完)", min_value=50, max_value=500, value=DEFAULT_PAGE_SIZE, step=50)
639
+
640
+ if st.button("🫵 拉取并生成报表", type="primary", use_container_width=False):
641
+ token = (st.session_state.new_api_bearer_token or "").strip()
642
+ if auto_token or not token:
643
+ with st.spinner("正在自动获取 Token..."):
644
+ try:
645
+ token, debug = fetch_center_token_auto()
646
+ except Exception as exc: # noqa: BLE001
647
+ st.error(f"自动获取 Token 失败:{exc}")
648
+ token = ""
649
+ else:
650
+ st.session_state.new_api_bearer_token = token
651
+ st.session_state.new_api_token_debug = debug
652
+ st.session_state.new_api_token_fetched_at = datetime.now()
653
+
654
+ if token:
655
+ st.session_state.new_api_bearer_token = token
656
+ st.session_state.new_api_query_date = query_date
657
+ date_str = query_date.strftime("%Y-%m-%d")
658
+ with st.spinner(f"正在调用接口拉取 {date_str} 的场次..."):
659
+ try:
660
+ records = fetch_all_movieshow(token, date_str, size=page_size)
661
+ except requests.HTTPError as exc:
662
+ st.error(f"HTTP 错误:{exc.response.status_code} {exc.response.reason}")
663
+ records = []
664
+ except Exception as exc: # noqa: BLE001
665
+ st.error(f"调用接口失败:{exc}")
666
+ records = []
667
+
668
+ if records:
669
+ st.session_state.new_api_records = records
670
+ st.session_state.new_api_data_source = f"API 拉取({len(records)} 条)"
671
+ source_df = records_to_source_df(records)
672
+ report_df, display_date = build_daily_report_from_source_df(
673
+ source_df,
674
+ selected_date_str=date_str,
675
+ )
676
+ st.session_state.new_api_report_df = report_df
677
+ st.session_state.new_api_display_date = display_date or query_date
678
+ if not report_df.empty:
679
+ st.toast(f"成功生成 {len(report_df)} 条报表数据。", icon="✅")
680
+
681
+
682
+ with input_tab_paste:
683
+ st.markdown("将接口返回的整个 JSON 文本(或 `data.records` 数组)粘贴到下方:")
684
+ paste_text = st.text_area(
685
+ "JSON 文本",
686
+ value=st.session_state.new_api_paste_text,
687
+ height=260,
688
+ placeholder='{"code":0,"data":{"records":[ ... ]}}',
689
+ key="new_api_paste_textarea",
690
+ )
691
+ fallback_date = st.date_input(
692
+ "用于补充缺失日期的回退值",
693
+ value=st.session_state.new_api_query_date,
694
+ key="new_api_paste_fallback_date",
695
+ )
696
+
697
+ if st.button("📥 解析并生成报表", key="new_api_parse_btn"):
698
+ st.session_state.new_api_paste_text = paste_text
699
+ try:
700
+ records = parse_pasted_response(paste_text)
701
+ except ValueError as exc:
702
+ st.error(str(exc))
703
+ records = []
704
+
705
+ if records:
706
+ st.session_state.new_api_records = records
707
+ st.session_state.new_api_data_source = f"粘贴 JSON({len(records)} 条)"
708
+ source_df = records_to_source_df(records)
709
+ fallback_str = fallback_date.strftime("%Y-%m-%d")
710
+ report_df, display_date = build_daily_report_from_source_df(
711
+ source_df,
712
+ selected_date_str=fallback_str,
713
+ )
714
+ st.session_state.new_api_report_df = report_df
715
+ st.session_state.new_api_display_date = display_date or fallback_date
716
+ if not report_df.empty:
717
+ st.toast(f"成功生成 {len(report_df)} 条报表数据。", icon="✅")
718
+
719
+
720
+ # ---------------------------------------------------------------------------
721
+ # 报表展示与下载
722
+ # ---------------------------------------------------------------------------
723
+ st.divider()
724
+
725
+ report_df: pd.DataFrame = st.session_state.new_api_report_df
726
+ display_date = st.session_state.new_api_display_date
727
+
728
+ if isinstance(report_df, pd.DataFrame) and not report_df.empty:
729
+ if display_date is None:
730
+ display_date = dt_date.today()
731
+ st.caption(f"当前数据来源:{st.session_state.new_api_data_source}")
732
+ st.markdown(f"#### {display_date.strftime('%Y-%m-%d')} 影片映出日累计报表")
733
+
734
+ st.dataframe(
735
+ report_df.style.format(
736
+ {"人数合计": "{:,.0f}", "座位数": "{:,.0f}", "上座率%": "{:.2f}%"}
737
+ ),
738
+ width="stretch",
739
+ hide_index=True,
740
+ )
741
+
742
+ total_attendance = pd.to_numeric(report_df.get("人数合计", 0), errors="coerce").fillna(0).sum()
743
+ st.markdown(
744
+ f"""
745
+ <div style="margin: 12px 0 18px; padding: 14px 18px; border-left: 6px solid #D83B01; background: #FFF4ED;">
746
+ <span style="font-size: 18px; font-weight: 700; color: #5C1F00;">已售人次:</span>
747
+ <span style="font-size: 26px; font-weight: 800; color: #D83B01;">{total_attendance:,.0f}</span>
748
+ <span style="font-size: 18px; font-weight: 700; color: #5C1F00;"> 人</span>
749
+ </div>
750
+ """,
751
+ unsafe_allow_html=True,
752
+ )
753
+
754
+ output_buffer = io.BytesIO()
755
+ report_df.to_excel(output_buffer, index=False, engine="openpyxl")
756
+ st.download_button(
757
+ label="📥 下载 XLSX 报表文件",
758
+ data=output_buffer.getvalue(),
759
+ file_name=f"{display_date.strftime('%Y-%m-%d')}_影片映出日累计报表.xlsx",
760
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
761
+ )
762
+
763
+ with st.expander("查看原始接口数据(最多 10 条)"):
764
+ sample = st.session_state.new_api_records[:10]
765
+ st.json(sample)
766
+ else:
767
+ st.info("尚无数据。请通过「API 拉取」或「粘贴 JSON」生成报表。")
pages/🎟️ 售票时间集中监控.py ADDED
@@ -0,0 +1,1341 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import re
4
+ import threading
5
+ import time
6
+ from collections import deque
7
+ from datetime import datetime, time as dt_time, timedelta, timezone
8
+ from pathlib import Path
9
+ from string import Template
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ import pandas as pd
13
+ import requests
14
+ import streamlit as st
15
+ from dotenv import load_dotenv
16
+
17
+ try:
18
+ from streamlit_autorefresh import st_autorefresh
19
+ except ImportError:
20
+ st_autorefresh = None
21
+
22
+
23
+ st.set_page_config(page_title="售票时间集中监控", page_icon="🎟️", layout="wide")
24
+
25
+
26
+ ROOT_DIR = Path(__file__).resolve().parent.parent
27
+ STATE_DIR = ROOT_DIR / "cinema_cache"
28
+
29
+ load_dotenv(dotenv_path=str(ROOT_DIR / ".env"), override=True)
30
+
31
+
32
+ def env_str(name: str, default: str = "") -> str:
33
+ value = os.getenv(name)
34
+ if value is None:
35
+ return default
36
+ return str(value).strip()
37
+
38
+
39
+ def env_int(name: str, default: int, min_value: Optional[int] = None) -> int:
40
+ try:
41
+ value = int(str(os.getenv(name, "")).strip())
42
+ except (TypeError, ValueError):
43
+ value = default
44
+ if min_value is not None:
45
+ value = max(min_value, value)
46
+ return value
47
+
48
+
49
+ def env_flag(name: str, default: bool = False) -> bool:
50
+ raw = str(os.getenv(name, "")).strip().lower()
51
+ if not raw:
52
+ return default
53
+ return raw not in {"0", "false", "no", "off"}
54
+
55
+
56
+ POS_BASE_URL = env_str("POS_CASHIER_BASE_URL", "https://pos.hengdianfilm.com").rstrip("/")
57
+ POS_TOKEN_COOKIE_NAME = env_str("POS_CASHIER_TOKEN_COOKIE_NAME", "cashier_token") or "cashier_token"
58
+ POS_CASHIER_BASIC_AUTH = env_str("POS_CASHIER_BASIC_AUTH")
59
+ POS_INTERNAL_SELLER_NAME = env_str("POS_CASHIER_INTERNAL_SELLER_NAME", "横店平台") or "横店平台"
60
+
61
+ CONFIG = {
62
+ "domain": env_str("POS_CASHIER_DOMAIN"),
63
+ "login_domain": env_str("POS_CASHIER_LOGIN_DOMAIN"),
64
+ "selling_point": env_str("POS_CASHIER_SELLING_POINT"),
65
+ "lead_minutes": env_int("POS_SEAT_MONITOR_LEAD_MINUTES", 20, 1),
66
+ "cluster_window_minutes": env_int("POS_SEAT_CLUSTER_WINDOW_MINUTES", 1, 0),
67
+ "cluster_threshold": env_int("POS_SEAT_CLUSTER_THRESHOLD", 4, 1),
68
+ "max_seats_per_session": env_int("POS_SEAT_MONITOR_MAX_SEATS", 160, 1),
69
+ "seat_status_path": os.getenv(
70
+ "POS_CASHIER_SEAT_STATUS_PATH",
71
+ "/api/cinema/mobile/cashier/cinemaSeatStatus",
72
+ ).strip(),
73
+ "seat_status_method": os.getenv("POS_CASHIER_SEAT_STATUS_METHOD", "POST").strip().upper() or "POST",
74
+ "use_default_seat_status": env_flag("POS_CASHIER_USE_DEFAULT_SEAT_STATUS", True),
75
+ "seat_api_url_template": os.getenv("POS_CASHIER_SOLD_SEATS_URL_TEMPLATE", "").strip(),
76
+ "seat_api_method": os.getenv("POS_CASHIER_SOLD_SEATS_METHOD", "GET").strip().upper() or "GET",
77
+ "seat_api_body_template": os.getenv("POS_CASHIER_SOLD_SEATS_BODY_TEMPLATE", "").strip(),
78
+ "seat_api_assume_all": env_flag("POS_CASHIER_SOLD_SEATS_ASSUME_ALL", False),
79
+ "refresh_grant_type": os.getenv("POS_CASHIER_REFRESH_GRANT_TYPE", "refresh_token").strip() or "refresh_token",
80
+ "show_business_ids": env_flag("POS_SEAT_MONITOR_SHOW_BUSINESS_IDS", False),
81
+ "show_seat_details": env_flag("POS_SEAT_MONITOR_SHOW_SEAT_DETAILS", True),
82
+ "include_alert_sellers": env_flag("POS_SEAT_MONITOR_ALERT_INCLUDE_SELLERS", True),
83
+ "include_alert_seats": env_flag("POS_SEAT_MONITOR_ALERT_INCLUDE_SEATS", True),
84
+ }
85
+
86
+ WEWORK_BOT_WEBHOOK = env_str("WEWORK_BOT_WEBHOOK")
87
+ MONITOR_RESOURCE_VERSION = "2026-07-10-seller-filter-v7"
88
+
89
+
90
+ def redact_sensitive_text(value: Any) -> str:
91
+ text = str(value)
92
+ replacements = (
93
+ (r"(?i)([?&](?:access_token|refresh_token|token|cashier_token|mobile|code|key)=)[^&\s]+", r"\1***"),
94
+ (r"(?i)(Bearer\s+)[A-Za-z0-9._~+/=-]+", r"\1***"),
95
+ (r"(?i)(Basic\s+)[A-Za-z0-9+/=-]+", r"\1***"),
96
+ (
97
+ r"(?i)(['\"]?(?:access_token|refresh_token|cashier_token|Authorization|Cookie|mobile|code)['\"]?\s*[:=]\s*['\"]?)[^,'\"\s}]+",
98
+ r"\1***",
99
+ ),
100
+ )
101
+ for pattern, replacement in replacements:
102
+ text = re.sub(pattern, replacement, text)
103
+ return text
104
+
105
+
106
+ def get_beijing_now() -> datetime:
107
+ utc_now = datetime.now(timezone.utc)
108
+ return utc_now.astimezone(timezone(timedelta(hours=8))).replace(tzinfo=None)
109
+
110
+
111
+ def get_business_date() -> str:
112
+ now = get_beijing_now()
113
+ if now.time() < dt_time(6, 0):
114
+ return (now - timedelta(days=1)).strftime("%Y-%m-%d")
115
+ return now.strftime("%Y-%m-%d")
116
+
117
+
118
+ def parse_datetime_value(value: Any) -> Optional[datetime]:
119
+ if not value:
120
+ return None
121
+ text = str(value).strip()
122
+ if not text:
123
+ return None
124
+ for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y/%m/%d %H:%M:%S", "%Y/%m/%d %H:%M"):
125
+ try:
126
+ return datetime.strptime(text, fmt)
127
+ except ValueError:
128
+ pass
129
+ parsed = pd.to_datetime(text, errors="coerce")
130
+ if pd.isna(parsed):
131
+ return None
132
+ return parsed.to_pydatetime().replace(tzinfo=None)
133
+
134
+
135
+ def parse_session_start(session: dict, business_date: str) -> Optional[datetime]:
136
+ for key in ("startLocaleDateTime", "start", "startDateTime"):
137
+ parsed = parse_datetime_value(session.get(key))
138
+ if parsed:
139
+ return parsed
140
+
141
+ start_time = str(session.get("startTime") or "").strip()
142
+ if not start_time:
143
+ return None
144
+ try:
145
+ parsed_time = datetime.strptime(start_time, "%H:%M").time()
146
+ base_date = datetime.strptime(str(session.get("businessDate") or business_date), "%Y-%m-%d").date()
147
+ if parsed_time < dt_time(6, 0):
148
+ base_date = base_date + timedelta(days=1)
149
+ return datetime.combine(base_date, parsed_time)
150
+ except Exception:
151
+ return None
152
+
153
+
154
+ def clean_hall_name(raw_name: Any) -> str:
155
+ text = str(raw_name or "").strip()
156
+ match = re.search(r"(\d+)号", text)
157
+ if match:
158
+ return f"{match.group(1)}号厅"
159
+ return text or "未知影厅"
160
+
161
+
162
+ def session_display_name(session: dict, business_date: str) -> str:
163
+ start_dt = parse_session_start(session, business_date)
164
+ start_text = start_dt.strftime("%H:%M") if start_dt else str(session.get("startTime") or "--")
165
+ return f"{clean_hall_name(session.get('hallName'))} {start_text}《{session.get('cineMovieName') or session.get('movieName') or '未知影片'}》"
166
+
167
+
168
+ def render_template(text: str, context: dict, allow_brace_format: bool = True) -> str:
169
+ rendered = Template(text).safe_substitute({k: str(v or "") for k, v in context.items()})
170
+ if allow_brace_format:
171
+ class SafeDict(dict):
172
+ def __missing__(self, key):
173
+ return "{" + key + "}"
174
+
175
+ rendered = rendered.format_map(SafeDict({k: str(v or "") for k, v in context.items()}))
176
+ return rendered
177
+
178
+
179
+ class WeWorkBotPusher:
180
+ def __init__(self, webhook_url: str):
181
+ self.webhook_url = webhook_url
182
+
183
+ def send_text(self, content: str) -> bool:
184
+ if not self.webhook_url:
185
+ return False
186
+ try:
187
+ resp = requests.post(
188
+ self.webhook_url,
189
+ json={"msgtype": "text", "text": {"content": content}},
190
+ headers={"Content-Type": "application/json"},
191
+ timeout=10,
192
+ )
193
+ payload = resp.json()
194
+ return payload.get("errcode") == 0
195
+ except Exception:
196
+ return False
197
+
198
+
199
+ class PosCashierAPI:
200
+ def __init__(self, config: dict, logger):
201
+ self.config = config
202
+ self.logger = logger
203
+ self.last_login_fail = 0.0
204
+ self.token_data = self._read_token_env()
205
+
206
+ @property
207
+ def domain(self) -> str:
208
+ return self.config["domain"]
209
+
210
+ def _default_headers(self, token: Optional[str] = None, json_content: bool = False) -> dict:
211
+ headers = {
212
+ "Accept": "application/json, text/plain, */*",
213
+ "Accept-Language": "zh-CN,zh;q=0.9",
214
+ "Channel": "2",
215
+ "Origin": POS_BASE_URL,
216
+ "Referer": f"{POS_BASE_URL}/index",
217
+ "User-Agent": "Mozilla/5.0",
218
+ "X-Platform": "cashier",
219
+ }
220
+ if token:
221
+ headers["Authorization"] = f"Bearer {token}"
222
+ headers["Cookie"] = f"{POS_TOKEN_COOKIE_NAME}={token}"
223
+ if json_content:
224
+ headers["Content-Type"] = "application/json;charset=UTF-8"
225
+ return headers
226
+
227
+ def _read_token_env(self) -> dict:
228
+ payload = {}
229
+ access_token = env_str("POS_CASHIER_ACCESS_TOKEN") or env_str("POS_CASHIER_TOKEN") or env_str("CASHIER_TOKEN")
230
+ refresh_token = env_str("POS_CASHIER_REFRESH_TOKEN")
231
+ if access_token:
232
+ payload["access_token"] = access_token
233
+ if refresh_token:
234
+ payload["refresh_token"] = refresh_token
235
+
236
+ optional_env_fields = {
237
+ "token_type": "POS_CASHIER_TOKEN_TYPE",
238
+ "expires_at": "POS_CASHIER_TOKEN_EXPIRES_AT",
239
+ "expires_in": "POS_CASHIER_TOKEN_EXPIRES_IN",
240
+ "scope": "POS_CASHIER_TOKEN_SCOPE",
241
+ "client_id": "POS_CASHIER_CLIENT_ID",
242
+ }
243
+ for field, env_name in optional_env_fields.items():
244
+ value = env_str(env_name)
245
+ if value:
246
+ payload[field] = value
247
+ return payload
248
+
249
+ def _read_token_state(self) -> Optional[dict]:
250
+ if self.token_data:
251
+ return dict(self.token_data)
252
+ payload = self._read_token_env()
253
+ self.token_data = dict(payload)
254
+ return payload or None
255
+
256
+ def _write_token_state(self, token_data: dict) -> None:
257
+ existing = self._read_token_state() or {}
258
+ merged = dict(token_data)
259
+ if not merged.get("refresh_token") and existing.get("refresh_token"):
260
+ merged["refresh_token"] = existing["refresh_token"]
261
+ self.token_data = merged
262
+
263
+ def _cache_token_payload(self, payload: dict) -> Optional[str]:
264
+ token = str(payload.get("access_token") or payload.get("token") or "").strip()
265
+ if not token:
266
+ return None
267
+
268
+ try:
269
+ expires_in = int(payload.get("expires_in") or 0)
270
+ except (TypeError, ValueError):
271
+ expires_in = 0
272
+
273
+ payload["saved_at"] = int(time.time())
274
+ if expires_in > 0:
275
+ payload["expires_at"] = int(time.time()) + expires_in
276
+ self._write_token_state(payload)
277
+ return token
278
+
279
+ def load_token(self) -> Optional[str]:
280
+ token_data = self._read_token_state()
281
+ if not token_data:
282
+ return None
283
+ token = str(token_data.get("access_token") or token_data.get("token") or "").strip()
284
+ expires_at = float(token_data.get("expires_at") or 0)
285
+ if expires_at and expires_at < time.time() + 60:
286
+ return None
287
+ return token or None
288
+
289
+ def refresh_access_token(self) -> Optional[str]:
290
+ token_data = self._read_token_state() or {}
291
+ refresh_token = (
292
+ os.getenv("POS_CASHIER_REFRESH_TOKEN", "").strip()
293
+ or str(token_data.get("refresh_token") or "").strip()
294
+ )
295
+ if not refresh_token:
296
+ return None
297
+ if not POS_CASHIER_BASIC_AUTH:
298
+ self.logger("POS Token 自动续期缺少 POS_CASHIER_BASIC_AUTH。")
299
+ return None
300
+ if not self.config["login_domain"] or not self.config["selling_point"]:
301
+ self.logger("POS Token 自动续期缺少 POS_CASHIER_LOGIN_DOMAIN / POS_CASHIER_SELLING_POINT。")
302
+ return None
303
+
304
+ headers = self._default_headers()
305
+ headers.update(
306
+ {
307
+ "Authorization": POS_CASHIER_BASIC_AUTH,
308
+ "Content-Type": "application/x-www-form-urlencoded",
309
+ "Referer": f"{POS_BASE_URL}/index",
310
+ }
311
+ )
312
+ params = {
313
+ "grant_type": self.config["refresh_grant_type"],
314
+ "refresh_token": refresh_token,
315
+ }
316
+ data = {
317
+ "domain": self.config["login_domain"],
318
+ "sellingPoint": self.config["selling_point"],
319
+ }
320
+
321
+ try:
322
+ resp = requests.post(
323
+ f"{POS_BASE_URL}/api/auth/oauth/token",
324
+ params=params,
325
+ data=data,
326
+ headers=headers,
327
+ timeout=15,
328
+ )
329
+ resp.raise_for_status()
330
+ payload = resp.json()
331
+ token = self._cache_token_payload(payload)
332
+ if not token:
333
+ raise RuntimeError(f"刷新 Token 未返回 access_token:{payload}")
334
+ self.logger("POS Token 已通过 refresh_token 自动续期。")
335
+ return token
336
+ except Exception as exc:
337
+ self.logger(f"POS Token 自动续期失败:{exc}")
338
+ return None
339
+
340
+ def login(self) -> Optional[str]:
341
+ if time.time() - self.last_login_fail < 300:
342
+ return None
343
+
344
+ mobile = os.getenv("POS_CASHIER_MOBILE", "").strip()
345
+ code = os.getenv("POS_CASHIER_SMS_CODE", "").strip()
346
+ if not mobile or not code:
347
+ self.logger("POS 登录缺少 POS_CASHIER_MOBILE / POS_CASHIER_SMS_CODE,改用已缓存或手工配置的 token。")
348
+ return None
349
+ if not POS_CASHIER_BASIC_AUTH:
350
+ self.logger("POS 登录缺少 POS_CASHIER_BASIC_AUTH,改用已缓存或手工配置的 token。")
351
+ return None
352
+ if not self.config["login_domain"] or not self.config["selling_point"]:
353
+ self.logger("POS 登录缺少 POS_CASHIER_LOGIN_DOMAIN / POS_CASHIER_SELLING_POINT。")
354
+ return None
355
+
356
+ params = {
357
+ "mobile": f"SMS@{mobile}",
358
+ "code": code,
359
+ "grant_type": os.getenv("POS_CASHIER_GRANT_TYPE", "password").strip() or "password",
360
+ }
361
+ data = {
362
+ "domain": self.config["login_domain"],
363
+ "sellingPoint": self.config["selling_point"],
364
+ }
365
+ headers = self._default_headers()
366
+ headers.update(
367
+ {
368
+ "Authorization": POS_CASHIER_BASIC_AUTH,
369
+ "Content-Type": "application/x-www-form-urlencoded",
370
+ "Referer": f"{POS_BASE_URL}/login?redirect=%2Findex",
371
+ }
372
+ )
373
+
374
+ try:
375
+ resp = requests.post(
376
+ f"{POS_BASE_URL}/api/auth/oauth/token",
377
+ params=params,
378
+ data=data,
379
+ headers=headers,
380
+ timeout=15,
381
+ )
382
+ resp.raise_for_status()
383
+ payload = resp.json()
384
+ token = self._cache_token_payload(payload)
385
+ if not token:
386
+ raise RuntimeError(f"POS 登录未返回 access_token:{payload}")
387
+ self.logger("POS 登录成功,Token 已缓存。")
388
+ return token
389
+ except Exception as exc:
390
+ self.last_login_fail = time.time()
391
+ self.logger(f"POS 登录失败:{exc}")
392
+ return None
393
+
394
+ def get_token(self, force_refresh: bool = False, force_login: bool = False) -> Optional[str]:
395
+ token = None if (force_refresh or force_login) else self.load_token()
396
+ if token:
397
+ return token
398
+ if not force_login:
399
+ refreshed_token = self.refresh_access_token()
400
+ if refreshed_token:
401
+ return refreshed_token
402
+ return self.login()
403
+
404
+ def request_json(
405
+ self,
406
+ method: str,
407
+ path_or_url: str,
408
+ *,
409
+ params: Optional[dict] = None,
410
+ json_body: Optional[dict] = None,
411
+ retry_auth: bool = True,
412
+ ) -> dict:
413
+ token = self.get_token()
414
+ if not token:
415
+ raise RuntimeError("没有可用的 POS Token。")
416
+ if not self.domain:
417
+ raise RuntimeError("缺少 POS_CASHIER_DOMAIN。")
418
+
419
+ url = path_or_url if path_or_url.startswith("http") else f"{POS_BASE_URL}{path_or_url}"
420
+ headers = self._default_headers(token=token, json_content=json_body is not None)
421
+ resp = requests.request(
422
+ method,
423
+ url,
424
+ params=params,
425
+ json=json_body,
426
+ headers=headers,
427
+ timeout=20,
428
+ )
429
+ if resp.status_code in {401, 403} and retry_auth:
430
+ token = self.get_token(force_refresh=True)
431
+ if token:
432
+ headers = self._default_headers(token=token, json_content=json_body is not None)
433
+ resp = requests.request(
434
+ method,
435
+ url,
436
+ params=params,
437
+ json=json_body,
438
+ headers=headers,
439
+ timeout=20,
440
+ )
441
+ resp.raise_for_status()
442
+ return resp.json()
443
+
444
+ def fetch_sessions(self, show_date: str) -> List[dict]:
445
+ payload = self.request_json(
446
+ "GET",
447
+ "/api/cinema/mobile/cashier/cinemaPlays3",
448
+ params={"domain": self.domain, "showDate": show_date, "type": "1"},
449
+ )
450
+ if payload.get("code") != 0:
451
+ raise RuntimeError(f"排片接口返回异常:{payload}")
452
+ return payload.get("data") or []
453
+
454
+ def fetch_seat_order_info(self, play_id: str, seat_id: str) -> Optional[dict]:
455
+ payload = self.request_json(
456
+ "POST",
457
+ "/api/order/order/getDxSeatOrderInfo",
458
+ json_body={"domain": self.domain, "id": str(play_id), "seatIds": [str(seat_id)]},
459
+ )
460
+ if payload.get("code") != 0:
461
+ return None
462
+ data = payload.get("data")
463
+ return data if isinstance(data, dict) else None
464
+
465
+ def fetch_sold_seat_infos(self, session: dict) -> List[dict]:
466
+ env_json = os.getenv("POS_CASHIER_SOLD_SEAT_IDS_JSON", "").strip()
467
+ if env_json:
468
+ try:
469
+ mapping = json.loads(env_json)
470
+ for key in (
471
+ str(session.get("id") or ""),
472
+ f"{session.get('businessDate')}_{session.get('id')}",
473
+ f"{session.get('hallId')}_{session.get('startTime')}",
474
+ ):
475
+ if key and isinstance(mapping, dict) and key in mapping:
476
+ return [{"seat_id": seat_id, "seat": seat_id} for seat_id in normalize_seat_ids(mapping[key])]
477
+ except Exception as exc:
478
+ self.logger(f"解析 POS_CASHIER_SOLD_SEAT_IDS_JSON 失败:{exc}")
479
+
480
+ context = {
481
+ "domain": self.domain,
482
+ "id": session.get("id"),
483
+ "play_id": session.get("id"),
484
+ "hall_id": session.get("hallId"),
485
+ "dx_hall_id": session.get("dxHallId"),
486
+ "show_date": session.get("businessDate"),
487
+ "start_time": session.get("startTime"),
488
+ "movie_id": session.get("movieId"),
489
+ "cine_movie_id": session.get("cineMovieId"),
490
+ }
491
+
492
+ url_template = self.config.get("seat_api_url_template") or ""
493
+ if url_template:
494
+ url = render_template(url_template, context, allow_brace_format=True)
495
+ body_template = self.config.get("seat_api_body_template") or ""
496
+ json_body = None
497
+ if body_template:
498
+ rendered_body = render_template(body_template, context, allow_brace_format=False)
499
+ json_body = json.loads(rendered_body)
500
+
501
+ payload = self.request_json(
502
+ self.config.get("seat_api_method") or "GET",
503
+ url,
504
+ json_body=json_body,
505
+ )
506
+ return extract_sold_seat_infos(payload, assume_all=self.config.get("seat_api_assume_all", False))
507
+
508
+ if not self.config.get("use_default_seat_status", True):
509
+ return []
510
+
511
+ seat_status_path = self.config.get("seat_status_path") or ""
512
+ if not seat_status_path:
513
+ return []
514
+
515
+ method = self.config.get("seat_status_method") or "GET"
516
+ seat_status_payload = {
517
+ "domain": self.domain,
518
+ "cinemaId": str(session.get("cinemaId") or self.domain),
519
+ "hallId": str(session.get("hallId") or ""),
520
+ "dxHallId": str(session.get("dxHallId") or ""),
521
+ "id": str(session.get("id") or ""),
522
+ }
523
+ if method == "GET":
524
+ payload = self.request_json(
525
+ method,
526
+ seat_status_path,
527
+ params=seat_status_payload,
528
+ )
529
+ else:
530
+ payload = self.request_json(
531
+ method,
532
+ seat_status_path,
533
+ json_body=seat_status_payload,
534
+ )
535
+ return extract_sold_seat_infos(payload, assume_all=self.config.get("seat_api_assume_all", False))
536
+
537
+
538
+ def normalize_seat_ids(value: Any) -> List[str]:
539
+ if value is None:
540
+ return []
541
+ if isinstance(value, str):
542
+ raw_items = re.split(r"[,,\s]+", value)
543
+ elif isinstance(value, (list, tuple, set)):
544
+ raw_items = list(value)
545
+ else:
546
+ raw_items = [value]
547
+ result = []
548
+ seen = set()
549
+ for item in raw_items:
550
+ text = str(item).strip()
551
+ if text and text not in seen:
552
+ seen.add(text)
553
+ result.append(text)
554
+ return result
555
+
556
+
557
+ SEAT_ID_FIELDS = (
558
+ "cinemaDxId",
559
+ "cinemaDxSeatId",
560
+ "dxSeatId",
561
+ "dxSeatID",
562
+ "seatId",
563
+ "seatID",
564
+ "seat_id",
565
+ "seatNo",
566
+ "seatNum",
567
+ "seatCode",
568
+ )
569
+ SEAT_NAME_FIELDS = (
570
+ "seat",
571
+ "seatName",
572
+ "seat_name",
573
+ "seatLabel",
574
+ "seatText",
575
+ "seatNoName",
576
+ "name",
577
+ )
578
+ SELL_TIME_FIELDS = (
579
+ "sellTime",
580
+ "soldTime",
581
+ "saleTime",
582
+ "orderTime",
583
+ "payTime",
584
+ "ticketTime",
585
+ "printTime",
586
+ "createTime",
587
+ "createdTime",
588
+ "updateTime",
589
+ )
590
+ STATUS_FIELDS = (
591
+ "playSeatStatus",
592
+ "printStatus",
593
+ "statusName",
594
+ "seatStatusName",
595
+ "saleStatusName",
596
+ "seatStatus",
597
+ "saleStatus",
598
+ "status",
599
+ "isSold",
600
+ "sold",
601
+ "isSale",
602
+ )
603
+ SOLD_TEXT_MARKERS = ("selled", "已售", "售出", "已出票", "锁定", "已锁", "sold", "sale")
604
+ FREE_STATUS_VALUES = {"ok", "free", "empty", "available", "可售", "空闲", "未售"}
605
+
606
+
607
+ def object_looks_sold(obj: dict) -> bool:
608
+ for key in ("isSold", "sold"):
609
+ if obj.get(key) is True:
610
+ return True
611
+
612
+ play_seat_status = str(obj.get("playSeatStatus") or "").strip().lower()
613
+ if play_seat_status:
614
+ if play_seat_status == "selled":
615
+ return True
616
+ if play_seat_status in FREE_STATUS_VALUES:
617
+ return False
618
+
619
+ status_values = []
620
+ for key in STATUS_FIELDS:
621
+ if key in obj and obj.get(key) is not None:
622
+ status_values.append(str(obj.get(key)).strip().lower())
623
+ if not status_values:
624
+ return False
625
+
626
+ status_text = " ".join(status_values)
627
+ if any(marker.lower() in status_text for marker in SOLD_TEXT_MARKERS):
628
+ return True
629
+
630
+ configured_values = {
631
+ item.strip().lower()
632
+ for item in os.getenv("POS_CASHIER_SOLD_STATUS_VALUES", "").split(",")
633
+ if item.strip()
634
+ }
635
+ return bool(configured_values and any(value in configured_values for value in status_values))
636
+
637
+
638
+ def extract_seat_id_from_object(obj: dict) -> Optional[str]:
639
+ for field in SEAT_ID_FIELDS:
640
+ value = obj.get(field)
641
+ if value not in (None, ""):
642
+ return str(value).strip()
643
+
644
+ if "id" in obj:
645
+ keys_text = " ".join(str(key).lower() for key in obj.keys())
646
+ if "seat" in keys_text or any(field in obj for field in STATUS_FIELDS):
647
+ return str(obj.get("id")).strip()
648
+ return None
649
+
650
+
651
+ def extract_seat_name_from_object(obj: dict) -> str:
652
+ for field in SEAT_NAME_FIELDS:
653
+ value = obj.get(field)
654
+ if value not in (None, ""):
655
+ return str(value).strip()
656
+
657
+ row = obj.get("rowName") or obj.get("row") or obj.get("seatRow")
658
+ col = obj.get("colName") or obj.get("columnName") or obj.get("col") or obj.get("seatCol")
659
+ if row not in (None, "") and col not in (None, ""):
660
+ row_text = str(row).strip()
661
+ col_text = str(col).strip()
662
+ if "排" in row_text or "座" in col_text:
663
+ return f"{row_text}{col_text}"
664
+ return f"{row_text}排{col_text}座"
665
+
666
+ seat_id = extract_seat_id_from_object(obj)
667
+ return seat_id or "未知座位"
668
+
669
+
670
+ def extract_sell_time_from_object(obj: dict) -> str:
671
+ for field in SELL_TIME_FIELDS:
672
+ value = obj.get(field)
673
+ if value not in (None, ""):
674
+ return str(value).strip()
675
+ return ""
676
+
677
+
678
+ def extract_sold_seat_ids(payload: Any, assume_all: bool = False) -> List[str]:
679
+ seat_ids = []
680
+
681
+ def walk(node: Any):
682
+ if isinstance(node, dict):
683
+ seat_id = extract_seat_id_from_object(node)
684
+ if seat_id and (assume_all or object_looks_sold(node)):
685
+ seat_ids.append(seat_id)
686
+ for value in node.values():
687
+ walk(value)
688
+ elif isinstance(node, list):
689
+ for value in node:
690
+ if assume_all and isinstance(value, (str, int)):
691
+ seat_ids.append(str(value))
692
+ else:
693
+ walk(value)
694
+
695
+ walk(payload)
696
+ return normalize_seat_ids(seat_ids)
697
+
698
+
699
+ def extract_sold_seat_infos(payload: Any, assume_all: bool = False) -> List[dict]:
700
+ seat_infos = []
701
+
702
+ def walk(node: Any):
703
+ if isinstance(node, dict):
704
+ if assume_all or object_looks_sold(node):
705
+ seat_id = extract_seat_id_from_object(node)
706
+ if seat_id:
707
+ item = dict(node)
708
+ item["seat_id"] = seat_id
709
+ item["seat"] = extract_seat_name_from_object(node)
710
+ item["sellTime"] = extract_sell_time_from_object(node)
711
+ seat_infos.append(item)
712
+ for value in node.values():
713
+ walk(value)
714
+ elif isinstance(node, list):
715
+ for value in node:
716
+ walk(value)
717
+
718
+ walk(payload)
719
+
720
+ result = []
721
+ seen = set()
722
+ for item in seat_infos:
723
+ seat_id = str(item.get("seat_id") or "").strip()
724
+ if not seat_id or seat_id in seen:
725
+ continue
726
+ seen.add(seat_id)
727
+ result.append(item)
728
+ return result
729
+
730
+
731
+ def build_sell_time_clusters(seat_infos: List[dict], window_minutes: int, threshold: int) -> List[List[dict]]:
732
+ enriched = []
733
+ for info in seat_infos:
734
+ sell_dt = parse_datetime_value(info.get("sellTime"))
735
+ if sell_dt:
736
+ item = dict(info)
737
+ item["_sell_dt"] = sell_dt
738
+ enriched.append(item)
739
+
740
+ enriched.sort(key=lambda item: item["_sell_dt"])
741
+ if len(enriched) < threshold:
742
+ return []
743
+
744
+ window_seconds = max(0, window_minutes) * 60
745
+ candidates = []
746
+ for start_index in range(len(enriched)):
747
+ group = []
748
+ first_dt = enriched[start_index]["_sell_dt"]
749
+ for item in enriched[start_index:]:
750
+ if (item["_sell_dt"] - first_dt).total_seconds() <= window_seconds:
751
+ group.append(item)
752
+ else:
753
+ break
754
+ if len(group) >= threshold:
755
+ candidates.append(group)
756
+
757
+ clusters = []
758
+ selected_key_sets = []
759
+ candidates.sort(key=lambda group: (-len(group), group[0]["_sell_dt"]))
760
+ for group in candidates:
761
+ key_set = {
762
+ str(item.get("seat_id") or item.get("seatIds") or item.get("seat") or "")
763
+ for item in group
764
+ if str(item.get("seat_id") or item.get("seatIds") or item.get("seat") or "").strip()
765
+ }
766
+ if not key_set:
767
+ continue
768
+ if any(key_set.issubset(selected) for selected in selected_key_sets):
769
+ continue
770
+ selected_key_sets.append(key_set)
771
+ clusters.append(group)
772
+ clusters.sort(key=lambda group: group[0]["_sell_dt"])
773
+ return clusters
774
+
775
+
776
+ def format_cluster_seats(cluster: List[dict]) -> str:
777
+ parts = []
778
+ for item in cluster:
779
+ seat_name = str(item.get("seat") or item.get("seatName") or item.get("seatIds") or item.get("seat_id") or "未知座位")
780
+ sell_time = str(item.get("sellTime") or "")
781
+ seller = str(item.get("seller") or "").strip()
782
+ time_part = sell_time[-8:] if len(sell_time) >= 8 else sell_time
783
+ if seller:
784
+ parts.append(f"{seat_name}({time_part} {seller})")
785
+ else:
786
+ parts.append(f"{seat_name}({time_part})")
787
+ return "、".join(parts)
788
+
789
+
790
+ def normalize_seller_name(value: Any) -> str:
791
+ return str(value or "").strip()
792
+
793
+
794
+ def cluster_has_non_hengdian_seller(cluster: List[dict]) -> bool:
795
+ for item in cluster:
796
+ seller = normalize_seller_name(item.get("seller"))
797
+ if seller and seller != POS_INTERNAL_SELLER_NAME:
798
+ return True
799
+ return False
800
+
801
+
802
+ def format_cluster_sellers(cluster: List[dict]) -> str:
803
+ sellers = []
804
+ seen = set()
805
+ for item in cluster:
806
+ seller = normalize_seller_name(item.get("seller")) or "未知售票员"
807
+ if seller in seen:
808
+ continue
809
+ seen.add(seller)
810
+ sellers.append(seller)
811
+ return "、".join(sellers)
812
+
813
+
814
+ class SeatClusterMonitor:
815
+ def __init__(self, config: dict):
816
+ self.config = dict(config)
817
+ self.logs = deque(maxlen=80)
818
+ self.status_text = "初始化中"
819
+ self.next_wakeup: Optional[datetime] = None
820
+ self.active_targets: List[str] = []
821
+ self.recent_results: List[dict] = []
822
+ self.daily_schedule_cache: List[dict] = []
823
+ self.current_business_date = ""
824
+ self.processed_checks = set()
825
+ self.alerted_clusters = set()
826
+ self.stats = {
827
+ "checked_sessions": 0,
828
+ "alert_count": 0,
829
+ "api_fails": 0,
830
+ "notify_fails": 0,
831
+ "missing_seat_api": 0,
832
+ }
833
+ self.lock = threading.RLock()
834
+ self.api = PosCashierAPI(self.config, self.log)
835
+ self.pusher = WeWorkBotPusher(WEWORK_BOT_WEBHOOK)
836
+ self.thread = threading.Thread(target=self._run_loop, daemon=True)
837
+ self.thread.start()
838
+
839
+ def log(self, message: str) -> None:
840
+ entry = f"[{get_beijing_now().strftime('%H:%M:%S')}] {redact_sensitive_text(message)}"
841
+ with self.lock:
842
+ self.logs.appendleft(entry)
843
+ print(entry)
844
+
845
+ def snapshot(self) -> dict:
846
+ with self.lock:
847
+ return {
848
+ "logs": list(self.logs),
849
+ "status_text": self.status_text,
850
+ "next_wakeup": self.next_wakeup,
851
+ "active_targets": list(self.active_targets),
852
+ "recent_results": list(self.recent_results),
853
+ "daily_schedule_cache": list(self.daily_schedule_cache),
854
+ "current_business_date": self.current_business_date,
855
+ "stats": dict(self.stats),
856
+ }
857
+
858
+ def get_config(self) -> dict:
859
+ with self.lock:
860
+ return dict(self.config)
861
+
862
+ def update_config(self, updates: dict) -> None:
863
+ allowed_int_fields = {
864
+ "lead_minutes": 1,
865
+ "cluster_window_minutes": 0,
866
+ "cluster_threshold": 1,
867
+ "max_seats_per_session": 1,
868
+ }
869
+ cleaned = {}
870
+ for key, min_value in allowed_int_fields.items():
871
+ if key not in updates:
872
+ continue
873
+ try:
874
+ cleaned[key] = max(min_value, int(updates[key]))
875
+ except (TypeError, ValueError):
876
+ continue
877
+ if not cleaned:
878
+ return
879
+ with self.lock:
880
+ self.config.update(cleaned)
881
+
882
+ def _set_status(self, status: str, next_wakeup: Optional[datetime] = None, active_targets: Optional[List[str]] = None):
883
+ with self.lock:
884
+ self.status_text = status
885
+ self.next_wakeup = next_wakeup
886
+ if active_targets is not None:
887
+ self.active_targets = active_targets
888
+
889
+ def _refresh_schedule(self, business_date: str) -> bool:
890
+ try:
891
+ schedule = self.api.fetch_sessions(business_date)
892
+ except Exception as exc:
893
+ with self.lock:
894
+ self.stats["api_fails"] += 1
895
+ self.log(f"获取 POS 排片失败:{exc}")
896
+ return False
897
+
898
+ with self.lock:
899
+ self.daily_schedule_cache = schedule
900
+ self.current_business_date = business_date
901
+ self.log(f"POS 排片已更新:{business_date} 共 {len(schedule)} 场。")
902
+ return True
903
+
904
+ def _session_check_time(self, session: dict, business_date: str) -> Optional[datetime]:
905
+ start_dt = parse_session_start(session, business_date)
906
+ if not start_dt:
907
+ return None
908
+ return start_dt - timedelta(minutes=self.config["lead_minutes"])
909
+
910
+ def _run_loop(self):
911
+ self.log("售票时间集中监控服务已启动。")
912
+ while True:
913
+ try:
914
+ now = get_beijing_now()
915
+ business_date = get_business_date()
916
+
917
+ need_refresh = (
918
+ not self.daily_schedule_cache
919
+ or self.current_business_date != business_date
920
+ )
921
+ if need_refresh:
922
+ self._set_status("同步排片中", active_targets=[])
923
+ if not self._refresh_schedule(business_date):
924
+ self._set_status("排片同步失败", next_wakeup=now + timedelta(minutes=1))
925
+ time.sleep(60)
926
+ continue
927
+ with self.lock:
928
+ self.processed_checks.clear()
929
+ self.alerted_clusters.clear()
930
+
931
+ schedule = list(self.daily_schedule_cache)
932
+ active_sessions = []
933
+ next_check_time = None
934
+
935
+ for session in schedule:
936
+ play_id = str(session.get("id") or "")
937
+ if not play_id or play_id in self.processed_checks:
938
+ continue
939
+ start_dt = parse_session_start(session, business_date)
940
+ check_time = self._session_check_time(session, business_date)
941
+ if not start_dt or not check_time:
942
+ continue
943
+ if now >= start_dt:
944
+ self.processed_checks.add(play_id)
945
+ continue
946
+ if now >= check_time:
947
+ active_sessions.append(session)
948
+ elif next_check_time is None or check_time < next_check_time:
949
+ next_check_time = check_time
950
+
951
+ if active_sessions:
952
+ labels = [session_display_name(item, business_date) for item in active_sessions[:8]]
953
+ self._set_status("正在检查", next_wakeup=now + timedelta(minutes=1), active_targets=labels)
954
+ self._process_sessions(active_sessions, business_date)
955
+ time.sleep(10)
956
+ continue
957
+
958
+ if next_check_time:
959
+ seconds = max(10, (next_check_time - now).total_seconds())
960
+ self._set_status("休眠中", next_wakeup=next_check_time, active_targets=[])
961
+ time.sleep(min(seconds, 300))
962
+ else:
963
+ self._set_status("今日待检查场次已结束", next_wakeup=now + timedelta(minutes=5), active_targets=[])
964
+ time.sleep(300)
965
+
966
+ except Exception as exc:
967
+ with self.lock:
968
+ self.stats["api_fails"] += 1
969
+ self.log(f"主循环异常:{exc}")
970
+ self._set_status("异常重试中", next_wakeup=get_beijing_now() + timedelta(minutes=1))
971
+ time.sleep(60)
972
+
973
+ def _process_sessions(self, sessions: List[dict], business_date: str) -> None:
974
+ latest_map = {}
975
+ try:
976
+ latest_schedule = self.api.fetch_sessions(business_date)
977
+ latest_map = {str(item.get("id") or ""): item for item in latest_schedule}
978
+ with self.lock:
979
+ self.daily_schedule_cache = latest_schedule
980
+ except Exception as exc:
981
+ with self.lock:
982
+ self.stats["api_fails"] += 1
983
+ self.log(f"检查前刷新 POS 排片失败,沿用缓存:{exc}")
984
+
985
+ for original in sessions:
986
+ play_id = str(original.get("id") or "")
987
+ session = latest_map.get(play_id, original)
988
+ if not play_id or play_id in self.processed_checks:
989
+ continue
990
+ try:
991
+ self._process_one_session(session, business_date)
992
+ finally:
993
+ with self.lock:
994
+ self.processed_checks.add(play_id)
995
+
996
+ def _fill_missing_sell_times(self, play_id: str, seat_infos: List[dict], display_name: str) -> List[dict]:
997
+ result = []
998
+ for item in seat_infos:
999
+ if parse_datetime_value(item.get("sellTime")):
1000
+ result.append(item)
1001
+ continue
1002
+
1003
+ seat_id = str(item.get("seat_id") or "").strip()
1004
+ if not seat_id:
1005
+ result.append(item)
1006
+ continue
1007
+
1008
+ try:
1009
+ detail = self.api.fetch_seat_order_info(play_id, seat_id)
1010
+ if detail:
1011
+ merged = dict(item)
1012
+ merged.update(detail)
1013
+ merged["seat_id"] = seat_id
1014
+ if not merged.get("seat"):
1015
+ merged["seat"] = item.get("seat") or detail.get("seat") or seat_id
1016
+ result.append(merged)
1017
+ else:
1018
+ result.append(item)
1019
+ except Exception as exc:
1020
+ with self.lock:
1021
+ self.stats["api_fails"] += 1
1022
+ self.log(f"{display_name} 补查已售座位 {seat_id} 售出时间失败:{exc}")
1023
+ result.append(item)
1024
+ time.sleep(0.03)
1025
+ return result
1026
+
1027
+ def _process_one_session(self, session: dict, business_date: str) -> None:
1028
+ play_id = str(session.get("id") or "")
1029
+ sold_count = int(session.get("seatSaleNum") or 0)
1030
+ display_name = session_display_name(session, business_date)
1031
+
1032
+ if sold_count < self.config["cluster_threshold"]:
1033
+ self._record_result(session, business_date, "未达到售票张数阈值", sold_count, [])
1034
+ self.log(f"{display_name} 已售 {sold_count} 张,低于阈值。")
1035
+ return
1036
+
1037
+ seat_infos = self.api.fetch_sold_seat_infos(session)
1038
+ if not seat_infos:
1039
+ with self.lock:
1040
+ self.stats["missing_seat_api"] += 1
1041
+ self._record_result(session, business_date, "未解析到已售座位", sold_count, [])
1042
+ self.log(f"{display_name} 已售 {sold_count} 张,但 cinemaSeatStatus 未解析到 playSeatStatus=selled 的座位。")
1043
+ return
1044
+
1045
+ seat_infos = seat_infos[: self.config["max_seats_per_session"]]
1046
+ timed_seat_count = sum(1 for item in seat_infos if parse_datetime_value(item.get("sellTime")))
1047
+ if timed_seat_count < min(len(seat_infos), self.config["cluster_threshold"]):
1048
+ seat_infos = self._fill_missing_sell_times(play_id, seat_infos, display_name)
1049
+ timed_seat_count = sum(1 for item in seat_infos if parse_datetime_value(item.get("sellTime")))
1050
+
1051
+ if timed_seat_count < self.config["cluster_threshold"]:
1052
+ self._record_result(session, business_date, "已售座位缺少售出时间", sold_count, [])
1053
+ self.log(
1054
+ f"{display_name} 解析到 {len(seat_infos)} 个已售座位,"
1055
+ f"但只有 {timed_seat_count} 个带售出时间,无法达到阈值。"
1056
+ )
1057
+ return
1058
+
1059
+ clusters = build_sell_time_clusters(
1060
+ seat_infos,
1061
+ self.config["cluster_window_minutes"],
1062
+ self.config["cluster_threshold"],
1063
+ )
1064
+ with self.lock:
1065
+ self.stats["checked_sessions"] += 1
1066
+
1067
+ if not clusters:
1068
+ self._record_result(session, business_date, "未命中集中售票", sold_count, [])
1069
+ self.log(f"{display_name} 已检查 {len(seat_infos)} 个已售座位,未命中集中售票。")
1070
+ return
1071
+
1072
+ self._record_result(session, business_date, "命中集���售票", sold_count, clusters)
1073
+ for cluster in clusters:
1074
+ alert_key = f"{play_id}|" + "|".join(sorted(str(item.get("seat") or item.get("seat_id") or "") for item in cluster))
1075
+ if alert_key in self.alerted_clusters:
1076
+ continue
1077
+ self.alerted_clusters.add(alert_key)
1078
+ if not cluster_has_non_hengdian_seller(cluster):
1079
+ self.log(
1080
+ f"{display_name} 命中集中售票,但售票员均为横店平台,跳过推送:"
1081
+ f"{format_cluster_seats(cluster)}"
1082
+ )
1083
+ continue
1084
+ self._send_alert(session, business_date, sold_count, cluster)
1085
+
1086
+ def _record_result(self, session: dict, business_date: str, status: str, sold_count: int, clusters: List[List[dict]]):
1087
+ start_dt = parse_session_start(session, business_date)
1088
+ hit_seats = ";".join(format_cluster_seats(cluster) for cluster in clusters)
1089
+ if clusters and not self.config.get("show_seat_details", True):
1090
+ hit_seats = f"已隐藏({sum(len(cluster) for cluster in clusters)} 座)"
1091
+ row = {
1092
+ "检查时间": get_beijing_now().strftime("%Y-%m-%d %H:%M:%S"),
1093
+ "场次": session_display_name(session, business_date),
1094
+ "开场时间": start_dt.strftime("%Y-%m-%d %H:%M") if start_dt else "--",
1095
+ "已售": sold_count,
1096
+ "状态": status,
1097
+ "命中组数": len(clusters),
1098
+ "命中座位": hit_seats,
1099
+ }
1100
+ with self.lock:
1101
+ self.recent_results.insert(0, row)
1102
+ self.recent_results = self.recent_results[:80]
1103
+
1104
+ def _send_alert(self, session: dict, business_date: str, sold_count: int, cluster: List[dict]) -> None:
1105
+ start_dt = parse_session_start(session, business_date)
1106
+ movie_name = session.get("cineMovieName") or session.get("movieName") or "未知影片"
1107
+ hall_name = clean_hall_name(session.get("hallName"))
1108
+ start_text = start_dt.strftime("%Y-%m-%d %H:%M") if start_dt else str(session.get("startTime") or "--")
1109
+ times = [item["_sell_dt"] for item in cluster if item.get("_sell_dt")]
1110
+ if times:
1111
+ window_text = f"{min(times).strftime('%H:%M:%S')} - {max(times).strftime('%H:%M:%S')}"
1112
+ else:
1113
+ window_text = "--"
1114
+ msg_lines = [
1115
+ "发现疑似集中购票",
1116
+ "",
1117
+ f"场次:{start_text}《{movie_name}》",
1118
+ f"影厅:{hall_name}",
1119
+ f"已售:{sold_count} 张",
1120
+ f"命中:{self.config['cluster_window_minutes']} 分钟内 {len(cluster)} 个座位",
1121
+ f"售出时间:{window_text}",
1122
+ ]
1123
+ if self.config.get("include_alert_sellers", True):
1124
+ msg_lines.append(f"售票员:{format_cluster_sellers(cluster)}")
1125
+ if self.config.get("include_alert_seats", True):
1126
+ msg_lines.append(f"座位:{format_cluster_seats(cluster)}")
1127
+ msg = "\n".join(msg_lines)
1128
+ if self.pusher.send_text(msg):
1129
+ with self.lock:
1130
+ self.stats["alert_count"] += 1
1131
+ self.log(f"已推送集中购票告警:{hall_name}《{movie_name}》。")
1132
+ else:
1133
+ with self.lock:
1134
+ self.stats["notify_fails"] += 1
1135
+ self.log(f"集中购票告警发送失败:{hall_name}《{movie_name}》。")
1136
+
1137
+
1138
+ @st.cache_resource
1139
+ def get_monitor(resource_version: str = MONITOR_RESOURCE_VERSION):
1140
+ return SeatClusterMonitor(CONFIG)
1141
+
1142
+
1143
+ def build_schedule_preview(schedule: List[dict], business_date: str, config: dict) -> pd.DataFrame:
1144
+ rows = []
1145
+ now = get_beijing_now()
1146
+ for item in schedule:
1147
+ start_dt = parse_session_start(item, business_date)
1148
+ if not start_dt:
1149
+ continue
1150
+ check_dt = start_dt - timedelta(minutes=int(config.get("lead_minutes") or CONFIG["lead_minutes"]))
1151
+ row = {
1152
+ "检查时间": check_dt.strftime("%H:%M"),
1153
+ "开场": start_dt.strftime("%H:%M"),
1154
+ "影厅": clean_hall_name(item.get("hallName")),
1155
+ "影片": item.get("cineMovieName") or item.get("movieName") or "",
1156
+ "已售": int(item.get("seatSaleNum") or 0),
1157
+ "状态": "已检查/过期" if start_dt <= now else ("待检查" if check_dt > now else "检查窗口内"),
1158
+ }
1159
+ if config.get("show_business_ids"):
1160
+ row["play_id"] = item.get("id")
1161
+ rows.append(row)
1162
+ return pd.DataFrame(rows).sort_values(["检查时间", "开场"]) if rows else pd.DataFrame()
1163
+
1164
+
1165
+ def has_sold_seat_source() -> bool:
1166
+ return bool(
1167
+ CONFIG["seat_api_url_template"]
1168
+ or os.getenv("POS_CASHIER_SOLD_SEAT_IDS_JSON", "").strip()
1169
+ or (CONFIG.get("use_default_seat_status", True) and CONFIG.get("seat_status_path"))
1170
+ )
1171
+
1172
+
1173
+ def sold_seat_source_label() -> str:
1174
+ if CONFIG["seat_api_url_template"]:
1175
+ return "自定义已售座位接口"
1176
+ if os.getenv("POS_CASHIER_SOLD_SEAT_IDS_JSON", "").strip():
1177
+ return "手工 seatIds JSON"
1178
+ if CONFIG.get("use_default_seat_status", True) and CONFIG.get("seat_status_path"):
1179
+ return f"cinemaSeatStatus ({CONFIG.get('seat_status_method', 'GET')})"
1180
+ return "未配置"
1181
+
1182
+
1183
+ def main():
1184
+ monitor = get_monitor()
1185
+ if (
1186
+ not hasattr(monitor, "get_config")
1187
+ or not hasattr(monitor, "update_config")
1188
+ or not hasattr(getattr(monitor, "api", None), "fetch_sold_seat_infos")
1189
+ ):
1190
+ get_monitor.clear()
1191
+ monitor = get_monitor()
1192
+ if st_autorefresh:
1193
+ st_autorefresh(interval=30 * 1000, key="pos_seat_cluster_refresh")
1194
+
1195
+ current_config = monitor.get_config()
1196
+ st.title("🎟️ 售票时间集中监控")
1197
+
1198
+ with st.container():
1199
+ st.subheader("阈值参数")
1200
+ col_a, col_b, col_c = st.columns(3)
1201
+ with col_a:
1202
+ lead_minutes = st.number_input(
1203
+ "开场前检查(分钟)",
1204
+ min_value=1,
1205
+ max_value=180,
1206
+ value=int(current_config.get("lead_minutes") or 20),
1207
+ step=1,
1208
+ help="例如 20 表示开场前 20 分钟开始检查该场次。",
1209
+ key="pos_monitor_lead_minutes",
1210
+ )
1211
+ with col_b:
1212
+ cluster_window_minutes = st.number_input(
1213
+ "售出时间接近阈值(分钟)",
1214
+ min_value=0,
1215
+ max_value=30,
1216
+ value=int(
1217
+ current_config.get("cluster_window_minutes")
1218
+ if current_config.get("cluster_window_minutes") is not None
1219
+ else 1
1220
+ ),
1221
+ step=1,
1222
+ help="例如 5 表示同一批座位售出时间相差不超过 5 分钟;0 表示必须同一秒。",
1223
+ key="pos_monitor_cluster_window_minutes",
1224
+ )
1225
+ with col_c:
1226
+ cluster_threshold = st.number_input(
1227
+ "命中座位数阈值(含)",
1228
+ min_value=1,
1229
+ max_value=50,
1230
+ value=int(current_config.get("cluster_threshold") or 4),
1231
+ step=1,
1232
+ help="例如 4 表示同/近时间售出的座位数大于等于 4 就推送。",
1233
+ key="pos_monitor_cluster_threshold",
1234
+ )
1235
+ monitor.update_config(
1236
+ {
1237
+ "lead_minutes": lead_minutes,
1238
+ "cluster_window_minutes": cluster_window_minutes,
1239
+ "cluster_threshold": cluster_threshold,
1240
+ }
1241
+ )
1242
+
1243
+ active_config = monitor.get_config()
1244
+ domain_label = active_config["domain"] if active_config.get("show_business_ids") else "已隐藏"
1245
+ st.caption(
1246
+ f"影城 domain:{domain_label} | "
1247
+ f"已售座位来源:{sold_seat_source_label()} | "
1248
+ f"企业微信推送:{'已配置' if WEWORK_BOT_WEBHOOK else '未配置'} | "
1249
+ "Token:从环境变量读取,刷新结果仅缓存在当前进程内"
1250
+ )
1251
+
1252
+ snapshot = monitor.snapshot()
1253
+ active_config = monitor.get_config()
1254
+
1255
+ if not has_sold_seat_source():
1256
+ st.warning("还缺少“已售座位 ID 列表/座位图”接口;当前能获取场次和单座详情,但无法自动枚举每场已售 seatIds。")
1257
+
1258
+ c1, c2, c3, c4 = st.columns(4)
1259
+ c1.metric("运行状态", snapshot["status_text"])
1260
+ c2.metric("下次唤醒", snapshot["next_wakeup"].strftime("%H:%M:%S") if snapshot["next_wakeup"] else "--")
1261
+ c3.metric("检查阈值", f"{active_config['cluster_window_minutes']} 分钟 / {active_config['cluster_threshold']} 座")
1262
+ c4.metric("今日告警", snapshot["stats"].get("alert_count", 0))
1263
+
1264
+ tab_live, tab_schedule, tab_api = st.tabs(["运行", "今日场次", "接口状态"])
1265
+
1266
+ with tab_live:
1267
+ col_logs, col_results = st.columns([3, 2])
1268
+ with col_logs:
1269
+ st.subheader("运行日志")
1270
+ st.text_area("logs", "\n".join(snapshot["logs"]), height=520, disabled=True, label_visibility="collapsed")
1271
+ with col_results:
1272
+ st.subheader("当前目标")
1273
+ if snapshot["active_targets"]:
1274
+ for target in snapshot["active_targets"]:
1275
+ st.info(target)
1276
+ else:
1277
+ st.caption("暂无正在检查的场次")
1278
+
1279
+ st.subheader("统计")
1280
+ stats = snapshot["stats"]
1281
+ stats_df = pd.DataFrame(
1282
+ [
1283
+ {"指标": "已检查场次", "数值": stats.get("checked_sessions", 0)},
1284
+ {"指标": "告警次数", "数值": stats.get("alert_count", 0)},
1285
+ {"指标": "接口失败", "数值": stats.get("api_fails", 0)},
1286
+ {"指标": "推送失败", "数值": stats.get("notify_fails", 0)},
1287
+ {"指标": "未解析座位", "数值": stats.get("missing_seat_api", 0)},
1288
+ ]
1289
+ )
1290
+ st.dataframe(stats_df, width="stretch", hide_index=True)
1291
+
1292
+ st.subheader("最近检查结果")
1293
+ if snapshot["recent_results"]:
1294
+ st.dataframe(pd.DataFrame(snapshot["recent_results"]), width="stretch", height=300, hide_index=True)
1295
+ else:
1296
+ st.caption("暂无检查结果")
1297
+
1298
+ with tab_schedule:
1299
+ schedule = snapshot["daily_schedule_cache"]
1300
+ business_date = snapshot["current_business_date"] or get_business_date()
1301
+ c1, c2 = st.columns([1, 4])
1302
+ c1.metric("营业日", business_date)
1303
+ c2.metric("已缓存场次", len(schedule))
1304
+ preview_df = build_schedule_preview(schedule, business_date, active_config)
1305
+ if not preview_df.empty:
1306
+ st.dataframe(preview_df, width="stretch", height=620, hide_index=True)
1307
+ else:
1308
+ st.info("尚未加载到今日场次。")
1309
+
1310
+ with tab_api:
1311
+ used_rows = [
1312
+ {"接口": "auth/oauth/token", "用途": "用环境变量里的 POS token / refresh_token 鉴权;刷新结果只保存在内存,不再写 pos_cashier_token.json。", "状态": "已接入"},
1313
+ {"接口": "cinemaPlays3", "用途": "获取全天场次、play_id、影厅、影片、开场时间、已售张数。", "状态": "已接入"},
1314
+ {"接口": "cinemaSeatStatus", "用途": "一次性获取座位状态;直接筛 playSeatStatus=selled 作为已售座位。", "状态": "已接入"},
1315
+ {"接口": "getDxSeatOrderInfo", "用途": "仅对 selled 座位补查 sellTime;不再扫描全场每个座位。", "状态": "按需补查"},
1316
+ {"接口": "cinemaPlays2", "用途": "按影厅取场次;cinemaPlays3 已包含全天场次,暂时不必用。", "状态": "暂不需要"},
1317
+ ]
1318
+ st.dataframe(pd.DataFrame(used_rows), width="stretch", hide_index=True)
1319
+
1320
+ missing_rows = [
1321
+ {
1322
+ "需要确认": "cinemaSeatStatus 售出时间字段",
1323
+ "原因": "已售判断使用 playSeatStatus=selled;如果 cinemaSeatStatus 不带售出时间,会仅对 selled 座位补查 getDxSeatOrderInfo。",
1324
+ "希望字段": "如果 cinemaSeatStatus 未来能直接返回 sellTime,可省掉补查。",
1325
+ },
1326
+ {
1327
+ "需要确认": "refresh_token 续期实测结果",
1328
+ "原因": "已按标准 OAuth grant_type=refresh_token 接入,并改为从系统变量读取 refresh_token;需要验证 POS refresh_token 能活多久。",
1329
+ "希望字段": "access_token 过期后刷新接口的 curl,或确认当前实现能刷新成功。",
1330
+ },
1331
+ ]
1332
+ st.dataframe(pd.DataFrame(missing_rows), width="stretch", hide_index=True)
1333
+
1334
+ st.caption(
1335
+ "自定义接口仍可通过 POS_CASHIER_SOLD_SEATS_URL_TEMPLATE 覆盖;"
1336
+ "默认流程使用 cinemaSeatStatus 的 playSeatStatus 字段。"
1337
+ )
1338
+
1339
+
1340
+ if __name__ == "__main__":
1341
+ main()
pages/💸 空场防空转监控.py ADDED
@@ -0,0 +1,787 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import requests
3
+ import time
4
+ import json
5
+ import os
6
+ import threading
7
+ import re
8
+ import urllib3
9
+ from datetime import datetime, timedelta, timezone, time as dt_time
10
+ from collections import deque
11
+ from dotenv import load_dotenv
12
+ from tms_proxy import build_tms_url, get_tms_proxy_base_url, tms_verify_ssl, with_tms_proxy_headers
13
+
14
+ # --- 0. 基础配置 ---
15
+ st.set_page_config(page_title="空场防空转监控", page_icon="💸", layout="wide")
16
+
17
+ # 屏蔽 HTTPS 证书警告 (TMS 系统通常使用自签名证书)
18
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
19
+
20
+ try:
21
+ from streamlit_autorefresh import st_autorefresh
22
+ except ImportError:
23
+ st_autorefresh = None
24
+
25
+ # --- 1. 配置与常量 ---
26
+ load_dotenv()
27
+
28
+ # 企业微信机器人配置
29
+ WEWORK_BOT_WEBHOOK = os.getenv("WEWORK_BOT_WEBHOOK")
30
+
31
+ # 影城系统配置
32
+ CINEMA_ID = os.getenv("CINEMA_ID")
33
+ TMS_APP_SECRET = os.getenv("TMS_APP_SECRET")
34
+ TMS_TICKET = os.getenv("TMS_TICKET")
35
+ TMS_X_SESSION_ID = os.getenv("TMS_X_SESSION_ID")
36
+ TMS_THEATER_ID = os.getenv("TMS_THEATER_ID") # 新增:影院ID
37
+
38
+ HALL_ID_MAP = {
39
+ "1": "79181753", "2": "87350725", "3": "93340931",
40
+ "4": "98009245", "5": "02194530", "6": "07183751",
41
+ "7": "11314566", "8": "15532561", "9": "20079450"
42
+ }
43
+
44
+ CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
45
+ ROOT_DIR = os.path.dirname(CURRENT_DIR)
46
+ TOKEN_FILE = os.path.join(ROOT_DIR, 'token_data.json')
47
+
48
+
49
+ # --- 2. 统计与工具类 ---
50
+
51
+ class DailyStats:
52
+ """每日运行数据统计"""
53
+ def __init__(self):
54
+ self.reset()
55
+ self.yesterday_stats = {
56
+ 'zero_sessions': 0,
57
+ 'triggers': 0,
58
+ 'notify_fails': 0,
59
+ 'api_fails': 0,
60
+ 'date': '未知'
61
+ }
62
+
63
+ def reset(self):
64
+ self.zero_sessions = 0 # 0票总场次
65
+ self.triggers = 0 # 触发通知次数
66
+ self.notify_fails = 0 # 发送通知失败次数
67
+ self.api_fails = 0 # API获取失败次数
68
+
69
+ def snapshot_as_yesterday(self, date_str):
70
+ self.yesterday_stats = {
71
+ 'zero_sessions': self.zero_sessions,
72
+ 'triggers': self.triggers,
73
+ 'notify_fails': self.notify_fails,
74
+ 'api_fails': self.api_fails,
75
+ 'date': date_str
76
+ }
77
+ self.reset()
78
+
79
+
80
+ # 全局统计实例
81
+ daily_stats = DailyStats()
82
+
83
+
84
+ def get_beijing_now():
85
+ utc_now = datetime.now(timezone.utc)
86
+ return utc_now.astimezone(timezone(timedelta(hours=8))).replace(tzinfo=None)
87
+
88
+
89
+ def get_business_date():
90
+ now = get_beijing_now()
91
+ if now.time() < dt_time(6, 0):
92
+ return (now - timedelta(days=1)).strftime("%Y-%m-%d")
93
+ return now.strftime("%Y-%m-%d")
94
+
95
+
96
+ def parse_show_datetime(date_str, time_str):
97
+ try:
98
+ show_t = datetime.strptime(time_str, "%H:%M").time()
99
+ base_date = datetime.strptime(date_str, "%Y-%m-%d")
100
+ if show_t < dt_time(6, 0):
101
+ base_date += timedelta(days=1)
102
+ return datetime.combine(base_date.date(), show_t)
103
+ except:
104
+ return None
105
+
106
+
107
+ def extract_hall_number_raw(hall_name):
108
+ """仅提取数字ID,用于API映射"""
109
+ match = re.search(r'(\d+)', str(hall_name))
110
+ return match.group(1) if match else str(hall_name)
111
+
112
+
113
+ def format_hall_name(hall_name):
114
+ """格式化影厅名称:【和成天下1号厅】 -> 1号厅"""
115
+ match = re.search(r'(\d+)号', str(hall_name))
116
+ if match:
117
+ return f"{match.group(1)}号厅"
118
+ return str(hall_name)
119
+
120
+
121
+ # --- 3. 企业微信机器人推送模块 ---
122
+ class WeWorkBotPusher:
123
+ def __init__(self, webhook_url):
124
+ self.webhook_url = webhook_url
125
+
126
+ def send_text(self, content):
127
+ """发送纯文本消息"""
128
+ if not self.webhook_url:
129
+ return
130
+
131
+ headers = {"Content-Type": "application/json"}
132
+ data = {
133
+ "msgtype": "text",
134
+ "text": {
135
+ "content": content
136
+ }
137
+ }
138
+
139
+ try:
140
+ resp = requests.post(self.webhook_url, json=data, headers=headers, timeout=10)
141
+ result = resp.json()
142
+ if result.get("errcode") != 0:
143
+ print(f"❌ 企业微信机器人发送失败: {result}")
144
+ daily_stats.notify_fails += 1
145
+ except Exception as e:
146
+ print(f"❌ 企业微信机器人发送异常: {e}")
147
+ daily_stats.notify_fails += 1
148
+
149
+
150
+ # --- 4. 通知管理系统 ---
151
+ class NotificationManager:
152
+ def __init__(self):
153
+ self.bot = WeWorkBotPusher(WEWORK_BOT_WEBHOOK)
154
+
155
+ def send_idle_alert(self, hall_name, movie_name, show_time, count_info, remark_text=""):
156
+ """发送空转告警 (企业微信机器人)"""
157
+ daily_stats.triggers += 1
158
+
159
+ msg = (
160
+ f"发现 {hall_name} 空场空转!\n\n"
161
+ f"{hall_name} {show_time}《{movie_name}》\n"
162
+ f"无人购票但服务器上有排程,未撤场,或许正在播放,请检查。\n"
163
+ f"{count_info},{remark_text}"
164
+ )
165
+ self.bot.send_text(msg)
166
+
167
+ def send_daily_report(self, today_date_cn):
168
+ """发送每日报告 (企业微信机器人)"""
169
+ y_stats = daily_stats.yesterday_stats
170
+
171
+ msg = (
172
+ f"影城“空转”检查服务就绪\n\n"
173
+ f"{today_date_cn},今日排片数据已加载,开始智能检查。\n\n"
174
+ f"昨日情况:\n"
175
+ f"0票总场次:{y_stats['zero_sessions']}\n"
176
+ f"触发通知次数:{y_stats['triggers']}\n"
177
+ f"发送通知失败次数:{y_stats['notify_fails']}\n"
178
+ f"API获取失败次数:{y_stats['api_fails']}\n"
179
+ f"服务正常运行中。"
180
+ )
181
+ self.bot.send_text(msg)
182
+
183
+ notifier = NotificationManager()
184
+
185
+
186
+ # --- 5. API 管理模块 ---
187
+ class TicketAPIManager:
188
+ def __init__(self, logger_func):
189
+ self.logger = logger_func
190
+ self.last_login_fail = 0
191
+
192
+ def load_token(self):
193
+ if os.path.exists(TOKEN_FILE):
194
+ try:
195
+ with open(TOKEN_FILE, 'r', encoding='utf-8') as f:
196
+ return json.load(f).get('token')
197
+ except:
198
+ pass
199
+ return None
200
+
201
+ def login(self):
202
+ if time.time() - self.last_login_fail < 300: return None
203
+ username = os.getenv("CINEMA_USERNAME")
204
+ password = os.getenv("CINEMA_PASSWORD")
205
+ res_code = os.getenv("CINEMA_RES_CODE")
206
+ device_id = os.getenv("CINEMA_DEVICE_ID")
207
+
208
+ if not all([username, password, res_code]):
209
+ self.logger("❌ 票务系统环境变量缺失")
210
+ daily_stats.api_fails += 1
211
+ return None
212
+
213
+ try:
214
+ session = requests.Session()
215
+ login_url = 'https://app.bi.piao51.cn/cinema-app/credential/login.action'
216
+ login_data = {'username': username, 'password': password, 'type': '1', 'resCode': res_code,
217
+ 'deviceid': device_id, 'dtype': 'ios'}
218
+ session.post(login_url, data=login_data, timeout=15)
219
+ resp = session.get('https://app.bi.piao51.cn/cinema-app/security/logined.action', timeout=10)
220
+ info = resp.json()
221
+ if info.get("success") and info.get("data", {}).get("token"):
222
+ with open(TOKEN_FILE, 'w', encoding='utf-8') as f:
223
+ json.dump(info['data'], f)
224
+ return info['data']['token']
225
+ except Exception as e:
226
+ self.last_login_fail = time.time()
227
+ self.logger(f"❌ 票务登录失败: {e}")
228
+ daily_stats.api_fails += 1
229
+ return None
230
+
231
+ def fetch_schedule(self, date_str):
232
+ token = self.load_token()
233
+ if not token:
234
+ token = self.login()
235
+ if not token:
236
+ daily_stats.api_fails += 1
237
+ return None
238
+
239
+ url = 'https://cawapi.yinghezhong.com/showInfo/getHallShowInfo'
240
+ params = {'showDate': date_str, 'token': token, '_': int(time.time() * 1000)}
241
+ headers = {'User-Agent': 'Mozilla/5.0'}
242
+
243
+ try:
244
+ res = requests.get(url, params=params, headers=headers, timeout=10)
245
+ data = res.json()
246
+ if data.get('code') == 1:
247
+ return data.get('data', [])
248
+ elif data.get('code') == 500:
249
+ token = self.login()
250
+ if token:
251
+ params['token'] = token
252
+ res = requests.get(url, params=params, headers=headers, timeout=10)
253
+ return res.json().get('data', [])
254
+ except Exception as e:
255
+ self.logger(f"⚠️ 票务API异常: {e}")
256
+ daily_stats.api_fails += 1
257
+ return None
258
+
259
+
260
+ class TMSAPIManager:
261
+ def __init__(self, logger_func):
262
+ self.logger = logger_func
263
+ self.auth_token = None
264
+ self.last_token_time = 0
265
+
266
+ def get_token(self):
267
+ if self.auth_token and (time.time() - self.last_token_time < 1800):
268
+ return self.auth_token
269
+
270
+ if not all([TMS_APP_SECRET, TMS_TICKET]): return None
271
+
272
+ # 获取 OA 系统 Token
273
+ tms_proxy_base_url = get_tms_proxy_base_url()
274
+ url = build_tms_url(
275
+ f'https://tms.hengdianfilm.com/cinema-api/admin/generateToken?token=hd&murl=?token=hd&murl=ticket={TMS_TICKET}',
276
+ tms_proxy_base_url,
277
+ )
278
+ headers = {
279
+ 'Accept': 'application/json, text/javascript, */*; q=0.01',
280
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7,zh-TW;q=0.6',
281
+ 'Content-Type': 'application/json',
282
+ 'Cookie': f'JSESSIONID={TMS_X_SESSION_ID}',
283
+ 'DNT': '1',
284
+ 'Origin': 'https://tms.hengdianfilm.com',
285
+ 'Priority': 'u=0, i',
286
+ 'Referer': f'https://tms.hengdianfilm.com/hd/oalogin?ticket={TMS_TICKET}',
287
+ 'Sec-CH-UA': '"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"',
288
+ 'Sec-CH-UA-Mobile': '?0',
289
+ 'Sec-CH-UA-Platform': '"macOS"',
290
+ 'Sec-Fetch-Dest': 'empty',
291
+ 'Sec-Fetch-Mode': 'cors',
292
+ 'Sec-Fetch-Site': 'same-origin',
293
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36',
294
+ 'X-Requested-With': 'XMLHttpRequest',
295
+ }
296
+ headers = with_tms_proxy_headers(headers, tms_proxy_base_url)
297
+ payload = {'appId': 'hd', 'appSecret': TMS_APP_SECRET, 'timeStamp': int(time.time() * 1000)}
298
+
299
+ try:
300
+ res = requests.post(url, json=payload, headers=headers, timeout=10)
301
+ data = res.json()
302
+ if data.get('error_code') == '0000':
303
+ self.auth_token = data['param']
304
+ self.last_token_time = time.time()
305
+ return self.auth_token
306
+ except:
307
+ pass
308
+ daily_stats.api_fails += 1
309
+ return None
310
+
311
+ def fetch_hall_schedule_list(self, hall_number):
312
+ token = self.get_token()
313
+ if not token: return None
314
+
315
+ tms_hall_id = HALL_ID_MAP.get(str(hall_number))
316
+ if not tms_hall_id: return None
317
+
318
+ tms_proxy_base_url = get_tms_proxy_base_url()
319
+ url = build_tms_url('https://tms.hengdianfilm.com/cinema-api/cinema/schedule/server/list', tms_proxy_base_url)
320
+ session_id = TMS_X_SESSION_ID or ''
321
+
322
+ headers = {
323
+ 'Accept': 'application/json, text/javascript, */*; q=0.01',
324
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7,zh-TW;q=0.6',
325
+ 'Content-Type': 'application/json; charset=UTF-8',
326
+ 'Cookie': f'JSESSIONID={session_id}',
327
+ 'DNT': '1',
328
+ 'Origin': 'https://tms.hengdianfilm.com',
329
+ 'Priority': 'u=1, i',
330
+ 'Referer': f'https://tms.hengdianfilm.com/hd/index?CinemaMonitorEdit&HALL_ID%3D{tms_hall_id}',
331
+ 'Sec-CH-UA': '"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"',
332
+ 'Sec-CH-UA-Mobile': '?0',
333
+ 'Sec-CH-UA-Platform': '"macOS"',
334
+ 'Sec-Fetch-Dest': 'empty',
335
+ 'Sec-Fetch-Mode': 'cors',
336
+ 'Sec-Fetch-Site': 'same-origin',
337
+ 'Token': token,
338
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36',
339
+ 'X-Requested-With': 'XMLHttpRequest',
340
+ 'X-SESSIONID': session_id
341
+ }
342
+ headers = with_tms_proxy_headers(headers, tms_proxy_base_url)
343
+ params = {'token': 'hd', 'murl': 'CinemaMonitor'}
344
+ json_data = {
345
+ 'THEATER_ID': 38205954,
346
+ 'STATE': 0,
347
+ 'HALL_ID': tms_hall_id,
348
+ 'START_TIME': int(time.time() * 1000),
349
+ 'PAGE_CAPACITY': 20,
350
+ 'PAGE_INDEX': 1,
351
+ }
352
+
353
+ try:
354
+ res = requests.post(url, params=params, headers=headers, json=json_data, timeout=10,
355
+ verify=tms_verify_ssl(default=False, proxy_url=tms_proxy_base_url))
356
+ data = res.json()
357
+ if data.get("RSPCD") == "000000":
358
+ return data.get("BODY", {}).get("LIST", [])
359
+ else:
360
+ return None
361
+ except Exception as e:
362
+ self.logger(f"⚠️ TMS 请求异常: {e}")
363
+ daily_stats.api_fails += 1
364
+ return None
365
+
366
+ def trigger_schedule_refresh(self):
367
+ """触发 TMS 排期刷新"""
368
+ token = self.get_token()
369
+ if not token or not TMS_THEATER_ID: return
370
+
371
+ try:
372
+ # 动态生成时间戳
373
+ now_tm = time.localtime()
374
+ today_midnight = time.mktime((now_tm.tm_year, now_tm.tm_mon, now_tm.tm_mday, 0, 0, 0, 0, 0, 0))
375
+ start_date = int(today_midnight * 1000)
376
+ end_date = start_date + (24 * 60 * 60 * 1000)
377
+
378
+ tms_proxy_base_url = get_tms_proxy_base_url()
379
+ url = build_tms_url('https://tms.hengdianfilm.com/cinema-api/tms/cmd/show/schedule', tms_proxy_base_url)
380
+ session_id = TMS_X_SESSION_ID or ''
381
+
382
+ headers = {
383
+ 'accept': 'application/json, text/javascript, */*; q=0.01',
384
+ 'content-type': 'application/json; charset=UTF-8',
385
+ 'origin': 'https://tms.hengdianfilm.com',
386
+ 'referer': f'https://tms.hengdianfilm.com/hd/index?Scheduling&THEATER_ID={TMS_THEATER_ID}&DATE={start_date}&PAGE_CAPACITY=20&PAGE_INDEX=1',
387
+ 'token': token, # 这里的 token 由 get_token() 动态获取
388
+ 'user-agent': 'Mozilla/5.0',
389
+ 'x-requested-with': 'XMLHttpRequest',
390
+ 'x-sessionid': session_id
391
+ }
392
+ headers = with_tms_proxy_headers(headers, tms_proxy_base_url)
393
+
394
+ params = {
395
+ 'token': 'hd',
396
+ 'murl': 'Scheduling',
397
+ }
398
+
399
+ cookies = {'JSESSIONID': session_id}
400
+
401
+ json_data = {
402
+ 'THEATER_LIST': [
403
+ {
404
+ 'THEATER_ID': int(TMS_THEATER_ID),
405
+ 'START_DATE': start_date,
406
+ 'END_DATE': end_date,
407
+ },
408
+ ],
409
+ }
410
+
411
+ # 发送请求,非阻塞,忽略证书错误
412
+ requests.post(
413
+ url,
414
+ params=params,
415
+ headers=headers,
416
+ cookies=cookies,
417
+ json=json_data,
418
+ timeout=5,
419
+ verify=tms_verify_ssl(default=False, proxy_url=tms_proxy_base_url),
420
+ )
421
+ self.logger("🔄 TMS 排期刷新指令已发送")
422
+
423
+ except Exception as e:
424
+ # 刷新失败不影响主程序
425
+ self.logger(f"⚠️ TMS 排期刷新失败: {e}")
426
+
427
+
428
+ # --- 6. 智能监控主逻辑 ---
429
+ class PlaybackMonitor:
430
+ def __init__(self):
431
+ self.logs = deque(maxlen=50)
432
+ self.status_text = "初始化中..."
433
+ self.next_wakeup_str = "--:--:--"
434
+ self.active_monitors = []
435
+
436
+ self.cleared_sessions = set()
437
+ self.processed_checks = set()
438
+
439
+ self.ticket_api = TicketAPIManager(self.log)
440
+ self.tms_api = TMSAPIManager(self.log)
441
+
442
+ self.current_business_date = None
443
+ self.daily_schedule_cache = None
444
+ self.last_daily_report_date = None
445
+
446
+ self.thread = threading.Thread(target=self._run_loop, daemon=True)
447
+ self.thread.start()
448
+
449
+ def log(self, msg):
450
+ ts = get_beijing_now().strftime("%H:%M:%S")
451
+ entry = f"[{ts}] {msg}"
452
+ self.logs.appendleft(entry)
453
+ print(entry)
454
+
455
+ def _get_target_check_points(self, schedule_list, business_date):
456
+ check_points = []
457
+ zero_count = 0
458
+ for item in schedule_list:
459
+ sold = int(item.get('soldTicketNum') or 0)
460
+ if sold == 0:
461
+ zero_count += 1
462
+ start_str = item.get('showStartTime')
463
+ if not start_str: continue
464
+
465
+ show_dt = parse_show_datetime(business_date, start_str)
466
+ if not show_dt: continue
467
+
468
+ for i in range(3):
469
+ check_time = show_dt + timedelta(minutes=i * 5)
470
+ check_points.append({
471
+ 'time': check_time,
472
+ 'type': 'CHECK',
473
+ 'check_index': i,
474
+ 'data': item
475
+ })
476
+
477
+ daily_stats.zero_sessions = zero_count
478
+
479
+ check_points.sort(key=lambda x: x['time'])
480
+ return check_points
481
+
482
+ def _run_loop(self):
483
+ self.log("🚀 空场防空转监控服务已启动")
484
+ is_first_run = True
485
+
486
+ while True:
487
+ try:
488
+ now = get_beijing_now()
489
+ biz_date = get_business_date()
490
+ today_str = now.strftime("%Y-%m-%d")
491
+
492
+ if is_first_run:
493
+ if now.time() >= dt_time(9, 0):
494
+ self.last_daily_report_date = today_str
495
+ self.log(f"🟡 首次运行,跳过当日即时通知: {today_str}")
496
+ else:
497
+ self.log("🟡 首次运行完成初始化,等待 09:00 后再发送每日报告")
498
+ is_first_run = False
499
+
500
+ # --- 初始化与日期变更 ---
501
+ need_refresh = False
502
+ if self.daily_schedule_cache is None:
503
+ need_refresh = True
504
+ self.log("🆕 初始化:获取全天排片...")
505
+ self.current_business_date = biz_date
506
+ elif self.current_business_date != biz_date:
507
+ if now.time() >= dt_time(9, 0):
508
+ need_refresh = True
509
+
510
+ daily_stats.snapshot_as_yesterday(self.current_business_date)
511
+ self.cleared_sessions.clear()
512
+ self.processed_checks.clear()
513
+ self.log(f"🌞 新营业日 ({biz_date}):刷新排片...")
514
+
515
+ if need_refresh:
516
+ schedule = self.ticket_api.fetch_schedule(biz_date)
517
+ if schedule:
518
+ self.daily_schedule_cache = schedule
519
+ self.current_business_date = biz_date
520
+ self.log(f"✅ 排片已更新,共 {len(schedule)} 场。")
521
+ else:
522
+ self.log("⚠️ 获取排片失败,5分钟后重试")
523
+ daily_stats.api_fails += 1
524
+ time.sleep(300)
525
+ continue
526
+
527
+ if now.time() >= dt_time(9, 0) and self.last_daily_report_date != today_str and self.daily_schedule_cache is not None:
528
+ try:
529
+ d_obj = datetime.strptime(biz_date, "%Y-%m-%d")
530
+ date_cn = f"{d_obj.year}年{d_obj.month}月{d_obj.day}日"
531
+ except:
532
+ date_cn = biz_date
533
+
534
+ self.log(f"🔔 发送每日统计报告: {date_cn}")
535
+ notifier.send_daily_report(date_cn)
536
+ self.last_daily_report_date = today_str
537
+
538
+ # --- 任务计算 ---
539
+ check_points = self._get_target_check_points(self.daily_schedule_cache, biz_date)
540
+
541
+ next_target = None
542
+ for cp in check_points:
543
+ hall_id = extract_hall_number_raw(cp['data']['hallName'])
544
+ start_str = cp['data']['showStartTime']
545
+ check_idx = cp['check_index']
546
+ dedup_key = f"{biz_date}_{hall_id}_{start_str}_{check_idx}"
547
+
548
+ if dedup_key in self.processed_checks:
549
+ continue
550
+
551
+ if cp['time'] > (now - timedelta(seconds=30)):
552
+ next_target = cp
553
+ break
554
+
555
+ if next_target:
556
+ target_time = next_target['time']
557
+
558
+ # 09:00 拦截器
559
+ nine_am_today = datetime.combine(now.date(), dt_time(9, 0))
560
+ force_wake_for_daily_reset = False
561
+ if now < nine_am_today and target_time > nine_am_today:
562
+ target_time = nine_am_today
563
+ force_wake_for_daily_reset = True
564
+
565
+ sleep_seconds = max(0, (target_time - now).total_seconds())
566
+
567
+ self.status_text = "💤 休眠中"
568
+ self.next_wakeup_str = target_time.strftime('%H:%M:%S')
569
+
570
+ if force_wake_for_daily_reset:
571
+ self.active_monitors = ["等待 09:00 日报刷新..."]
572
+ else:
573
+ raw_hall = next_target['data']['hallName']
574
+ clean_hall = format_hall_name(raw_hall)
575
+ self.active_monitors = [
576
+ f"下个任务: {clean_hall} {next_target['data']['showStartTime']} (第{next_target['check_index'] + 1}次检查)"]
577
+
578
+ self.log(f"💤 智能休眠 {int(sleep_seconds)}秒,将在 {self.next_wakeup_str} 唤醒...")
579
+
580
+ time.sleep(sleep_seconds)
581
+
582
+ if force_wake_for_daily_reset:
583
+ continue
584
+
585
+ # --- 正常唤醒检查 ---
586
+ self.status_text = "🔥 正在检查"
587
+ self.log("⏰ 唤醒!正在同步最新票务数据...")
588
+
589
+ latest_schedule = self.ticket_api.fetch_schedule(biz_date)
590
+ if latest_schedule:
591
+ self.daily_schedule_cache = latest_schedule
592
+ else:
593
+ self.log("⚠️ 同步排片失败,使用旧数据")
594
+ daily_stats.api_fails += 1
595
+ latest_schedule = self.daily_schedule_cache
596
+
597
+ check_now = get_beijing_now()
598
+ current_targets = []
599
+ latest_check_points = self._get_target_check_points(latest_schedule, biz_date)
600
+
601
+ for cp in latest_check_points:
602
+ time_diff = abs((cp['time'] - check_now).total_seconds())
603
+ if time_diff < 120:
604
+ current_targets.append(cp)
605
+
606
+ if current_targets:
607
+ self._process_targets(current_targets, biz_date)
608
+
609
+ time.sleep(5)
610
+
611
+ else:
612
+ self.status_text = "🌙 今日监控结束"
613
+ self.active_monitors = []
614
+ tmr_9am = datetime.combine(datetime.strptime(biz_date, "%Y-%m-%d").date() + timedelta(days=1),
615
+ dt_time(9, 0))
616
+ seconds_to_tmr = (tmr_9am - now).total_seconds()
617
+
618
+ if seconds_to_tmr > 3600:
619
+ self.log("暂无目标,休眠 1 小时...")
620
+ time.sleep(3600)
621
+ else:
622
+ self.log(f"休眠至明日 09:00...")
623
+ time.sleep(seconds_to_tmr)
624
+
625
+ except Exception as e:
626
+ self.log(f"❌ 主循环异常: {e}")
627
+ time.sleep(60)
628
+
629
+ def _process_targets(self, targets, biz_date):
630
+ halls_to_check = {}
631
+ for t in targets:
632
+ hall_id = extract_hall_number_raw(t['data']['hallName'])
633
+ start_str = t['data']['showStartTime']
634
+ check_idx = t['check_index']
635
+
636
+ dedup_key = f"{biz_date}_{hall_id}_{start_str}_{check_idx}"
637
+ if dedup_key in self.processed_checks:
638
+ continue
639
+
640
+ if hall_id not in halls_to_check:
641
+ halls_to_check[hall_id] = []
642
+ halls_to_check[hall_id].append(t)
643
+
644
+ if not halls_to_check: return
645
+
646
+ self.log(f"🔍 核查 {len(halls_to_check)} 个影厅 TMS 状态...")
647
+ display_results = []
648
+
649
+ for hall_id, target_list in halls_to_check.items():
650
+
651
+ all_cleared = True
652
+ for target in target_list:
653
+ start_str = target['data'].get('showStartTime')
654
+ key = f"{biz_date}_{hall_id}_{start_str}"
655
+ if key not in self.cleared_sessions:
656
+ all_cleared = False
657
+ break
658
+
659
+ if all_cleared:
660
+ for target in target_list:
661
+ dedup_key = f"{biz_date}_{hall_id}_{target['data']['showStartTime']}_{target['check_index']}"
662
+ self.processed_checks.add(dedup_key)
663
+
664
+ clean_name = format_hall_name(target_list[0]['data']['hallName'])
665
+ display_results.append(f"{clean_name} ✅ [已跳过 (已确认撤场)]")
666
+ continue
667
+
668
+ tms_list = self.tms_api.fetch_hall_schedule_list(hall_id)
669
+
670
+ for target in target_list:
671
+ session = target['data']
672
+ check_idx = target['check_index']
673
+ hall_name_raw = session.get('hallName')
674
+ movie_name = session.get('movieName')
675
+ ticket_start_str = session.get('showStartTime')
676
+ ticket_start_dt = parse_show_datetime(biz_date, ticket_start_str)
677
+
678
+ unique_key = f"{biz_date}_{hall_id}_{ticket_start_str}"
679
+ dedup_key = f"{biz_date}_{hall_id}_{ticket_start_str}_{check_idx}"
680
+
681
+ if dedup_key in self.processed_checks: continue
682
+ if unique_key in self.cleared_sessions:
683
+ self.processed_checks.add(dedup_key)
684
+ continue
685
+
686
+ hall_name_clean = format_hall_name(hall_name_raw)
687
+ status_desc = f"{hall_name_clean} {ticket_start_str} (第{check_idx + 1}/3次)"
688
+
689
+ if tms_list is not None:
690
+ match_found = False
691
+ for tms_item in tms_list:
692
+ t_start_str = tms_item.get('START_TIME')
693
+ if not t_start_str: continue
694
+ t_start_dt = parse_show_datetime(biz_date, t_start_str)
695
+ if not t_start_dt: continue
696
+ if abs((t_start_dt - ticket_start_dt).total_seconds()) < 1800:
697
+ match_found = True
698
+ break
699
+
700
+ if match_found:
701
+ status_desc += " ⚠️ [空转! TMS未撤]"
702
+ self.log(f"🚨 发现空转: {hall_name_clean}《{movie_name}》")
703
+
704
+ # 调用新的告警接口
705
+ count_info = f"第 {check_idx + 1}/3 次检查"
706
+ remark = "仅停止播放未撤排程下次检查依然会推送通知。"
707
+ notifier.send_idle_alert(hall_name_clean, movie_name, ticket_start_str, count_info, remark)
708
+
709
+ # --- NEW: 第一次发现后,触发TMS刷新 ---
710
+ if check_idx == 0:
711
+ self.tms_api.trigger_schedule_refresh()
712
+
713
+ else:
714
+ status_desc += " ✅ [正常 (TMS无排期)]"
715
+ self.cleared_sessions.add(unique_key)
716
+ else:
717
+ status_desc += " ❓ [TMS查询失败]"
718
+ daily_stats.api_fails += 1
719
+
720
+ self.processed_checks.add(dedup_key)
721
+ display_results.append(status_desc)
722
+
723
+ self.active_monitors = display_results
724
+
725
+
726
+ # --- 7. Streamlit 前端 ---
727
+ @st.cache_resource
728
+ def get_monitor():
729
+ return PlaybackMonitor()
730
+
731
+
732
+ def main():
733
+ monitor = get_monitor()
734
+
735
+ if st_autorefresh:
736
+ st_autorefresh(interval=30 * 1000, key="pb_refresh")
737
+
738
+ st.title("💸 空场防空转监控")
739
+
740
+ remaining_zero_count = 0
741
+ remaining_total_count = 0
742
+ if monitor.daily_schedule_cache:
743
+ now = get_beijing_now()
744
+ biz_date = monitor.current_business_date or get_business_date()
745
+
746
+ for item in monitor.daily_schedule_cache:
747
+ start_str = item.get('showStartTime')
748
+ if not start_str: continue
749
+
750
+ show_dt = parse_show_datetime(biz_date, start_str)
751
+ if show_dt and show_dt > now:
752
+ remaining_total_count += 1
753
+ if int(item.get('soldTicketNum') or 0) == 0:
754
+ remaining_zero_count += 1
755
+
756
+ c1, c2, c3 = st.columns(3)
757
+ with c1:
758
+ st.metric("运行状态", monitor.status_text)
759
+ with c2:
760
+ st.metric("下次唤醒", monitor.next_wakeup_str)
761
+ with c3:
762
+ st.metric("剩余 0 票场次 / 剩余总场次", f"{remaining_zero_count} / {remaining_total_count}")
763
+
764
+ st.divider()
765
+
766
+ col_logs, col_mon = st.columns([3, 2])
767
+
768
+ with col_logs:
769
+ st.subheader("📜 运行日志")
770
+ st.text_area("Logs", "\n".join(list(monitor.logs)), height=450, disabled=True)
771
+
772
+ with col_mon:
773
+ st.subheader("🎯 实时检查结果")
774
+ if monitor.active_monitors:
775
+ for m in monitor.active_monitors:
776
+ if "⚠️" in m:
777
+ st.error(m)
778
+ elif "✅" in m:
779
+ st.success(m)
780
+ else:
781
+ st.info(m)
782
+ else:
783
+ st.caption("暂无检查结果")
784
+
785
+
786
+ if __name__ == "__main__":
787
+ main()
pages/📡 次日排片 TMS 文件核对.py ADDED
@@ -0,0 +1,705 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import re
4
+ import time
5
+ from collections import defaultdict
6
+ from datetime import date, timedelta
7
+
8
+ import requests
9
+ import streamlit as st
10
+ import urllib3
11
+ from dotenv import load_dotenv
12
+ from tms_proxy import build_tms_url, get_tms_proxy_base_url, tms_verify_ssl, with_tms_proxy_headers
13
+
14
+
15
+ st.set_page_config(page_title="次日排片TMS核对(日期范围)", page_icon="📡", layout="wide")
16
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
17
+ load_dotenv()
18
+
19
+ ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
20
+ TOKEN_FILE = os.path.join(ROOT_DIR, "token_data.json")
21
+
22
+
23
+ def load_token():
24
+ if os.path.exists(TOKEN_FILE):
25
+ try:
26
+ with open(TOKEN_FILE, "r", encoding="utf-8") as f:
27
+ return json.load(f)
28
+ except (json.JSONDecodeError, FileNotFoundError):
29
+ return None
30
+ return None
31
+
32
+
33
+ def save_token(token_data):
34
+ try:
35
+ with open(TOKEN_FILE, "w", encoding="utf-8") as f:
36
+ json.dump(token_data, f, ensure_ascii=False, indent=4)
37
+ return True
38
+ except Exception as e:
39
+ st.error(f"保存 Token 失败:{e}")
40
+ return False
41
+
42
+
43
+ def login_and_get_token():
44
+ username = os.getenv("CINEMA_USERNAME")
45
+ password = os.getenv("CINEMA_PASSWORD")
46
+ res_code = os.getenv("CINEMA_RES_CODE")
47
+ device_id = os.getenv("CINEMA_DEVICE_ID")
48
+
49
+ if not all([username, password, res_code]):
50
+ st.error("登录失败:未配置 CINEMA_USERNAME / CINEMA_PASSWORD / CINEMA_RES_CODE。")
51
+ return None
52
+
53
+ session = requests.Session()
54
+ session.headers.update({
55
+ "Host": "app.bi.piao51.cn",
56
+ "Accept": "application/json, text/javascript, */*; q=0.01",
57
+ "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
58
+ })
59
+
60
+ login_url = "https://app.bi.piao51.cn/cinema-app/credential/login.action"
61
+ login_headers = {
62
+ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
63
+ "Origin": "https://app.bi.piao51.cn",
64
+ }
65
+ login_data = {
66
+ "username": username,
67
+ "password": password,
68
+ "type": "1",
69
+ "resCode": res_code,
70
+ "deviceid": device_id,
71
+ "dtype": "ios",
72
+ }
73
+
74
+ try:
75
+ response_login = session.post(login_url, headers=login_headers, data=login_data, allow_redirects=False, timeout=15)
76
+ if not (300 <= response_login.status_code < 400 and "token" in session.cookies):
77
+ st.error(f"登录步骤 1 失败,未能获取 Session Token。状态码:{response_login.status_code}")
78
+ return None
79
+
80
+ user_info_url = "https://app.bi.piao51.cn/cinema-app/security/logined.action"
81
+ response_user_info = session.get(user_info_url, timeout=10)
82
+ response_user_info.raise_for_status()
83
+
84
+ user_info = response_user_info.json()
85
+ if user_info.get("success") and user_info.get("data", {}).get("token"):
86
+ token_data = user_info["data"]
87
+ if save_token(token_data):
88
+ st.toast("登录成功,已获取并保存新 Token。", icon="🔑")
89
+ return token_data
90
+
91
+ st.error(f"登录步骤 2 失败:{user_info.get('msg')}")
92
+ return None
93
+ except requests.exceptions.RequestException as e:
94
+ st.error(f"登录请求过程中发生网络错误:{e}")
95
+ return None
96
+
97
+
98
+ def fetch_hall_info(token):
99
+ url = "https://cawapi.yinghezhong.com/showInfo/getShowHallInfo"
100
+ params = {"token": token, "_": int(time.time() * 1000)}
101
+ headers = {"Origin": "https://caw.yinghezhong.com", "User-Agent": "Mozilla/5.0"}
102
+ response = requests.get(url, params=params, headers=headers, timeout=10)
103
+ response.raise_for_status()
104
+ data = response.json()
105
+ if data.get("code") == 1 and data.get("data"):
106
+ return {item["hallId"]: item["seatNum"] for item in data["data"]}
107
+ raise RuntimeError(f"获取影厅信息失败:{data.get('msg', '未知错误')}")
108
+
109
+
110
+ def fetch_schedule_data(token, show_date):
111
+ url = "https://cawapi.yinghezhong.com/showInfo/getHallShowInfo"
112
+ params = {"showDate": show_date, "token": token, "_": int(time.time() * 1000)}
113
+ headers = {"Origin": "https://caw.yinghezhong.com", "User-Agent": "Mozilla/5.0"}
114
+ response = requests.get(url, params=params, headers=headers, timeout=15)
115
+ response.raise_for_status()
116
+ data = response.json()
117
+ if data.get("code") == 1:
118
+ return data.get("data", [])
119
+ if data.get("code") == 500:
120
+ raise ValueError("Token 可能已失效")
121
+ raise RuntimeError(f"获取排片数据失败:{data.get('msg', '未知错误')}")
122
+
123
+
124
+ def get_api_data_with_token_management(show_date):
125
+ token_data = load_token()
126
+ token = token_data.get("token") if token_data else None
127
+ if not token:
128
+ token_data = login_and_get_token()
129
+ if not token_data:
130
+ return None, None
131
+ token = token_data.get("token")
132
+
133
+ try:
134
+ schedule_list = fetch_schedule_data(token, show_date)
135
+ hall_seat_map = fetch_hall_info(token)
136
+ return schedule_list, hall_seat_map
137
+ except ValueError:
138
+ st.toast("Token 已失效,正在重新登录并重试...", icon="🔄")
139
+ token_data = login_and_get_token()
140
+ if not token_data:
141
+ return None, None
142
+ token = token_data.get("token")
143
+ try:
144
+ schedule_list = fetch_schedule_data(token, show_date)
145
+ hall_seat_map = fetch_hall_info(token)
146
+ return schedule_list, hall_seat_map
147
+ except Exception as e:
148
+ st.error(f"重试获取排片数据失败:{e}")
149
+ return None, None
150
+ except Exception as e:
151
+ st.error(f"获取排片数据时发生错误:{e}")
152
+ return None, None
153
+
154
+
155
+ def _get_tms_env_and_auth():
156
+ """读取 TMS 环境变量并完成 generateToken 认证,返回 (theater_id, x_session_id, ticket, auth_token, proxy_base_url)。"""
157
+ app_secret = os.getenv("TMS_APP_SECRET")
158
+ ticket = os.getenv("TMS_TICKET")
159
+ theater_id_str = os.getenv("TMS_THEATER_ID")
160
+ x_session_id = os.getenv("TMS_X_SESSION_ID")
161
+
162
+ if not all([app_secret, ticket, theater_id_str, x_session_id]):
163
+ raise ValueError("TMS 环境变量不完整,请检查 TMS_APP_SECRET/TMS_TICKET/TMS_THEATER_ID/TMS_X_SESSION_ID")
164
+
165
+ theater_id = int(str(theater_id_str))
166
+ tms_proxy_base_url = get_tms_proxy_base_url()
167
+
168
+ token_headers = {
169
+ "Accept": "application/json, text/javascript, */*; q=0.01",
170
+ "Content-Type": "application/json",
171
+ "Cookie": f"JSESSIONID={x_session_id}",
172
+ "Origin": "https://tms.hengdianfilm.com",
173
+ "Referer": f"https://tms.hengdianfilm.com/hd/oalogin?ticket={ticket}",
174
+ "User-Agent": "Mozilla/5.0",
175
+ "X-Requested-With": "XMLHttpRequest",
176
+ }
177
+ token_json_data = {
178
+ "appId": "hd",
179
+ "appSecret": app_secret,
180
+ "timeStamp": int(time.time() * 1000),
181
+ }
182
+ token_url = build_tms_url(
183
+ f"https://tms.hengdianfilm.com/cinema-api/admin/generateToken?token=hd&murl=?token=hd&murl=ticket={ticket}",
184
+ tms_proxy_base_url,
185
+ )
186
+ token_headers = with_tms_proxy_headers(token_headers, tms_proxy_base_url)
187
+
188
+ token_resp = requests.post(token_url, headers=token_headers, json=token_json_data, timeout=12)
189
+ token_resp.raise_for_status()
190
+ token_data = token_resp.json()
191
+ if token_data.get("error_code") != "0000":
192
+ raise RuntimeError(f"TMS 认证失败: {token_data.get('error_desc')}")
193
+ auth_token = token_data.get("param")
194
+ return theater_id, x_session_id, ticket, auth_token, tms_proxy_base_url
195
+
196
+
197
+ def fetch_tms_hall_status():
198
+ """拉取 TMS 影厅设备列表,返回按厅号排序的列表。STATUS==1 视为在线。"""
199
+ theater_id, x_session_id, ticket, auth_token, tms_proxy_base_url = _get_tms_env_and_auth()
200
+
201
+ list_url = build_tms_url(
202
+ "https://tms.hengdianfilm.com/cinema-api/cinema/hall/list",
203
+ tms_proxy_base_url,
204
+ )
205
+
206
+ halls = []
207
+ page_index = 1
208
+ while True:
209
+ list_headers = {
210
+ "Accept": "application/json, text/javascript, */*; q=0.01",
211
+ "Content-Type": "application/json; charset=UTF-8",
212
+ "Cookie": f"JSESSIONID={x_session_id}",
213
+ "Origin": "https://tms.hengdianfilm.com",
214
+ "Referer": f"https://tms.hengdianfilm.com/hd/index?CinemaListEdit&THEATER_ID={theater_id}",
215
+ "Token": auth_token,
216
+ "User-Agent": "Mozilla/5.0",
217
+ "X-Requested-With": "XMLHttpRequest",
218
+ "X-SESSIONID": x_session_id,
219
+ }
220
+ list_headers = with_tms_proxy_headers(list_headers, tms_proxy_base_url)
221
+ list_params = {"token": "hd", "murl": "CinemaList"}
222
+ list_json = {
223
+ "PAGE_INDEX": page_index,
224
+ "THEATER_ID": str(theater_id),
225
+ "PAGE_CAPACITY": 20,
226
+ }
227
+
228
+ resp = requests.post(
229
+ list_url,
230
+ params=list_params,
231
+ headers=list_headers,
232
+ json=list_json,
233
+ timeout=15,
234
+ verify=tms_verify_ssl(default=False, proxy_url=tms_proxy_base_url),
235
+ )
236
+ resp.raise_for_status()
237
+ data = resp.json()
238
+ if data.get("RSPCD") != "000000":
239
+ raise RuntimeError(f"TMS 影厅列表接口失败: {data.get('RSPMSG')}")
240
+
241
+ body = data.get("BODY", {}) or {}
242
+ items = body.get("LIST", []) or []
243
+ if not items:
244
+ break
245
+ halls.extend(items)
246
+ if len(halls) >= body.get("COUNT", 0):
247
+ break
248
+ page_index += 1
249
+ time.sleep(0.2)
250
+
251
+ halls.sort(key=lambda h: get_hall_sort_key(h.get("NAME") or h.get("OUTER_ID") or ""))
252
+ return halls
253
+
254
+
255
+ def fetch_tms_server_movies_by_hall():
256
+ theater_id, x_session_id, ticket, auth_token, tms_proxy_base_url = _get_tms_env_and_auth()
257
+
258
+ all_movies = []
259
+ page_index = 1
260
+ while True:
261
+ list_headers = {
262
+ "Accept": "application/json, text/javascript, */*; q=0.01",
263
+ "Content-Type": "application/json; charset=UTF-8",
264
+ "Cookie": f"JSESSIONID={x_session_id}",
265
+ "Origin": "https://tms.hengdianfilm.com",
266
+ "Referer": f"https://tms.hengdianfilm.com/hd/index?ContentMovie&THEATER_ID={theater_id}",
267
+ "Token": auth_token,
268
+ "User-Agent": "Mozilla/5.0",
269
+ "X-Requested-With": "XMLHttpRequest",
270
+ "X-SESSIONID": x_session_id,
271
+ }
272
+ list_params = {"token": "hd", "murl": "ContentMovie"}
273
+ list_json = {
274
+ "THEATER_ID": theater_id,
275
+ "SOURCE": "SERVER",
276
+ "ASSERT_TYPE": 2,
277
+ "PAGE_CAPACITY": 20,
278
+ "PAGE_INDEX": page_index,
279
+ }
280
+ list_url = build_tms_url(
281
+ "https://tms.hengdianfilm.com/cinema-api/cinema/server/dcp/list",
282
+ tms_proxy_base_url,
283
+ )
284
+ list_headers = with_tms_proxy_headers(list_headers, tms_proxy_base_url)
285
+
286
+ resp = requests.post(
287
+ list_url,
288
+ params=list_params,
289
+ headers=list_headers,
290
+ json=list_json,
291
+ timeout=15,
292
+ verify=tms_verify_ssl(default=False, proxy_url=tms_proxy_base_url),
293
+ )
294
+ resp.raise_for_status()
295
+ data = resp.json()
296
+ if data.get("RSPCD") != "000000":
297
+ raise RuntimeError(f"TMS 列表接口失败: {data.get('RSPMSG')}")
298
+
299
+ body = data.get("BODY", {})
300
+ items = body.get("LIST", [])
301
+ if not items:
302
+ break
303
+ all_movies.extend(items)
304
+ if len(all_movies) >= body.get("COUNT", 0):
305
+ break
306
+ page_index += 1
307
+ time.sleep(0.3)
308
+
309
+ movie_details = {
310
+ m.get("CONTENT_NAME"): {
311
+ "assert_name": m.get("ASSERT_NAME"),
312
+ "assert_id": m.get("ASSERT_ID"),
313
+ "source_format": m.get("SOURCE_FORMAT"),
314
+ "halls": sorted([h.get("HALL_NAME") for h in m.get("HALL_INFO", [])]),
315
+ }
316
+ for m in all_movies
317
+ if m.get("CONTENT_NAME")
318
+ }
319
+
320
+ by_hall = defaultdict(list)
321
+ for content_name, details in movie_details.items():
322
+ for hall_name in details.get("halls", []):
323
+ by_hall[hall_name].append({"content_name": content_name, "details": details})
324
+
325
+ return dict(by_hall)
326
+
327
+
328
+ def format_movie_display_name(movie_name, movie_language, movie_media_type):
329
+ """统一的影片展示名:``片名 语言 制式``,缺失部分自动跳过。"""
330
+ name = str(movie_name or "").strip() or "未知影片"
331
+ parts = [name]
332
+ language = str(movie_language or "").strip()
333
+ if language:
334
+ parts.append(language)
335
+ media = str(movie_media_type or "").strip()
336
+ if media:
337
+ parts.append(media)
338
+ return " ".join(parts)
339
+
340
+
341
+ def check_tms_file_availability(schedule_list, tms_data, date_str):
342
+ if not schedule_list:
343
+ return {"issue_text": "未获取到排片数据,无法检查。", "issues": []}
344
+ if not tms_data:
345
+ return {"issue_text": "未获取到 TMS 数据,无法检查。", "issues": []}
346
+
347
+ def clean_hall_display_name(raw_name):
348
+ hall_name = str(raw_name or "").strip("【】[] ").strip()
349
+ hall_num_match = re.search(r"(\d+)\s*号", hall_name)
350
+ if hall_num_match:
351
+ return f"{hall_num_match.group(1)}号厅"
352
+ return hall_name
353
+
354
+ def get_hall_key_num(name):
355
+ nums = re.findall(r"\d+", str(name))
356
+ return nums[0] if nums else str(name)
357
+
358
+ def normalize_id_code(value):
359
+ s = str(value or "").strip().upper()
360
+ if not s or s == "NAN":
361
+ return ""
362
+ if re.fullmatch(r"[A-Z0-9]+\.0", s):
363
+ s = s[:-2]
364
+ return re.sub(r"[^A-Z0-9]", "", s)
365
+
366
+ def to_12_digit_movie_num(movie_num):
367
+ s = normalize_id_code(movie_num)
368
+ return s[:12] if len(s) >= 12 else ""
369
+
370
+ def normalize_media_type(media_type):
371
+ v = str(media_type or "").upper()
372
+ if "3D" in v:
373
+ return "3D"
374
+ if "2D" in v:
375
+ return "2D"
376
+ return ""
377
+
378
+ def normalize_source_format(source_format, content_name):
379
+ sf = str(source_format or "").upper()
380
+ cn = str(content_name or "").upper()
381
+ if "3D" in sf or re.search(r"(^|_)FTR-3D([_-]|$)", cn):
382
+ return "3D"
383
+ if "2D" in sf or re.search(r"(^|_)FTR-2D([_-]|$)", cn):
384
+ return "2D"
385
+ return ""
386
+
387
+ def normalize_language(movie_language):
388
+ v = str(movie_language or "").strip().upper()
389
+ if not v:
390
+ return ""
391
+ if "粤" in v or "YUE" in v:
392
+ return "YUE"
393
+ if "英语" in v or "原版" in v or "原声" in v or v == "EN":
394
+ return "EN"
395
+ if "国语" in v or "普通话" in v or "中文" in v or "CMN" in v or "ZH" in v:
396
+ return "CMN"
397
+ return ""
398
+
399
+ def language_match(lang_key, content_name, assert_name):
400
+ if not lang_key:
401
+ return True
402
+ cn = str(content_name or "").upper()
403
+ an = str(assert_name or "")
404
+
405
+ if lang_key == "YUE":
406
+ return bool(re.search(r"(^|_)YUE([-_]|$)", cn)) or ("粤语" in an)
407
+ if lang_key == "EN":
408
+ return bool(re.search(r"(^|_)EN([-_]|$)", cn)) or ("英语" in an) or ("原版" in an) or ("原声" in an)
409
+ if lang_key == "CMN":
410
+ return bool(re.search(r"(^|_)(CMN|ZH)([-_]|$)", cn)) or ("国语" in an) or ("中文" in an)
411
+ return True
412
+
413
+ tms_by_hall = defaultdict(list)
414
+ for hall_name, movies in tms_data.items():
415
+ hall_key = get_hall_key_num(hall_name)
416
+ for movie in movies:
417
+ details = movie.get("details", {}) or {}
418
+ content_name = str(movie.get("content_name") or "")
419
+ assert_name = str(details.get("assert_name") or "")
420
+ assert_id_raw = str(details.get("assert_id") or "")
421
+ assert_id_norm = normalize_id_code(assert_id_raw)
422
+ source_format = str(details.get("source_format") or "")
423
+
424
+ tms_by_hall[hall_key].append({
425
+ "content_name": content_name,
426
+ "assert_name": assert_name,
427
+ "assert_id": assert_id_raw,
428
+ "assert_id_norm": assert_id_norm,
429
+ "assert_12": assert_id_norm[:12] if len(assert_id_norm) >= 12 else "",
430
+ "media": normalize_source_format(source_format, content_name),
431
+ })
432
+
433
+ issue_records = []
434
+ checked = set()
435
+
436
+ def append_issue(issue_type, hall_num, hall_display, movie_name, message, item):
437
+ issue_records.append({
438
+ "issue_type": issue_type,
439
+ "date": date_str,
440
+ "hall_num": str(hall_num),
441
+ "hall_display": str(hall_display),
442
+ "movie_name": str(movie_name),
443
+ "movie_num": str(item.get("movieNum") or ""),
444
+ "movie_language": str(item.get("movieLanguage") or ""),
445
+ "movie_media_type": str(item.get("movieMediaType") or ""),
446
+ "message": message,
447
+ })
448
+
449
+ for item in schedule_list:
450
+ hall_raw = item.get("hallName") or item.get("Hall")
451
+ movie_raw = item.get("movieName") or item.get("Movie")
452
+ if not hall_raw or not movie_raw:
453
+ continue
454
+
455
+ hall_num = get_hall_key_num(hall_raw)
456
+ hall_display = clean_hall_display_name(hall_raw)
457
+ movie_name = str(movie_raw)
458
+ movie_language_raw = str(item.get("movieLanguage") or "").strip()
459
+ movie_media_raw = str(item.get("movieMediaType") or "").strip()
460
+ movie_display = format_movie_display_name(movie_name, movie_language_raw, movie_media_raw)
461
+ movie_num_12 = to_12_digit_movie_num(item.get("movieNum"))
462
+ language_key = normalize_language(item.get("movieLanguage"))
463
+ media_key = normalize_media_type(item.get("movieMediaType"))
464
+
465
+ combo_key = (hall_num, movie_num_12, language_key, media_key, movie_name)
466
+ if combo_key in checked:
467
+ continue
468
+ checked.add(combo_key)
469
+
470
+ hall_candidates = tms_by_hall.get(hall_num, [])
471
+ if not hall_candidates:
472
+ continue
473
+
474
+ coarse_candidates = [c for c in hall_candidates if c.get("assert_12") == movie_num_12] if movie_num_12 else hall_candidates[:]
475
+ if not coarse_candidates:
476
+ append_issue(
477
+ "missing_assert12",
478
+ hall_num,
479
+ hall_display,
480
+ movie_name,
481
+ f"【{hall_display}】《{movie_display}》movieNum={item.get('movieNum')} 未命中同厅 ASSERT_ID 前12位。",
482
+ item,
483
+ )
484
+ continue
485
+
486
+ lang_candidates = coarse_candidates
487
+ if language_key:
488
+ lang_filtered = [
489
+ c for c in coarse_candidates
490
+ if language_match(language_key, c.get("content_name"), c.get("assert_name"))
491
+ ]
492
+ if lang_filtered:
493
+ lang_candidates = lang_filtered
494
+
495
+ media_candidates = lang_candidates
496
+ if media_key:
497
+ media_filtered = [c for c in lang_candidates if c.get("media") == media_key]
498
+ if media_filtered:
499
+ media_candidates = media_filtered
500
+
501
+ id_display_map = {}
502
+ for c in media_candidates:
503
+ assert_id_norm = c.get("assert_id_norm")
504
+ if not assert_id_norm:
505
+ continue
506
+ if assert_id_norm not in id_display_map:
507
+ id_display_map[assert_id_norm] = str(c.get("assert_id") or assert_id_norm).strip().upper()
508
+
509
+ unique_assert_id_norms = sorted(id_display_map.keys())
510
+ if not unique_assert_id_norms:
511
+ append_issue(
512
+ "missing_assert_id",
513
+ hall_num,
514
+ hall_display,
515
+ movie_name,
516
+ f"【{hall_display}】《{movie_display}》已命中前12位,但未找到可确权的 ASSERT_ID(语言={item.get('movieLanguage')},制式={item.get('movieMediaType')})。",
517
+ item,
518
+ )
519
+ continue
520
+
521
+ if len(unique_assert_id_norms) > 1:
522
+ sample_names = " | ".join([c.get("content_name", "") for c in media_candidates[:3]])
523
+ display_ids = [id_display_map[i] for i in unique_assert_id_norms[:5]]
524
+ append_issue(
525
+ "ambiguous_assert_id",
526
+ hall_num,
527
+ hall_display,
528
+ movie_name,
529
+ f"【{hall_display}】《{movie_display}》命中多个 ASSERT_ID({', '.join(display_ids)}),未唯一确权。样本:{sample_names}",
530
+ item,
531
+ )
532
+
533
+ if not issue_records:
534
+ return {"issue_text": None, "issues": []}
535
+
536
+ lines = [f"{idx}. ⚠️ TMS核对警告:{issue['message']}" for idx, issue in enumerate(issue_records, 1)]
537
+ return {"issue_text": "\n".join(lines), "issues": issue_records}
538
+
539
+
540
+ def iter_dates(start_dt: date, end_dt: date):
541
+ d = start_dt
542
+ while d <= end_dt:
543
+ yield d
544
+ d += timedelta(days=1)
545
+
546
+
547
+ def format_short_day(value):
548
+ text = str(value or "").strip()
549
+ if not text:
550
+ return "--"
551
+
552
+ try:
553
+ return date.fromisoformat(text).strftime("%m%d")
554
+ except ValueError:
555
+ return text
556
+
557
+
558
+ def get_hall_sort_key(name):
559
+ nums = re.findall(r"\d+", str(name))
560
+ if nums:
561
+ return 0, int(nums[0]), str(name)
562
+ return 1, 0, str(name)
563
+
564
+
565
+ def build_missing_file_summary(issue_records):
566
+ missing_issue_types = {"missing_assert12", "missing_assert_id"}
567
+ # 以 (片名, 语言, 制式) 作为聚合键,避免不同语言/制式版本被合并到同一条
568
+ hall_movie_dates = defaultdict(lambda: defaultdict(set))
569
+ movie_display_map = defaultdict(dict)
570
+
571
+ for issue in issue_records:
572
+ if issue.get("issue_type") not in missing_issue_types:
573
+ continue
574
+ hall_display = str(issue.get("hall_display") or "未知影厅")
575
+ movie_name = str(issue.get("movie_name") or "未知影片")
576
+ movie_language = str(issue.get("movie_language") or "").strip()
577
+ movie_media_type = str(issue.get("movie_media_type") or "").strip()
578
+ movie_key = (movie_name, movie_language, movie_media_type)
579
+ movie_display_map[hall_display][movie_key] = format_movie_display_name(
580
+ movie_name, movie_language, movie_media_type
581
+ )
582
+
583
+ issue_date = format_short_day(issue.get("date"))
584
+ if issue_date and issue_date != "--":
585
+ hall_movie_dates[hall_display][movie_key].add(issue_date)
586
+ else:
587
+ hall_movie_dates[hall_display][movie_key]
588
+
589
+ summary_lines = []
590
+ for hall_display in sorted(hall_movie_dates.keys(), key=get_hall_sort_key):
591
+ movie_parts = []
592
+ movie_keys_sorted = sorted(
593
+ hall_movie_dates[hall_display].keys(),
594
+ key=lambda k: movie_display_map[hall_display][k],
595
+ )
596
+ for movie_key in movie_keys_sorted:
597
+ display_name = movie_display_map[hall_display][movie_key]
598
+ dates = [day for day in sorted(hall_movie_dates[hall_display][movie_key]) if day and day != "--"]
599
+ if dates:
600
+ movie_parts.append(f"《{display_name}》({'/'.join(dates)})")
601
+ else:
602
+ movie_parts.append(f"《{display_name}》")
603
+ summary_lines.append(f"{hall_display} 缺少{' '.join(movie_parts)}的文件")
604
+
605
+ return summary_lines
606
+
607
+
608
+ st.title("📡 次日排片 TMS 文件核对")
609
+ st.caption("默认从明天开始到未来第5天。点击按钮后自动拉取排程与TMS数据并逐日核对。")
610
+
611
+
612
+ default_start = date.today() + timedelta(days=1)
613
+ default_end = date.today() + timedelta(days=5)
614
+
615
+ c1, c2 = st.columns(2)
616
+ with c1:
617
+ start_date = st.date_input("开始日期", value=default_start, key="tms_check_start_date")
618
+ with c2:
619
+ end_date = st.date_input("结束日期", value=default_end, key="tms_check_end_date")
620
+
621
+ if start_date > end_date:
622
+ st.error("开始日期不能晚于结束日期。")
623
+ else:
624
+ if st.button("开始范围核对", type="primary", key="tms_range_check_btn"):
625
+ with st.spinner("正在连接 TMS 并拉取排程数据..."):
626
+ try:
627
+ tms_hall_data = fetch_tms_server_movies_by_hall()
628
+ except Exception as e:
629
+ st.error(f"获取 TMS 数据失败:{e}")
630
+ st.stop()
631
+
632
+ try:
633
+ tms_hall_status = fetch_tms_hall_status()
634
+ except Exception as e:
635
+ tms_hall_status = None
636
+ st.warning(f"获取 TMS 影厅设备状态失败:{e}")
637
+
638
+ all_results = []
639
+ all_issue_records = []
640
+ issue_total = 0
641
+ for current_date in iter_dates(start_date, end_date):
642
+ date_str = current_date.strftime("%Y-%m-%d")
643
+ schedule_data, _ = get_api_data_with_token_management(date_str)
644
+
645
+ if not schedule_data:
646
+ issue_text = "未获取到排片数据,无法核对。"
647
+ status = "异常"
648
+ issue_total += 1
649
+ else:
650
+ check_result = check_tms_file_availability(schedule_data, tms_hall_data, date_str)
651
+ issue_text_value = check_result.get("issue_text")
652
+ issue_list_value = check_result.get("issues")
653
+ issue_text = issue_text_value if isinstance(issue_text_value, str) else None
654
+ if isinstance(issue_list_value, list):
655
+ all_issue_records.extend(issue_list_value)
656
+ status = "正常" if issue_text is None else "异常"
657
+ if issue_text:
658
+ issue_total += 1
659
+
660
+ all_results.append({
661
+ "日期": date_str,
662
+ "状态": status,
663
+ "问题详情": issue_text,
664
+ })
665
+
666
+ if issue_total == 0:
667
+ st.success(f"✅ 核对完成:{len(all_results)} 天全部正常,无需处理。")
668
+ else:
669
+ st.warning(f"⚠️ 核对完成:共 {len(all_results)} 天,其中 {issue_total} 天存在问题。")
670
+
671
+ if tms_hall_status is not None:
672
+ st.subheader("🖥️ 影厅设备 TMS 在线状态")
673
+ online_halls = [h for h in tms_hall_status if h.get("STATUS") == 1]
674
+ offline_halls = [h for h in tms_hall_status if h.get("STATUS") != 1]
675
+
676
+ m1, m2, m3 = st.columns(3)
677
+ m1.metric("影厅总数", len(tms_hall_status))
678
+ m2.metric("在线", len(online_halls))
679
+ m3.metric("离线", len(offline_halls))
680
+
681
+ if offline_halls:
682
+ offline_names = "、".join(
683
+ str(h.get("NAME") or h.get("OUTER_ID") or "未知厅") for h in offline_halls
684
+ )
685
+ st.error(f"以下影厅 TMS 离线:{offline_names}")
686
+ else:
687
+ st.success("全部影厅 TMS 在线。")
688
+
689
+ missing_summary_lines = build_missing_file_summary(all_issue_records)
690
+ if missing_summary_lines:
691
+ st.subheader("按影厅汇总缺少文件")
692
+ st.caption("仅汇总未命中同厅 ASSERT_ID 前12位,或命中后仍无法找到可确权 ASSERT_ID 的影片。")
693
+ st.code(
694
+ "\n".join([f"{idx}. {line}" for idx, line in enumerate(missing_summary_lines, 1)]),
695
+ language="text",
696
+ )
697
+ elif issue_total > 0:
698
+ st.info("本次异常中未发现可归类为“缺少影片文件”的问题,或异常仅为排片数据缺失/ASSERT_ID 不唯一。")
699
+
700
+ for result in all_results:
701
+ if result["状态"] == "正常":
702
+ st.success(f"{result['日期']}:无问题")
703
+ else:
704
+ st.error(f"{result['日期']}:发现问题")
705
+ st.code(result["问题详情"], language="text")
pages/📢 突发购票监控.py ADDED
@@ -0,0 +1,499 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import requests
3
+ import time
4
+ import json
5
+ import os
6
+ import threading
7
+ import re
8
+ import urllib3
9
+ from datetime import datetime, timedelta, timezone, time as dt_time
10
+ from collections import deque
11
+ from dotenv import load_dotenv
12
+
13
+ # --- 0. 基础配置 ---
14
+ st.set_page_config(page_title="突发购票监控", page_icon="🛡️", layout="wide")
15
+
16
+ # 屏蔽 HTTPS 证书警告
17
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
18
+
19
+ # 尝试导入自动刷新组件
20
+ try:
21
+ from streamlit_autorefresh import st_autorefresh
22
+ except ImportError:
23
+ st_autorefresh = None
24
+
25
+ # --- 1. 配置与常量 ---
26
+ load_dotenv()
27
+
28
+ # 企业微信机器人配置
29
+ WEWORK_BOT_WEBHOOK = os.getenv("WEWORK_BOT_WEBHOOK")
30
+
31
+ # 影城配置
32
+ CINEMA_ID = os.getenv("CINEMA_ID")
33
+
34
+ # 获取 Token 文件路径
35
+ CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
36
+ ROOT_DIR = os.path.dirname(CURRENT_DIR)
37
+ TOKEN_FILE = os.path.join(ROOT_DIR, 'token_data.json')
38
+
39
+
40
+ # --- 2. 工具函数 ---
41
+ def get_beijing_now():
42
+ """获取当前的北京时间"""
43
+ utc_now = datetime.now(timezone.utc)
44
+ beijing_now = utc_now.astimezone(timezone(timedelta(hours=8)))
45
+ return beijing_now.replace(tzinfo=None)
46
+
47
+
48
+ def get_business_date():
49
+ """获取当前的营业日日期"""
50
+ now = get_beijing_now()
51
+ if now.time() < dt_time(6, 0):
52
+ return (now - timedelta(days=1)).strftime("%Y-%m-%d")
53
+ return now.strftime("%Y-%m-%d")
54
+
55
+
56
+ def parse_show_datetime(date_str, time_str):
57
+ """解析排片时间 (处理跨天)"""
58
+ try:
59
+ show_t = datetime.strptime(time_str, "%H:%M").time()
60
+ base_date = datetime.strptime(date_str, "%Y-%m-%d")
61
+ if show_t < dt_time(6, 0):
62
+ base_date += timedelta(days=1)
63
+ return datetime.combine(base_date.date(), show_t)
64
+ except:
65
+ return None
66
+
67
+
68
+ def simplify_hall_name(raw_name):
69
+ """
70
+ 影厅名称简化
71
+ 例如:'【和成天下1号厅】...' -> '1号厅'
72
+ '6号激光厅' -> '6号厅'
73
+ """
74
+ if not raw_name:
75
+ return "未知影厅"
76
+ match = re.search(r'(\d+)号', raw_name)
77
+ if match:
78
+ return f"{match.group(1)}号厅"
79
+ return raw_name
80
+
81
+
82
+ # --- 3. 企业微信机器人推送模块 ---
83
+ class WeWorkBotPusher:
84
+ def __init__(self, webhook_url):
85
+ self.webhook_url = webhook_url
86
+
87
+ def send_text(self, content):
88
+ """发送纯文本消息"""
89
+ if not self.webhook_url:
90
+ return False
91
+
92
+ headers = {"Content-Type": "application/json"}
93
+ data = {
94
+ "msgtype": "text",
95
+ "text": {
96
+ "content": content
97
+ }
98
+ }
99
+
100
+ try:
101
+ resp = requests.post(self.webhook_url, json=data, headers=headers, timeout=10)
102
+ result = resp.json()
103
+ if result.get("errcode") == 0:
104
+ return True
105
+ else:
106
+ print(f"❌ 企业微信机器人发送失败: {result}")
107
+ return False
108
+ except Exception as e:
109
+ print(f"❌ 企业微信机器人发送异常: {e}")
110
+ return False
111
+
112
+
113
+ # --- 4. API 管理模块 ---
114
+ class APIManager:
115
+ def __init__(self, logger):
116
+ self.last_login_fail_time = 0
117
+ self.logger = logger
118
+
119
+ def save_token(self, token_data):
120
+ try:
121
+ with open(TOKEN_FILE, 'w', encoding='utf-8') as f:
122
+ json.dump(token_data, f, ensure_ascii=False, indent=4)
123
+ except:
124
+ pass
125
+
126
+ def load_token(self):
127
+ if os.path.exists(TOKEN_FILE):
128
+ try:
129
+ with open(TOKEN_FILE, 'r', encoding='utf-8') as f:
130
+ return json.load(f).get('token')
131
+ except:
132
+ pass
133
+ return None
134
+
135
+ def login(self):
136
+ if time.time() - self.last_login_fail_time < 600:
137
+ self.logger("⏳ 登录冷却中,跳过重试。")
138
+ return None
139
+
140
+ self.logger("🔄 尝试后台自动登录...")
141
+ username = os.getenv("CINEMA_USERNAME")
142
+ password = os.getenv("CINEMA_PASSWORD")
143
+ res_code = os.getenv("CINEMA_RES_CODE")
144
+ device_id = os.getenv("CINEMA_DEVICE_ID")
145
+
146
+ if not all([username, password, res_code]):
147
+ self.logger("❌ 环境变量缺失,无法自动登录")
148
+ return None
149
+
150
+ session = requests.Session()
151
+ session.headers.update({'User-Agent': 'Mozilla/5.0'})
152
+ login_url = 'https://app.bi.piao51.cn/cinema-app/credential/login.action'
153
+ login_data = {
154
+ 'username': username, 'password': password, 'type': '1',
155
+ 'resCode': res_code, 'deviceid': device_id, 'dtype': 'ios',
156
+ }
157
+
158
+ try:
159
+ session.post(login_url, data=login_data, timeout=15)
160
+ resp = session.get('https://app.bi.piao51.cn/cinema-app/security/logined.action', timeout=10)
161
+ info = resp.json()
162
+ if info.get("success") and info.get("data", {}).get("token"):
163
+ self.save_token(info['data'])
164
+ self.logger("✅ 登录成功。")
165
+ return info['data']['token']
166
+ else:
167
+ raise Exception("未获取到Token")
168
+ except Exception as e:
169
+ self.last_login_fail_time = time.time()
170
+ self.logger(f"❌ 登录失败: {e}")
171
+ return None
172
+
173
+ def fetch_schedule(self, date_str):
174
+ token = self.load_token()
175
+ if not token:
176
+ token = self.login()
177
+ if not token: return None
178
+
179
+ url = 'https://cawapi.yinghezhong.com/showInfo/getHallShowInfo'
180
+ params = {'showDate': date_str, 'token': token, '_': int(time.time() * 1000)}
181
+ headers = {'Origin': 'https://caw.yinghezhong.com', 'User-Agent': 'Mozilla/5.0'}
182
+
183
+ try:
184
+ response = requests.get(url, params=params, headers=headers, timeout=15)
185
+ data = response.json()
186
+ if data.get('code') == 1:
187
+ return data.get('data', [])
188
+ elif data.get('code') == 500:
189
+ self.logger("⚠️ Token失效,重试中...")
190
+ token = self.login()
191
+ if token:
192
+ params['token'] = token
193
+ response = requests.get(url, params=params, headers=headers, timeout=15)
194
+ return response.json().get('data', [])
195
+ return None
196
+ except Exception as e:
197
+ self.logger(f"API请求异常: {e}")
198
+ return None
199
+
200
+
201
+ # --- 5. 监控服务主逻辑 ---
202
+ class CinemaMonitor:
203
+ def __init__(self):
204
+ self.logs = deque(maxlen=50)
205
+ self.status_text = "初始化中..."
206
+ self.next_wakeup = None
207
+ self.monitored_sessions = []
208
+
209
+ self.api = APIManager(self.log)
210
+ self.bot = WeWorkBotPusher(WEWORK_BOT_WEBHOOK)
211
+
212
+ self.current_business_date = None
213
+ self.daily_schedule_cache = None
214
+
215
+ self.zero_ticket_candidates = set()
216
+ self.alerted_sessions = set()
217
+ self.last_daily_report_date = None
218
+
219
+ # 统计数据 (用于每日报告)
220
+ self.stats = {
221
+ "zero_total": 0, # 纳入监控的0票总场次
222
+ "alert_count": 0, # 触发通知次数
223
+ "api_fails": 0, # API获取失败次数
224
+ "notify_fails": 0 # 发送通知失败次数
225
+ }
226
+
227
+ self.thread = threading.Thread(target=self._run_loop, daemon=True)
228
+ self.thread.start()
229
+
230
+ def log(self, msg):
231
+ timestamp = get_beijing_now().strftime("%H:%M:%S")
232
+ entry = f"[{timestamp}] {msg}"
233
+ print(entry)
234
+ self.logs.appendleft(entry)
235
+
236
+ def send_ticket_alert(self, hall_name, movie_name, show_time, ticket_count):
237
+ """
238
+ 发送突发购票告警 (企业微信机器人)
239
+ """
240
+ msg = (
241
+ f"发现 {hall_name} {show_time} 场次有票!\n\n"
242
+ f"{hall_name} {show_time}《{movie_name}》\n"
243
+ f"突增至 {ticket_count} 张\n"
244
+ f"请尽快处理。"
245
+ )
246
+
247
+ if not self.bot.send_text(msg):
248
+ self.stats["notify_fails"] += 1
249
+
250
+ def send_daily_report(self, date_str):
251
+ """
252
+ 发送每日统计报告 (企业微信机器人)
253
+ """
254
+ stats_text_full = (
255
+ f"0票总场次:{self.stats['zero_total']}\n"
256
+ f"触发通知次数:{self.stats['alert_count']}\n"
257
+ f"发送通知失败次数:{self.stats['notify_fails']}\n"
258
+ f"API获取失败次数:{self.stats['api_fails']}"
259
+ )
260
+
261
+ msg = (
262
+ f"0票场次开场前10分钟内突发购票检查就绪\n\n"
263
+ f"{date_str},今日排片数据已加载,开始智能检查。\n\n"
264
+ f"昨日情况:\n{stats_text_full}\n"
265
+ f"服务正常运行中。"
266
+ )
267
+ if not self.bot.send_text(msg):
268
+ self.stats["notify_fails"] += 1
269
+
270
+ def _run_loop(self):
271
+ self.log("🚀 突发购票监控服务已启动")
272
+ is_first_run = True
273
+
274
+ while True:
275
+ try:
276
+ now = get_beijing_now()
277
+ biz_date = get_business_date()
278
+ today_str = now.strftime("%Y-%m-%d")
279
+
280
+ if is_first_run:
281
+ if now.time() >= dt_time(9, 0):
282
+ self.last_daily_report_date = today_str
283
+ self.log(f"🟡 首次运行,跳过当日即时通知: {today_str}")
284
+ else:
285
+ self.log("🟡 首次运行完成初始化,等待 09:00 后再发送每日报告")
286
+ is_first_run = False
287
+
288
+ # --- 每日健康检查 (09:00 后发送一次) ---
289
+ if now.time() >= dt_time(9, 0) and self.last_daily_report_date != today_str:
290
+ report_date_str = now.strftime("%Y年%m月%d日")
291
+ self.log(f"🔔 发送每日统计报告: {today_str}")
292
+
293
+ # 发送报告
294
+ self.send_daily_report(report_date_str)
295
+
296
+ # 重置统计数据 & 更新日期
297
+ self.last_daily_report_date = today_str
298
+ self.stats = {
299
+ "zero_total": 0, "alert_count": 0,
300
+ "api_fails": 0, "notify_fails": 0
301
+ }
302
+
303
+ # 休眠逻辑 (06:00 - 09:30)
304
+ start_check_time = now.replace(hour=9, minute=30, second=0, microsecond=0)
305
+ is_early_morning = (dt_time(6, 0) <= now.time() < dt_time(9, 30))
306
+
307
+ if is_early_morning:
308
+ self.status_text = "非监控时段 (等待 09:30)"
309
+ self.next_wakeup = start_check_time
310
+ self.zero_ticket_candidates.clear()
311
+ self.alerted_sessions.clear()
312
+ time.sleep(60)
313
+ continue
314
+
315
+ # 缓存刷新
316
+ if self.daily_schedule_cache is None or self.current_business_date != biz_date:
317
+ self.log(f"📅 获取 {biz_date} 全天排片...")
318
+ schedule = self.api.fetch_schedule(biz_date)
319
+ if schedule is None:
320
+ self.log("❌ 初始化获取失败,1分钟后重试")
321
+ self.stats["api_fails"] += 1
322
+ time.sleep(60)
323
+ continue
324
+ self.daily_schedule_cache = schedule
325
+ self.current_business_date = biz_date
326
+ self.zero_ticket_candidates.clear()
327
+ self.alerted_sessions.clear()
328
+ self.log(f"✅ 数据已更新,共 {len(schedule)} 场")
329
+
330
+ # 筛选窗口期
331
+ active_check_needed = False
332
+ min_sleep_seconds = 3600
333
+ sessions_in_window = []
334
+ current_schedule = self.daily_schedule_cache
335
+
336
+ for item in current_schedule:
337
+ start_time_str = item.get('showStartTime')
338
+ if not start_time_str: continue
339
+ show_dt = parse_show_datetime(biz_date, start_time_str)
340
+ if not show_dt: continue
341
+
342
+ if now >= show_dt: continue # 已开场
343
+
344
+ monitor_start_dt = show_dt - timedelta(minutes=11)
345
+
346
+ if now >= monitor_start_dt:
347
+ sessions_in_window.append({'data': item, 'dt': show_dt})
348
+ active_check_needed = True
349
+ else:
350
+ seconds_until_window = (monitor_start_dt - now).total_seconds()
351
+ if seconds_until_window < min_sleep_seconds:
352
+ min_sleep_seconds = seconds_until_window
353
+
354
+ # 执行监控
355
+ if active_check_needed:
356
+ self.status_text = "🔥 正在监控"
357
+ self.next_wakeup = now + timedelta(seconds=60)
358
+
359
+ realtime_schedule = self.api.fetch_schedule(biz_date)
360
+ if realtime_schedule is None:
361
+ self.log("⚠️ API请求失败,跳过本次判定")
362
+ self.stats["api_fails"] += 1
363
+ time.sleep(60)
364
+ continue
365
+
366
+ realtime_map = {f"{x.get('hallId')}_{x.get('showStartTime')}": x for x in realtime_schedule}
367
+ display_monitors = []
368
+
369
+ for session in sessions_in_window:
370
+ key = f"{session['data'].get('hallId')}_{session['data'].get('showStartTime')}"
371
+ unique_id = f"{biz_date}_{key}"
372
+
373
+ latest = realtime_map.get(key)
374
+ if not latest: continue
375
+
376
+ movie = latest.get('movieName')
377
+ hall_raw = latest.get('hallName')
378
+ # 影厅名简化
379
+ hall_short = simplify_hall_name(hall_raw)
380
+
381
+ sold = int(latest.get('soldTicketNum') or 0)
382
+ start = latest.get('showStartTime')
383
+
384
+ if sold == 0:
385
+ # 统计新加入的0票场次
386
+ if unique_id not in self.zero_ticket_candidates:
387
+ self.stats["zero_total"] += 1
388
+
389
+ self.zero_ticket_candidates.add(unique_id)
390
+ display_monitors.append(f"👁️ {start} {movie} (0票)")
391
+ else:
392
+ if unique_id in self.zero_ticket_candidates:
393
+ if unique_id not in self.alerted_sessions:
394
+ self.log(f"🚨 突发购票!{hall_short}《{movie}》")
395
+
396
+ # 统计触发次数
397
+ self.stats["alert_count"] += 1
398
+
399
+ # 发送通知 (Pushover + WeChat)
400
+ self.send_ticket_alert(hall_short, movie, start, sold)
401
+
402
+ self.alerted_sessions.add(unique_id)
403
+ self.zero_ticket_candidates.remove(unique_id)
404
+ display_monitors.append(f"✅ {start} {movie} (新售出)")
405
+ else:
406
+ display_monitors.append(f"🛡️ {start} {movie} (原有票)")
407
+
408
+ self.monitored_sessions = display_monitors
409
+ time.sleep(60)
410
+
411
+ else:
412
+ if 0 < min_sleep_seconds < 86400:
413
+ wakeup_dt = now + timedelta(seconds=min_sleep_seconds)
414
+ self.status_text = "💤 休眠中"
415
+ self.next_wakeup = wakeup_dt
416
+ self.monitored_sessions = []
417
+ self.log(f"休眠 {min_sleep_seconds / 60:.1f} 分钟,直到 {wakeup_dt.strftime('%H:%M')}")
418
+ time.sleep(min_sleep_seconds)
419
+ else:
420
+ self.log("今日监控结束,长休眠")
421
+ time.sleep(300)
422
+
423
+ except Exception as e:
424
+ self.log(f"主循环异常: {e}")
425
+ time.sleep(60)
426
+
427
+
428
+ # --- 6. Streamlit 前端 ---
429
+
430
+ @st.cache_resource
431
+ def get_monitor():
432
+ return CinemaMonitor()
433
+
434
+
435
+ def main():
436
+ monitor = get_monitor()
437
+
438
+ # 自动刷新:30秒
439
+ if st_autorefresh:
440
+ st_autorefresh(interval=30 * 1000, key="monitor_refresh")
441
+
442
+ st.title("📢 突发购票监控")
443
+
444
+ c1, c2, c3 = st.columns(3)
445
+ with c1:
446
+ st.metric("运行状态", monitor.status_text)
447
+ with c2:
448
+ wakeup_str = monitor.next_wakeup.strftime("%H:%M:%S") if monitor.next_wakeup else "--"
449
+ st.metric("下次唤醒", wakeup_str)
450
+
451
+ # --- 统计逻辑 ---
452
+ remaining_zero_count = 0
453
+ remaining_total_count = 0
454
+
455
+ if monitor.daily_schedule_cache:
456
+ now = get_beijing_now()
457
+ biz_date = monitor.current_business_date or get_business_date()
458
+
459
+ for item in monitor.daily_schedule_cache:
460
+ start_str = item.get('showStartTime')
461
+ if not start_str: continue
462
+
463
+ # 解析时间
464
+ show_dt = parse_show_datetime(biz_date, start_str)
465
+
466
+ # 只统计“当前时间之后”的场次
467
+ if show_dt and show_dt > now:
468
+ remaining_total_count += 1
469
+ if int(item.get('soldTicketNum') or 0) == 0:
470
+ remaining_zero_count += 1
471
+
472
+ with c3:
473
+ st.metric("剩余 0 票场次 / 剩余总场次", f"{remaining_zero_count} / {remaining_total_count}")
474
+
475
+ st.divider()
476
+
477
+ col_logs, col_list = st.columns([3, 2])
478
+
479
+ with col_logs:
480
+ st.subheader("📜 运行日志")
481
+ log_text = "\n".join(list(monitor.logs))
482
+ st.text_area("Logs", log_text, height=500, disabled=True)
483
+
484
+ with col_list:
485
+ st.subheader("🎯 实时监控列表")
486
+ if monitor.monitored_sessions:
487
+ for s in monitor.monitored_sessions:
488
+ if "✅" in s:
489
+ st.success(s)
490
+ elif "👁️" in s:
491
+ st.error(s)
492
+ else:
493
+ st.info(s)
494
+ else:
495
+ st.caption("暂无监控目标")
496
+
497
+
498
+ if __name__ == "__main__":
499
+ main()
pages/📨 排片检查与 TMS 内容核对监控.py ADDED
@@ -0,0 +1,1153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import re
4
+ import threading
5
+ import time
6
+ from collections import defaultdict, deque
7
+ from datetime import date, datetime, timedelta, timezone, time as dt_time
8
+
9
+ import requests
10
+ import streamlit as st
11
+ import urllib3
12
+ from dotenv import load_dotenv
13
+ from tms_proxy import build_tms_url, get_tms_proxy_base_url, tms_verify_ssl, with_tms_proxy_headers
14
+
15
+ try:
16
+ from streamlit_autorefresh import st_autorefresh
17
+ except ImportError:
18
+ st_autorefresh = None
19
+
20
+
21
+ st.set_page_config(page_title="排片检查与 TMS 内容核对监控", page_icon="📨", layout="wide")
22
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
23
+ load_dotenv()
24
+
25
+ ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
26
+ TOKEN_FILE = os.path.join(ROOT_DIR, "token_data.json")
27
+ STATE_DIR = os.path.join(ROOT_DIR, "cinema_cache")
28
+ STATE_FILE = os.path.join(STATE_DIR, "tms_morning_summary_monitor_state.json")
29
+
30
+ WEWORK_BOT_WEBHOOK = os.getenv("WEWORK_BOT_WEBHOOK")
31
+ WEEKDAY_RANGE_OFFSETS = {
32
+ 0: 3,
33
+ 1: 2,
34
+ 2: 3,
35
+ 3: 3,
36
+ 4: 3,
37
+ 5: 5,
38
+ 6: 4,
39
+ }
40
+ WEEKDAY_NAMES = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
41
+ MISSING_ISSUE_TYPES = {"missing_assert12", "missing_assert_id"}
42
+ MAX_RETRY_ATTEMPTS = 3
43
+ RETRY_DELAY_SECONDS = 300
44
+
45
+
46
+ class RetryableAPIError(RuntimeError):
47
+ pass
48
+
49
+
50
+ def ensure_state_dir():
51
+ os.makedirs(STATE_DIR, exist_ok=True)
52
+
53
+
54
+ def get_beijing_now():
55
+ utc_now = datetime.now(timezone.utc)
56
+ return utc_now.astimezone(timezone(timedelta(hours=8))).replace(tzinfo=None)
57
+
58
+
59
+ def format_datetime_text(dt_obj):
60
+ if not dt_obj:
61
+ return "--"
62
+ return dt_obj.strftime("%Y-%m-%d %H:%M:%S")
63
+
64
+
65
+ def format_short_day(value):
66
+ if isinstance(value, datetime):
67
+ return value.strftime("%m%d")
68
+ if isinstance(value, date):
69
+ return value.strftime("%m%d")
70
+
71
+ text = str(value or "").strip()
72
+ if not text:
73
+ return "--"
74
+
75
+ for fmt in ("%Y-%m-%d", "%m%d"):
76
+ try:
77
+ return datetime.strptime(text, fmt).strftime("%m%d")
78
+ except ValueError:
79
+ continue
80
+ return text
81
+
82
+
83
+ def format_day_with_weekday(day_value):
84
+ if isinstance(day_value, datetime):
85
+ day_obj = day_value.date()
86
+ elif isinstance(day_value, date):
87
+ day_obj = day_value
88
+ else:
89
+ day_obj = datetime.strptime(str(day_value), "%Y-%m-%d").date()
90
+ return f"{day_obj.strftime('%m%d')}({WEEKDAY_NAMES[day_obj.weekday()]})"
91
+
92
+
93
+ def get_check_date_range(base_date=None):
94
+ today = base_date or get_beijing_now().date()
95
+ end_date = today + timedelta(days=WEEKDAY_RANGE_OFFSETS[today.weekday()])
96
+ return today, end_date
97
+
98
+
99
+ def default_monitor_state():
100
+ return {
101
+ "last_completed_date": "",
102
+ "completed_at": "",
103
+ "run_status": "未完成",
104
+ "result_status": "未执行",
105
+ "range_start": "",
106
+ "range_end": "",
107
+ "hall_count": 0,
108
+ "expected_sessions_per_day": 0,
109
+ "min_sessions_threshold": 0,
110
+ "notification_sent": False,
111
+ "summary_text": "",
112
+ "daily_results": [],
113
+ "tms_summary_lines": [],
114
+ "tms_hall_total": 0,
115
+ "tms_hall_online": 0,
116
+ "tms_hall_offline_names": [],
117
+ "failure_message": "",
118
+ }
119
+
120
+
121
+ def load_monitor_state():
122
+ ensure_state_dir()
123
+ state = default_monitor_state()
124
+ if os.path.exists(STATE_FILE):
125
+ try:
126
+ with open(STATE_FILE, "r", encoding="utf-8") as f:
127
+ data = json.load(f)
128
+ if isinstance(data, dict):
129
+ state.update(data)
130
+ except (json.JSONDecodeError, OSError):
131
+ pass
132
+
133
+ legacy_status = state.get("status")
134
+ if not state.get("result_status") and isinstance(legacy_status, str) and legacy_status:
135
+ state["result_status"] = legacy_status
136
+ if not state.get("run_status"):
137
+ state["run_status"] = "未完成"
138
+ if not isinstance(state.get("daily_results"), list):
139
+ state["daily_results"] = []
140
+ if not isinstance(state.get("tms_summary_lines"), list):
141
+ state["tms_summary_lines"] = []
142
+ if not isinstance(state.get("tms_hall_offline_names"), list):
143
+ state["tms_hall_offline_names"] = []
144
+ return state
145
+
146
+
147
+ def save_monitor_state(state):
148
+ ensure_state_dir()
149
+ final_state = default_monitor_state()
150
+ final_state.update(state or {})
151
+ with open(STATE_FILE, "w", encoding="utf-8") as f:
152
+ json.dump(final_state, f, ensure_ascii=False, indent=2)
153
+ return final_state
154
+
155
+
156
+ def load_token():
157
+ if os.path.exists(TOKEN_FILE):
158
+ try:
159
+ with open(TOKEN_FILE, "r", encoding="utf-8") as f:
160
+ return json.load(f)
161
+ except (json.JSONDecodeError, FileNotFoundError):
162
+ return None
163
+ return None
164
+
165
+
166
+ def save_token(token_data):
167
+ try:
168
+ with open(TOKEN_FILE, "w", encoding="utf-8") as f:
169
+ json.dump(token_data, f, ensure_ascii=False, indent=4)
170
+ return True
171
+ except Exception:
172
+ return False
173
+
174
+
175
+ def login_and_get_token():
176
+ username = os.getenv("CINEMA_USERNAME")
177
+ password = os.getenv("CINEMA_PASSWORD")
178
+ res_code = os.getenv("CINEMA_RES_CODE")
179
+ device_id = os.getenv("CINEMA_DEVICE_ID")
180
+
181
+ if not all([username, password, res_code]):
182
+ raise RuntimeError("未配置 CINEMA_USERNAME / CINEMA_PASSWORD / CINEMA_RES_CODE")
183
+
184
+ session = requests.Session()
185
+ session.headers.update({
186
+ "Host": "app.bi.piao51.cn",
187
+ "Accept": "application/json, text/javascript, */*; q=0.01",
188
+ "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
189
+ })
190
+
191
+ login_url = "https://app.bi.piao51.cn/cinema-app/credential/login.action"
192
+ login_headers = {
193
+ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
194
+ "Origin": "https://app.bi.piao51.cn",
195
+ }
196
+ login_data = {
197
+ "username": username,
198
+ "password": password,
199
+ "type": "1",
200
+ "resCode": res_code,
201
+ "deviceid": device_id,
202
+ "dtype": "ios",
203
+ }
204
+
205
+ try:
206
+ response_login = session.post(
207
+ login_url,
208
+ headers=login_headers,
209
+ data=login_data,
210
+ allow_redirects=False,
211
+ timeout=15,
212
+ )
213
+ if not (300 <= response_login.status_code < 400 and "token" in session.cookies):
214
+ raise RetryableAPIError(f"票务登录失败,状态码:{response_login.status_code}")
215
+
216
+ user_info_url = "https://app.bi.piao51.cn/cinema-app/security/logined.action"
217
+ response_user_info = session.get(user_info_url, timeout=10)
218
+ response_user_info.raise_for_status()
219
+ user_info = response_user_info.json()
220
+
221
+ if user_info.get("success") and user_info.get("data", {}).get("token"):
222
+ token_data = user_info["data"]
223
+ save_token(token_data)
224
+ return token_data
225
+
226
+ raise RetryableAPIError(f"票务登录未获取到 Token:{user_info.get('msg')}")
227
+ except RetryableAPIError:
228
+ raise
229
+ except requests.exceptions.RequestException as exc:
230
+ raise RetryableAPIError(f"票务登录接口异常:{exc}") from exc
231
+ except Exception as exc:
232
+ raise RetryableAPIError(f"票务登录处理异常:{exc}") from exc
233
+
234
+
235
+ def get_valid_cinema_token():
236
+ token_data = load_token()
237
+ token = token_data.get("token") if token_data else None
238
+ if token:
239
+ return token
240
+ token_data = login_and_get_token()
241
+ return token_data.get("token") if token_data else None
242
+
243
+
244
+ def fetch_hall_info(token):
245
+ url = "https://cawapi.yinghezhong.com/showInfo/getShowHallInfo"
246
+ params = {"token": token, "_": int(time.time() * 1000)}
247
+ headers = {"Origin": "https://caw.yinghezhong.com", "User-Agent": "Mozilla/5.0"}
248
+
249
+ try:
250
+ response = requests.get(url, params=params, headers=headers, timeout=10)
251
+ response.raise_for_status()
252
+ data = response.json()
253
+ except requests.exceptions.RequestException as exc:
254
+ raise RetryableAPIError(f"获取影厅信息接口异常:{exc}") from exc
255
+ except Exception as exc:
256
+ raise RetryableAPIError(f"获取影厅信息处理异常:{exc}") from exc
257
+
258
+ if data.get("code") == 1 and data.get("data") is not None:
259
+ return {item["hallId"]: item["seatNum"] for item in data["data"]}
260
+ if data.get("code") == 500:
261
+ raise ValueError("Token 可能已失效")
262
+ raise RetryableAPIError(f"获取影厅信息失败:{data.get('msg', '未知错误')}")
263
+
264
+
265
+ def fetch_schedule_data(token, show_date):
266
+ url = "https://cawapi.yinghezhong.com/showInfo/getHallShowInfo"
267
+ params = {"showDate": show_date, "token": token, "_": int(time.time() * 1000)}
268
+ headers = {"Origin": "https://caw.yinghezhong.com", "User-Agent": "Mozilla/5.0"}
269
+
270
+ try:
271
+ response = requests.get(url, params=params, headers=headers, timeout=15)
272
+ response.raise_for_status()
273
+ data = response.json()
274
+ except requests.exceptions.RequestException as exc:
275
+ raise RetryableAPIError(f"获取 {show_date} 排片接口异常:{exc}") from exc
276
+ except Exception as exc:
277
+ raise RetryableAPIError(f"获取 {show_date} 排片处理异常:{exc}") from exc
278
+
279
+ if data.get("code") == 1:
280
+ return data.get("data", [])
281
+ if data.get("code") == 500:
282
+ raise ValueError("Token 可能已失效")
283
+ raise RetryableAPIError(f"获取 {show_date} 排片失败:{data.get('msg', '未知错误')}")
284
+
285
+
286
+ def get_hall_info_with_token_management():
287
+ token = get_valid_cinema_token()
288
+ if not token:
289
+ raise RetryableAPIError("未获取到票务 Token")
290
+
291
+ try:
292
+ return fetch_hall_info(token)
293
+ except ValueError:
294
+ token_data = login_and_get_token()
295
+ token = token_data.get("token") if token_data else None
296
+ if not token:
297
+ raise RetryableAPIError("重新登录后仍未获取到票务 Token")
298
+ return fetch_hall_info(token)
299
+
300
+
301
+ def get_schedule_data_with_token_management(show_date):
302
+ token = get_valid_cinema_token()
303
+ if not token:
304
+ raise RetryableAPIError("未获取到票务 Token")
305
+
306
+ try:
307
+ return fetch_schedule_data(token, show_date)
308
+ except ValueError:
309
+ token_data = login_and_get_token()
310
+ token = token_data.get("token") if token_data else None
311
+ if not token:
312
+ raise RetryableAPIError("重新登录后仍未获取到票务 Token")
313
+ return fetch_schedule_data(token, show_date)
314
+
315
+
316
+ def _get_tms_env_and_auth():
317
+ """读取 TMS 环境变量并完成 generateToken 认证,返回 (theater_id, x_session_id, ticket, auth_token, proxy_base_url)。"""
318
+ app_secret = os.getenv("TMS_APP_SECRET")
319
+ ticket = os.getenv("TMS_TICKET")
320
+ theater_id_str = os.getenv("TMS_THEATER_ID")
321
+ x_session_id = os.getenv("TMS_X_SESSION_ID")
322
+
323
+ if not all([app_secret, ticket, theater_id_str, x_session_id]):
324
+ raise RuntimeError("TMS 环境变量不完整,请检查 TMS_APP_SECRET/TMS_TICKET/TMS_THEATER_ID/TMS_X_SESSION_ID")
325
+
326
+ theater_id = int(str(theater_id_str))
327
+ tms_proxy_base_url = get_tms_proxy_base_url()
328
+ token_headers = {
329
+ "Accept": "application/json, text/javascript, */*; q=0.01",
330
+ "Content-Type": "application/json",
331
+ "Cookie": f"JSESSIONID={x_session_id}",
332
+ "Origin": "https://tms.hengdianfilm.com",
333
+ "Referer": f"https://tms.hengdianfilm.com/hd/oalogin?ticket={ticket}",
334
+ "User-Agent": "Mozilla/5.0",
335
+ "X-Requested-With": "XMLHttpRequest",
336
+ }
337
+ token_json_data = {
338
+ "appId": "hd",
339
+ "appSecret": app_secret,
340
+ "timeStamp": int(time.time() * 1000),
341
+ }
342
+ token_url = build_tms_url(
343
+ f"https://tms.hengdianfilm.com/cinema-api/admin/generateToken?token=hd&murl=?token=hd&murl=ticket={ticket}",
344
+ tms_proxy_base_url,
345
+ )
346
+ token_headers = with_tms_proxy_headers(token_headers, tms_proxy_base_url)
347
+
348
+ try:
349
+ token_resp = requests.post(token_url, headers=token_headers, json=token_json_data, timeout=12)
350
+ token_resp.raise_for_status()
351
+ token_data = token_resp.json()
352
+ except requests.exceptions.RequestException as exc:
353
+ raise RetryableAPIError(f"TMS 认证接口异常:{exc}") from exc
354
+ except Exception as exc:
355
+ raise RetryableAPIError(f"TMS 认证处理异常:{exc}") from exc
356
+
357
+ if token_data.get("error_code") != "0000":
358
+ raise RetryableAPIError(f"TMS 认证失败:{token_data.get('error_desc')}")
359
+ auth_token = token_data.get("param")
360
+ return theater_id, x_session_id, ticket, auth_token, tms_proxy_base_url
361
+
362
+
363
+ def fetch_tms_hall_status():
364
+ """拉取 TMS 影厅设备列表,返回所有影厅记录(包含 STATUS 字段,1 表示在线)。"""
365
+ theater_id, x_session_id, ticket, auth_token, tms_proxy_base_url = _get_tms_env_and_auth()
366
+
367
+ list_url = build_tms_url(
368
+ "https://tms.hengdianfilm.com/cinema-api/cinema/hall/list",
369
+ tms_proxy_base_url,
370
+ )
371
+
372
+ halls = []
373
+ page_index = 1
374
+ while True:
375
+ list_headers = {
376
+ "Accept": "application/json, text/javascript, */*; q=0.01",
377
+ "Content-Type": "application/json; charset=UTF-8",
378
+ "Cookie": f"JSESSIONID={x_session_id}",
379
+ "Origin": "https://tms.hengdianfilm.com",
380
+ "Referer": f"https://tms.hengdianfilm.com/hd/index?CinemaListEdit&THEATER_ID={theater_id}",
381
+ "Token": auth_token,
382
+ "User-Agent": "Mozilla/5.0",
383
+ "X-Requested-With": "XMLHttpRequest",
384
+ "X-SESSIONID": x_session_id,
385
+ }
386
+ list_headers = with_tms_proxy_headers(list_headers, tms_proxy_base_url)
387
+ list_params = {"token": "hd", "murl": "CinemaList"}
388
+ list_json = {
389
+ "PAGE_INDEX": page_index,
390
+ "THEATER_ID": str(theater_id),
391
+ "PAGE_CAPACITY": 20,
392
+ }
393
+
394
+ try:
395
+ resp = requests.post(
396
+ list_url,
397
+ params=list_params,
398
+ headers=list_headers,
399
+ json=list_json,
400
+ timeout=15,
401
+ verify=tms_verify_ssl(default=False, proxy_url=tms_proxy_base_url),
402
+ )
403
+ resp.raise_for_status()
404
+ data = resp.json()
405
+ except requests.exceptions.RequestException as exc:
406
+ raise RetryableAPIError(f"TMS 影厅列表接口异常:{exc}") from exc
407
+ except Exception as exc:
408
+ raise RetryableAPIError(f"TMS 影厅列表处理异常:{exc}") from exc
409
+
410
+ if data.get("RSPCD") != "000000":
411
+ raise RetryableAPIError(f"TMS 影厅列表接口失败:{data.get('RSPMSG')}")
412
+
413
+ body = data.get("BODY", {}) or {}
414
+ items = body.get("LIST", []) or []
415
+ if not items:
416
+ break
417
+ halls.extend(items)
418
+ if len(halls) >= body.get("COUNT", 0):
419
+ break
420
+ page_index += 1
421
+ time.sleep(0.2)
422
+
423
+ return halls
424
+
425
+
426
+ def fetch_tms_server_movies_by_hall():
427
+ theater_id, x_session_id, ticket, auth_token, tms_proxy_base_url = _get_tms_env_and_auth()
428
+
429
+ all_movies = []
430
+ page_index = 1
431
+ while True:
432
+ list_headers = {
433
+ "Accept": "application/json, text/javascript, */*; q=0.01",
434
+ "Content-Type": "application/json; charset=UTF-8",
435
+ "Cookie": f"JSESSIONID={x_session_id}",
436
+ "Origin": "https://tms.hengdianfilm.com",
437
+ "Referer": f"https://tms.hengdianfilm.com/hd/index?ContentMovie&THEATER_ID={theater_id}",
438
+ "Token": auth_token,
439
+ "User-Agent": "Mozilla/5.0",
440
+ "X-Requested-With": "XMLHttpRequest",
441
+ "X-SESSIONID": x_session_id,
442
+ }
443
+ list_params = {"token": "hd", "murl": "ContentMovie"}
444
+ list_json = {
445
+ "THEATER_ID": theater_id,
446
+ "SOURCE": "SERVER",
447
+ "ASSERT_TYPE": 2,
448
+ "PAGE_CAPACITY": 20,
449
+ "PAGE_INDEX": page_index,
450
+ }
451
+ list_url = build_tms_url(
452
+ "https://tms.hengdianfilm.com/cinema-api/cinema/server/dcp/list",
453
+ tms_proxy_base_url,
454
+ )
455
+ list_headers = with_tms_proxy_headers(list_headers, tms_proxy_base_url)
456
+
457
+ try:
458
+ resp = requests.post(
459
+ list_url,
460
+ params=list_params,
461
+ headers=list_headers,
462
+ json=list_json,
463
+ timeout=15,
464
+ verify=tms_verify_ssl(default=False, proxy_url=tms_proxy_base_url),
465
+ )
466
+ resp.raise_for_status()
467
+ data = resp.json()
468
+ except requests.exceptions.RequestException as exc:
469
+ raise RetryableAPIError(f"TMS 列表接口异常:{exc}") from exc
470
+ except Exception as exc:
471
+ raise RetryableAPIError(f"TMS 列表处理异常:{exc}") from exc
472
+
473
+ if data.get("RSPCD") != "000000":
474
+ raise RetryableAPIError(f"TMS 列表接口失败:{data.get('RSPMSG')}")
475
+
476
+ body = data.get("BODY", {})
477
+ items = body.get("LIST", [])
478
+ if not items:
479
+ break
480
+ all_movies.extend(items)
481
+ if len(all_movies) >= body.get("COUNT", 0):
482
+ break
483
+ page_index += 1
484
+ time.sleep(0.3)
485
+
486
+ movie_details = {
487
+ movie.get("CONTENT_NAME"): {
488
+ "assert_name": movie.get("ASSERT_NAME"),
489
+ "assert_id": movie.get("ASSERT_ID"),
490
+ "source_format": movie.get("SOURCE_FORMAT"),
491
+ "halls": sorted([hall.get("HALL_NAME") for hall in movie.get("HALL_INFO", [])]),
492
+ }
493
+ for movie in all_movies
494
+ if movie.get("CONTENT_NAME")
495
+ }
496
+
497
+ by_hall = defaultdict(list)
498
+ for content_name, details in movie_details.items():
499
+ for hall_name in details.get("halls", []):
500
+ by_hall[hall_name].append({"content_name": content_name, "details": details})
501
+
502
+ return dict(by_hall)
503
+
504
+
505
+ def format_movie_display_name(movie_name, movie_language, movie_media_type):
506
+ """统一的影片展示名:``片名 语言 制式``,缺失部分自动跳过。"""
507
+ name = str(movie_name or "").strip() or "未知影片"
508
+ parts = [name]
509
+ language = str(movie_language or "").strip()
510
+ if language:
511
+ parts.append(language)
512
+ media = str(movie_media_type or "").strip()
513
+ if media:
514
+ parts.append(media)
515
+ return " ".join(parts)
516
+
517
+
518
+ def check_tms_file_availability(schedule_list, tms_data, date_str):
519
+ if schedule_list is None:
520
+ return {"issue_text": "未获取到排片数据,无法检查。", "issues": []}
521
+ if not tms_data:
522
+ return {"issue_text": "未获取到 TMS 数据,无法检查。", "issues": []}
523
+ if not schedule_list:
524
+ return {"issue_text": None, "issues": []}
525
+
526
+ def clean_hall_display_name(raw_name):
527
+ hall_name = str(raw_name or "").strip("【】[] ").strip()
528
+ hall_num_match = re.search(r"(\d+)\s*号", hall_name)
529
+ if hall_num_match:
530
+ return f"{hall_num_match.group(1)}号厅"
531
+ return hall_name
532
+
533
+ def get_hall_key_num(name):
534
+ nums = re.findall(r"\d+", str(name))
535
+ return nums[0] if nums else str(name)
536
+
537
+ def normalize_id_code(value):
538
+ s = str(value or "").strip().upper()
539
+ if not s or s == "NAN":
540
+ return ""
541
+ if re.fullmatch(r"[A-Z0-9]+\.0", s):
542
+ s = s[:-2]
543
+ return re.sub(r"[^A-Z0-9]", "", s)
544
+
545
+ def to_12_digit_movie_num(movie_num):
546
+ s = normalize_id_code(movie_num)
547
+ return s[:12] if len(s) >= 12 else ""
548
+
549
+ def normalize_media_type(media_type):
550
+ v = str(media_type or "").upper()
551
+ if "3D" in v:
552
+ return "3D"
553
+ if "2D" in v:
554
+ return "2D"
555
+ return ""
556
+
557
+ def normalize_source_format(source_format, content_name):
558
+ sf = str(source_format or "").upper()
559
+ cn = str(content_name or "").upper()
560
+ if "3D" in sf or re.search(r"(^|_)FTR-3D([_-]|$)", cn):
561
+ return "3D"
562
+ if "2D" in sf or re.search(r"(^|_)FTR-2D([_-]|$)", cn):
563
+ return "2D"
564
+ return ""
565
+
566
+ def normalize_language(movie_language):
567
+ v = str(movie_language or "").strip().upper()
568
+ if not v:
569
+ return ""
570
+ if "粤" in v or "YUE" in v:
571
+ return "YUE"
572
+ if "英语" in v or "原版" in v or "原声" in v or v == "EN":
573
+ return "EN"
574
+ if "国语" in v or "普通话" in v or "中文" in v or "CMN" in v or "ZH" in v:
575
+ return "CMN"
576
+ return ""
577
+
578
+ def language_match(lang_key, content_name, assert_name):
579
+ if not lang_key:
580
+ return True
581
+ cn = str(content_name or "").upper()
582
+ an = str(assert_name or "")
583
+
584
+ if lang_key == "YUE":
585
+ return bool(re.search(r"(^|_)YUE([-_]|$)", cn)) or ("粤语" in an)
586
+ if lang_key == "EN":
587
+ return bool(re.search(r"(^|_)EN([-_]|$)", cn)) or ("英语" in an) or ("原版" in an) or ("原声" in an)
588
+ if lang_key == "CMN":
589
+ return bool(re.search(r"(^|_)(CMN|ZH)([-_]|$)", cn)) or ("国语" in an) or ("中文" in an)
590
+ return True
591
+
592
+ tms_by_hall = defaultdict(list)
593
+ for hall_name, movies in tms_data.items():
594
+ hall_key = get_hall_key_num(hall_name)
595
+ for movie in movies:
596
+ details = movie.get("details", {}) or {}
597
+ content_name = str(movie.get("content_name") or "")
598
+ assert_name = str(details.get("assert_name") or "")
599
+ assert_id_raw = str(details.get("assert_id") or "")
600
+ assert_id_norm = normalize_id_code(assert_id_raw)
601
+ source_format = str(details.get("source_format") or "")
602
+
603
+ tms_by_hall[hall_key].append({
604
+ "content_name": content_name,
605
+ "assert_name": assert_name,
606
+ "assert_id": assert_id_raw,
607
+ "assert_id_norm": assert_id_norm,
608
+ "assert_12": assert_id_norm[:12] if len(assert_id_norm) >= 12 else "",
609
+ "media": normalize_source_format(source_format, content_name),
610
+ })
611
+
612
+ issue_records = []
613
+ checked = set()
614
+
615
+ def append_issue(issue_type, hall_num, hall_display, movie_name, message, item):
616
+ issue_records.append({
617
+ "issue_type": issue_type,
618
+ "date": date_str,
619
+ "hall_num": str(hall_num),
620
+ "hall_display": str(hall_display),
621
+ "movie_name": str(movie_name),
622
+ "movie_num": str(item.get("movieNum") or ""),
623
+ "movie_language": str(item.get("movieLanguage") or ""),
624
+ "movie_media_type": str(item.get("movieMediaType") or ""),
625
+ "message": message,
626
+ })
627
+
628
+ for item in schedule_list:
629
+ hall_raw = item.get("hallName") or item.get("Hall")
630
+ movie_raw = item.get("movieName") or item.get("Movie")
631
+ if not hall_raw or not movie_raw:
632
+ continue
633
+
634
+ hall_num = get_hall_key_num(hall_raw)
635
+ hall_display = clean_hall_display_name(hall_raw)
636
+ movie_name = str(movie_raw)
637
+ movie_language_raw = str(item.get("movieLanguage") or "").strip()
638
+ movie_media_raw = str(item.get("movieMediaType") or "").strip()
639
+ movie_display = format_movie_display_name(movie_name, movie_language_raw, movie_media_raw)
640
+ movie_num_12 = to_12_digit_movie_num(item.get("movieNum"))
641
+ language_key = normalize_language(item.get("movieLanguage"))
642
+ media_key = normalize_media_type(item.get("movieMediaType"))
643
+
644
+ combo_key = (hall_num, movie_num_12, language_key, media_key, movie_name)
645
+ if combo_key in checked:
646
+ continue
647
+ checked.add(combo_key)
648
+
649
+ hall_candidates = tms_by_hall.get(hall_num, [])
650
+ if not hall_candidates:
651
+ continue
652
+
653
+ coarse_candidates = [candidate for candidate in hall_candidates if candidate.get("assert_12") == movie_num_12] if movie_num_12 else hall_candidates[:]
654
+ if not coarse_candidates:
655
+ append_issue(
656
+ "missing_assert12",
657
+ hall_num,
658
+ hall_display,
659
+ movie_name,
660
+ f"【{hall_display}】《{movie_display}》movieNum={item.get('movieNum')} 未命中同厅 ASSERT_ID 前12位。",
661
+ item,
662
+ )
663
+ continue
664
+
665
+ lang_candidates = coarse_candidates
666
+ if language_key:
667
+ lang_filtered = [
668
+ candidate for candidate in coarse_candidates
669
+ if language_match(language_key, candidate.get("content_name"), candidate.get("assert_name"))
670
+ ]
671
+ if lang_filtered:
672
+ lang_candidates = lang_filtered
673
+
674
+ media_candidates = lang_candidates
675
+ if media_key:
676
+ media_filtered = [candidate for candidate in lang_candidates if candidate.get("media") == media_key]
677
+ if media_filtered:
678
+ media_candidates = media_filtered
679
+
680
+ id_display_map = {}
681
+ for candidate in media_candidates:
682
+ assert_id_norm = candidate.get("assert_id_norm")
683
+ if not assert_id_norm:
684
+ continue
685
+ if assert_id_norm not in id_display_map:
686
+ id_display_map[assert_id_norm] = str(candidate.get("assert_id") or assert_id_norm).strip().upper()
687
+
688
+ unique_assert_id_norms = sorted(id_display_map.keys())
689
+ if not unique_assert_id_norms:
690
+ append_issue(
691
+ "missing_assert_id",
692
+ hall_num,
693
+ hall_display,
694
+ movie_name,
695
+ f"【{hall_display}】《{movie_display}》已命中前12位,但未找到可确权的 ASSERT_ID(语言={item.get('movieLanguage')},制式={item.get('movieMediaType')})。",
696
+ item,
697
+ )
698
+ continue
699
+
700
+ if len(unique_assert_id_norms) > 1:
701
+ sample_names = " | ".join([candidate.get("content_name", "") for candidate in media_candidates[:3]])
702
+ display_ids = [id_display_map[key] for key in unique_assert_id_norms[:5]]
703
+ append_issue(
704
+ "ambiguous_assert_id",
705
+ hall_num,
706
+ hall_display,
707
+ movie_name,
708
+ f"【{hall_display}】《{movie_display}》命中多个 ASSERT_ID({', '.join(display_ids)}),未唯一确权。样本:{sample_names}",
709
+ item,
710
+ )
711
+
712
+ if not issue_records:
713
+ return {"issue_text": None, "issues": []}
714
+
715
+ lines = [f"{idx}. ⚠️ TMS核对警告:{issue['message']}" for idx, issue in enumerate(issue_records, 1)]
716
+ return {"issue_text": "\n".join(lines), "issues": issue_records}
717
+
718
+
719
+ def iter_dates(start_dt: date, end_dt: date):
720
+ current = start_dt
721
+ while current <= end_dt:
722
+ yield current
723
+ current += timedelta(days=1)
724
+
725
+
726
+ def get_hall_sort_key(name):
727
+ nums = re.findall(r"\d+", str(name))
728
+ if nums:
729
+ return 0, int(nums[0]), str(name)
730
+ return 1, 0, str(name)
731
+
732
+
733
+ def build_tms_issue_summary(issue_records):
734
+ # 以 (片名, 语言, 制式) 作为聚合键,避免不同语言/制式版本被合并为同一条
735
+ missing_map = defaultdict(lambda: defaultdict(set))
736
+ ambiguous_map = defaultdict(lambda: defaultdict(set))
737
+ missing_display_map = defaultdict(dict)
738
+ ambiguous_display_map = defaultdict(dict)
739
+
740
+ for issue in issue_records:
741
+ hall_display = str(issue.get("hall_display") or "未知影厅")
742
+ movie_name = str(issue.get("movie_name") or "未知影片")
743
+ movie_language = str(issue.get("movie_language") or "").strip()
744
+ movie_media_type = str(issue.get("movie_media_type") or "").strip()
745
+ movie_key = (movie_name, movie_language, movie_media_type)
746
+ display_name = format_movie_display_name(movie_name, movie_language, movie_media_type)
747
+ issue_date = format_short_day(issue.get("date"))
748
+ issue_type = issue.get("issue_type")
749
+
750
+ if issue_type in MISSING_ISSUE_TYPES:
751
+ missing_map[hall_display][movie_key].add(issue_date)
752
+ missing_display_map[hall_display][movie_key] = display_name
753
+ elif issue_type == "ambiguous_assert_id":
754
+ ambiguous_map[hall_display][movie_key].add(issue_date)
755
+ ambiguous_display_map[hall_display][movie_key] = display_name
756
+
757
+ summary_lines = []
758
+
759
+ for hall_display in sorted(missing_map.keys(), key=get_hall_sort_key):
760
+ movie_parts = []
761
+ movie_keys_sorted = sorted(
762
+ missing_map[hall_display].keys(),
763
+ key=lambda k: missing_display_map[hall_display][k],
764
+ )
765
+ for movie_key in movie_keys_sorted:
766
+ display_name = missing_display_map[hall_display][movie_key]
767
+ dates = [day for day in sorted(missing_map[hall_display][movie_key]) if day and day != "--"]
768
+ if dates:
769
+ movie_parts.append(f"《{display_name}》({'、'.join(dates)})")
770
+ else:
771
+ movie_parts.append(f"《{display_name}》")
772
+ summary_lines.append(f"{hall_display} 缺少{' '.join(movie_parts)}的文件")
773
+
774
+ for hall_display in sorted(ambiguous_map.keys(), key=get_hall_sort_key):
775
+ movie_parts = []
776
+ movie_keys_sorted = sorted(
777
+ ambiguous_map[hall_display].keys(),
778
+ key=lambda k: ambiguous_display_map[hall_display][k],
779
+ )
780
+ for movie_key in movie_keys_sorted:
781
+ display_name = ambiguous_display_map[hall_display][movie_key]
782
+ dates = [day for day in sorted(ambiguous_map[hall_display][movie_key]) if day and day != "--"]
783
+ if dates:
784
+ movie_parts.append(f"《{display_name}》({'/'.join(dates)})")
785
+ else:
786
+ movie_parts.append(f"《{display_name}》")
787
+ summary_lines.append(f"{hall_display} {' '.join(movie_parts)}存在 ASSERT_ID 不唯一")
788
+
789
+ return summary_lines
790
+
791
+
792
+ def build_notification_message(run_result):
793
+ lines = ["排片场次数量检查与TMS内容核对监控", ""]
794
+ lines.append(
795
+ f"检查范围:{format_day_with_weekday(run_result.get('range_start'))} 至 {format_day_with_weekday(run_result.get('range_end'))}"
796
+ )
797
+ lines.append("每日结果:")
798
+
799
+ daily_results = run_result.get("daily_results") or []
800
+ failure_message = str(run_result.get("failure_message") or "").strip()
801
+
802
+ if daily_results:
803
+ for item in daily_results:
804
+ schedule_count = item.get("schedule_count")
805
+ schedule_display = "--" if schedule_count is None else str(schedule_count)
806
+ lines.append(f"- {format_short_day(item.get('date'))}:{schedule_display} 场,{item.get('status', '未知')}")
807
+ elif failure_message:
808
+ lines.append(f"- 未完成:{failure_message}")
809
+ else:
810
+ lines.append("- 暂无结果")
811
+
812
+ lines.append("与TMS内容核对:")
813
+ tms_summary_lines = run_result.get("tms_summary_lines") or []
814
+ if tms_summary_lines:
815
+ for idx, line in enumerate(tms_summary_lines, 1):
816
+ lines.append(f"{idx}. {line}")
817
+ else:
818
+ lines.append("- 正常" if not failure_message else "- 未完成")
819
+
820
+ if failure_message:
821
+ lines.append(f"接口异常:{failure_message}")
822
+
823
+ tms_hall_total = run_result.get("tms_hall_total") or 0
824
+ tms_hall_online = run_result.get("tms_hall_online") or 0
825
+ if tms_hall_total:
826
+ lines.append(f"TMS服务器在线状态:{tms_hall_online}/{tms_hall_total}")
827
+
828
+ return "\n".join(lines)
829
+
830
+
831
+ def build_failed_run_result(run_date, failure_message):
832
+ start_date, end_date = get_check_date_range(run_date)
833
+ run_result = {
834
+ "completed_at": format_datetime_text(get_beijing_now()),
835
+ "range_start": start_date.strftime("%Y-%m-%d"),
836
+ "range_end": end_date.strftime("%Y-%m-%d"),
837
+ "hall_count": 0,
838
+ "expected_sessions_per_day": 0,
839
+ "min_sessions_threshold": 0,
840
+ "daily_results": [],
841
+ "tms_summary_lines": [],
842
+ "tms_hall_total": 0,
843
+ "tms_hall_online": 0,
844
+ "tms_hall_offline_names": [],
845
+ "failure_message": failure_message,
846
+ "result_status": "未完成",
847
+ }
848
+ run_result["summary_text"] = build_notification_message(run_result)
849
+ return run_result
850
+
851
+
852
+ def send_wework_text(content):
853
+ if not WEWORK_BOT_WEBHOOK:
854
+ return False
855
+
856
+ headers = {"Content-Type": "application/json"}
857
+ data = {
858
+ "msgtype": "text",
859
+ "text": {
860
+ "content": content,
861
+ },
862
+ }
863
+ try:
864
+ resp = requests.post(WEWORK_BOT_WEBHOOK, json=data, headers=headers, timeout=10)
865
+ result = resp.json()
866
+ return result.get("errcode") == 0
867
+ except Exception:
868
+ return False
869
+
870
+
871
+ def run_daily_check(run_date, logger):
872
+ start_date, end_date = get_check_date_range(run_date)
873
+ completed_at = format_datetime_text(get_beijing_now())
874
+
875
+ logger(f"📡 开始晨检,范围:{format_day_with_weekday(start_date)} 至 {format_day_with_weekday(end_date)}")
876
+
877
+ hall_info = get_hall_info_with_token_management()
878
+ hall_count = len(hall_info or {})
879
+ expected_sessions_per_day = hall_count * 6
880
+ min_sessions_threshold = expected_sessions_per_day / 2 if hall_count > 0 else 0
881
+ logger(f"✅ 影厅数量:{hall_count},总场次阈值:少于 {int(min_sessions_threshold) if min_sessions_threshold else 0} 场")
882
+
883
+ tms_hall_data = fetch_tms_server_movies_by_hall()
884
+ logger("✅ TMS 服务器影片数据获取成功")
885
+
886
+ tms_hall_total = 0
887
+ tms_hall_online = 0
888
+ tms_hall_offline_names = []
889
+ try:
890
+ tms_hall_status = fetch_tms_hall_status()
891
+ tms_hall_total = len(tms_hall_status)
892
+ for hall in tms_hall_status:
893
+ if hall.get("STATUS") == 1:
894
+ tms_hall_online += 1
895
+ else:
896
+ tms_hall_offline_names.append(str(hall.get("NAME") or hall.get("OUTER_ID") or "未知厅"))
897
+ offline_text = "全部在线" if not tms_hall_offline_names else f"离线:{'、'.join(tms_hall_offline_names)}"
898
+ logger(f"✅ TMS 影厅设备状态:在线 {tms_hall_online}/{tms_hall_total}({offline_text})")
899
+ except Exception as exc:
900
+ logger(f"⚠️ 获取 TMS 影厅设备状态失败:{exc}")
901
+
902
+ daily_results = []
903
+ all_issue_records = []
904
+
905
+ date_list = list(iter_dates(start_date, end_date))
906
+ for idx, current_date in enumerate(date_list):
907
+ date_str = current_date.strftime("%Y-%m-%d")
908
+ logger(f"🔍 检查 {date_str} 排片")
909
+ schedule_data = get_schedule_data_with_token_management(date_str)
910
+ schedule_count = len(schedule_data)
911
+ day_status = "正常"
912
+
913
+ if hall_count > 0 and schedule_count < min_sessions_threshold:
914
+ day_status = "异常"
915
+
916
+ check_result = check_tms_file_availability(schedule_data, tms_hall_data, date_str)
917
+ issue_list = check_result.get("issues") or []
918
+ if issue_list:
919
+ all_issue_records.extend(issue_list)
920
+
921
+ daily_results.append({
922
+ "date": date_str,
923
+ "schedule_count": schedule_count,
924
+ "status": day_status,
925
+ })
926
+
927
+ if idx < len(date_list) - 1:
928
+ logger("⏱️ 30 秒后继续检查下一天排片,降低接口访问频率")
929
+ time.sleep(30)
930
+
931
+ tms_summary_lines = build_tms_issue_summary(all_issue_records)
932
+ result_status = "异常" if any(item.get("status") == "异常" for item in daily_results) or tms_summary_lines else "正常"
933
+
934
+ run_result = {
935
+ "completed_at": completed_at,
936
+ "range_start": start_date.strftime("%Y-%m-%d"),
937
+ "range_end": end_date.strftime("%Y-%m-%d"),
938
+ "hall_count": hall_count,
939
+ "expected_sessions_per_day": expected_sessions_per_day,
940
+ "min_sessions_threshold": int(min_sessions_threshold) if min_sessions_threshold else 0,
941
+ "daily_results": daily_results,
942
+ "tms_summary_lines": tms_summary_lines,
943
+ "tms_hall_total": tms_hall_total,
944
+ "tms_hall_online": tms_hall_online,
945
+ "tms_hall_offline_names": tms_hall_offline_names,
946
+ "failure_message": "",
947
+ "result_status": result_status,
948
+ }
949
+ run_result["summary_text"] = build_notification_message(run_result)
950
+ return run_result
951
+
952
+
953
+ class DailyTMSSummaryMonitor:
954
+ def __init__(self):
955
+ self.logs = deque(maxlen=100)
956
+ self.status_text = "未完成"
957
+ self.next_run_str = "--"
958
+ self.last_state = load_monitor_state()
959
+ self.thread = threading.Thread(target=self._run_loop, daemon=True)
960
+ self.thread.start()
961
+
962
+ def log(self, message):
963
+ timestamp = get_beijing_now().strftime("%H:%M:%S")
964
+ entry = f"[{timestamp}] {message}"
965
+ self.logs.appendleft(entry)
966
+ print(entry)
967
+
968
+ def _save_run_result(self, run_result, notification_sent):
969
+ state = {
970
+ "last_completed_date": get_beijing_now().strftime("%Y-%m-%d"),
971
+ "completed_at": run_result.get("completed_at", ""),
972
+ "run_status": "已完成",
973
+ "result_status": run_result.get("result_status", "未执行"),
974
+ "range_start": run_result.get("range_start", ""),
975
+ "range_end": run_result.get("range_end", ""),
976
+ "hall_count": run_result.get("hall_count", 0),
977
+ "expected_sessions_per_day": run_result.get("expected_sessions_per_day", 0),
978
+ "min_sessions_threshold": run_result.get("min_sessions_threshold", 0),
979
+ "notification_sent": notification_sent,
980
+ "summary_text": run_result.get("summary_text", ""),
981
+ "daily_results": run_result.get("daily_results", []),
982
+ "tms_summary_lines": run_result.get("tms_summary_lines", []),
983
+ "tms_hall_total": run_result.get("tms_hall_total", 0),
984
+ "tms_hall_online": run_result.get("tms_hall_online", 0),
985
+ "tms_hall_offline_names": run_result.get("tms_hall_offline_names", []),
986
+ "failure_message": run_result.get("failure_message", ""),
987
+ }
988
+ self.last_state = save_monitor_state(state)
989
+
990
+ def _execute_today_check(self):
991
+ run_date = get_beijing_now().date()
992
+ self.status_text = "进行中"
993
+ self.next_run_str = "--"
994
+
995
+ for attempt in range(1, MAX_RETRY_ATTEMPTS + 1):
996
+ try:
997
+ self.log(f"🚀 开始执行晨检,第 {attempt}/{MAX_RETRY_ATTEMPTS} 次尝试")
998
+ run_result = run_daily_check(run_date, self.log)
999
+ notification_sent = send_wework_text(run_result.get("summary_text", ""))
1000
+
1001
+ if notification_sent:
1002
+ self.log("✅ 汇总通知发送成功")
1003
+ elif WEWORK_BOT_WEBHOOK:
1004
+ self.log("⚠️ 汇总通知发送失败")
1005
+ else:
1006
+ self.log("⚠️ 未配置企业微信机器人,未发送通知")
1007
+
1008
+ self._save_run_result(run_result, notification_sent)
1009
+ self.status_text = "已完成"
1010
+ return
1011
+ except RetryableAPIError as exc:
1012
+ self.log(f"⚠️ 第 {attempt}/{MAX_RETRY_ATTEMPTS} 次获取数据失败:{exc}")
1013
+ if attempt < MAX_RETRY_ATTEMPTS:
1014
+ retry_at = get_beijing_now() + timedelta(seconds=RETRY_DELAY_SECONDS)
1015
+ self.next_run_str = format_datetime_text(retry_at)
1016
+ self.log(f"⏳ 5 分钟后重试,预计 {self.next_run_str}")
1017
+ time.sleep(RETRY_DELAY_SECONDS)
1018
+ self.status_text = "进行中"
1019
+ continue
1020
+
1021
+ run_result = build_failed_run_result(run_date, str(exc))
1022
+ notification_sent = send_wework_text(run_result.get("summary_text", ""))
1023
+ if notification_sent:
1024
+ self.log("✅ 连续失败后已发送异常通知")
1025
+ elif WEWORK_BOT_WEBHOOK:
1026
+ self.log("⚠️ 连续失败后异常通知发送失败")
1027
+ else:
1028
+ self.log("⚠️ 未配置企业微信机器人,未发送异常通知")
1029
+ self._save_run_result(run_result, notification_sent)
1030
+ self.status_text = "已完成"
1031
+ return
1032
+ except Exception as exc:
1033
+ self.log(f"❌ 执行失败:{exc}")
1034
+ run_result = build_failed_run_result(run_date, str(exc))
1035
+ notification_sent = send_wework_text(run_result.get("summary_text", ""))
1036
+ if notification_sent:
1037
+ self.log("✅ 执行失败后已发送异常通知")
1038
+ elif WEWORK_BOT_WEBHOOK:
1039
+ self.log("⚠️ 执行失败后异常通知发送失败")
1040
+ else:
1041
+ self.log("⚠️ 未配置企业微信机器人,未发送异常通知")
1042
+ self._save_run_result(run_result, notification_sent)
1043
+ self.status_text = "已完成"
1044
+ return
1045
+
1046
+ def _run_loop(self):
1047
+ self.log("🕤 排片检查与 TMS 内容核对监控已启动")
1048
+ while True:
1049
+ try:
1050
+ now = get_beijing_now()
1051
+ today_str = now.strftime("%Y-%m-%d")
1052
+ state = load_monitor_state()
1053
+ self.last_state = state
1054
+ today_run_time = datetime.combine(now.date(), dt_time(9, 55))
1055
+
1056
+ if state.get("last_completed_date") != today_str and now >= today_run_time:
1057
+ self._execute_today_check()
1058
+ time.sleep(5)
1059
+ continue
1060
+
1061
+ if state.get("last_completed_date") == today_str:
1062
+ self.status_text = "已完成"
1063
+ next_run_dt = datetime.combine(now.date() + timedelta(days=1), dt_time(9, 55))
1064
+ else:
1065
+ self.status_text = "未完成"
1066
+ next_run_dt = today_run_time if now < today_run_time else now
1067
+
1068
+ self.next_run_str = format_datetime_text(next_run_dt if next_run_dt > now else datetime.combine(now.date() + timedelta(days=1), dt_time(9, 55)))
1069
+ time.sleep(30 if state.get("last_completed_date") != today_str and now < today_run_time else 60)
1070
+ except Exception as exc:
1071
+ self.log(f"❌ 主循环异常:{exc}")
1072
+ time.sleep(60)
1073
+
1074
+
1075
+ @st.cache_resource
1076
+ def get_monitor():
1077
+ return DailyTMSSummaryMonitor()
1078
+
1079
+
1080
+ def main():
1081
+ monitor = get_monitor()
1082
+ state = monitor.last_state or load_monitor_state()
1083
+
1084
+ if st_autorefresh:
1085
+ st_autorefresh(interval=30 * 1000, key="tms_morning_summary_refresh")
1086
+
1087
+ start_date, end_date = get_check_date_range(get_beijing_now().date())
1088
+
1089
+ st.title("📨 排片检查与 TMS 内容核对监控")
1090
+ st.caption("每天北京时间 09:55 自动执行一次。若接口异常,5 分钟后重试,最多 3 次;成功后当天只发送一次汇总通知。")
1091
+
1092
+ c1, c2 = st.columns(2)
1093
+ with c1:
1094
+ st.metric("运行状态", monitor.status_text)
1095
+ with c2:
1096
+ st.metric("今日计划范围", f"{format_short_day(start_date)} ~ {format_short_day(end_date)}")
1097
+
1098
+ st.divider()
1099
+
1100
+ info_col, log_col = st.columns([2, 3])
1101
+
1102
+ with info_col:
1103
+ daily_results_value = state.get("daily_results")
1104
+ daily_results = daily_results_value if isinstance(daily_results_value, list) else []
1105
+ st.subheader("每日结果")
1106
+ if daily_results:
1107
+ for item in daily_results:
1108
+ if not isinstance(item, dict):
1109
+ continue
1110
+ text = f"{format_short_day(item.get('date'))}:{item.get('schedule_count', '--')} 场,{item.get('status', '未知')}"
1111
+ if item.get("status") == "异常":
1112
+ st.error(text)
1113
+ else:
1114
+ st.success(text)
1115
+ elif state.get("failure_message"):
1116
+ st.error(f"未完成:{state.get('failure_message')}")
1117
+ else:
1118
+ st.caption("暂无结果")
1119
+
1120
+ st.subheader("与TMS内容核对")
1121
+ tms_summary_lines_value = state.get("tms_summary_lines")
1122
+ tms_summary_lines = tms_summary_lines_value if isinstance(tms_summary_lines_value, list) else []
1123
+ if tms_summary_lines:
1124
+ st.code("\n".join([f"{idx}. {line}" for idx, line in enumerate(tms_summary_lines, 1)]), language="text")
1125
+ elif state.get("failure_message"):
1126
+ st.error("未完成")
1127
+ else:
1128
+ st.success("正常")
1129
+
1130
+ st.subheader("TMS 影厅设备在线状态")
1131
+ tms_hall_total = state.get("tms_hall_total") or 0
1132
+ tms_hall_online = state.get("tms_hall_online") or 0
1133
+ offline_names_value = state.get("tms_hall_offline_names")
1134
+ offline_names = offline_names_value if isinstance(offline_names_value, list) else []
1135
+ if tms_hall_total:
1136
+ mc1, mc2, mc3 = st.columns(3)
1137
+ mc1.metric("影厅总数", tms_hall_total)
1138
+ mc2.metric("在线", tms_hall_online)
1139
+ mc3.metric("离线", tms_hall_total - tms_hall_online)
1140
+ if offline_names:
1141
+ st.error(f"离线影厅:{'、'.join(offline_names)}")
1142
+ else:
1143
+ st.success("全部影厅 TMS 在线")
1144
+ else:
1145
+ st.caption("暂无 TMS 影厅设备状态数据")
1146
+
1147
+ with log_col:
1148
+ st.subheader("运行日志")
1149
+ st.text_area("Logs", "\n".join(list(monitor.logs)), height=700, disabled=True)
1150
+
1151
+
1152
+ if __name__ == "__main__":
1153
+ main()
pages/🔍 TMS 服务器影片内容查询.py ADDED
@@ -0,0 +1,559 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import pandas as pd
3
+ import requests
4
+ import time
5
+ import os
6
+ import re
7
+ import urllib3
8
+ from collections import defaultdict
9
+ from datetime import date, timedelta
10
+ from pathlib import Path
11
+ from dotenv import load_dotenv
12
+ from cinema_api_client import get_schedule_and_hall_info
13
+ from tms_proxy import (
14
+ TMS_ORIGIN,
15
+ TMS_PROXY_URL_ENV,
16
+ build_tms_url,
17
+ get_tms_proxy_base_url,
18
+ tms_verify_ssl,
19
+ with_tms_proxy_headers,
20
+ )
21
+
22
+ # --- 基础配置 ---
23
+ st.set_page_config(page_title="TMS 影片查询", page_icon="🎬", layout="wide")
24
+
25
+ # 屏蔽 HTTPS 证书警告
26
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
27
+
28
+ # 加载环境变量
29
+ load_dotenv()
30
+
31
+ # --- TMS 内容统计缓存路径 ---
32
+ _TMS_CONTENT_STATS_FILE = Path(__file__).resolve().parent.parent / "cinema_cache" / "tms_server_content_stats.csv"
33
+
34
+ # --- 工具函数 ---
35
+
36
+
37
+ def format_tms_http_error(prefix, response):
38
+ if response is None:
39
+ return prefix
40
+
41
+ proxy_error = response.headers.get("X-TMS-Proxy-Error") or "-"
42
+ proxy_region = (
43
+ response.headers.get("X-TMS-Proxy-Region")
44
+ or response.headers.get("X-SB-Edge-Region")
45
+ or "-"
46
+ )
47
+ upstream_status = response.headers.get("X-TMS-Upstream-Status") or "-"
48
+ body_preview = re.sub(r"\s+", " ", response.text or "").strip()[:240] or "-"
49
+ return (
50
+ f"{prefix}: HTTP {response.status_code}; "
51
+ f"代理错误={proxy_error}; 代理区域={proxy_region}; "
52
+ f"TMS上游状态={upstream_status}; 响应={body_preview}"
53
+ )
54
+
55
+
56
+ def get_circled_number(hall_name):
57
+ """
58
+ 将影厅数字转换为带圈数字,例如 1 -> ①
59
+ """
60
+ mapping = {'1': '①', '2': '②', '3': '③', '4': '④', '5': '⑤', '6': '⑥', '7': '⑦', '8': '⑧', '9': '⑨'}
61
+ # 提取字符串中的数字
62
+ num_str = ''.join(filter(str.isdigit, str(hall_name)))
63
+ return mapping.get(num_str, num_str)
64
+
65
+
66
+ def format_play_time(time_str):
67
+ """
68
+ 格式化时长字符串,例如 "01:30" -> 90
69
+ """
70
+ if not time_str or not isinstance(time_str, str): return None
71
+ try:
72
+ parts = time_str.split(':')
73
+ hours = int(parts[0])
74
+ minutes = int(parts[1])
75
+ return hours * 60 + minutes
76
+ except (ValueError, IndexError):
77
+ return None
78
+
79
+
80
+ def format_content_name_with_explanation(content_name):
81
+ raw = str(content_name or '').strip()
82
+ if not raw:
83
+ return ''
84
+
85
+ lang_map = {
86
+ 'CMN': '国语/普通话', 'YUE': '粤语', 'EN': '英语', 'JP': '日语/或简化命名中的加密标记',
87
+ 'KO': '韩语', 'FR': '法语', 'ES': '西班牙语', 'TH': '泰语', 'HI': '印地语', 'RU': '俄语',
88
+ 'PTH': '普通话', 'GDH': '广东话', 'YS': '原声', 'YZ': '译制', 'SCH': '四川话',
89
+ 'NAN': '闽南语', 'WU': '吴语/上海话', 'XX': '无字幕', 'QMS': '简中字幕',
90
+ 'QMT': '繁中字幕', 'CCAP': '听障字幕'
91
+ }
92
+ audio_map = {'20': '2.0', '51': '5.1', '71': '7.1', 'ATMOS': 'Dolby Atmos', 'DTSX': 'DTS:X'}
93
+ type_map = {'FTR': '正片', 'TLR': '预告片', 'TSR': '先导预告'}
94
+ pack_map = {'OV': '原始版本包', 'VF': '版本增量包'}
95
+
96
+ notes = []
97
+ parts = raw.split('_')
98
+ first_tokens = parts[0].split('-') if parts else []
99
+ if first_tokens:
100
+ notes.append(f"[片名/标识:{first_tokens[0]}]")
101
+ for token in first_tokens[1:]:
102
+ up = token.upper()
103
+ if up in type_map:
104
+ notes.append(f"[内容类型:{type_map[up]}({token})]")
105
+ elif up in {'2D', '3D'}:
106
+ notes.append(f"[制式:{up}]")
107
+ elif up in {'4FL', '24FPS', '48FPS', '60FPS', '120FPS'}:
108
+ notes.append(f"[技术参数:{token}]")
109
+ elif re.fullmatch(r'\d+', up):
110
+ notes.append(f"[版本号:{token}]")
111
+ else:
112
+ notes.append(f"[{token}]")
113
+
114
+ for token in parts[1:]:
115
+ up = token.upper()
116
+ if '-' in up:
117
+ a, b = up.split('-', 1)
118
+ if a in lang_map and b in lang_map:
119
+ notes.append(f"[音频:{lang_map[a]}({a})]")
120
+ notes.append(f"[字幕:{lang_map[b]}({b})]")
121
+ continue
122
+ if up in {'F', 'S', 'C', 'F-178', 'C-19', '235', '185'}:
123
+ notes.append(f"[画幅:{token}]")
124
+ elif re.fullmatch(r'\d{2,3}M', up):
125
+ notes.append(f"[时长:{token}]")
126
+ elif up in audio_map:
127
+ notes.append(f"[音效:{audio_map[up]}({token})]")
128
+ elif up in {'2K', '4K'}:
129
+ notes.append(f"[分辨率:{up}]")
130
+ elif up in {'SMPTE', 'IOP'}:
131
+ notes.append(f"[封装标准:{up}]")
132
+ elif re.fullmatch(r'\d{8}', up):
133
+ notes.append(f"[打包日期:{token}]")
134
+ elif re.fullmatch(r'\d{4}', up):
135
+ notes.append(f"[月日批次:{token}]")
136
+ elif up in pack_map:
137
+ notes.append(f"[包类型:{pack_map[up]}({up})]")
138
+ elif up in lang_map:
139
+ notes.append(f"[语言/标记:{lang_map[up]}({up})]")
140
+ elif up.startswith('CN'):
141
+ notes.append(f"[地区/分级:{token}]")
142
+ else:
143
+ notes.append(f"[{token}]")
144
+
145
+ return f"{raw} / {' '.join(notes)}"
146
+
147
+
148
+ def normalize_id_code(value):
149
+ text = str(value or '').strip().upper()
150
+ if not text or text == 'NAN':
151
+ return ''
152
+ if re.fullmatch(r'[A-Z0-9]+\.0', text):
153
+ text = text[:-2]
154
+ return re.sub(r'[^A-Z0-9]', '', text)
155
+
156
+
157
+ def to_12_digit_movie_num(value):
158
+ normalized = normalize_id_code(value)
159
+ return normalized[:12] if len(normalized) >= 12 else ''
160
+
161
+
162
+ def get_hall_key_num(name):
163
+ nums = re.findall(r'\d+', str(name or ''))
164
+ return nums[0] if nums else str(name or '').strip()
165
+
166
+
167
+ def load_tms_content_stats_map():
168
+ """加载 TMS 内容统计缓存,返回 {内容文件名: {对应影片, 该影院总票房, 全国总票房, 该影片在该影院总场次, 当前影厅该影片场次}} 字典"""
169
+ if not _TMS_CONTENT_STATS_FILE.exists():
170
+ return {}
171
+ try:
172
+ df = pd.read_csv(_TMS_CONTENT_STATS_FILE)
173
+ except Exception:
174
+ return {}
175
+ if df.empty or '内容文件名' not in df.columns:
176
+ return {}
177
+
178
+ stats_map = {}
179
+ for _, row in df.iterrows():
180
+ content_name = str(row.get('内容文件名') or '').strip()
181
+ if not content_name:
182
+ continue
183
+ stats_map[content_name] = {
184
+ '对应影片': str(row.get('对应影片') or '').strip(),
185
+ '该影院总票房': row.get('该影院总票房', ''),
186
+ '全国总票房': str(row.get('全国总票房') or '').strip(),
187
+ '该影片在该影院总场次': row.get('该影片在该影院总场次', ''),
188
+ '当前影厅该影片场次': str(row.get('当前影厅该影片场次') or '').strip(),
189
+ }
190
+ return stats_map
191
+
192
+
193
+ def build_tms_content_movie_map(movie_list_sorted):
194
+ content_assert_map = defaultdict(lambda: {'assert_ids': set(), 'hall_keys': set()})
195
+ for item in movie_list_sorted or []:
196
+ content_name = str(item.get('content_name') or '')
197
+ assert_id_12 = to_12_digit_movie_num(item.get('assert_id'))
198
+ if content_name and assert_id_12:
199
+ content_assert_map[content_name]['assert_ids'].add(assert_id_12)
200
+ content_assert_map[content_name]['hall_keys'].update(
201
+ {get_hall_key_num(hall_name) for hall_name in item.get('halls', []) if get_hall_key_num(hall_name)}
202
+ )
203
+
204
+ if not content_assert_map:
205
+ return {}
206
+
207
+ movie_map = defaultdict(set)
208
+ start_date = date.today() + timedelta(days=1)
209
+ for day_offset in range(5):
210
+ show_date = (start_date + timedelta(days=day_offset)).strftime('%Y-%m-%d')
211
+ try:
212
+ schedule_list, _hall_map, _token = get_schedule_and_hall_info(show_date)
213
+ except Exception:
214
+ continue
215
+
216
+ for schedule in schedule_list or []:
217
+ movie_num_12 = to_12_digit_movie_num(schedule.get('movieNum'))
218
+ if not movie_num_12:
219
+ continue
220
+ movie_name = clean_movie_title(str(schedule.get('movieName') or '').strip())
221
+ if not movie_name:
222
+ continue
223
+ hall_key = get_hall_key_num(schedule.get('hallName') or schedule.get('Hall'))
224
+ for content_name, content_meta in content_assert_map.items():
225
+ hall_keys = content_meta.get('hall_keys') or set()
226
+ if movie_num_12 in content_meta.get('assert_ids', set()) and (not hall_keys or hall_key in hall_keys):
227
+ movie_map[content_name].add(movie_name)
228
+
229
+ return {
230
+ content_name: " / ".join(sorted(movie_names))
231
+ for content_name, movie_names in movie_map.items()
232
+ }
233
+
234
+
235
+ def clean_movie_title(raw_title, canonical_names=None):
236
+ """
237
+ 电影名称标准化清洗函数
238
+ """
239
+ if not isinstance(raw_title, str):
240
+ return raw_title
241
+
242
+ base_name = None
243
+
244
+ # 1. 尝试匹配标准名称
245
+ if canonical_names:
246
+ # 按长度倒序排序,确保最长匹配优先
247
+ sorted_names = sorted(canonical_names, key=len, reverse=True)
248
+ for name in sorted_names:
249
+ if name in raw_title:
250
+ base_name = name
251
+ break
252
+
253
+ # 2. 回退逻辑:如果没传列表或没匹配到,使用空格分割
254
+ if not base_name:
255
+ base_name = raw_title.split(' ', 1)[0]
256
+
257
+ # 3. 后缀追加逻辑
258
+ raw_upper = raw_title.upper()
259
+ suffix = ""
260
+
261
+ if "HDR LED" in raw_upper:
262
+ suffix = "(HDR LED)"
263
+ elif "CINITY" in raw_upper:
264
+ suffix = "(CINITY)"
265
+ elif "杜比" in raw_upper or "DOLBY" in raw_upper:
266
+ suffix = "(杜比视界)"
267
+ elif "IMAX" in raw_upper:
268
+ if "3D" in raw_upper:
269
+ suffix = "(数字IMAX3D)"
270
+ else:
271
+ suffix = "(数字IMAX)"
272
+ elif "巨幕" in raw_upper:
273
+ if "立体" in raw_upper:
274
+ suffix = "(中国巨幕立体)"
275
+ else:
276
+ suffix = "(中国巨幕)"
277
+ elif "3D" in raw_upper:
278
+ suffix = "(数字3D)"
279
+
280
+ # 只有当 base_name 自身不包含该后缀时才添加
281
+ if suffix and suffix not in base_name:
282
+ return f"{base_name}{suffix}"
283
+
284
+ return base_name
285
+
286
+
287
+ # --- 核心功能模块 ---
288
+
289
+ @st.cache_data(show_spinner=False, ttl=600)
290
+ def fetch_and_process_server_movies(priority_movie_titles=None, tms_proxy_base_url=""):
291
+ if priority_movie_titles is None: priority_movie_titles = []
292
+ proxy_base_url = get_tms_proxy_base_url(tms_proxy_base_url)
293
+
294
+ # 获取环境变量
295
+ app_secret = os.getenv("TMS_APP_SECRET")
296
+ ticket = os.getenv("TMS_TICKET")
297
+ theater_id_str = os.getenv("TMS_THEATER_ID")
298
+ x_session_id = os.getenv("TMS_X_SESSION_ID")
299
+
300
+ # 转换 ID 为整数
301
+ try:
302
+ theater_id = int(theater_id_str) if theater_id_str else 0
303
+ except ValueError:
304
+ st.error("环境变量 TMS_THEATER_ID 格式错误,应为数字。")
305
+ return {}, []
306
+
307
+ token_headers = {
308
+ 'Accept': 'application/json, text/javascript, */*; q=0.01',
309
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7,zh-TW;q=0.6',
310
+ 'Content-Type': 'application/json',
311
+ 'Cookie': f'JSESSIONID={x_session_id}',
312
+ 'DNT': '1',
313
+ 'Origin': 'https://tms.hengdianfilm.com',
314
+ 'Priority': 'u=0, i',
315
+ 'Referer': f'https://tms.hengdianfilm.com/hd/oalogin?ticket={ticket}',
316
+ 'Sec-CH-UA': '"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"',
317
+ 'Sec-CH-UA-Mobile': '?0',
318
+ 'Sec-CH-UA-Platform': '"macOS"',
319
+ 'Sec-Fetch-Dest': 'empty',
320
+ 'Sec-Fetch-Mode': 'cors',
321
+ 'Sec-Fetch-Site': 'same-origin',
322
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36',
323
+ 'X-Requested-With': 'XMLHttpRequest',
324
+ }
325
+
326
+ # 使用变量
327
+ token_json_data = {'appId': 'hd', 'appSecret': app_secret, 'timeStamp': int(time.time() * 1000)}
328
+ # 动态构建 URL
329
+ token_url = build_tms_url(
330
+ f'{TMS_ORIGIN}/cinema-api/admin/generateToken?token=hd&murl=?token=hd&murl=ticket={ticket}',
331
+ proxy_base_url,
332
+ )
333
+ token_headers = with_tms_proxy_headers(token_headers, proxy_base_url)
334
+
335
+ try:
336
+ response = requests.post(token_url, headers=token_headers, json=token_json_data, timeout=10)
337
+ response.raise_for_status()
338
+ token_data = response.json()
339
+ if token_data.get('error_code') != '0000':
340
+ raise Exception(f"获取Token失败: {token_data.get('error_desc')}")
341
+ auth_token = token_data['param']
342
+ except requests.exceptions.HTTPError as e:
343
+ st.error(format_tms_http_error("连接 TMS 认证服务失败", e.response))
344
+ return {}, []
345
+ except Exception as e:
346
+ st.error(f"连接 TMS 认证服务失败: {e}")
347
+ return {}, []
348
+
349
+ all_movies, page_index = [], 1
350
+ while True:
351
+ list_headers = {
352
+ 'Accept': 'application/json, text/javascript, */*; q=0.01',
353
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7,zh-TW;q=0.6',
354
+ 'Content-Type': 'application/json; charset=UTF-8',
355
+ 'Cookie': f'JSESSIONID={x_session_id}',
356
+ 'DNT': '1',
357
+ 'Origin': 'https://tms.hengdianfilm.com',
358
+ 'Priority': 'u=1, i',
359
+ 'Referer': f'https://tms.hengdianfilm.com/hd/index?ContentMovie&THEATER_ID={theater_id}&SOURCE=SERVER&ASSERT_TYPE=2&PAGE_CAPACITY=20&PAGE_INDEX=1',
360
+ 'Sec-CH-UA': '"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"',
361
+ 'Sec-CH-UA-Mobile': '?0',
362
+ 'Sec-CH-UA-Platform': '"macOS"',
363
+ 'Sec-Fetch-Dest': 'empty',
364
+ 'Sec-Fetch-Mode': 'cors',
365
+ 'Sec-Fetch-Site': 'same-origin',
366
+ 'Token': auth_token,
367
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36',
368
+ 'X-Requested-With': 'XMLHttpRequest',
369
+ 'X-SESSIONID': x_session_id,
370
+ }
371
+ list_params = {'token': 'hd', 'murl': 'ContentMovie'}
372
+ list_json_data = {'THEATER_ID': theater_id, 'SOURCE': 'SERVER', 'ASSERT_TYPE': 2, 'PAGE_CAPACITY': 20,
373
+ 'PAGE_INDEX': page_index}
374
+
375
+ list_url = build_tms_url(f'{TMS_ORIGIN}/cinema-api/cinema/server/dcp/list', proxy_base_url)
376
+ list_headers = with_tms_proxy_headers(list_headers, proxy_base_url)
377
+ try:
378
+ response = requests.post(
379
+ list_url,
380
+ params=list_params,
381
+ headers=list_headers,
382
+ json=list_json_data,
383
+ verify=tms_verify_ssl(default=False, proxy_url=proxy_base_url),
384
+ timeout=15,
385
+ )
386
+ response.raise_for_status()
387
+ movie_data = response.json()
388
+ if movie_data.get("RSPCD") != "000000":
389
+ raise Exception(f"获取影片列表失��: {movie_data.get('RSPMSG')}")
390
+
391
+ body = movie_data.get("BODY", {})
392
+ movies_on_page = body.get("LIST", [])
393
+ if not movies_on_page: break
394
+ all_movies.extend(movies_on_page)
395
+ if len(all_movies) >= body.get("COUNT", 0): break
396
+ page_index += 1
397
+ time.sleep(0.5)
398
+ except requests.exceptions.HTTPError as e:
399
+ st.error(format_tms_http_error(f"获取影片列表页 {page_index} 失败", e.response))
400
+ break
401
+ except Exception as e:
402
+ st.error(f"获取影片列表页 {page_index} 失败: {e}")
403
+ break
404
+
405
+ return process_tms_movies(all_movies, priority_movie_titles)
406
+
407
+
408
+ def process_tms_movies(all_movies, priority_movie_titles=None):
409
+ if priority_movie_titles is None:
410
+ priority_movie_titles = []
411
+
412
+ # 处理数据
413
+ movie_details = {m.get('CONTENT_NAME'): {'assert_name': m.get('ASSERT_NAME'),
414
+ 'assert_id': m.get('ASSERT_ID'),
415
+ 'halls': sorted([h.get('HALL_NAME') for h in m.get('HALL_INFO', [])]),
416
+ 'play_time': m.get('PLAY_TIME')} for m in all_movies if
417
+ m.get('CONTENT_NAME')}
418
+
419
+ by_hall = defaultdict(list)
420
+ for content_name, details in movie_details.items():
421
+ for hall_name in details['halls']:
422
+ by_hall[hall_name].append({'content_name': content_name, 'details': details})
423
+
424
+ for hall_name in by_hall:
425
+ by_hall[hall_name].sort(
426
+ key=lambda item: (item['details']['assert_name'] is None or item['details']['assert_name'] == '',
427
+ item['details']['assert_name'] or item['content_name']))
428
+
429
+ view2_list = [{'assert_name': d['assert_name'], 'assert_id': d.get('assert_id'), 'content_name': c, 'halls': d['halls'], 'play_time': d['play_time']}
430
+ for c, d in movie_details.items() if d.get('assert_name')]
431
+
432
+ priority_list = [item for item in view2_list if
433
+ any(p_title in item['assert_name'] for p_title in priority_movie_titles)]
434
+ other_list_items = [item for item in view2_list if item not in priority_list]
435
+
436
+ priority_list.sort(key=lambda x: x['assert_name'])
437
+ other_list_items.sort(key=lambda x: x['assert_name'])
438
+ final_sorted_list = priority_list + other_list_items
439
+
440
+ return dict(sorted(by_hall.items())), final_sorted_list
441
+
442
+
443
+ # --- 主界面 ---
444
+
445
+ def main():
446
+ st.title("🔍 TMS 服务器影片内容查询")
447
+ st.info("查询 TMS 服务器上的 DCP 内容及分布情况。")
448
+ proxy_base_url = get_tms_proxy_base_url()
449
+ if proxy_base_url:
450
+ st.caption(f"当前 TMS 请求将通过中转代理访问:{proxy_base_url}")
451
+ else:
452
+ st.caption(f"当前 TMS 请求为直连;配置 `{TMS_PROXY_URL_ENV}` 后会自动启用中转代理。")
453
+
454
+ # 尝试从 Session State 获取优先显示的影片(如果在主页加载了排片)
455
+ priority_titles = []
456
+ if 'api_df' in st.session_state and not st.session_state.api_df.empty:
457
+ df = st.session_state.api_df
458
+ if '影片名称_清理后' in df.columns:
459
+ priority_titles = df['影片名称_清理后'].unique().tolist()
460
+ elif '影片名称' in df.columns:
461
+ priority_titles = df['影片名称'].apply(lambda x: clean_movie_title(x)).unique().tolist()
462
+
463
+ # 也可以检查 file_df
464
+ elif 'file_df' in st.session_state and not st.session_state.file_df.empty:
465
+ df = st.session_state.file_df
466
+ if '影片名称_清理后' in df.columns:
467
+ priority_titles = df['影片名称_清理后'].unique().tolist()
468
+ elif '影片名称' in df.columns:
469
+ priority_titles = df['影片名称'].apply(lambda x: clean_movie_title(x)).unique().tolist()
470
+
471
+ if st.button('点击查询 TMS 服务器', key="query_tms", type="primary", icon="🔍"):
472
+ with st.spinner("正在从 TMS 服务器获取数据中..."):
473
+ try:
474
+ halls_data, movie_list_sorted = fetch_and_process_server_movies(priority_titles, proxy_base_url)
475
+
476
+ if not movie_list_sorted:
477
+ st.warning("未获取到任何影片数据,请检查 TMS 连接配置。")
478
+ else:
479
+ st.success("TMS 服务器数据获取成功!")
480
+ content_movie_map = build_tms_content_movie_map(movie_list_sorted)
481
+ tms_stats_map = load_tms_content_stats_map()
482
+
483
+ # 1. 按影片查看
484
+ st.markdown("### 🎥 按影片查看所在影厅")
485
+ view2_data = []
486
+ for item in movie_list_sorted:
487
+ content_name = item['content_name']
488
+ stats = tms_stats_map.get(content_name, {})
489
+ view2_data.append({
490
+ '影片名称': item['assert_name'],
491
+ '所在影厅': " ".join(sorted([get_circled_number(h) for h in item['halls']])),
492
+ '时长(分钟)': format_play_time(item['play_time']),
493
+ '对应影片': stats.get('对应影片') or content_movie_map.get(content_name, ''),
494
+ '该影院总票房': stats.get('该影院总票房', ''),
495
+ '全国总票房': stats.get('全国总票房', ''),
496
+ '该影片在该影院总场次': stats.get('该影片在该影院总场次', ''),
497
+ '当前影厅该影片场次': stats.get('当前影厅该影片场次', ''),
498
+ '文件名': format_content_name_with_explanation(content_name),
499
+ })
500
+ view2_df = pd.DataFrame(view2_data)
501
+ for num_col in ['该影院总票房', '该影片在该影院总场次']:
502
+ if num_col in view2_df.columns:
503
+ view2_df[num_col] = pd.to_numeric(view2_df[num_col], errors='coerce')
504
+ view2_format = {
505
+ '该影院总票房': '{:,.2f}',
506
+ '该影片在该影院总场次': '{:,.0f}',
507
+ }
508
+ st.dataframe(
509
+ view2_df.style.format(
510
+ {k: v for k, v in view2_format.items() if k in view2_df.columns},
511
+ na_rep="",
512
+ ),
513
+ hide_index=True,
514
+ width="stretch",
515
+ )
516
+
517
+ st.divider()
518
+
519
+ # 2. 按影厅查看
520
+ st.markdown("### 🏢 按影厅查看影片内容")
521
+ if halls_data:
522
+ hall_tabs = st.tabs(list(halls_data.keys()))
523
+ for tab, hall_name in zip(hall_tabs, halls_data.keys()):
524
+ with tab:
525
+ view1_data = []
526
+ for item in halls_data[hall_name]:
527
+ content_name = item['content_name']
528
+ stats = tms_stats_map.get(content_name, {})
529
+ view1_data.append({
530
+ '影片名称': item['details']['assert_name'],
531
+ '所在影厅': " ".join(sorted([get_circled_number(h) for h in item['details']['halls']])),
532
+ '时长(分钟)': format_play_time(item['details']['play_time']),
533
+ '对应影片': stats.get('对应影片') or content_movie_map.get(content_name, ''),
534
+ '该影院总票房': stats.get('该影院总票房', ''),
535
+ '全国总票房': stats.get('全国总票房', ''),
536
+ '该影片在该影院总场次': stats.get('该影片在该影院总场次', ''),
537
+ '当前影厅该影片场次': stats.get('当前影厅该影片场次', ''),
538
+ '文件名': format_content_name_with_explanation(content_name),
539
+ })
540
+ view1_df = pd.DataFrame(view1_data)
541
+ for num_col in ['该影院总票房', '该影片在该影院总场次']:
542
+ if num_col in view1_df.columns:
543
+ view1_df[num_col] = pd.to_numeric(view1_df[num_col], errors='coerce')
544
+ st.dataframe(
545
+ view1_df.style.format(
546
+ {k: v for k, v in view2_format.items() if k in view1_df.columns},
547
+ na_rep="",
548
+ ),
549
+ hide_index=True,
550
+ width="stretch",
551
+ )
552
+ else:
553
+ st.info("暂无影厅数据。")
554
+
555
+ except Exception as e:
556
+ st.error(f"查询 TMS 服务器时出错: {e}")
557
+
558
+ if __name__ == "__main__":
559
+ main()
pages/🔦 影片放映时间表统计.py ADDED
@@ -0,0 +1,409 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ from io import StringIO
3
+ from math import floor
4
+
5
+ import pandas as pd
6
+ import streamlit as st
7
+
8
+ DISPLAY_COLUMNS = ['测试时长(分)', '广告时长(分)', '影片播放时长(分)', '剩余激光时长(小时)']
9
+ REMAINING_COLUMN = '剩余激光时长(小时)'
10
+
11
+ st.set_page_config(layout="wide")
12
+ st.title('🔦 影片放映时间表统计')
13
+
14
+
15
+ def build_table_styles():
16
+ return [
17
+ {
18
+ 'selector': 'th.col_heading',
19
+ 'props': [
20
+ ('background-color', '#4a4a4a'),
21
+ ('color', 'white'),
22
+ ('text-align', 'center'),
23
+ ],
24
+ },
25
+ {
26
+ 'selector': 'th.row_heading',
27
+ 'props': [('text-align', 'center')],
28
+ },
29
+ {
30
+ 'selector': 'td',
31
+ 'props': [('text-align', 'center')],
32
+ },
33
+ ]
34
+
35
+
36
+ def clean_hall_name(name):
37
+ if isinstance(name, str):
38
+ match = re.search(r'(\d+)号', name)
39
+ if match:
40
+ return f"{match.group(1)}号厅"
41
+ return name
42
+
43
+
44
+ def get_hall_sort_key(name):
45
+ numbers = re.findall(r'\d+', str(name))
46
+ if numbers:
47
+ return 0, int(numbers[0]), str(name)
48
+ return 1, 0, str(name)
49
+
50
+
51
+ def normalize_text(value):
52
+ return str(value or '').strip().replace('(', '(').replace(')', ')').replace(' ', '')
53
+
54
+
55
+ def parse_number(value):
56
+ text = str(value).strip().replace(',', '')
57
+ if not text:
58
+ return None
59
+ try:
60
+ return float(text)
61
+ except ValueError:
62
+ return None
63
+
64
+
65
+ def format_number(value):
66
+ if value == '':
67
+ return ''
68
+ number = parse_number(value)
69
+ if number is None:
70
+ return value
71
+ if abs(number - round(number)) < 1e-9:
72
+ return int(round(number))
73
+ return round(number, 2)
74
+
75
+
76
+ def floor_display_hours(value):
77
+ return int(floor(float(value) + 1e-9))
78
+
79
+
80
+ def is_metric_header(value):
81
+ normalized = normalize_text(value)
82
+ return any(keyword in normalized for keyword in ['测试时长', '广告时长', '影片播放时长', '剩余激光时长'])
83
+
84
+
85
+ def format_table_for_display(table):
86
+ display_table = table.copy().astype(object)
87
+ for column in display_table.columns:
88
+ display_table[column] = display_table[column].map(format_number)
89
+ return display_table
90
+
91
+
92
+ def render_table(table, style_df=None):
93
+ styler = format_table_for_display(table).style.set_table_styles(build_table_styles())
94
+ if style_df is not None:
95
+ styler = styler.apply(lambda _: style_df, axis=None)
96
+ table_height = min(1200, (len(table) + 3) * 35)
97
+ st.dataframe(styler, height=table_height, width="stretch")
98
+
99
+
100
+ def build_excel_copy_text(table):
101
+ export_df = format_table_for_display(table).reset_index(drop=True)
102
+ return export_df.to_csv(sep='\t', index=False, header=False).rstrip('\n')
103
+
104
+
105
+ def build_schedule_pivot_table(df, ad_duration, test_duration):
106
+ df = df.copy()
107
+ df['影片'] = df['影片'].astype(str)
108
+ df['影厅'] = df['影厅'].apply(clean_hall_name)
109
+ df['放映日期'] = pd.to_datetime(df['放映日期'])
110
+ df['日期'] = df['放映日期'].dt.strftime('%m月%d日')
111
+ df.dropna(subset=['影厅', '片长'], inplace=True)
112
+
113
+ summary = df.groupby(['日期', '影厅']).agg(
114
+ 影片数量=('影片', 'count'),
115
+ 原始影片时长=('片长', 'sum'),
116
+ ).reset_index()
117
+ summary['测试时长(分)'] = test_duration
118
+ summary['广告时长(分)'] = summary['影片数量'] * ad_duration
119
+ summary['影片播放时长(分)'] = summary['原始影片时长']
120
+
121
+ pivot_table = summary.pivot_table(
122
+ index='日期',
123
+ columns='影厅',
124
+ values=['测试时长(分)', '广告时长(分)', '影片播放时长(分)'],
125
+ ).fillna(0).astype(int)
126
+
127
+ if pivot_table.empty:
128
+ return pivot_table
129
+
130
+ pivot_table = pivot_table.swaplevel(0, 1, axis=1).sort_index(axis=1)
131
+ halls = sorted(pivot_table.columns.get_level_values(0).unique(), key=get_hall_sort_key)
132
+ new_columns = pd.MultiIndex.from_product([halls, DISPLAY_COLUMNS], names=['影厅', None])
133
+ pivot_table = pivot_table.reindex(columns=new_columns).fillna('')
134
+ return pivot_table
135
+
136
+
137
+ def detect_date_column(raw_df):
138
+ for row_index in range(min(3, len(raw_df))):
139
+ if '日期' in normalize_text(raw_df.iat[row_index, 0]):
140
+ return True
141
+
142
+ for row_index in range(len(raw_df)):
143
+ first_value = str(raw_df.iat[row_index, 0]).strip()
144
+ rest_values = [str(value).strip() for value in raw_df.iloc[row_index, 1:].tolist()]
145
+ non_empty_rest = [value for value in rest_values if value]
146
+ if first_value and parse_number(first_value) is None and len(non_empty_rest) >= 4:
147
+ if all(parse_number(value) is not None for value in non_empty_rest):
148
+ return True
149
+ return False
150
+
151
+
152
+ def find_first_data_row(raw_df, start_col):
153
+ for row_index in range(len(raw_df)):
154
+ row_values = [str(value).strip() for value in raw_df.iloc[row_index, start_col:].tolist()]
155
+ non_empty_values = [value for value in row_values if value]
156
+ if len(non_empty_values) >= 4 and all(parse_number(value) is not None for value in non_empty_values):
157
+ return row_index
158
+ return None
159
+
160
+
161
+ def extract_hall_names(raw_df, first_data_row, start_col, group_count) -> list[str]:
162
+ hall_names: list[str] = []
163
+ for group_index in range(group_count):
164
+ hall_name = ''
165
+ group_start_col = start_col + group_index * 4
166
+ for row_index in range(first_data_row):
167
+ candidate = str(raw_df.iat[row_index, group_start_col]).strip()
168
+ if candidate and not is_metric_header(candidate):
169
+ hall_name = clean_hall_name(candidate)
170
+ break
171
+ if not hall_name:
172
+ hall_name = f'{group_index + 1}号厅'
173
+ hall_names.append(hall_name)
174
+ return hall_names
175
+
176
+
177
+ def parse_pasted_laser_text(pasted_text):
178
+ raw_df = pd.read_csv(StringIO(pasted_text), sep='\t', header=None, dtype=str, keep_default_na=False)
179
+ raw_df = raw_df.replace(r'^\s*$', pd.NA, regex=True).dropna(axis=0, how='all').dropna(axis=1, how='all')
180
+ raw_df = raw_df.fillna('').reset_index(drop=True)
181
+
182
+ if raw_df.empty:
183
+ raise ValueError('未识别到任何数据。')
184
+
185
+ has_date_column = detect_date_column(raw_df)
186
+ start_col = 1 if has_date_column else 0
187
+ first_data_row = find_first_data_row(raw_df, start_col)
188
+ if first_data_row is None:
189
+ raise ValueError('未识别到可计算的数据行,请检查粘贴内容。')
190
+
191
+ value_col_count = raw_df.shape[1] - start_col
192
+ if value_col_count <= 0 or value_col_count % 4 != 0:
193
+ raise ValueError(f'检测到 {value_col_count} 列有效数据,无法按每 4 列一个影厅进行解析。')
194
+
195
+ group_count = value_col_count // 4
196
+ hall_names: list[str] = extract_hall_names(raw_df, first_data_row, start_col, group_count)
197
+ parsed_rows: list[list[float]] = []
198
+ row_labels: list[str] = []
199
+
200
+ for row_index in range(first_data_row, len(raw_df)):
201
+ label = str(raw_df.iat[row_index, 0]).strip() if has_date_column else f'第{len(row_labels) + 1}天'
202
+ if not label:
203
+ label = f'第{len(row_labels) + 1}天'
204
+
205
+ numeric_row = []
206
+ row_values = [str(value).strip() for value in raw_df.iloc[row_index, start_col:].tolist()]
207
+ non_empty_values = [value for value in row_values if value]
208
+ if not non_empty_values:
209
+ continue
210
+
211
+ if not all(parse_number(value) is not None for value in non_empty_values):
212
+ continue
213
+
214
+ if len(row_values) != value_col_count:
215
+ raise ValueError(f'第 {row_index + 1} 行列数不完整。')
216
+
217
+ for col_index, raw_value in enumerate(row_values, start=1):
218
+ number = parse_number(raw_value)
219
+ if number is None:
220
+ raise ValueError(f'第 {row_index + 1} 行第 {col_index + start_col} 列不是数字:{raw_value}')
221
+ numeric_row.append(number)
222
+
223
+ row_labels.append(label)
224
+ parsed_rows.append(numeric_row)
225
+
226
+ if not parsed_rows:
227
+ raise ValueError('未识别到可计算的数据行,请检查粘贴内容。')
228
+
229
+ columns = pd.MultiIndex.from_product([hall_names, DISPLAY_COLUMNS], names=['影厅', None])
230
+ table = pd.DataFrame(parsed_rows, index=pd.Index(row_labels), columns=columns)
231
+ table.index.name = '日期' if has_date_column else '天次'
232
+ return table
233
+
234
+
235
+ def find_best_recharge_count(previous_remaining, usage_hours, actual_remaining, recharge_hours):
236
+ theoretical_without_recharge = previous_remaining - usage_hours
237
+ if recharge_hours <= 0:
238
+ theoretical_display = floor_display_hours(theoretical_without_recharge)
239
+ return 0, theoretical_display
240
+
241
+ approx_count = max(0, int(round((actual_remaining - theoretical_without_recharge) / recharge_hours)))
242
+ candidate_counts = range(max(0, approx_count - 2), approx_count + 3)
243
+ best_count = 0
244
+ best_display = floor_display_hours(theoretical_without_recharge)
245
+ best_gap = abs(actual_remaining - best_display)
246
+
247
+ for recharge_count in candidate_counts:
248
+ theoretical_display = floor_display_hours(theoretical_without_recharge + recharge_count * recharge_hours)
249
+ gap = abs(actual_remaining - theoretical_display)
250
+ if gap < best_gap or (gap == best_gap and recharge_count < best_count):
251
+ best_count = recharge_count
252
+ best_display = theoretical_display
253
+ best_gap = gap
254
+
255
+ return best_count, best_display
256
+
257
+
258
+ def build_laser_check_style(table, recharge_hours):
259
+ style_df = pd.DataFrame('', index=table.index, columns=table.columns)
260
+ abnormal_count = 0
261
+ halls = list(dict.fromkeys(table.columns.get_level_values(0)))
262
+
263
+ for hall_name in halls:
264
+ previous_remaining = None
265
+ for row_label in table.index:
266
+ actual_remaining = float(table.loc[row_label, (hall_name, REMAINING_COLUMN)])
267
+ if previous_remaining is None:
268
+ previous_remaining = actual_remaining
269
+ continue
270
+
271
+ ad_minutes = float(table.loc[row_label, (hall_name, '广告时长(分)')])
272
+ movie_minutes = float(table.loc[row_label, (hall_name, '影片播放时长(分)')])
273
+ usage_hours = (ad_minutes + movie_minutes) / 60
274
+ _, theoretical_display = find_best_recharge_count(previous_remaining, usage_hours, actual_remaining, recharge_hours)
275
+ diff_hours = actual_remaining - theoretical_display
276
+
277
+ if abs(diff_hours) > 1:
278
+ abnormal_count += 1
279
+ text_color = '#d32f2f' if diff_hours < 0 else '#2e7d32'
280
+ style_df.loc[row_label, (hall_name, REMAINING_COLUMN)] = (
281
+ f'background-color: #fff3b0; color: {text_color}; font-weight: 700'
282
+ )
283
+
284
+ previous_remaining = actual_remaining
285
+
286
+ return style_df, abnormal_count
287
+
288
+
289
+ def build_laser_gap_summary(table, recharge_hours):
290
+ records = []
291
+ halls = list(dict.fromkeys(table.columns.get_level_values(0)))
292
+
293
+ for hall_name in halls:
294
+ previous_remaining = None
295
+ total_content_hours = 0.0
296
+ total_laser_hours = 0.0
297
+
298
+ for row_label in table.index:
299
+ actual_remaining = float(table.loc[row_label, (hall_name, REMAINING_COLUMN)])
300
+ if previous_remaining is None:
301
+ previous_remaining = actual_remaining
302
+ continue
303
+
304
+ ad_minutes = float(table.loc[row_label, (hall_name, '广告时长(分)')])
305
+ movie_minutes = float(table.loc[row_label, (hall_name, '影片播放时长(分)')])
306
+ content_hours = (ad_minutes + movie_minutes) / 60
307
+ recharge_count, _ = find_best_recharge_count(previous_remaining, content_hours, actual_remaining, recharge_hours)
308
+ actual_laser_hours = previous_remaining + recharge_count * recharge_hours - actual_remaining
309
+
310
+ total_content_hours += content_hours
311
+ total_laser_hours += actual_laser_hours
312
+ previous_remaining = actual_remaining
313
+
314
+ diff_hours = total_content_hours - total_laser_hours
315
+ if diff_hours > 1e-9:
316
+ result_text = f'节省 {diff_hours:.2f} 小时'
317
+ elif diff_hours < -1e-9:
318
+ result_text = f'浪费 {abs(diff_hours):.2f} 小时'
319
+ else:
320
+ result_text = '无差额'
321
+
322
+ records.append({
323
+ '影厅': hall_name,
324
+ '广告+影片时长(小时)': round(total_content_hours, 2),
325
+ '激光时长(小时)': round(total_laser_hours, 2),
326
+ '差额(小时)': round(diff_hours, 2),
327
+ '结果': result_text,
328
+ })
329
+
330
+ return pd.DataFrame(records)
331
+
332
+
333
+ def render_schedule_summary():
334
+ st.subheader('上传文件生成统计')
335
+ uploaded_file = st.file_uploader('上传“影片放映时间表.xlsx”文件', type=['xlsx'], key='laser_upload_file')
336
+
337
+ col1, col2 = st.columns(2)
338
+ with col1:
339
+ ad_duration = st.number_input('输入每个广告的时长(分钟)', min_value=0, value=5, key='laser_ad_duration')
340
+ with col2:
341
+ test_duration = st.number_input('输入测试时长(分)', min_value=0, value=5, key='laser_test_duration')
342
+
343
+ if uploaded_file is None:
344
+ return
345
+
346
+ try:
347
+ df = pd.read_excel(uploaded_file, header=3)
348
+ st.subheader('上传的原始数据')
349
+ st.dataframe(df, width="stretch")
350
+
351
+ pivot_table = build_schedule_pivot_table(df, ad_duration, test_duration)
352
+ if pivot_table.empty:
353
+ st.warning('没有可用于生成统计信息的数据。')
354
+ return
355
+
356
+ st.subheader('影厅播放统计')
357
+ render_table(pivot_table)
358
+
359
+ st.subheader('复制到 Excel')
360
+ st.caption('以下内容从“测试时长(分)”第一个数据开始,到最后一个数据结束;不包含日期和表头。')
361
+ st.code(build_excel_copy_text(pivot_table), language='text')
362
+ except Exception as e:
363
+ st.error(f'处理文件时出错: {e}')
364
+
365
+
366
+ def render_laser_checker():
367
+ st.divider()
368
+ st.subheader('粘贴文本校验剩余激光时长')
369
+ st.caption('支持直接粘贴 Excel 文本。黄色底色表示差异绝对值超过 1 小时;红字表示实际值低于正常值,绿字表示实际值高于正常值。')
370
+
371
+ recharge_hours = st.number_input('激光默认充值小时数', min_value=0, value=1000, step=100, key='laser_recharge_hours')
372
+ pasted_text = st.text_area(
373
+ '粘贴需要校验的文本',
374
+ height=260,
375
+ placeholder='日期\t1号厅\t\t\t\n\t测试时长(分)\t广告时长(分)\t影片播放时长(分)\t剩余激光时长(小时)\n3月1日\t5\t30\t750\t665\n3月2日\t5\t25\t625\t654',
376
+ key='laser_pasted_text',
377
+ )
378
+
379
+ if not pasted_text.strip():
380
+ return
381
+
382
+ try:
383
+ pasted_table = parse_pasted_laser_text(pasted_text)
384
+ style_df, abnormal_count = build_laser_check_style(pasted_table, float(recharge_hours))
385
+ except Exception as e:
386
+ st.error(f'校验失败:{e}')
387
+ return
388
+
389
+ hall_count = int(pasted_table.columns.get_level_values(0).nunique())
390
+ if abnormal_count > 0:
391
+ st.warning(f'共识别 {hall_count} 个影厅,发现 {abnormal_count} 个异常检查点。')
392
+ else:
393
+ st.success(f'共识别 {hall_count} 个影厅,所有检查点都在 1 小时以内。')
394
+
395
+ render_table(pasted_table, style_df=style_df)
396
+
397
+ st.divider()
398
+ st.subheader('各影厅激光差额汇总')
399
+ st.caption('按当前输入的全部数据逐行累计计算:广告时长 + 影片时长 大于 激光时长 记为节省,反之记为浪费。')
400
+
401
+ gap_summary_df = build_laser_gap_summary(pasted_table, float(recharge_hours))
402
+ if gap_summary_df.empty:
403
+ st.info('当前数据不足以计算各影厅差额汇总。')
404
+ else:
405
+ st.dataframe(gap_summary_df, width="stretch", hide_index=True)
406
+
407
+
408
+ render_schedule_summary()
409
+ render_laser_checker()
pages/🧐 影片效率分析.py ADDED
@@ -0,0 +1,916 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import pandas as pd
3
+ import numpy as np
4
+ import datetime
5
+ import altair as alt
6
+ import json
7
+ import tempfile
8
+ import os
9
+ from pathlib import Path
10
+
11
+ from dotenv import load_dotenv
12
+
13
+ from historical_sessions import (
14
+ LOCAL_HISTORY_FILE,
15
+ LOCAL_HISTORY_MANIFEST_FILE,
16
+ build_duration_reference_from_history,
17
+ create_duration_label,
18
+ create_empty_history_df,
19
+ default_history_manifest,
20
+ load_history_df,
21
+ load_history_manifest,
22
+ merge_history_df,
23
+ round_minutes_to_10min,
24
+ save_history_df,
25
+ save_history_manifest,
26
+ )
27
+ from r2_storage import R2Storage
28
+
29
+ # --- 全局设置 ---
30
+ st.set_page_config(layout="wide", page_title="影片效率分析")
31
+
32
+ ROOT_DIR = Path(__file__).resolve().parent.parent
33
+ load_dotenv(dotenv_path=str(ROOT_DIR / ".env"), override=True)
34
+
35
+
36
+ # --- 自动数据获取(本地 + R2)---
37
+ def build_default_prefix():
38
+ cinema_id = (os.getenv("CINEMA_ID") or "").strip() or "default"
39
+ return f"{cinema_id}/sessions_total"
40
+
41
+
42
+ def build_r2_history_keys():
43
+ object_prefix = build_default_prefix().rstrip("/")
44
+ return (
45
+ f"{object_prefix}/historical_sessions.csv",
46
+ f"{object_prefix}/historical_sessions_manifest.json",
47
+ )
48
+
49
+
50
+ def load_history_df_from_path(csv_path: Path) -> pd.DataFrame:
51
+ """从指定 CSV 路径加载历史场次数据,失败时返回空 DataFrame。"""
52
+ try:
53
+ from historical_sessions import _normalize_history_df
54
+ return _normalize_history_df(pd.read_csv(csv_path))
55
+ except Exception:
56
+ return create_empty_history_df()
57
+
58
+
59
+ @st.cache_data(show_spinner=False)
60
+ def _cached_load_local_history(path_str: str, mtime: float, size: int) -> pd.DataFrame:
61
+ """
62
+ 进程级缓存:本地 CSV 一旦解析成功,后续所有会话直接复用。
63
+ mtime / size 参与 cache key,文件被覆盖时自动失效。
64
+ """
65
+ return load_history_df()
66
+
67
+
68
+ def load_local_history_cached() -> pd.DataFrame:
69
+ if LOCAL_HISTORY_FILE.exists():
70
+ stat = LOCAL_HISTORY_FILE.stat()
71
+ return _cached_load_local_history(str(LOCAL_HISTORY_FILE), stat.st_mtime, stat.st_size)
72
+ return load_history_df()
73
+
74
+
75
+ def load_manifest_from_path(manifest_path: Path) -> dict:
76
+ manifest = default_history_manifest()
77
+ if manifest_path.exists():
78
+ try:
79
+ payload = json.loads(manifest_path.read_text(encoding="utf-8"))
80
+ if isinstance(payload, dict):
81
+ manifest.update(payload)
82
+ except Exception:
83
+ pass
84
+ return manifest
85
+
86
+
87
+ def auto_load_history_dataset(force_r2_sync: bool = False):
88
+ """
89
+ 自动获取历史场次数据。
90
+ 1. 优先读取本地缓存 `cinema_cache/historical_sessions.csv`。
91
+ 2. 当本地缺失或用户强制刷新时,尝试从 R2 下载,并合并保存。
92
+ 返回 (history_df, source_label, message)。
93
+ """
94
+ csv_key, manifest_key = build_r2_history_keys()
95
+ local_exists = LOCAL_HISTORY_FILE.exists()
96
+ storage = None
97
+ storage_error = None
98
+
99
+ try:
100
+ storage = R2Storage()
101
+ except Exception as exc:
102
+ storage_error = str(exc)
103
+
104
+ # 情况 1:本地存在且无需强制 R2 同步
105
+ if local_exists and not force_r2_sync:
106
+ df = load_local_history_cached()
107
+ msg = f"已加载本地历史库({len(df)} 条记录)。"
108
+ if storage_error:
109
+ msg += f" R2 暂不可用:{storage_error}"
110
+ return df, "local", msg
111
+
112
+ # 情况 2:本地不存在但 R2 不可用
113
+ if not local_exists and storage is None:
114
+ return (
115
+ create_empty_history_df(),
116
+ "empty",
117
+ f"本地历史库不存在,且 R2 不可用:{storage_error or '未配置'}",
118
+ )
119
+
120
+ # 情况 3:需要从 R2 下载
121
+ if storage is not None:
122
+ try:
123
+ remote_csv_exists = storage.exists(csv_key)
124
+ remote_manifest_exists = storage.exists(manifest_key)
125
+ except Exception as exc:
126
+ df = load_history_df() if local_exists else create_empty_history_df()
127
+ return df, "local" if local_exists else "empty", f"R2 检查失败:{exc}"
128
+
129
+ if not remote_csv_exists:
130
+ df = load_local_history_cached() if local_exists else create_empty_history_df()
131
+ source = "local" if local_exists else "empty"
132
+ return df, source, "R2 上暂无历史库文件。" + ("" if local_exists else "等待管理员先在监控页同步数据。")
133
+
134
+ try:
135
+ with tempfile.TemporaryDirectory(prefix="xiaolv_history_") as temp_dir:
136
+ temp_dir_path = Path(temp_dir)
137
+ remote_csv_path = temp_dir_path / "historical_sessions.csv"
138
+ remote_manifest_path = temp_dir_path / "historical_sessions_manifest.json"
139
+ storage.download_file(csv_key, remote_csv_path)
140
+ if remote_manifest_exists:
141
+ storage.download_file(manifest_key, remote_manifest_path)
142
+ remote_df = load_history_df_from_path(remote_csv_path)
143
+ remote_manifest = load_manifest_from_path(remote_manifest_path)
144
+ except Exception as exc:
145
+ df = load_local_history_cached() if local_exists else create_empty_history_df()
146
+ return df, "local" if local_exists else "empty", f"从 R2 下载失败:{exc}"
147
+
148
+ if local_exists:
149
+ local_df = load_local_history_cached()
150
+ local_manifest = load_history_manifest()
151
+ merged_df = merge_history_df(local_df, remote_df)
152
+ merged_manifest = local_manifest.copy()
153
+ merged_manifest.setdefault("synced_dates", [])
154
+ merged_manifest["synced_dates"] = sorted(
155
+ set(merged_manifest.get("synced_dates", [])) | set(remote_manifest.get("synced_dates", []))
156
+ )
157
+ save_history_manifest(merged_manifest)
158
+ return (
159
+ merged_df,
160
+ "local+r2",
161
+ f"已把 R2 历史库与本地合并(合并后 {len(merged_df)} 条记录)。",
162
+ )
163
+ else:
164
+ saved_df = save_history_df(remote_df)
165
+ save_history_manifest(remote_manifest)
166
+ return (
167
+ saved_df,
168
+ "r2",
169
+ f"本地无历史库,已从 R2 下载({len(saved_df)} 条记录)。",
170
+ )
171
+
172
+ # 兜底
173
+ return create_empty_history_df(), "empty", "未能加载任何历史数据。"
174
+
175
+
176
+ def render_analysis_overview(df, start_date, end_date):
177
+ total_revenue = float(pd.to_numeric(df.get('总收入'), errors='coerce').fillna(0).sum()) if not df.empty else 0.0
178
+ total_people = float(pd.to_numeric(df.get('总人次'), errors='coerce').fillna(0).sum()) if not df.empty else 0.0
179
+ total_sessions = int(len(df))
180
+ movie_count = int(df['影片名称_清理后'].nunique()) if '影片名称_清理后' in df.columns and not df.empty else 0
181
+ date_label = f"{start_date.strftime('%Y-%m-%d')} 至 {end_date.strftime('%Y-%m-%d')}"
182
+ st.markdown(
183
+ f"""
184
+ <div style="margin: 8px 0 18px 0; padding: 18px 22px; border-radius: 10px; background: #fff7ed; border: 1px solid #fed7aa;">
185
+ <div style="font-size: 16px; font-weight: 700; color: #7c2d12; margin-bottom: 10px;">{date_label} 数据总览</div>
186
+ <div style="display: flex; gap: 26px; flex-wrap: wrap; align-items: baseline;">
187
+ <div><span style="font-size: 18px; font-weight: 700; color: #5C1F00;">总票房:</span><span style="font-size: 34px; font-weight: 900; color: #c2410c;">¥{total_revenue:,.2f}</span></div>
188
+ <div><span style="font-size: 18px; font-weight: 700; color: #5C1F00;">总人次:</span><span style="font-size: 34px; font-weight: 900; color: #c2410c;">{total_people:,.0f}</span></div>
189
+ <div><span style="font-size: 18px; font-weight: 700; color: #5C1F00;">总场次:</span><span style="font-size: 34px; font-weight: 900; color: #c2410c;">{total_sessions:,.0f}</span></div>
190
+ <div><span style="font-size: 18px; font-weight: 700; color: #5C1F00;">影片数:</span><span style="font-size: 34px; font-weight: 900; color: #c2410c;">{movie_count:,.0f}</span></div>
191
+ </div>
192
+ </div>
193
+ """,
194
+ unsafe_allow_html=True,
195
+ )
196
+
197
+
198
+ def process_and_analyze_data(df):
199
+ if df.empty:
200
+ return pd.DataFrame()
201
+ analysis_df = df.groupby('影片名称_清理后').agg(
202
+ 座位数=('座位数', 'sum'), 场次=('影片名称_清理后', 'size'),
203
+ 票房=('总收入', 'sum'), 人次=('总人次', 'sum')
204
+ ).reset_index()
205
+ analysis_df.rename(columns={'影片名称_清理后': '影片'}, inplace=True)
206
+ analysis_df = analysis_df.sort_values(by='票房', ascending=False).reset_index(drop=True)
207
+
208
+ total_seats = analysis_df['座位数'].sum()
209
+ total_sessions = analysis_df['场次'].sum()
210
+ total_revenue = analysis_df['票房'].sum()
211
+
212
+ if total_revenue == 0:
213
+ analysis_df[['均价', '座次比', '场次比', '票房比', '座次效率', '场次效率']] = 0
214
+ else:
215
+ analysis_df['均价'] = np.divide(analysis_df['票房'], analysis_df['人次']).fillna(0)
216
+ analysis_df['座次比'] = np.divide(analysis_df['座位数'], total_seats).fillna(0) if total_seats > 0 else 0
217
+ analysis_df['场次比'] = np.divide(analysis_df['场次'], total_sessions).fillna(0) if total_sessions > 0 else 0
218
+ analysis_df['票房比'] = np.divide(analysis_df['票房'], total_revenue).fillna(0)
219
+ analysis_df['座次效率'] = np.divide(analysis_df['票房比'], analysis_df['座次比']).fillna(0)
220
+ analysis_df['场次效率'] = np.divide(analysis_df['票房比'], analysis_df['场次比']).fillna(0)
221
+
222
+ final_columns = ['影片', '座位数', '场次', '票房', '人次', '均价', '座次比', '场次比', '票房比', '座次效率',
223
+ '场次效率']
224
+ analysis_df = analysis_df[final_columns]
225
+ return analysis_df
226
+
227
+
228
+ def get_chinese_holidays_2025():
229
+ holidays = set()
230
+ holidays.add(datetime.date(2025, 1, 1))
231
+ holidays.update([datetime.date(2025, 1, 28) + datetime.timedelta(days=i) for i in range(7)])
232
+ holidays.update([datetime.date(2025, 4, 4) + datetime.timedelta(days=i) for i in range(3)])
233
+ holidays.update([datetime.date(2025, 5, 1) + datetime.timedelta(days=i) for i in range(5)])
234
+ holidays.update([datetime.date(2025, 5, 30) + datetime.timedelta(days=i) for i in range(3)])
235
+ holidays.add(datetime.date(2025, 10, 6))
236
+ holidays.update([datetime.date(2025, 10, 1) + datetime.timedelta(days=i) for i in range(7)])
237
+ return holidays
238
+
239
+
240
+ def plot_daily_box_office(df, selected_movie='全部影片'):
241
+ if df.empty:
242
+ return None
243
+ plot_df = df[df['影片名称_清理后'] == selected_movie].copy() if selected_movie != '全部影片' else df.copy()
244
+ if plot_df.empty:
245
+ st.warning(f"影片《{selected_movie}》在所选日期范围内没有找到数据。")
246
+ return None
247
+ daily_revenue = plot_df.groupby('放映日期')['总收入'].sum().reset_index()
248
+ daily_revenue.rename(columns={'放映日期': '日期', '总收入': '票房'}, inplace=True)
249
+ total_box_office = daily_revenue['票房'].sum()
250
+ chart_title = f'每日票房表现 - {selected_movie} | 总票房: {total_box_office:,.0f} 元'
251
+ start_date = pd.to_datetime(df['放映日期'].min())
252
+ end_date = pd.to_datetime(df['放映日期'].max())
253
+ full_date_range = pd.to_datetime(pd.date_range(start=start_date, end=end_date, freq='D'))
254
+ daily_revenue['日期'] = pd.to_datetime(daily_revenue['日期'])
255
+ daily_revenue = pd.merge(pd.DataFrame({'日期': full_date_range}), daily_revenue, on='日期', how='left').fillna(0)
256
+ holidays = get_chinese_holidays_2025()
257
+ daily_revenue['day_of_week'] = daily_revenue['日期'].dt.dayofweek
258
+ daily_revenue['类型'] = daily_revenue.apply(
259
+ lambda row: '节假日' if row['日期'].date() in holidays else (
260
+ '周末' if row['day_of_week'] in [4, 5, 6] else '工作日'),
261
+ axis=1,
262
+ )
263
+ chart = alt.Chart(daily_revenue).mark_bar().encode(
264
+ x=alt.X('日期:T', title='日期', axis=alt.Axis(labelAngle=-45, format='%m-%d')),
265
+ y=alt.Y('票房:Q', title='票房 (元)', scale=alt.Scale(domainMin=0)),
266
+ color=alt.Color('类型:N',
267
+ scale=alt.Scale(domain=['工作日', '周末', '节假日'], range=['#87CEEB', '#FFA500', '#FF4500']),
268
+ legend=alt.Legend(title="日期类型")),
269
+ tooltip=[alt.Tooltip('日期:T', format='%Y-%m-%d', title='日期'),
270
+ alt.Tooltip('票房:Q', format=',.2f', title='票房'),
271
+ alt.Tooltip('类型:N', title='类型')]
272
+ ).properties(title=chart_title).interactive()
273
+ return chart
274
+
275
+
276
+ def round_time_to_5min(t_datetime):
277
+ delta = t_datetime - datetime.datetime.min
278
+ intervals = delta.total_seconds() // 300
279
+ return (datetime.datetime.min + datetime.timedelta(seconds=intervals * 300)).time()
280
+
281
+
282
+ def enrich_df_with_duration_types(df, duration_df):
283
+ if df.empty:
284
+ return pd.DataFrame(), []
285
+
286
+ enriched_df = df.copy()
287
+ if '影片时长(分钟)' not in enriched_df.columns:
288
+ enriched_df['影片时长(分钟)'] = np.nan
289
+
290
+ enriched_df['影片时长(分钟)'] = pd.to_numeric(enriched_df['影片时长(分钟)'], errors='coerce')
291
+
292
+ if duration_df is not None and not duration_df.empty:
293
+ fallback_df = duration_df.copy()
294
+ if '记录场次' not in fallback_df.columns:
295
+ fallback_df['记录场次'] = 0
296
+ fallback_df['影片时长(分钟)'] = pd.to_numeric(fallback_df['影片时长(分钟)'], errors='coerce')
297
+ fallback_df = fallback_df.dropna(subset=['影片名称_清理后', '影片时长(分钟)']).copy()
298
+ fallback_df = fallback_df.sort_values(['记录场次', '影片时长(分钟)'], ascending=[False, False])
299
+ fallback_df = fallback_df.drop_duplicates(subset=['影片名称_清理后'], keep='first')
300
+ fallback_map = fallback_df.set_index('影片名称_清理后')['影片时长(分钟)'].to_dict()
301
+
302
+ missing_mask = enriched_df['影片时长(分钟)'].isna()
303
+ enriched_df.loc[missing_mask, '影片时长(分钟)'] = enriched_df.loc[missing_mask, '影片名称_清理后'].map(fallback_map)
304
+
305
+ enriched_df = enriched_df[
306
+ (enriched_df['影片时长(分钟)'].isna()) |
307
+ ((enriched_df['影片时长(分钟)'] > 0) & (enriched_df['影片时长(分钟)'] <= 400))
308
+ ].copy()
309
+ enriched_df['影片时长(分钟)'] = pd.to_numeric(enriched_df['影片时长(分钟)'], errors='coerce')
310
+ enriched_df['影片时长档位'] = enriched_df['影片时长(分钟)'].apply(round_minutes_to_10min)
311
+ enriched_df['影片时长类型'] = enriched_df['影片时长档位'].apply(create_duration_label)
312
+
313
+ missing_movies = sorted(
314
+ enriched_df.loc[enriched_df['影片时长类型'].isna(), '影片名称_清理后'].dropna().unique().tolist()
315
+ )
316
+ enriched_df = enriched_df.dropna(subset=['影片时长档位', '影片时长类型']).copy()
317
+ return enriched_df, missing_movies
318
+
319
+
320
+ def get_all_5min_time_labels():
321
+ return [t.strftime('%H:%M') for t in pd.to_datetime(pd.date_range('09:30', '23:55', freq='5min')).time]
322
+
323
+
324
+ def build_missing_movies_text(missing_movies, limit=8):
325
+ if not missing_movies:
326
+ return ''
327
+ preview = '、'.join(missing_movies[:limit])
328
+ suffix = ' 等' if len(missing_movies) > limit else ''
329
+ return f"以下影片暂未匹配到有效时长,未纳入时长效率分析:{preview}{suffix}。"
330
+
331
+
332
+ def calculate_duration_bucket_time_efficiency(df, duration_df=None, benchmark_scope='bucket'):
333
+ if df.empty:
334
+ return pd.DataFrame(), pd.DataFrame(), []
335
+
336
+ enriched_df, missing_movies = enrich_df_with_duration_types(df, duration_df)
337
+ if enriched_df.empty:
338
+ return pd.DataFrame(), pd.DataFrame(), missing_movies
339
+
340
+ df_filtered = enriched_df[(enriched_df['放映时间'] >= datetime.time(9, 30)) & (enriched_df['放映时间'] <= datetime.time(23, 59))].copy()
341
+ if df_filtered.empty:
342
+ return pd.DataFrame(), pd.DataFrame(), missing_movies
343
+
344
+ df_filtered['时间点'] = df_filtered['放映时间'].apply(
345
+ lambda t: round_time_to_5min(datetime.datetime.combine(datetime.date.today(), t))
346
+ )
347
+
348
+ time_analysis = df_filtered.groupby(['放映日期', '影片时长类型', '影片时长档位', '时间点']).agg(
349
+ 座位数=('座位数', 'sum'),
350
+ 场次=('场次', 'sum'),
351
+ 票房=('总收入', 'sum')
352
+ ).reset_index()
353
+
354
+ bucket_stats = enriched_df.groupby(['影片时长类型', '影片时长档位']).agg(
355
+ 总场次=('场次', 'sum'),
356
+ 总票房=('总收入', 'sum'),
357
+ 涉及影片数=('影片名称_清理后', 'nunique')
358
+ ).reset_index().sort_values(['影片时长档位', '影片时长类型']).reset_index(drop=True)
359
+
360
+ if benchmark_scope == 'bucket':
361
+ compare_totals = enriched_df.groupby(['影片时长类型', '影片时长档位']).agg(
362
+ 对比票房=('总收入', 'sum'),
363
+ 对比座位数=('座位数', 'sum'),
364
+ 对比场次=('场次', 'sum')
365
+ ).reset_index()
366
+ time_analysis = pd.merge(time_analysis, compare_totals, on=['影片时长类型', '影片时长档位'], how='left')
367
+ else:
368
+ total_revenue_full_day = df['总收入'].sum()
369
+ total_seats_full_day = df['座位数'].sum()
370
+ total_sessions_full_day = df['场次'].sum()
371
+ if total_revenue_full_day == 0 or total_seats_full_day == 0 or total_sessions_full_day == 0:
372
+ return pd.DataFrame(), bucket_stats, missing_movies
373
+ time_analysis['对比票房'] = total_revenue_full_day
374
+ time_analysis['对比座位数'] = total_seats_full_day
375
+ time_analysis['对比场次'] = total_sessions_full_day
376
+
377
+ time_analysis = time_analysis[(time_analysis['对比票房'] > 0) & (time_analysis['对比座位数'] > 0) & (time_analysis['对比场次'] > 0)].copy()
378
+ if time_analysis.empty:
379
+ return pd.DataFrame(), bucket_stats, missing_movies
380
+
381
+ time_analysis['票房比'] = time_analysis['票房'] / time_analysis['对比票房']
382
+ time_analysis['座次比'] = time_analysis['座位数'] / time_analysis['对比座位数']
383
+ time_analysis['场次比'] = time_analysis['场次'] / time_analysis['对比场次']
384
+ time_analysis['座次效率'] = (time_analysis['票房比'] / time_analysis['座次比']).fillna(0).replace([np.inf, -np.inf], 0)
385
+ time_analysis['场次效率'] = (time_analysis['票房比'] / time_analysis['场次比']).fillna(0).replace([np.inf, -np.inf], 0)
386
+
387
+ avg_efficiency = time_analysis.groupby(['影片时长类型', '影片时长档位', '时间点'])[['座次效率', '场次效率']].mean().reset_index()
388
+ avg_efficiency['时间点'] = avg_efficiency['时间点'].apply(lambda t: t.strftime('%H:%M'))
389
+ avg_efficiency = avg_efficiency.sort_values(['影片时长档位', '时间点']).reset_index(drop=True)
390
+ return avg_efficiency, bucket_stats, missing_movies
391
+
392
+
393
+ def plot_duration_bucket_same_type_efficiency(df, duration_df=None):
394
+ st.markdown("##### 影片时长效率分析(同档对比)")
395
+ st.write("影片时长优先使用历史场次 API 自带的 `movieLength`,按每 10 分钟四舍五入分档;少量缺时长的旧记录,会尝试按该影片历史主时长补齐。")
396
+
397
+ avg_efficiency, bucket_stats, missing_movies = calculate_duration_bucket_time_efficiency(df, duration_df, benchmark_scope='bucket')
398
+ if avg_efficiency.empty or bucket_stats.empty:
399
+ st.warning("暂无足够的影片时长数据,无法进行同档效率分析。")
400
+ missing_text = build_missing_movies_text(missing_movies)
401
+ if missing_text:
402
+ st.caption(missing_text)
403
+ return
404
+
405
+ bucket_options = bucket_stats['影片时长类型'].tolist()
406
+ default_bucket = bucket_stats.sort_values('总场次', ascending=False)['影片时长类型'].iloc[0]
407
+ selected_bucket = st.selectbox('选择影片时长类型', options=bucket_options,
408
+ index=bucket_options.index(default_bucket), key='auto_duration_bucket_same_selector')
409
+
410
+ bucket_meta = bucket_stats[bucket_stats['影片时长类型'] == selected_bucket].iloc[0]
411
+ st.caption(f"当前查看 {selected_bucket}:共 {int(bucket_meta['总场次'])} 场,涉及 {int(bucket_meta['涉及影片数'])} 部影片。")
412
+ missing_text = build_missing_movies_text(missing_movies)
413
+ if missing_text:
414
+ st.caption(missing_text)
415
+
416
+ all_times = pd.DataFrame({'时间点': get_all_5min_time_labels()})
417
+ selected_df = avg_efficiency[avg_efficiency['影片时长类型'] == selected_bucket][['时间点', '座次效率', '场次效率']].copy()
418
+ selected_df = pd.merge(all_times, selected_df, on='时间点', how='left').fillna(0)
419
+ selected_df['标注'] = np.where((selected_df['座次效率'] == 0) & (selected_df['场次效率'] == 0), '此时间点无排片', '有排片')
420
+
421
+ source = selected_df.melt(id_vars=['时间点', '标注'], value_vars=['座次效率', '场次效率'],
422
+ var_name='效率类型', value_name='效率值')
423
+ chart = alt.Chart(source).mark_bar().encode(
424
+ x=alt.X('时间点:N', title='时间点', sort=get_all_5min_time_labels(), axis=alt.Axis(labelAngle=-45)),
425
+ y=alt.Y('效率值:Q', title='平均效率'),
426
+ color=alt.Color('效率类型:N', title='效率类型'),
427
+ xOffset='效率类型:N',
428
+ tooltip=[alt.Tooltip('时间点:N', title='时间点'), alt.Tooltip('效率类型:N', title='效率类型'),
429
+ alt.Tooltip('效率值:Q', title='平均效率', format='.2f'), alt.Tooltip('标注:N', title='备注')]
430
+ ).properties(title=f'{selected_bucket} 各时间点平均效率(对比同时长档)').interactive()
431
+ st.altair_chart(chart, width="stretch")
432
+
433
+
434
+ def plot_duration_bucket_market_efficiency(df, duration_df=None):
435
+ st.markdown("##### 影片时长效率分析(全市场对比)")
436
+ st.write("影片时长优先使用历史场次 API 自带的 `movieLength`,不同档位在每个 5 分钟开场点的效率对比全部影片。")
437
+
438
+ avg_efficiency, bucket_stats, missing_movies = calculate_duration_bucket_time_efficiency(df, duration_df, benchmark_scope='market')
439
+ if avg_efficiency.empty or bucket_stats.empty:
440
+ st.warning("暂无足够的影片时长数据,无法进行全市场效率分析。")
441
+ missing_text = build_missing_movies_text(missing_movies)
442
+ if missing_text:
443
+ st.caption(missing_text)
444
+ return
445
+
446
+ bucket_options = bucket_stats['影片时长类型'].tolist()
447
+ default_buckets = bucket_stats.sort_values('总场次', ascending=False)['影片时长类型'].head(min(4, len(bucket_options))).tolist()
448
+ selected_buckets = st.multiselect('选择要对比的影片时长类型', options=bucket_options,
449
+ default=default_buckets, key='auto_duration_bucket_market_selector')
450
+ metric = st.radio('选择效率指标', options=['座次效率', '场次效率'], horizontal=True,
451
+ key='auto_duration_bucket_market_metric')
452
+
453
+ missing_text = build_missing_movies_text(missing_movies)
454
+ if missing_text:
455
+ st.caption(missing_text)
456
+
457
+ if not selected_buckets:
458
+ st.info('请至少选择一个影片时长类型。')
459
+ return
460
+
461
+ all_times = get_all_5min_time_labels()
462
+ full_grid = pd.MultiIndex.from_product([selected_buckets, all_times], names=['影片时长类型', '时间点']).to_frame(index=False)
463
+ plot_df = pd.merge(
464
+ full_grid,
465
+ avg_efficiency[avg_efficiency['影片时长类型'].isin(selected_buckets)][['影片时长类型', '时间点', '座次效率', '场次效率']],
466
+ on=['影片时长类型', '时间点'],
467
+ how='left'
468
+ ).fillna(0)
469
+ plot_df = pd.merge(plot_df, bucket_stats[['影片时长类型', '影片时长档位']], on='影片时长类型', how='left')
470
+ plot_df = plot_df.sort_values(['影片时长档位', '时间点']).reset_index(drop=True)
471
+
472
+ chart = alt.Chart(plot_df).mark_line(point=True).encode(
473
+ x=alt.X('时间点:N', title='时间点', sort=all_times, axis=alt.Axis(labelAngle=-45)),
474
+ y=alt.Y(f'{metric}:Q', title=metric),
475
+ color=alt.Color('影片时长类型:N', title='影片时长类型'),
476
+ tooltip=[alt.Tooltip('影片时长类型:N', title='影片时长类型'),
477
+ alt.Tooltip('时间点:N', title='时间点'),
478
+ alt.Tooltip(f'{metric}:Q', title=metric, format='.2f')]
479
+ ).properties(title=f'不同影片时长类型各时间点 {metric}(对比全部影片)').interactive()
480
+ st.altair_chart(chart, width="stretch")
481
+
482
+
483
+ def plot_daily_timeslot_box_office(df, selected_movie='全部影片'):
484
+ if df.empty:
485
+ return None
486
+ plot_df = df[df['影片名称_清理后'] == selected_movie].copy() if selected_movie != '全部影片' else df.copy()
487
+ if plot_df.empty:
488
+ return None
489
+ plot_df['时间点'] = plot_df['放映时间'].apply(
490
+ lambda t: round_time_to_5min(datetime.datetime.combine(datetime.date.today(), t)))
491
+ time_revenue = plot_df.groupby('时间点').agg(票房=('总收入', 'sum'), 场次=('影片名称_清理后', 'size')).reset_index()
492
+ full_time_range = pd.to_datetime(pd.date_range("09:30", "23:55", freq="5min")).time
493
+ full_time_df = pd.DataFrame({'时间点': full_time_range})
494
+ merged_df = pd.merge(full_time_df, time_revenue, on='时间点', how='left')
495
+ merged_df['场均票房'] = (merged_df['票房'] / merged_df['场次']).fillna(0)
496
+
497
+ def apply_conditions_avg(row):
498
+ if pd.isna(row['场次']):
499
+ return -1
500
+ return row['场均票房']
501
+
502
+ merged_df['展示值'] = merged_df.apply(apply_conditions_avg, axis=1)
503
+ merged_df['时间点_str'] = merged_df['时间点'].apply(lambda t: t.strftime('%H:%M'))
504
+ merged_df[['票房', '场次']] = merged_df[['票房', '场次']].fillna(0)
505
+ chart_title = f'影城每日时间段场均票房表现 - {selected_movie}'
506
+ chart = alt.Chart(merged_df).mark_bar().encode(
507
+ x=alt.X('时间点_str:N', title='时间点', sort=None, axis=alt.Axis(labelAngle=-45)),
508
+ y=alt.Y('展示值:Q', title='场均票房 (元)', scale=alt.Scale(domainMin=0)),
509
+ tooltip=[alt.Tooltip('时间点_str:N', title='时间点'),
510
+ alt.Tooltip('场均票房:Q', format=',.2f', title='场均票房'),
511
+ alt.Tooltip('票房:Q', format=',.2f', title='总票房'),
512
+ alt.Tooltip('场次:Q', format='.0f', title='总场次')]
513
+ ).properties(title=chart_title).interactive()
514
+ return chart
515
+
516
+
517
+ def plot_time_efficiency_analysis(df):
518
+ if df.empty:
519
+ return
520
+ df_filtered = df[(df['放映时间'] >= datetime.time(9, 30)) & (df['放映时间'] <= datetime.time(23, 59))].copy()
521
+ if df_filtered.empty:
522
+ st.warning("在 9:30 - 23:59 时间段内没有找到场次数据。")
523
+ return
524
+ df_filtered['时间点'] = df_filtered['放映时间'].apply(
525
+ lambda t: round_time_to_5min(datetime.datetime.combine(datetime.date.today(), t)))
526
+ total_revenue_full_day = df['总收入'].sum()
527
+ total_seats_full_day = df['座位数'].sum()
528
+ total_sessions_full_day = df['场次'].sum()
529
+ if total_revenue_full_day == 0 or total_seats_full_day == 0 or total_sessions_full_day == 0:
530
+ st.warning("总收入、总座位数或总场次数为零,无法计算效率。")
531
+ return
532
+ time_analysis = df_filtered.groupby(['放映日期', '时间点']).agg(座位数=('座位数', 'sum'),
533
+ 场次=('影片名称_清理后', 'size'),
534
+ 票房=('总收入', 'sum')).reset_index()
535
+ time_analysis['票房比'] = time_analysis['票房'] / total_revenue_full_day
536
+ time_analysis['座次比'] = time_analysis['座位数'] / total_seats_full_day
537
+ time_analysis['场次比'] = time_analysis['场次'] / total_sessions_full_day
538
+ time_analysis['座次效率'] = (time_analysis['票房比'] / time_analysis['座次比']).fillna(0)
539
+ time_analysis['场次效率'] = (time_analysis['票房比'] / time_analysis['场次比']).fillna(0)
540
+ avg_time_efficiency = time_analysis.groupby('时间点')[['座次效率', '场次效率']].mean().reset_index()
541
+ avg_time_efficiency['时间点'] = avg_time_efficiency['时间点'].apply(lambda t: t.strftime('%H:%M'))
542
+ source = avg_time_efficiency.melt(id_vars=['时间点'], value_vars=['座次效率', '场次效率'], var_name='效率类型',
543
+ value_name='效率值')
544
+ chart = alt.Chart(source).mark_bar().encode(
545
+ x=alt.X('时间点:N', title='时间点', sort=None, axis=alt.Axis(labelAngle=-45)),
546
+ y=alt.Y('效率值:Q', title='平均效率'), color=alt.Color('效率类型:N', title='效率类型'), xOffset='效率类型:N',
547
+ tooltip=[alt.Tooltip('时间点:N', title='时间点'), alt.Tooltip('效率类型:N', title='效率类型'),
548
+ alt.Tooltip('效率值:Q', title='平均效率', format='.2f')]
549
+ ).properties(title='每日时间点平均效率分析 (9:30-23:59)').interactive()
550
+ st.altair_chart(chart, width="stretch")
551
+
552
+
553
+ def plot_movie_time_efficiency_analysis(df, selected_movie):
554
+ if df.empty:
555
+ return
556
+ if selected_movie == '全部影片':
557
+ st.info("请选择一部具体的影片进行分析。")
558
+ return
559
+ df_movie = df[df['影片名称_清理后'] == selected_movie].copy()
560
+ df_movie = df_movie[
561
+ (df_movie['放映时间'] >= datetime.time(9, 30)) & (df_movie['放映时间'] <= datetime.time(23, 59))]
562
+ if df_movie.empty:
563
+ st.warning(f"在 9:30 - 23:59 时间段内没有找到影片《{selected_movie}》的场次数据。")
564
+ return
565
+ df_movie['时间点'] = df_movie['放映时间'].apply(
566
+ lambda t: round_time_to_5min(datetime.datetime.combine(datetime.date.today(), t)))
567
+ daily_totals = df.groupby('放映日期').agg(总票房=('总收入', 'sum'), 总座位数=('座位数', 'sum'),
568
+ 总场次数=('场次', 'sum')).reset_index()
569
+ if daily_totals.empty:
570
+ return
571
+ df_movie = pd.merge(df_movie, daily_totals, on='放映日期')
572
+ df_movie = df_movie[(df_movie['总票房'] > 0) & (df_movie['总座位数'] > 0) & (df_movie['总场次数'] > 0)]
573
+ df_movie['票房比'] = df_movie['总收入'] / df_movie['总票房']
574
+ df_movie['座次比'] = df_movie['座位数'] / df_movie['总座位数']
575
+ df_movie['场次比'] = 1 / df_movie['总场次数']
576
+ df_movie['座次效率'] = (df_movie['票房比'] / df_movie['座次比']).fillna(0)
577
+ df_movie['场次效率'] = (df_movie['票房比'] / df_movie['场次比']).fillna(0)
578
+ avg_movie_time_efficiency = df_movie.groupby('时间点')[['座次效率', '场次效率']].mean().reset_index()
579
+ avg_movie_time_efficiency['时间点'] = avg_movie_time_efficiency['时间点'].apply(lambda t: t.strftime('%H:%M'))
580
+ all_times = pd.DataFrame(
581
+ {'时间点': [t.strftime('%H:%M') for t in pd.to_datetime(pd.date_range("09:30", "23:55", freq="5min")).time]})
582
+ avg_movie_time_efficiency = pd.merge(all_times, avg_movie_time_efficiency, on='时间点', how='left').fillna(0)
583
+ avg_movie_time_efficiency['标注'] = np.where(
584
+ (avg_movie_time_efficiency['座次效率'] == 0) & (avg_movie_time_efficiency['场次效率'] == 0), '此时间点无排片',
585
+ '有排片')
586
+ source = avg_movie_time_efficiency.melt(id_vars=['时间点', '标注'], value_vars=['座次效率', '场次效率'],
587
+ var_name='效率类型', value_name='效率值')
588
+ chart = alt.Chart(source).mark_bar().encode(
589
+ x=alt.X('时间点:N', title='时间点', sort=None, axis=alt.Axis(labelAngle=-45)),
590
+ y=alt.Y('效率值:Q', title='平均效率'), color=alt.Color('效率类型:N', title='效率类型'), xOffset='效率类型:N',
591
+ tooltip=[alt.Tooltip('时间点:N', title='时间点'), alt.Tooltip('效率类型:N', title='效率类型'),
592
+ alt.Tooltip('效率值:Q', title='平均效率', format='.2f'), alt.Tooltip('标注:N', title='备注')]
593
+ ).properties(title=f'影片《{selected_movie}》各时间点平均效率分析').interactive()
594
+ st.altair_chart(chart, width="stretch")
595
+
596
+
597
+ def plot_segmented_time_efficiency(df):
598
+ if df.empty:
599
+ return
600
+ st.markdown("##### 每日时间效率分析 (分时间段)")
601
+ st.write("按指定的时间间隔(分钟)分割 09:30-23:59 时段,计算每个时间段内所有影片作为一个整体的座次效率和场次效率。")
602
+ interval = st.number_input("选择时间间隔 (分钟):", min_value=5, max_value=120, value=30, step=5,
603
+ key='auto_segment_interval')
604
+ df_filtered = df[(df['放映时间'] >= datetime.time(9, 30)) & (df['放映时间'] <= datetime.time(23, 59))].copy()
605
+ if df_filtered.empty:
606
+ return
607
+ total_revenue_full_day = df['总收入'].sum()
608
+ total_seats_full_day = df['座位数'].sum()
609
+ total_sessions_full_day = df['场次'].sum()
610
+ if total_revenue_full_day == 0 or total_seats_full_day == 0 or total_sessions_full_day == 0:
611
+ return
612
+ df_filtered['minutes_from_midnight'] = df_filtered['放映时间'].apply(lambda t: t.hour * 60 + t.minute)
613
+ start_minute = 9 * 60 + 30
614
+ end_minute = 24 * 60
615
+ bins = list(range(start_minute, end_minute, interval))
616
+ if end_minute not in bins:
617
+ bins.append(end_minute)
618
+
619
+ def to_hm_str(minutes):
620
+ return (datetime.datetime.min + datetime.timedelta(minutes=minutes)).strftime('%H:%M')
621
+
622
+ labels = [f"{to_hm_str(bins[i])} - {to_hm_str(bins[i + 1])}" for i in range(len(bins) - 1)]
623
+ df_filtered['时间段'] = pd.cut(df_filtered['minutes_from_midnight'], bins=bins, labels=labels, right=False,
624
+ include_lowest=True)
625
+ segment_analysis = df_filtered.groupby('时间段', observed=True).agg(区间票房=('总收入', 'sum'),
626
+ 区间座位数=('座位数', 'sum'),
627
+ 区间场次=('场次', 'sum')).reset_index()
628
+
629
+ segment_analysis['时间段'] = segment_analysis['时间段'].astype(str)
630
+
631
+ segment_analysis['票房比'] = segment_analysis['区间票房'] / total_revenue_full_day
632
+ segment_analysis['座次比'] = segment_analysis['区间座位数'] / total_seats_full_day
633
+ segment_analysis['场次比'] = segment_analysis['区间场次'] / total_sessions_full_day
634
+ segment_analysis['座次效率'] = (segment_analysis['票房比'] / segment_analysis['座次比']).fillna(0).replace(
635
+ [np.inf, -np.inf], 0)
636
+ segment_analysis['场次效率'] = (segment_analysis['票房比'] / segment_analysis['场次比']).fillna(0).replace(
637
+ [np.inf, -np.inf], 0)
638
+ source = segment_analysis.melt(id_vars=['时间段'], value_vars=['座次效率', '场次效率'], var_name='效率类型',
639
+ value_name='效率值')
640
+ chart = alt.Chart(source).mark_bar().encode(
641
+ x=alt.X('时间段:N', title='时间段', sort=None, axis=alt.Axis(labelAngle=-45)),
642
+ y=alt.Y('效率值:Q', title='效率值'), color=alt.Color('效率类型:N', title='效率类型'), xOffset='效率类型:N',
643
+ tooltip=[alt.Tooltip('时间段:N', title='时间段'), alt.Tooltip('效率类型:N', title='效率类型'),
644
+ alt.Tooltip('效率值:Q', title='效率值', format='.2f')]
645
+ ).properties(title=f'每日时间效率分析 (每 {interval} 分钟)').interactive()
646
+ st.altair_chart(chart, width="stretch")
647
+
648
+
649
+ def plot_movie_efficiency_in_window(df):
650
+ if df.empty:
651
+ return
652
+ st.markdown("##### 单片时间效率分析 (分时间段)")
653
+ st.write("选择一个中心时间点和一个前后时长,分析在该时间窗口内各影片的相对效率。")
654
+ col1, col2 = st.columns(2)
655
+ with col1:
656
+ center_time = st.time_input("选择中心时间点:", value=datetime.time(19, 30), step=datetime.timedelta(minutes=5),
657
+ key='auto_window_center_time')
658
+ with col2:
659
+ duration = st.number_input("选择前后时长 (分钟):", min_value=5, value=20, step=5, key='auto_window_duration')
660
+ center_dt = datetime.datetime.combine(datetime.date.today(), center_time)
661
+ start_dt = center_dt - datetime.timedelta(minutes=duration)
662
+ end_dt = center_dt + datetime.timedelta(minutes=duration)
663
+ start_time = start_dt.time()
664
+ end_time = end_dt.time()
665
+ st.info(f"分析时间窗口: {start_time.strftime('%H:%M')} - {end_time.strftime('%H:%M')}")
666
+ df_window = df[df['放映时间'].between(start_time, end_time)].copy() if start_time <= end_time else df[
667
+ (df['放映时间'] >= start_time) | (df['放映时间'] <= end_time)].copy()
668
+ if df_window.empty:
669
+ st.warning("所选时间窗口内没有场次数据。")
670
+ return
671
+ total_revenue_w = df_window['总收入'].sum()
672
+ total_seats_w = df_window['座位数'].sum()
673
+ total_sessions_w = df_window['场次'].sum()
674
+ if total_revenue_w == 0 or total_seats_w == 0 or total_sessions_w == 0:
675
+ st.warning("所选时间窗口内总票房、总座位数或总场次数为零,无法计算效率。")
676
+ return
677
+ movie_analysis = df_window.groupby('影片名称_清理后').agg(票房=('总收入', 'sum'), 座位数=('座位数', 'sum'),
678
+ 场次=('场次', 'sum')).reset_index()
679
+ movie_analysis.rename(columns={'影片名称_清理后': '影片'}, inplace=True)
680
+ movie_analysis['票房比'] = movie_analysis['票房'] / total_revenue_w
681
+ movie_analysis['座次比'] = movie_analysis['座位数'] / total_seats_w
682
+ movie_analysis['场次比'] = movie_analysis['场次'] / total_sessions_w
683
+ movie_analysis['座次效率'] = (movie_analysis['票房比'] / movie_analysis['座次比']).fillna(0).replace(
684
+ [np.inf, -np.inf], 0)
685
+ movie_analysis['场次效率'] = (movie_analysis['票房比'] / movie_analysis['场次比']).fillna(0).replace(
686
+ [np.inf, -np.inf], 0)
687
+ movie_analysis = movie_analysis.sort_values(by='座次效率', ascending=False).reset_index(drop=True)
688
+ source = movie_analysis.melt(id_vars=['影片'], value_vars=['座次效率', '场次效率'], var_name='效率类型',
689
+ value_name='效率值')
690
+ chart = alt.Chart(source).mark_bar().encode(
691
+ x=alt.X('效率值:Q', title='效率值'), y=alt.Y('影片:N', title='影片', sort='-x'),
692
+ color=alt.Color('效率类型:N', title='效率类型'),
693
+ tooltip=[alt.Tooltip('影片:N', title='影片'), alt.Tooltip('效率类型:N', title='效率类型'),
694
+ alt.Tooltip('效率值:Q', title='效率值', format='.2f')]
695
+ ).properties(title=f'窗口 {start_time.strftime("%H:%M")}-{end_time.strftime("%H:%M")} 内单片效率对比').interactive()
696
+ st.altair_chart(chart, width="stretch")
697
+ st.dataframe(movie_analysis.style.format(
698
+ {'票房': '{:,.2f}', '座位数': '{:,.0f}', '场次': '{:,.0f}', '票房比': '{:.2%}', '座次比': '{:.2%}',
699
+ '场次比': '{:.2%}', '座次效率': '{:.2f}', '场次效率': '{:.2f}'}), width="stretch", hide_index=True)
700
+
701
+
702
+ def get_duration_source_df_cached(filtered_df, cache_key):
703
+ """
704
+ 根据筛选后的场次数据计算影片时长档位汇总表,并按 cache_key 缓存到 session_state。
705
+ cache_key 通常由 (start_date, end_date, weekday_filter, time_filter, master_len) 组成。
706
+ """
707
+ cache = st.session_state.get('auto_duration_cache')
708
+ if cache and cache.get('key') == cache_key:
709
+ return cache['df']
710
+ duration_df = build_duration_reference_from_history(filtered_df)
711
+ st.session_state.auto_duration_cache = {'key': cache_key, 'df': duration_df}
712
+ return duration_df
713
+
714
+
715
+ def render_duration_tab(filtered_df, cache_key, plot_func, button_key):
716
+ """通用渲染:按需触发时长档位计算,结果按 cache_key 缓存。"""
717
+ cache = st.session_state.get('auto_duration_cache') or {}
718
+ cached_df = cache.get('df') if cache.get('key') == cache_key else None
719
+
720
+ if cached_df is None:
721
+ st.info("影片时长档位需要按当前日期范围 / 过滤条件计算。点击下方按钮开始分析。")
722
+ if st.button("开始计算影片时长效率", key=button_key, type="primary"):
723
+ with st.spinner("正在统计影片时长档位……"):
724
+ cached_df = get_duration_source_df_cached(filtered_df, cache_key)
725
+ st.rerun()
726
+ return
727
+
728
+ if cached_df.empty:
729
+ st.warning("当前筛选条件下没有可用的影片时长数据。")
730
+ return
731
+
732
+ plot_func(filtered_df.copy(), cached_df.copy())
733
+
734
+
735
+ # --- Streamlit Main UI ---
736
+ st.title('🧐 影片效率分析')
737
+
738
+ # 初始化数据加载(只加载一次,除非用户主动刷新)
739
+ if 'auto_master_df' not in st.session_state:
740
+ df_loaded, source, message = auto_load_history_dataset(force_r2_sync=False)
741
+ st.session_state.auto_master_df = df_loaded
742
+ st.session_state.auto_data_source = source
743
+ st.session_state.auto_data_message = message
744
+
745
+ with st.expander("数据状态与同步", expanded=True):
746
+ st.caption("本页历史场次数据自动从本地缓存读取,必要时会从 R2 拉取并合并,无需手动上传 Excel。")
747
+ source_label_map = {
748
+ "local": "本地缓存",
749
+ "r2": "R2 远程",
750
+ "local+r2": "本地 + R2 合并",
751
+ "empty": "暂无数据",
752
+ }
753
+ info_cols = st.columns([2, 2, 1])
754
+ with info_cols[0]:
755
+ st.metric("数据来源", source_label_map.get(st.session_state.auto_data_source, "未知"))
756
+ with info_cols[1]:
757
+ st.metric("当前记录数", f"{len(st.session_state.auto_master_df):,}")
758
+ with info_cols[2]:
759
+ if st.button("从 R2 重新同步", help="忽略本地缓存,强制从 R2 下载并合并", key="auto_refresh_from_r2"):
760
+ with st.spinner("正在从 R2 下载并合并历史数据……"):
761
+ df_loaded, source, message = auto_load_history_dataset(force_r2_sync=True)
762
+ st.session_state.auto_master_df = df_loaded
763
+ st.session_state.auto_data_source = source
764
+ st.session_state.auto_data_message = message
765
+ # 数据更新后清掉旧的时长缓存,下次打开时长 tab 会按新数据重算
766
+ st.session_state.pop('auto_duration_cache', None)
767
+ st.success(message)
768
+ st.rerun()
769
+
770
+ st.caption(st.session_state.auto_data_message)
771
+
772
+ # 数据库覆盖范围概览
773
+ if not st.session_state.auto_master_df.empty:
774
+ date_min = st.session_state.auto_master_df['放映日期'].min()
775
+ date_max = st.session_state.auto_master_df['放映日期'].max()
776
+ date_min_text = date_min.strftime('%Y-%m-%d') if pd.notna(date_min) else '--'
777
+ date_max_text = date_max.strftime('%Y-%m-%d') if pd.notna(date_max) else '--'
778
+ st.caption(f"覆盖日期:{date_min_text} 至 {date_max_text}")
779
+
780
+ # 影片时长数据按需计算:仅在打开时长 tab 时才会调用 build_duration_reference_from_history
781
+
782
+ if not st.session_state.auto_master_df.empty:
783
+ st.divider()
784
+ st.header("数据分析区")
785
+ min_date = st.session_state.auto_master_df['放映日期'].min().date()
786
+ max_date = st.session_state.auto_master_df['放映日期'].max().date()
787
+ default_start = max(min_date, max_date - datetime.timedelta(days=6))
788
+ selected_date_range = st.date_input("请选择要分析的日期范围:", value=(default_start, max_date),
789
+ min_value=min_date, max_value=max_date,
790
+ key='auto_date_range_selector')
791
+ if len(selected_date_range) == 2:
792
+ start_date, end_date = selected_date_range
793
+ mask = (st.session_state.auto_master_df['放映日期'].dt.date >= start_date) & (
794
+ st.session_state.auto_master_df['放映日期'].dt.date <= end_date)
795
+ df = st.session_state.auto_master_df[mask].copy()
796
+
797
+ # 时间过滤器
798
+ filter_invalid_times = st.checkbox("过滤掉 9:45 前和 23:35 后的无效场次", value=True,
799
+ key='auto_filter_invalid_times')
800
+ if filter_invalid_times:
801
+ original_rows = len(df)
802
+ df = df[df['放映时间'].between(datetime.time(9, 45), datetime.time(23, 35))]
803
+ filtered_rows = len(df)
804
+ st.info(
805
+ f"已应用时间过滤器 (09:45 - 23:35)。所选日期范围内场次数从 {original_rows} 减少到 {filtered_rows}。")
806
+
807
+ # --- 星期过滤器 ---
808
+ st.markdown("---")
809
+ weekdays_chinese = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
810
+ weekday_map = {"星期一": 0, "星期二": 1, "星期三": 2, "星期四": 3,
811
+ "星期五": 4, "星期六": 5, "星期日": 6}
812
+
813
+ selected_weekdays = st.multiselect(
814
+ "请选择要分析的星期:",
815
+ options=weekdays_chinese,
816
+ default=weekdays_chinese,
817
+ key='auto_weekday_selector'
818
+ )
819
+
820
+ if selected_weekdays:
821
+ selected_weekday_numbers = [weekday_map[day] for day in selected_weekdays]
822
+ original_rows_before_weekday_filter = len(df)
823
+ df = df[df['放映日期'].dt.dayofweek.isin(selected_weekday_numbers)]
824
+ filtered_rows_after_weekday_filter = len(df)
825
+ if len(selected_weekdays) < 7:
826
+ st.info(
827
+ f"已应用星期过滤器。场次数从 {original_rows_before_weekday_filter} "
828
+ f"减少到 {filtered_rows_after_weekday_filter}。")
829
+ else:
830
+ df = pd.DataFrame(columns=df.columns)
831
+ st.warning("您没有选择任何星期,因此没有数据可供分析。")
832
+
833
+ if df.empty:
834
+ st.warning("在您选择的日期范围和过滤条件下,没有找到任何数据。请尝试调整日期或过滤选项。")
835
+ else:
836
+ st.toast("数据已根据所选日期和星期范围更新!", icon="📅")
837
+ render_analysis_overview(df, start_date, end_date)
838
+ format_config = {'座位数': '{:,.0f}', '场次': '{:,.0f}', '人次': '{:,.0f}', '票房': '{:,.2f}',
839
+ '均价': '{:.2f}', '座次比': '{:.2%}', '场次比': '{:.2%}', '票房比': '{:.2%}',
840
+ '座次效率': '{:.2f}', '场次效率': '{:.2f}'}
841
+ full_day_analysis = process_and_analyze_data(df.copy())
842
+ prime_time_analysis = process_and_analyze_data(
843
+ df[df['放映时间'].between(datetime.time(14, 0), datetime.time(21, 0))].copy())
844
+ st.markdown("### 全天排片效率分析")
845
+ if not full_day_analysis.empty:
846
+ st.dataframe(full_day_analysis.style.format(format_config),
847
+ width="stretch", hide_index=True)
848
+ st.markdown("#### 黄金时段排片效率分析 (14:00-21:00)")
849
+ if not prime_time_analysis.empty:
850
+ st.dataframe(prime_time_analysis.style.format(format_config),
851
+ width="stretch", hide_index=True)
852
+ with st.expander("影城每日票房表现", expanded=True):
853
+ movie_options = ['全部影片'] + full_day_analysis['影片'].unique().tolist()
854
+ selected_movie_for_chart = st.selectbox(
855
+ '选择影片查看其每日票房(默认为影城全部)', options=movie_options,
856
+ key='auto_daily_box_office_selector'
857
+ )
858
+ daily_chart = plot_daily_box_office(df.copy(), selected_movie_for_chart)
859
+ if daily_chart:
860
+ st.altair_chart(daily_chart, width="stretch")
861
+ timeslot_chart = plot_daily_timeslot_box_office(df.copy(), selected_movie_for_chart)
862
+ if timeslot_chart:
863
+ st.altair_chart(timeslot_chart, width="stretch")
864
+ with st.expander("每日时间效率分析", expanded=False):
865
+ tab1, tab2, tab3, tab4, tab5, tab6 = st.tabs([
866
+ "每日时间效率分析",
867
+ "单片时间效率分析",
868
+ "每日时间效率分析(分时间段)",
869
+ "单片时间效率分析(分时间段)",
870
+ "影片时长效率分析(同档对比)",
871
+ "影片时长效率分析(全市场对比)"
872
+ ])
873
+ with tab1:
874
+ plot_time_efficiency_analysis(df.copy())
875
+ with tab2:
876
+ movie_options_for_time = ['全部影片'] + full_day_analysis['影片'].unique().tolist()
877
+ default_index = 0
878
+ if not full_day_analysis.empty:
879
+ top_movie = full_day_analysis['影片'].iloc[0]
880
+ if top_movie in movie_options_for_time:
881
+ default_index = movie_options_for_time.index(top_movie)
882
+ selected_movie_for_time_chart = st.selectbox(
883
+ '选择影片进行分析', options=movie_options_for_time,
884
+ key='auto_movie_time_efficiency_selector', index=default_index
885
+ )
886
+ plot_movie_time_efficiency_analysis(df.copy(), selected_movie_for_time_chart)
887
+ with tab3:
888
+ plot_segmented_time_efficiency(df.copy())
889
+ with tab4:
890
+ plot_movie_efficiency_in_window(df.copy())
891
+
892
+ duration_cache_key = (
893
+ start_date.isoformat(),
894
+ end_date.isoformat(),
895
+ tuple(sorted(selected_weekdays)),
896
+ bool(filter_invalid_times),
897
+ int(len(df)),
898
+ )
899
+ with tab5:
900
+ render_duration_tab(
901
+ df,
902
+ duration_cache_key,
903
+ plot_duration_bucket_same_type_efficiency,
904
+ button_key='auto_duration_compute_same',
905
+ )
906
+ with tab6:
907
+ render_duration_tab(
908
+ df,
909
+ duration_cache_key,
910
+ plot_duration_bucket_market_efficiency,
911
+ button_key='auto_duration_compute_market',
912
+ )
913
+ else:
914
+ st.info("请选择一个有效的日期范围以开始分析。")
915
+ else:
916
+ st.info("当前历史库为空,请先在 “历史场次自动同步监控” 页执行同步,或点击上方 “从 R2 重新同步” 拉取数据。")
pages/🧰 工具大全.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """工具大全 - 汇总常用外部小工具与油猴脚本链接。
2
+
3
+ 本页面通过 Streamlit 多页面机制自动出现在侧边栏导航中,
4
+ 点击任一卡片按钮会在新的浏览器标签页打开对应的外部网址。
5
+ """
6
+
7
+ import streamlit as st
8
+
9
+
10
+ st.set_page_config(page_title="工具大全", page_icon="🧰", layout="wide")
11
+
12
+
13
+ # --- 链接数据 ---
14
+ EXTERNAL_TOOLS = [
15
+ {
16
+ "name": "外卖订单价格计算工具",
17
+ "url": "https://ethscriptions-waimai.static.hf.space",
18
+ "icon": "🍱",
19
+ "desc": "快速计算外卖订单的实际单价/分摊价格,便于对账与员工餐统计。",
20
+ },
21
+ {
22
+ "name": "水印批量添加助手",
23
+ "url": "https://ethscriptions-shuiyin.static.hf.space",
24
+ "icon": "💧",
25
+ "desc": "批量给图片添加自定义文字/图片水印,适合宣传素材整理。",
26
+ },
27
+ ]
28
+
29
+ USERSCRIPTS = [
30
+ {
31
+ "name": "查询排程时自动填写影城名字",
32
+ "url": "https://openuserjs.org/scripts/pzt/%E6%9F%A5%E8%AF%A2%E6%8E%92%E7%A8%8B%E6%97%B6%E8%87%AA%E5%8A%A8%E5%A1%AB%E5%86%99%E5%BD%B1%E5%9F%8E%E5%90%8D%E5%AD%97",
33
+ "icon": "🎬",
34
+ "desc": "在排程查询页面自动填入影城名字,省去重复输入。",
35
+ },
36
+ {
37
+ "name": "KDM 批量下载助手",
38
+ "url": "https://openuserjs.org/scripts/pzt/KDM_%E6%89%B9%E9%87%8F%E4%B8%8B%E8%BD%BD%E5%8A%A9%E6%89%8B",
39
+ "icon": "🔑",
40
+ "desc": "一键批量下载 KDM 密钥文件,无需逐个点击。",
41
+ },
42
+ {
43
+ "name": "TMS 服务器影片内容查询助手",
44
+ "url": "https://openuserjs.org/scripts/pzt/TMS_%E6%9C%8D%E5%8A%A1%E5%99%A8%E5%BD%B1%E7%89%87%E5%86%85%E5%AE%B9%E6%9F%A5%E8%AF%A2%E5%8A%A9%E6%89%8B",
45
+ "icon": "🗄️",
46
+ "desc": "在 TMS 后台快速查询服务器上的影片内容,方便核对文件状态。",
47
+ },
48
+ ]
49
+
50
+
51
+ def render_link_cards(items, columns_per_row=2):
52
+ """以网格卡片形式渲染链接条目。"""
53
+ for row_start in range(0, len(items), columns_per_row):
54
+ row_items = items[row_start:row_start + columns_per_row]
55
+ cols = st.columns(columns_per_row)
56
+ for col, item in zip(cols, row_items):
57
+ with col:
58
+ with st.container(border=True):
59
+ st.markdown(f"#### {item['icon']} {item['name']}")
60
+ st.caption(item["desc"])
61
+ st.link_button(
62
+ "🔗 打开",
63
+ item["url"],
64
+ use_container_width=True,
65
+ )
66
+ st.caption(f"<small>{item['url']}</small>", unsafe_allow_html=True)
67
+
68
+
69
+ # --- 页面主体 ---
70
+ st.title("🧰 工具大全")
71
+ st.write("汇总日常工作中常用的外部小工具与浏览器脚本,点击卡片即可在新标签页打开。")
72
+
73
+ st.divider()
74
+
75
+ st.subheader("🔗 外部小工具")
76
+ st.caption("基于网页的在线工具,打开即用,无需安装。")
77
+ render_link_cards(EXTERNAL_TOOLS)
78
+
79
+ st.subheader("🐒 油猴脚本(Userscript)")
80
+ st.caption(
81
+ "以下脚本需先在浏览器安装 "
82
+ "[Tampermonkey](https://www.tampermonkey.net/) 或 "
83
+ "[Violentmonkey](https://violentmonkey.github.io/) 扩展,"
84
+ "然后点击「打开」进入脚本页面,再点击页面上的 *Install* 完成安装。"
85
+ )
86
+ render_link_cards(USERSCRIPTS)
87
+
pages/🪄 次日自动排片(随机贪心构造 + 蒙特卡洛评估)测试版本.py ADDED
The diff for this file is too large to render. See raw diff
 
print_settings.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "led_font": "思源黑体-常规 (推荐 LED 屏)",
3
+ "png_led": false,
4
+ "led_start_cutoff_time": "17:00",
5
+ "led_end_cutoff_time": "01:00",
6
+ "times_font": "思源黑体-粗体 (推荐散场表)",
7
+ "font_size_multiplier": 1.2,
8
+ "split_time": "17:00",
9
+ "time_adjustment": 0,
10
+ "times_end_cutoff_time": "01:00",
11
+ "hall_display_format": "Default",
12
+ "png_times": false
13
+ }
r2_config_sync.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import tempfile
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from r2_storage import R2Storage
8
+
9
+
10
+ ROOT_DIR = Path(__file__).resolve().parent
11
+
12
+
13
+ def build_settings_prefix() -> str:
14
+ cinema_id = (os.getenv("CINEMA_ID") or "").strip() or "default"
15
+ return f"{cinema_id}/app_settings"
16
+
17
+
18
+ def build_settings_key(file_name: str) -> str:
19
+ return f"{build_settings_prefix().rstrip('/')}/{str(file_name).lstrip('/')}"
20
+
21
+
22
+ def get_local_settings_path(file_name: str | Path) -> Path:
23
+ path = Path(file_name)
24
+ if path.is_absolute():
25
+ return path
26
+ return ROOT_DIR / path
27
+
28
+
29
+ def get_local_settings_mtime(file_name: str | Path) -> Optional[float]:
30
+ try:
31
+ return get_local_settings_path(file_name).stat().st_mtime
32
+ except OSError:
33
+ return None
34
+
35
+
36
+ def get_r2_storage_or_none() -> Optional[R2Storage]:
37
+ try:
38
+ return R2Storage()
39
+ except Exception:
40
+ return None
41
+
42
+
43
+ def upload_settings_file_to_r2(file_name: str | Path) -> bool:
44
+ storage = get_r2_storage_or_none()
45
+ if storage is None:
46
+ return False
47
+
48
+ local_path = get_local_settings_path(file_name)
49
+ if not local_path.exists():
50
+ return False
51
+
52
+ try:
53
+ storage.upload_file(local_path, build_settings_key(local_path.name), content_type="application/json")
54
+ return True
55
+ except Exception:
56
+ return False
57
+
58
+
59
+ def ensure_settings_file_from_r2(file_name: str | Path, force_download: bool = False) -> bool:
60
+ storage = get_r2_storage_or_none()
61
+ if storage is None:
62
+ return False
63
+
64
+ local_path = get_local_settings_path(file_name)
65
+ key = build_settings_key(local_path.name)
66
+
67
+ try:
68
+ remote_exists = storage.exists(key)
69
+ except Exception:
70
+ return False
71
+
72
+ if not remote_exists:
73
+ return False
74
+
75
+ if local_path.exists() and not force_download:
76
+ return False
77
+
78
+ try:
79
+ local_path.parent.mkdir(parents=True, exist_ok=True)
80
+ storage.download_file(key, local_path)
81
+ return True
82
+ except Exception:
83
+ return False
84
+
85
+
86
+ def ensure_settings_file_synced(file_name: str | Path) -> str:
87
+ """本地优先:只有本地缺失时才尝试从 R2 恢复。"""
88
+ local_path = get_local_settings_path(file_name)
89
+ if local_path.exists():
90
+ return "local_exists"
91
+
92
+ storage = get_r2_storage_or_none()
93
+ if storage is None:
94
+ return "r2_unavailable"
95
+
96
+ key = build_settings_key(local_path.name)
97
+
98
+ try:
99
+ remote_exists = storage.exists(key)
100
+ except Exception:
101
+ return "r2_check_failed"
102
+
103
+ if remote_exists:
104
+ try:
105
+ local_path.parent.mkdir(parents=True, exist_ok=True)
106
+ storage.download_file(key, local_path)
107
+ return "downloaded"
108
+ except Exception:
109
+ return "download_failed"
110
+
111
+ return "missing_both"
112
+
113
+
114
+ def write_json_file(file_name: str | Path, payload: dict) -> Path:
115
+ local_path = get_local_settings_path(file_name)
116
+ local_path.parent.mkdir(parents=True, exist_ok=True)
117
+
118
+ temp_path = None
119
+ try:
120
+ with tempfile.NamedTemporaryFile(
121
+ mode="w",
122
+ encoding="utf-8",
123
+ dir=local_path.parent,
124
+ prefix=f".{local_path.name}.",
125
+ suffix=".tmp",
126
+ delete=False,
127
+ ) as temp_file:
128
+ json.dump(payload, temp_file, ensure_ascii=False, indent=4)
129
+ temp_file.write("\n")
130
+ temp_path = Path(temp_file.name)
131
+ temp_path.replace(local_path)
132
+ except Exception:
133
+ if temp_path is not None:
134
+ try:
135
+ temp_path.unlink(missing_ok=True)
136
+ except Exception:
137
+ pass
138
+ raise
139
+
140
+ return local_path
r2_storage.py ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+ from typing import Optional, Tuple
5
+ from urllib.parse import urlparse, urlunparse
6
+
7
+
8
+ def _split_endpoint_bucket(endpoint: str) -> Tuple[str, str, str]:
9
+ parsed = urlparse(endpoint)
10
+ if not parsed.scheme or not parsed.netloc:
11
+ return endpoint, "", ""
12
+
13
+ path = (parsed.path or "").strip("/")
14
+ if not path:
15
+ return urlunparse((parsed.scheme, parsed.netloc, "", "", "", "")), "", ""
16
+
17
+ parts = [part for part in path.split("/") if part]
18
+ bucket = parts[0] if parts else ""
19
+ base_prefix = "/".join(parts[1:]) if len(parts) > 1 else ""
20
+ base_endpoint = urlunparse((parsed.scheme, parsed.netloc, "", "", "", ""))
21
+ return base_endpoint, bucket, base_prefix
22
+
23
+
24
+ def _join_key(prefix: str, key: str) -> str:
25
+ prefix = (prefix or "").strip("/")
26
+ key = (key or "").lstrip("/")
27
+ if not prefix:
28
+ return key
29
+ if not key:
30
+ return prefix
31
+ return f"{prefix}/{key}"
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class R2Config:
36
+ endpoint_url: str
37
+ bucket: str
38
+ access_key_id: str
39
+ secret_access_key: str
40
+ base_prefix: str = ""
41
+
42
+
43
+ class R2Storage:
44
+ def __init__(self, config: Optional[R2Config] = None):
45
+ self.config = config or self._load_config_from_env()
46
+ self._client = None
47
+
48
+ @staticmethod
49
+ def _load_config_from_env() -> R2Config:
50
+ endpoint_raw = (os.getenv("R2_ENDPOINT") or os.getenv("R2_Endpoint") or "").strip()
51
+ access_key_id = (os.getenv("R2_ACCESS_KEY_ID") or os.getenv("R2_ID") or "").strip()
52
+ secret_access_key = (os.getenv("R2_SECRET_ACCESS_KEY") or os.getenv("R2_API") or "").strip()
53
+ bucket = (os.getenv("R2_BUCKET") or "").strip()
54
+ base_prefix = (os.getenv("R2_PREFIX") or "").strip()
55
+
56
+ if endpoint_raw and not bucket:
57
+ endpoint_url, bucket_from_path, prefix_from_path = _split_endpoint_bucket(endpoint_raw)
58
+ bucket = bucket or bucket_from_path
59
+ endpoint_raw = endpoint_url
60
+ base_prefix = base_prefix or prefix_from_path
61
+
62
+ endpoint_url = endpoint_raw
63
+ if not endpoint_url or not bucket or not access_key_id or not secret_access_key:
64
+ missing = []
65
+ if not endpoint_url:
66
+ missing.append("R2_ENDPOINT / R2_Endpoint")
67
+ if not bucket:
68
+ missing.append("R2_BUCKET (或把 bucket 放到 R2_Endpoint 的路径里)")
69
+ if not access_key_id:
70
+ missing.append("R2_ACCESS_KEY_ID / R2_ID")
71
+ if not secret_access_key:
72
+ missing.append("R2_SECRET_ACCESS_KEY / R2_API")
73
+ raise ValueError(f"R2 配置缺失: {', '.join(missing)}")
74
+
75
+ if secret_access_key.startswith("cfat_"):
76
+ raise ValueError("R2_SECRET_ACCESS_KEY 看起来是 Cloudflare API Token(cfat_),不是 R2 的 S3 Secret Access Key。请在 Cloudflare 控制台生成 R2 的 S3 API 访问密钥并替换。")
77
+
78
+ return R2Config(
79
+ endpoint_url=endpoint_url,
80
+ bucket=bucket,
81
+ access_key_id=access_key_id,
82
+ secret_access_key=secret_access_key,
83
+ base_prefix=base_prefix,
84
+ )
85
+
86
+ def _get_client(self):
87
+ if self._client is not None:
88
+ return self._client
89
+
90
+ try:
91
+ import boto3
92
+ from botocore.client import Config
93
+ except Exception as exc:
94
+ raise RuntimeError("缺少依赖 boto3,请先安装 requirements.txt") from exc
95
+
96
+ self._client = boto3.client(
97
+ "s3",
98
+ endpoint_url=self.config.endpoint_url,
99
+ aws_access_key_id=self.config.access_key_id,
100
+ aws_secret_access_key=self.config.secret_access_key,
101
+ region_name="auto",
102
+ config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
103
+ )
104
+ return self._client
105
+
106
+ def resolve_key(self, key: str) -> str:
107
+ return _join_key(self.config.base_prefix, key)
108
+
109
+ def exists(self, key: str) -> bool:
110
+ client = self._get_client()
111
+ resolved_key = self.resolve_key(key)
112
+ try:
113
+ client.head_object(Bucket=self.config.bucket, Key=resolved_key)
114
+ return True
115
+ except Exception as exc:
116
+ try:
117
+ from botocore.exceptions import ClientError
118
+ except Exception:
119
+ raise
120
+
121
+ if isinstance(exc, ClientError):
122
+ error = (exc.response or {}).get("Error") or {}
123
+ code = str(error.get("Code") or "")
124
+ if code in {"404", "NoSuchKey", "NotFound"}:
125
+ return False
126
+ raise
127
+
128
+ def upload_file(self, local_path: str | Path, key: str, content_type: Optional[str] = None) -> str:
129
+ client = self._get_client()
130
+ resolved_key = self.resolve_key(key)
131
+ path = Path(local_path)
132
+ if not path.exists():
133
+ raise FileNotFoundError(str(path))
134
+
135
+ kwargs = {}
136
+ if content_type:
137
+ kwargs["ContentType"] = content_type
138
+
139
+ with path.open("rb") as handle:
140
+ client.put_object(Bucket=self.config.bucket, Key=resolved_key, Body=handle, **kwargs)
141
+ return resolved_key
142
+
143
+ def download_file(self, key: str, local_path: str | Path) -> Path:
144
+ client = self._get_client()
145
+ resolved_key = self.resolve_key(key)
146
+ path = Path(local_path)
147
+ path.parent.mkdir(parents=True, exist_ok=True)
148
+ response = client.get_object(Bucket=self.config.bucket, Key=resolved_key)
149
+ body = response.get("Body")
150
+ try:
151
+ with path.open("wb") as handle:
152
+ handle.write(body.read())
153
+ finally:
154
+ if body is not None:
155
+ try:
156
+ body.close()
157
+ except Exception:
158
+ pass
159
+ return path
160
+
161
+ def list_keys(self, prefix: str = "", max_keys: int = 20) -> list[str]:
162
+ client = self._get_client()
163
+ resolved_prefix = self.resolve_key(prefix) if prefix else ""
164
+ params = {"Bucket": self.config.bucket, "MaxKeys": int(max_keys)}
165
+ if resolved_prefix:
166
+ params["Prefix"] = resolved_prefix
167
+ response = client.list_objects_v2(**params)
168
+ contents = response.get("Contents") or []
169
+ return [item.get("Key", "") for item in contents if isinstance(item, dict) and item.get("Key")]
170
+
171
+ def presign_get_url(self, key: str, expires_in: int = 3600) -> str:
172
+ client = self._get_client()
173
+ resolved_key = self.resolve_key(key)
174
+ return client.generate_presigned_url(
175
+ ClientMethod="get_object",
176
+ Params={"Bucket": self.config.bucket, "Key": resolved_key},
177
+ ExpiresIn=int(expires_in),
178
+ )
requirements.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ pandas
2
+ matplotlib
3
+ xlrd
4
+ pypinyin
5
+ openpyxl
6
+ numpy
7
+ streamlit-autorefresh
8
+ html2image
9
+ Pillow
10
+ flask
11
+ streamlit
12
+ python-dotenv
13
+ boto3
14
+ requests
15
+ altair
schedule_api_client.py ADDED
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 与 app.py 一致的票务排程 API(Token、影厅排片),供多页面复用,避免 import app 时执行整站 UI。
3
+ """
4
+ import json
5
+ import os
6
+ import time
7
+
8
+ import pandas as pd
9
+ import requests
10
+ from dotenv import load_dotenv
11
+
12
+
13
+ class _NoopStreamlit:
14
+ @staticmethod
15
+ def error(*args, **kwargs):
16
+ return None
17
+
18
+ @staticmethod
19
+ def toast(*args, **kwargs):
20
+ return None
21
+
22
+ @staticmethod
23
+ def cache_data(*args, **kwargs):
24
+ def _decorator(func):
25
+ return func
26
+
27
+ return _decorator
28
+
29
+
30
+ def _resolve_streamlit():
31
+ """
32
+ - Streamlit 页面内:保留原能力(toast/cache_data)
33
+ - 非 Streamlit 运行(如 Flask/FastAPI):使用 no-op,避免无运行时警告
34
+ """
35
+ try:
36
+ import streamlit as _st
37
+ from streamlit.runtime.scriptrunner import get_script_run_ctx
38
+
39
+ if get_script_run_ctx(suppress_warning=True) is None:
40
+ return _NoopStreamlit()
41
+ return _st
42
+ except Exception:
43
+ return _NoopStreamlit()
44
+
45
+
46
+ st = _resolve_streamlit()
47
+
48
+ load_dotenv()
49
+
50
+ TOKEN_FILE = "token_data.json"
51
+ CINEMA_ID = os.getenv("CINEMA_ID")
52
+
53
+
54
+ def load_token():
55
+ if os.path.exists(TOKEN_FILE):
56
+ try:
57
+ with open(TOKEN_FILE, "r", encoding="utf-8") as f:
58
+ return json.load(f)
59
+ except (json.JSONDecodeError, FileNotFoundError):
60
+ return None
61
+ return None
62
+
63
+
64
+ def save_token(token_data):
65
+ try:
66
+ with open(TOKEN_FILE, "w", encoding="utf-8") as f:
67
+ json.dump(token_data, f, ensure_ascii=False, indent=4)
68
+ return True
69
+ except Exception as e:
70
+ st.error(f"保存Token失败: {e}")
71
+ return False
72
+
73
+
74
+ def login_and_get_token():
75
+ username = os.getenv("CINEMA_USERNAME")
76
+ password = os.getenv("CINEMA_PASSWORD")
77
+ res_code = os.getenv("CINEMA_RES_CODE")
78
+ device_id = os.getenv("CINEMA_DEVICE_ID")
79
+
80
+ if not all([username, password, res_code]):
81
+ st.error("登录失败:未配置用户名、密码或影院编码环境变量。")
82
+ return None
83
+
84
+ session = requests.Session()
85
+ session.headers.update({
86
+ "Host": "app.bi.piao51.cn",
87
+ "Accept": "application/json, text/javascript, */*; q=0.01",
88
+ "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
89
+ })
90
+
91
+ login_url = "https://app.bi.piao51.cn/cinema-app/credential/login.action"
92
+ login_headers = {
93
+ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
94
+ "Origin": "https://app.bi.piao51.cn",
95
+ }
96
+ login_data = {
97
+ "username": username,
98
+ "password": password,
99
+ "type": "1",
100
+ "resCode": res_code,
101
+ "deviceid": device_id,
102
+ "dtype": "ios",
103
+ }
104
+
105
+ try:
106
+ response_login = session.post(login_url, headers=login_headers, data=login_data, allow_redirects=False, timeout=15)
107
+ if not (300 <= response_login.status_code < 400 and "token" in session.cookies):
108
+ st.error(f"登录步骤 1 失败,未能获取 Session Token。状态码: {response_login.status_code}")
109
+ return None
110
+
111
+ user_info_url = "https://app.bi.piao51.cn/cinema-app/security/logined.action"
112
+ response_user_info = session.get(user_info_url, timeout=10)
113
+ response_user_info.raise_for_status()
114
+
115
+ user_info = response_user_info.json()
116
+ if user_info.get("success") and user_info.get("data", {}).get("token"):
117
+ token_data = user_info["data"]
118
+ if save_token(token_data):
119
+ st.toast("登录成功,已获取并保存新 Token!", icon="🔑")
120
+ return token_data
121
+ st.error(f"登录步骤 2 失败,未能从 JSON 中提取 Token。响应: {user_info.get('msg')}")
122
+ return None
123
+
124
+ except requests.exceptions.RequestException as e:
125
+ st.error(f"登录请求过程中发生网络错误: {e}")
126
+ return None
127
+
128
+
129
+ def fetch_hall_info(token):
130
+ url = "https://cawapi.yinghezhong.com/showInfo/getShowHallInfo"
131
+ params = {"token": token, "_": int(time.time() * 1000)}
132
+ headers = {"Origin": "https://caw.yinghezhong.com", "User-Agent": "Mozilla/5.0"}
133
+ response = requests.get(url, params=params, headers=headers, timeout=10)
134
+ response.raise_for_status()
135
+ data = response.json()
136
+ if data.get("code") == 1 and data.get("data"):
137
+ return {item["hallId"]: item["seatNum"] for item in data["data"]}
138
+ raise Exception(f"获取影厅信息失败: {data.get('msg', '未知错误')}")
139
+
140
+
141
+ def fetch_schedule_data(token, show_date):
142
+ url = "https://cawapi.yinghezhong.com/showInfo/getHallShowInfo"
143
+ params = {"showDate": show_date, "token": token, "_": int(time.time() * 1000)}
144
+ headers = {"Origin": "https://caw.yinghezhong.com", "User-Agent": "Mozilla/5.0"}
145
+ response = requests.get(url, params=params, headers=headers, timeout=15)
146
+ response.raise_for_status()
147
+ data = response.json()
148
+ if data.get("code") == 1:
149
+ return data.get("data", [])
150
+ if data.get("code") == 500:
151
+ raise ValueError("Token 可能已失效")
152
+ raise Exception(f"获取排片数据失败: {data.get('msg', '未知错误')}")
153
+
154
+
155
+ def get_api_data_with_token_management(show_date):
156
+ token_data = load_token()
157
+ token = token_data.get("token") if token_data else None
158
+ if not token:
159
+ token_data = login_and_get_token()
160
+ if not token_data:
161
+ return None, None
162
+ token = token_data.get("token")
163
+
164
+ try:
165
+ schedule_list = fetch_schedule_data(token, show_date)
166
+ hall_seat_map = fetch_hall_info(token)
167
+ return schedule_list, hall_seat_map
168
+ except ValueError:
169
+ st.toast("Token 已失效,正在尝试重新登录并重试...", icon="🔄")
170
+ token_data = login_and_get_token()
171
+ if not token_data:
172
+ return None, None
173
+ token = token_data.get("token")
174
+ try:
175
+ schedule_list = fetch_schedule_data(token, show_date)
176
+ hall_seat_map = fetch_hall_info(token)
177
+ return schedule_list, hall_seat_map
178
+ except Exception as e:
179
+ st.error(f"重试获取数据失败: {e}")
180
+ return None, None
181
+ except Exception as e:
182
+ st.error(f"获取 API 数据时发生错误: {e}")
183
+ return None, None
184
+
185
+
186
+ @st.cache_data(show_spinner=False, ttl=600)
187
+ def fetch_canonical_movie_names(token, date_str):
188
+ if not CINEMA_ID:
189
+ return []
190
+ url = "https://app.bi.piao51.cn/cinema-app/mycinema/movieSellGross.action"
191
+ params = {
192
+ "token": token,
193
+ "startDate": date_str,
194
+ "endDate": date_str,
195
+ "dateType": "day",
196
+ "cinemaId": CINEMA_ID,
197
+ }
198
+ headers = {
199
+ "Host": "app.bi.piao51.cn",
200
+ "X-Requested-With": "XMLHttpRequest",
201
+ "jwt": "0",
202
+ "Accept": "application/json, text/javascript, */*; q=0.01",
203
+ "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
204
+ }
205
+
206
+ try:
207
+ response = requests.get(url, params=params, headers=headers, timeout=10)
208
+ response.raise_for_status()
209
+ data = response.json()
210
+ if data.get("code") == "A00000" and data.get("results"):
211
+ return [
212
+ item["movieName"]
213
+ for item in data["results"]
214
+ if item.get("movieName") and item["movieName"] != "总计"
215
+ ]
216
+ except Exception as e:
217
+ print(f"获取标准电影名称失败: {e}")
218
+ return []
219
+
220
+
221
+ def clean_movie_title(raw_title, canonical_names=None):
222
+ if not isinstance(raw_title, str):
223
+ return raw_title
224
+
225
+ base_name = None
226
+
227
+ if canonical_names:
228
+ sorted_names = sorted(canonical_names, key=len, reverse=True)
229
+ for name in sorted_names:
230
+ if name in raw_title:
231
+ base_name = name
232
+ break
233
+
234
+ if not base_name:
235
+ base_name = raw_title.split(" ", 1)[0]
236
+
237
+ raw_upper = raw_title.upper()
238
+ suffix = ""
239
+
240
+ if "HDR LED" in raw_upper:
241
+ suffix = "(HDR LED)"
242
+ elif "CINITY" in raw_upper:
243
+ suffix = "(CINITY)"
244
+ elif "杜比" in raw_upper or "DOLBY" in raw_upper:
245
+ suffix = "(杜比视界)"
246
+ elif "IMAX" in raw_upper:
247
+ suffix = "(数字IMAX3D)" if "3D" in raw_upper else "(数字IMAX)"
248
+ elif "巨幕" in raw_upper:
249
+ suffix = "(中国巨幕立体)" if "立体" in raw_upper else "(中国巨幕)"
250
+ elif "3D" in raw_upper:
251
+ suffix = "(数字3D)"
252
+
253
+ if suffix and suffix not in base_name:
254
+ return f"{base_name}{suffix}"
255
+
256
+ return base_name
257
+
258
+
259
+ def get_valid_token(force_refresh=False):
260
+ token_data = None if force_refresh else load_token()
261
+ if not token_data:
262
+ token_data = login_and_get_token()
263
+ if not token_data:
264
+ return None
265
+ return token_data.get("token")
266
+
267
+
268
+ def fetch_schedule_api_bundle(show_date):
269
+ """
270
+ 一次性获取排程相关 API 原始数据:
271
+ - getHallShowInfo(场次列表)
272
+ - getShowHallInfo(影厅座位映射)
273
+ - movieSellGross(标准影片名称)
274
+ """
275
+ schedule_list, hall_seat_map = get_api_data_with_token_management(show_date)
276
+ if schedule_list is None or hall_seat_map is None:
277
+ return None
278
+
279
+ token_data = load_token()
280
+ token = token_data.get("token") if token_data else None
281
+ canonical_names = fetch_canonical_movie_names(token, show_date) if token else []
282
+
283
+ return {
284
+ "show_date": show_date,
285
+ "token": token,
286
+ "schedule_list": schedule_list,
287
+ "hall_seat_map": hall_seat_map,
288
+ "canonical_names": canonical_names,
289
+ }
290
+
291
+
292
+ def process_schedule_dataframe(schedule_list, hall_seat_map, canonical_names=None):
293
+ """将排程 API 原始数据整理成便于展示的表格。"""
294
+ if not schedule_list:
295
+ return pd.DataFrame()
296
+
297
+ df = pd.DataFrame(schedule_list)
298
+ if df.empty:
299
+ return pd.DataFrame()
300
+
301
+ df["座位数"] = df["hallId"].map(hall_seat_map or {}).fillna(0).astype(int)
302
+ df.rename(
303
+ columns={
304
+ "movieName": "影片名称",
305
+ "showStartTime": "放映时间",
306
+ "soldBoxOffice": "总收入",
307
+ "soldTicketNum": "总人次",
308
+ "hallName": "影厅名称",
309
+ "showEndTime": "散场时间",
310
+ },
311
+ inplace=True,
312
+ )
313
+
314
+ if "影片名称" in df.columns:
315
+ df["影片名称_清洗后"] = df["影片名称"].apply(
316
+ lambda x: clean_movie_title(x, canonical_names)
317
+ )
318
+
319
+ required_cols = [
320
+ "影片名称",
321
+ "影片名称_清洗后",
322
+ "放映时间",
323
+ "散场时间",
324
+ "影厅名称",
325
+ "座位数",
326
+ "总收入",
327
+ "总人次",
328
+ ]
329
+ for col in required_cols:
330
+ if col not in df.columns:
331
+ df[col] = None
332
+
333
+ df = df[required_cols]
334
+ for col in ["座位数", "总收入", "总人次"]:
335
+ df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0)
336
+
337
+ return df
schedule_api_data.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+
3
+ import pandas as pd
4
+ import streamlit as st
5
+
6
+ from schedule_api_client import fetch_schedule_api_bundle, process_schedule_dataframe
7
+
8
+
9
+ st.set_page_config(layout="wide", page_title="排程 API 数据")
10
+ st.title("🧩 排程 API 数据查看")
11
+ st.caption("统一展示排程相关 API:场次、影厅座位、标准影片名。")
12
+
13
+ col1, col2 = st.columns([2, 1])
14
+ with col1:
15
+ selected_date = st.date_input("排程日期", value=datetime.now().date(), key="schedule_api_date")
16
+ with col2:
17
+ st.write("")
18
+ st.write("")
19
+ fetch_btn = st.button("获取并展示", type="primary", key="schedule_api_fetch")
20
+
21
+ date_str = selected_date.strftime("%Y-%m-%d")
22
+
23
+ if fetch_btn:
24
+ with st.spinner(f"正在获取 {date_str} 的排程 API 数据..."):
25
+ bundle = fetch_schedule_api_bundle(date_str)
26
+ if not bundle:
27
+ st.error("获取失败,请检查 .env、网络或 token。")
28
+ else:
29
+ st.session_state["schedule_api_bundle"] = bundle
30
+ st.toast("排程 API 数据加载成功", icon="✅")
31
+
32
+ bundle = st.session_state.get("schedule_api_bundle")
33
+ if bundle:
34
+ schedule_list = bundle.get("schedule_list") or []
35
+ hall_seat_map = bundle.get("hall_seat_map") or {}
36
+ canonical_names = bundle.get("canonical_names") or []
37
+
38
+ c1, c2, c3 = st.columns(3)
39
+ c1.metric("场次数", len(schedule_list))
40
+ c2.metric("影厅数", len(hall_seat_map))
41
+ c3.metric("标准影片名数量", len(canonical_names))
42
+
43
+ tidy_df = process_schedule_dataframe(schedule_list, hall_seat_map, canonical_names)
44
+
45
+ tab1, tab2, tab3, tab4 = st.tabs(
46
+ [
47
+ "整理后表格",
48
+ "原始场次 API",
49
+ "影厅座位 API",
50
+ "标准影片名 API",
51
+ ]
52
+ )
53
+
54
+ with tab1:
55
+ if tidy_df.empty:
56
+ st.warning("暂无可展示的排程数据。")
57
+ else:
58
+ st.dataframe(tidy_df, width="stretch", height=600)
59
+
60
+ with tab2:
61
+ if schedule_list:
62
+ raw_schedule_df = pd.DataFrame(schedule_list)
63
+ st.dataframe(raw_schedule_df, width="stretch", height=500)
64
+ with st.expander("查看原始 JSON"):
65
+ st.json(schedule_list)
66
+ else:
67
+ st.info("API 返回空场次列表。")
68
+
69
+ with tab3:
70
+ if hall_seat_map:
71
+ hall_df = pd.DataFrame(
72
+ [{"hallId": k, "seatNum": v} for k, v in hall_seat_map.items()]
73
+ ).sort_values("hallId")
74
+ st.dataframe(hall_df, width="stretch", height=500)
75
+ with st.expander("查看原始 JSON"):
76
+ st.json(hall_seat_map)
77
+ else:
78
+ st.info("未获取到影厅座位数据。")
79
+
80
+ with tab4:
81
+ if canonical_names:
82
+ canonical_df = pd.DataFrame({"movieName": canonical_names})
83
+ st.dataframe(canonical_df, width="stretch", height=500)
84
+ with st.expander("查看原始 JSON"):
85
+ st.json(canonical_names)
86
+ else:
87
+ st.info("未获取到标准影片名数据(可能是 CINEMA_ID 未配置或接口返回为空)。")
88
+ else:
89
+ st.info("请选择日期并点击「获取并展示」。")
schedule_check_settings.json ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "rule1_enabled": true,
3
+ "rule1_min_interval_minutes": 30,
4
+ "rule2_enabled": true,
5
+ "rule2_threshold_sessions": 4,
6
+ "rule2_window_minutes": 20,
7
+ "rule2_exempt_ranges": [
8
+ "14:00-16:00",
9
+ "19:00-21:00"
10
+ ],
11
+ "rule3_enabled": true,
12
+ "rule3_gap_threshold_minutes": 30,
13
+ "rule4_enabled": true,
14
+ "rule4_first_show_latest_time": "10:00",
15
+ "rule4_last_show_earliest_time": "22:30",
16
+ "rule5_enabled": true,
17
+ "rule5_idle_threshold_minutes": 60,
18
+ "rule5_window_start_time": "10:00",
19
+ "rule5_window_end_time": "23:00",
20
+ "rule6_enabled": true,
21
+ "rule6_min_conversion_minutes": 10,
22
+ "rule7_enabled": true,
23
+ "rule7_window_minutes": 10,
24
+ "rule7_step_minutes": 5,
25
+ "rule7_total_peak_threshold": 5,
26
+ "rule7_same_time_threshold": 3,
27
+ "rule8_enabled": true,
28
+ "rule8_last_end_threshold_time": "23:00",
29
+ "rule11_hot_top_n": 3,
30
+ "rule11_late_start_time": "22:00",
31
+ "rule9_enabled": true,
32
+ "rule9_hot_top_n": 3,
33
+ "rule9_density_min_ratio": 0.2,
34
+ "rule9_density_max_ratio": 0.55,
35
+ "rule10_enabled": true,
36
+ "rule11_enabled": true,
37
+ "rule12_enabled": true,
38
+ "rule12_top_n": 5,
39
+ "rule12_golden_start_time": "14:00",
40
+ "rule12_golden_end_time": "21:00",
41
+ "rule13_enabled": true,
42
+ "rule13_restricted_halls_text": "2,8,9",
43
+ "rule14_enabled": true,
44
+ "rule14_start_score_threshold": 4,
45
+ "rule14_end_score_threshold": 4,
46
+ "rule15_enabled": true,
47
+ "rule15_special_hall_text": "9",
48
+ "rule15_price_delta_yuan": 6.0,
49
+ "guide_enabled": true,
50
+ "guide_movie_session_rules": [
51
+ {
52
+ "enabled": true,
53
+ "movie": "续范亭将军",
54
+ "start_date": "2026-07-09",
55
+ "end_date": "2026-07-25",
56
+ "min_sessions": 28
57
+ }
58
+ ],
59
+ "guide_idle_window_rules": [
60
+ {
61
+ "enabled": true,
62
+ "start_date": "2026-07-01",
63
+ "end_date": "2026-09-01",
64
+ "weekdays_text": "周一,周二,周三,周四,周五,周六,周日",
65
+ "time_range": "09:30-12:00"
66
+ }
67
+ ],
68
+ "guide_required_show_rules": [
69
+ {
70
+ "enabled": true,
71
+ "start_date": "2026-07-01",
72
+ "end_date": "2026-09-01",
73
+ "weekdays_text": "周六,周日",
74
+ "time_point": "15:00",
75
+ "movie": ""
76
+ }
77
+ ]
78
+ }
tms_proxy.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from urllib.parse import urlparse
3
+
4
+
5
+ TMS_ORIGIN = "https://tms.hengdianfilm.com"
6
+ TMS_PROXY_URL_ENV = "TMS_CF_WORKER_URL"
7
+ TMS_PROXY_TOKEN_ENV = "TMS_CF_WORKER_TOKEN"
8
+ TMS_PROXY_TOKEN_HEADER = "X-TMS-Proxy-Token"
9
+
10
+
11
+ def get_tms_proxy_base_url(proxy_url=None):
12
+ raw = proxy_url if proxy_url is not None else os.getenv(TMS_PROXY_URL_ENV, "")
13
+ raw = str(raw or "").strip()
14
+ if not raw:
15
+ return ""
16
+ if "://" not in raw:
17
+ raw = f"https://{raw}"
18
+ return raw.rstrip("/")
19
+
20
+
21
+ def build_tms_url(origin_url, proxy_url=None):
22
+ proxy_base_url = get_tms_proxy_base_url(proxy_url)
23
+ if not proxy_base_url:
24
+ return origin_url
25
+
26
+ parsed = urlparse(origin_url)
27
+ origin = f"{parsed.scheme}://{parsed.netloc}"
28
+ if origin != TMS_ORIGIN:
29
+ return origin_url
30
+
31
+ query = f"?{parsed.query}" if parsed.query else ""
32
+ return f"{proxy_base_url}{parsed.path}{query}"
33
+
34
+
35
+ def with_tms_proxy_headers(headers=None, proxy_url=None):
36
+ headers = dict(headers or {})
37
+ if get_tms_proxy_base_url(proxy_url):
38
+ proxy_token = os.getenv(TMS_PROXY_TOKEN_ENV, "").strip()
39
+ if proxy_token:
40
+ headers[TMS_PROXY_TOKEN_HEADER] = proxy_token
41
+ return headers
42
+
43
+
44
+ def tms_verify_ssl(default=False, proxy_url=None):
45
+ if get_tms_proxy_base_url(proxy_url):
46
+ return True
47
+ return default
waterbar_goods_settings.json ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "selected_goods": [
3
+ "福满百香(500ml冷)",
4
+ "红豆椰椰冰",
5
+ "粉荔知夏",
6
+ "粉荔知夏700ml",
7
+ "青羽椰椰冰",
8
+ "加珍珠",
9
+ "芭乐莓莓鲜果茶700ml",
10
+ "樱为有你·啵啵冰(大杯)",
11
+ "大红袍珍珠吨吨桶(1L)",
12
+ "大红袍珍珠奶茶(大杯)",
13
+ "樱为有你·啵啵奶(大杯)",
14
+ "加3Q魔芋",
15
+ "大杯韩式柚子茶22oz",
16
+ "樱为有你·啵啵茶(中杯)",
17
+ "福满百香(700ml)",
18
+ "清香茉莉500ml",
19
+ "樱为有你·啵啵茶(大杯)",
20
+ "耙耙柑茉莉冰茶(中杯)",
21
+ "一颗爆C柠"
22
+ ],
23
+ "selected_package_goods": [
24
+ "大杯柠檬可乐22oz",
25
+ "大杯酸甜檬太奇22oz",
26
+ "双人套餐C|大杯酸甜檬太奇22oz",
27
+ "双人套餐C|樱为有你·啵啵冰(大杯)",
28
+ "单人套餐D|中杯柠檬可乐16oz",
29
+ "双人套餐C|大杯柠檬可乐22oz",
30
+ "儿童乐享套餐|中杯柠檬可乐16oz",
31
+ "单人套餐A|中杯柠檬可乐16oz",
32
+ "单人套餐B|大杯柠檬可乐22oz",
33
+ "单人套餐D|大杯柠檬可乐22oz",
34
+ "双人套餐C|耙耙柑栀香轻乳茶(大杯)",
35
+ "多拼家庭套餐|大杯柠檬可乐22oz",
36
+ "新柠单人套餐|大杯柠檬可乐22oz",
37
+ "新柠双人套餐|大杯柠檬可乐22oz",
38
+ "电影美食套餐|大杯柠檬可乐22oz",
39
+ "空心薯双人套餐|大杯柠檬可乐22oz"
40
+ ]
41
+ }