"""Daily site digest -> Feishu webhook. 1) Cloudflare edge analytics for dota2.refining.dev (human homepage estimate) 2) Auto-refresh status: Gitea Actions (web-daily / web-weekly / web-patch) + Cloudflare Pages production deploys that day Credentials (env, prefer keyzoo inject names): CLOUDFLARE_EMAIL / KEYZOO_ASSET_META_USERNAME CLOUDFLARE_API_KEY / KEYZOO_ASSET_SECRET_GLOBAL_API_KEY FEISHU_WEBHOOK_URL / KEYZOO_ASSET_SECRET_FEISHU_WEBHOOK_URL GITEA_TOKEN / KEYZOO_ASSET_SECRET_PERSONAL_ACCESS_TOKEN_GITEA_1 (optional; without it the refresh section shows \"未配置 token\") Usage: python notify_site_traffic.py # yesterday CST python notify_site_traffic.py --day 2026-07-27 python notify_site_traffic.py --dry-run """ from __future__ import annotations import argparse import json import os import re import sys import urllib.error import urllib.request from dataclasses import dataclass, field from datetime import date, datetime, timedelta, timezone from pathlib import Path from typing import Any HOST = "dota2.refining.dev" ZONE_NAME = "refining.dev" SITE_URL = "https://dota2.refining.dev" PAGES_PROJECT = "climperor-relations" GITEA_URL_DEFAULT = "https://gitea.refining.dev" GITEA_OWNER = "refining" GITEA_REPO = "climperor" CST = timezone(timedelta(hours=8)) # (workflow file basename, short label) REFRESH_WORKFLOWS: tuple[tuple[str, str], ...] = ( ("web-daily.yml", "每日数据"), ("web-weekly.yml", "每周 Meta"), ("web-patch.yml", "版本检测"), ) HUMAN_BROWSERS = frozenset( { "Chrome", "Firefox", "Safari", "Edge", "Opera", "MobileSafari", "ChromeMobile", "ChromeMobileWebview", "FirefoxMobile", "SamsungInternet", "EdgeMobile", } ) def _env(*names: str) -> str | None: for n in names: v = os.environ.get(n) if v: return v return None def cf_headers() -> dict[str, str]: email = _env("CLOUDFLARE_EMAIL", "KEYZOO_ASSET_META_USERNAME") key = _env("CLOUDFLARE_API_KEY", "KEYZOO_ASSET_SECRET_GLOBAL_API_KEY") if not email or not key: raise SystemExit( "missing Cloudflare credentials " "(CLOUDFLARE_EMAIL + CLOUDFLARE_API_KEY, or keyzoo inject)" ) return { "X-Auth-Email": email, "X-Auth-Key": key, "Content-Type": "application/json", } def feishu_url() -> str: url = _env("FEISHU_WEBHOOK_URL", "KEYZOO_ASSET_SECRET_FEISHU_WEBHOOK_URL") if not url: raise SystemExit( "missing Feishu webhook " "(FEISHU_WEBHOOK_URL or KEYZOO_ASSET_SECRET_FEISHU_WEBHOOK_URL)" ) return url def gitea_token() -> str | None: return _env( "GITEA_TOKEN", "KEYZOO_ASSET_SECRET_PERSONAL_ACCESS_TOKEN_GITEA_1", "KEYZOO_ASSET_SECRET_PERSONAL_ACCESS_TOKEN__GITEA_1", "KEYZOO_ASSET_TOKEN", ) def gitea_base() -> str: return ( _env("GITEA_URL", "KEYZOO_ASSET_META_URL") or GITEA_URL_DEFAULT ).rstrip("/") def http_json( url: str, *, method: str = "GET", body: dict | None = None, headers: dict | None = None, ) -> dict | list: data = None if body is None else json.dumps(body).encode() req = urllib.request.Request(url, data=data, method=method, headers=headers or {}) try: with urllib.request.urlopen(req, timeout=60) as r: raw = r.read().decode() if not raw: return {} return json.loads(raw) except urllib.error.HTTPError as e: raw = e.read().decode(errors="replace") raise SystemExit(f"HTTP {e.code} {url}: {raw[:800]}") from e def http_json_soft( url: str, *, method: str = "GET", body: dict | None = None, headers: dict | None = None, ) -> tuple[int, Any]: data = None if body is None else json.dumps(body).encode() req = urllib.request.Request(url, data=data, method=method, headers=headers or {}) try: with urllib.request.urlopen(req, timeout=60) as r: raw = r.read().decode() if not raw: return r.status, {} return r.status, json.loads(raw) except urllib.error.HTTPError as e: raw = e.read().decode(errors="replace") try: return e.code, json.loads(raw) except json.JSONDecodeError: return e.code, raw def gql(query: str, variables: dict) -> dict: data = http_json( "https://api.cloudflare.com/client/v4/graphql", method="POST", body={"query": query, "variables": variables}, headers=cf_headers(), ) if isinstance(data, dict) and data.get("errors"): raise SystemExit( "GraphQL errors: " + json.dumps(data["errors"], ensure_ascii=False)[:1500] ) assert isinstance(data, dict) return data def read_site_version() -> str: path = Path(__file__).resolve().parent / "web" / "relations" / "config.js" try: text = path.read_text(encoding="utf-8") except OSError: return "?" m = re.search(r'SITE_VERSION\s*=\s*"([^"]+)"', text) return m.group(1) if m else "?" @dataclass class DayStats: day: date all_req: int = 0 all_vis: int = 0 human_home_req: int = 0 human_home_vis: int = 0 other_home_vis: int = 0 rum_human: int = 0 rum_other: int = 0 bytes: int = 0 top_countries: list[tuple[str, int, int]] = field(default_factory=list) status: list[tuple[Any, int]] = field(default_factory=list) @dataclass class WorkflowDayStatus: file: str label: str runs: list[dict] = field(default_factory=list) # status/conclusion/started_at note: str = "" # e.g. missing token / none @property def ok_count(self) -> int: return sum(1 for r in self.runs if r.get("conclusion") == "success") @property def fail_count(self) -> int: return sum( 1 for r in self.runs if r.get("conclusion") not in (None, "success") and r.get("status") == "completed" ) @property def running_count(self) -> int: return sum(1 for r in self.runs if r.get("status") in ("queued", "in_progress", "waiting")) @dataclass class PagesDeploy: created_on: datetime status: str commit: str url: str @dataclass class DigestExtras: site_version: str workflows: list[WorkflowDayStatus] = field(default_factory=list) deploys: list[PagesDeploy] = field(default_factory=list) gitea_note: str = "" def zone_id() -> str: d = http_json( f"https://api.cloudflare.com/client/v4/zones?name={ZONE_NAME}", headers=cf_headers(), ) assert isinstance(d, dict) rows = d.get("result") or [] if not rows: raise SystemExit(f"zone not found: {ZONE_NAME}") return rows[0]["id"] def fetch_day(zid: str, day: date) -> DayStats: q = """ query($zoneTag: string!, $day: Date!, $host: string!) { viewer { zones(filter: { zoneTag: $zoneTag }) { all: httpRequestsAdaptiveGroups( limit: 1 filter: { date: $day, clientRequestHTTPHost: $host } ) { count sum { visits edgeResponseBytes } } homeByUA: httpRequestsAdaptiveGroups( limit: 40 filter: { date: $day clientRequestHTTPHost: $host clientRequestPath: "/" } orderBy: [count_DESC] ) { dimensions { userAgentBrowser } count sum { visits } } rumByUA: httpRequestsAdaptiveGroups( limit: 20 filter: { date: $day clientRequestHTTPHost: $host clientRequestPath: "/cdn-cgi/rum" } orderBy: [count_DESC] ) { dimensions { userAgentBrowser } count } homeByCountry: httpRequestsAdaptiveGroups( limit: 8 filter: { date: $day clientRequestHTTPHost: $host clientRequestPath: "/" } orderBy: [count_DESC] ) { dimensions { clientCountryName } count sum { visits } } byStatus: httpRequestsAdaptiveGroups( limit: 8 filter: { date: $day, clientRequestHTTPHost: $host } orderBy: [count_DESC] ) { dimensions { edgeResponseStatus } count } } } } """ data = gql(q, {"zoneTag": zid, "day": day.isoformat(), "host": HOST}) z = data["data"]["viewer"]["zones"][0] st = DayStats(day=day) all_rows = z.get("all") or [] if all_rows: r = all_rows[0] st.all_req = r.get("count") or 0 st.all_vis = (r.get("sum") or {}).get("visits") or 0 st.bytes = (r.get("sum") or {}).get("edgeResponseBytes") or 0 for r in z.get("homeByUA") or []: ua = (r.get("dimensions") or {}).get("userAgentBrowser") c = r.get("count") or 0 v = (r.get("sum") or {}).get("visits") or 0 if ua in HUMAN_BROWSERS: st.human_home_req += c st.human_home_vis += v else: st.other_home_vis += v for r in z.get("rumByUA") or []: ua = (r.get("dimensions") or {}).get("userAgentBrowser") c = r.get("count") or 0 if ua in HUMAN_BROWSERS: st.rum_human += c else: st.rum_other += c for r in z.get("homeByCountry") or []: name = (r.get("dimensions") or {}).get("clientCountryName") or "?" v = (r.get("sum") or {}).get("visits") or 0 st.top_countries.append((name, r.get("count") or 0, v)) for r in z.get("byStatus") or []: code = (r.get("dimensions") or {}).get("edgeResponseStatus") st.status.append((code, r.get("count") or 0)) return st def day_window_utc(day: date) -> tuple[datetime, datetime]: """Inclusive start / exclusive end of CST calendar day, as aware UTC datetimes.""" start_cst = datetime(day.year, day.month, day.day, tzinfo=CST) end_cst = start_cst + timedelta(days=1) return start_cst.astimezone(timezone.utc), end_cst.astimezone(timezone.utc) def parse_gitea_time(s: str | None) -> datetime | None: if not s: return None try: # Gitea may return +08:00 or Z if s.endswith("Z"): return datetime.fromisoformat(s.replace("Z", "+00:00")) return datetime.fromisoformat(s) except ValueError: return None def fetch_workflow_statuses(day: date) -> tuple[list[WorkflowDayStatus], str]: tok = gitea_token() if not tok: return ( [ WorkflowDayStatus(file=f, label=lab, note="未配置 GITEA_TOKEN") for f, lab in REFRESH_WORKFLOWS ], "未配置 GITEA_TOKEN,跳过 Actions 查询", ) headers = { "Authorization": f"token {tok}", "Accept": "application/json", "Content-Type": "application/json", } start_utc, end_utc = day_window_utc(day) code, payload = http_json_soft( f"{gitea_base()}/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}/actions/runs?limit=50", headers=headers, ) if code != 200: return ( [ WorkflowDayStatus(file=f, label=lab, note=f"API {code}") for f, lab in REFRESH_WORKFLOWS ], f"Gitea runs API HTTP {code}", ) if isinstance(payload, dict): items = payload.get("workflow_runs") or payload.get("runs") or [] elif isinstance(payload, list): items = payload else: items = [] by_file: dict[str, list[dict]] = {f: [] for f, _ in REFRESH_WORKFLOWS} for x in items: path = str(x.get("path") or "") # path like "web-daily.yml@refs/heads/main" base = path.split("@", 1)[0] if base not in by_file: continue ts = parse_gitea_time(x.get("started_at") or x.get("completed_at")) if ts is None: continue ts_utc = ts.astimezone(timezone.utc) if not (start_utc <= ts_utc < end_utc): continue by_file[base].append( { "id": x.get("id"), "status": x.get("status"), "conclusion": x.get("conclusion"), "event": x.get("event"), "started_at": x.get("started_at"), "completed_at": x.get("completed_at"), "html_url": x.get("html_url"), } ) out: list[WorkflowDayStatus] = [] for f, lab in REFRESH_WORKFLOWS: runs = by_file.get(f) or [] note = "" if runs else "当日无运行" out.append(WorkflowDayStatus(file=f, label=lab, runs=runs, note=note)) return out, "" def cf_account_id() -> str: d = http_json( "https://api.cloudflare.com/client/v4/accounts", headers=cf_headers(), ) assert isinstance(d, dict) rows = d.get("result") or [] if not rows: raise SystemExit("no Cloudflare accounts") return rows[0]["id"] def fetch_pages_deploys(day: date) -> list[PagesDeploy]: aid = cf_account_id() d = http_json( f"https://api.cloudflare.com/client/v4/accounts/{aid}/pages/projects/" f"{PAGES_PROJECT}/deployments", headers=cf_headers(), ) assert isinstance(d, dict) start_utc, end_utc = day_window_utc(day) out: list[PagesDeploy] = [] for x in d.get("result") or []: created = parse_gitea_time(x.get("created_on")) if created is None: continue created_utc = created.astimezone(timezone.utc) if not (start_utc <= created_utc < end_utc): continue if (x.get("environment") or "") != "production": continue stage = x.get("latest_stage") or {} trig = x.get("deployment_trigger") or {} meta = trig.get("metadata") or {} commit = str(meta.get("commit_hash") or "")[:10] out.append( PagesDeploy( created_on=created.astimezone(CST), status=str(stage.get("status") or "?"), commit=commit, url=str(x.get("url") or ""), ) ) out.sort(key=lambda p: p.created_on, reverse=True) return out def fmt_bytes(n: int) -> str: x = float(n) for unit in ("B", "KB", "MB", "GB"): if abs(x) < 1024: return f"{x:.1f} {unit}" x /= 1024 return f"{x:.1f} TB" def _fmt_workflow_line(w: WorkflowDayStatus) -> str: if w.note and not w.runs: return f"- **{w.label}**(`{w.file}`):{w.note}" parts: list[str] = [] if w.ok_count: parts.append(f"成功 {w.ok_count}") if w.fail_count: parts.append(f"失败 {w.fail_count}") if w.running_count: parts.append(f"进行中 {w.running_count}") if not parts: parts.append(w.note or "无结论") # Show latest conclusion time if any latest = "" if w.runs: r0 = w.runs[0] t = r0.get("completed_at") or r0.get("started_at") or "" if t: dt = parse_gitea_time(t) if dt: latest = " · 最近 " + dt.astimezone(CST).strftime("%H:%M") return f"- **{w.label}**:{' / '.join(parts)}{latest}" def build_card(st: DayStats, extras: DigestExtras) -> dict: """Feishu interactive card (msg_type=interactive).""" countries = "、".join( f"{name} {vis}访/{req}次" for name, req, vis in st.top_countries[:5] ) or "—" status_line = "、".join(f"{code}×{n}" for code, n in st.status[:6]) or "—" now = datetime.now(CST).strftime("%Y-%m-%d %H:%M CST") estimate = st.human_home_vis rum = st.rum_human 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" ) wf_lines = [_fmt_workflow_line(w) for w in extras.workflows] if extras.gitea_note: wf_lines.append(f"- _{extras.gitea_note}_") 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)}**" + (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" ) else: deploy_md = "**Pages 生产部署**:当日无新部署(数据未变则 refresh 会跳过 deploy)\n" refresh_md = ( f"**站点版本**:`v{extras.site_version}`\n\n" f"**自动刷新(Gitea Actions)**\n" + "\n".join(wf_lines) + "\n\n" + deploy_md.strip() ) 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 ) if any_refresh_fail: header_color = "red" elif estimate > 0 or any_refresh_ok: header_color = "blue" else: header_color = "grey" elements: list[dict] = [ { "tag": "div", "text": {"tag": "lark_md", "content": "**访问**\n" + traffic_md}, }, { "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"_模块:英雄 / 排行 / 主播 / 走势 / 物品 / 版本 / 机制_" ), }, }, { "tag": "note", "elements": [ { "tag": "plain_text", "content": ( f"{HOST} · Free 档无 BotScore · " f"refresh=web-daily/weekly/patch · {now}" ), } ], }, { "tag": "action", "actions": [ { "tag": "button", "text": {"tag": "plain_text", "content": "打开站点"}, "type": "primary", "url": SITE_URL, }, { "tag": "button", "text": {"tag": "plain_text", "content": "Gitea Actions"}, "type": "default", "url": f"{gitea_base()}/{GITEA_OWNER}/{GITEA_REPO}/actions", }, ], }, ] return { "msg_type": "interactive", "card": { "header": { "title": { "tag": "plain_text", "content": f"上分帝 Web 日报 · {st.day.isoformat()}", }, "template": header_color, }, "elements": elements, }, } def post_feishu(payload: dict) -> dict: url = feishu_url() resp = http_json( url, method="POST", body=payload, headers={"Content-Type": "application/json"}, ) assert isinstance(resp, dict) return resp def parse_day(s: str | None) -> date: if not s: return (datetime.now(CST) - timedelta(days=1)).date() return date.fromisoformat(s) def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--day", help="YYYY-MM-DD (default: yesterday CST)") ap.add_argument( "--dry-run", action="store_true", help="print JSON card only, do not call Feishu", ) args = ap.parse_args(argv) day = parse_day(args.day) zid = zone_id() st = fetch_day(zid, day) workflows, gitea_note = fetch_workflow_statuses(day) deploys = fetch_pages_deploys(day) extras = DigestExtras( site_version=read_site_version(), workflows=workflows, deploys=deploys, gitea_note=gitea_note, ) payload = build_card(st, extras) print( f"{HOST} {day}: human_home_vis={st.human_home_vis} " f"rum_human={st.rum_human} all_req={st.all_req} " f"deploys={len(deploys)} " f"wf_ok={sum(w.ok_count for w in workflows)} " f"wf_fail={sum(w.fail_count for w in workflows)} " f"v={extras.site_version}", flush=True, ) if args.dry_run: print(json.dumps(payload, ensure_ascii=False, indent=2)) return 0 resp = post_feishu(payload) code = resp.get("code", resp.get("StatusCode")) if code not in (0, None) and code != 200: print( "Feishu error:", json.dumps(resp, ensure_ascii=False)[:800], file=sys.stderr, ) return 1 print("feishu ok:", json.dumps(resp, ensure_ascii=False)[:200]) return 0 if __name__ == "__main__": raise SystemExit(main())