diff --git a/scripts/eu-infra-pds.py b/scripts/eu-infra-pds.py new file mode 100644 index 0000000..5527040 --- /dev/null +++ b/scripts/eu-infra-pds.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Count accounts hosted on European AT Protocol infrastructure, merged into europe-stats.json as +`europeanInfra`. + +Being hosted on a European sovereign PDS (Eurosky and peers) is a strong signal that an account is +European, but it says nothing about WHICH country, so this is a separate top-level number and is +never folded into the per-country counts or the language estimate. It answers a different question: +how many people run on European infrastructure rather than Bluesky's US hosting. + + python3 scripts/eu-infra-pds.py --stats europe-stats.json + python3 scripts/eu-infra-pds.py --self-test +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import time +import urllib.request + +# European-operated PDS hosts. Start with Eurosky; add peers here as they appear. `endpoint` is the +# PDS service URL that answers com.atproto.sync.listRepos. +EUROPEAN_PDS = [ + {"host": "eurosky.social", "endpoint": "https://eurosky.social", "label": "Eurosky"}, +] + + +def count_repos(endpoint: str) -> tuple[int, int]: + """(total, active) repos on a PDS, paging through com.atproto.sync.listRepos.""" + total = active = 0 + cursor = None + while True: + url = f"{endpoint}/xrpc/com.atproto.sync.listRepos?limit=1000" + if cursor: + url += f"&cursor={cursor}" + with urllib.request.urlopen(url, timeout=30) as r: + d = json.load(r) + repos = d.get("repos", []) + total += len(repos) + active += sum(1 for x in repos if x.get("active", True)) + cursor = d.get("cursor") + if not cursor or not repos: + break + time.sleep(0.1) + return total, active + + +def build(hosts=EUROPEAN_PDS) -> dict: + out = [] + for h in hosts: + try: + total, active = count_repos(h["endpoint"]) + out.append({"host": h["host"], "label": h["label"], "accounts": active, "reposTotal": total}) + except Exception as e: # a PDS being down must never block the deploy + print(f" {h['host']} failed: {e}", file=sys.stderr) + return {"hosts": out, "total": sum(x["accounts"] for x in out)} + + +def _self_test(): + infra = {"hosts": [{"host": "x", "label": "X", "accounts": 5, "reposTotal": 6}], "total": 5} + assert infra["total"] == 5 + print("eu-infra self-test OK") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--stats", default="europe-stats.json") + ap.add_argument("--out", default=None) + ap.add_argument("--self-test", action="store_true") + args = ap.parse_args() + + if args.self_test: + _self_test() + return + + infra = build() + stats = json.load(open(args.stats)) + stats["europeanInfra"] = infra + out = args.out or args.stats + tmp = out + ".tmp" + with open(tmp, "w") as f: + f.write(json.dumps(stats, indent=2) + "\n") + os.replace(tmp, out) + hosts = ", ".join(f"{h['label']}={h['accounts']:,}" for h in infra["hosts"]) + print(f"europeanInfra: {infra['total']:,} active on {len(infra['hosts'])} European PDS host(s) ({hosts})") + + +if __name__ == "__main__": + main() diff --git a/src/data/europe-stats.json b/src/data/europe-stats.json index 90ba247..bb50ee9 100644 --- a/src/data/europe-stats.json +++ b/src/data/europe-stats.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-09-14T07:38:11Z", + "generatedAt": "2026-09-14T10:01:34Z", "people": 53089, "contested": 41, "countries": [ @@ -18,8 +18,7 @@ "verifier": 103, "companyDomain": 84, "sifaLocation": 12 - }, - "estimate": 77248 + } }, { "country": "gb", @@ -47,8 +46,7 @@ "strongTld": 6987, "companyDomain": 57, "sifaLocation": 37 - }, - "estimate": 941156 + } }, { "country": "fr", @@ -64,8 +62,7 @@ "verifier": 1635, "companyDomain": 45, "sifaLocation": 26 - }, - "estimate": 616083 + } }, { "country": "nl", @@ -81,8 +78,7 @@ "starterpack": 1178, "companyDomain": 58, "sifaLocation": 25 - }, - "estimate": 230000 + } }, { "country": "at", @@ -97,8 +93,7 @@ "weakTld": 3176, "companyDomain": 30, "sifaLocation": 8 - }, - "estimate": 99652 + } }, { "country": "be", @@ -114,8 +109,7 @@ "strongTld": 783, "sifaLocation": 14, "companyDomain": 13 - }, - "estimate": 133524 + } }, { "country": "ch", @@ -129,8 +123,7 @@ "strongTld": 1413, "companyDomain": 27, "sifaLocation": 8 - }, - "estimate": 90389 + } }, { "country": "se", @@ -145,8 +138,7 @@ "weakTld": 1016, "companyDomain": 30, "sifaLocation": 6 - }, - "estimate": 96238 + } }, { "country": "dk", @@ -160,8 +152,7 @@ "strongTld": 908, "companyDomain": 6, "sifaLocation": 3 - }, - "estimate": 39466 + } }, { "country": "fi", @@ -175,8 +166,7 @@ "strongTld": 764, "companyDomain": 9, "sifaLocation": 1 - }, - "estimate": 77881 + } }, { "country": "no", @@ -190,8 +180,7 @@ "strongTld": 751, "companyDomain": 8, "sifaLocation": 5 - }, - "estimate": 60846 + } }, { "country": "it", @@ -206,8 +195,7 @@ "weakTld": 745, "companyDomain": 10, "sifaLocation": 5 - }, - "estimate": 156213 + } }, { "country": "is", @@ -220,8 +208,7 @@ "bySignal": { "weakTld": 550, "companyDomain": 1 - }, - "estimate": 4174 + } }, { "country": "pl", @@ -235,8 +222,7 @@ "strongTld": 515, "companyDomain": 9, "sifaLocation": 1 - }, - "estimate": 70438 + } }, { "country": "cz", @@ -250,8 +236,7 @@ "strongTld": 483, "companyDomain": 14, "sifaLocation": 4 - }, - "estimate": 45543 + } }, { "country": "ie", @@ -278,8 +263,7 @@ "bySignal": { "strongTld": 388, "sifaLocation": 1 - }, - "estimate": 389 + } }, { "country": "ro", @@ -292,8 +276,7 @@ "bySignal": { "strongTld": 324, "sifaLocation": 1 - }, - "estimate": 5348 + } }, { "country": "li", @@ -304,8 +287,7 @@ }, "bySignal": { "strongTld": 252 - }, - "estimate": 252 + } }, { "country": "si", @@ -319,8 +301,7 @@ "strongTld": 248, "sifaLocation": 2, "companyDomain": 1 - }, - "estimate": 4393 + } }, { "country": "pt", @@ -334,8 +315,7 @@ "strongTld": 225, "sifaLocation": 6, "companyDomain": 4 - }, - "estimate": 235 + } }, { "country": "ee", @@ -349,8 +329,7 @@ "strongTld": 231, "sifaLocation": 1, "companyDomain": 1 - }, - "estimate": 2782 + } }, { "country": "rs", @@ -361,8 +340,7 @@ }, "bySignal": { "strongTld": 196 - }, - "estimate": 196 + } }, { "country": "hu", @@ -375,8 +353,7 @@ "bySignal": { "strongTld": 175, "sifaLocation": 1 - }, - "estimate": 18715 + } }, { "country": "lv", @@ -387,8 +364,7 @@ }, "bySignal": { "strongTld": 176 - }, - "estimate": 3807 + } }, { "country": "gr", @@ -399,8 +375,7 @@ }, "bySignal": { "strongTld": 134 - }, - "estimate": 27349 + } }, { "country": "ua", @@ -412,8 +387,7 @@ "bySignal": { "strongTld": 122, "companyDomain": 1 - }, - "estimate": 123 + } }, { "country": "al", @@ -424,8 +398,7 @@ }, "bySignal": { "strongTld": 118 - }, - "estimate": 118 + } }, { "country": "mk", @@ -436,8 +409,7 @@ }, "bySignal": { "strongTld": 108 - }, - "estimate": 108 + } }, { "country": "sk", @@ -450,8 +422,7 @@ "bySignal": { "strongTld": 99, "sifaLocation": 3 - }, - "estimate": 8593 + } }, { "country": "lu", @@ -464,8 +435,7 @@ "bySignal": { "strongTld": 99, "sifaLocation": 1 - }, - "estimate": 6996 + } }, { "country": "lt", @@ -478,8 +448,7 @@ "bySignal": { "strongTld": 80, "sifaLocation": 1 - }, - "estimate": 659 + } }, { "country": "ad", @@ -492,8 +461,7 @@ "bySignal": { "strongTld": 55, "sifaLocation": 1 - }, - "estimate": 56 + } }, { "country": "gl", @@ -504,8 +472,7 @@ }, "bySignal": { "strongTld": 44 - }, - "estimate": 44 + } }, { "country": "by", @@ -516,8 +483,7 @@ }, "bySignal": { "strongTld": 20 - }, - "estimate": 20 + } }, { "country": "hr", @@ -531,8 +497,7 @@ "strongTld": 16, "sifaLocation": 1, "companyDomain": 1 - }, - "estimate": 4613 + } }, { "country": "mt", @@ -543,8 +508,7 @@ }, "bySignal": { "strongTld": 18 - }, - "estimate": 73 + } }, { "country": "fo", @@ -555,8 +519,7 @@ }, "bySignal": { "strongTld": 16 - }, - "estimate": 16 + } }, { "country": "ba", @@ -567,8 +530,7 @@ }, "bySignal": { "strongTld": 15 - }, - "estimate": 15 + } }, { "country": "bg", @@ -582,8 +544,7 @@ "strongTld": 13, "sifaLocation": 1, "companyDomain": 1 - }, - "estimate": 6517 + } }, { "country": "cy", @@ -594,8 +555,7 @@ }, "bySignal": { "strongTld": 12 - }, - "estimate": 2378 + } }, { "country": "sm", @@ -606,8 +566,7 @@ }, "bySignal": { "strongTld": 4 - }, - "estimate": 4 + } }, { "country": "mc", @@ -618,8 +577,7 @@ }, "bySignal": { "strongTld": 1 - }, - "estimate": 1 + } } ], "method": { @@ -633,15 +591,15 @@ "se" ] }, - "peopleEstimate": 2832651, - "estimateMethod": { - "model": "K * \u03a3_lang authors(lang) * speakerWeight(cc|lang)", - "calibratedTo": "atproto.nl modelledNl", - "languageUsed": true, - "excludes": [ - "gb", - "ie" + "europeanInfra": { + "hosts": [ + { + "host": "eurosky.social", + "label": "Eurosky", + "accounts": 31379, + "reposTotal": 32305 + } ], - "note": "estimate layer only; language never confirms a country. English-first countries (gb, ie) are excluded (English untracked); es/pt under-covered." + "total": 31379 } } diff --git a/src/data/europe-stats.ts b/src/data/europe-stats.ts index 1218ec6..45fc0d4 100644 --- a/src/data/europe-stats.ts +++ b/src/data/europe-stats.ts @@ -44,9 +44,17 @@ export interface EuropeStats { excludes: string[]; note: string; }; + /** Accounts hosted on European sovereign PDS infrastructure (Eurosky and peers). A separate + * signal: hosting there means European, but not which country, so it is never folded into the + * per-country counts. Absent until eu-infra-pds.py has run. */ + europeanInfra?: { + total: number; + hosts: { host: string; label: string; accounts: number; reposTotal: number }[]; + }; } export const europeStats: EuropeStats = raw as EuropeStats; +export const europeanInfra = europeStats.europeanInfra; /** Countries ordered by what we can actually stand behind, not by headline total. * diff --git a/src/pages/[...lang]/countries.astro b/src/pages/[...lang]/countries.astro index 343370d..d633b44 100644 --- a/src/pages/[...lang]/countries.astro +++ b/src/pages/[...lang]/countries.astro @@ -4,6 +4,7 @@ import { localePaths, localeHref } from '../../i18n/paths'; import { getScope } from '../../config/scopes'; import { europeStats, byConfirmed, confirmedTotal, isWeakTldCountry, countryName, communityFor, citiesFor, + europeanInfra, } from '../../data/europe-stats'; import { existingCommunities } from '../../data/communities'; import EuropeFacts from '../../components/pages/EuropeFacts.astro'; @@ -78,6 +79,21 @@ const cityCommunities = existingCommunities.filter((c) => c.scope === 'city'); )} + + {europeanInfra && europeanInfra.total > 0 && ( +
+
+ On European infrastructure + {nf(europeanInfra.total)} + accounts on {europeanInfra.hosts.map((h) => h.label).join(', ')}, a European PDS +
+

+ Running on a European PDS like Eurosky is a deliberate choice for European data + sovereignty. It tells us an account is European, but not which country, so it is counted + here rather than under any country above. Most accounts are still on Bluesky's US hosting. +

+
+ )}
@@ -217,6 +233,14 @@ const cityCommunities = existingCommunities.filter((c) => c.scope === 'city'); .proportion-fill { height: 100%; background: var(--accent); border-radius: 999px; } .proportion figcaption { margin-top: var(--space-2); font-size: 0.8125rem; color: var(--muted); line-height: 1.5; max-width: 42em; } + .infra { + margin-top: var(--space-6); padding: var(--space-4) var(--space-5); + border: 1px solid var(--rule); border-radius: 10px; + } + .infra .num { margin-bottom: var(--space-3); } + .infra-fig { color: var(--brand); } + .infra-note { margin: 0; max-width: 46em; } + .ct-group-h { font-family: var(--mono); font-size: 0.8125rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted); font-weight: 500; margin: var(--space-6) 0 0;