|
#!/usr/bin/env python3 |
|
"""Count special characters in unique Registry player names.""" |
|
|
|
from __future__ import annotations |
|
|
|
import argparse |
|
import json |
|
from collections import Counter |
|
from pathlib import Path |
|
from typing import Any, Iterator |
|
from urllib.parse import urlencode |
|
from urllib.request import Request, urlopen |
|
|
|
|
|
API_URL = "https://api.registry.bf2.co/v1/players" |
|
PER_PAGE = 1000 |
|
TIMEOUT_SECONDS = 30 |
|
|
|
GROUPS_BY_PROVIDER = { |
|
"bf2hub": ( |
|
"bf2hub_gamespy", |
|
"bf2hub_native", |
|
), |
|
"playbf2": ( |
|
"playbf2_gamespy", |
|
"playbf2_native", |
|
), |
|
"openspy": ("openspy",), |
|
"b2bf2": ("b2bf2",), |
|
"gameppy": ("gameppy",), |
|
} |
|
|
|
REPORT_GROUPS = ( |
|
("GameSpy", ("bf2hub_gamespy", "playbf2_gamespy")), |
|
("BF2Hub", ("bf2hub_native",)), |
|
("PlayBF2", ("playbf2_native",)), |
|
("OpenSpy", ("openspy",)), |
|
("B2BF2", ("b2bf2",)), |
|
("Gameppy", ("gameppy",)), |
|
) |
|
|
|
|
|
def fetch_provider(provider: str, api_url: str = API_URL) -> Iterator[Any]: |
|
"""Yield API player records for one provider, following the ID cursor.""" |
|
after = "" |
|
while True: |
|
params = {"provider": provider, "perPage": PER_PAGE} |
|
if after: |
|
params["after"] = after |
|
request = Request( |
|
f"{api_url}?{urlencode(params)}", |
|
headers={"User-Agent": "player-name-special-character-report"}, |
|
) |
|
with urlopen(request, timeout=TIMEOUT_SECONDS) as response: |
|
page = json.loads(response.read()) |
|
|
|
if not isinstance(page, dict): |
|
raise ValueError(f"Registry returned an invalid page for {provider}") |
|
players = page.get("players") |
|
has_more = page.get("hasMore") |
|
if not isinstance(players, list) or not isinstance(has_more, bool): |
|
raise ValueError(f"Registry returned an invalid page for {provider}") |
|
|
|
yield from players |
|
if not has_more: |
|
return |
|
if not players or not isinstance(players[-1], dict): |
|
raise ValueError(f"Registry returned no pagination cursor for {provider}") |
|
|
|
next_after = players[-1].get("id") |
|
if not isinstance(next_after, str) or not next_after or next_after == after: |
|
raise ValueError(f"Registry returned an invalid pagination cursor for {provider}") |
|
after = next_after |
|
|
|
|
|
def group_key(provider: str, pid: int) -> str: |
|
if provider == "bf2hub": |
|
return "bf2hub_gamespy" if pid < 500_000_000 else "bf2hub_native" |
|
if provider == "playbf2": |
|
return "playbf2_gamespy" if pid < 480_000_000 else "playbf2_native" |
|
return GROUPS_BY_PROVIDER[provider][0] |
|
|
|
|
|
def special_characters(name: str) -> set[str]: |
|
return { |
|
character |
|
for character in name |
|
if not (character.isascii() and character.isalnum()) |
|
} |
|
|
|
|
|
def count_provider(provider: str, api_url: str = API_URL) -> dict[str, Counter[str]]: |
|
counts = { |
|
key: Counter() |
|
for key in GROUPS_BY_PROVIDER[provider] |
|
} |
|
seen_names = { |
|
key: set() |
|
for key in GROUPS_BY_PROVIDER[provider] |
|
} |
|
|
|
for player in fetch_provider(provider, api_url): |
|
if not isinstance(player, dict): |
|
raise ValueError(f"Registry returned an invalid player for {provider}") |
|
name = player.get("nick") |
|
pid = player.get("pid") |
|
if not isinstance(name, str) or not isinstance(pid, int) or isinstance(pid, bool): |
|
raise ValueError(f"Registry returned an invalid player for {provider}") |
|
|
|
key = group_key(provider, pid) |
|
if name in seen_names[key]: |
|
continue |
|
seen_names[key].add(name) |
|
counts[key].update(special_characters(name)) |
|
|
|
return counts |
|
|
|
|
|
def markdown_character(character: str) -> str: |
|
display = character if character.isprintable() else f"U+{ord(character):04X}" |
|
display = display.replace("\\", "\\\\").replace("|", "\\|") |
|
return f"{display} (U+{ord(character):04X})" |
|
|
|
|
|
def render_markdown(counts: dict[str, Counter[str]]) -> str: |
|
characters = set().union(*(counter.keys() for counter in counts.values())) |
|
ordered = sorted( |
|
characters, |
|
key=lambda character: ( |
|
-sum( |
|
counts[key][character] |
|
for _label, source_keys in REPORT_GROUPS |
|
for key in source_keys |
|
), |
|
character, |
|
), |
|
) |
|
|
|
headers = ["Character", *(label for label, _source_keys in REPORT_GROUPS), "Total"] |
|
lines = [ |
|
"| " + " | ".join(headers) + " |", |
|
"| " + " | ".join(":---" for _ in headers) + " |", |
|
] |
|
for character in ordered: |
|
values = [ |
|
sum(counts[key][character] for key in source_keys) |
|
for _label, source_keys in REPORT_GROUPS |
|
] |
|
total = sum(values) |
|
lines.append( |
|
"| " |
|
+ " | ".join( |
|
[markdown_character(character), *(str(value) for value in values), str(total)] |
|
) |
|
+ " |" |
|
) |
|
|
|
return ( |
|
"# Special characters used in Battlefield 2 player names\n\n" |
|
"Table shows the number of unique player names containing each character. " |
|
"Names are deduplicated within each provider and PID group.\n" |
|
"Players on BF2Hub and PlayBF2 with original GameSpy PIDs are counted in the GameSpy column, " |
|
"not in the BF2Hub or PlayBF2 columns.\n\n" |
|
+ "\n".join(lines) |
|
+ "\n" |
|
) |
|
|
|
|
|
def main() -> None: |
|
parser = argparse.ArgumentParser( |
|
description="Count special characters in Registry player names." |
|
) |
|
parser.add_argument( |
|
"-o", |
|
"--output", |
|
type=Path, |
|
default=Path("special-character-table.md"), |
|
help="Markdown output path (default: special-character-table.md)", |
|
) |
|
args = parser.parse_args() |
|
|
|
all_counts: dict[str, Counter[str]] = {} |
|
for provider in GROUPS_BY_PROVIDER: |
|
all_counts.update(count_provider(provider)) |
|
|
|
args.output.parent.mkdir(parents=True, exist_ok=True) |
|
args.output.write_text(render_markdown(all_counts), encoding="utf-8") |
|
print(f"Wrote character counts to {args.output}") |
|
|
|
|
|
if __name__ == "__main__": |
|
main() |