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)
+67
View File
@@ -0,0 +1,67 @@
"""The readings of scripts/site_insights.py, on synthetic figures."""
from __future__ import annotations
import datetime as dt
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import site_insights as si # noqa: E402
PAGE = "https://abdess.github.io/retrobios/emulators/mgba/"
class FileNamesInSearches(unittest.TestCase):
def test_each_name_carries_what_the_project_knows(self) -> None:
queries = [si.Query("gba_bios.bin download", PAGE, 1, 300, 8.0),
si.Query("where to put GBA_BIOS.BIN", PAGE, 0, 50, 9.0),
si.Query("dsp1b.rom snes", PAGE, 0, 40, 9.0),
si.Query("mystery.pup", PAGE, 0, 10, 30.0),
si.Query("gba bios", PAGE, 5, 900, 7.0)]
rows = si.searched_files(queries, held={"gba_bios.bin"}, declared={"gba_bios.bin", "dsp1b.rom"})
self.assertEqual(rows, [("gba_bios.bin", 350, "held"), ("dsp1b.rom", 40, "declared, not held"),
("mystery.pup", 10, "unknown")])
class SearchGaps(unittest.TestCase):
def test_first_page_rarely_clicked(self) -> None:
low = si.Query("swanstation", PAGE, 3, 540, 5.6)
fine = si.Query("nethersx2 bios", PAGE, 54, 484, 4.6)
far = si.Query("xbox bios", PAGE, 0, 900, 10.7)
self.assertEqual(si.missed_clicks([low, fine, far]), [low])
self.assertEqual(si.near_first_page([low, fine, far]), [far])
class SiteActions(unittest.TestCase):
def test_events_are_grouped_by_kind_and_pages_apart(self) -> None:
hits = [("/emulators/mgba/", 40), ("search-none/kenji nx", 3), ("search-none/dsp1b.rom", 7),
("helpful/no", 2), ("/old/link/ [404]", 5), ("download/Batocera_43.1_BIOS_Pack.zip", 9)]
actions = si.site_actions(hits)
self.assertEqual(actions["search-none"], [("dsp1b.rom", 7), ("kenji nx", 3)])
self.assertEqual(actions["404"], [("/old/link/", 5)])
self.assertEqual(actions["pages"], [("/emulators/mgba/", 40)])
self.assertEqual(actions["helpful"], [("no", 2)])
class TheReport(unittest.TestCase):
def test_a_skipped_source_leaves_no_section(self) -> None:
text = si.report(dt.date(2026, 10, 1), dt.date(2026, 10, 28),
[si.Query("gba_bios.bin", PAGE, 1, 300, 8.0)], None, None,
held={"gba_bios.bin"}, declared=set())
self.assertIn("## Google searches", text)
self.assertIn("| gba_bios.bin | /emulators/mgba/ | 300 | 0.3% | 8.0 |", text)
self.assertNotIn("## On the site", text)
self.assertNotIn("## GitHub", text)
def test_names_come_from_profiles_and_the_collection(self) -> None:
db = {"indexes": {"by_name": {"SCPH5501.BIN": ["x"]}}}
profiles = {"p": {"files": [{"name": "bios/dsp1b.rom", "aliases": ["DSP1B.bin"]}, "str"]}}
held, declared = si.collection_names(db, profiles)
self.assertEqual(held, {"scph5501.bin"})
self.assertEqual(declared, {"dsp1b.rom", "dsp1b.bin"})
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -65,7 +65,7 @@ Every module in `tests/`, by the area it guards.
| Pack probe | the cores a platform runs for a system, staging grown to a fixed point, runs mapped to pack and manifest entries, the line and its schema, the probe lock per run, that no page shows its lines. One class builds a real pack narrowed to PlayStation and lays it out both ways, from the archive and with `install.py --dest`, and skips without the collection | `test_pack_probe` |
| Automation runner, disk cleanup, asset sync and bit-flip repair | fake jobs, git repositories with a bare origin and session worktrees, a committed file with one bit flipped, canned CI runs in place of `gh` | `test_autorun` |
| Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` |
| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_cited_lines`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_pin_build`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_retired_files`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_arcade_boards`, `test_site_availability`, `test_site_bootstrap`, `test_site_built_gate`, `test_site_components`, `test_site_console_folders`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_css`, `test_site_density_cards`, `test_site_emulator_page`, `test_site_emulators`, `test_site_entry`, `test_site_entry_tables`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_integration`, `test_site_nav`, `test_site_picker`, `test_site_platform_density`, `test_site_platform_hashes`, `test_site_reference`, `test_site_render`, `test_site_search`, `test_site_system_links`, `test_site_system_names`, `test_site_system_sections`, `test_site_systems`, `test_site_validation`, `test_site_weight`, `test_site_wiki`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_version_basis`, `test_workflow_paths` |
| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_cited_lines`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_pin_build`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_retired_files`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_arcade_boards`, `test_site_availability`, `test_site_bootstrap`, `test_site_built_gate`, `test_site_components`, `test_site_console_folders`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_css`, `test_site_density_cards`, `test_site_emulator_page`, `test_site_emulators`, `test_site_entry`, `test_site_entry_tables`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_insights`, `test_site_integration`, `test_site_nav`, `test_site_picker`, `test_site_platform_density`, `test_site_platform_hashes`, `test_site_privacy`, `test_site_reference`, `test_site_render`, `test_site_search`, `test_site_system_links`, `test_site_system_names`, `test_site_system_sections`, `test_site_systems`, `test_site_validation`, `test_site_weight`, `test_site_wiki`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_version_basis`, `test_workflow_paths` |
| Games on the card | small DATs, listxml and setup sources, a fake fetcher, fixture install lists, and the committed data against its schemas | `test_arcade_parents`, `test_game_catalogs`, `test_game_headers`, `test_game_regions`, `test_games_index`, `test_romfolders_card`, `test_romfolders_es` |
## Test architecture
+1
View File
@@ -1353,6 +1353,7 @@ before a release (wiki/release-process.md). Its lines stay in
| `generate_readme.py` | Generate README.md and CONTRIBUTING.md from database |
| `generate_site.py` | Generate the site (this documentation) into the Starlight project in `website/` |
| `sitesources.py` | Record which cited source paths each pinned upstream tree holds, for the site's source links (`--fetch` reads a tree no cache holds) |
| `site_insights.py` | What readers look for and do not find, from totals only: searches that show the site but are rarely clicked, file names searched and whether the collection holds them, site searches that found nothing, broken addresses, "Did this page help?" answers, GitHub traffic and release downloads (`--days`, `--skip gsc,goatcounter,github`, `--output`). Reads Search Console, GoatCounter and GitHub with credentials kept outside the repository. [What the site counts](privacy.md) |
| `validate_site.py` | Validate rendered metadata, heading order, image alternatives, JSON-LD, local resources, links and fragments, and the page size and search index budgets |
| `romset_recipes.py` | Identify which emulator version an arcade archive matches, and rebuild a pinned archive from ROMs already held |
| `scraper/romset_dat_importer.py` | Fetch and import per-set recipes from MAME `-listxml` or FBNeo DATs into `recipes/`. `--fetch` alone takes the newest MAME release, and FBNeo DATs are refreshed by git blob sha. `--fetch <revision>` reads FBNeo at that tag, branch or commit |