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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
+217
@@ -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()
|
||||
@@ -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 做钩子,用定性关系内容做留存与收费,是最匹配当前资产的路径。
|
||||
Reference in New Issue
Block a user