Skip to content

Temporary: scan recent WCA assignment data #1

Temporary: scan recent WCA assignment data

Temporary: scan recent WCA assignment data #1

name: Temporary WCA assignment scan v2
on:
pull_request:
paths:
- .github/workflows/wca-assignment-scan-v2.yml
permissions:
contents: read
jobs:
scan:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Scan public WCIF assignment data
shell: python
run: |
import csv, json, os, time, urllib.error, urllib.parse, urllib.request
from collections import Counter
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import date
START = date.fromisoformat("2026-04-30")
END = date.fromisoformat("2026-07-30")
API = "https://www.worldcubeassociation.org/api/v0"
HEADERS = {"User-Agent": "competitiongroups.com assignment usage analysis"}
OUT = "assignment-scan-v2"
os.makedirs(OUT, exist_ok=True)
def get_json(url, attempts=4):
last = None
for attempt in range(attempts):
try:
request = urllib.request.Request(url, headers=HEADERS)
with urllib.request.urlopen(request, timeout=30) as response:
return json.loads(response.read().decode(response.headers.get_content_charset() or "utf-8"))
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as error:
last = error
if getattr(error, "code", None) in (404, 422):
raise
time.sleep(min(2 ** attempt, 8))
raise last
competitions = []
page_signatures = set()
for page in range(1, 101):
query = urllib.parse.urlencode({"start": START.isoformat(), "end": END.isoformat(), "page": page})
batch = get_json(f"{API}/competitions?{query}")
if not batch:
break
signature = tuple(item["id"] for item in batch)
if signature in page_signatures:
print(f"Duplicate page detected at page {page}; stopping pagination")
break
page_signatures.add(signature)
competitions.extend(batch)
print(f"Competition page {page}: {len(batch)}", flush=True)
if len(batch) < 25:
break
else:
raise RuntimeError("Competition pagination exceeded 100 pages")
competitions = {
item["id"]: item for item in competitions
if START <= date.fromisoformat(item["start_date"]) <= END
}
competitions = sorted(competitions.values(), key=lambda item: (item["start_date"], item["id"]))
print(f"Competitions in final date range: {len(competitions)}", flush=True)
def scan(item):
cid = item["id"]
row = {
"competition_id": cid,
"name": item.get("name", ""),
"start_date": item.get("start_date", ""),
"end_date": item.get("end_date", ""),
"country_iso2": item.get("country_iso2", ""),
"city": item.get("city", ""),
"cancelled": bool(item.get("cancelled_at")),
"persons_total": 0,
"persons_with_assignments": 0,
"assignment_count": 0,
"competitor_assignment_count": 0,
"staff_assignment_count": 0,
"other_assignment_count": 0,
"assignment_codes": "",
"has_person_assignments": False,
"has_competitor_assignments": False,
"has_staff_assignments": False,
"wcif_status": "ok",
"inference": "no assignment data",
"wca_url": item.get("url") or f"https://www.worldcubeassociation.org/competitions/{cid}",
"competitiongroups_url": f"https://www.competitiongroups.com/competitions/{cid}",
}
try:
wcif = get_json(f"{API}/competitions/{cid}/wcif/public")
persons = wcif.get("persons") or []
row["persons_total"] = len(persons)
codes = Counter()
for person in persons:
assignments = person.get("assignments") or []
row["persons_with_assignments"] += bool(assignments)
for assignment in assignments:
codes[assignment.get("assignmentCode") or "unknown"] += 1
row["assignment_count"] = sum(codes.values())
row["competitor_assignment_count"] = codes.get("competitor", 0)
row["staff_assignment_count"] = sum(v for k, v in codes.items() if k.startswith("staff-"))
row["other_assignment_count"] = row["assignment_count"] - row["competitor_assignment_count"] - row["staff_assignment_count"]
row["assignment_codes"] = ";".join(f"{k}:{v}" for k, v in sorted(codes.items()))
row["has_person_assignments"] = row["assignment_count"] > 0
row["has_competitor_assignments"] = row["competitor_assignment_count"] > 0
row["has_staff_assignments"] = row["staff_assignment_count"] > 0
if row["has_person_assignments"]:
row["inference"] = "potential Competition Groups user: assignment data present"
except urllib.error.HTTPError as error:
row["wcif_status"] = f"http_{error.code}"
except Exception as error:
row["wcif_status"] = f"error:{type(error).__name__}"
return row
rows = []
with ThreadPoolExecutor(max_workers=20) as executor:
futures = [executor.submit(scan, item) for item in competitions]
for index, future in enumerate(as_completed(futures), 1):
rows.append(future.result())
if index % 50 == 0 or index == len(futures):
print(f"Scanned {index}/{len(futures)}", flush=True)
rows.sort(key=lambda item: (item["start_date"], item["competition_id"]))
assignment_rows = [row for row in rows if row["has_person_assignments"]]
fields = list(rows[0]) if rows else []
for filename, output_rows in (("all_competitions.csv", rows), ("competitions_with_assignments.csv", assignment_rows)):
with open(f"{OUT}/{filename}", "w", newline="", encoding="utf-8") as output:
writer = csv.DictWriter(output, fieldnames=fields)
writer.writeheader(); writer.writerows(output_rows)
summary = {
"date_window": {"start": START.isoformat(), "end": END.isoformat(), "basis": "competition start_date"},
"competitions_total": len(rows),
"competitions_cancelled": sum(row["cancelled"] for row in rows),
"competitions_with_assignments": len(assignment_rows),
"assignment_coverage_percent": round(100 * len(assignment_rows) / len(rows), 2) if rows else 0,
"competitions_with_competitor_assignments": sum(row["has_competitor_assignments"] for row in rows),
"competitions_with_staff_assignments": sum(row["has_staff_assignments"] for row in rows),
"total_person_assignments": sum(row["assignment_count"] for row in rows),
"wcif_failures": dict(Counter(row["wcif_status"] for row in rows if row["wcif_status"] != "ok")),
"by_country": dict(Counter(row["country_iso2"] for row in assignment_rows).most_common()),
"by_month": dict(sorted(Counter(row["start_date"][:7] for row in assignment_rows).items())),
}
with open(f"{OUT}/summary.json", "w", encoding="utf-8") as output:
json.dump(summary, output, indent=2, ensure_ascii=False)
print(json.dumps(summary, indent=2), flush=True)
- name: Upload scan results
uses: actions/upload-artifact@v4
with:
name: wca-assignment-scan-v2-2026-07-30
path: assignment-scan-v2/
retention-days: 7