Created
          September 26, 2025 21:23 
        
      - 
      
- 
        Save capttwinky/4b8a74dba1bfe460f3061620f40f820c to your computer and use it in GitHub Desktop. 
  
    
      This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
      Learn more about bidirectional Unicode characters
    
  
  
    
  | /** | |
| * 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, | |
| }; | 
  
    Sign up for free
    to join this conversation on GitHub.
    Already have an account?
    Sign in to comment
  
            
npx tsx ./catch-report.ts catch_data.txt > catch_report.json