Skip to content

Commit be73fa3

Browse files
jlhe97claude
andauthored
Step 1: scrape tournament entry lists (kingregistration + chessaction)
* Add Playwright scraper for kingregistration.com entry lists Scrapes tournament pages at /entrylist/<id>, waits for JS rendering, extracts player table rows, and normalises column headers to canonical field names (name, rating, uscf_id, section, club, etc.). Outputs CSV or JSON to stdout; --save-html dumps rendered page for debugging. https://claude.ai/code/session_01VQfqug9MDEyRFydkmES4n2 * Simplify scraper to requests+BS4, add README Site is static HTML so Playwright is unnecessary. Replaced it with requests + BeautifulSoup — no browser install required. Added README with install instructions, usage examples, output format docs, and a roadmap for the dossier-building steps ahead. https://claude.ai/code/session_01VQfqug9MDEyRFydkmES4n2 * Add chessaction.com support, extend README scraper.py now handles both kingregistration.com and chessaction.com. Site is auto-detected from full URLs; --site flag selects it for ID shorthands. Header normalisation extended to cover chessaction column variants (pre-rating, player name, uscf#, team, etc.). README updated with a supported-sites table, per-site usage examples, and a flags reference table. https://claude.ai/code/session_01VQfqug9MDEyRFydkmES4n2 * Add tests; fix nav-table false-positive in parser 25 offline unit tests covering resolve_url, _detect_site, _normalize, and parse_entry_list with HTML fixtures for both sites and edge cases (empty table, blank rows, no table, multiple tables). Fixed parser to require at least one recognised chess column header before accepting a table, so nav/layout tables are skipped correctly. https://claude.ai/code/session_01VQfqug9MDEyRFydkmES4n2 * Add .gitignore Excludes __pycache__, bytecode, build artifacts, and common output files (csv, json, html) from version control. https://claude.ai/code/session_01VQfqug9MDEyRFydkmES4n2 * Add GitHub Actions CI workflow Runs pytest on every push and pull request using Python 3.11. https://claude.ai/code/session_01VQfqug9MDEyRFydkmES4n2 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 42e530c commit be73fa3

6 files changed

Lines changed: 536 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
jobs:
8+
test:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: actions/checkout@v4
12+
13+
- uses: actions/setup-python@v5
14+
with:
15+
python-version: "3.11"
16+
cache: pip
17+
18+
- run: pip install -r requirements.txt
19+
20+
- run: pytest tests/ -v

.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
__pycache__/
2+
*.py[cod]
3+
*.egg-info/
4+
dist/
5+
build/
6+
.venv/
7+
*.csv
8+
*.json
9+
*.html

README.md

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# Chess Dossier Builder
2+
3+
Build opponent dossiers for players registered in the same chess tournament.
4+
5+
## Step 1 — Scrape tournament entry lists
6+
7+
`scraper.py` fetches a tournament entry list and returns the registered players as CSV or JSON.
8+
9+
**Supported sites**
10+
| Site | URL pattern |
11+
|---|---|
12+
| [kingregistration.com](https://www.kingregistration.com) | `/entrylist/<id>` |
13+
| [chessaction.com](https://chessaction.com) | `/tournaments/advance_entry_list.php?tid=<id>` |
14+
15+
### Install
16+
17+
```bash
18+
pip install -r requirements.txt
19+
```
20+
21+
### Tests
22+
23+
```bash
24+
pytest tests/ -v
25+
```
26+
27+
All tests run offline using HTML fixtures — no network required.
28+
29+
### Usage
30+
31+
**kingregistration.com** (default)
32+
```bash
33+
python scraper.py Challenge34
34+
python scraper.py Challenge34 --output json
35+
python scraper.py https://www.kingregistration.com/entrylist/Challenge34
36+
```
37+
38+
**chessaction.com**
39+
```bash
40+
python scraper.py nKGioA== --site chessaction
41+
python scraper.py "https://chessaction.com/tournaments/advance_entry_list.php?tid=nKGioA=="
42+
```
43+
44+
When a full URL is passed, `--site` is auto-detected and can be omitted.
45+
46+
**All flags**
47+
```
48+
python scraper.py <tournament> [--site kingregistration|chessaction]
49+
[--output csv|json]
50+
[--save-html FILE]
51+
```
52+
53+
| Flag | Default | Description |
54+
|---|---|---|
55+
| `--site` | `kingregistration` | Site to use for ID shorthands |
56+
| `--output` | `csv` | Output format: `csv` or `json` |
57+
| `--save-html FILE` || Save the raw HTML for debugging |
58+
59+
### Output
60+
61+
**CSV (default)**
62+
```
63+
"name","rating","uscf_id","section","club","state"
64+
"Smith, John","1850","12345678","Open","Metro Chess Club","NY"
65+
```
66+
67+
**JSON**
68+
```json
69+
[
70+
{
71+
"name": "Smith, John",
72+
"rating": "1850",
73+
"uscf_id": "12345678",
74+
"section": "Open",
75+
"club": "Metro Chess Club",
76+
"state": "NY"
77+
}
78+
]
79+
```
80+
81+
Column headers are normalised automatically across both sites
82+
(e.g. `"Rtng"`, `"Pre-Rating"`, `"USCF Rating"` all map to `"rating"`).
83+
Unknown headers are passed through lowercased.
84+
85+
### Piping output
86+
87+
```bash
88+
python scraper.py Challenge34 > entries.csv
89+
python scraper.py Challenge34 --output json | jq '.[].name'
90+
```
91+
92+
### Debugging an unknown layout
93+
94+
If the scraper prints `No player table found`, run with `--save-html` and
95+
inspect the HTML to identify the right selector to add:
96+
97+
```bash
98+
python scraper.py Challenge34 --save-html page.html
99+
```
100+
101+
## Roadmap
102+
103+
- [x] Step 1 — Scrape tournament entry lists (kingregistration, chessaction)
104+
- [ ] Step 2 — Look up each player on USCF / chess.com / Lichess
105+
- [ ] Step 3 — Fetch recent games per player
106+
- [ ] Step 4 — Analyse openings and tendencies
107+
- [ ] Step 5 — Generate per-opponent dossier report

requirements.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
requests>=2.31.0
2+
beautifulsoup4>=4.12.0
3+
pytest>=8.0.0

scraper.py

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
"""
2+
Scrape tournament entry lists from kingregistration.com or chessaction.com.
3+
4+
Usage:
5+
python scraper.py <tournament> [--site kingregistration|chessaction] [--output csv|json]
6+
7+
Tournament can be:
8+
- A tournament ID shorthand (resolved using --site, default: kingregistration)
9+
- A full URL (site auto-detected; --site flag ignored)
10+
11+
Examples:
12+
python scraper.py Challenge34
13+
python scraper.py Challenge34 --output json
14+
python scraper.py nKGioA== --site chessaction
15+
python scraper.py https://www.kingregistration.com/entrylist/Challenge34
16+
python scraper.py "https://chessaction.com/tournaments/advance_entry_list.php?tid=nKGioA=="
17+
python scraper.py Challenge34 --save-html page.html
18+
"""
19+
20+
import sys
21+
import json
22+
import argparse
23+
24+
import requests
25+
from bs4 import BeautifulSoup
26+
27+
28+
# --- Site URL templates ---------------------------------------------------
29+
30+
_SITES = {
31+
"kingregistration": "https://www.kingregistration.com/entrylist/{tid}",
32+
"chessaction": "https://chessaction.com/tournaments/advance_entry_list.php?tid={tid}",
33+
}
34+
35+
_HEADERS = {
36+
"User-Agent": (
37+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
38+
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
39+
),
40+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
41+
"Accept-Language": "en-US,en;q=0.9",
42+
}
43+
44+
# Normalise common column header variants to canonical field names
45+
_HEADER_MAP = {
46+
"name": "name",
47+
"player": "name",
48+
"player name": "name",
49+
"full name": "name",
50+
"last, first": "name",
51+
"rating": "rating",
52+
"rtng": "rating",
53+
"uscf rating": "rating",
54+
"pre-rating": "rating",
55+
"pre rating": "rating",
56+
"uscf": "uscf_id",
57+
"uscf id": "uscf_id",
58+
"uscf#": "uscf_id",
59+
"id": "uscf_id",
60+
"section": "section",
61+
"division": "section",
62+
"club": "club",
63+
"team": "club",
64+
"state": "state",
65+
"grade": "grade",
66+
"school": "school",
67+
"city": "city",
68+
}
69+
70+
71+
# --- URL resolution -------------------------------------------------------
72+
73+
def _detect_site(url: str) -> str | None:
74+
for site in _SITES:
75+
if site in url:
76+
return site
77+
return None
78+
79+
80+
def resolve_url(tournament: str, site: str = "kingregistration") -> str:
81+
if tournament.startswith("http"):
82+
return tournament
83+
tid = tournament.rstrip("/").split("/")[-1]
84+
return _SITES[site].format(tid=tid)
85+
86+
87+
# --- Fetching & parsing ---------------------------------------------------
88+
89+
def fetch_html(url: str) -> str:
90+
resp = requests.get(url, headers=_HEADERS, timeout=15)
91+
resp.raise_for_status()
92+
return resp.text
93+
94+
95+
def parse_entry_list(html: str) -> list[dict]:
96+
soup = BeautifulSoup(html, "html.parser")
97+
98+
for table in soup.find_all("table"):
99+
rows = table.find_all("tr")
100+
if len(rows) < 2:
101+
continue
102+
103+
headers = [th.get_text(strip=True) for th in rows[0].find_all(["th", "td"])]
104+
if not headers:
105+
continue
106+
107+
# Skip tables with no recognised chess columns (e.g. nav/layout tables)
108+
known = {_HEADER_MAP.get(h.lower().strip()) for h in headers} - {None}
109+
if not known:
110+
continue
111+
112+
players = []
113+
for row in rows[1:]:
114+
cells = [td.get_text(strip=True) for td in row.find_all(["th", "td"])]
115+
if not cells or all(c == "" for c in cells):
116+
continue
117+
players.append(_normalize(dict(zip(headers, cells))))
118+
119+
if players:
120+
return players
121+
122+
print("No player table found. Page text preview:", file=sys.stderr)
123+
print(soup.get_text(separator="\n", strip=True)[:2000], file=sys.stderr)
124+
return []
125+
126+
127+
def _normalize(entry: dict) -> dict:
128+
return {
129+
_HEADER_MAP.get(k.lower().strip(), k.lower().strip()): v
130+
for k, v in entry.items()
131+
}
132+
133+
134+
# --- Main entry point -----------------------------------------------------
135+
136+
def scrape_entry_list(tournament: str, site: str = "kingregistration",
137+
save_html: str | None = None) -> list[dict]:
138+
# Auto-detect site from full URLs so --site flag is optional
139+
if tournament.startswith("http"):
140+
detected = _detect_site(tournament)
141+
if detected:
142+
site = detected
143+
144+
url = resolve_url(tournament, site)
145+
print(f"[{site}] Fetching: {url}", file=sys.stderr)
146+
147+
html = fetch_html(url)
148+
149+
if save_html:
150+
with open(save_html, "w", encoding="utf-8") as fh:
151+
fh.write(html)
152+
print(f"HTML saved to {save_html}", file=sys.stderr)
153+
154+
players = parse_entry_list(html)
155+
print(f"Found {len(players)} player(s).", file=sys.stderr)
156+
return players
157+
158+
159+
# --- Output helpers -------------------------------------------------------
160+
161+
def output_csv(players: list[dict]) -> None:
162+
if not players:
163+
print("No data.")
164+
return
165+
headers = list(players[0].keys())
166+
print(",".join(f'"{h}"' for h in headers))
167+
for player in players:
168+
print(",".join(f'"{player.get(h, "")}"' for h in headers))
169+
170+
171+
def output_json(players: list[dict]) -> None:
172+
print(json.dumps(players, indent=2, ensure_ascii=False))
173+
174+
175+
def main():
176+
parser = argparse.ArgumentParser(
177+
description="Scrape a chess tournament entry list.",
178+
formatter_class=argparse.RawDescriptionHelpFormatter,
179+
epilog=__doc__,
180+
)
181+
parser.add_argument("tournament", help="Tournament ID shorthand or full URL")
182+
parser.add_argument(
183+
"--site", choices=list(_SITES), default="kingregistration",
184+
help="Site to scrape (default: kingregistration). Ignored when a full URL is given.",
185+
)
186+
parser.add_argument(
187+
"--output", choices=["csv", "json"], default="csv",
188+
help="Output format (default: csv)",
189+
)
190+
parser.add_argument(
191+
"--save-html", metavar="FILE",
192+
help="Save raw HTML to FILE for debugging",
193+
)
194+
args = parser.parse_args()
195+
196+
players = scrape_entry_list(args.tournament, site=args.site, save_html=args.save_html)
197+
198+
if args.output == "json":
199+
output_json(players)
200+
else:
201+
output_csv(players)
202+
203+
204+
if __name__ == "__main__":
205+
main()

0 commit comments

Comments
 (0)