feat(mmp): 独立红包领取台(手机+PC)、同步、筛选与功能说明
隔离通用消息台;支持设置/手气摘要/电脑同步;忽略临时逆向脚本与运行时 JSON。
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
"""notiMessage 本地调试转发服务 — 在 PC 浏览器查看手机抓取的消息。"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
@@ -13,10 +14,80 @@ from urllib.parse import urlparse
|
||||
HOST = "0.0.0.0"
|
||||
PORT = 8765
|
||||
MAX_MESSAGES = 500
|
||||
SETTINGS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "mmp_settings.json")
|
||||
PACKETS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "mmp_packets.json")
|
||||
|
||||
_messages = deque(maxlen=MAX_MESSAGES)
|
||||
_mmp_messages = deque(maxlen=MAX_MESSAGES)
|
||||
_mmp_synced = {} # packetId -> packet dict(手机全量同步,落盘)
|
||||
_lock = threading.Lock()
|
||||
_last_dedup = {"key": None, "ts": 0.0}
|
||||
_mmp_last_dedup = {"key": None, "ts": 0.0}
|
||||
_mmp_settings_lock = threading.Lock()
|
||||
_DEFAULT_MMP_SETTINGS = {
|
||||
"historyCooldownSec": 8,
|
||||
"detailCooldownSec": 8,
|
||||
"dedupSec": 3,
|
||||
"detailGapMs": 80,
|
||||
"pagePollMs": 1000,
|
||||
"openHistoryIfNoTemplate": True,
|
||||
}
|
||||
_mmp_settings = dict(_DEFAULT_MMP_SETTINGS)
|
||||
|
||||
|
||||
def _load_mmp_settings():
|
||||
global _mmp_settings
|
||||
try:
|
||||
if os.path.isfile(SETTINGS_PATH):
|
||||
with open(SETTINGS_PATH, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
merged = dict(_DEFAULT_MMP_SETTINGS)
|
||||
merged.update(data)
|
||||
_mmp_settings = _normalize_mmp_settings(merged)
|
||||
except Exception as e:
|
||||
print("load mmp settings failed:", e)
|
||||
|
||||
|
||||
def _save_mmp_settings():
|
||||
try:
|
||||
with open(SETTINGS_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(_mmp_settings, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
print("save mmp settings failed:", e)
|
||||
|
||||
|
||||
def _normalize_mmp_settings(raw):
|
||||
out = dict(_DEFAULT_MMP_SETTINGS)
|
||||
if not isinstance(raw, dict):
|
||||
return out
|
||||
try:
|
||||
out["historyCooldownSec"] = max(1, min(300, int(raw.get("historyCooldownSec", out["historyCooldownSec"]))))
|
||||
out["detailCooldownSec"] = max(1, min(300, int(raw.get("detailCooldownSec", out["detailCooldownSec"]))))
|
||||
out["dedupSec"] = max(0, min(120, int(raw.get("dedupSec", out["dedupSec"]))))
|
||||
out["detailGapMs"] = max(0, min(5000, int(raw.get("detailGapMs", out["detailGapMs"]))))
|
||||
out["pagePollMs"] = max(500, min(30000, int(raw.get("pagePollMs", out["pagePollMs"]))))
|
||||
out["openHistoryIfNoTemplate"] = bool(raw.get(
|
||||
"openHistoryIfNoTemplate", out["openHistoryIfNoTemplate"]))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _get_mmp_settings():
|
||||
with _mmp_settings_lock:
|
||||
return dict(_mmp_settings)
|
||||
|
||||
|
||||
def _set_mmp_settings(raw):
|
||||
global _mmp_settings
|
||||
with _mmp_settings_lock:
|
||||
_mmp_settings = _normalize_mmp_settings(raw)
|
||||
_save_mmp_settings()
|
||||
return dict(_mmp_settings)
|
||||
|
||||
|
||||
_load_mmp_settings()
|
||||
|
||||
|
||||
def _now_iso():
|
||||
@@ -35,7 +106,6 @@ def _add_message(payload):
|
||||
item = dict(payload)
|
||||
item["group"] = _resolve_group(payload)
|
||||
item["receivedAt"] = _now_iso()
|
||||
# 本机 + 局域网双推时可能各成功一次,3 秒内同内容去重
|
||||
dedup_key = "|".join([
|
||||
str(payload.get("source") or ""),
|
||||
str(payload.get("packageName") or ""),
|
||||
@@ -43,17 +113,31 @@ def _add_message(payload):
|
||||
str(payload.get("content") or ""),
|
||||
])
|
||||
now = time.time()
|
||||
is_mmp = _is_mmp_message(item)
|
||||
with _lock:
|
||||
if (_last_dedup["key"] == dedup_key
|
||||
and now - float(_last_dedup["ts"]) < 3.0):
|
||||
if _messages:
|
||||
return _messages[0]
|
||||
_last_dedup["key"] = dedup_key
|
||||
_last_dedup["ts"] = now
|
||||
_messages.appendleft(item)
|
||||
item["id"] = len(_messages)
|
||||
print("[{0}] [{1}] [{2}] {3} | {4}".format(
|
||||
if is_mmp:
|
||||
if (_mmp_last_dedup["key"] == dedup_key
|
||||
and now - float(_mmp_last_dedup["ts"]) < 3.0):
|
||||
if _mmp_messages:
|
||||
return _mmp_messages[0]
|
||||
_mmp_last_dedup["key"] = dedup_key
|
||||
_mmp_last_dedup["ts"] = now
|
||||
_mmp_messages.appendleft(item)
|
||||
item["id"] = len(_mmp_messages)
|
||||
channel = "MMP"
|
||||
else:
|
||||
if (_last_dedup["key"] == dedup_key
|
||||
and now - float(_last_dedup["ts"]) < 3.0):
|
||||
if _messages:
|
||||
return _messages[0]
|
||||
_last_dedup["key"] = dedup_key
|
||||
_last_dedup["ts"] = now
|
||||
_messages.appendleft(item)
|
||||
item["id"] = len(_messages)
|
||||
channel = "MSG"
|
||||
print("[{0}] [{1}] [{2}] [{3}] {4} | {5}".format(
|
||||
_now_iso(),
|
||||
channel,
|
||||
item["group"],
|
||||
payload.get("source", "?"),
|
||||
payload.get("appName", payload.get("packageName", "")),
|
||||
@@ -62,6 +146,16 @@ def _add_message(payload):
|
||||
return item
|
||||
|
||||
|
||||
def _clear_mmp_messages():
|
||||
with _lock:
|
||||
_mmp_messages.clear()
|
||||
_mmp_synced.clear()
|
||||
# 兼容:顺带清掉旧版混入通用队列的 MMP
|
||||
keep = [m for m in _messages if not _is_mmp_message(m)]
|
||||
_messages.clear()
|
||||
_messages.extend(keep)
|
||||
_save_mmp_synced()
|
||||
|
||||
def _json_response(handler, status, data):
|
||||
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
||||
handler.send_response(status)
|
||||
@@ -75,6 +169,8 @@ def _json_response(handler, status, data):
|
||||
def _group_messages(messages):
|
||||
groups = {}
|
||||
for msg in messages:
|
||||
if _is_mmp_message(msg):
|
||||
continue
|
||||
key = msg.get("group") or _resolve_group(msg)
|
||||
if key not in groups:
|
||||
groups[key] = {
|
||||
@@ -257,10 +353,154 @@ def _aggregate_claims(claims):
|
||||
return result
|
||||
|
||||
|
||||
|
||||
def _packet_score(it):
|
||||
board = it.get("leaderboard") or it.get("claims") or []
|
||||
return (
|
||||
1 if it.get("finished") else 0,
|
||||
1 if it.get("issuedAt") else 0,
|
||||
len(board),
|
||||
it.get("updatedAt") or it.get("fetchedAt") or it.get("latestAt") or "",
|
||||
)
|
||||
|
||||
|
||||
def _normalize_synced_packet(raw):
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
pid = str(raw.get("packetId") or raw.get("packet") or "").strip()
|
||||
if not pid:
|
||||
return None
|
||||
board = raw.get("leaderboard") or []
|
||||
if not isinstance(board, list):
|
||||
board = []
|
||||
norm_board = []
|
||||
for i, row in enumerate(board):
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
amt = row.get("amount")
|
||||
if amt is None:
|
||||
amt = _normalize_money(row.get("amountText"))
|
||||
try:
|
||||
amt = float(amt or 0)
|
||||
except (TypeError, ValueError):
|
||||
amt = 0.0
|
||||
nick = (row.get("nickname") or "?").strip() or "?"
|
||||
norm_board.append({
|
||||
"nickname": nick,
|
||||
"amount": round(amt, 4),
|
||||
"amountText": row.get("amountText") or "{0:.2f}".format(amt),
|
||||
"claimTime": row.get("claimTime") or "",
|
||||
"rank": int(row.get("rank") or (i + 1)),
|
||||
})
|
||||
norm_board.sort(key=lambda x: x["amount"], reverse=True)
|
||||
for i, row in enumerate(norm_board, 1):
|
||||
row["rank"] = i
|
||||
best = norm_board[0] if norm_board else None
|
||||
worst = norm_board[-1] if norm_board else None
|
||||
sum_claimed = raw.get("sumClaimed")
|
||||
try:
|
||||
sum_claimed = float(sum_claimed) if sum_claimed is not None else round(
|
||||
sum(x["amount"] for x in norm_board), 4)
|
||||
except (TypeError, ValueError):
|
||||
sum_claimed = round(sum(x["amount"] for x in norm_board), 4)
|
||||
total = raw.get("total") or ""
|
||||
total_num = _normalize_money(total)
|
||||
if total_num is not None:
|
||||
total = "{0:.2f}".format(total_num)
|
||||
elif sum_claimed:
|
||||
total = "{0:.2f}".format(sum_claimed)
|
||||
issued = raw.get("issuedAt") or raw.get("issued") or ""
|
||||
return {
|
||||
"packetId": pid,
|
||||
"title": raw.get("title") or "TNG 红包",
|
||||
"sender": raw.get("sender") or "",
|
||||
"group": raw.get("group") or "",
|
||||
"total": total,
|
||||
"via": raw.get("via") or "phone-sync",
|
||||
"issuedAt": issued,
|
||||
"updatedAt": raw.get("updatedAt") or "",
|
||||
"fetchedAt": raw.get("updatedAt") or raw.get("fetchedAt") or _now_iso(),
|
||||
"latestAt": issued or raw.get("updatedAt") or "",
|
||||
"finished": bool(raw.get("finished")),
|
||||
"snapshots": int(raw.get("snapshots") or 1),
|
||||
"leaderboard": norm_board,
|
||||
"claimantCount": len(norm_board) if norm_board else int(raw.get("claimantCount") or 0),
|
||||
"sumClaimed": sum_claimed,
|
||||
"bestNick": (best or {}).get("nickname") or "",
|
||||
"bestAmount": (best or {}).get("amountText") or "",
|
||||
"worstNick": (worst or {}).get("nickname") or "",
|
||||
"worstAmount": (worst or {}).get("amountText") or "",
|
||||
"fromSync": True,
|
||||
}
|
||||
|
||||
|
||||
def _load_mmp_synced():
|
||||
global _mmp_synced
|
||||
try:
|
||||
if not os.path.isfile(PACKETS_PATH):
|
||||
return
|
||||
with open(PACKETS_PATH, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
items = data.get("packets") if isinstance(data, dict) else data
|
||||
if not isinstance(items, list):
|
||||
return
|
||||
synced = {}
|
||||
for raw in items:
|
||||
p = _normalize_synced_packet(raw)
|
||||
if p:
|
||||
synced[p["packetId"]] = p
|
||||
with _lock:
|
||||
_mmp_synced = synced
|
||||
print("loaded mmp synced packets:", len(synced))
|
||||
except Exception as e:
|
||||
print("load mmp packets failed:", e)
|
||||
|
||||
|
||||
def _save_mmp_synced():
|
||||
try:
|
||||
with _lock:
|
||||
packets = list(_mmp_synced.values())
|
||||
with open(PACKETS_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump({"packets": packets, "savedAt": _now_iso()}, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
print("save mmp packets failed:", e)
|
||||
|
||||
|
||||
def _sync_mmp_packets(payload):
|
||||
"""手机全量同步:合并进落盘镜像,电脑刷新 /mmp 即可看到。"""
|
||||
items = []
|
||||
if isinstance(payload, dict):
|
||||
items = payload.get("packets") or []
|
||||
elif isinstance(payload, list):
|
||||
items = payload
|
||||
if not isinstance(items, list):
|
||||
return {"ok": False, "error": "packets must be list", "count": 0}
|
||||
merged = 0
|
||||
with _lock:
|
||||
for raw in items:
|
||||
p = _normalize_synced_packet(raw)
|
||||
if not p:
|
||||
continue
|
||||
key = p["packetId"]
|
||||
old = _mmp_synced.get(key)
|
||||
if old is None or _packet_score(p) >= _packet_score(old):
|
||||
if old and old.get("finished"):
|
||||
p["finished"] = True
|
||||
if old and not p.get("issuedAt") and old.get("issuedAt"):
|
||||
p["issuedAt"] = old["issuedAt"]
|
||||
p["latestAt"] = p["issuedAt"] or p.get("latestAt") or ""
|
||||
_mmp_synced[key] = p
|
||||
merged += 1
|
||||
total = len(_mmp_synced)
|
||||
_save_mmp_synced()
|
||||
return {"ok": True, "count": merged, "total": total}
|
||||
|
||||
|
||||
def _mmp_packets():
|
||||
with _lock:
|
||||
msgs = [m for m in _messages if _is_mmp_message(m)]
|
||||
# 每个红包只保留「最新一次完整领取榜」快照,避免多个红包因缺 packetId 被揉在一起
|
||||
# 独立队列 + 兼容旧版混入通用消息的 MMP
|
||||
msgs = list(_mmp_messages) + [m for m in _messages if _is_mmp_message(m)]
|
||||
# 每个红包只保留「最新一次完整领取榜」快照
|
||||
packets = {}
|
||||
for msg in msgs:
|
||||
meta, claims = _parse_mmp_content(msg.get("content") or "")
|
||||
@@ -268,10 +508,11 @@ def _mmp_packets():
|
||||
continue
|
||||
packet_id = meta.get("packet") or meta.get("packetId") or ""
|
||||
if not packet_id or packet_id in ("unknown", "TNG 红包", "TNG Money Packet"):
|
||||
# 用领取名单指纹区分不同红包
|
||||
packet_id = "红包-" + _claims_fingerprint(claims)[:48]
|
||||
key = str(packet_id)
|
||||
issued = meta.get("issued") or ""
|
||||
finished = str(meta.get("done") or "").lower() in ("1", "true") \
|
||||
or str(meta.get("status") or "").upper() in ("FINISHED", "COMPLETE", "COMPLETED", "EXPIRED")
|
||||
item = {
|
||||
"packetId": key,
|
||||
"title": msg.get("title") or "TNG 红包",
|
||||
@@ -282,17 +523,11 @@ def _mmp_packets():
|
||||
"issuedAt": issued,
|
||||
"fetchedAt": msg.get("receivedAt") or "",
|
||||
"latestAt": issued or (msg.get("receivedAt") or ""),
|
||||
"finished": finished,
|
||||
"snapshots": 1,
|
||||
"claims": claims,
|
||||
"rawMessages": [{
|
||||
"id": msg.get("id"),
|
||||
"receivedAt": msg.get("receivedAt"),
|
||||
"content": msg.get("content"),
|
||||
"source": msg.get("source"),
|
||||
}],
|
||||
}
|
||||
existing = packets.get(key)
|
||||
# 优先保留「有发放时间 + 领取更全」的快照;时间戳用发放时间排序
|
||||
def _score(it):
|
||||
return (
|
||||
1 if it.get("issuedAt") else 0,
|
||||
@@ -302,17 +537,19 @@ def _mmp_packets():
|
||||
if existing is None or _score(item) >= _score(existing):
|
||||
if existing is not None:
|
||||
item["snapshots"] = int(existing.get("snapshots") or 1) + 1
|
||||
item["rawMessages"] = existing.get("rawMessages", []) + item["rawMessages"]
|
||||
if not item.get("issuedAt") and existing.get("issuedAt"):
|
||||
item["issuedAt"] = existing["issuedAt"]
|
||||
item["latestAt"] = item["issuedAt"] or item.get("fetchedAt") or ""
|
||||
if existing.get("finished"):
|
||||
item["finished"] = True
|
||||
packets[key] = item
|
||||
else:
|
||||
existing["snapshots"] = int(existing.get("snapshots") or 1) + 1
|
||||
existing["rawMessages"].append(item["rawMessages"][0])
|
||||
if not existing.get("issuedAt") and issued:
|
||||
existing["issuedAt"] = issued
|
||||
existing["latestAt"] = issued
|
||||
if finished:
|
||||
existing["finished"] = True
|
||||
result = []
|
||||
for p in packets.values():
|
||||
ranked = _aggregate_claims(p.get("claims") or [])
|
||||
@@ -323,15 +560,38 @@ def _mmp_packets():
|
||||
p["sumClaimed"] = round(sum(x["amount"] for x in ranked), 4)
|
||||
except Exception:
|
||||
p["sumClaimed"] = 0
|
||||
# 总额缺失或只抓到单笔金额时,用领取合计兜底
|
||||
total_num = _normalize_money(p.get("total"))
|
||||
if total_num is None or (p["sumClaimed"] > 0 and abs(total_num - p["sumClaimed"]) > 0.001
|
||||
and total_num <= max((x["amount"] for x in ranked), default=0) + 1e-9):
|
||||
p["total"] = "{0:.2f}".format(p["sumClaimed"])
|
||||
elif total_num is not None:
|
||||
p["total"] = "{0:.2f}".format(total_num)
|
||||
best = ranked[0] if ranked else None
|
||||
worst = ranked[-1] if ranked else None
|
||||
p["bestNick"] = (best or {}).get("nickname") or ""
|
||||
p["bestAmount"] = (best or {}).get("amountText") or ""
|
||||
p["worstNick"] = (worst or {}).get("nickname") or ""
|
||||
p["worstAmount"] = (worst or {}).get("amountText") or ""
|
||||
del p["claims"]
|
||||
result.append(p)
|
||||
# 合并手机全量同步落盘数据(电脑重启后仍可看)
|
||||
with _lock:
|
||||
synced_items = list(_mmp_synced.values())
|
||||
by_id = {p["packetId"]: p for p in result}
|
||||
for sp in synced_items:
|
||||
key = sp.get("packetId")
|
||||
if not key:
|
||||
continue
|
||||
cur = by_id.get(key)
|
||||
if cur is None or _packet_score(sp) >= _packet_score(cur):
|
||||
merged = dict(sp)
|
||||
if cur and cur.get("finished"):
|
||||
merged["finished"] = True
|
||||
if cur and not merged.get("issuedAt") and cur.get("issuedAt"):
|
||||
merged["issuedAt"] = cur["issuedAt"]
|
||||
merged["latestAt"] = merged["issuedAt"]
|
||||
by_id[key] = merged
|
||||
result = list(by_id.values())
|
||||
result.sort(key=lambda x: _mmp_time_sort_key(x.get("issuedAt") or x.get("latestAt") or ""), reverse=True)
|
||||
return result
|
||||
|
||||
@@ -345,182 +605,14 @@ def _mmp_time_sort_key(text):
|
||||
return s
|
||||
|
||||
|
||||
def _mmp_html_page():
|
||||
html = """<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>红包领取台</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: "Segoe UI", ui-sans-serif, system-ui, sans-serif; margin: 0; background: #0b1220; color: #e8eef7; height: 100vh; display: flex; flex-direction: column; }
|
||||
header { padding: 14px 20px; background: linear-gradient(90deg,#1a1030,#0f1b2d); border-bottom: 1px solid #2a3550; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; flex-shrink: 0; }
|
||||
h1 { margin: 0; font-size: 18px; letter-spacing: 0.02em; }
|
||||
.stat { color: #9db0cc; font-size: 13px; }
|
||||
a.nav { color: #8ec5ff; text-decoration: none; font-size: 13px; }
|
||||
a.nav:hover { text-decoration: underline; }
|
||||
button { background: #c45c26; color: #fff; border: 0; padding: 8px 14px; border-radius: 8px; cursor: pointer; font-size: 13px; }
|
||||
button.secondary { background: #1c2740; border: 1px solid #314062; }
|
||||
.layout { display: flex; flex: 1; min-height: 0; }
|
||||
.sidebar { width: 320px; border-right: 1px solid #2a3550; background: #111a2c; overflow-y: auto; flex-shrink: 0; }
|
||||
.sidebar h2 { margin: 0; padding: 14px 16px 8px; font-size: 12px; color: #8aa0c0; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
.pkt { padding: 14px 16px; border-bottom: 1px solid #1a2438; cursor: pointer; }
|
||||
.pkt:hover { background: #162033; }
|
||||
.pkt.active { background: #1a2744; border-left: 3px solid #ff7a45; padding-left: 13px; }
|
||||
.pkt-id { font-size: 13px; color: #ffd2a8; word-break: break-all; font-family: ui-monospace, Consolas, monospace; }
|
||||
.pkt-meta { color: #8aa0c0; font-size: 12px; margin-top: 6px; line-height: 1.45; }
|
||||
.main { flex: 1; overflow-y: auto; padding: 18px 22px; }
|
||||
.title { margin: 0 0 6px; font-size: 20px; color: #ffe0c2; }
|
||||
.sub { color: #9db0cc; font-size: 13px; margin-bottom: 16px; }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin-bottom: 18px; }
|
||||
.card { background: #141f35; border: 1px solid #273552; border-radius: 10px; padding: 12px 14px; }
|
||||
.card .k { color: #8aa0c0; font-size: 11px; }
|
||||
.card .v { margin-top: 4px; font-size: 18px; color: #fff; font-variant-numeric: tabular-nums; }
|
||||
table { width: 100%; border-collapse: collapse; background: #111a2c; border-radius: 10px; overflow: hidden; border: 1px solid #273552; }
|
||||
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #1e2a42; font-size: 13px; }
|
||||
th { background: #182338; color: #9db0cc; font-weight: 600; }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
.rank { width: 48px; color: #ff9b5a; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.amt { font-variant-numeric: tabular-nums; color: #7dffb3; font-weight: 600; }
|
||||
.empty { padding: 48px 20px; text-align: center; color: #8aa0c0; }
|
||||
.hint { margin-top: 10px; font-size: 12px; color: #6f84a6; }
|
||||
.raw { margin-top: 18px; }
|
||||
.raw summary { cursor: pointer; color: #8ec5ff; font-size: 13px; }
|
||||
pre { background: #0d1524; border: 1px solid #273552; border-radius: 8px; padding: 12px; overflow: auto; font-size: 12px; color: #c9d6ea; white-space: pre-wrap; word-break: break-word; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>红包领取台</h1>
|
||||
<span class="stat" id="pktCount">0 个红包</span>
|
||||
<span class="stat" id="updated">-</span>
|
||||
<a class="nav" href="/">← 返回消息台</a>
|
||||
<button onclick="loadData()">刷新</button>
|
||||
<button class="secondary" onclick="clearAll()">清空全部</button>
|
||||
</header>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<h2>红包列表</h2>
|
||||
<div id="list"></div>
|
||||
</aside>
|
||||
<main class="main">
|
||||
<div id="empty" class="empty">
|
||||
暂无红包数据<br/>
|
||||
<div class="hint">登录 TNG 后停留任意页面即可自动拉历史并拉详情;也可打开 Money Packet 历史页加速。无需再手动点详情。</div>
|
||||
</div>
|
||||
<div id="panel" style="display:none">
|
||||
<h2 class="title" id="title">-</h2>
|
||||
<div class="sub" id="sub">-</div>
|
||||
<div class="cards">
|
||||
<div class="card"><div class="k">领取人数</div><div class="v" id="cPeople">0</div></div>
|
||||
<div class="card"><div class="k">领取合计</div><div class="v" id="cSum">0</div></div>
|
||||
<div class="card"><div class="k">抓取次数</div><div class="v" id="cSnap">0</div></div>
|
||||
<div class="card"><div class="k">红包总额</div><div class="v" id="cTotal">-</div></div>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th class="rank">排名</th><th>昵称</th><th>领取金额</th><th>领取时间</th></tr></thead>
|
||||
<tbody id="board"></tbody>
|
||||
</table>
|
||||
<div class="raw"><details><summary>查看原始数据</summary><pre id="raw"></pre></details></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script>
|
||||
let packets = [];
|
||||
let activeId = null;
|
||||
|
||||
async function loadData() {
|
||||
const res = await fetch('/api/mmp');
|
||||
packets = await res.json();
|
||||
document.getElementById('pktCount').textContent = packets.length + ' 个红包';
|
||||
document.getElementById('updated').textContent = '更新: ' + new Date().toLocaleTimeString();
|
||||
const list = document.getElementById('list');
|
||||
list.innerHTML = '';
|
||||
const empty = document.getElementById('empty');
|
||||
const panel = document.getElementById('panel');
|
||||
if (!packets.length) {
|
||||
empty.style.display = 'block';
|
||||
panel.style.display = 'none';
|
||||
activeId = null;
|
||||
return;
|
||||
}
|
||||
empty.style.display = 'none';
|
||||
panel.style.display = 'block';
|
||||
if (!activeId || !packets.find(p => p.packetId === activeId)) {
|
||||
activeId = packets[0].packetId;
|
||||
}
|
||||
for (const p of packets) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'pkt' + (p.packetId === activeId ? ' active' : '');
|
||||
div.onclick = () => { activeId = p.packetId; loadData(); };
|
||||
div.innerHTML = `
|
||||
<div class="pkt-id">${esc(shortId(p.packetId))} · ${esc((p.sender || '红包') + ' RM' + (p.sumClaimed ?? p.total ?? 0))}</div>
|
||||
<div class="pkt-meta">${p.claimantCount || 0} 人领取 · ${esc(p.issuedAt ? ('发放 ' + p.issuedAt) : '发放时间未知')}</div>`;
|
||||
list.appendChild(div);
|
||||
}
|
||||
renderActive();
|
||||
}
|
||||
|
||||
function shortId(id) {
|
||||
const s = String(id || '');
|
||||
if (s.length >= 8 && s.indexOf('-') > 0) return s.slice(0, 8);
|
||||
return s.slice(0, 16) || '-';
|
||||
}
|
||||
|
||||
function renderActive() {
|
||||
const p = packets.find(x => x.packetId === activeId);
|
||||
if (!p) return;
|
||||
document.getElementById('title').textContent =
|
||||
(p.sender ? p.sender + ' 的红包' : (p.title || '红包详情'));
|
||||
const parts = [];
|
||||
if (p.packetId) parts.push('activityId ' + p.packetId);
|
||||
if (p.sender) parts.push('发送人 ' + p.sender);
|
||||
if (p.issuedAt) parts.push('发放 ' + p.issuedAt);
|
||||
if (p.group) parts.push('群组 ' + p.group);
|
||||
if (p.via) parts.push('来源 ' + (p.via === 'http' ? '网络' : p.via === 'gson' ? '解析' : p.via === 'rpc' ? 'RPC' : p.via));
|
||||
document.getElementById('sub').textContent = parts.join(' · ') || '单个红包领取排行';
|
||||
document.getElementById('cPeople').textContent = p.claimantCount || 0;
|
||||
document.getElementById('cSum').textContent = Number(p.sumClaimed ?? 0).toFixed(2);
|
||||
document.getElementById('cSnap').textContent = p.snapshots || 0;
|
||||
document.getElementById('cTotal').textContent = p.total || '-';
|
||||
const board = document.getElementById('board');
|
||||
board.innerHTML = '';
|
||||
const rows = p.leaderboard || [];
|
||||
if (!rows.length) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = '<td colspan="4" style="color:#8aa0c0;text-align:center">暂无领取记录</td>';
|
||||
board.appendChild(tr);
|
||||
}
|
||||
for (const row of rows) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td class="rank">${row.rank}</td>
|
||||
<td>${esc(row.nickname)}</td>
|
||||
<td class="amt">${esc(row.amountText || Number(row.amount).toFixed(2))}</td>
|
||||
<td>${esc(row.claimTime || '-')}</td>`;
|
||||
board.appendChild(tr);
|
||||
}
|
||||
const raw = (p.rawMessages || []).map(m =>
|
||||
'[' + (m.receivedAt || '') + '] ' + (m.source || '') + '\\n' + (m.content || '')
|
||||
).join('\\n---\\n');
|
||||
document.getElementById('raw').textContent = raw || '(无)';
|
||||
}
|
||||
|
||||
async function clearAll() {
|
||||
await fetch('/api/messages', { method: 'DELETE' });
|
||||
activeId = null;
|
||||
loadData();
|
||||
}
|
||||
function esc(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
loadData();
|
||||
setInterval(loadData, 2000);
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
return html.encode("utf-8")
|
||||
def _mmp_html_page(filename="mmp_page.html"):
|
||||
page = os.path.join(os.path.dirname(os.path.abspath(__file__)), filename)
|
||||
try:
|
||||
with open(page, "r", encoding="utf-8") as f:
|
||||
return f.read().encode("utf-8")
|
||||
except Exception as e:
|
||||
body = "<h1>%s missing</h1><pre>%s</pre>" % (filename, e)
|
||||
return body.encode("utf-8")
|
||||
|
||||
|
||||
def _html_page():
|
||||
@@ -564,7 +656,7 @@ def _html_page():
|
||||
<span class="stat" id="count">0 条</span>
|
||||
<span class="stat" id="groupCount">0 群</span>
|
||||
<span class="stat" id="updated">-</span>
|
||||
<a class="nav" href="/mmp">红包领取台 →</a>
|
||||
<a class="nav" href="/mmp">红包领取台(独立) →</a>
|
||||
<button onclick="loadMessages()">刷新</button>
|
||||
<button class="secondary" onclick="clearMessages()">清空</button>
|
||||
</header>
|
||||
@@ -686,7 +778,15 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.wfile.write(body)
|
||||
return
|
||||
if path == "/mmp":
|
||||
body = _mmp_html_page()
|
||||
body = _mmp_html_page("mmp_page.html")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
if path == "/mmp/help":
|
||||
body = _mmp_html_page("mmp_help.html")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
@@ -706,6 +806,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if path == "/api/mmp":
|
||||
_json_response(self, 200, _mmp_packets())
|
||||
return
|
||||
if path == "/api/mmp/settings":
|
||||
_json_response(self, 200, _get_mmp_settings())
|
||||
return
|
||||
if path == "/health":
|
||||
_json_response(self, 200, {"ok": True})
|
||||
return
|
||||
@@ -713,6 +816,28 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
def do_POST(self):
|
||||
path = urlparse(self.path).path
|
||||
if path == "/api/mmp/settings":
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
raw = self.rfile.read(length) if length else b"{}"
|
||||
try:
|
||||
payload = json.loads(raw.decode("utf-8") or "{}")
|
||||
except ValueError:
|
||||
_json_response(self, 400, {"error": "invalid json"})
|
||||
return
|
||||
_json_response(self, 200, _set_mmp_settings(payload))
|
||||
return
|
||||
if path == "/api/mmp/sync":
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
raw = self.rfile.read(length) if length else b"{}"
|
||||
try:
|
||||
payload = json.loads(raw.decode("utf-8") or "{}")
|
||||
except ValueError:
|
||||
_json_response(self, 400, {"error": "invalid json"})
|
||||
return
|
||||
result = _sync_mmp_packets(payload)
|
||||
print("[{0}] MMP sync from phone: {1}".format(_now_iso(), result))
|
||||
_json_response(self, 200 if result.get("ok") else 400, result)
|
||||
return
|
||||
if path not in ("/api/messages", "/api/debug/push", "/api/bills/app-upload"):
|
||||
_json_response(self, 404, {"error": "not found"})
|
||||
return
|
||||
@@ -746,19 +871,32 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
def do_DELETE(self):
|
||||
path = urlparse(self.path).path
|
||||
if path == "/api/mmp":
|
||||
_clear_mmp_messages()
|
||||
_json_response(self, 200, {"ok": True})
|
||||
return
|
||||
if path != "/api/messages":
|
||||
_json_response(self, 404, {"error": "not found"})
|
||||
return
|
||||
with _lock:
|
||||
# 只清通用消息,保留红包独立队列
|
||||
keep = [m for m in _messages if _is_mmp_message(m)]
|
||||
_messages.clear()
|
||||
# 旧数据里的 MMP 迁入独立队列
|
||||
for m in keep:
|
||||
_mmp_messages.appendleft(m)
|
||||
_json_response(self, 200, {"ok": True})
|
||||
|
||||
|
||||
_load_mmp_synced()
|
||||
|
||||
|
||||
def main():
|
||||
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
||||
print("notiMessage debug server: http://127.0.0.1:{0}".format(PORT))
|
||||
print("通用调试台: http://127.0.0.1:{0}/".format(PORT))
|
||||
print("红包领取台: http://127.0.0.1:{0}/mmp".format(PORT))
|
||||
print("手机同步: POST /api/mmp/sync → 落盘 mmp_packets.json,电脑打开 /mmp 即可查看")
|
||||
print("手机经 USB: adb reverse tcp:8765 tcp:8765(走 127.0.0.1)")
|
||||
print("手机经 Wi-Fi: AppConfig 已含局域网地址,与电脑同一网段即可")
|
||||
print("App 会对 DEBUG_SERVER_URLS 全部推送;不可达的会失败,可达的生效")
|
||||
|
||||
Reference in New Issue
Block a user