Media and entertainment

lichess.org

Lichess - Daily Puzzle: Position, Solution, Rating, Themes (HTML page)

Notes an AI agent reads before it works on lichess.org: what to open, what to call, what breaks. Written by browser-harness contributors after real runs, and kept on GitHub under the MIT license.

Give it to your agent

Any coding agent

Start the task with this line. The Markdown version is the same notes without the page around them.

Read https://browser-use.com/skills/lichess.md, then <your task>

Browser Harness

Save the files into your workspace and turn domain skills on. The code below calls its helpers, such as http_get and js. What Browser Harness is.

d="${BH_AGENT_WORKSPACE:-$HOME/.config/browser-harness/agent-workspace}/domain-skills/lichess"
mkdir -p "$d" && cd "$d"
curl -sLO https://raw.githubusercontent.com/browser-use/browser-harness/main/agent-workspace/domain-skills/lichess/scraping.md
export BH_DOMAIN_SKILLS=1

Field-tested against lichess.org on 2026-11-08 using fresh Browser Use cloud browsers (logged out) plus curl. All loads of /training/daily and its same-origin HTML re-read were serial with 2 s or more between them: HTTP 200, no 429, no CAPTCHA, no Cloudflare challenge, no cf_clearance cookie set. Player pages (/@/<user>) are different: see "What does not work".

robots.txt (lichess.org/robots.txt, read 2026-11-08): one User-agent: * group, Allow: /, disallows /game/, /@/*/download, /games/export/, /api/, /opening/config/, /study/search, /study/embed/, /embed/, /video?*, /training/of-player, then allows /game/export/gif/thumbnail/; no named AI-agent groups, so the * group applies to all of them. So /api/puzzle/daily is off limits here and the block reads only /training/daily. Terms (Terms of Service, last modified 01 August 2026): no scraping, bot or AI/ML clause; "You can use our services for your own personal or commercial use, such as in your own applications, projects, research, or products", subject to "reasonable caps, limits"; the fair play rules ban computer help in your own games, so never use this skill during a live game.

Quick summary

  • Daily puzzle: https://lichess.org/training/daily renders from a <script id="page-init-data"> JSON block that the puzzle app removes after it boots. Block 1 loads the page, then reads the same URL's HTML once more from inside the tab (fetch, same origin, no cookies) and parses that block: puzzle id, rating, plays, solution (UCI), themes, initialPly, and the source game (id, players with ratings, perf, clock, pgn up to the puzzle).
  • No FEN in the page data. The position is the game's pgn replayed to the end (initialPly + 1 plies). Block 1 carries a ~175-line stdlib chess board (no install) that replays SAN, prints the FEN and turns the UCI solution into SAN. It matched python-chess on 300 random games (25,144 plies: legal moves, SAN, FEN), and block 1 also checks it against the board lichess drew (cg-board piece elements carry their square in el.cgKey).
  • No player pages. /@/<user> serves a Cloudflare "Just a moment..." challenge to a fresh browser, so this skill does not read player ratings or games.
  • Pace: serial, 2 s after each request. The lichess API docs (lichess-org/api spec, "Rate limiting") say "Only make one request at a time" and to wait about a minute after a 429; the block keeps pages to one at a time and stops on any challenge page or non-200 HTML read.

What works

1. Today's daily puzzle: position (FEN), solution, rating, plays, themes, source game (browser)

Self-contained. Prints the puzzle and its solution for the agent; the solution is a spoiler, so pass it on to the user only when they ask for it.

import json, re, time
 
PAUSE = 2.0   # seconds after each lichess.org request, all serial (lichess API docs: "Only make one request at a time")
DAILY = "https://lichess.org/training/daily"
 
def open_lichess(url):
    """Load a lichess.org page in the attached tab (first call opens a new tab) and stop on any challenge."""
    try:
        here = js("location.origin") == "https://lichess.org"
    except Exception:
        here = False
    (goto_url if here else new_tab)(url)
    wait_for_load()
    time.sleep(PAUSE)
    title = (js("document.title") or "").lower()
    if any(s in title for s in ("just a moment", "attention required", "access denied", "too many requests")):
        raise SystemExit(f"challenge or block page on {js('location.href')}: stop and back off, do not retry around it")
 
# The page renders from <script id="page-init-data">, which the puzzle app removes once it boots, so read the
# same URL's HTML once more from inside the tab (same origin, no cookies) and parse that block.
# js() gives up after a few seconds, so start the fetch, then poll for it.
START_JS = r"""(() => { window.__li = window.__li || {}; const id = String(Math.random()).slice(2); window.__li[id] = null;
  fetch(%s, {credentials: 'omit'}).then(async r => { const t = await r.text();
    const m = t.match(/<script[^>]*id="page-init-data"[^>]*>([\s\S]*?)<\/script>/);
    window.__li[id] = {status: r.status, init: m ? m[1] : null}; })
  .catch(e => { window.__li[id] = {status: 0, init: null, error: String(e)}; });
  return id; })()"""
POLL_JS = """(() => { const v = window.__li[%s]; if (v) delete window.__li[%s]; return v; })()"""
 
def page_init(path, timeout=60):
    rid = json.dumps(js(START_JS % json.dumps(path)))
    t0 = time.time()
    while (r := js(POLL_JS % (rid, rid))) is None:
        if time.time() - t0 > timeout:
            raise RuntimeError(f"{path}: no answer after {timeout}s; stopping")
        time.sleep(0.3)
    time.sleep(PAUSE)
    if r["status"] != 200 or not r["init"]:
        raise RuntimeError(f"{path}: HTTP {r['status']}, page-init-data {'found' if r['init'] else 'missing'} {r.get('error', '')}; stopping")
    return json.loads(r["init"])
 
# ---- minimal chess board: replays the game's SAN moves to get the puzzle FEN, turns the UCI solution into SAN ----
FILES = "abcdefgh"
KNIGHT = [(1, 2), (2, 1), (2, -1), (1, -2), (-1, -2), (-2, -1), (-2, 1), (-1, 2)]
KING = [(1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1)]
LINES = {"b": [(1, 1), (1, -1), (-1, 1), (-1, -1)], "r": [(1, 0), (-1, 0), (0, 1), (0, -1)]}
LINES["q"] = LINES["b"] + LINES["r"]
 
def sq(name):
    return FILES.index(name[0]) + 8 * (int(name[1]) - 1)
 
def sqname(i):
    return FILES[i % 8] + str(i // 8 + 1)
 
def white(p):
    return p.isupper()
 
class Board:
    def __init__(self):
        back = "RNBQKBNR"
        self.b = [None] * 64
        for f in range(8):
            self.b[f], self.b[8 + f], self.b[48 + f], self.b[56 + f] = back[f], "P", "p", back[f].lower()
        self.turn_white, self.castling, self.ep, self.half, self.full = True, set("KQkq"), None, 0, 1
 
    def copy(self):
        c = Board.__new__(Board)
        c.b, c.turn_white, c.castling, c.ep, c.half, c.full = self.b[:], self.turn_white, set(self.castling), self.ep, self.half, self.full
        return c
 
    def attacks(self, i):
        """Squares the piece on i attacks (pawns: diagonal captures only)."""
        p, f, r = self.b[i], i % 8, i // 8
        kind, out = p.lower(), []
        if kind == "p":
            d = 1 if white(p) else -1
            return [(r + d) * 8 + f + df for df in (-1, 1) if 0 <= f + df < 8 and 0 <= r + d < 8]
        if kind in "nk":
            for df, dr in (KNIGHT if kind == "n" else KING):
                if 0 <= f + df < 8 and 0 <= r + dr < 8:
                    out.append((r + dr) * 8 + f + df)
            return out
        for df, dr in LINES[kind]:
            ff, rr = f + df, r + dr
            while 0 <= ff < 8 and 0 <= rr < 8:
                out.append(rr * 8 + ff)
                if self.b[rr * 8 + ff]:
                    break
                ff, rr = ff + df, rr + dr
        return out
 
    def in_check(self, white_king):
        k = self.b.index("K" if white_king else "k")
        return any(p and white(p) != white_king and k in self.attacks(i) for i, p in enumerate(self.b))
 
    def pseudo(self):
        """(from, to, promo) for the side to move; castling only when the path is empty, checks tested in legal()."""
        out = []
        for i, p in enumerate(self.b):
            if not p or white(p) != self.turn_white:
                continue
            if p.lower() == "p":
                d, f, r = (8 if white(p) else -8), i % 8, i // 8
                last = 7 if white(p) else 0
                targets = [t for t in self.attacks(i) if (self.b[t] and white(self.b[t]) != white(p)) or t == self.ep]
                if not self.b[i + d]:
                    targets.append(i + d)
                    if r == (1 if white(p) else 6) and not self.b[i + 2 * d]:
                        targets.append(i + 2 * d)
                for t in targets:
                    out += [(i, t, q) for q in "qrbn"] if t // 8 == last else [(i, t, None)]
            else:
                out += [(i, t, None) for t in self.attacks(i) if not self.b[t] or white(self.b[t]) != white(p)]
                if p.lower() == "k" and i == (4 if white(p) else 60):
                    for side, rook, path in (("K", 7, [5, 6]), ("Q", 0, [1, 2, 3])):
                        right = side if white(p) else side.lower()
                        if right in self.castling and all(not self.b[i - 4 + s] for s in path):
                            out.append((i, i + (2 if side == "K" else -2), None))
        return out
 
    def legal(self):
        res = []
        for m in self.pseudo():
            fr, to, _ = m
            if self.b[fr].lower() == "k" and abs(to - fr) == 2:   # castling: not out of, through or into check
                mid = (fr + to) // 2
                if self.in_check(self.turn_white) or \
                   any(mid in self.attacks(i) for i, p in enumerate(self.b) if p and white(p) != self.turn_white):
                    continue
            if not self.push(m).in_check(self.turn_white):
                res.append(m)
        return res
 
    def push(self, m):
        fr, to, promo = m
        n, p = self.copy(), self.b[fr]
        capture = self.b[to] is not None
        if p.lower() == "p" and to == self.ep:
            n.b[to + (-8 if white(p) else 8)] = None
            capture = True
        if p.lower() == "k" and abs(to - fr) == 2:
            rf, rt = (fr + 3, fr + 1) if to > fr else (fr - 4, fr - 1)
            n.b[rt], n.b[rf] = n.b[rf], None
        n.b[to], n.b[fr] = (promo.upper() if white(p) else promo) if promo else p, None
        if p.lower() == "k":
            n.castling -= {"K", "Q"} if white(p) else {"k", "q"}
        for corner, right in ((0, "Q"), (7, "K"), (56, "q"), (63, "k")):
            if corner in (fr, to):
                n.castling.discard(right)
        n.ep = (fr + to) // 2 if p.lower() == "p" and abs(to - fr) == 16 else None
        n.half = 0 if p.lower() == "p" or capture else self.half + 1
        n.full = self.full + (0 if self.turn_white else 1)
        n.turn_white = not self.turn_white
        return n
 
    def san(self, m):
        fr, to, promo = m
        p = self.b[fr]
        if p.lower() == "k" and abs(to - fr) == 2:
            s = "O-O" if to > fr else "O-O-O"
        else:
            capture = self.b[to] is not None or (p.lower() == "p" and to == self.ep)
            if p.lower() == "p":
                s = (FILES[fr % 8] + "x" if capture else "") + sqname(to) + ("=" + promo.upper() if promo else "")
            else:
                rivals = [o for o, t, _ in self.legal() if t == to and o != fr and self.b[o] == p]
                dis = ""
                if rivals:
                    if all(o % 8 != fr % 8 for o in rivals):
                        dis = FILES[fr % 8]
                    elif all(o // 8 != fr // 8 for o in rivals):
                        dis = str(fr // 8 + 1)
                    else:
                        dis = sqname(fr)
                s = p.upper() + dis + ("x" if capture else "") + sqname(to)
        n = self.push(m)
        if n.in_check(n.turn_white):
            s += "#" if not n.legal() else "+"
        return s
 
    def push_san(self, san):
        want = san.rstrip("+#!?")
        if want in ("O-O", "O-O-O"):
            cands = [m for m in self.legal() if self.b[m[0]].lower() == "k" and abs(m[1] - m[0]) == 2 and (m[1] > m[0]) == (want == "O-O")]
        else:
            g = re.fullmatch(r"([NBRQK])?([a-h])?([1-8])?x?([a-h][1-8])(?:=?([QRBN]))?", want)
            if not g:
                raise ValueError(f"unreadable SAN {san!r}")
            piece, df, dr, to, promo = g.groups()
            cands = [m for m in self.legal() if m[1] == sq(to) and self.b[m[0]].upper() == (piece or "P")
                     and m[2] == (promo.lower() if promo else None)
                     and (not df or FILES[m[0] % 8] == df) and (not dr or str(m[0] // 8 + 1) == dr)]
        if len(cands) != 1:
            raise ValueError(f"SAN {san!r} matches {len(cands)} legal moves")
        return self.push(cands[0])
 
    def push_uci(self, uci):
        m = (sq(uci[:2]), sq(uci[2:4]), uci[4:] or None)
        if m not in self.legal():
            raise ValueError(f"illegal UCI {uci!r}")
        return self.push(m)
 
    def fen(self):
        rows = []
        for r in range(7, -1, -1):
            row, gap = "", 0
            for f in range(8):
                p = self.b[r * 8 + f]
                if p:
                    row, gap = row + (str(gap) if gap else "") + p, 0
                else:
                    gap += 1
            rows.append(row + (str(gap) if gap else ""))
        ep = "-"   # like lichess / python-chess: only when an en passant capture is actually legal
        if self.ep is not None and any(m[1] == self.ep and self.b[m[0]].lower() == "p" for m in self.legal()):
            ep = sqname(self.ep)
        castle = "".join(c for c in "KQkq" if c in self.castling) or "-"
        return f"{'/'.join(rows)} {'w' if self.turn_white else 'b'} {castle} {ep} {self.half} {self.full}"
 
# ---- the task ----
open_lichess(DAILY)
init = page_init("/training/daily")["data"]
pz, game = init["puzzle"], init["game"]
moves = game["pgn"].split()
if len(moves) != pz["initialPly"] + 1:
    raise RuntimeError(f"pgn has {len(moves)} plies, initialPly {pz['initialPly']}: puzzle format changed")
 
board = Board()
for san in moves:
    board = board.push_san(san)
fen = board.fen()
 
# Check the replay against the board lichess actually drew (chessground keeps each piece's square in el.cgKey).
drawn = js("""[...document.querySelectorAll('cg-board piece:not(.ghost)')].map(p => [p.cgKey, p.className])""")
mine = {sqname(i): ("white " if white(p) else "black ") + {"p": "pawn", "n": "knight", "b": "bishop", "r": "rook", "q": "queen", "k": "king"}[p.lower()]
        for i, p in enumerate(board.b) if p}
if {k: c for k, c in drawn} != mine:
    raise RuntimeError(f"replayed position differs from the drawn board: {sorted(set(mine.items()) ^ set(map(tuple, drawn)))}")
 
line, b = [], board
for u in pz["solution"]:
    m = (sq(u[:2]), sq(u[2:4]), u[4:] or None)
    line.append(b.san(m))
    b = b.push_uci(u)  # raises on an illegal move, so bad page data never prints a made-up line
 
to_move = "white" if board.turn_white else "black"
print(f"daily puzzle {pz['id']}  https://lichess.org/training/{pz['id']}  (read {time.strftime('%Y-%m-%d %H:%M UTC', time.gmtime())})")
print(f"rating {pz['rating']} | plays {pz['plays']} | themes {pz['themes']} | isDaily {init['isDaily']}")
print(f"{to_move} to move after {moves[-1]} (ply {pz['initialPly'] + 1}); FEN {fen}")
feedback = js("(document.querySelector('.puzzle__feedback') || {}).innerText") or ""
print("page says:", " / ".join(feedback.split("\n")[:2]))   # the rest is the hint / solution buttons; never click them
print(f"solution UCI {pz['solution']}  SAN {' '.join(line)}  -> final FEN {b.fen()}")
w, bl = game["players"]
print(f"from game {game['id']} ({'rated ' if game['rated'] else ''}{game['perf']['name']} {game['clock']}): "
      f"{w['name']} ({w['rating']}) vs {bl['name']} ({bl['rating']})")
print("game moves before the puzzle:", " ".join(f"{i // 2 + 1}.{m}" if i % 2 == 0 else m for i, m in enumerate(moves)))

Output (from a run in a fresh cloud browser on 2026-11-08):

daily puzzle pnNfB  https://lichess.org/training/pnNfB  (read 2026-11-08 23:49 UTC)
rating 2144 | plays 25768 | themes ['mateIn2', 'middlegame', 'short', 'sacrifice', 'deflection', 'bodenMate'] | isDaily True
white to move after h5 (ply 36); FEN r1bqk2r/1p1n1pb1/4p1p1/1P1pP2p/pB1P1PQP/3B1N2/1P4P1/3K3R w kq - 0 19
page says: Your turn / Find the best move for white.
solution UCI ['g4e6', 'f7e6', 'd3g6']  SAN Qxe6+ fxe6 Bxg6#  -> final FEN r1bqk2r/1p1n2b1/4p1B1/1P1pP2p/pB1P1P1P/5N2/1P4P1/3K3R b kq - 0 20
from game F3H4Ogo2 (rated Blitz 3+2): mando_emiliano (1712) vs taufikBRA (1747)
game moves before the puzzle: 1.e4 e6 2.d4 d5 3.Nd2 Nf6 4.e5 Nfd7 5.Qg4 c5 6.c3 Nc6 7.Nb3 cxd4 8.cxd4 Nb4 9.Bd2 Nc2+ 10.Kd1 Nxa1 11.Bd3 Nxb3 12.axb3 g6 13.f4 Bg7 14.Nf3 Qb6 15.b4 a5 16.b5 a4 17.Bb4 Qd8 18.h4 h5

Fields

FieldWhereNotes
puzzle.id, .rating, .playsdaily page-init-datahttps://lichess.org/training/<id> is the puzzle's own page; the og:title says "Chess tactic #<id> - White to play"
puzzle.solution[]daily page-init-dataUCI, solver's moves and replies alternating; the solver moves first
puzzle.themes[]daily page-init-datatheme keys (mateIn2, sacrifice, bodenMate, ...); not shown on the page until the puzzle is solved
puzzle.initialPlydaily page-init-dataindex of the last game move before the puzzle; pgn has initialPly + 1 moves
game.id, .perf, .clock, .rated, .players[]daily page-init-datathe finished game the puzzle came from; players carry name, id, color, rating
isDaily, angledaily page-init-dataisDaily: true on /training/daily

Gotchas

  • Puzzle rating and plays move during the day. Reads of the same daily puzzle on 2026-11-08: 2008 / 24,453 plays, then 2085 / 24,646 at 23:26 UTC, 2052 / 24,828 at 23:30, 1971 / 24,921 at 23:32, 1958 / 25,025 at 23:33, 1985 / 25,681 at 23:47, 2144 / 25,768 at 23:49. Print the read time with the numbers.
  • page-init-data is gone from the live DOM after the puzzle app boots (document.scripts has no such block), which is why the block re-reads the HTML. It is the page's own URL, not an API.
  • The tab title gets a horse marker from the harness (🐴 Just a moment...): match with includes, not startsWith.

What does not work

  • Player pages /@/<user>: a fresh logged-out cloud browser got a Cloudflare "Just a moment..." challenge on the first load (2 of 2 fresh browsers on 2026-11-08; cf_clearance cookie set only after it); curl gets a Cloudflare HTTP 403. /training/daily set no such cookie. Not worked around: no player ratings or game counts here.
  • /api/ (daily puzzle, user, games export) and /@/<user>/download: disallowed by robots.txt for every user agent. Not used.
  • Not tested: solving the puzzle on the page (hint / solution buttons were never clicked), logged-in views, puzzle pages other than the daily one.

scraping.md · updated Nov 8, 2026 · 2,708 words

Sites change. These notes describe what worked when they were last updated, not a guarantee they still do. If something broke, the fix is a pull request to browser-harness.