#!/usr/bin/env python3 """ Refine a huge Lichess-style PGN file, streaming one game at a time so RAM stays flat regardless of file size (works fine on a 200GB file). Rules applied to every game: 1. If the game has no %eval annotations at all, drop it entirely. 2. Keep only these header tags: Site, WhiteElo, BlackElo. 3. In the movetext, remove every comment/annotation except %eval. 4. Keep only the %eval annotation from comments containing multiple attributes, e.g. { [%eval 0.18] [%clk 0:01:00] } -> { [%eval 0.18] }. 5. Remove non-%eval textual comments, e.g. { Black offers draw }. 6. Remove NAGs such as !, ?, !?, ?!, etc. Games are separated by a blank line after the movetext. Usage: python refine_pgn.py input.pgn output.pgn """ import argparse import re # Only these header tags survive KEEP_TAGS = {"Site", "WhiteElo", "BlackElo"} # Matches a header tag line, e.g. [WhiteElo "1500"] TAG_RE = re.compile(r'^\[(\w+)\s+"(.*)"\]\s*$') # Matches a %eval annotation inside a PGN comment. # # Examples: # [%eval 0.18] # [%eval -1.25] # [%eval #26] # # The value is captured without the surrounding brackets. EVAL_RE = re.compile(r'\[%eval\s+([^\]]+)\]') # Matches PGN comments enclosed in { ... }. COMMENT_RE = re.compile(r'\{([^}]*)\}') # Detect presence of an %eval annotation anywhere in the movetext. EVAL_PRESENT_RE = re.compile(r'\[%eval\b') def clean_comment(match: re.Match) -> str: """ Replace a PGN comment with only its %eval annotation. Examples: { [%eval 0.18] } -> { [%eval 0.18] } { [%eval 0.18] [%clk 0:01:00] } -> { [%eval 0.18] } { Black offers draw } -> "" { [%clk 0:01:00] } -> "" """ comment = match.group(1) eval_match = EVAL_RE.search(comment) if eval_match: return "{ [%eval " + eval_match.group(1).strip() + "] }" return "" def clean_movetext(movetext: str) -> str: """ Remove every PGN comment except %eval annotations. NAGs such as !, ?, !?, ?! are also removed. """ # Keep only %eval from {...} comments. text = COMMENT_RE.sub(clean_comment, movetext) # Remove NAGs / move annotations: # # ! # ? # !! # ?? # !? # ?! # # These are annotations, not %eval attributes. text = re.sub(r'(?<=[a-zA-Z0-9+#=])(?:!!|\?\?|!\?|\?!|!|\?)(?=\s|$)', '', text) # Collapse whitespace. text = re.sub(r'[ \t]+', ' ', text) # Remove excessive whitespace around eval comments. text = re.sub(r'\s+\{', ' {', text) text = re.sub(r'\}\s+', '} ', text) return text.strip() def process_game(header_lines, movetext_lines, fout) -> bool: """ Filter/transform one game and write it out if it qualifies. Returns True if the game was written. """ if not movetext_lines: return False movetext = ' '.join( line.strip() for line in movetext_lines if line.strip() ) # Drop games with no %eval at all. if not EVAL_PRESENT_RE.search(movetext): return False # Keep only whitelisted header tags. kept_headers = [] for line in header_lines: m = TAG_RE.match(line) if m and m.group(1) in KEEP_TAGS: kept_headers.append(line.rstrip('\n')) # Remove everything except %eval. cleaned_movetext = clean_movetext(movetext) for h in kept_headers: fout.write(h + "\n") fout.write("\n") fout.write(cleaned_movetext + "\n") fout.write("\n") return True def refine(input_path: str, output_path: str): games_in = 0 games_out = 0 header_lines = [] movetext_lines = [] # State: # "seek_header" -> collecting [Tag "..."] lines # "seek_movetext" -> collecting movetext until blank line state = "seek_header" with open( input_path, "r", encoding="utf-8", errors="replace", buffering=1024 * 1024 ) as fin, open( output_path, "w", encoding="utf-8", buffering=1024 * 1024 ) as fout: for raw_line in fin: line = raw_line.rstrip("\n") if state == "seek_header": if line.startswith("["): header_lines.append(line) elif line.strip() == "": # Blank line between headers and movetext. if header_lines: state = "seek_movetext" # Anything else while no headers are collected: # ignore it. elif state == "seek_movetext": if line.strip() == "": # End of this game's movetext. games_in += 1 if process_game( header_lines, movetext_lines, fout ): games_out += 1 header_lines = [] movetext_lines = [] state = "seek_header" else: movetext_lines.append(line) # Handle a file without a trailing blank line. if header_lines and movetext_lines: games_in += 1 if process_game( header_lines, movetext_lines, fout ): games_out += 1 return games_in, games_out def main(): parser = argparse.ArgumentParser( description=( "Refine a large PGN file: keep only selected headers " "and %eval annotations." ) ) parser.add_argument( "input", help="Path to the input PGN file" ) parser.add_argument( "output", help="Path to write the refined PGN file" ) args = parser.parse_args() total_in, total_out = refine( args.input, args.output ) print( f"Read {total_in} games, " f"kept {total_out} (had %eval), " f"dropped {total_in - total_out} (no %eval)." ) print(f"Output written to {args.output}") if __name__ == "__main__": main()