Skip to content

Commit 94867ba

Browse files
committed
Add HTML output format with colour-coded opening tables
- render_html(): self-contained HTML dossier per opponent; As White section bordered in gold, As Black in dark; win% cells colour-coded green/yellow/red (>=55% / 40-55% / <40%) - render_html_combined(): single combined.html with player nav sidebar and section anchors; @media print adds page-break-after each player - run_pipeline() default changed to fmt="html"; produces .html per player + combined.html with navigation - dossier.report CLI --output default changed to html - 11 new tests covering renderer output and runner HTML mode (154 total) https://claude.ai/code/session_01VQfqug9MDEyRFydkmES4n2
1 parent e60316b commit 94867ba

3 files changed

Lines changed: 264 additions & 19 deletions

File tree

dossier/report.py

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,162 @@ def render_markdown(dossier: dict) -> str:
135135
return "\n".join(lines)
136136

137137

138+
_HTML_CSS = """
139+
body { font-family: Georgia, serif; max-width: 960px; margin: 2rem auto; padding: 0 1.5rem; color: #1a1a1a; }
140+
h1 { border-bottom: 2px solid #333; padding-bottom: .4rem; }
141+
h2 { margin-top: 2rem; color: #2c2c2c; }
142+
h3 { margin-top: 1.2rem; color: #444; }
143+
.meta { color: #666; font-style: italic; margin-bottom: 1.5rem; }
144+
.profiles { list-style: none; padding: 0; }
145+
.profiles li { margin: .3rem 0; }
146+
.warn { color: #c0392b; font-style: italic; }
147+
table { border-collapse: collapse; width: 100%; margin: .8rem 0; font-size: .92rem; }
148+
th { background: #2c3e50; color: #fff; padding: .45rem .7rem; text-align: left; }
149+
td { padding: .35rem .7rem; border-bottom: 1px solid #ddd; }
150+
tr:nth-child(even) td { background: #f7f7f7; }
151+
.line { font-family: monospace; font-size: .85rem; }
152+
.wp-hi { background: #c8f7c5 !important; }
153+
.wp-mid { background: #fef9c3 !important; }
154+
.wp-lo { background: #fcd6d6 !important; }
155+
.overview td:first-child { font-weight: bold; }
156+
.section-white { border-left: 4px solid #f0c040; padding-left: .8rem; }
157+
.section-black { border-left: 4px solid #444; padding-left: .8rem; }
158+
@media print { .player-section { page-break-after: always; } }
159+
"""
160+
161+
162+
def render_html(dossier: dict) -> str:
163+
player = dossier["player"]
164+
body = _html_player_section(
165+
player, dossier["stats"], dossier["openings"],
166+
dossier["profiles"], dossier["generated"]
167+
)
168+
return (
169+
f"<!doctype html><html lang='en'><head>"
170+
f"<meta charset='utf-8'><title>Dossier: {_esc(player)}</title>"
171+
f"<style>{_HTML_CSS}</style></head><body>"
172+
+ body + "</body></html>"
173+
)
174+
175+
176+
def render_html_combined(dossiers: list[dict]) -> str:
177+
def _nav_item(d):
178+
pid = _slug_id(d["player"])
179+
return f"<li><a href='#{pid}'>{_esc(d['player'])}</a></li>"
180+
181+
nav_links = "".join(_nav_item(d) for d in dossiers)
182+
nav = f"<nav><h2>Players</h2><ul>{nav_links}</ul></nav><hr>"
183+
184+
sections = "".join(
185+
_html_player_section(
186+
d["player"], d["stats"], d["openings"], d["profiles"], d["generated"],
187+
anchor=_slug_id(d["player"])
188+
)
189+
for d in dossiers
190+
)
191+
return (
192+
"<!doctype html><html lang='en'><head>"
193+
"<meta charset='utf-8'><title>Tournament Dossiers</title>"
194+
f"<style>{_HTML_CSS}</style></head><body>"
195+
+ nav + sections + "</body></html>"
196+
)
197+
198+
199+
def _slug_id(name: str) -> str:
200+
return re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_")
201+
202+
203+
def _esc(s: str) -> str:
204+
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
205+
206+
207+
def _html_player_section(player, stats, openings, profiles, generated, anchor=None) -> str:
208+
aid = f" id='{anchor}'" if anchor else ""
209+
ov = stats["overall"]
210+
211+
def wp_class(pct):
212+
if pct >= 55: return "wp-hi"
213+
if pct >= 40: return "wp-mid"
214+
return "wp-lo"
215+
216+
# --- profiles ---
217+
prof_html = ""
218+
if profiles:
219+
items = []
220+
for p in profiles:
221+
site = "Lichess" if "lichess" in p.get("url", "") else "chess.com"
222+
ratings = ", ".join(f"{k.capitalize()}: {v}" for k, v in p.get("ratings", {}).items())
223+
title = f"{p['title']} " if p.get("title") else ""
224+
warn = " <span class='warn'>⚠ low-confidence match</span>" if p.get("confidence") == "low" else ""
225+
rat_str = f" — {_esc(ratings)}" if ratings else ""
226+
items.append(
227+
f"<li><strong>{site}</strong>: "
228+
f"<a href='{p['url']}'>{_esc(title)}{_esc(p['display_name'])}</a>"
229+
f"{rat_str}{warn}</li>"
230+
)
231+
prof_html = f"<h2>Online Profiles</h2><ul class='profiles'>{''.join(items)}</ul>"
232+
233+
# --- overview table ---
234+
aw, ab = stats["as_white"], stats["as_black"]
235+
overview = (
236+
"<h2>Overview</h2>"
237+
"<table class='overview'>"
238+
"<tr><th></th><th>White</th><th>Black</th><th>Overall</th></tr>"
239+
f"<tr><td>Games</td><td>{aw['count']}</td><td>{ab['count']}</td><td>{stats['total']}</td></tr>"
240+
f"<tr><td>Wins</td><td>{aw['wins']}</td><td>{ab['wins']}</td><td>{ov['wins']}</td></tr>"
241+
f"<tr><td>Draws</td><td>{aw['draws']}</td><td>{ab['draws']}</td><td>{ov['draws']}</td></tr>"
242+
f"<tr><td>Losses</td><td>{aw['losses']}</td><td>{ab['losses']}</td><td>{ov['losses']}</td></tr>"
243+
f"<tr><td>Win %</td>"
244+
f"<td class='{wp_class(aw['win_pct'])}'>{aw['win_pct']}%</td>"
245+
f"<td class='{wp_class(ab['win_pct'])}'>{ab['win_pct']}%</td>"
246+
f"<td class='{wp_class(ov['win_pct'])}'>{ov['win_pct']}%</td>"
247+
"</tr></table>"
248+
f"<p><strong>Average game length:</strong> {stats['avg_length']} half-moves</p>"
249+
)
250+
251+
# --- as white ---
252+
white_body = (
253+
_html_opening_table(openings["as_white"], wp_class)
254+
if openings["as_white"] else "<p><em>No games found as White.</em></p>"
255+
)
256+
as_white = f"<div class='section-white'><h2>As White</h2>{white_body}</div>"
257+
258+
# --- as black ---
259+
black_parts = []
260+
if stats["vs_e4"]:
261+
black_parts.append(f"<h3>vs 1. e4</h3>{_html_opening_table(stats['vs_e4'], wp_class)}")
262+
if stats["vs_d4"]:
263+
black_parts.append(f"<h3>vs 1. d4</h3>{_html_opening_table(stats['vs_d4'], wp_class)}")
264+
if openings["as_black"]:
265+
black_parts.append(
266+
f"<h3>All openings as Black</h3>{_html_opening_table(openings['as_black'], wp_class)}"
267+
)
268+
if not black_parts:
269+
black_parts.append("<p><em>No games found as Black.</em></p>")
270+
as_black = f"<div class='section-black'><h2>As Black</h2>{''.join(black_parts)}</div>"
271+
272+
return (
273+
f"<section class='player-section'{aid}>"
274+
f"<h1>Dossier: {_esc(player)}</h1>"
275+
f"<p class='meta'>Generated {_esc(generated)} · {stats['total']} games analysed</p>"
276+
+ prof_html + overview + as_white + as_black
277+
+ "</section>"
278+
)
279+
280+
281+
def _html_opening_table(rows: list[dict], wp_class) -> str:
282+
header = "<tr><th>Opening</th><th>Games</th><th>W</th><th>D</th><th>L</th><th>Win%</th></tr>"
283+
def _row(r):
284+
cls = wp_class(r["win_pct"])
285+
return (
286+
f"<tr><td class='line'>{_esc(r['line'])}</td>"
287+
f"<td>{r['count']}</td><td>{r['wins']}</td>"
288+
f"<td>{r['draws']}</td><td>{r['losses']}</td>"
289+
f"<td class='{cls}'>{r['win_pct']}%</td></tr>"
290+
)
291+
return f"<table>{header}{''.join(_row(r) for r in rows)}</table>"
292+
293+
138294
def render_json(dossier: dict) -> str:
139295
return json.dumps(dossier, indent=2, ensure_ascii=False)
140296

@@ -200,7 +356,7 @@ def main() -> None:
200356
help="Opening depth in half-moves (default: 6)")
201357
parser.add_argument("--top", type=int, default=8,
202358
help="Top N opening lines per colour (default: 8)")
203-
parser.add_argument("--output", choices=["markdown", "json"], default="markdown")
359+
parser.add_argument("--output", choices=["markdown", "html", "json"], default="html")
204360
args = parser.parse_args()
205361

206362
pgn_strings: list[str] = []
@@ -231,6 +387,8 @@ def main() -> None:
231387

232388
if args.output == "json":
233389
print(render_json(dossier))
390+
elif args.output == "html":
391+
print(render_html(dossier))
234392
else:
235393
print(render_markdown(dossier))
236394

pipeline/runner.py

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
python -m pipeline.runner Challenge34
66
python -m pipeline.runner Challenge34 --site kingregistration --output-dir ./dossiers
77
python -m pipeline.runner "https://chessaction.com/tournaments/advance_entry_list.php?tid=nKGioA=="
8-
python -m pipeline.runner Challenge34 --max-games 30 --format json
8+
python -m pipeline.runner Challenge34 --max-games 30 --format html
99
10-
Output (default):
10+
Output (default html):
1111
<output-dir>/
12-
smith_john.md ← one file per opponent
13-
combined.md ← all dossiers concatenated (print-friendly)
12+
smith_john.html ← one file per opponent
13+
combined.html ← all dossiers with nav (printable)
1414
"""
1515

1616
import re
@@ -19,7 +19,7 @@
1919
from pathlib import Path
2020

2121
from scraper import scrape_entry_list
22-
from dossier.report import build_dossier, render_markdown, render_json
22+
from dossier.report import build_dossier, render_markdown, render_html, render_html_combined, render_json
2323
from pipeline.resolver import resolve_lichess, resolve_chesscom
2424

2525

@@ -72,7 +72,7 @@ def run_pipeline(
7272
chesscom_months: int = 3,
7373
depth: int = 6,
7474
top: int = 8,
75-
fmt: str = "markdown",
75+
fmt: str = "html",
7676
) -> list[Path]:
7777
"""
7878
Run the full pipeline for a tournament. Returns list of written file paths.
@@ -88,7 +88,7 @@ def run_pipeline(
8888
print(f"Found {len(players)} player(s).", file=sys.stderr)
8989

9090
written: list[Path] = []
91-
combined_parts: list[str] = []
91+
dossiers: list[dict] = []
9292

9393
for i, player in enumerate(players, 1):
9494
name = player.get("name", "").strip()
@@ -129,25 +129,34 @@ def run_pipeline(
129129

130130
dossier = build_dossier(name, pgn_strings, profiles=profiles,
131131
depth=depth, top=top)
132+
dossiers.append(dossier)
132133

133134
if fmt == "json":
134135
content = render_json(dossier)
135136
ext = "json"
137+
elif fmt == "html":
138+
content = render_html(dossier)
139+
ext = "html"
136140
else:
137141
content = render_markdown(dossier)
138142
ext = "md"
139143

140144
path = out / f"{_slug(name)}.{ext}"
141145
path.write_text(content, encoding="utf-8")
142146
written.append(path)
143-
combined_parts.append(content)
144147
print(f" Saved → {path}", file=sys.stderr)
145148

146149
# --- Combined output ---
147-
if combined_parts and fmt == "markdown":
148-
sep = "\n\n---\n\n"
150+
if dossiers and fmt == "markdown":
149151
combined = out / "combined.md"
150-
combined.write_text(sep.join(combined_parts), encoding="utf-8")
152+
combined.write_text(
153+
"\n\n---\n\n".join(render_markdown(d) for d in dossiers), encoding="utf-8"
154+
)
155+
written.append(combined)
156+
print(f"\nCombined → {combined}", file=sys.stderr)
157+
elif dossiers and fmt == "html":
158+
combined = out / "combined.html"
159+
combined.write_text(render_html_combined(dossiers), encoding="utf-8")
151160
written.append(combined)
152161
print(f"\nCombined → {combined}", file=sys.stderr)
153162

@@ -172,8 +181,8 @@ def main() -> None:
172181
help="Opening depth in half-moves (default: 6)")
173182
parser.add_argument("--top", type=int, default=8,
174183
help="Top N opening lines per colour (default: 8)")
175-
parser.add_argument("--format", dest="fmt", choices=["markdown", "json"],
176-
default="markdown")
184+
parser.add_argument("--format", dest="fmt", choices=["markdown", "html", "json"],
185+
default="html")
177186
args = parser.parse_args()
178187

179188
run_pipeline(

0 commit comments

Comments
 (0)