Add data pipeline: fetch and parse Cheat mod DBs into JSON

Pulls items/perks/buffs/skills CSVs and command docs from
pryans/kcd2-cheat, parses the mod's CSV dialect (backslash-escaped
commas, multiline quoted fields) and BBCode command docs into
vendored JSON under src/data/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Homer
2026-07-13 09:06:53 +02:00
parent 04bf6066ac
commit e0a118f52b
8 changed files with 5076 additions and 0 deletions
+181
View File
@@ -0,0 +1,181 @@
// Regenerates src/data/*.json from the KCD2 "Cheat" mod repository
// (https://github.com/pryans/kcd2-cheat — source of the Nexus "Cheat" mod).
//
// Usage: node scripts/fetch-data.mjs
//
// Output is committed to the repo so builds are offline; re-run to refresh
// when the mod updates its item/perk/buff/skill dumps or command docs.
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const BASE = 'https://raw.githubusercontent.com/pryans/kcd2-cheat/main/docs/';
const OUT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'data');
async function fetchText(name) {
const res = await fetch(BASE + name);
if (!res.ok) throw new Error(`Fetching ${name} failed: HTTP ${res.status}`);
return (await res.text()).replaceAll('\r\n', '\n');
}
// The mod's CSV dialect: fields are double-quoted (header and empty trailing
// fields are bare), commas inside fields are backslash-escaped, and quoted
// fields may contain literal newlines.
function parseCsv(text) {
const rows = [];
let row = [];
let field = '';
let inQuotes = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (inQuotes) {
if (c === '\\' && i + 1 < text.length) {
field += text[++i];
} else if (c === '"') {
inQuotes = false;
} else {
field += c;
}
} else if (c === '"') {
inQuotes = true;
} else if (c === ',') {
row.push(field);
field = '';
} else if (c === '\n') {
row.push(field);
if (row.some((f) => f !== '')) rows.push(row);
row = [];
field = '';
} else {
field += c;
}
}
row.push(field);
if (row.some((f) => f !== '')) rows.push(row);
return rows.slice(1); // drop header
}
// Skill/perk descriptions are HTML-entity-encoded rich text; flatten to plain
// text with paragraph breaks.
function cleanText(s) {
return s
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&apos;', "'")
.replaceAll('&amp;', '&')
.replace(/<\/p>\s*<p>/g, '\n\n')
.replace(/<br\s*\/?>/g, '\n')
.replace(/<[^>]+>/g, '')
.trim();
}
const CATEGORY_RULES = [
[/money/, 'Money'],
[/item|inventory|stash|gear|stolen/, 'Items & Inventory'],
[/skill|stat/, 'Skills & Stats'],
[/perk/, 'Perks'],
[/buff|potion/, 'Buffs'],
[/horse/, 'Horse'],
[/teleport|_loc$|clip|phys_|checkpoint/, 'Movement & Teleport'],
[/npc|kill|spawn|charm|target|revive/, 'NPCs'],
[/time|weather|map/, 'World & Time'],
[/lockpick|pickpocket/, 'Minigames'],
[/hud|compass|statusbar|reticle|third_person|regen|wash|save|state/, 'Player & UI'],
[/action|alias|localization|find_/, 'Utility'],
];
function categorize(name) {
for (const [re, cat] of CATEGORY_RULES) if (re.test(name)) return cat;
return 'Misc';
}
function stripBbcode(s) {
return s.replace(/\[\/?(?:b|i|u|size|color|url)(?:=[^\]]*)?\]/g, '').trim();
}
// docs.txt: intro + command index, then one block per command:
// [size=4][b]name[/b][/size]
// description lines
// [b]Arguments:[/b] (optional section, tab-indented "arg: (required type) desc")
// [b]Examples:[/b] (optional section, tab-indented caption/command groups)
function parseCommandDocs(text) {
const version = text.match(/Cheat version ([\d.]+)/)?.[1] ?? null;
const commands = [];
const blockRe = /^\[size=4\]\[b\](\w+)\[\/b\]\[\/size\]$/gm;
const headers = [...text.matchAll(blockRe)];
for (let h = 0; h < headers.length; h++) {
const name = headers[h][1];
const start = headers[h].index + headers[h][0].length;
const end = h + 1 < headers.length ? headers[h + 1].index : text.length;
const lines = text.slice(start, end).split('\n');
let section = 'desc';
const descLines = [];
const args = [];
const examples = [];
let example = null;
for (const rawLine of lines) {
const line = rawLine.replace(/\s+$/, '');
if (/^\[b\]Arguments:\[\/b\]$/.test(line)) { section = 'args'; continue; }
if (/^\[b\]Examples:\[\/b\]$/.test(line)) { section = 'examples'; continue; }
if (section === 'desc') {
descLines.push(stripBbcode(line));
} else if (section === 'args') {
const m = line.match(/^\t(\w+): \((required|optional)(?: (\w+))?\)\s*(.*)$/);
if (m) args.push({ name: m[1], required: m[2] === 'required', type: m[3] ?? 'string', desc: m[4] });
} else if (section === 'examples') {
const t = line.replace(/^\t/, '');
if (t === '') {
if (example?.command) { examples.push(example); example = null; }
} else if (t.startsWith(name)) {
example ??= { caption: '' };
example.command = t;
} else {
example ??= { caption: '' };
example.caption = (example.caption ? example.caption + ' ' : '') + t.replace(/:$/, '');
}
}
}
if (example?.command) examples.push(example);
commands.push({
name,
desc: descLines.join('\n').replace(/\n{3,}/g, '\n\n').trim(),
args,
examples,
category: categorize(name),
source: 'cheatmod',
});
}
return { version, commands };
}
async function writeJson(name, data) {
await writeFile(join(OUT_DIR, name), JSON.stringify(data));
console.log(`${name}: ${Array.isArray(data) ? data.length : data.commands?.length ?? ''} records`);
}
await mkdir(OUT_DIR, { recursive: true });
const [itemsCsv, perksCsv, buffsCsv, skillsCsv, docsTxt] = await Promise.all(
['items.csv', 'perks.csv', 'buffs.csv', 'skills.csv', 'docs.txt'].map(fetchText),
);
const items = parseCsv(itemsCsv).map(([id, name, desc]) => ({ id, name, desc: cleanText(desc ?? '') }));
const perks = parseCsv(perksCsv).map(([id, name, desc]) => ({ id, name, desc: cleanText(desc ?? '') }));
const buffs = parseCsv(buffsCsv).map(([id, name, params, desc]) => ({ id, name, params: params ?? '', desc: cleanText(desc ?? '') }));
const skills = parseCsv(skillsCsv).map(([id, name, desc]) => ({ id, name, desc: cleanText(desc ?? '') }));
const { version, commands } = parseCommandDocs(docsTxt);
await writeJson('items.json', items);
await writeJson('perks.json', perks);
await writeJson('buffs.json', buffs);
await writeJson('skills.json', skills);
await writeJson('commands-mod.json', commands);
await writeJson('meta.json', {
source: 'https://github.com/pryans/kcd2-cheat',
cheatModDocsVersion: version,
fetchedAt: new Date().toISOString(),
});
+4889
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"source":"https://github.com/pryans/kcd2-cheat","cheatModDocsVersion":"2.18","fetchedAt":"2026-07-13T07:06:21.835Z"}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long