From 96a9312194b579ab18ac29db7f8ebae3b4cb823f Mon Sep 17 00:00:00 2001 From: voson Date: Wed, 29 Jul 2026 12:44:31 +0800 Subject: [PATCH] Add Cloudflare traffic helper scripts and commercialization notes. Track reusable CF analytics utilities and the commercial prospects document while keeping local probe/sample artifacts untracked. Co-authored-by: Cursor --- _cf_human_home.py | 154 ++++++++++++++ _cf_human_traffic.py | 386 +++++++++++++++++++++++++++++++++++ _cf_traffic.py | 217 ++++++++++++++++++++ docs/commercial-prospects.md | 165 +++++++++++++++ 4 files changed, 922 insertions(+) create mode 100644 _cf_human_home.py create mode 100644 _cf_human_traffic.py create mode 100644 _cf_traffic.py create mode 100644 docs/commercial-prospects.md diff --git a/_cf_human_home.py b/_cf_human_home.py new file mode 100644 index 0000000..13dfa28 --- /dev/null +++ b/_cf_human_home.py @@ -0,0 +1,154 @@ +"""Human homepage opens: path=/ AND known browser UA (exclude bots/unknown).""" + +from __future__ import annotations + +import json +import os +import urllib.request +from datetime import date, timedelta + +HOST = "dota2.refining.dev" +ZONE = "refining.dev" + +HUMAN_BROWSERS = [ + "Chrome", + "Firefox", + "Safari", + "Edge", + "Opera", + "MobileSafari", + "ChromeMobile", + "ChromeMobileWebview", + "FirefoxMobile", + "SamsungInternet", + "EdgeMobile", +] + + +def headers() -> dict[str, str]: + email = os.environ["KEYZOO_ASSET_META_USERNAME"] + key = os.environ["KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"] + return { + "X-Auth-Email": email, + "X-Auth-Key": key, + "Content-Type": "application/json", + } + + +def gql(query: str, variables: dict) -> dict: + req = urllib.request.Request( + "https://api.cloudflare.com/client/v4/graphql", + data=json.dumps({"query": query, "variables": variables}).encode(), + method="POST", + headers=headers(), + ) + with urllib.request.urlopen(req) as r: + return json.loads(r.read().decode()) + + +def main() -> None: + zid = json.loads( + urllib.request.urlopen( + urllib.request.Request( + f"https://api.cloudflare.com/client/v4/zones?name={ZONE}", + headers=headers(), + ) + ).read() + )["result"][0]["id"] + + q = """ + query($zoneTag:string!,$day:Date!,$host:string!){ + viewer{zones(filter:{zoneTag:$zoneTag}){ + homeByUA:httpRequestsAdaptiveGroups( + limit:30 + filter:{date:$day,clientRequestHTTPHost:$host,clientRequestPath:"/"} + orderBy:[count_DESC] + ){dimensions{userAgentBrowser clientCountryName} count sum{visits}} + rumByUA:httpRequestsAdaptiveGroups( + limit:20 + filter:{ + date:$day + clientRequestHTTPHost:$host + clientRequestPath:"/cdn-cgi/rum" + } + orderBy:[count_DESC] + ){dimensions{userAgentBrowser} count sum{visits}} + homeByCountry:httpRequestsAdaptiveGroups( + limit:15 + filter:{date:$day,clientRequestHTTPHost:$host,clientRequestPath:"/"} + orderBy:[count_DESC] + ){dimensions{clientCountryName} count sum{visits}} + }} + } + """ + + print(f"{HOST} homepage (/) by UA | RUM by UA\n") + tot_human_home_req = tot_human_home_vis = 0 + tot_rum = 0 + tot_bot_home_vis = 0 + + for i in range(7, -1, -1): + day = date.today() - timedelta(days=i) + data = gql(q, {"zoneTag": zid, "day": day.isoformat(), "host": HOST}) + if data.get("errors"): + print(day, data["errors"][0]["message"][:120]) + continue + z = data["data"]["viewer"]["zones"][0] + home = z["homeByUA"] or [] + rum = z["rumByUA"] or [] + if not home and not rum: + continue + + human_req = human_vis = bot_vis = rum_c = 0 + print(f"=== {day} ===") + print(" homepage / by UA:") + for r in home: + ua = (r["dimensions"] or {}).get("userAgentBrowser") + c = r["count"] or 0 + v = (r.get("sum") or {}).get("visits") or 0 + tag = "HUMAN" if ua in HUMAN_BROWSERS else "bot/other" + print(f" {c:>4} req {v:>4} vis [{tag}] {ua}") + if ua in HUMAN_BROWSERS: + human_req += c + human_vis += v + else: + bot_vis += v + print(" RUM by UA:") + for r in rum: + ua = (r["dimensions"] or {}).get("userAgentBrowser") + c = r["count"] or 0 + rum_c += c + print(f" {c:>4} beacons {ua}") + print(" homepage / by country:") + for r in z["homeByCountry"] or []: + cc = (r["dimensions"] or {}).get("clientCountryName") + v = (r.get("sum") or {}).get("visits") or 0 + print(f" {r['count']:>4} req {v:>4} vis {cc}") + print( + f" day human home: {human_req} req / {human_vis} visits | " + f"bot-other home visits: {bot_vis} | rum: {rum_c}" + ) + tot_human_home_req += human_req + tot_human_home_vis += human_vis + tot_bot_home_vis += bot_vis + tot_rum += rum_c + + print("\n======== SUMMARY (8 days) ========") + print(f" Human browser homepage requests: {tot_human_home_req}") + print(f" Human browser homepage visits: {tot_human_home_vis}") + print(f" Non-human homepage visits: {tot_bot_home_vis}") + print(f" RUM beacons (JS pageviews): {tot_rum}") + print() + print(" Verdict (exclude crawlers):") + print(f" ~{tot_human_home_vis} real homepage visits (known browser UA + /)") + print(f" ~{tot_rum} RUM pageviews (browser executed JS)") + # If RUM > human home visits, same session may fire multiple beacons + # or SPA navigations; take human home visits as unique-ish sessions, + # RUM as pageview count. + lo = min(tot_human_home_vis, tot_rum) if tot_rum else tot_human_home_vis + hi = max(tot_human_home_vis, tot_rum) if tot_rum else tot_human_home_vis + print(f" Best estimate: ~{lo}–{hi} real human opens") + + +if __name__ == "__main__": + main() diff --git a/_cf_human_traffic.py b/_cf_human_traffic.py new file mode 100644 index 0000000..a5f0010 --- /dev/null +++ b/_cf_human_traffic.py @@ -0,0 +1,386 @@ +"""Estimate human traffic for dota2.refining.dev (exclude obvious bots). + +Free plan has no BotScore; we use: + - HTML document hits (/) vs asset/bot paths + - RUM beacons (/cdn-cgi/rum) as JS-executing browsers + - UA browser family when available + - Country + path heuristics +""" + +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.request +from datetime import date, timedelta + +HOST = "dota2.refining.dev" +ZONE_NAME = "refining.dev" + + +def headers() -> dict[str, str]: + email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get( + "KEYZOO_ASSET_META_USERNAME" + ) + key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get( + "KEYZOO_ASSET_SECRET_GLOBAL_API_KEY" + ) + if not email or not key: + raise SystemExit("missing credentials in env") + return { + "X-Auth-Email": email, + "X-Auth-Key": key, + "Content-Type": "application/json", + } + + +def api(path: str) -> dict: + req = urllib.request.Request( + "https://api.cloudflare.com/client/v4" + path, + method="GET", + headers=headers(), + ) + with urllib.request.urlopen(req) as r: + return json.loads(r.read().decode()) + + +def gql(query: str, variables: dict) -> dict: + req = urllib.request.Request( + "https://api.cloudflare.com/client/v4/graphql", + data=json.dumps({"query": query, "variables": variables}).encode(), + method="POST", + headers=headers(), + ) + try: + with urllib.request.urlopen(req) as r: + return json.loads(r.read().decode()) + except urllib.error.HTTPError as e: + return {"errors": [{"message": e.read().decode()[:1500]}]} + + +def try_query(label: str, query: str, variables: dict) -> dict | None: + data = gql(query, variables) + if data.get("errors"): + msg = data["errors"][0].get("message", "")[:200] + print(f" [{label}] unavailable: {msg}") + return None + return data + + +def main() -> None: + zid = api(f"/zones?name={ZONE_NAME}")["result"][0]["id"] + days = [date.today() - timedelta(days=i) for i in range(7, -1, -1)] + + print(f"\n{HOST} human-traffic estimate (Free plan, no BotScore)\n") + + # --- probe schema fields once --- + day0 = days[-2] if len(days) > 1 else days[-1] # prefer yesterday + probes = [ + ( + "UA browser", + """ + query($zoneTag:string!,$day:Date!,$host:string!){ + viewer{zones(filter:{zoneTag:$zoneTag}){ + g:httpRequestsAdaptiveGroups( + limit:20 + filter:{date:$day,clientRequestHTTPHost:$host} + orderBy:[count_DESC] + ){dimensions{userAgentBrowser} count sum{visits}} + }} + } + """, + ), + ( + "device type", + """ + query($zoneTag:string!,$day:Date!,$host:string!){ + viewer{zones(filter:{zoneTag:$zoneTag}){ + g:httpRequestsAdaptiveGroups( + limit:10 + filter:{date:$day,clientRequestHTTPHost:$host} + orderBy:[count_DESC] + ){dimensions{clientDeviceType} count sum{visits}} + }} + } + """, + ), + ( + "botScore", + """ + query($zoneTag:string!,$day:Date!,$host:string!){ + viewer{zones(filter:{zoneTag:$zoneTag}){ + g:httpRequestsAdaptiveGroups( + limit:10 + filter:{date:$day,clientRequestHTTPHost:$host,botScore_geq:30} + orderBy:[count_DESC] + ){count sum{visits} dimensions{botScore}} + }} + } + """, + ), + ( + "content type", + """ + query($zoneTag:string!,$day:Date!,$host:string!){ + viewer{zones(filter:{zoneTag:$zoneTag}){ + g:httpRequestsAdaptiveGroups( + limit:15 + filter:{date:$day,clientRequestHTTPHost:$host} + orderBy:[count_DESC] + ){dimensions{edgeResponseContentTypeName} count sum{visits}} + }} + } + """, + ), + ] + print(f"Schema probes on {day0}:") + available = {} + for label, q in probes: + data = try_query( + label, + q, + {"zoneTag": zid, "day": day0.isoformat(), "host": HOST}, + ) + if data: + available[label] = data["data"]["viewer"]["zones"][0]["g"] + print(f" [{label}] OK") + + for label, rows in available.items(): + print(f"\n--- {label} ({day0}) ---") + for r in rows[:12]: + dims = r.get("dimensions") or {} + key = next(iter(dims.values()), "?") if dims else "?" + v = (r.get("sum") or {}).get("visits") or 0 + print(f" {r['count']:>6} req {v:>5} vis {key}") + + # --- per-day: total / homepage / rum / likely-bot paths --- + day_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}} + home:httpRequestsAdaptiveGroups( + limit:1 + filter:{date:$day,clientRequestHTTPHost:$host,clientRequestPath:"/"} + ){count sum{visits}} + rum:httpRequestsAdaptiveGroups( + limit:1 + filter:{date:$day,clientRequestHTTPHost:$host,clientRequestPath:"/cdn-cgi/rum"} + ){count sum{visits}} + html:httpRequestsAdaptiveGroups( + limit:1 + filter:{ + date:$day + clientRequestHTTPHost:$host + edgeResponseContentTypeName:"text/html" + } + ){count sum{visits}} + byPath:httpRequestsAdaptiveGroups( + limit:40 + filter:{date:$day,clientRequestHTTPHost:$host} + orderBy:[count_DESC] + ){dimensions{clientRequestPath} count sum{visits}} + byUA:httpRequestsAdaptiveGroups( + limit:25 + filter:{date:$day,clientRequestHTTPHost:$host} + orderBy:[count_DESC] + ){dimensions{userAgentBrowser} count sum{visits}} + }} + } + """ + + BOT_PATH_MARKERS = ( + "wp-", + "wordpress", + "xmlrpc", + "wlwmanifest", + ".env", + "phpmyadmin", + "robots.txt", + "sitemap", + "favicon.ico", # often bots; keep separate + ) + HUMAN_UA = { + "Chrome", + "Firefox", + "Safari", + "Edge", + "Opera", + "Samsung Internet", + "Mobile Safari", + "Chrome Mobile", + "Firefox Mobile", + "Edg", + "Chromium", + } + BOT_UA = { + "Bot", + "Spider", + "Crawler", + "curl", + "Go-http-client", + "python", + "Python", + "Scrapy", + "Headless", + "Empty", + "Unknown", + "", + None, + } + + print(f"\n{'date':<12} {'all_req':>8} {'all_vis':>8} {'home_vis':>8} {'rum':>6} {'html_vis':>8}") + totals = { + "all_req": 0, + "all_vis": 0, + "home_vis": 0, + "rum": 0, + "html_vis": 0, + "human_ua_vis": 0, + "bot_path_req": 0, + } + best_day = None + best_z = None + best_home = 0 + + for day in days: + data = try_query( + day.isoformat(), + day_q, + {"zoneTag": zid, "day": day.isoformat(), "host": HOST}, + ) + if not data: + # retry without html / UA if fields fail + day_q_min = """ + query($zoneTag:string!,$day:Date!,$host:string!){ + viewer{zones(filter:{zoneTag:$zoneTag}){ + all:httpRequestsAdaptiveGroups( + limit:1 filter:{date:$day,clientRequestHTTPHost:$host} + ){count sum{visits}} + home:httpRequestsAdaptiveGroups( + limit:1 + filter:{date:$day,clientRequestHTTPHost:$host,clientRequestPath:"/"} + ){count sum{visits}} + rum:httpRequestsAdaptiveGroups( + limit:1 + filter:{ + date:$day + clientRequestHTTPHost:$host + clientRequestPath:"/cdn-cgi/rum" + } + ){count sum{visits}} + byPath:httpRequestsAdaptiveGroups( + limit:40 + filter:{date:$day,clientRequestHTTPHost:$host} + orderBy:[count_DESC] + ){dimensions{clientRequestPath} count sum{visits}} + }} + } + """ + data = try_query( + day.isoformat() + "-min", + day_q_min, + {"zoneTag": zid, "day": day.isoformat(), "host": HOST}, + ) + if not data: + continue + z = data["data"]["viewer"]["zones"][0] + + def one(name: str) -> tuple[int, int]: + rows = z.get(name) or [] + if not rows: + return 0, 0 + r = rows[0] + return r.get("count") or 0, (r.get("sum") or {}).get("visits") or 0 + + all_c, all_v = one("all") + home_c, home_v = one("home") + rum_c, _ = one("rum") + html_c, html_v = one("html") if "html" in z else (0, 0) + + print( + f"{day.isoformat():<12} {all_c:>8} {all_v:>8} {home_v:>8} {rum_c:>6} {html_v:>8}" + ) + totals["all_req"] += all_c + totals["all_vis"] += all_v + totals["home_vis"] += home_v + totals["rum"] += rum_c + totals["html_vis"] += html_v + + # bot-looking paths + for r in z.get("byPath") or []: + p = (r["dimensions"] or {}).get("clientRequestPath") or "" + if any(m in p.lower() for m in BOT_PATH_MARKERS): + totals["bot_path_req"] += r["count"] or 0 + + # UA sum visits for human browsers + for r in z.get("byUA") or []: + ua = (r["dimensions"] or {}).get("userAgentBrowser") + v = (r.get("sum") or {}).get("visits") or 0 + if ua in HUMAN_UA or ( + ua + and not any( + str(b).lower() in str(ua).lower() + for b in BOT_UA + if b + ) + and ua + not in ("", None) + ): + # count known browsers only + if ua in HUMAN_UA or ( + isinstance(ua, str) + and any( + x in ua + for x in ( + "Chrome", + "Firefox", + "Safari", + "Edge", + "Opera", + ) + ) + ): + totals["human_ua_vis"] += v + + if home_v >= best_home: + best_home = home_v + best_day = day + best_z = z + + print("\n--- 7-day totals ---") + print(f" all edge requests: {totals['all_req']}") + print(f" all edge visits: {totals['all_vis']}") + print(f" homepage visits (/): {totals['home_vis']}") + print(f" RUM beacons: {totals['rum']} (JS ran = real browser)") + print(f" HTML content visits: {totals['html_vis']}") + print(f" obvious bot-path req: {totals['bot_path_req']}") + + # Best estimate narrative + rum = totals["rum"] + home = totals["home_vis"] + # Conservative human sessions: min(home visits, rum) .. max, with rum as best + print("\n=== Real-human estimate ===") + print(f" Best proxy (RUM / JS pageviews): ~{rum}") + print(f" Homepage edge visits (/): ~{home}") + print( + f" Suggested range: ~{min(rum, home) if rum and home else max(rum, home)}" + f" .. ~{max(rum, home)} unique-ish real opens" + ) + print( + " (RUM fires only when a real browser runs JS; scrapers hitting wp-*/assets do not.)" + ) + + if best_z and best_day: + print(f"\n--- UA browsers on {best_day} (if available) ---") + for r in (best_z.get("byUA") or [])[:15]: + ua = (r["dimensions"] or {}).get("userAgentBrowser") + v = (r.get("sum") or {}).get("visits") or 0 + print(f" {r['count']:>6} req {v:>5} vis {ua!r}") + + +if __name__ == "__main__": + main() diff --git a/_cf_traffic.py b/_cf_traffic.py new file mode 100644 index 0000000..bc286de --- /dev/null +++ b/_cf_traffic.py @@ -0,0 +1,217 @@ +"""Pull traffic for dota2.refining.dev via Cloudflare GraphQL Analytics API. + +Credentials from env (keyzoo inject). Read-only; prints a short summary. +""" + +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.request +from datetime import date, timedelta + +HOST = "dota2.refining.dev" +ZONE_NAME = "refining.dev" +DAYS = 14 + + +def headers() -> dict[str, str]: + email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get( + "KEYZOO_ASSET_META_USERNAME" + ) + key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get( + "KEYZOO_ASSET_SECRET_GLOBAL_API_KEY" + ) + if not email or not key: + raise SystemExit("missing credentials in env") + return { + "X-Auth-Email": email, + "X-Auth-Key": key, + "Content-Type": "application/json", + } + + +def api(path: str) -> dict: + req = urllib.request.Request( + "https://api.cloudflare.com/client/v4" + path, + method="GET", + headers=headers(), + ) + with urllib.request.urlopen(req) as r: + return json.loads(r.read().decode()) + + +def gql(query: str, variables: dict) -> dict: + req = urllib.request.Request( + "https://api.cloudflare.com/client/v4/graphql", + data=json.dumps({"query": query, "variables": variables}).encode(), + method="POST", + headers=headers(), + ) + try: + with urllib.request.urlopen(req) as r: + return json.loads(r.read().decode()) + except urllib.error.HTTPError as e: + body = e.read().decode() + raise SystemExit(f"HTTP {e.code}: {body[:2000]}") from e + + +def fmt_bytes(n: int | float | None) -> str: + if n is None: + return "-" + n = float(n) + for unit in ("B", "KB", "MB", "GB", "TB"): + if abs(n) < 1024: + return f"{n:.1f} {unit}" + n /= 1024 + return f"{n:.1f} PB" + + +def main() -> None: + zd = api(f"/zones?name={ZONE_NAME}") + zone = (zd.get("result") or [None])[0] + if not zone: + raise SystemExit(f"zone not found: {zd.get('errors')}") + zid = zone["id"] + + end = date.today() + start = end - timedelta(days=DAYS) + + # Free plan: httpRequests1dGroups works for multi-day zone totals. + # Adaptive (host/path filter) is limited to a 1-day window. + zone_q = """ + query($zoneTag: string!, $start: Date!, $end: Date!) { + viewer { + zones(filter: {zoneTag: $zoneTag}) { + httpRequests1dGroups( + limit: 20 + filter: {date_geq: $start, date_leq: $end} + orderBy: [date_ASC] + ) { + dimensions { date } + sum { requests bytes cachedRequests threats } + uniq { uniques } + } + } + } + } + """ + data = gql( + zone_q, + {"zoneTag": zid, "start": start.isoformat(), "end": end.isoformat()}, + ) + if data.get("errors"): + raise SystemExit(json.dumps(data["errors"], indent=2)[:4000]) + rows = data["data"]["viewer"]["zones"][0]["httpRequests1dGroups"] + print(f"\n{ZONE_NAME} zone (all hosts) {start} .. {end}") + print(f"{'date':<12} {'requests':>10} {'uniques':>10} {'bytes':>12} {'threats':>8}") + tot_r = tot_u = tot_b = tot_t = 0 + for row in rows: + d = row["dimensions"]["date"] + s = row["sum"] + u = row["uniq"]["uniques"] + print( + f"{d:<12} {s['requests']:>10} {u:>10} " + f"{fmt_bytes(s['bytes']):>12} {s['threats']:>8}" + ) + tot_r += s["requests"] + tot_u += u + tot_b += s["bytes"] + tot_t += s["threats"] + print( + f"{'TOTAL':<12} {tot_r:>10} {tot_u:>10} " + f"{fmt_bytes(tot_b):>12} {tot_t:>8}" + ) + + # Host-scoped detail: one day at a time (Free quota). + host_q = """ + query($zoneTag: string!, $day: Date!, $host: string!) { + viewer { + zones(filter: {zoneTag: $zoneTag}) { + total: httpRequestsAdaptiveGroups( + limit: 1 + filter: {date: $day, clientRequestHTTPHost: $host} + ) { + count + sum { edgeResponseBytes visits } + } + byPath: httpRequestsAdaptiveGroups( + limit: 15 + filter: {date: $day, clientRequestHTTPHost: $host} + orderBy: [count_DESC] + ) { + dimensions { clientRequestPath } + count + sum { visits } + } + byCountry: httpRequestsAdaptiveGroups( + limit: 8 + filter: {date: $day, clientRequestHTTPHost: $host} + orderBy: [count_DESC] + ) { + dimensions { clientCountryName } + count + sum { visits } + } + byStatus: httpRequestsAdaptiveGroups( + limit: 8 + filter: {date: $day, clientRequestHTTPHost: $host} + orderBy: [count_DESC] + ) { + dimensions { edgeResponseStatus } + count + } + } + } + } + """ + print(f"\n{HOST} host detail (last {min(DAYS, 7)} days, Free=1d/query)") + print(f"{'date':<12} {'requests':>10} {'visits':>10} {'bytes':>12}") + host_days = [] + for i in range(min(DAYS, 7), -1, -1): + day = end - timedelta(days=i) + hd = gql( + host_q, + {"zoneTag": zid, "day": day.isoformat(), "host": HOST}, + ) + if hd.get("errors"): + print(f"{day.isoformat():<12} error: {hd['errors'][0].get('message')}") + continue + z = hd["data"]["viewer"]["zones"][0] + tot = (z["total"] or [{}])[0] + c = tot.get("count") or 0 + v = (tot.get("sum") or {}).get("visits") or 0 + b = (tot.get("sum") or {}).get("edgeResponseBytes") or 0 + print(f"{day.isoformat():<12} {c:>10} {v:>10} {fmt_bytes(b):>12}") + host_days.append((day, z, c, v, b)) + + # Detail for the busiest recent day. + pick = max(host_days, key=lambda x: x[2], default=None) + if pick and pick[2] > 0: + day, z, _, _, _ = pick + print(f"\nDetail for busiest day {day.isoformat()} ({HOST})") + print("Top paths:") + for row in z["byPath"][:15]: + path = row["dimensions"].get("clientRequestPath") or "/" + v = (row.get("sum") or {}).get("visits") or 0 + print(f" {row['count']:>8} req {v:>6} visits {path}") + print("Top countries:") + for row in z["byCountry"]: + name = row["dimensions"].get("clientCountryName") or "?" + v = (row.get("sum") or {}).get("visits") or 0 + print(f" {row['count']:>8} req {v:>6} visits {name}") + print("Status codes:") + for row in z["byStatus"]: + st = row["dimensions"].get("edgeResponseStatus") + print(f" {st}: {row['count']}") + + print( + "\nNote: edge requests/visits via Cloudflare proxy (not RUM pageviews). " + "Includes assets/bots. Zone totals mix all refining.dev hosts; " + "host table is dota2-only." + ) + + +if __name__ == "__main__": + main() diff --git a/docs/commercial-prospects.md b/docs/commercial-prospects.md new file mode 100644 index 0000000..b232fac --- /dev/null +++ b/docs/commercial-prospects.md @@ -0,0 +1,165 @@ +# 上分帝(Climperor)商业化前景 + +基于当前产品形态(局内选将识别 + 关系/物品/版本知识站)与 Dota 2 市场结构的判断。 + +- 分析日期:2026-07-28 +- 主战场假设:**中国大陆简中天梯玩家**(Overwolf 需科学上网,大陆侧基本不可用) + +--- + +## 总判断 + +**大陆市场窗口更清晰——可赚钱的利基工具,仍非融资级平台。** + +痛点真实、合规路径清晰。Overwolf 系助手在中国大陆需科学上网,实际不可用,局内免费广告竞品几乎空窗。主战场应锁定简中天梯用户;天花板仍受 Dota 付费层规模限制,适合「社区/内容 + Freemium」小而稳生意。 + +| 维度 | 评级 | +|------|------| +| 规模化潜力 | 中低 | +| 大陆利基变现 | 偏高 | +| 合规护城河 | 高 | +| 本地化窗口 | 中高 | + +--- + +## 一、市场与需求 + +### TAM 量级(以大陆为主) + +全球 Steam 并发约 42–60 万(2026 上半年均值波动,来源:Steam Charts / Steambase)。上分帝的有效市场是简中客户端天梯玩家。认真打定位局、愿为选将决策付费的人仍窄,但大陆侧几乎没有可用的 Overwolf 级局内助手,付费转化阻力低于海外。 + +### 真实痛点 + +Valve 已关闭普通玩家视角的实时 draft GSI。大陆玩家的替代路径主要是网页查表、直播间口播、自制笔记——选将阶段几秒内很难用。识别 + Overlay 填的是「局内实时」空缺,不是和 Overwolf 抢同一批用户。 + +### 渠道前提 + +产品已天然适配大陆:简体位置字模板、中文 aliases/tags、定性理由文案、国内可访问的知识站(Cloudflare Pages + 阿里云 OSS)。分发与内容都不必依赖科学上网。**不要走 Overwolf 商店分发。** + +### 产品资产 + +| 资产层 | 现状 | 商业含义 | +|--------|------|----------| +| 局内识别 + Overlay | GSI 触发 + CDN 模板匹配 + Top-3 青标 | 差异化入口;安装成本高,转化漏斗长 | +| 定性关系数据 | `relations.json` 克制/搭档边 + 理由 | 可成为内容 IP;维护成本高,胜率库难替代 | +| 知识站 | 英雄/物品/版本,已部署 `dota2.refining.dev` | 获客与品牌面;可走广告/会员,不必装客户端 | +| 合规边界 | 仅 GSI + 截屏,禁读内存 | 降低封号恐惧,利于信任与渠道合作 | + +--- + +## 二、竞争格局(大陆视角) + +| 玩家 | 模式 | 大陆可用性 | 对上分帝的压力 | +|------|------|------------|----------------| +| Overwolf DotaPlus | 免费 + 广告;选禁建议 | 需科学上网,基本不可用 | 海外强竞品;大陆侧可忽略 | +| Valve Dota Plus | ~$3.99/月官方订阅 | 可用(Steam) | 官方光环;偏生涯/助手,无双方阵容识别 | +| Dotabuff / Stratz | 网页 + Plus 订阅 | 访问不稳 / 体验差 | 赛后数据强;选将实时几乎无威胁 | +| 中文 Wiki / 攻略站 / B 站 | 流量 + 广告 / 内容 | 完全可用 | 知识站主竞品;局内 overlay 仍空缺 | +| 本地脚本 / 群文件工具 | 免费、分散、常违规 | 可用但信任差 | 识别可被复刻;合规+体验可拉开差距 | + +**相对优势:** 大陆「局内实时选将助手」几乎空白。① 合规视觉方案填补 GSI draft 空洞;② 中文定性克制(非胜率表);③ 识别→推荐→知识站闭环,且全链路不依赖科学上网。真正要赢的是中文内容站与信任感,不是打赢 Overwolf。 + +### 附录:大陆中文 Wiki / 攻略渠道 + +玩家实际查资料时,并不是单一站点垄断,而是「MAX+ 查数 + NGA/B 站看说法 + 官网百科对技能」。 + +#### 百科 / 官方资料 + +| 站点 | 地址 | 定位 | +|------|------|------| +| 刀塔百科 | https://wiki.dota2.com.cn/ | 国服官方向中文 Wiki(英雄/机制资料) | +| 完美世界官网 | https://www.dota2.com.cn/ | 英雄页、物品页、版本公告、活动 | +| Liquipedia | https://liquipedia.net/dota2/ | 赛事/机制权威 Wiki;英文为主,大陆可访问但不算中文站 | + +#### 数据 + 攻略一体 + +| 站点 | 地址 | 定位 | +|------|------|------| +| MAX+ | https://maxjia.com/ | 战绩 + 英雄胜率/出装 + 社区攻略,移动端心智最强 | + +#### 社区 UGC + +| 站点 | 地址 | 定位 | +|------|------|------| +| NGA 刀塔区 | https://bbs.nga.cn/thread.php?fid=321 | 长文攻略、版本讨论、精华帖;深度最高 | +| 百度 Dota2 吧 | 贴吧 | 碎片讨论、整活、初级问答,质量参差 | +| B 站 | 搜索「DOTA2 版本 / 上分」 | 事实上最大的攻略形态(视频) | + +#### 传统门户(影响力较弱) + +- [17173 DOTA2 专区](https://dota2.17173.com/) — 旧式图文攻略,更新慢、常过期 +- 游民星空 / 多玩等也曾有专区,现很少当主信息源 + +**与上分帝的关系:** 知识站主要抢注意力的是 MAX+ 英雄页与 NGA/B 站版本内容;局内实时 Overlay 上述站点都覆盖不到。 + +--- + +## 三、商业模式对照 + +### A. 知识站 Freemium(优先) + +- 免费:浏览关系/物品/版本 +- 付费:完整理由库、分路定制、导出、无广告、更新优先 +- 获客成本低(SEO / Hash 深度链接),与安装客户端解耦 + +### B. 桌面端一次性 / 年费(优先) + +- 识别 + Overlay 基础免费或低价 +- 高级:实时推荐、会话复盘、分路过滤强度 +- 定价锚:¥68–128/季 或 ¥168–298/年(对标 Dotabuff / 官方 Plus 心理账户) + +### C. 国内渠道获客 + 轻广告(大陆适配) + +- 小红书 / B 站 / 抖音 / QQ 群 / 贴吧口碑 +- 知识站可挂非侵入广告或赞赏 +- 安装包自托管(GitHub Release 或国内网盘/OSS),支付用微信/支付宝 +- **不要走 Overwolf 商店** + +### D. B2B / 内容合作(远期) + +- 教练团、主播选人面板、俱乐部内部工具;版本更新内容授权 +- 单客价值高、销售周期长;需产品包装与支持能力 + +### 收入情景(示意,非承诺) + +情景已按「Overwolf 不构成大陆竞争」略上调基准;仍是量级框架,非财务预测。乐观情景仍属个人/小团队生意规模。 + +| 情景 | 付费用户 | ARPU/年 | 年收入粗估 | 前提 | +|------|----------|---------|------------|------| +| 保守 | 300–800 | ¥120–200 | ¥5–12 万 | 知识站会员为主,桌面口碑未起量 | +| 基准 | 1.5k–4k | ¥150–250 | ¥25–80 万 | 大陆局内助手空窗被认知 + 稳定更新 | +| 乐观 | 5k–12k | ¥200–300 | ¥120–300 万 | 头部主播带安装或社区爆款 | + +--- + +## 四、风险与门槛 + +| 类别 | 说明 | +|------|------| +| 政策 / 合规 | 截屏 overlay 通常比读内存安全,但仍可能被社区视为「选将外挂」。需持续公开合规说明,避免自动化点击/注入。 | +| 产品摩擦 | GSI 配置、无边框、标定、宽高比、皮肤帧——安装到「第一局好用」的路径长,付费转化杀手。 | +| 内容运营 | 定性边与版本同步是人力活;停更即失信。胜率自动源便宜但与产品定位冲突,不能偷懒换库。 | + +其他约束:Windows 单平台;TAM 仍窄;大陆无 Overwolf 对手;中文攻略站抢知识流量;识别偶发失败影响信任;安装/支付需自建国内链路。 + +--- + +## 五、建议路径 + +### Now + +大陆优先:知识站获客 + 明确「免科学上网」卖点。对外文案直接对比「国外助手要翻墙」。强化版本速览与可分享关系页;桌面端先免费验证识别稳定性,再收费。 + +### Next + +一键安装包(国内下载)+ 微信/支付宝年费。降低 GSI/标定摩擦;收费绑「定位局实时 Top-3 + 会话复盘」。分发走自有安装包/OSS,支付走国内通道——不要依赖 Overwolf 或海外订阅基建。 + +### Later + +主播/社群带量与轻 B2B,避免烧钱扩品类。版本专栏、教练工具包、主播选人面板。护城河在定性数据质量与「大陆能用的局内助手」心智,不在功能堆叠或出海抢 Overwolf。 + +--- + +## 一句话结论 + +在中国大陆,上分帝面对的是「局内实时选将助手近乎空白」的窗口,商业土壤比全球视角更乐观;但仍是利基生意,不是融资级平台。用免翻墙的识别 Overlay 做钩子,用定性关系内容做留存与收费,是最匹配当前资产的路径。