pgn-postmortem
Turn a folder of PGN chess games into a set of Markdown post-mortems you can browse on GitHub. Every blunder a chosen player made gets a board diagram, the move the engine preferred, how the mistake should have been punished, and an opening-theory breakdown showing where the game left known lines.
Everything runs locally with Stockfish, and the output is plain Markdown and SVG. There's no account and no server, and the site lives in git.
→ Live demo: five classic games, from Chigorin–Steinitz (1892) to Carlsen–Anand (2014). The same pages are also browsable on GitHub.
Move 32. Bb4 by Mikhail Chigorin (Blunder)Moves since the previous diagram: 29. Ne6+ Kf6 30. Re7 Rge2 31. d5 Rcd2 Better was: 32. Rxb7 Bh5 33. Rb3 Rxd5 34. Nf4 Rxd6 35. Nxh5+ Ke7 +- Best continuation: 32... Rxh2+ 33. Kg1 Rdg2# -+ Excerpt from game 1: World Championship 1892, round 23. Chigorin was winning, then walked into mate in two. |
Features
- Independent engine analysis. Stockfish re-evaluates every position itself. Any annotations the PGN already carries are ignored, since site exports (chess.com's, for example) are inconsistent about what they attach where.
- Judged by win probability, not raw centipawns. Evals are converted to win % using lichess's logistic fit, and a move is flagged by how many win % points it threw away (default thresholds are lichess's own: 10 / 20 / 30 for Inaccuracy / Mistake / Blunder). Going from mate-in-4 to mate-in-9 is a huge centipawn swing but costs nothing, so it isn't flagged. The same swing in an equal position is.
- Two engine lines per mistake. Better was is what should have been played. Best continuation is how the mistake should have been punished, which is useful when the real opponent missed it.
- Opening theory. Each game is matched against the ~3,800 named lines in lichess-org/chess-openings. The page shows the position where the game left known theory, who left it, and which named lines were still available at that point.
- Any player, any collection. Filter to one player (
--player yourname) to review your own games, or use--player '*'to annotate both sides of master games. - Standard, reusable output. The analyzed PGNs are ordinary PGN files: an eval comment on every
move, standard NAGs (
$2/$4/$6), and engine lines as variations ending in position symbols (±,-+, ...). They load into any chess GUI. - Incremental and deterministic. Only new games are sent to Stockfish. Pages are fully regenerated
on each run and the output is byte-identical, which the test suite checks against
examples/.
Quickstart
Requires Python 3.11+ and a stockfish binary on your PATH (apt install stockfish,
brew install stockfish, ...).
git clone https://github.com/diegoami/pgn-postmortem.git && cd pgn-postmortem
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
cp .env.example .env # preconfigured for the bundled examples/
scripts/update_games.sh # analyze new games, then regenerate docs/
With your own games
- Edit
.envand setCHESS_PLAYERto your username as it appears in the PGNWhite/Blackheaders, andCHESS_DATA_DIRto a directory of your own. - Put your games in
$CHESS_DATA_DIR/daily_games/as1.pgn,2.pgn, ..., one game per file. - Run
scripts/update_games.sh, then open$CHESS_DATA_DIR/docs/index.md, or push the directory to GitHub to browse it there.
A handy setup is to keep your games in a separate repository (daily_games/, analyzed_games/,
docs/), so this one stays pure tooling and your games repo can be published independently, for
example with GitHub Pages.
How it works
daily_games/*.pgn ──analyze_games.py──▶ analyzed_games/*.pgn ──publish_games.py──▶ docs/
(your PGNs) (Stockfish) (evals + NAGs + (Markdown) index.md
engine lines) games/<id>.md + SVGs
scripts/analyze_games.py keeps only the mainline of each game,
evaluates every position, and writes a clean annotated copy. For each flagged move it attaches two
variations, each capped at --pv-length half-moves:
32. Bb4 $4 { -999.98 } ( 32. Rxb7 Bh5 33. Rb3 Rxd5 34. Nf4 Rxd6 35. Nxh5+ Ke7 $18 )
32... Rxh2+ { -999.99 } ( 32... Rxh2+ 33. Kg1 Rdg2# $19 ) 0-1
The first variation hangs off the position before the move (Better was). The second hangs off
the position after it (Best continuation). Games already present in analyzed_games/ are
skipped; --force redoes them all.
scripts/publish_games.py reads the analyzed PGNs and writes:
| Output | Contents |
|---|---|
docs/index.md |
One row per game: date, players, result, opening, blunder and inaccuracy counts |
docs/games/<id>.md |
Game info, opening-theory section, every flagged move in order (inaccuracies folded under <details>), full PGN |
docs/games/<id>/*.svg |
Board before each flagged move, with the move played as a red arrow, plus the opening-deviation position |
A "blunder" on these pages means a move by --player rated Mistake ($2), Blunder ($4) or Miss
($9). Note that the opening matching only covers named lines in the dataset, so a "deviation" means
"no longer in a named line", not "a bad move".
Each source file must contain exactly one game. Every output path is derived from the filename, and python-chess silently reads only the first game of a multi-game file, so a file with more than one game is skipped with a warning rather than losing games without a trace.
Configuration
Every setting can be passed as a flag or set in .env (see .env.example). A flag
always wins.
| Flag | .env variable |
Default | Meaning |
|---|---|---|---|
--player |
CHESS_PLAYER |
(required) | Whose moves to review (case-insensitive), or * for both sides |
--data-dir |
CHESS_DATA_DIR |
repo root | Directory containing daily_games/; outputs are written next to it |
--time |
ANALYSIS_TIME |
0.3 |
Seconds of search per position |
--depth |
ANALYSIS_DEPTH |
— | Fixed search depth instead of a time limit |
--inaccuracy-threshold |
ANALYSIS_INACCURACY_PCT |
10 |
Win % points lost to flag an Inaccuracy |
--mistake-threshold |
ANALYSIS_MISTAKE_PCT |
20 |
… a Mistake |
--blunder-threshold |
ANALYSIS_BLUNDER_PCT |
30 |
… a Blunder |
--pv-length |
— | 8 |
Max half-moves per engine line |
--force |
— | off | Re-analyze games already in analyzed_games/ |
--source |
— | daily_games |
Which directory publish_games.py reads (update_games.sh uses analyzed_games) |
The library (in progress)
pgn-postmortem is growing into a Python library that turns a player's PGN collections into a
Wikipedia-style site and an EPUB book (ROADMAP.md, F-1). So far it reads,
analyzes and writes the site: multi-game files, directories and glob patterns in, the player's games
kept once each with every source comment, variation and NAG stripped, then a parallel Stockfish pass
that writes standard [%eval] comments and skips games it has already analyzed, then a static site
with an article for every game and a quiz of the player's own mistakes.
.venv/bin/pip install -e .
pgn-postmortem read 'collections/**/*.pgn' --player "Ada Example" --alias adaex --out games/
pgn-postmortem analyze games/ --out analyzed/ --workers 4
pgn-postmortem site analyzed/ --player "Ada Example" --alias adaex --out site/
from pgn_postmortem import Collection
games = Collection.read(["collections/**/*.pgn"], player="Ada Example", aliases=["adaex"])
games.analyze("analyzed/", depth=18, workers=4)
analyzed = Collection.read("analyzed/", player="Ada Example", aliases=["adaex"], keep_analysis=True)
analyzed.build_site("site/", title="Games of Ada Example") # the quiz is for the names read with
Quote a ** pattern so the library, not the shell, expands it. The scripts above are unchanged by it.
Every game the library writes is named <date>-<id>.pgn and carries two headers of its own:
PostmortemId, the game's content id;PostmortemAnalysis, only on analyzed games: the engine and search limit, e.g.Stockfish 16, depth 18.analyzeskips a game when a file in its output directory carries that game's id and this header, so games thatread --outonly stripped are still analyzed, even in the same directory. In turn,read --outleaves such a file alone, so reading new games into an analysis directory keeps the analysis already there. The skip ignores which engine and search limit the header records: to redo a game with other settings (a deeper search, a newer Stockfish), delete its file and runanalyzeagain.
Two copies of a game count as one when they have the same start position, moves, result and date.
The players' names are not compared, so a game exported under two of your names or aliases is kept
once. A FEN header that spells out the standard starting position counts the same as none. The
Result and Date headers are compared exactly as written, which has two consequences:
- Copies with a missing, partial or differently written date (
2019.??.??and2019.03.14, or2019.3.14) are kept twice. The same goes for copies with different results (1-0and*). - Two different games with the same moves and result on the same day are kept as one. That can happen with a short trap, or an agreed draw in a well-known line, played against two opponents.
The exact rule is in pgn_postmortem/collection.py.
The site
pgn-postmortem site writes index.html (the games by year), one games/<date>-<id>.html article
per game, one stylesheet and, for a player, quiz.html (below). Open index.html in a browser, or copy the folder to a phone: every link
within the site is relative, nothing loads from the network, and the colours follow the system's light or dark mode.
The only links out are the two lichess links below, which open in a new tab only when you tap them.
The only JavaScript is the optional reading history below; without it the pages read the same. Each
article has an infobox with the final position, a lead paragraph, the moves, a conclusion and the
PGN, all in template prose.
For an analyzed game the moves carry notes (?! inaccuracy, ? mistake, ?? blunder), and each
critical moment gets a diagram and a question, "What would you play?", with the answer hidden
until you tap it. A critical moment is a move that cost its side at least 20 points of winning chances
(a mistake or a blunder), computed from the [%eval] comments that analyze wrote, with the same win
percentage and thresholds that grade the moves.
A move that changed the expected result is a critical moment too, even when it cost less. After each
move the position is White winning (60% or more for White), Black winning (40% or less) or level.
A move counts when it made that worse for its side (winning to level, level to losing, or winning to
losing), cost its side at least 10 points (the inaccuracy threshold in use), and the analysis shows a
better move there: an engine line that analyze stored at that position starts with another move. Only
the stored analysis is read, so nothing is analyzed again. Its note says how the expected result changed, for example "an inaccuracy that turned a
level game into a losing one". The bands are build_site(..., outcome_bands=(40, 60)): the lower one
above 0 and below 50, the upper one above 50 and below 100.
Lichess links. Under the final position in the infobox, "Open this game on lichess" opens the
game on lichess's free analysis board: https://lichess.org/analysis/pgn/<moves>, the moves in SAN
without move numbers and without + and #, URL-encoded (e4%20e5%20Nf3). A game that starts from a
set-up position (a FEN header other than the standard start) or has no moves has no such link.
Inside each hidden answer, "Analyze this position on lichess" opens the question's position there
(https://lichess.org/analysis/<FEN>, the FEN's spaces written as _), so you can put an engine on
it; it is in the answer because an engine on the question gives the answer away. Both need the
network and lichess.org; the rest of the site does not.
Games that have not been analyzed, or whose only
evaluations came from their source, still get an article, without notes or questions. Pass the games
and their analysis together (site games/ analyzed/) and the analyzed copy of each game is used.
Building again into the same folder removes the pages it wrote before for games that are no longer in
the collection; a file it did not write is never touched.
A game whose result was not recorded (Result "*", or no Result header) still gets one. If the game
ended in checkmate, stalemate or insufficient material, the board decides it. Otherwise, if the game
was analyzed, the final position decides it: a win for the side with at least 70% winning chances (a
forced mate counts as 100%), a draw otherwise. The 70 is build_site(..., presume_threshold=70), from
55 to 95. Failing both, the article says the result was not recorded. The result is shown like a
recorded one, in the infobox, the lead, after the moves, in the conclusion and in the index. The
source's * is left as it is: the article's PGN section shows it, and the game's id, and therefore its
file name, is computed from that * result, not from the result shown.
The quiz. With --player or --alias (in the library, the names the collection was read with,
or build_site(..., player=..., aliases=[...])), the index links at its top to quiz.html: every
critical moment where the player was the one to move, across the player's games, from the move that
lost the most winning chances down. Each line shows its rank, the move played, the points it lost
and the game (date and opponent), and links straight to the question in its article; the page has no
diagrams and no answers, so it stays small on a phone. The opponents' critical moments are not in
it; in a game where both sides are the player's names, both sides' are. Ties go by the index's
order, then the article's file name, then move order. A player without a critical moment of their
own gets a quiz page that says so. Without a player there is no quiz page and no link to it, and a
rebuild without one removes the quiz.html an earlier build wrote (never one it did not write).
The reading history. Every page carries a small script, inline, that remembers in the reader's
own browser which games were opened and which answers were revealed. The index then shows a
Recently viewed list (the latest 10 games, newest first), a mark on each game already opened with
how many of its answers were revealed ("viewed · 2/4 answers"), and a Clear history button, which
asks before it removes anything. On the quiz page, a question whose answer was revealed is marked
"answered", and the order stays the same; the index's button clears these marks too. The history is kept in the browser's localStorage: per device and
per browser, never shared and never sent anywhere; the script loads nothing. It can be lost: when the
reader clears the browser's data, in Safari after 7 days without a visit, and for one game when its id
changes (a corrected Date or Result). With scripts off, or where the browser gives no storage (some
private windows, some browsers for file:// pages), the history stays hidden and the pages read the
same. The EPUB will carry no script.
Browser storage is shared by every page of one origin: all the GitHub Pages sites of one user, and in
some browsers every file:// page. So each site stores its history under a site key, derived from
its title by default; two sites with the same title on one origin share a history. Set a key of your
own with --site-key KEY (or build_site(..., site_key="ada-otb")): 1 to 64 ASCII letters, digits,
., _ or -. --no-history (build_site(..., history=False)) writes the pages without the script,
the history section, the quiz's marks and the data- attributes.
The site of the test fixture is committed in tests/golden/site/, and
the same site without the reading history in
tests/golden/site-no-history/.
Claude Code skill
.claude/skills/publish-games is a
Claude Code skill for this workflow. Say "I added new games, publish
them" and it runs the pipeline using your .env.
Development
.venv/bin/pip install -r requirements-dev.txt -e .
.venv/bin/pytest # unit tests, a golden-file test against examples/, and the Stockfish tests
.venv/bin/ruff check .
node --test 'tests/js/*.test.mjs' # the reading-history script (Node 22 or newer, no npm packages)
Quote the glob: Node expands it, and a bare tests/js/ fails on Node 21 and newer.
If you intentionally change the page output, regenerate the golden files:
.venv/bin/python scripts/publish_games.py --player '*' --data-dir examples --source analyzed_games
and for the library's site (the fixture's analysis is committed, so this needs no Stockfish):
.venv/bin/python -m pgn_postmortem site tests/fixtures/site/analyzed --player "Ada Example" --alias adaex --alias "Example, Ada" --out tests/golden/site
.venv/bin/python -m pgn_postmortem site tests/fixtures/site/analyzed --player "Ada Example" --alias adaex --alias "Example, Ada" --no-history --out tests/golden/site-no-history
Credits
- python-chess for PGN parsing, the engine protocol and the SVG boards, and the piece set by Colin M.L. Burnett that the site's diagrams use
- Stockfish for the analysis
- lichess-org/chess-openings (CC0) for the opening
names, bundled in
data/openings/ - lichess's accuracy page for the win % model and default thresholds
License
Metadata
Release files for pgn-postmortem 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| pgn_postmortem-0.1.0.tar.gz | 93.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pgn_postmortem-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 144.6 kB
Release files / pgn_postmortem-0.1.0.tar.gz
| Download URL | pgn_postmortem-0.1.0.tar.gz |
|---|---|
| Size | 93.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
3be11dc6683d0749205b97172f9c56e6b5b86ed7fb21726850128edbdf05b477
|
|
BLAKE2b-256 checksum How to use checksums |
b4908336f9c60700a4b3b5c5478fa21dc3e9ded8674313175ee306e81a3c67e1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|
Release files / pgn_postmortem-0.1.0-py3-none-any.whl
| Download URL | pgn_postmortem-0.1.0-py3-none-any.whl |
|---|---|
| Size | 50.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c5ba50a0584f522173293783845b3f50f994e246d7c939dc8f59b6e57509fb24
|
|
BLAKE2b-256 checksum How to use checksums |
ba0b125e3b5b433fd787e491bd70d70faee44ffe5cc3e4782111b06e75d25872
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|