#!/usr/bin/env python3 """ PuzzleDepot Daily Crossword Generator Generates daily crossword puzzles following the Universal Crossword JSON Schema v1.0 """ import json import os import sys from datetime import datetime, timedelta from typing import Dict, List, Tuple, Any import random import argparse from pathlib import Path # Add the project root to the path sys.path.append(str(Path(__file__).parent.parent.parent.parent)) # Import clue database from clue_database import get_clue, get_all_words, CLUE_DATABASE class CrosswordGenerator: """Universal crossword puzzle generator""" def __init__(self, output_dir: str = "html/crosswords/corpus/daily"): self.output_dir = Path(output_dir) self.output_dir.mkdir(parents=True, exist_ok=True) # Common crossword words and patterns self.common_words = self._load_common_words() self.clue_templates = self._load_clue_templates() def _load_common_words(self) -> List[str]: """Load common crossword words from clue database""" # Use all words from the clue database - they all have clues! return get_all_words() def _load_clue_templates(self) -> Dict[str, List[str]]: """Load clue templates for different word types""" return { "direction": [ "Side-to-side direction in crosswords", "Horizontal direction", "Left-to-right orientation" ], "object": [ "What this is", "Common puzzle type", "Word grid entertainment" ], "pattern": [ "Layout of letters and blocks", "Grid arrangement", "Letter and void pattern" ], "verb": [ "To work on a puzzle", "To find solutions", "To figure out answers" ], "adjective": [ "Not difficult", "Of medium challenge", "Requiring skill" ] } def generate_daily_puzzle(self, date: str = None, difficulty: str = "medium") -> Dict[str, Any]: """Generate a daily crossword puzzle""" if date is None: date = datetime.now().strftime("%Y-%m-%d") # Generate puzzle parameters puzzle_params = self._generate_puzzle_parameters(difficulty) # Generate grid grid = self._generate_grid(puzzle_params) # Generate entries (words and their positions) entries = self._generate_entries(grid, puzzle_params) # Generate clues clues = self._generate_clues(entries, difficulty) # Create puzzle object puzzle = { "schema_version": "1.0", "puzzle_id": f"daily-{date}", "title": f"Daily Crossword - {self._format_date_display(date)}", "author": { "name": "PuzzleDepot Generator", "contact": "editor@puzzledepot.com" }, "publication": { "date": date, "source": "PuzzleDepot", "license": "public-domain" }, "dimensions": puzzle_params["dimensions"], "grid": grid, "form": { "family": "crossword", "subtype": "newspaper-dense" }, "difficulty": puzzle_params["difficulty"], "entries": entries, "clues": clues, "generation": { "origin": "synthetic", "cadence": "daily", "generator_id": "pd-daily-gen", "generator_version": "1.0", "generation_parameters": puzzle_params }, "tags": ["daily", difficulty, "generated"] } return puzzle def _generate_puzzle_parameters(self, difficulty: str) -> Dict[str, Any]: """Generate puzzle parameters based on difficulty""" base_params = { "easy": { "rows": 13, "columns": 13, "min_word_length": 3, "max_word_length": 8, "density_factor": 0.6, "difficulty_numeric": 1 }, "medium": { "rows": 15, "columns": 15, "min_word_length": 3, "max_word_length": 12, "density_factor": 0.75, "difficulty_numeric": 2 }, "hard": { "rows": 15, "columns": 15, "min_word_length": 4, "max_word_length": 15, "density_factor": 0.85, "difficulty_numeric": 3 }, "expert": { "rows": 17, "columns": 17, "min_word_length": 4, "max_word_length": 15, "density_factor": 0.9, "difficulty_numeric": 4 } } params = base_params.get(difficulty, base_params["medium"]) params["difficulty"] = { "label": difficulty, "numeric": params["difficulty_numeric"], "estimated_solve_minutes": self._estimate_solve_time(params), "difficulty_factors": { "vocabulary_rarity": params["difficulty_numeric"], "clue_abstraction": params["difficulty_numeric"], "grid_density": params["difficulty_numeric"], "rebus_usage": 1 if difficulty in ["hard", "expert"] else 0 } } params["dimensions"] = { "rows": params["rows"], "columns": params["columns"] } return params def _estimate_solve_time(self, params: Dict[str, Any]) -> int: """Estimate solve time based on puzzle parameters""" base_time = params["rows"] * params["columns"] * 0.8 difficulty_multiplier = 1 + (params["difficulty_numeric"] - 1) * 0.5 return int(base_time * difficulty_multiplier) def _generate_grid(self, params: Dict[str, Any]) -> List[List[int]]: """Generate a crossword grid pattern""" rows, cols = params["rows"], params["columns"] # Start with all blocks grid = [[0 for _ in range(cols)] for _ in range(rows)] # Create symmetrical patterns for classic crosswords # This is a simplified generator - real generators would use more sophisticated algorithms # Fill center area with letters for row in range(2, rows - 2): for col in range(2, cols - 2): # Create checkerboard-like pattern with some randomness if (row + col) % 3 != 0 or random.random() < params["density_factor"]: grid[row][col] = 1 # Ensure symmetry for row in range(rows): for col in range(cols // 2): grid[row][cols - 1 - col] = grid[row][col] # Ensure at least some connectivity if not self._is_connected(grid): self._ensure_connectivity(grid) return grid def _is_connected(self, grid: List[List[int]]) -> bool: """Check if the letter cells form a connected graph""" rows, cols = len(grid), len(grid[0]) visited = [[False for _ in range(cols)] for _ in range(rows)] # Find first letter cell start = None for r in range(rows): for c in range(cols): if grid[r][c] == 1: start = (r, c) break if start: break if not start: return False # BFS to check connectivity queue = [start] visited[start[0]][start[1]] = True count = 1 while queue: r, c = queue.pop(0) for dr, dc in [(0, 1), (0, -1), (1, 0), (-1, 0)]: nr, nc = r + dr, c + dc if (0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 1 and not visited[nr][nc]): visited[nr][nc] = True queue.append((nr, nc)) count += 1 # Count total letter cells total_letters = sum(cell == 1 for row in grid for cell in row) return count >= total_letters * 0.8 # Allow some isolated cells def _ensure_connectivity(self, grid: List[List[int]]): """Add connections to ensure the grid is connected""" rows, cols = len(grid), len(grid[0]) # Find letter cells letter_cells = [(r, c) for r in range(rows) for c in range(cols) if grid[r][c] == 1] if not letter_cells: return # Connect isolated letter cells to the main component for r, c in letter_cells: # Check if this cell is isolated adjacent_letters = 0 for dr, dc in [(0, 1), (0, -1), (1, 0), (-1, 0)]: nr, nc = r + dr, c + dc if (0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 1): adjacent_letters += 1 if adjacent_letters == 0: # Add connections to nearby cells for dr, dc in [(0, 1), (0, -1), (1, 0), (-1, 0)]: nr, nc = r + dr, c + dc if 0 <= nr < rows and 0 <= nc < cols: if random.random() < 0.3: # 30% chance to add connection grid[nr][nc] = 1 def _generate_entries(self, grid: List[List[int]], params: Dict[str, Any]) -> Dict[str, Any]: """Generate word entries from the grid""" entries = {} entry_id = 1 rows, cols = len(grid), len(grid[0]) # Find horizontal words for row in range(rows): col = 0 while col < cols: if grid[row][col] == 1: # Found start of a word start_col = col while col < cols and grid[row][col] == 1: col += 1 word_length = col - start_col if word_length >= params["min_word_length"]: # Generate word for this position word = self._generate_word(word_length) entry_key = f"A{entry_id}" entries[entry_key] = { "word": word, "direction": "across", "start": {"row": row + 1, "col": start_col + 1}, "length": word_length } entry_id += 1 else: col += 1 # Find vertical words for col in range(cols): row = 0 while row < rows: if grid[row][col] == 1: # Found start of a word start_row = row while row < rows and grid[row][col] == 1: row += 1 word_length = row - start_row if word_length >= params["min_word_length"]: # Generate word for this position word = self._generate_word(word_length) entry_key = f"D{entry_id}" entries[entry_key] = { "word": word, "direction": "down", "start": {"row": start_row + 1, "col": col + 1}, "length": word_length } entry_id += 1 else: row += 1 return entries def _generate_word(self, length: int) -> str: """Generate a word of specified length""" # Filter words by length suitable_words = [w for w in self.common_words if len(w) == length] if suitable_words: return random.choice(suitable_words) # If no exact match, generate a random word of that length # This would typically use a more sophisticated word generator vowels = "AEIOU" consonants = "BCDFGHJKLMNPQRSTVWXYZ" word = "" for i in range(length): if i == 0 or random.random() < 0.3: word += random.choice(vowels) else: word += random.choice(consonants) return word def _generate_clues(self, entries: Dict[str, Any], difficulty: str = "medium") -> Dict[str, Any]: """Generate clues for the entries using the clue database""" clues = {} for entry_id, entry in entries.items(): word = entry["word"] # Get clue from database clue_text = get_clue(word, difficulty) clues[entry_id] = { "text": clue_text } return clues def _extract_definition(self, clue_text: str) -> str: """Extract the definition part from a clue""" # Simple extraction - look for patterns like "X is Y" if " is " in clue_text: return clue_text.split(" is ")[1] elif " or " in clue_text: return clue_text.split(" or ")[1] else: # Return the last part as the definition parts = clue_text.split() return " ".join(parts[-3:]) if len(parts) > 3 else clue_text def _format_date_display(self, date_str: str) -> str: """Format date for display""" try: date = datetime.strptime(date_str, "%Y-%m-%d") return date.strftime("%B %d, %Y") except: return date_str def save_puzzle(self, puzzle: Dict[str, Any]) -> str: """Save puzzle to JSON file""" puzzle_id = puzzle["puzzle_id"] filename = f"{puzzle_id}.json" filepath = self.output_dir / filename with open(filepath, 'w', encoding='utf-8') as f: json.dump(puzzle, f, indent=2, ensure_ascii=False) return str(filepath) def generate_puzzle_batch(self, start_date: str = None, count: int = 30, difficulty: str = "medium"): """Generate a batch of daily puzzles""" if start_date is None: start_date = datetime.now().strftime("%Y-%m-%d") start = datetime.strptime(start_date, "%Y-%m-%d") generated_files = [] for i in range(count): current_date = (start + timedelta(days=i)).strftime("%Y-%m-%d") print(f"Generating puzzle for {current_date}...") puzzle = self.generate_daily_puzzle(current_date, difficulty) filepath = self.save_puzzle(puzzle) generated_files.append(filepath) print(f"Saved: {filepath}") return generated_files def generate_todays_puzzle(self, difficulty: str = "medium"): """Generate today's puzzle""" today = datetime.now().strftime("%Y-%m-%d") puzzle = self.generate_daily_puzzle(today, difficulty) filepath = self.save_puzzle(puzzle) print(f"Generated today's puzzle: {filepath}") return filepath def main(): """Main function for command line usage""" parser = argparse.ArgumentParser(description="Generate daily crossword puzzles") parser.add_argument("--date", help="Date in YYYY-MM-DD format (default: today)") parser.add_argument("--difficulty", choices=["easy", "medium", "hard", "expert"], default="medium", help="Puzzle difficulty") parser.add_argument("--batch", type=int, help="Generate N puzzles starting from date") parser.add_argument("--output", default="html/crosswords/corpus/daily", help="Output directory") args = parser.parse_args() generator = CrosswordGenerator(args.output) if args.batch: if not args.date: args.date = datetime.now().strftime("%Y-%m-%d") files = generator.generate_puzzle_batch(args.date, args.batch, args.difficulty) print(f"\nGenerated {len(files)} puzzles:") for file in files: print(f" {file}") else: if args.date: puzzle = generator.generate_daily_puzzle(args.date, args.difficulty) else: puzzle = generator.generate_todays_puzzle(args.difficulty) print(f"Puzzle generated successfully!") if args.date: print(f"Date: {args.date}") print(f"Difficulty: {args.difficulty}") if __name__ == "__main__": main()