Phase 7: structured ruleset data (Open5e 5e + Foundry PF2e classes)

- Scrapers scripts/fetch_open5e.ts (classes/races/backgrounds/feats, CC-BY/OGL) and
  fetch_foundry_pf2e.ts (25 PF2e classes, OGL/ORC) → normalized JSON in src/data/srd
  + public/data/pf2e/classes.json.
- src/lib/ruleset/normalize.ts: pure, tested normalizers → unified RulesetClass
  (hit die, key abilities, saves/ranks, perception, skills, caster, subclasses, profs).
- compendium loaders (loadClasses/loadRaces5e/loadBackgrounds5e) + a "Classes" tab for
  both systems (ClassDetail) with full stats; Bestiary stays the default category.
- Data-source attribution in Settings. Feeds the Phase 5 builder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 15:23:25 +02:00
parent f61fabadb5
commit e1ba3fe1b9
14 changed files with 366 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
/* Scrape Foundry VTT pf2e class packs (OGL/ORC + Paizo Community Use) →
* normalized JSON in public/data/pf2e/classes.json.
* Run: bunx tsx scripts/fetch_foundry_pf2e.ts */
import { writeFileSync } from 'node:fs';
import { normalizeFoundryClass, type FoundryClass } from '../src/lib/ruleset/normalize';
const LIST = 'https://api.github.com/repos/foundryvtt/pf2e/contents/packs/classes?ref=master';
async function main() {
const res = await fetch(LIST, { headers: { 'User-Agent': 'ttrpg-manager-scraper', Accept: 'application/vnd.github+json' } });
if (!res.ok) throw new Error(`list: ${res.status}`);
const files = (await res.json()) as { name: string; download_url: string }[];
const classFiles = files.filter((f) => f.name.endsWith('.json'));
const out = [];
for (const f of classFiles) {
const raw = (await (await fetch(f.download_url)).json()) as FoundryClass;
out.push(normalizeFoundryClass(f.name.replace(/\.json$/, ''), raw));
}
out.sort((a, b) => a.name.localeCompare(b.name));
writeFileSync('public/data/pf2e/classes.json', JSON.stringify(out));
console.log(`pf2e: classes ${out.length} (${out.map((c) => c.name).join(', ')})`);
}
main().catch((e) => { console.error(e); process.exit(1); });
+48
View File
@@ -0,0 +1,48 @@
/* Scrape Open5e (SRD 5.1, CC-BY-4.0 / OGL) → normalized JSON in src/data/srd/.
* Run: bunx tsx scripts/fetch_open5e.ts */
import { writeFileSync } from 'node:fs';
import { normalizeOpen5eClass, type Open5eClass } from '../src/lib/ruleset/normalize';
const BASE = 'https://api.open5e.com/v1';
async function all<T = Record<string, unknown>>(path: string): Promise<T[]> {
let url: string | null = `${BASE}/${path}/?limit=500`;
const out: T[] = [];
while (url) {
const r = await fetch(url);
if (!r.ok) throw new Error(`${path}: ${r.status}`);
const j = (await r.json()) as { results: T[]; next: string | null };
out.push(...j.results);
url = j.next;
}
return out;
}
const first = (s: string | undefined): string => {
const p = (s ?? '').replace(/[#*_`>]/g, '').split('\n').map((x) => x.trim()).filter(Boolean)[0] ?? '';
return p.length > 500 ? `${p.slice(0, 497)}` : p;
};
async function main() {
const classes = (await all<Open5eClass>('classes')).map(normalizeOpen5eClass).sort((a, b) => a.name.localeCompare(b.name));
writeFileSync('src/data/srd/classes.json', JSON.stringify(classes));
const races = (await all<Record<string, string>>('races')).map((r) => ({
slug: r.slug, name: r.name, desc: first(r.desc), asi: r.asi_desc ?? '', speed: r.speed_desc ?? '', vision: r.vision ?? '', traits: first(r.traits),
})).sort((a, b) => a.name.localeCompare(b.name));
writeFileSync('src/data/srd/races.json', JSON.stringify(races));
const backgrounds = (await all<Record<string, string>>('backgrounds')).map((b) => ({
slug: b.slug, name: b.name, desc: first(b.desc), skills: b.skill_proficiencies ?? '', tools: b.tool_proficiencies ?? '', languages: b.languages ?? '', feature: b.feature ?? '',
})).sort((a, b) => a.name.localeCompare(b.name));
writeFileSync('src/data/srd/backgrounds.json', JSON.stringify(backgrounds));
const feats = (await all<Record<string, string>>('feats')).map((f) => ({
slug: f.slug, name: f.name, desc: first(f.desc), prerequisite: f.prerequisite ?? '',
})).sort((a, b) => a.name.localeCompare(b.name));
writeFileSync('src/data/srd/feats.json', JSON.stringify(feats));
console.log(`5e: classes ${classes.length}, races ${races.length}, backgrounds ${backgrounds.length}, feats ${feats.length}`);
}
main().catch((e) => { console.error(e); process.exit(1); });