#!/usr/bin/env node // Extract unique grid patterns from NYT crosswords JSON and xd puzzle files // Output: one file per size (grids_15x15.txt, grids_21x21.txt) // Format: each grid is lines of '#' (black) and '.' (white), separated by blank lines const fs = require('fs') const path = require('path') const grids = new Map() // size -> Set of pattern strings function addGrid(width, height, pattern) { // only keep 15x15 and 21x21 if (!((width === 15 && height === 15) || (width === 21 && height === 21))) return const key = `${width}x${height}` if (!grids.has(key)) grids.set(key, new Set()) grids.get(key).add(pattern) } function gridToPattern(grid, width, height) { const lines = [] for (let r = 0; r < height; r++) { let line = '' for (let c = 0; c < width; c++) { const cell = grid[r * width + c] line += (cell === '.' || cell === '#') ? '#' : '.' } lines.push(line) } return lines.join('\n') } // validate pattern: every white cell in runs >= 3 both directions, connected function isValidPattern(pattern, width, height) { const cells = pattern.split('\n').map(l => l.split('')) // check runs >= 3 in both directions for every white cell for (let r = 0; r < height; r++) { for (let c = 0; c < width; c++) { if (cells[r][c] !== '.') continue // across run length let as_ = c, ae = c while (as_ > 0 && cells[r][as_-1] === '.') as_-- while (ae < width-1 && cells[r][ae+1] === '.') ae++ if (ae - as_ + 1 < 3) return false // down run length let ds = r, de = r while (ds > 0 && cells[ds-1][c] === '.') ds-- while (de < height-1 && cells[de+1][c] === '.') de++ if (de - ds + 1 < 3) return false } } // connectivity check (BFS) let startR = -1, startC = -1, whiteCount = 0 for (let r = 0; r < height && startR < 0; r++) for (let c = 0; c < width && startR < 0; c++) if (cells[r][c] === '.') { startR = r; startC = c } if (startR < 0) return false for (let r = 0; r < height; r++) for (let c = 0; c < width; c++) if (cells[r][c] === '.') whiteCount++ const visited = Array.from({length: height}, () => Array(width).fill(false)) const queue = [[startR, startC]] visited[startR][startC] = true let reached = 1 while (queue.length > 0) { const [r, c] = queue.shift() for (const [dr, dc] of [[-1,0],[1,0],[0,-1],[0,1]]) { const nr = r+dr, nc = c+dc if (nr >= 0 && nr < height && nc >= 0 && nc < width && !visited[nr][nc] && cells[nr][nc] === '.') { visited[nr][nc] = true reached++ queue.push([nr, nc]) } } } return reached === whiteCount } // --- Process NYT JSON files --- function processNYT(dir) { if (!fs.existsSync(dir)) { console.log('NYT dir not found:', dir); return } let count = 0 const walk = (d) => { for (const entry of fs.readdirSync(d, { withFileTypes: true })) { const full = path.join(d, entry.name) if (entry.isDirectory()) { walk(full); continue } if (!entry.name.endsWith('.json')) continue try { const data = JSON.parse(fs.readFileSync(full, 'utf8')) const w = data.size?.cols || 0 const h = data.size?.rows || 0 if ((w === 15 && h === 15) || (w === 21 && h === 21)) { if (data.grid && data.grid.length === w * h) { const pattern = gridToPattern(data.grid, w, h) if (isValidPattern(pattern, w, h)) { addGrid(w, h, pattern) count++ } } } } catch {} } } walk(dir) console.log(`NYT: extracted ${count} valid grids`) } // --- Process xd files --- function processXD(dir) { if (!fs.existsSync(dir)) { console.log('XD dir not found:', dir); return } let count = 0 const walk = (d) => { for (const entry of fs.readdirSync(d, { withFileTypes: true })) { const full = path.join(d, entry.name) if (entry.isDirectory()) { walk(full); continue } if (!entry.name.endsWith('.xd')) continue try { const text = fs.readFileSync(full, 'utf8') // xd format: headers, blank line, grid section, blank line, clues // grid lines are like: .ABC.DEF. where . = black, letters = white const sections = text.split(/\n\n+/) let gridSection = null for (const sec of sections) { const lines = sec.trim().split('\n').filter(l => l.trim()) // grid section: all lines same length, contain only letters, dots, #, and spaces if (lines.length >= 5 && lines.every(l => /^[A-Za-z.#_]+$/.test(l.trim())) && new Set(lines.map(l => l.trim().length)).size === 1) { gridSection = lines.map(l => l.trim()) break } } if (!gridSection) continue const h = gridSection.length const w = gridSection[0].length if (!((w === 15 && h === 15) || (w === 21 && h === 21))) continue const patternLines = gridSection.map(line => line.split('').map(c => (c === '.' || c === '#' || c === '_') ? '#' : '.').join('') ) const pattern = patternLines.join('\n') if (isValidPattern(pattern, w, h)) { addGrid(w, h, pattern) count++ } } catch {} } } walk(dir) console.log(`XD: extracted ${count} valid grids`) } // --- Main --- const nytDir = process.argv[2] || '/tmp/nyt_crosswords' const xdDir = process.argv[3] || '/tmp/xd-puzzles' const outDir = process.argv[4] || 'data/grid-templates' console.log('Processing NYT crosswords...') processNYT(nytDir) console.log('Processing xd puzzles...') processXD(xdDir) // Write output fs.mkdirSync(outDir, { recursive: true }) for (const [size, patterns] of grids) { const outFile = path.join(outDir, `grids_${size}.txt`) const content = [...patterns].join('\n\n') + '\n' fs.writeFileSync(outFile, content) console.log(`${size}: ${patterns.size} unique patterns -> ${outFile}`) } console.log('Done!')