Skip to content

Instantly share code, notes, and snippets.

@capttwinky
Created September 26, 2025 21:23
Show Gist options
  • Save capttwinky/4b8a74dba1bfe460f3061620f40f820c to your computer and use it in GitHub Desktop.
Save capttwinky/4b8a74dba1bfe460f3061620f40f820c to your computer and use it in GitHub Desktop.
/**
* Catch List → Reports (Map-of-Maps)
*
* Input format (single string, possibly multiple lines):
* "✨CatcherA: Species X ✨ ✨CatcherB: Species Y ✨ ✨CatcherA: Species Z ✨ ..."
*
* Output (single JSON object):
* {
* "catch_count": { "<catcher>": <#caught>, ... },
* "caught_count": { "<species>": <#caught>, ... },
* "catchers_caught": { "<catcher>": ["<species>", ...] },
* "caught_catchers": { "<species>": ["<catcher>", ...] }
* }
*
* Run examples:
* npx ts-node --esm ./catch-report.ts catch_data.txt
* # or
* npx ts-node --compiler-options '{"module":"CommonJS"}' ./catch-report.ts catch_data.txt
* # or
* npx tsx ./catch-report.ts catch_data.txt
* # or
* deno run --allow-read ./catch-report.ts catch_data.txt
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { pathToFileURL } from "node:url";
/** Options to tweak how "caught" names are normalized */
interface NormalizeOptions {
/** Expand common regional shorthand: "Gal" → "Galarian", "His" → "Hisuian", "Alo" → "Alolan" */
expandRegionalPrefixes?: boolean;
/** Strip trailing gender markers like "(F)" or "(M)" when they are the final token */
stripGenderSuffix?: boolean;
/** Collapse internal whitespace to a single space */
collapseWhitespace?: boolean;
}
/** Default normalization behavior */
const DEFAULT_NORMALIZE: NormalizeOptions = {
expandRegionalPrefixes: true,
stripGenderSuffix: true,
collapseWhitespace: true,
};
/** A single parsed entry */
interface Entry {
catcher: string;
caughtRaw: string; // as-found
caught: string; // normalized
}
/** Basic whitespace cleanup */
function tidy(s: string): string {
return s.replace(/\s+/g, " ").trim();
}
/** Normalize "caught" names with configured options */
function normalizeCaught(raw: string, opts: NormalizeOptions): string {
let s = raw;
if (opts.collapseWhitespace) s = s.replace(/\s+/g, " ");
// Expand common regional prefixes ONLY if they appear as a leading word
if (opts.expandRegionalPrefixes) {
s = s.replace(/^Gal\s+/i, "Galarian ");
s = s.replace(/^His\s+/i, "Hisuian ");
s = s.replace(/^Alo\s+/i, "Alolan ");
}
// Strip trailing gender markers like "(F)" or "(M)" IF they are the final token
if (opts.stripGenderSuffix) {
s = s.replace(/\s+\((?:F|M)\)\s*$/i, "");
}
return s.trim();
}
/** Parse the big input string into (catcher, caught) pairs */
function parseEntries(input: string, norm: NormalizeOptions = DEFAULT_NORMALIZE): Entry[] {
const entries: Entry[] = [];
// Require one or more sparkles before each pair; consume them so they never enter the groups.
// 1 = catcher, 2 = caught (stop before next sparkle or end)
const pairRe = /✨+\s*([^:]+?)\s*:\s*([^✨]+?)(?=\s*✨|$)/gu;
let m: RegExpExecArray | null;
while ((m = pairRe.exec(input)) !== null) {
const catcher = tidy(m[1]).replace(/^✨+|\s+✨+$/g, "");
const caughtRaw = tidy(m[2]);
if (!catcher || !caughtRaw) continue;
const caught = normalizeCaught(caughtRaw, norm);
entries.push({ catcher, caughtRaw, caught });
}
return entries;
}
/** Build the four report maps and return a single map-of-maps object */
function buildReportMaps(entries: Entry[]) {
// (1) Catchers → number caught
const countsByCatcher = new Map<string, number>();
// (2) Species → number caught
const countsBySpecies = new Map<string, number>();
// (3) Catchers → list of caught (in capture order, duplicates allowed)
const listByCatcher = new Map<string, string[]>();
// (4) Species → list of unique catchers (first-seen order)
const catchersBySpecies = new Map<string, string[]>();
const pushUnique = (arr: string[], val: string) => {
if (!arr.includes(val)) arr.push(val);
};
for (const { catcher, caught } of entries) {
countsByCatcher.set(catcher, (countsByCatcher.get(catcher) ?? 0) + 1);
countsBySpecies.set(caught, (countsBySpecies.get(caught) ?? 0) + 1);
if (!listByCatcher.has(catcher)) listByCatcher.set(catcher, []);
listByCatcher.get(catcher)!.push(caught);
if (!catchersBySpecies.has(caught)) catchersBySpecies.set(caught, []);
pushUnique(catchersBySpecies.get(caught)!, catcher);
}
// Convert Maps → plain objects. For counts, we’ll insert in descending count order for readability.
const toObjectInOrder = <K extends string, V>(
entriesArr: Array<[K, V]>
): Record<K, V> => {
const out = {} as Record<K, V>;
for (const [k, v] of entriesArr) (out as any)[k] = v;
return out;
};
const catchCountObj = toObjectInOrder(
Array.from(countsByCatcher.entries()).sort(
(a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0])
)
);
const caughtCountObj = toObjectInOrder(
Array.from(countsBySpecies.entries()).sort(
(a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0])
)
);
// For lists, keep capture/first-seen order; sort keys alphabetically for easier scanning.
const catchersCaughtObj = toObjectInOrder(
Array.from(listByCatcher.entries()).sort((a, b) => a[0].localeCompare(b[0]))
);
const caughtCatchersObj = toObjectInOrder(
Array.from(catchersBySpecies.entries()).sort((a, b) => a[0].localeCompare(b[0]))
);
return {
catch_count: catchCountObj,
caught_count: caughtCountObj,
catchers_caught: catchersCaughtObj,
caught_catchers: caughtCatchersObj,
};
}
/** Print single JSON object to stdout */
function printReportMap(reportMap: ReturnType<typeof buildReportMaps>) {
console.log(JSON.stringify(reportMap, null, 2));
}
/** Main CLI */
async function main() {
const fileArg = process.argv[2];
let input = "";
if (fileArg) {
const p = path.resolve(process.cwd(), fileArg);
input = fs.readFileSync(p, "utf8");
} else {
// Read from STDIN
input = await new Promise<string>((resolve) => {
let data = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => (data += chunk));
process.stdin.on("end", () => resolve(data));
if (process.stdin.isTTY) {
console.error("Paste your input, then Ctrl+D (Linux/macOS) or Ctrl+Z Enter (Windows):");
}
});
}
const entries = parseEntries(input, DEFAULT_NORMALIZE);
const reportMap = buildReportMaps(entries);
printReportMap(reportMap);
}
/* ------------------------ ESM-safe "run if invoked" ------------------------ */
const invokedAsScript = (() => {
try {
return import.meta.url === pathToFileURL(process.argv[1]).href;
} catch {
// Some runtimes (older ts-node) may not support import.meta.url equality here; assume script.
return true;
}
})();
if (invokedAsScript) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
main();
}
/* Optionally export utilities for testing/importing elsewhere */
export {
parseEntries,
buildReportMaps,
DEFAULT_NORMALIZE,
type Entry,
type NormalizeOptions,
};
@capttwinky
Copy link
Author

capttwinky commented Sep 26, 2025

  1. ensure node, npm and tsx are available / installed
  2. copy shiny catch list from plugin
  3. paste the catch list into a file called 'catch_data.txt'
  4. run via npx tsx ./catch-report.ts catch_data.txt > catch_report.json

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment