Skip to content

Commit aa4451b

Browse files
committed
feat(game): stream validation for game metadata
1 parent ca37083 commit aa4451b

5 files changed

Lines changed: 326 additions & 40 deletions

File tree

README.md

Lines changed: 26 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,47 @@
11
# TechAPI
22

3-
> **Curated, open dataset for consumer electronics specs.** Free, share-alike, machine-readable.
3+
> Curated, open dataset for consumer electronics specifications and game metadata.
44
55
[![validate-data](https://github.com/GetTechAPI/TechAPI/actions/workflows/validate-data.yml/badge.svg)](https://github.com/GetTechAPI/TechAPI/actions/workflows/validate-data.yml)
6-
 Data: **CC-BY-SA 4.0**
6+
Data: **CC-BY-SA 4.0**
77

8-
This repo holds the curated **dataset** and the **public site** (Astro intro +
9-
playground). The API server, ingestion crawlers, coverage checks, and the
10-
static-dump generator live in
11-
[**TechEngine**](https://github.com/GetTechAPI/TechEngine).
8+
This repository owns the versioned dataset and the public Astro site. The API
9+
server, ingestion crawlers, coverage checks, and static-dump generator live in
10+
[TechEngine](https://github.com/GetTechAPI/TechEngine), included here as the
11+
`TechEngine` submodule.
1212

1313
## Layout
1414

1515
```text
16-
data/brand/<country>/<slug>.json # e.g. data/brand/kr/samsung.json
17-
data/soc/<manufacturer>/<year>/<slug>.json # data/soc/qualcomm/2024/snapdragon-8-elite.json
18-
data/smartphone/<brand>/<year>/<slug>.json # data/smartphone/samsung/2025/galaxy-s25.json
19-
data/gpu/<manufacturer>/<year>/<segment>/<slug>.json # data/gpu/nvidia/2025/consumer/geforce-rtx-5090.json
20-
data/cpu/<manufacturer>/<year>/<segment>/<slug>.json # data/cpu/intel/2023/consumer/core-i9-14900k.json
16+
data/brand/<country>/<slug>.json
17+
data/cpu/<manufacturer>/<year>/<segment>/<slug>.json
18+
data/gpu/<manufacturer>/<year>/<segment>/<slug>.json
19+
data/smartphone/<brand>/<year>/<slug>.json
20+
data/game/<slug[:2]>/<slug>.json
21+
site/ # Astro Pages site
22+
site/public/v1/ # committed static API dump from TechEngine
23+
app/validate.py # stdlib-only, fast repository self-check
2124
```
2225

23-
All paths use singular folder names. Slugs are kebab-case and unique within each category.
26+
The game directory is sharded by normalized slug rather than genre or import
27+
batch. See [the data layout guide](docs/DATA_LAYOUT.md) before adding or moving
28+
game records.
2429

25-
The Astro site lives under `site/` and is the deploy target for GitHub Pages. It
26-
consumes the static JSON dump produced by TechEngine's `weekly-refresh` workflow.
27-
28-
## Self-Check
29-
30-
A lightweight bundled validator lives at `app/validate.py`. It runs on every PR via
31-
[`validate-data.yml`](.github/workflows/validate-data.yml) and is also chained into
32-
the heavier TechEngine validation workflow as a downstream job.
30+
## Local workflow
3331

3432
```bash
3533
python -m app.validate
34+
cd site && npm ci && npm run build
3635
```
3736

38-
The validator uses only the Python standard library; no install step required.
39-
40-
## Contributing
37+
Use TechEngine for ingest, API, and dump generation. Do not change its code or
38+
submodule pointer as part of a data-only change. The committed
39+
`site/public/v1/` dump is deployed by GitHub Pages; its contents must remain in
40+
the repository even when it is too large to browse comfortably locally.
4141

42-
Open a PR with the new/updated JSON file. The PR template walks through what to
43-
include. The validator must pass. All records (`brand`, `soc`, `smartphone`,
44-
`gpu`, and `cpu`) must include `source_urls` with at least one canonical
45-
reference (vendor product page, Wikipedia infobox, datasheet).
42+
For a focused workflow and the boundary between this repository and TechEngine,
43+
see [docs/LOCAL_WORKFLOW.md](docs/LOCAL_WORKFLOW.md).
4644

4745
## License
4846

49-
Data is licensed **CC-BY-SA 4.0**; attribute "Data from TechAPI" and share alike.
50-
The bundled validator code is [MIT](LICENSE).
47+
Data is licensed **CC-BY-SA 4.0**. The bundled validator is [MIT](LICENSE).

app/validate.py

Lines changed: 144 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77

88
from __future__ import annotations
99

10+
import argparse
1011
import json
12+
import os
1113
import re
1214
import sys
1315
from pathlib import Path
@@ -112,6 +114,29 @@
112114
"verified",
113115
}
114116

117+
GAME_LIST_FIELDS = (
118+
"platforms",
119+
"genres",
120+
"stores",
121+
"developers",
122+
"publishers",
123+
"tags",
124+
)
125+
126+
GAME_ESRB_RATINGS = {
127+
"Everyone",
128+
"Everyone 10+",
129+
"Teen",
130+
"Mature",
131+
"Adults Only",
132+
"Rating Pending",
133+
}
134+
135+
136+
def _game_shard(slug: str) -> str:
137+
"""Return the stable on-disk shard for a game slug."""
138+
return slug[:2]
139+
115140
SOFTWARE_REQUIRED = {
116141
"slug",
117142
"name",
@@ -159,6 +184,37 @@ def _check_date(name: str, value: object, errors: list[str]) -> None:
159184
errors.append(f"{name}: release_date '{value}' must be ISO 8601 YYYY-MM-DD (§14.2)")
160185

161186

187+
def _check_title(name: str, value: object, errors: list[str]) -> None:
188+
if not isinstance(value, str) or not value.strip():
189+
errors.append(f"{name}: name must be a non-empty string")
190+
191+
192+
def _check_bool(name: str, field: str, value: object, errors: list[str]) -> None:
193+
if not isinstance(value, bool):
194+
errors.append(f"{name}: {field} must be a boolean")
195+
196+
197+
def _check_string_list(name: str, field: str, value: object, errors: list[str]) -> None:
198+
"""Validate optional game classification lists without constraining vocabulary."""
199+
if value is None:
200+
return
201+
if not isinstance(value, list) or not all(
202+
isinstance(item, str) and item.strip() for item in value
203+
):
204+
errors.append(f"{name}: {field} must be a list of non-empty strings")
205+
return
206+
normalized = [item.casefold() for item in value]
207+
if len(set(normalized)) != len(normalized):
208+
errors.append(f"{name}: {field} contains duplicate values")
209+
210+
211+
def _check_optional_url(name: str, field: str, value: object, errors: list[str]) -> None:
212+
if value is not None and (
213+
not isinstance(value, str) or not value.startswith(("http://", "https://"))
214+
):
215+
errors.append(f"{name}: {field} must be an http(s) URL when present")
216+
217+
162218
def _check_unique_slugs(
163219
category: str, records: list[tuple[str, dict[str, Any]]], errors: list[str]
164220
) -> None:
@@ -230,6 +286,83 @@ def _check_variant_path(
230286
errors.append(f"{fname}: filename must match slug '{rec.get('slug')}'")
231287

232288

289+
def _check_game_path(fname: str, rec: dict[str, Any], errors: list[str]) -> None:
290+
"""Keep imports in the documented ``game/<shard>/<slug>.json`` layout."""
291+
parts = Path(fname).parts
292+
slug = rec.get("slug")
293+
if not isinstance(slug, str):
294+
return
295+
if len(parts) != 3:
296+
errors.append(f"{fname}: game records must live at 'game/<shard>/<slug>.json'")
297+
return
298+
_, shard, filename = parts
299+
expected_shard = _game_shard(slug)
300+
if shard != expected_shard:
301+
errors.append(
302+
f"{fname}: lives in shard '{shard}' but slug '{slug}' belongs in '{expected_shard}'"
303+
)
304+
if filename != f"{slug}.json":
305+
errors.append(f"{fname}: filename must match slug '{slug}'")
306+
307+
308+
def _validate_game_record(fname: str, rec: dict[str, Any], errors: list[str]) -> None:
309+
_check_required(fname, rec, GAME_REQUIRED, errors)
310+
_check_source_urls(fname, rec, errors)
311+
_check_slug(fname, rec.get("slug"), errors)
312+
_check_title(fname, rec.get("name"), errors)
313+
_check_bool(fname, "verified", rec.get("verified"), errors)
314+
if rec.get("release_date") is not None:
315+
_check_date(fname, rec["release_date"], errors)
316+
if rec.get("rating") is not None:
317+
_check_range(fname, "rating", rec.get("rating"), 0, 5, errors)
318+
if rec.get("rating_count") is not None:
319+
_check_range(fname, "rating_count", rec.get("rating_count"), 0, 2_000_000_000, errors)
320+
if rec.get("metacritic") is not None:
321+
_check_range(fname, "metacritic", rec.get("metacritic"), 0, 100, errors)
322+
if rec.get("playtime_hours") is not None:
323+
_check_range(fname, "playtime_hours", rec.get("playtime_hours"), 0, 100_000, errors)
324+
for field in GAME_LIST_FIELDS:
325+
_check_string_list(fname, field, rec.get(field), errors)
326+
esrb_rating = rec.get("esrb_rating")
327+
if esrb_rating is not None and esrb_rating not in GAME_ESRB_RATINGS:
328+
errors.append(f"{fname}: esrb_rating '{esrb_rating}' not in {sorted(GAME_ESRB_RATINGS)}")
329+
_check_optional_url(fname, "background_image", rec.get("background_image"), errors)
330+
_check_game_path(fname, rec, errors)
331+
332+
333+
def validate_games(data_dir: Path | None = None) -> list[str]:
334+
"""Stream game records so the import-scale dataset is not retained in memory."""
335+
errors: list[str] = []
336+
root = (data_dir or DATA_DIR) / "game"
337+
if not root.exists():
338+
return errors
339+
340+
seen: dict[str, str] = {}
341+
for directory, directories, filenames in os.walk(root):
342+
directories.sort()
343+
for filename in sorted(filenames):
344+
if not filename.endswith(".json"):
345+
continue
346+
path = Path(directory) / filename
347+
fname = str(path.relative_to(data_dir or DATA_DIR))
348+
try:
349+
rec = json.loads(path.read_text(encoding="utf-8-sig"))
350+
except (OSError, json.JSONDecodeError) as exc:
351+
errors.append(f"{fname}: invalid JSON ({exc})")
352+
continue
353+
if not isinstance(rec, dict):
354+
errors.append(f"{fname}: game record must be a JSON object")
355+
continue
356+
slug = rec.get("slug")
357+
if isinstance(slug, str):
358+
if slug in seen:
359+
errors.append(f"{fname}: duplicate game slug '{slug}' (also in {seen[slug]})")
360+
else:
361+
seen[slug] = fname
362+
_validate_game_record(fname, rec, errors)
363+
return errors
364+
365+
233366
def validate() -> list[str]:
234367
errors: list[str] = []
235368

@@ -416,15 +549,7 @@ def validate() -> list[str]:
416549
_check_variant_path(fname, rec, "monitor", errors, allow_flat=True)
417550

418551
for fname, rec in games:
419-
_check_required(fname, rec, GAME_REQUIRED, errors)
420-
_check_source_urls(fname, rec, errors)
421-
_check_slug(fname, rec.get("slug"), errors)
422-
if rec.get("release_date") is not None:
423-
_check_date(fname, rec["release_date"], errors)
424-
if rec.get("rating") is not None:
425-
_check_range(fname, "rating", rec.get("rating"), 0, 5, errors)
426-
if rec.get("metacritic") is not None:
427-
_check_range(fname, "metacritic", rec.get("metacritic"), 0, 100, errors)
552+
_validate_game_record(fname, rec, errors)
428553

429554
for fname, rec in software:
430555
_check_required(fname, rec, SOFTWARE_REQUIRED, errors)
@@ -436,12 +561,20 @@ def validate() -> list[str]:
436561
return errors
437562

438563

439-
def run() -> int:
564+
def run(argv: list[str] | None = None) -> int:
565+
parser = argparse.ArgumentParser(description="Validate TechAPI record data")
566+
parser.add_argument(
567+
"--category",
568+
choices=("all", "game"),
569+
default="all",
570+
help="validate all records (default) or stream the game dataset only",
571+
)
572+
args = parser.parse_args(argv)
440573
try:
441574
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[union-attr]
442575
except Exception:
443576
pass
444-
errors = validate()
577+
errors = validate_games() if args.category == "game" else validate()
445578
if errors:
446579
print(f"❌ Data validation failed ({len(errors)} issue(s)):")
447580
for err in errors:

docs/DATA_LAYOUT.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Data layout
2+
3+
Versioned records stay in `data/`; the directory name is the record family and
4+
the JSON file is the individual record. Do not group records by a transient
5+
import run or by a scraper name.
6+
7+
## Games
8+
9+
Game records use a deterministic shard:
10+
11+
```
12+
data/game/<slug first two characters>/<slug>.json
13+
```
14+
15+
For example, `hades` belongs at `data/game/ha/hades.json`. This keeps a
16+
large import stable and prevents one directory from becoming a bottleneck.
17+
18+
## Local checkout policy
19+
20+
The default checkout deliberately excludes `data/` and generated `site/`
21+
content. They remain tracked and are still present on the remote; omitting
22+
them only prevents routine Git polling from scanning millions of files.
23+
24+
For a focused data import, temporarily add only the family you need:
25+
26+
```
27+
git sparse-checkout add --cone data/cpu
28+
```
29+
30+
For a full game import, add `data/game` for the duration of the import, then
31+
return to the small default checkout:
32+
33+
```
34+
git sparse-checkout add --cone data/game
35+
git sparse-checkout set --cone .github app docs TechEngine
36+
```
37+
38+
Never use `assume-unchanged` or `skip-worktree` as an import workflow. Run
39+
`python -m app.validate` only while the needed data family is checked out.
40+
For the million-record game import, use the streaming validator:
41+
42+
```
43+
python -m app.validate --category game
44+
```

docs/LOCAL_WORKFLOW.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Local workflow
2+
3+
## Ownership boundary
4+
5+
TechAPI owns versioned record files, the small standard-library validator, and
6+
the Astro Pages site. TechEngine owns ingestion, the API service, schemas,
7+
database models, dump generation, and heavy validation. Keep a schema change
8+
coordinated across both repositories; do not modify the `TechEngine` submodule
9+
pointer for an ordinary data cleanup.
10+
11+
## Game records
12+
13+
Games are individual JSON files under `data/game/<shard>/`. The shard is the
14+
first two characters of `slug` (or the whole slug when it has one character).
15+
The layout is intentionally independent of genre, platform, developer, and
16+
import source so that imports stay stable and directories stay bounded.
17+
18+
Before an import:
19+
20+
1. Put each record in its slug shard.
21+
2. Keep `slug`, `name`, `source_urls`, and `verified` present.
22+
3. Run `python -m app.validate`.
23+
4. Generate or verify the static dump through TechEngine when the API output
24+
needs to change.
25+
26+
## Generated files
27+
28+
- `site/public/v1/` is committed API output used by GitHub Pages; do not delete
29+
it as a local cleanup shortcut.
30+
- `site/public/v1/history.json` and `verification.json` are build outputs and
31+
are ignored.
32+
- `.claude/archive/` contains local historical work notes and is ignored.
33+
- Python caches (`.pytest_cache`, `.mypy_cache`, `.ruff_cache`) are disposable.

0 commit comments

Comments
 (0)