Spaces:
Paused
Paused
zt p commited on
Commit ·
80c4080
0
Parent(s):
Overwrite Space with local hengdian app
Browse files- .dockerignore +20 -0
- .gitattributes +37 -0
- .gitignore +20 -0
- Dockerfile +27 -0
- README.md +13 -0
- app.py +0 -0
- cinema_api_client.py +373 -0
- data/efficiency_at_each_time_point.json +169 -0
- gantt_export.py +118 -0
- historical_sessions.py +408 -0
- legacy_pages/tms-2.py +372 -0
- movie_duration_data.csv +0 -0
- new_api_proxy.py +65 -0
- pages/⏰ 历史场次自动同步监控.py +0 -0
- pages/🆕 新API卖品销售总额测试.py +1028 -0
- pages/🆕 新API影片映出日累计报表测试.py +767 -0
- pages/🎟️ 售票时间集中监控.py +1341 -0
- pages/💸 空场防空转监控.py +787 -0
- pages/📡 次日排片 TMS 文件核对.py +705 -0
- pages/📢 突发购票监控.py +499 -0
- pages/📨 排片检查与 TMS 内容核对监控.py +1153 -0
- pages/🔍 TMS 服务器影片内容查询.py +559 -0
- pages/🔦 影片放映时间表统计.py +409 -0
- pages/🧐 影片效率分析.py +916 -0
- pages/🧰 工具大全.py +87 -0
- pages/🪄 次日自动排片(随机贪心构造 + 蒙特卡洛评估)测试版本.py +0 -0
- print_settings.json +13 -0
- r2_config_sync.py +140 -0
- r2_storage.py +178 -0
- requirements.txt +15 -0
- schedule_api_client.py +337 -0
- schedule_api_data.py +89 -0
- schedule_check_settings.json +78 -0
- tms_proxy.py +47 -0
- waterbar_goods_settings.json +41 -0
.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 |
+
}
|