Enrich Feishu daily digest with auto-refresh status.

Include Gitea web-daily/weekly/patch conclusions and Cloudflare Pages deploys; ship the refresh workflows so those signals exist.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-07-28 21:17:23 +08:00
co-authored by Cursor
parent 9f484166d4
commit 37769580f5
10 changed files with 889 additions and 68 deletions
+386 -39
View File
@@ -1,18 +1,20 @@
"""Daily site traffic digest -> Feishu webhook.
"""Daily site digest -> Feishu webhook.
Pulls Cloudflare edge analytics for dota2.refining.dev, estimates human
homepage visits (known browser UA on `/`, plus RUM beacons), and posts a
Feishu custom-bot card.
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 (UTC date)
python notify_site_traffic.py # yesterday CST
python notify_site_traffic.py --day 2026-07-27
python notify_site_traffic.py --dry-run # print card, do not POST
python notify_site_traffic.py --dry-run
"""
from __future__ import annotations
@@ -20,16 +22,30 @@ 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(
{
@@ -81,17 +97,64 @@ def feishu_url() -> str:
return url
def http_json(url: str, *, method: str = "GET", body: dict | None = None, headers: dict | None = None) -> dict:
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:
return json.loads(r.read().decode())
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",
@@ -99,11 +162,24 @@ def gql(query: str, variables: dict) -> dict:
body={"query": query, "variables": variables},
headers=cf_headers(),
)
if data.get("errors"):
raise SystemExit("GraphQL errors: " + json.dumps(data["errors"], ensure_ascii=False)[:1500])
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
@@ -115,15 +191,57 @@ class DayStats:
rum_human: int = 0
rum_other: int = 0
bytes: int = 0
top_countries: list[tuple[str, int, int]] = field(default_factory=list) # name, req, vis
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}")
@@ -230,6 +348,142 @@ def fetch_day(zid: str, day: date) -> DayStats:
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"):
@@ -239,39 +493,110 @@ def fmt_bytes(n: int) -> str:
return f"{x:.1f} TB"
def build_card(st: DayStats) -> dict:
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(timezone(timedelta(hours=8))).strftime("%Y-%m-%d %H:%M CST")
now = datetime.now(CST).strftime("%Y-%m-%d %H:%M CST")
# Free plan: no BotScore. Human ≈ known-browser homepage visits.
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": (
f"**真实访问(估)**:约 **{estimate}** 次首页打开\n"
f"(已知浏览器 UA 打开 `/`;不含 Unknown/Headless/爬虫)\n\n"
f"**RUM 页面浏览**{rum}(人类浏览器执行 JS"
+ (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"
),
},
"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**状态码**{status_line}",
"content": (
f"**首页地区 Top**{countries}\n"
f"**状态码**{status_line}\n"
f"_模块:英雄 / 排行 / 主播 / 走势 / 物品 / 版本 / 机制_"
),
},
},
{
@@ -280,7 +605,8 @@ def build_card(st: DayStats) -> dict:
{
"tag": "plain_text",
"content": (
f"{HOST} · Free 档无 BotScore,按 UA+路径估算 · {now}"
f"{HOST} · Free 档无 BotScore · "
f"refresh=web-daily/weekly/patch · {now}"
),
}
],
@@ -293,7 +619,13 @@ def build_card(st: DayStats) -> dict:
"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",
},
],
},
]
@@ -304,9 +636,9 @@ def build_card(st: DayStats) -> dict:
"header": {
"title": {
"tag": "plain_text",
"content": f"上分帝 Web 日 · {st.day.isoformat()}",
"content": f"上分帝 Web 日 · {st.day.isoformat()}",
},
"template": "blue" if estimate > 0 else "grey",
"template": header_color,
},
"elements": elements,
},
@@ -315,19 +647,19 @@ def build_card(st: DayStats) -> dict:
def post_feishu(payload: dict) -> dict:
url = feishu_url()
return http_json(
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:
# Yesterday in China time (site audience).
cst = timezone(timedelta(hours=8))
return (datetime.now(cst) - timedelta(days=1)).date()
return (datetime.now(CST) - timedelta(days=1)).date()
return date.fromisoformat(s)
@@ -344,11 +676,23 @@ def main(argv: list[str] | None = None) -> int:
day = parse_day(args.day)
zid = zone_id()
st = fetch_day(zid, day)
payload = build_card(st)
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"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,
)
@@ -357,10 +701,13 @@ def main(argv: list[str] | None = None) -> int:
return 0
resp = post_feishu(payload)
# Feishu returns {code:0, msg:success} on OK
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)
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