feat: add site insights report

Search Console queries by page, file names searched against the
collection, GoatCounter totals and GitHub traffic in one Markdown report.
This commit is contained in:
Abdessamad Derraz committed 2026-11-06 12:55:57 +01:00
1 parent ebe9745e7f
commit b23840bc21
4 files changed
+380 -1

No files matched your search

+311
View File
@@ -0,0 +1,311 @@
#!/usr/bin/env python3
"""What readers look for and do not find, from totals only.
Four sources, none of which names a person:
- Google Search Console: the searches that showed the site, by page.
- GoatCounter: page views, the domains readers came from, and the actions
website/src/scripts/count.ts counts (searches that found nothing,
"Did this page help?", downloads, broken links). wiki/privacy.md says
what is sent.
- GitHub: repository traffic (14 days) and release download counts.
- The collection: which searched file names it holds, and which no
emulator profile declares.
The report is Markdown on stdout. Credentials stay outside the repository:
~/.config/retrobios/gsc-service-account.json Search Console, read only
~/.config/retrobios/goatcounter-api-token.txt GoatCounter, read only
GITHUB_TOKEN or gh auth token GitHub
Usage:
python scripts/site_insights.py
python scripts/site_insights.py --days 90 --output report.md
python scripts/site_insights.py --skip gsc,github
"""
from __future__ import annotations
import argparse
import base64
import datetime as dt
import json
import os
import re
import subprocess
import sys
import tempfile
import time
import urllib.parse
import urllib.request
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
sys.path.insert(0, os.path.dirname(__file__))
from common import github_token, load_database, load_emulator_profiles, run_cli
REPO_ROOT = Path(__file__).resolve().parent.parent
CONFIG = Path(os.environ.get("RETROBIOS_CONFIG", Path.home() / ".config" / "retrobios"))
SITE_PROPERTY = "https://abdess.github.io/retrobios/"
GOATCOUNTER = "https://retrobios.goatcounter.com/api/v0"
REPOSITORY = "Abdess/retrobios"
SOURCES = ("gsc", "goatcounter", "github")
# A word of a search that names a file: "scph5501.bin", "prod.keys".
FILE_TOKEN = re.compile(r"\b[\w.+-]+\.(?:bin|rom|zip|pup|keys|img|dat|bios|ic\d+|u\d+|sys|nvm|mec|"
r"rom1|j64|pce|col|sms|gb|gbc|gba|nds|cfg|chd|iso|txt|fw|dll|xml|yaml)\b", re.I)
@dataclass(frozen=True)
class Query:
text: str
page: str
clicks: int
impressions: int
position: float
@property
def ctr(self) -> float:
return self.clicks / self.impressions if self.impressions else 0.0
# Reading the sources
def _get_json(url: str, headers: dict[str, str] | None = None, body: dict | None = None) -> dict | list:
data = json.dumps(body).encode() if body is not None else None
request = urllib.request.Request(url, data=data, headers={
"User-Agent": "retrobios-site-insights", **({"Content-Type": "application/json"} if data else {}),
**(headers or {})})
with urllib.request.urlopen(request, timeout=60) as response:
return json.load(response)
def _google_token(key_file: Path) -> str:
"""An access token for a service account, signed by openssl: the JWT
grant needs RS256 and the standard library has no RSA."""
key = json.loads(key_file.read_text(encoding="utf-8"))
now = int(time.time())
def b64(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
claims = {"iss": key["client_email"], "scope": "https://www.googleapis.com/auth/webmasters.readonly",
"aud": key["token_uri"], "iat": now, "exp": now + 3600}
unsigned = f"{b64(json.dumps({'alg': 'RS256', 'typ': 'JWT'}).encode())}.{b64(json.dumps(claims).encode())}"
with tempfile.TemporaryDirectory() as scratch:
pem = Path(scratch) / "key.pem"
pem.touch(mode=0o600)
pem.write_text(key["private_key"], encoding="utf-8")
signature = subprocess.run(["openssl", "dgst", "-sha256", "-sign", str(pem)],
input=unsigned.encode(), capture_output=True, check=True).stdout
body = urllib.parse.urlencode({"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion": f"{unsigned}.{b64(signature)}"}).encode()
request = urllib.request.Request(key["token_uri"], data=body)
with urllib.request.urlopen(request, timeout=60) as response:
return json.load(response)["access_token"]
def fetch_queries(start: dt.date, end: dt.date) -> list[Query]:
token = _google_token(CONFIG / "gsc-service-account.json")
url = ("https://www.googleapis.com/webmasters/v3/sites/"
f"{urllib.parse.quote(SITE_PROPERTY, safe='')}/searchAnalytics/query")
rows: list[Query] = []
while True:
answer = _get_json(url, {"Authorization": f"Bearer {token}"}, {
"startDate": start.isoformat(), "endDate": end.isoformat(),
"dimensions": ["query", "page"], "rowLimit": 25000, "startRow": len(rows)})
batch = answer.get("rows", [])
rows += [Query(r["keys"][0], r["keys"][1], int(r["clicks"]), int(r["impressions"]),
float(r["position"])) for r in batch]
if len(batch) < 25000:
return rows
def fetch_goatcounter(start: dt.date, end: dt.date) -> dict[str, list[tuple[str, int]]]:
"""Paths and events with their visit counts, and the referring domains."""
token = (CONFIG / "goatcounter-api-token.txt").read_text(encoding="utf-8").strip()
auth = {"Authorization": f"Bearer {token}"}
span = {"start": f"{start.isoformat()}T00:00:00Z", "end": f"{end.isoformat()}T23:59:59Z"}
hits: list[tuple[str, int]] = []
exclude: list[str] = []
while True:
params = urllib.parse.urlencode({**span, "limit": 100, "exclude_paths": ",".join(exclude)})
answer = _get_json(f"{GOATCOUNTER}/stats/hits?{params}", auth)
for hit in answer.get("hits", []):
hits.append((hit["path"] + (" [404]" if hit.get("title") == "404" else ""), int(hit["count"])))
exclude.append(str(hit["path_id"]))
if not answer.get("more"):
break
refs = _get_json(f"{GOATCOUNTER}/stats/toprefs?{urllib.parse.urlencode({**span, 'limit': 50})}", auth)
referrers = [(r.get("name") or "(direct)", int(r["count"])) for r in refs.get("stats", [])]
return {"hits": hits, "referrers": referrers}
def fetch_github() -> dict:
token = github_token()
if not token:
raise SystemExit("GitHub traffic needs a token: GITHUB_TOKEN, or gh auth login")
auth = {"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"}
api = f"https://api.github.com/repos/{REPOSITORY}"
releases = _get_json(f"{api}/releases?per_page=10", auth)
return {
"views": _get_json(f"{api}/traffic/views", auth),
"clones": _get_json(f"{api}/traffic/clones", auth),
"referrers": _get_json(f"{api}/traffic/popular/referrers", auth),
"paths": _get_json(f"{api}/traffic/popular/paths", auth),
"downloads": [(release["tag_name"], asset["name"], asset["download_count"])
for release in releases for asset in release.get("assets", [])],
}
# Reading the figures
def searched_files(queries: list[Query], held: set[str], declared: set[str]) -> list[tuple[str, int, str]]:
"""File names typed in searches, by impressions, with what the project
knows of each: held in the collection, declared by a profile only, or
unknown."""
seen: dict[str, int] = defaultdict(int)
shown: dict[str, str] = {}
for query in queries:
for token in FILE_TOKEN.findall(query.text):
key = token.casefold()
seen[key] += query.impressions
shown.setdefault(key, token)
state = {key: "held" if key in held else "declared, not held" if key in declared else "unknown"
for key in seen}
return sorted(((shown[key], seen[key], state[key]) for key in seen), key=lambda row: -row[1])
def missed_clicks(queries: list[Query], least: int = 100) -> list[Query]:
"""On the first page but rarely clicked: what the result says loses
the reader."""
return sorted((q for q in queries if q.position <= 10 and q.impressions >= least and q.ctr < 0.03),
key=lambda q: -q.impressions)
def near_first_page(queries: list[Query], least: int = 50) -> list[Query]:
return sorted((q for q in queries if 10 < q.position <= 20 and q.impressions >= least),
key=lambda q: -q.impressions)
def site_actions(hits: list[tuple[str, int]]) -> dict[str, list[tuple[str, int]]]:
"""Events by kind ("search-none", "helpful", "download"...), pages apart."""
groups: dict[str, list[tuple[str, int]]] = defaultdict(list)
for path, count in hits:
if path.endswith(" [404]"):
groups["404"].append((path.removesuffix(" [404]"), count))
elif not path.startswith("/") and "/" in path:
kind, _, name = path.partition("/")
groups[kind].append((name, count))
else:
groups["pages"].append((path, count))
return {kind: sorted(rows, key=lambda row: -row[1]) for kind, rows in groups.items()}
# The report
def _table(header: tuple[str, ...], rows: list[tuple], limit: int = 25) -> list[str]:
if not rows:
return ["None.", ""]
lines = ["| " + " | ".join(header) + " |", "|" + "---|" * len(header)]
lines += ["| " + " | ".join(str(cell) for cell in row) + " |" for row in rows[:limit]]
return [*lines, ""]
def _page(url: str) -> str:
return url.removeprefix(SITE_PROPERTY.rstrip("/")) or "/"
def report(start: dt.date, end: dt.date, queries: list[Query] | None, goat: dict | None,
github: dict | None, held: set[str], declared: set[str]) -> str:
out = [f"# Site insights, {start.isoformat()} to {end.isoformat()}", ""]
if queries is not None:
clicks = sum(q.clicks for q in queries)
shown = sum(q.impressions for q in queries)
out += ["## Google searches", "",
f"{clicks:,} clicks, {shown:,} impressions, {len({q.text for q in queries}):,} searches.", "",
"### On the first page, rarely clicked", "",
*_table(("Search", "Page", "Impressions", "CTR", "Position"),
[(q.text, _page(q.page), f"{q.impressions:,}", f"{q.ctr:.1%}", f"{q.position:.1f}")
for q in missed_clicks(queries)]),
"### Just past the first page", "",
*_table(("Search", "Page", "Impressions", "Position"),
[(q.text, _page(q.page), f"{q.impressions:,}", f"{q.position:.1f}")
for q in near_first_page(queries)]),
"### File names searched", "",
*_table(("File", "Impressions", "In the project"),
[(f"`{name}`", f"{seen:,}", state)
for name, seen, state in searched_files(queries, held, declared)], limit=40)]
if goat is not None:
actions = site_actions(goat["hits"])
named = {"search-none": "Site searches that found nothing", "404": "Broken addresses",
"helpful": "Did this page help?", "get": "Pack pages opened",
"download": "Release downloads", "file": "File downloads", "copy": "Copy buttons",
"out": "Links leaving the site", "pages": "Pages"}
out += ["## On the site", ""]
for kind, title in named.items():
if kind in actions:
out += [f"### {title}", "", *_table(("Name", "Visits"), [(n, f"{c:,}") for n, c in actions[kind]])]
out += ["### Came from", "", *_table(("Site", "Visits"), [(n, f"{c:,}") for n, c in goat["referrers"]])]
if github is not None:
views, clones = github["views"], github["clones"]
out += ["## GitHub", "",
f"Last 14 days: {views.get('count', 0):,} views by {views.get('uniques', 0):,} visitors, "
f"{clones.get('count', 0):,} clones.", "",
"### Came from", "",
*_table(("Site", "Views", "Visitors"),
[(r["referrer"], r["count"], r["uniques"]) for r in github["referrers"]]),
"### Most viewed", "",
*_table(("Path", "Views"), [(p["path"], p["count"]) for p in github["paths"]]),
"### Release downloads", "",
*_table(("Release", "File", "Downloads"),
sorted(github["downloads"], key=lambda row: -row[2]), limit=40)]
return "\n".join(out).rstrip() + "\n"
def collection_names(db: dict, profiles: dict[str, dict]) -> tuple[set[str], set[str]]:
"""File names the collection holds, and those a profile declares."""
held = {name.casefold() for name in db.get("indexes", {}).get("by_name", {})}
declared: set[str] = set()
for profile in profiles.values():
for entry in profile.get("files") or []:
if isinstance(entry, dict):
for name in [entry.get("name"), *(entry.get("aliases") or [])]:
if name:
declared.add(Path(str(name)).name.casefold())
return held, declared
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0], allow_abbrev=False)
parser.add_argument("--days", type=int, default=28, help="days to read, ending 3 days ago (default 28)")
parser.add_argument("--skip", default="", help=f"sources to leave out, among {', '.join(SOURCES)}")
parser.add_argument("--output", type=Path, help="write the report here instead of stdout")
args = parser.parse_args()
skip = {name.strip() for name in args.skip.split(",") if name.strip()}
unknown = skip - set(SOURCES)
if unknown:
parser.error(f"unknown source(s): {', '.join(sorted(unknown))}")
# Search Console data settles two to three days after the fact.
end = dt.date.today() - dt.timedelta(days=3)
start = end - dt.timedelta(days=args.days - 1)
held, declared = collection_names(load_database(str(REPO_ROOT / "database.json")),
load_emulator_profiles(str(REPO_ROOT / "emulators")))
queries = None if "gsc" in skip else fetch_queries(start, end)
goat = None if "goatcounter" in skip else fetch_goatcounter(start, dt.date.today())
github = None if "github" in skip else fetch_github()
text = report(start, end, queries, goat, github, held, declared)
if args.output:
args.output.write_text(text, encoding="utf-8")
print(f"Wrote {args.output}", file=sys.stderr)
else:
sys.stdout.write(text)
return 0
if __name__ == "__main__":
run_cli(main)