v0.5.84: matches origin filter, mobile gate, refresh reliability.
Ship Web refresh cache/lock, mobile demand gate, matches 职业/国服 filter, and related site updates through 0.5.84. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+317
-32
@@ -20,12 +20,14 @@ Usage:
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
@@ -155,6 +157,33 @@ def http_json_soft(
|
||||
return e.code, raw
|
||||
|
||||
|
||||
def http_response_soft(
|
||||
url: str,
|
||||
*,
|
||||
headers: dict | None = None,
|
||||
) -> tuple[int, bytes, str, dict[str, str]]:
|
||||
request_headers = {"User-Agent": "climperor-monitor", "Accept": "*/*"}
|
||||
request_headers.update(headers or {})
|
||||
req = urllib.request.Request(url, headers=request_headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as response:
|
||||
return (
|
||||
response.status,
|
||||
response.read(),
|
||||
response.headers.get_content_type(),
|
||||
{k.lower(): v for k, v in response.headers.items()},
|
||||
)
|
||||
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as exc:
|
||||
if isinstance(exc, urllib.error.HTTPError):
|
||||
return (
|
||||
exc.code,
|
||||
exc.read(),
|
||||
exc.headers.get_content_type(),
|
||||
{k.lower(): v for k, v in exc.headers.items()},
|
||||
)
|
||||
return 0, str(exc).encode("utf-8", errors="replace"), "", {}
|
||||
|
||||
|
||||
def gql(query: str, variables: dict) -> dict:
|
||||
data = http_json(
|
||||
"https://api.cloudflare.com/client/v4/graphql",
|
||||
@@ -234,6 +263,18 @@ class DigestExtras:
|
||||
workflows: list[WorkflowDayStatus] = field(default_factory=list)
|
||||
deploys: list[PagesDeploy] = field(default_factory=list)
|
||||
gitea_note: str = ""
|
||||
refresh_summaries: dict[str, dict] = field(default_factory=dict)
|
||||
production_health: "ProductionHealth | None" = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProductionHealth:
|
||||
site_version: str = "?"
|
||||
ages_hours: dict[str, float | None] = field(default_factory=dict)
|
||||
stale: list[str] = field(default_factory=list)
|
||||
live_api_ok: bool = False
|
||||
refresh_run_id: str = ""
|
||||
note: str = ""
|
||||
|
||||
|
||||
def zone_id() -> str:
|
||||
@@ -437,6 +478,213 @@ def fetch_workflow_statuses(day: date) -> tuple[list[WorkflowDayStatus], str]:
|
||||
return out, ""
|
||||
|
||||
|
||||
def fetch_refresh_summaries(workflows: list[WorkflowDayStatus]) -> tuple[dict[str, dict], str]:
|
||||
"""Download summary.json artifacts for the latest visible refresh runs."""
|
||||
token = gitea_token()
|
||||
if not token:
|
||||
return {}, "无 Gitea token,未读取刷新摘要"
|
||||
headers = {"Authorization": f"token {token}", "Accept": "application/json"}
|
||||
code, payload = http_json_soft(
|
||||
f"{gitea_base()}/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}/actions/artifacts?limit=100",
|
||||
headers=headers,
|
||||
)
|
||||
if code != 200 or not isinstance(payload, dict):
|
||||
return {}, f"刷新摘要 API HTTP {code}"
|
||||
artifacts = payload.get("artifacts") or []
|
||||
by_run: dict[int, dict] = {}
|
||||
for artifact in artifacts:
|
||||
if not isinstance(artifact, dict) or artifact.get("expired"):
|
||||
continue
|
||||
run = artifact.get("workflow_run") or {}
|
||||
try:
|
||||
run_id = int(run.get("id") or artifact.get("workflow_run_id") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if run_id:
|
||||
by_run[run_id] = artifact
|
||||
|
||||
summaries: dict[str, dict] = {}
|
||||
for workflow in workflows:
|
||||
latest = None
|
||||
for run in workflow.runs:
|
||||
try:
|
||||
run_id = int(run.get("id") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if run.get("status") == "completed" and run_id in by_run:
|
||||
latest = run
|
||||
break
|
||||
if not latest:
|
||||
continue
|
||||
artifact = by_run[int(latest["id"])]
|
||||
artifact_id = artifact.get("id")
|
||||
if not artifact_id:
|
||||
continue
|
||||
status, raw, _, _ = http_response_soft(
|
||||
f"{gitea_base()}/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}/actions/artifacts/"
|
||||
f"{artifact_id}/zip",
|
||||
headers=headers,
|
||||
)
|
||||
if status != 200:
|
||||
continue
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(raw)) as archive:
|
||||
member = next(
|
||||
name for name in archive.namelist() if name.endswith("summary.json")
|
||||
)
|
||||
summary = json.loads(archive.read(member).decode("utf-8"))
|
||||
if isinstance(summary, dict):
|
||||
summaries[workflow.file] = summary
|
||||
except (ValueError, KeyError, StopIteration, zipfile.BadZipFile, json.JSONDecodeError):
|
||||
continue
|
||||
return summaries, ""
|
||||
|
||||
|
||||
def _nested_timestamp(payload: dict, *path: str) -> datetime | None:
|
||||
value: Any = payload
|
||||
for key in path:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
value = value.get(key)
|
||||
return parse_gitea_time(value)
|
||||
|
||||
|
||||
def _summary_proves_refresh(summary: dict, label: str, step: str) -> bool:
|
||||
if not summary.get("ok"):
|
||||
return False
|
||||
step_ok = any(
|
||||
isinstance(row, dict)
|
||||
and row.get("step") == step
|
||||
and row.get("ok")
|
||||
for row in (summary.get("steps") or [])
|
||||
)
|
||||
if not step_ok:
|
||||
return False
|
||||
health = summary.get("health") or {}
|
||||
if label == "主播资料":
|
||||
return not (
|
||||
int(health.get("streamer_profile_missing") or 0)
|
||||
or int(health.get("streamer_live_probe_missing") or 0)
|
||||
)
|
||||
if label == "STRATZ Meta":
|
||||
return not health.get("stratz_meta_stale")
|
||||
if label == "STRATZ 对位":
|
||||
return int(health.get("stratz_matchup_stale") or 0) == 0
|
||||
return True
|
||||
|
||||
|
||||
def fetch_production_health(
|
||||
now: datetime | None = None,
|
||||
refresh_summaries: dict[str, dict] | None = None,
|
||||
) -> ProductionHealth:
|
||||
"""Probe production payload freshness and the live-status Function."""
|
||||
current = now or datetime.now(timezone.utc)
|
||||
health = ProductionHealth()
|
||||
status, config_raw, _, _ = http_response_soft(f"{SITE_URL}/config.js")
|
||||
if status == 200:
|
||||
match = re.search(
|
||||
rb'SITE_VERSION\s*=\s*"([^"]+)"',
|
||||
config_raw,
|
||||
)
|
||||
if match:
|
||||
health.site_version = match.group(1).decode("utf-8", errors="replace")
|
||||
|
||||
status, data_raw, _, _ = http_response_soft(f"{SITE_URL}/data.json")
|
||||
if status != 200:
|
||||
health.note = f"生产 data.json HTTP {status}"
|
||||
health.stale.append("data.json")
|
||||
return health
|
||||
try:
|
||||
payload = json.loads(data_raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
health.note = "生产 data.json 无法解析"
|
||||
health.stale.append("data.json")
|
||||
return health
|
||||
|
||||
health.refresh_run_id = str((payload.get("meta") or {}).get("refresh_run_id") or "")
|
||||
summaries = refresh_summaries or {}
|
||||
checks = {
|
||||
"英雄统计": (
|
||||
("hero_stats", "fetched_at"),
|
||||
36.0,
|
||||
"web-daily.yml",
|
||||
"fetch_hero_stats.py",
|
||||
),
|
||||
"排行榜": (
|
||||
("leaderboards", "fetched_at"),
|
||||
36.0,
|
||||
"web-daily.yml",
|
||||
"fetch_leaderboards.py",
|
||||
),
|
||||
"近期比赛": (
|
||||
("hero_matches", "meta", "fetched_at"),
|
||||
36.0,
|
||||
"web-daily.yml",
|
||||
"fetch_hero_matches.py",
|
||||
),
|
||||
"明星比赛": (
|
||||
("pro_matches", "meta", "fetched_at"),
|
||||
36.0,
|
||||
"web-daily.yml",
|
||||
"fetch_pro_matches.py",
|
||||
),
|
||||
"主播资料": (
|
||||
("streamers", "fetched_at"),
|
||||
36.0,
|
||||
"web-daily.yml",
|
||||
"fetch_streamers.py",
|
||||
),
|
||||
"STRATZ Meta": (
|
||||
("stratz_hero_meta", "fetched_at"),
|
||||
216.0,
|
||||
"web-weekly.yml",
|
||||
"fetch_stratz_meta.py",
|
||||
),
|
||||
"STRATZ 对位": (
|
||||
("stratz_matchup_tops", "fetched_at"),
|
||||
216.0,
|
||||
"web-weekly.yml",
|
||||
"fetch_stratz_meta.py",
|
||||
),
|
||||
"英雄装备": (
|
||||
("hero_items", "meta", "fetched_at"),
|
||||
216.0,
|
||||
"web-weekly.yml",
|
||||
"fetch_hero_items.py",
|
||||
),
|
||||
}
|
||||
for label, (path, max_age, workflow_file, step) in checks.items():
|
||||
timestamp = _nested_timestamp(payload, *path)
|
||||
age = None
|
||||
if timestamp is not None:
|
||||
age = max(0.0, (current - timestamp.astimezone(timezone.utc)).total_seconds() / 3600)
|
||||
health.ages_hours[label] = age
|
||||
latest_refresh_ok = _summary_proves_refresh(
|
||||
summaries.get(workflow_file) or {},
|
||||
label,
|
||||
step,
|
||||
)
|
||||
if (age is None or age > max_age) and not latest_refresh_ok:
|
||||
health.stale.append(label)
|
||||
|
||||
live_status, live_raw, live_type, live_headers = http_response_soft(
|
||||
f"{SITE_URL}/api/live-status"
|
||||
)
|
||||
try:
|
||||
live_payload = json.loads(live_raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
live_payload = None
|
||||
health.live_api_ok = (
|
||||
live_status == 200
|
||||
and live_type == "application/json"
|
||||
and isinstance(live_payload, dict)
|
||||
and live_headers.get("x-live-cache") not in ("error", "stale-override")
|
||||
)
|
||||
if not health.live_api_ok:
|
||||
health.stale.append("直播状态 API")
|
||||
return health
|
||||
|
||||
|
||||
def cf_account_id() -> str:
|
||||
d = http_json(
|
||||
"https://api.cloudflare.com/client/v4/accounts",
|
||||
@@ -495,7 +743,7 @@ def fmt_bytes(n: int) -> str:
|
||||
|
||||
def _fmt_workflow_line(w: WorkflowDayStatus) -> str:
|
||||
if w.note and not w.runs:
|
||||
return f"- **{w.label}**(`{w.file}`):{w.note}"
|
||||
return f"- **{w.label}**:{w.note}"
|
||||
parts: list[str] = []
|
||||
if w.ok_count:
|
||||
parts.append(f"成功 {w.ok_count}")
|
||||
@@ -528,49 +776,91 @@ def build_card(st: DayStats, extras: DigestExtras) -> dict:
|
||||
estimate = st.human_home_vis
|
||||
rum = st.rum_human
|
||||
|
||||
# Human estimate = known-browser UA hits on `/` (SPA hash routes are not paths).
|
||||
traffic_md = (
|
||||
f"**真实访问(估)**:约 **{estimate}** 次首页打开\n"
|
||||
f"(已知浏览器 UA 打开 `/`;SPA Hash 路由不计路径;不含爬虫)\n\n"
|
||||
f"**RUM 页面浏览**:{rum}"
|
||||
+ (f" + {st.rum_other} 其他" if st.rum_other else "")
|
||||
+ "\n"
|
||||
f"**边缘总量**:{st.all_req} 请求 / {st.all_vis} visits / {fmt_bytes(st.bytes)}\n"
|
||||
f"**非人类首页命中**:{st.other_home_vis} visits"
|
||||
f"**非人类首页**:{st.other_home_vis} visits\n"
|
||||
f"**地区 Top**:{countries}\n"
|
||||
f"**状态码**:{status_line}"
|
||||
)
|
||||
|
||||
wf_lines = [_fmt_workflow_line(w) for w in extras.workflows]
|
||||
if extras.gitea_note:
|
||||
wf_lines.append(f"- _{extras.gitea_note}_")
|
||||
for workflow_file, summary in extras.refresh_summaries.items():
|
||||
label = next(
|
||||
(label for file, label in REFRESH_WORKFLOWS if file == workflow_file),
|
||||
workflow_file,
|
||||
)
|
||||
result = "已部署" if summary.get("deployed") else "无需部署"
|
||||
if not summary.get("ok"):
|
||||
result = "刷新失败"
|
||||
stale_count = int(
|
||||
(summary.get("health") or {}).get("stratz_matchup_stale") or 0
|
||||
)
|
||||
live_missing = int(
|
||||
(summary.get("health") or {}).get("streamer_live_probe_missing") or 0
|
||||
)
|
||||
wf_lines.append(
|
||||
f"- **{label}摘要**:{result}"
|
||||
+ (f" · 对位陈旧 {stale_count}" if stale_count else "")
|
||||
+ (f" · 直播探测缺失 {live_missing}" if live_missing else "")
|
||||
)
|
||||
|
||||
ok_deploys = [p for p in extras.deploys if p.status == "success"]
|
||||
fail_deploys = [p for p in extras.deploys if p.status != "success"]
|
||||
if extras.deploys:
|
||||
deploy_md = (
|
||||
f"**Pages 生产部署**:成功 **{len(ok_deploys)}**"
|
||||
deploy_lines = [
|
||||
f"**Pages**:成功 **{len(ok_deploys)}**"
|
||||
+ (f" / 异常 {len(fail_deploys)}" if fail_deploys else "")
|
||||
+ "\n"
|
||||
)
|
||||
]
|
||||
for p in extras.deploys[:5]:
|
||||
mark = "[ok]" if p.status == "success" else "[!]"
|
||||
deploy_md += (
|
||||
f"- {mark} {p.created_on.strftime('%H:%M')} "
|
||||
f"`{p.commit or '?'}` {p.status}\n"
|
||||
mark = "ok" if p.status == "success" else "!"
|
||||
deploy_lines.append(
|
||||
f"- [{mark}] {p.created_on.strftime('%H:%M')} "
|
||||
f"`{p.commit or '?'}` {p.status}"
|
||||
)
|
||||
deploy_md = "\n".join(deploy_lines)
|
||||
else:
|
||||
deploy_md = "**Pages 生产部署**:当日无新部署(数据未变则 refresh 会跳过 deploy)\n"
|
||||
deploy_md = "**Pages**:当日无新部署"
|
||||
|
||||
health = extras.production_health
|
||||
production_lines: list[str] = []
|
||||
if health:
|
||||
newest = [
|
||||
f"{label} {age:.1f}h"
|
||||
for label, age in health.ages_hours.items()
|
||||
if age is not None
|
||||
]
|
||||
production_lines.append(
|
||||
f"**生产探针**:{'正常' if not health.stale else '异常'}"
|
||||
f" · live API {'正常' if health.live_api_ok else '失败'}"
|
||||
)
|
||||
if newest:
|
||||
production_lines.append("- " + " / ".join(newest))
|
||||
if health.stale:
|
||||
production_lines.append("- **超时/异常**:" + "、".join(health.stale))
|
||||
if health.refresh_run_id:
|
||||
production_lines.append(f"- run `{health.refresh_run_id}`")
|
||||
|
||||
refresh_md = (
|
||||
f"**站点版本**:`v{extras.site_version}`\n\n"
|
||||
f"**自动刷新(Gitea Actions)**\n"
|
||||
f"**版本**:`v{extras.site_version}`\n"
|
||||
f"**Actions**\n"
|
||||
+ "\n".join(wf_lines)
|
||||
+ "\n\n"
|
||||
+ deploy_md.strip()
|
||||
+ "\n"
|
||||
+ deploy_md
|
||||
+ ("\n" + "\n".join(production_lines) if production_lines else "")
|
||||
)
|
||||
|
||||
any_refresh_ok = any(w.ok_count > 0 for w in extras.workflows) or bool(ok_deploys)
|
||||
any_refresh_fail = any(w.fail_count > 0 for w in extras.workflows) or bool(
|
||||
fail_deploys
|
||||
)
|
||||
any_refresh_fail = any_refresh_fail or bool(health and health.stale)
|
||||
|
||||
if any_refresh_fail:
|
||||
header_color = "red"
|
||||
@@ -586,18 +876,7 @@ def build_card(st: DayStats, extras: DigestExtras) -> dict:
|
||||
},
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {"tag": "lark_md", "content": "**自动更新**\n" + refresh_md},
|
||||
},
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"tag": "lark_md",
|
||||
"content": (
|
||||
f"**首页地区 Top**:{countries}\n"
|
||||
f"**状态码**:{status_line}\n"
|
||||
f"_模块:英雄 / 排行 / 主播 / 走势 / 物品 / 版本 / 机制_"
|
||||
),
|
||||
},
|
||||
"text": {"tag": "lark_md", "content": "**刷新与部署**\n" + refresh_md},
|
||||
},
|
||||
{
|
||||
"tag": "note",
|
||||
@@ -605,8 +884,7 @@ def build_card(st: DayStats, extras: DigestExtras) -> dict:
|
||||
{
|
||||
"tag": "plain_text",
|
||||
"content": (
|
||||
f"{HOST} · Free 档无 BotScore · "
|
||||
f"refresh=web-daily/weekly/patch · {now}"
|
||||
f"{HOST} · 估数=已知浏览器 UA 打开 / · {now}"
|
||||
),
|
||||
}
|
||||
],
|
||||
@@ -674,15 +952,22 @@ def main(argv: list[str] | None = None) -> int:
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
day = parse_day(args.day)
|
||||
refresh_day = datetime.now(CST).date()
|
||||
zid = zone_id()
|
||||
st = fetch_day(zid, day)
|
||||
workflows, gitea_note = fetch_workflow_statuses(day)
|
||||
workflows, gitea_note = fetch_workflow_statuses(refresh_day)
|
||||
refresh_summaries, summary_note = fetch_refresh_summaries(workflows)
|
||||
if summary_note:
|
||||
gitea_note = ";".join(x for x in (gitea_note, summary_note) if x)
|
||||
deploys = fetch_pages_deploys(day)
|
||||
production_health = fetch_production_health(refresh_summaries=refresh_summaries)
|
||||
extras = DigestExtras(
|
||||
site_version=read_site_version(),
|
||||
site_version=production_health.site_version,
|
||||
workflows=workflows,
|
||||
deploys=deploys,
|
||||
gitea_note=gitea_note,
|
||||
refresh_summaries=refresh_summaries,
|
||||
production_health=production_health,
|
||||
)
|
||||
payload = build_card(st, extras)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user