Skip to content

Commit 500ef83

Browse files
jlhe97claude
andauthored
Step 3: Lichess and chess.com player lookup
lookup/lichess.py: name search via autocomplete API, profile fetch, game retrieval as ndjson or PGN (classical/rapid/blitz, configurable). lookup/chesscom.py: profile + stats fetch, monthly game archive retrieval as JSON or PGN, username guesser that derives candidates from 'Last, First' or 'First Last' format and tries each against the API until one resolves. 26 new tests covering profile slimming, search, game parsing, username guessing, month generation, HTTP error handling, and find_profile fallback. All HTTP calls mocked — no network required. README updated with Step 3 usage docs and Python API examples. https://claude.ai/code/session_01VQfqug9MDEyRFydkmES4n2 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 71b29fd commit 500ef83

5 files changed

Lines changed: 636 additions & 1 deletion

File tree

README.md

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,10 +134,61 @@ for game in games:
134134
print(game["pgn"])
135135
```
136136

137+
## Step 3 — Online profile lookup (Lichess & chess.com)
138+
139+
Given a player name from the tournament entry list, find their online profiles and fetch recent games.
140+
141+
### Lichess
142+
143+
```bash
144+
# Search by name → candidate usernames
145+
python -m lookup.lichess search "Magnus Carlsen"
146+
147+
# Fetch profile by known username
148+
python -m lookup.lichess profile DrNykterstein
149+
150+
# Fetch recent games (PGN or JSON)
151+
python -m lookup.lichess games DrNykterstein
152+
python -m lookup.lichess games DrNykterstein --max 20 --output json
153+
python -m lookup.lichess games DrNykterstein --perf classical
154+
```
155+
156+
### chess.com
157+
158+
chess.com has no public search endpoint. Use `find` to try common username
159+
patterns derived from the player name, or `profile` if the username is known.
160+
161+
```bash
162+
# Guess username from name and try each candidate
163+
python -m lookup.chesscom find "Carlsen, Magnus"
164+
165+
# Fetch profile by known username
166+
python -m lookup.chesscom profile MagnusCarlsen
167+
168+
# Fetch recent games (last 3 months by default)
169+
python -m lookup.chesscom games MagnusCarlsen
170+
python -m lookup.chesscom games MagnusCarlsen --months 6 --output json
171+
```
172+
173+
### Python API
174+
175+
```python
176+
from lookup.lichess import search, get_games
177+
from lookup.chesscom import find_profile, games_as_pgn
178+
179+
# Lichess
180+
candidates = search("Smith, John") # returns list of profile dicts
181+
pgn = get_games("username", max=50)
182+
183+
# chess.com
184+
profile = find_profile("Smith, John") # tries username guesses, returns first match
185+
pgn = games_as_pgn("username", months=3)
186+
```
187+
137188
## Roadmap
138189

139190
- [x] Step 1 — Scrape tournament entry lists (kingregistration, chessaction)
140191
- [x] Step 2 — Index ChessBase MegaDatabase for fast player lookups
141-
- [ ] Step 3 — Look up each player on USCF / chess.com / Lichess
192+
- [x] Step 3 — Look up each player on Lichess and chess.com
142193
- [ ] Step 4 — Analyse openings and tendencies
143194
- [ ] Step 5 — Generate per-opponent dossier report

lookup/__init__.py

Whitespace-only changes.

lookup/chesscom.py

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
"""
2+
chess.com player lookup and game retrieval.
3+
4+
chess.com has no public search endpoint — a username must be known.
5+
Use `guess_usernames(name)` to generate common patterns to try.
6+
7+
Usage:
8+
python -m lookup.chesscom profile Magnus_Carlsen
9+
python -m lookup.chesscom games Magnus_Carlsen
10+
python -m lookup.chesscom games Magnus_Carlsen --months 3 --output json
11+
12+
API used (no auth required):
13+
https://api.chess.com/pub/player/{username} — profile
14+
https://api.chess.com/pub/player/{username}/stats — ratings
15+
https://api.chess.com/pub/player/{username}/games/{y}/{m} — monthly archives
16+
"""
17+
18+
import sys
19+
import json
20+
import argparse
21+
import calendar
22+
from datetime import date, timedelta
23+
24+
import requests
25+
26+
_BASE = "https://api.chess.com/pub"
27+
_HEADERS = {
28+
"User-Agent": "chess-dossier-builder/1.0",
29+
}
30+
31+
32+
def _get(url: str) -> requests.Response:
33+
resp = requests.get(url, headers=_HEADERS, timeout=15)
34+
resp.raise_for_status()
35+
return resp
36+
37+
38+
def guess_usernames(name: str) -> list[str]:
39+
"""
40+
Generate plausible chess.com usernames from a 'Last, First' or 'First Last' name.
41+
Returns candidates ordered by likelihood — caller should try each with get_profile().
42+
"""
43+
name = name.strip()
44+
if "," in name:
45+
last, _, first = name.partition(",")
46+
last, first = last.strip(), first.strip()
47+
else:
48+
parts = name.split()
49+
first, last = parts[0], parts[-1]
50+
51+
f, l = first.lower(), last.lower()
52+
return [
53+
f"{f}{l}", f"{l}{f}", f"{f}_{l}", f"{l}_{f}",
54+
f"{f[0]}{l}", f"{l}{f[0]}", f"{f}.{l}",
55+
f"{f}", f"{l}",
56+
]
57+
58+
59+
def get_profile(username: str) -> dict:
60+
"""Fetch profile and ratings for a chess.com username. Raises on 404."""
61+
profile = _get(f"{_BASE}/player/{username}").json()
62+
try:
63+
stats = _get(f"{_BASE}/player/{username}/stats").json()
64+
except requests.HTTPError:
65+
stats = {}
66+
67+
return _slim_profile(username, profile, stats)
68+
69+
70+
def find_profile(name: str) -> dict | None:
71+
"""
72+
Try guessed usernames until one resolves. Returns first match or None.
73+
"""
74+
for username in guess_usernames(name):
75+
try:
76+
return get_profile(username)
77+
except requests.HTTPError:
78+
continue
79+
return None
80+
81+
82+
def get_games(username: str, months: int = 3) -> list[dict]:
83+
"""
84+
Fetch games from the last `months` monthly archives.
85+
Returns a flat list of game dicts (chess.com native format).
86+
"""
87+
games = []
88+
for year, month in _recent_months(months):
89+
try:
90+
data = _get(f"{_BASE}/player/{username}/games/{year}/{month:02d}").json()
91+
games.extend(data.get("games", []))
92+
except requests.HTTPError:
93+
continue
94+
return games
95+
96+
97+
def games_as_pgn(username: str, months: int = 3) -> str:
98+
"""Fetch games from recent archives and return as a single PGN string."""
99+
pgns = []
100+
for year, month in _recent_months(months):
101+
try:
102+
data = _get(f"{_BASE}/player/{username}/games/{year}/{month:02d}/pgn").text
103+
pgns.append(data)
104+
except requests.HTTPError:
105+
continue
106+
return "\n\n".join(pgns)
107+
108+
109+
def _recent_months(n: int) -> list[tuple[int, int]]:
110+
months = []
111+
d = date.today().replace(day=1)
112+
for _ in range(n):
113+
months.append((d.year, d.month))
114+
d = (d - timedelta(days=1)).replace(day=1)
115+
return months
116+
117+
118+
def _slim_profile(username: str, profile: dict, stats: dict) -> dict:
119+
ratings = {}
120+
for key, label in (
121+
("chess_classical", "classical"),
122+
("chess_rapid", "rapid"),
123+
("chess_blitz", "blitz"),
124+
("chess_bullet", "bullet"),
125+
):
126+
if key in stats and "last" in stats[key]:
127+
ratings[label] = stats[key]["last"]["rating"]
128+
129+
return {
130+
"username": username,
131+
"display_name": profile.get("name") or profile.get("username", username),
132+
"title": profile.get("title"),
133+
"ratings": ratings,
134+
"url": profile.get("url", f"https://www.chess.com/member/{username}"),
135+
"country": profile.get("country", "").split("/")[-1],
136+
}
137+
138+
139+
def main() -> None:
140+
parser = argparse.ArgumentParser(description="chess.com player lookup.")
141+
sub = parser.add_subparsers(dest="cmd", required=True)
142+
143+
p_profile = sub.add_parser("profile", help="Fetch profile by username")
144+
p_profile.add_argument("username")
145+
146+
p_find = sub.add_parser("find", help="Guess username from a player name")
147+
p_find.add_argument("name", help="Player name e.g. 'Carlsen, Magnus'")
148+
149+
p_games = sub.add_parser("games", help="Fetch recent games by username")
150+
p_games.add_argument("username")
151+
p_games.add_argument("--months", type=int, default=3)
152+
p_games.add_argument("--output", choices=["json", "pgn"], default="pgn")
153+
154+
args = parser.parse_args()
155+
156+
if args.cmd == "profile":
157+
print(json.dumps(get_profile(args.username), indent=2))
158+
159+
elif args.cmd == "find":
160+
result = find_profile(args.name)
161+
if result:
162+
print(json.dumps(result, indent=2))
163+
else:
164+
print(f"No chess.com profile found for '{args.name}'.", file=sys.stderr)
165+
sys.exit(1)
166+
167+
elif args.cmd == "games":
168+
if args.output == "pgn":
169+
print(games_as_pgn(args.username, months=args.months))
170+
else:
171+
games = get_games(args.username, months=args.months)
172+
print(f"Fetched {len(games)} game(s).", file=sys.stderr)
173+
print(json.dumps(games, indent=2))
174+
175+
176+
if __name__ == "__main__":
177+
main()

lookup/lichess.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"""
2+
Lichess player lookup and game retrieval.
3+
4+
Usage:
5+
python -m lookup.lichess search "Magnus Carlsen"
6+
python -m lookup.lichess profile thibault
7+
python -m lookup.lichess games thibault
8+
python -m lookup.lichess games thibault --max 20 --output json
9+
10+
API used (no auth required):
11+
https://lichess.org/api/users/autocomplete — name → username candidates
12+
https://lichess.org/api/user/{username} — profile + ratings
13+
https://lichess.org/api/games/user/{username} — PGN/ndjson game stream
14+
"""
15+
16+
import sys
17+
import json
18+
import argparse
19+
import time
20+
21+
import requests
22+
23+
_BASE = "https://lichess.org/api"
24+
_HEADERS = {
25+
"User-Agent": "chess-dossier-builder/1.0",
26+
"Accept": "application/json",
27+
}
28+
_RATE_DELAY = 1.0 # seconds between requests to stay within rate limits
29+
30+
31+
def _get(path: str, params: dict | None = None, accept: str = "application/json") -> requests.Response:
32+
headers = {**_HEADERS, "Accept": accept}
33+
resp = requests.get(f"{_BASE}{path}", headers=headers, params=params, timeout=15)
34+
resp.raise_for_status()
35+
return resp
36+
37+
38+
def search(name: str, max_results: int = 5) -> list[dict]:
39+
"""
40+
Search for Lichess users by display name or username.
41+
Returns a list of candidate profiles (id, username, title, ratings).
42+
"""
43+
resp = _get("/users/autocomplete", params={"term": name, "object": "true"})
44+
data = resp.json()
45+
users = data if isinstance(data, list) else data.get("result", [])
46+
return [_slim_profile(u) for u in users[:max_results]]
47+
48+
49+
def get_profile(username: str) -> dict:
50+
"""Fetch full profile for a known username."""
51+
resp = _get(f"/user/{username}")
52+
return _slim_profile(resp.json())
53+
54+
55+
def get_games(username: str, max: int = 50,
56+
perf_types: str = "classical,rapid,blitz") -> list[dict]:
57+
"""
58+
Fetch up to `max` recent games for a Lichess user.
59+
Returns a list of dicts with PGN and metadata.
60+
"""
61+
time.sleep(_RATE_DELAY)
62+
resp = requests.get(
63+
f"{_BASE}/games/user/{username}",
64+
headers={**_HEADERS, "Accept": "application/x-ndjson"},
65+
params={"max": max, "perfType": perf_types, "clocks": "false", "evals": "false"},
66+
timeout=30,
67+
stream=True,
68+
)
69+
resp.raise_for_status()
70+
71+
games = []
72+
for line in resp.iter_lines():
73+
if line:
74+
games.append(json.loads(line))
75+
return games
76+
77+
78+
def games_as_pgn(username: str, max: int = 50,
79+
perf_types: str = "classical,rapid,blitz") -> str:
80+
"""Fetch games and return as a single PGN string."""
81+
time.sleep(_RATE_DELAY)
82+
resp = requests.get(
83+
f"{_BASE}/games/user/{username}",
84+
headers={**_HEADERS, "Accept": "application/x-chess-pgn"},
85+
params={"max": max, "perfType": perf_types, "clocks": "false", "evals": "false"},
86+
timeout=30,
87+
)
88+
resp.raise_for_status()
89+
return resp.text
90+
91+
92+
def _slim_profile(data: dict) -> dict:
93+
perfs = data.get("perfs", {})
94+
return {
95+
"username": data.get("id") or data.get("username", ""),
96+
"display_name": data.get("username", ""),
97+
"title": data.get("title"),
98+
"ratings": {
99+
k: perfs[k]["rating"]
100+
for k in ("classical", "rapid", "blitz", "bullet")
101+
if k in perfs and "rating" in perfs[k]
102+
},
103+
"url": f"https://lichess.org/@/{data.get('id') or data.get('username', '')}",
104+
}
105+
106+
107+
def main() -> None:
108+
parser = argparse.ArgumentParser(description="Lichess player lookup.")
109+
sub = parser.add_subparsers(dest="cmd", required=True)
110+
111+
p_search = sub.add_parser("search", help="Search users by name")
112+
p_search.add_argument("name")
113+
p_search.add_argument("--max", type=int, default=5)
114+
115+
p_profile = sub.add_parser("profile", help="Fetch profile by username")
116+
p_profile.add_argument("username")
117+
118+
p_games = sub.add_parser("games", help="Fetch recent games by username")
119+
p_games.add_argument("username")
120+
p_games.add_argument("--max", type=int, default=50)
121+
p_games.add_argument("--output", choices=["json", "pgn"], default="pgn")
122+
p_games.add_argument("--perf", default="classical,rapid,blitz")
123+
124+
args = parser.parse_args()
125+
126+
if args.cmd == "search":
127+
results = search(args.name, max_results=args.max)
128+
print(json.dumps(results, indent=2))
129+
130+
elif args.cmd == "profile":
131+
print(json.dumps(get_profile(args.username), indent=2))
132+
133+
elif args.cmd == "games":
134+
if args.output == "pgn":
135+
print(games_as_pgn(args.username, max=args.max, perf_types=args.perf))
136+
else:
137+
games = get_games(args.username, max=args.max, perf_types=args.perf)
138+
print(f"Fetched {len(games)} game(s).", file=sys.stderr)
139+
print(json.dumps(games, indent=2))
140+
141+
142+
if __name__ == "__main__":
143+
main()

0 commit comments

Comments
 (0)