├── .prettierignore ├── .gitignore ├── scss ├── _navbar.scss ├── style.scss └── _bootstrap.scss ├── public └── favicon.ico ├── .prettierrc.json ├── vite.config.ts ├── src ├── interfaces.ts ├── endpoints.ts ├── app.ts ├── main.ts ├── model.ts ├── page │ ├── home.ts │ ├── puzzleRace.ts │ ├── bulkList.ts │ ├── openChallenge.ts │ ├── bulkNew.ts │ └── bulkShow.ts ├── util.ts ├── ndJsonStream.ts ├── form.ts ├── routing.ts ├── view │ ├── util.ts │ ├── layout.ts │ └── form.ts ├── auth.ts └── scraper │ ├── tests │ ├── fixtures │ │ ├── team-swiss-pairings-with-usernames.html │ │ ├── team-round-robin-pairings.html │ │ ├── individual-round-robin-pairings.html │ │ ├── team-ko-pairings-with-usernames.html │ │ ├── players-list-with-usernames.html │ │ └── individual-swiss-pairings.html │ └── scrape.test.ts │ └── scraper.ts ├── .github └── workflows │ ├── ci.yml │ └── deploy.yml ├── index.html ├── tsconfig.json ├── README.md └── package.json /.prettierignore: -------------------------------------------------------------------------------- 1 | /pnpm-lock.yaml 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist/ 2 | /node_modules/ 3 | -------------------------------------------------------------------------------- /scss/_navbar.scss: -------------------------------------------------------------------------------- 1 | #navbarSupportedContent { 2 | justify-content: flex-end; 3 | } 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lichess-org/api-ui/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 110, 4 | "arrowParens": "avoid" 5 | } 6 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: 'jsdom', 6 | }, 7 | }); 8 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | import type { VNode } from 'snabbdom'; 2 | 3 | export type MaybeVNodes = VNode | (VNode | string | undefined)[]; 4 | export type Redraw = (ui: VNode) => void; 5 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | ci: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | script: 14 | - test 15 | - tsc 16 | - check-format 17 | - build 18 | steps: 19 | - uses: actions/checkout@v5 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version: 24 23 | - uses: pnpm/action-setup@v4 24 | with: 25 | run_install: true 26 | - run: pnpm ${{ matrix.script }} 27 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | 13 | Lichess API UI 14 | 15 | 16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/endpoints.ts: -------------------------------------------------------------------------------- 1 | interface Endpoint { 2 | name: string; 3 | desc: string; 4 | path: string; 5 | } 6 | 7 | export const bulkPairing: Endpoint = { 8 | name: 'Schedule games', 9 | desc: 'Requires Lichess admin permissions', 10 | path: '/endpoint/schedule-games', 11 | }; 12 | 13 | export const endpoints: Endpoint[] = [ 14 | { 15 | name: 'Open challenge', 16 | desc: 'Create a game that any two players can join', 17 | path: '/endpoint/open-challenge', 18 | }, 19 | bulkPairing, 20 | { 21 | name: 'Puzzle race', 22 | desc: 'Create a private puzzle race with an invite link', 23 | path: '/endpoint/puzzle-race', 24 | }, 25 | ]; 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 | "types": ["vite/client"], 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "verbatimModuleSyntax": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "erasableSyntaxOnly": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true 24 | }, 25 | "include": ["src"] 26 | } 27 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import { h } from 'snabbdom'; 2 | import { Auth } from './auth'; 3 | import type { Redraw } from './interfaces'; 4 | import layout from './view/layout'; 5 | 6 | export interface Config { 7 | lichessHost: string; 8 | } 9 | 10 | export class App { 11 | auth: Auth; 12 | config: Config; 13 | redraw: Redraw; 14 | 15 | constructor(config: Config, redraw: Redraw) { 16 | this.config = config; 17 | this.redraw = redraw; 18 | this.auth = new Auth(config.lichessHost); 19 | } 20 | 21 | notFound = () => this.redraw(layout(this, h('div', [h('h1.mt-5', 'Not Found')]))); 22 | 23 | tooManyRequests = () => 24 | this.redraw( 25 | layout( 26 | this, 27 | h('div', [h('h1.mt-5', 'Too many requests'), h('p.lead', 'Please wait a little then try again.')]), 28 | ), 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /scss/style.scss: -------------------------------------------------------------------------------- 1 | @use './bootstrap'; 2 | @use './navbar'; 3 | 4 | body > .container { 5 | min-height: 60vh; 6 | } 7 | 8 | .login { 9 | .about { 10 | margin: 10vh 10vw; 11 | } 12 | .big { 13 | font-size: 2rem; 14 | } 15 | & .btn { 16 | font-size: 3rem; 17 | } 18 | } 19 | 20 | .about { 21 | padding: 5em 0; 22 | } 23 | 24 | .loading { 25 | display: flex; 26 | justify-content: center; 27 | align-items: center; 28 | min-height: 70vh; 29 | } 30 | 31 | .navbar-brand { 32 | .lichess-logo-white { 33 | opacity: 0.8; 34 | max-height: 30px; 35 | } 36 | } 37 | 38 | .bd-footer { 39 | a { 40 | color: var(--bs-body-color); 41 | text-decoration: none; 42 | } 43 | .lichess-logo-white { 44 | max-height: 30px; 45 | opacity: 0.8; 46 | } 47 | } 48 | 49 | .input-copy { 50 | cursor: pointer; 51 | } 52 | 53 | .mono { 54 | font-family: var(--bs-font-monospace) !important; 55 | } 56 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { init, attributesModule, eventListenersModule, classModule, type VNode } from 'snabbdom'; 2 | import { loadingBody } from './view/util'; 3 | import '../scss/style.scss'; 4 | import 'bootstrap/js/dist/dropdown.js'; 5 | import 'bootstrap/js/dist/collapse.js'; 6 | import routing from './routing'; 7 | import { App, type Config } from './app'; 8 | 9 | const config: Config = { 10 | lichessHost: localStorage.getItem('lichessHost') || 'https://lichess.org', 11 | }; 12 | 13 | async function attach(element: HTMLDivElement) { 14 | const patch = init([attributesModule, eventListenersModule, classModule]); 15 | 16 | const app = new App(config, redraw); 17 | 18 | let vnode = patch(element, loadingBody()); 19 | 20 | function redraw(ui: VNode) { 21 | vnode = patch(vnode, ui); 22 | } 23 | 24 | await app.auth.init(); 25 | routing(app); 26 | } 27 | 28 | attach(document.querySelector('#app')!); 29 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Publish the Lichess API UI app 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: read 10 | pages: write 11 | id-token: write 12 | 13 | concurrency: 14 | group: pages 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | deploy: 19 | environment: 20 | name: github-pages 21 | url: ${{ steps.deployment.outputs.page_url }} 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v5 25 | - uses: actions/setup-node@v4 26 | with: 27 | node-version: 24 28 | - uses: pnpm/action-setup@v4 29 | with: 30 | run_install: true 31 | - run: pnpm build --base=/api/ui 32 | - uses: actions/configure-pages@v5 33 | - uses: actions/upload-pages-artifact@v4 34 | with: 35 | path: dist 36 | - uses: actions/deploy-pages@v4 37 | id: deployment 38 | -------------------------------------------------------------------------------- /src/model.ts: -------------------------------------------------------------------------------- 1 | export interface Bulk { 2 | id: BulkId; 3 | games: BulkGame[]; 4 | variant: string; 5 | rated: boolean; 6 | rules: Rule[]; 7 | pairAt: Date; 8 | startClocksAt?: number; 9 | scheduledAt: number; 10 | pairedAt?: number; 11 | clock: { limit: number; increment: number }; 12 | } 13 | interface BulkGame { 14 | id: string; 15 | white: Username; 16 | black: Username; 17 | } 18 | export type BulkId = string; 19 | export type Username = string; 20 | 21 | export type Rule = 'noAbort' | 'noRematch' | 'noGiveTime' | 'noClaimWin' | 'noEarlyDraw'; 22 | 23 | export interface Game { 24 | id: string; 25 | moves: string; 26 | status: 'created' | 'started'; 27 | players: { white: Player; black: Player }; 28 | winner?: 'white' | 'black'; 29 | } 30 | export interface Player { 31 | user: LightUser; 32 | rating: number; 33 | } 34 | export interface LightUser { 35 | id: string; 36 | name: string; 37 | title?: string; 38 | } 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lichess API UI 2 | 3 | Some web UIs for [the Lichess API](https://lichess.org/api) 4 | 5 | ## Try it out 6 | 7 | [https://lichess.org/api/ui](https://lichess.org/api/ui) 8 | 9 | ## Run it on your machine 10 | 11 | ```bash 12 | pnpm install 13 | pnpm dev 14 | ``` 15 | 16 | ### Build for production + preview 17 | 18 | ```bash 19 | pnpm build 20 | pnpm preview 21 | ``` 22 | 23 | ## Tests 24 | 25 | ```bash 26 | pnpm test 27 | ## or 28 | pnpm test:watch 29 | ``` 30 | 31 | ```bash 32 | # run prettier 33 | pnpm format 34 | 35 | # check typescript 36 | pnpm tsc 37 | ``` 38 | 39 | ## Using a development instance of Lila 40 | 41 | Open the browser console and run: 42 | 43 | ```js 44 | localStorage.setItem('lichessHost', 'http://localhost:8080'); 45 | 46 | localStorage.setItem('lichessHost', 'https://lichess.dev'); 47 | ``` 48 | 49 | Modify the CSP meta tag in `index.html` to include that domain. 50 | 51 | Refresh and verify the configuration value in the footer. 52 | 53 | To reset back to prod default, log out and it will clear localStorage. 54 | -------------------------------------------------------------------------------- /src/page/home.ts: -------------------------------------------------------------------------------- 1 | import { App } from '../app'; 2 | import { h } from 'snabbdom'; 3 | import layout from '../view/layout'; 4 | import { endpoints } from '../endpoints'; 5 | import { href } from '../view/util'; 6 | 7 | export class Home { 8 | readonly app: App; 9 | constructor(app: App) { 10 | this.app = app; 11 | } 12 | 13 | render = () => layout(this.app, h('div.app-home', [this.renderAbout(), this.listEndpoints()])); 14 | redraw = () => this.app.redraw(this.render()); 15 | 16 | listEndpoints = () => 17 | h( 18 | 'div.list-group.mb-7', 19 | endpoints.map(e => 20 | h('a.list-group-item.list-group-item-action', { attrs: href(e.path) }, [ 21 | h('h3', e.name), 22 | h('span', e.desc), 23 | ]), 24 | ), 25 | ); 26 | 27 | renderAbout = () => 28 | h('div.about', [ 29 | h('p.lead.mt-5', [ 30 | 'A user interface to some of the ', 31 | h('a', { attrs: { href: 'https://lichess.org/api' } }, 'Lichess API'), 32 | ' endpoints.', 33 | ]), 34 | ]); 35 | } 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lichess-api-ui", 3 | "private": true, 4 | "license": "GPL-3.0-or-later", 5 | "dependencies": { 6 | "@bity/oauth2-auth-code-pkce": "~2.13.0", 7 | "@lichess-org/types": "^2.0.86", 8 | "bootstrap": "~5.3.8", 9 | "cheerio": "^1.1.2", 10 | "openapi-fetch": "^0.15.0", 11 | "page": "~1.11.6", 12 | "snabbdom": "~3.6.3" 13 | }, 14 | "devDependencies": { 15 | "@types/page": "^1.11.9", 16 | "jsdom": "^27.0.0", 17 | "prettier": "^3.6.2", 18 | "sass": "^1.93.2", 19 | "tslib": "^2.8.1", 20 | "typescript": "^5.9.3", 21 | "vite": "^7.1.9", 22 | "vitest": "^3.2.4" 23 | }, 24 | "scripts": { 25 | "build": "tsc && vite build", 26 | "check-format": "prettier --check .", 27 | "dev": "vite", 28 | "format": "prettier --write .", 29 | "preview": "vite preview", 30 | "test:watch": "vitest", 31 | "test": "vitest run", 32 | "tsc": "tsc --noEmit" 33 | }, 34 | "packageManager": "pnpm@10.17.1+sha512.17c560fca4867ae9473a3899ad84a88334914f379be46d455cbf92e5cf4b39d34985d452d2583baf19967fa76cb5c17bc9e245529d0b98745721aa7200ecaf7a" 35 | } 36 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import type { Rule } from './model'; 2 | 3 | export const BASE_PATH = location.pathname.replace(/\/$/, ''); 4 | 5 | export const variants = [ 6 | ['standard', 'Standard'], 7 | ['chess960', 'Chess960'], 8 | ['crazyhouse', 'Crazyhouse'], 9 | ['kingOfTheHill', 'KingOfTheHill'], 10 | ['threeCheck', 'ThreeCheck'], 11 | ['antichess', 'Antichess'], 12 | ['atomic', 'Atomic'], 13 | ['horde', 'Horde'], 14 | ['racingKings', 'RacingKing'], 15 | ]; 16 | 17 | export const gameRules: [Rule, string][] = [ 18 | ['noAbort', 'Players cannot abort the game'], 19 | ['noRematch', 'Players cannot offer a rematch'], 20 | ['noGiveTime', 'Players cannot give extra time'], 21 | ['noClaimWin', 'Players cannot claim the win if the opponent leaves'], 22 | ['noEarlyDraw', 'Players cannot offer a draw before move 30 (ply 60)'], 23 | ]; 24 | export const gameRuleKeys = gameRules.map(([key]) => key); 25 | 26 | export const gameRulesExceptNoAbort = gameRules.filter(([key]) => key !== 'noAbort'); 27 | export const gameRuleKeysExceptNoAbort = gameRulesExceptNoAbort.map(([key]) => key); 28 | 29 | export const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); 30 | 31 | export const ucfirst = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); 32 | -------------------------------------------------------------------------------- /src/ndJsonStream.ts: -------------------------------------------------------------------------------- 1 | // ND-JSON response streamer 2 | // See https://lichess.org/api#section/Introduction/Streaming-with-ND-JSON 3 | 4 | type Handler = (line: any) => void; 5 | 6 | export interface Stream { 7 | closePromise: Promise; 8 | close(): Promise; 9 | } 10 | 11 | export const readStream = (response: Response, handler: Handler): Stream => { 12 | const stream = response.body!.getReader(); 13 | const matcher = /\r?\n/; 14 | const decoder = new TextDecoder(); 15 | let buf = ''; 16 | 17 | const process = (json: string) => { 18 | const msg = JSON.parse(json); 19 | handler(msg); 20 | }; 21 | 22 | const loop: () => Promise = () => 23 | stream.read().then(({ done, value }) => { 24 | if (done) { 25 | if (buf.length > 0) process(buf); 26 | return; 27 | } else { 28 | const chunk = decoder.decode(value, { 29 | stream: true, 30 | }); 31 | buf += chunk; 32 | 33 | const parts = buf.split(matcher); 34 | buf = parts.pop() || ''; 35 | for (const i of parts.filter(p => p)) process(i); 36 | return loop(); 37 | } 38 | }); 39 | 40 | return { 41 | closePromise: loop(), 42 | close: () => stream.cancel(), 43 | }; 44 | }; 45 | -------------------------------------------------------------------------------- /src/form.ts: -------------------------------------------------------------------------------- 1 | export interface Success { 2 | result: R; 3 | } 4 | export interface Failure { 5 | error: { [key: string]: string }; 6 | } 7 | 8 | export type Feedback = Success | Failure | undefined; 9 | 10 | export function isSuccess(feedback: Feedback): feedback is Success { 11 | return feedback !== undefined && 'result' in feedback; 12 | } 13 | export function isFailure(feedback: Feedback): feedback is Failure { 14 | return feedback !== undefined && 'error' in feedback; 15 | } 16 | 17 | export const formData = (data: any): FormData => { 18 | const formData = new FormData(); 19 | for (const k of Object.keys(data)) formData.append(k, data[k]); 20 | return formData; 21 | }; 22 | 23 | export const responseToFeedback = async (req: Promise): Promise> => { 24 | let feedback: Feedback; 25 | try { 26 | const res = await req; 27 | const json = await res.json(); 28 | if (res.status != 200) throw json; 29 | feedback = { result: json }; 30 | } catch (err) { 31 | const error = (err as any).error || err; 32 | feedback = { 33 | error: 34 | typeof error === 'object' 35 | ? Object.fromEntries(Object.entries(error).map(([k, v]) => [k, (v as string[]).join(', ')])) 36 | : { error }, 37 | }; 38 | console.log(error, feedback); 39 | } 40 | return feedback; 41 | }; 42 | -------------------------------------------------------------------------------- /src/routing.ts: -------------------------------------------------------------------------------- 1 | import { App } from './app'; 2 | import page from 'page'; 3 | import { Home } from './page/home'; 4 | import { BulkNew } from './page/bulkNew'; 5 | import { OpenChallenge } from './page/openChallenge'; 6 | import { PuzzleRace } from './page/puzzleRace'; 7 | import { BulkList } from './page/bulkList'; 8 | import type { Me } from './auth'; 9 | import { BulkShow } from './page/bulkShow'; 10 | import { BASE_PATH } from './util'; 11 | 12 | export default function (app: App) { 13 | const withAuth = (f: (me: Me) => void) => { 14 | if (app.auth.me) f(app.auth.me); 15 | else page('/login'); 16 | }; 17 | 18 | page.base(BASE_PATH); 19 | page('/', ctx => { 20 | if (ctx.querystring.includes('code=liu_')) history.pushState({}, '', BASE_PATH || '/'); 21 | new Home(app).redraw(); 22 | }); 23 | page('/login', async _ => { 24 | if (app.auth.me) return page('/'); 25 | await app.auth.login(); 26 | }); 27 | page('/logout', async _ => { 28 | await app.auth.logout(); 29 | location.href = BASE_PATH; 30 | }); 31 | page('/endpoint/open-challenge', _ => new OpenChallenge(app).redraw()); 32 | page('/endpoint/schedule-games', _ => withAuth(me => new BulkList(app, me).redraw())); 33 | page('/endpoint/schedule-games/new', _ => withAuth(me => new BulkNew(app, me).redraw())); 34 | page('/endpoint/schedule-games/:id', ctx => withAuth(me => new BulkShow(app, me, ctx.params.id).redraw())); 35 | page('/endpoint/puzzle-race', _ => withAuth(me => new PuzzleRace(app, me).redraw())); 36 | page('/too-many-requests', _ => app.tooManyRequests()); 37 | page('*', _ => app.notFound()); 38 | page({ hashbang: true }); 39 | } 40 | -------------------------------------------------------------------------------- /src/view/util.ts: -------------------------------------------------------------------------------- 1 | import { h } from 'snabbdom'; 2 | import type { MaybeVNodes } from '../interfaces'; 3 | import { BASE_PATH } from '../util'; 4 | 5 | export const loadingBody = () => h('div.loading', spinner()); 6 | 7 | export const spinner = () => 8 | h( 9 | 'div.spinner-border.text-primary', 10 | { attrs: { role: 'status' } }, 11 | h('span.visually-hidden', 'Loading...'), 12 | ); 13 | 14 | export const timeFormat = new Intl.DateTimeFormat(document.documentElement.lang, { 15 | year: 'numeric', 16 | month: 'short', 17 | day: 'numeric', 18 | hour: 'numeric', 19 | minute: 'numeric', 20 | }).format; 21 | 22 | export const card = (id: string, header: MaybeVNodes, body: MaybeVNodes) => 23 | h(`div#card-${id}.card.mb-5`, [ 24 | h('h2.card-header.bg-success.text-body-emphasis.py-4', header), 25 | h('div.card-body', body), 26 | ]); 27 | 28 | export const copyInput = (label: string, value: string) => { 29 | const id = Math.floor(Math.random() * Date.now()).toString(36); 30 | return h('div.input-group.mb-3', [ 31 | h( 32 | 'span.input-group-text.input-copy.bg-primary.text-body-emphasis', 33 | { 34 | on: { 35 | click: e => { 36 | navigator.clipboard.writeText(value); 37 | (e.target as HTMLElement).classList.remove('bg-primary'); 38 | (e.target as HTMLElement).classList.add('bg-success'); 39 | }, 40 | }, 41 | }, 42 | 'Copy', 43 | ), 44 | h('div.form-floating', [ 45 | h(`input#${id}.form-control`, { 46 | attrs: { type: 'text', readonly: true, value }, 47 | }), 48 | h('label', { attrs: { for: id } }, label), 49 | ]), 50 | ]); 51 | }; 52 | 53 | export const url = (path: string) => `${BASE_PATH}${path}`; 54 | export const href = (path: string) => ({ href: url(path) }); 55 | -------------------------------------------------------------------------------- /src/page/puzzleRace.ts: -------------------------------------------------------------------------------- 1 | import { h } from 'snabbdom'; 2 | import { App } from '../app'; 3 | import { type Feedback, isSuccess, responseToFeedback } from '../form'; 4 | import * as form from '../view/form'; 5 | import layout from '../view/layout'; 6 | import { card, copyInput } from '../view/util'; 7 | import type { Me } from '../auth'; 8 | 9 | interface Result { 10 | id: string; 11 | url: string; 12 | } 13 | 14 | export class PuzzleRace { 15 | feedback: Feedback = undefined; 16 | readonly app: App; 17 | readonly me: Me; 18 | constructor(app: App, me: Me) { 19 | this.app = app; 20 | this.me = me; 21 | } 22 | redraw = () => this.app.redraw(this.render()); 23 | render = () => 24 | layout( 25 | this.app, 26 | h('div', [ 27 | h('h1.mt-5', 'Puzzle race'), 28 | h('p.lead', [ 29 | 'Uses the ', 30 | h( 31 | 'a', 32 | { attrs: { href: 'https://lichess.org/api#tag/Puzzles/operation/racerPost' } }, 33 | 'Lichess puzzle race API', 34 | ), 35 | ' to create a private race with an invite link.', 36 | ]), 37 | this.renderForm(), 38 | ]), 39 | ); 40 | 41 | private onSubmit = async () => { 42 | const req = this.me.httpClient(`${this.app.config.lichessHost}/api/racer`, { method: 'POST' }); 43 | this.feedback = await responseToFeedback(req); 44 | this.redraw(); 45 | form.scrollToForm(); 46 | }; 47 | 48 | private renderForm = () => 49 | form.form(this.onSubmit, [ 50 | form.feedback(this.feedback), 51 | isSuccess(this.feedback) ? this.renderResult(this.feedback.result) : undefined, 52 | form.submit('Create the race'), 53 | ]); 54 | 55 | private renderResult = (result: Result) => { 56 | return card( 57 | result.id, 58 | ['PuzzleRace #', result.id], 59 | [h('h3', 'Link'), copyInput('Invite URL', result.url)], 60 | ); 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /scss/_bootstrap.scss: -------------------------------------------------------------------------------- 1 | // 1. Include functions first (so you can manipulate colors, SVGs, calc, etc) 2 | @import '../node_modules/bootstrap/scss/functions'; 3 | 4 | // 2. Include any default variable overrides here 5 | 6 | // 3. Include remainder of required Bootstrap stylesheets (including any separate color mode stylesheets) 7 | @import '../node_modules/bootstrap/scss/variables'; 8 | @import '../node_modules/bootstrap/scss/variables-dark'; 9 | 10 | // 4. Include any default map overrides here 11 | 12 | // 5. Include remainder of required parts 13 | @import '../node_modules/bootstrap/scss/maps'; 14 | @import '../node_modules/bootstrap/scss/mixins'; 15 | @import '../node_modules/bootstrap/scss/root'; 16 | 17 | // 4. Include any optional Bootstrap CSS as needed 18 | @import '../node_modules/bootstrap/scss/utilities'; 19 | @import '../node_modules/bootstrap/scss/reboot'; 20 | @import '../node_modules/bootstrap/scss/type'; 21 | @import '../node_modules/bootstrap/scss/nav'; 22 | @import '../node_modules/bootstrap/scss/navbar'; 23 | @import '../node_modules/bootstrap/scss/buttons'; 24 | @import '../node_modules/bootstrap/scss/button-group'; 25 | @import '../node_modules/bootstrap/scss/dropdown'; 26 | @import '../node_modules/bootstrap/scss/containers'; 27 | @import '../node_modules/bootstrap/scss/grid'; 28 | @import '../node_modules/bootstrap/scss/spinners'; 29 | @import '../node_modules/bootstrap/scss/transitions'; 30 | @import '../node_modules/bootstrap/scss/list-group'; 31 | @import '../node_modules/bootstrap/scss/tables'; 32 | @import '../node_modules/bootstrap/scss/alert'; 33 | @import '../node_modules/bootstrap/scss/card'; 34 | @import '../node_modules/bootstrap/scss/forms'; 35 | @import '../node_modules/bootstrap/scss/breadcrumb'; 36 | @import '../node_modules/bootstrap/scss/badge'; 37 | 38 | @import '../node_modules/bootstrap/scss/helpers'; 39 | 40 | // 5. Optionally include utilities API last to generate classes based on the Sass map in `_utilities.scss` 41 | @import '../node_modules/bootstrap/scss/utilities/api'; 42 | 43 | $container-max-widths: ( 44 | sm: 540px, 45 | md: 720px, 46 | lg: 960px, 47 | xl: 1100px, 48 | xxl: 1140px, 49 | ); 50 | -------------------------------------------------------------------------------- /src/auth.ts: -------------------------------------------------------------------------------- 1 | import { OAuth2AuthCodePKCE } from '@bity/oauth2-auth-code-pkce'; 2 | import { BASE_PATH } from './util'; 3 | 4 | export const scopes = ['challenge:bulk', 'racer:write', 'web:mod']; 5 | export const clientId = 'lichess-api-ui'; 6 | export const clientUrl = `${location.protocol}//${location.host}${BASE_PATH || '/'}`; 7 | 8 | type HttpClient = (url: string, options?: RequestInit) => Promise; 9 | 10 | export interface Me { 11 | id: string; 12 | username: string; 13 | httpClient: HttpClient; // with pre-set Authorization header 14 | } 15 | 16 | export class Auth { 17 | me?: Me; 18 | readonly lichessHost: string; 19 | readonly oauth: OAuth2AuthCodePKCE; 20 | constructor(lichessHost: string) { 21 | this.lichessHost = lichessHost; 22 | 23 | this.oauth = new OAuth2AuthCodePKCE({ 24 | authorizationUrl: `${this.lichessHost}/oauth`, 25 | tokenUrl: `${this.lichessHost}/api/token`, 26 | clientId, 27 | scopes, 28 | redirectUrl: clientUrl, 29 | onAccessTokenExpiry: refreshAccessToken => refreshAccessToken(), 30 | onInvalidGrant: console.warn, 31 | }); 32 | } 33 | 34 | async init() { 35 | try { 36 | const accessContext = await this.oauth.getAccessToken(); 37 | if (accessContext) await this.authenticate(); 38 | } catch (err) { 39 | console.error(err); 40 | } 41 | if (!this.me) { 42 | try { 43 | const hasAuthCode = await this.oauth.isReturningFromAuthServer(); 44 | if (hasAuthCode) await this.authenticate(); 45 | } catch (err) { 46 | console.error(err); 47 | } 48 | } 49 | } 50 | 51 | async login() { 52 | await this.oauth.fetchAuthorizationCode(); 53 | } 54 | 55 | async logout() { 56 | if (this.me) await this.me.httpClient(`${this.lichessHost}/api/token`, { method: 'DELETE' }); 57 | localStorage.clear(); 58 | this.me = undefined; 59 | } 60 | 61 | private authenticate = async () => { 62 | const httpClient = this.oauth.decorateFetchHTTPClient(window.fetch); 63 | const res = await httpClient(`${this.lichessHost}/api/account`); 64 | if (res.status == 429) { 65 | location.href = clientUrl + '#!/too-many-requests'; 66 | return; 67 | } 68 | const me = { 69 | ...(await res.json()), 70 | httpClient, 71 | }; 72 | if (me.error) throw me.error; 73 | this.me = me; 74 | }; 75 | } 76 | -------------------------------------------------------------------------------- /src/page/bulkList.ts: -------------------------------------------------------------------------------- 1 | import { h } from 'snabbdom'; 2 | import { App } from '../app'; 3 | import type { Me } from '../auth'; 4 | import layout from '../view/layout'; 5 | import { href, timeFormat, url } from '../view/util'; 6 | import type { Bulk } from '../model'; 7 | import { bulkPairing } from '../endpoints'; 8 | import { BulkShow } from './bulkShow'; 9 | import { ucfirst } from '../util'; 10 | 11 | export class BulkList { 12 | bulks?: Bulk[]; 13 | readonly app: App; 14 | readonly me: Me; 15 | constructor(app: App, me: Me) { 16 | this.app = app; 17 | this.me = me; 18 | this.loadBulks(); 19 | } 20 | loadBulks = async () => { 21 | const res = await this.me.httpClient(`${this.app.config.lichessHost}/api/bulk-pairing`); 22 | this.bulks = (await res.json()).bulks; 23 | this.redraw(); 24 | }; 25 | redraw = () => this.app.redraw(this.render()); 26 | render = () => 27 | layout( 28 | this.app, 29 | h('div', [ 30 | h('h1.mt-5', 'Schedule games'), 31 | h('p.lead', [ 32 | 'Uses the ', 33 | h( 34 | 'a', 35 | { attrs: { href: 'https://lichess.org/api#tag/Bulk-pairings/operation/bulkPairingCreate' } }, 36 | 'Lichess bulk pairing API', 37 | ), 38 | ' to create a bunch of games at once.', 39 | ]), 40 | h( 41 | 'a.btn.btn-primary.mt-5', 42 | { attrs: { href: url(`${bulkPairing.path}/new`) } }, 43 | 'Schedule new games', 44 | ), 45 | this.bulks 46 | ? h('table.table.table-striped.mt-5', [ 47 | h('thead', [ 48 | h('tr', [ 49 | h('th', 'Bulk'), 50 | h('th', 'Games'), 51 | h('th', 'Created'), 52 | h('th', 'Pair at'), 53 | h('th', 'Paired'), 54 | ]), 55 | ]), 56 | h( 57 | 'tbody', 58 | this.bulks.map(bulk => 59 | h('tr', [ 60 | h( 61 | 'td.mono', 62 | h('a', { attrs: href(`${bulkPairing.path}/${bulk.id}`) }, [ 63 | `#${bulk.id}`, 64 | ' ', 65 | BulkShow.renderClock(bulk), 66 | ' ', 67 | ucfirst(bulk.variant), 68 | ' ', 69 | bulk.rated ? 'Rated' : 'Casual', 70 | ]), 71 | ), 72 | h('td', bulk.games.length), 73 | h('td', timeFormat(new Date(bulk.scheduledAt))), 74 | h('td', bulk.pairAt && timeFormat(new Date(bulk.pairAt))), 75 | h('td', bulk.pairedAt && timeFormat(new Date(bulk.pairedAt))), 76 | ]), 77 | ), 78 | ), 79 | ]) 80 | : h('div.m-5', h('div.spinner-border.d-block.mx-auto', { attrs: { role: 'status' } })), 81 | ]), 82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /src/page/openChallenge.ts: -------------------------------------------------------------------------------- 1 | import { h } from 'snabbdom'; 2 | import { App } from '../app'; 3 | import { type Feedback, formData, isSuccess, responseToFeedback } from '../form'; 4 | import { gameRuleKeysExceptNoAbort, gameRulesExceptNoAbort } from '../util'; 5 | import * as form from '../view/form'; 6 | import layout from '../view/layout'; 7 | import { card, copyInput } from '../view/util'; 8 | 9 | interface Result { 10 | id: string; 11 | url: string; 12 | open: { 13 | userIds?: [string, string]; 14 | }; 15 | urlWhite: string; 16 | urlBlack: string; 17 | } 18 | 19 | export class OpenChallenge { 20 | feedback: Feedback = undefined; 21 | readonly app: App; 22 | constructor(app: App) { 23 | this.app = app; 24 | } 25 | redraw = () => this.app.redraw(this.render()); 26 | render = () => 27 | layout( 28 | this.app, 29 | h('div', [ 30 | h('h1.mt-5', 'Open challenge'), 31 | h('p.lead', [ 32 | 'Uses the ', 33 | h( 34 | 'a', 35 | { attrs: { href: 'https://lichess.org/api#tag/Challenges/operation/challengeOpen' } }, 36 | 'Lichess open challenge API', 37 | ), 38 | ' to create a game that any two players can join.', 39 | ]), 40 | h('p', ['No OAuth token is required.']), 41 | this.renderForm(), 42 | ]), 43 | ); 44 | 45 | private onSubmit = async (data: FormData) => { 46 | const get = (key: string) => data.get(key) as string; 47 | const req = fetch(`${this.app.config.lichessHost}/api/challenge/open`, { 48 | method: 'POST', 49 | body: formData({ 50 | 'clock.limit': parseFloat(get('clockLimit')) * 60, 51 | 'clock.increment': get('clockIncrement'), 52 | variant: get('variant'), 53 | rated: !!get('rated'), 54 | fen: get('fen'), 55 | name: get('name'), 56 | users: get('users') 57 | .trim() 58 | .replace(/[\s,]+/g, ','), 59 | rules: gameRuleKeysExceptNoAbort.filter(key => !!get(key)).join(','), 60 | }), 61 | }); 62 | this.feedback = await responseToFeedback(req); 63 | this.redraw(); 64 | form.scrollToForm(); 65 | }; 66 | 67 | private renderForm = () => 68 | form.form(this.onSubmit, [ 69 | form.feedback(this.feedback), 70 | isSuccess(this.feedback) ? this.renderResult(this.feedback.result) : undefined, 71 | form.clock(), 72 | h('div.form-check.form-switch.mb-3', form.checkboxWithLabel('rated', 'Rated game')), 73 | form.variant(), 74 | form.fen(), 75 | h('div.mb-3', [ 76 | form.label('Challenge name', 'name'), 77 | form.input('name', { tpe: 'text' }), 78 | h('p.form-text', 'Optional text that players will see on the challenge page.'), 79 | ]), 80 | h('div.mb-3', [ 81 | form.label('Only allow these players to join', 'name'), 82 | form.input('users', { tpe: 'text' }), 83 | h( 84 | 'p.form-text', 85 | 'Optional pair of usernames, separated by a comma. If set, only these users will be allowed to join the game. The first username gets the white pieces.', 86 | ), 87 | ]), 88 | form.specialRules(gameRulesExceptNoAbort), 89 | form.submit('Create the challenge'), 90 | ]); 91 | 92 | private renderResult = (result: Result) => { 93 | const c = result; 94 | return card( 95 | c.id, 96 | ['Challenge #', c.id], 97 | [ 98 | h('h3', 'Links'), 99 | ...(c.open?.userIds 100 | ? [copyInput('Game URL', c.url)] 101 | : [ 102 | copyInput('Game URL - random color', c.url), 103 | copyInput('Game URL for white', result.urlWhite), 104 | copyInput('Game URL for black', result.urlBlack), 105 | ]), 106 | ], 107 | ); 108 | }; 109 | } 110 | -------------------------------------------------------------------------------- /src/view/layout.ts: -------------------------------------------------------------------------------- 1 | import { h, type VNode } from 'snabbdom'; 2 | import type { Me } from '../auth'; 3 | import { App } from '../app'; 4 | import { type MaybeVNodes } from '../interfaces'; 5 | import { endpoints } from '../endpoints'; 6 | import { href } from './util'; 7 | 8 | export default function (app: App, body: MaybeVNodes): VNode { 9 | return h('body', [renderNavBar(app), h('div.container', body), renderFooter(app)]); 10 | } 11 | 12 | const renderNavBar = (app: App) => 13 | h('header.navbar.navbar-expand-md.bg-body-tertiary', [ 14 | h('div.container', [ 15 | h( 16 | 'a.navbar-brand', 17 | { 18 | attrs: href('/'), 19 | }, 20 | [ 21 | h('img.lichess-logo-white.me-3', { 22 | attrs: logoAttrs, 23 | }), 24 | 'Lichess API', 25 | ], 26 | ), 27 | h( 28 | 'button.navbar-toggler', 29 | { 30 | attrs: { 31 | type: 'button', 32 | 'data-bs-toggle': 'collapse', 33 | 'data-bs-target': '#navbarSupportedContent', 34 | 'aria-controls': 'navbarSupportedContent', 35 | 'aria-expanded': false, 36 | 'aria-label': 'Toggle navigation', 37 | }, 38 | }, 39 | h('span.navbar-toggler-icon'), 40 | ), 41 | h('div#navbarSupportedContent.collapse.navbar-collapse', [ 42 | h('ul.navbar-nav.me-auto.mb-lg-0"', []), 43 | endpointNav(), 44 | h('ul.navbar-nav', [app.auth.me ? userNav(app.auth.me) : anonNav()]), 45 | ]), 46 | ]), 47 | ]); 48 | 49 | const endpointNav = () => 50 | h('ul.navbar-nav.me-3', [ 51 | h('li.nav-item.dropdown', [ 52 | h( 53 | 'a#navbarDropdown.nav-link.dropdown-toggle', 54 | { 55 | attrs: { 56 | href: '#', 57 | role: 'button', 58 | 'data-bs-toggle': 'dropdown', 59 | 'aria-expanded': false, 60 | }, 61 | }, 62 | 'Endpoints', 63 | ), 64 | h( 65 | 'ul.dropdown-menu', 66 | { 67 | attrs: { 68 | 'aria-labelledby': 'navbarDropdown', 69 | }, 70 | }, 71 | endpoints.map(e => 72 | h('li', h('a.dropdown-item', { attrs: { ...href(e.path), title: e.desc } }, e.name)), 73 | ), 74 | ), 75 | ]), 76 | ]); 77 | 78 | const userNav = (me: Me) => 79 | h('li.nav-item.dropdown', [ 80 | h( 81 | 'a#navbarDropdown.nav-link.dropdown-toggle', 82 | { 83 | attrs: { 84 | href: '#', 85 | role: 'button', 86 | 'data-bs-toggle': 'dropdown', 87 | 'aria-expanded': false, 88 | }, 89 | }, 90 | me.username, 91 | ), 92 | h( 93 | 'ul.dropdown-menu', 94 | { 95 | attrs: { 96 | 'aria-labelledby': 'navbarDropdown', 97 | }, 98 | }, 99 | [ 100 | h( 101 | 'li', 102 | h( 103 | 'a.dropdown-item', 104 | { 105 | attrs: href('/logout'), 106 | }, 107 | 'Log out', 108 | ), 109 | ), 110 | ], 111 | ), 112 | ]); 113 | 114 | const anonNav = () => 115 | h( 116 | 'li.nav-item', 117 | h( 118 | 'a.btn.btn-primary.text-nowrap', 119 | { 120 | attrs: href('/login'), 121 | }, 122 | 'Login with Lichess', 123 | ), 124 | ); 125 | 126 | const renderFooter = (app: App) => 127 | h( 128 | 'footer.bd-footer.py-4.py-md-5.mt-5.bg-body-tertiary', 129 | h('div.container.py-4.py-md-5.px-4.px-md-3.text-body-secondary', [ 130 | h('div.row', [ 131 | h('div.col.mb-3', [ 132 | h('h5', 'Links'), 133 | h('ul.list-unstyled', [ 134 | linkLi('https://lichess.org/api', 'Lichess API documentation'), 135 | linkLi('https://database.lichess.org', 'Lichess database'), 136 | linkLi('https://github.com/lichess-org/api-ui', 'Source code of this website'), 137 | linkLi('https://lichess.org', 'The best chess server'), 138 | ]), 139 | ]), 140 | h('div.col.mb-3', [h('h5', 'Configuration'), h('code', JSON.stringify(app.config, null, 2))]), 141 | ]), 142 | ]), 143 | ); 144 | const linkLi = (href: string, text: string) => h('li.mb-2', h('a', { attrs: { href } }, text)); 145 | 146 | const logoAttrs = { 147 | src: 'https://lichess1.org/assets/logo/lichess-white.svg', 148 | alt: 'Lichess logo', 149 | }; 150 | -------------------------------------------------------------------------------- /src/view/form.ts: -------------------------------------------------------------------------------- 1 | import { h } from 'snabbdom'; 2 | import { variants } from '../util'; 3 | import type { MaybeVNodes } from '../interfaces'; 4 | import { type Failure, type Feedback, isFailure } from '../form'; 5 | import type { Rule } from '../model'; 6 | import type { SavedPlayerUrls } from '../scraper/scraper'; 7 | 8 | export interface Input { 9 | tpe: string; 10 | placeholder: string; 11 | required: boolean; 12 | value?: string; 13 | } 14 | export const makeInput = (opts: Partial): Input => ({ 15 | tpe: 'string', 16 | placeholder: '', 17 | required: false, 18 | ...opts, 19 | }); 20 | 21 | export const input = (id: string, opts: Partial = {}) => { 22 | const i = makeInput(opts); 23 | return h(`input#${id}.form-control`, { 24 | attrs: { 25 | name: id, 26 | type: i.tpe, 27 | placeholder: i.placeholder, 28 | ...(i.value ? { value: i.value } : {}), 29 | ...(i.required ? { required: true } : {}), 30 | }, 31 | }); 32 | }; 33 | 34 | export const label = (label: string, id?: string) => 35 | h(`label.form-label`, id ? { attrs: { for: id } } : {}, label); 36 | 37 | export const selectOption = (value: string, label: string) => h('option', { attrs: { value } }, label); 38 | 39 | export const checkbox = (id: string, checked: boolean = false) => 40 | h(`input#${id}.form-check-input`, { attrs: { type: 'checkbox', name: id, value: 'true', checked } }); 41 | 42 | export const checkboxWithLabel = (id: string, label: string, checked: boolean = false) => [ 43 | checkbox(id, checked), 44 | h('label.form-check-label', { attrs: { for: id } }, label), 45 | ]; 46 | 47 | export const clock = () => 48 | h('div.mb-3', [ 49 | label('Clock'), 50 | h('div.input-group', [ 51 | input('clockLimit', { 52 | tpe: 'number', 53 | // value: '5', 54 | required: true, 55 | placeholder: 'Initial time in minutes', 56 | }), 57 | h('span.input-group-text', '+'), 58 | input('clockIncrement', { 59 | tpe: 'number', 60 | // value: '3', 61 | required: true, 62 | placeholder: 'Increment in seconds', 63 | }), 64 | ]), 65 | ]); 66 | 67 | export const variant = () => 68 | h('div.mb-3', [ 69 | label('Variant', 'variant'), 70 | h( 71 | 'select.form-select', 72 | { attrs: { name: 'variant' } }, 73 | variants.map(([key, name]) => selectOption(key, name)), 74 | ), 75 | ]); 76 | 77 | export const specialRules = (rules: [Rule, string][]) => 78 | h('div.mb-3', [ 79 | h('div', label('Special rules', 'rules')), 80 | ...rules.map(([key, label]) => h('div.form-check.form-switch.mb-1', checkboxWithLabel(key, label))), 81 | ]); 82 | 83 | export const fen = () => 84 | h('div.mb-3', [ 85 | label('FEN initial position', 'fen'), 86 | input('fen', { tpe: 'text' }), 87 | h( 88 | 'p.form-text', 89 | 'If set, the variant must be standard, fromPosition, or chess960 (if a valid 960 starting position), and the game cannot be rated.', 90 | ), 91 | ]); 92 | 93 | export const form = (onSubmit: (form: FormData) => void, content: MaybeVNodes) => 94 | h( 95 | 'form#endpoint-form.mt-5', 96 | { 97 | on: { 98 | submit: (e: Event) => { 99 | e.preventDefault(); 100 | onSubmit(new FormData(e.target as HTMLFormElement)); 101 | }, 102 | }, 103 | }, 104 | content, 105 | ); 106 | 107 | export const submit = (label: string) => h('button.btn.btn-primary.btn-lg.mt-3', { type: 'submit' }, label); 108 | 109 | export const feedback = (feedback: Feedback) => 110 | isFailure(feedback) ? h('div.alert.alert-danger', renderErrors(feedback)) : undefined; 111 | 112 | const renderErrors = (fail: Failure) => 113 | h( 114 | 'ul.mb-0', 115 | Object.entries(fail.error).map(([k, v]) => h('li', `${k}: ${v}`)), 116 | ); 117 | 118 | export const scrollToForm = () => 119 | document.getElementById('endpoint-form')?.scrollIntoView({ behavior: 'smooth' }); 120 | 121 | export const loadPlayersFromUrl = (savedPlayerUrls?: SavedPlayerUrls) => 122 | h('div', [ 123 | h('div.form-group.mb-3', [ 124 | label('Pairings URL', 'cr-pairings-url'), 125 | input('cr-pairings-url', { 126 | value: savedPlayerUrls?.pairingsUrl, 127 | }), 128 | ]), 129 | h('div.form-group', [ 130 | label('Players URL', 'cr-players-url'), 131 | input('cr-players-url', { 132 | value: savedPlayerUrls?.playersUrl, 133 | }), 134 | h('p.form-text', [ 135 | 'Only required if the usernames are not provided on the Pairings page.', 136 | h('br'), 137 | 'The Lichess usernames must be in the "Club/City" field.', 138 | ]), 139 | ]), 140 | ]); 141 | -------------------------------------------------------------------------------- /src/scraper/tests/fixtures/team-swiss-pairings-with-usernames.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 34 | 35 | 36 | 37 | 38 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 65 | 66 | 67 | 68 | 69 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 96 | 97 | 98 | 99 | 100 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 127 | 128 | 129 | 130 | 131 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 171 | 172 | 173 | 174 | 175 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 202 | 203 | 204 | 205 | 206 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 233 | 234 | 235 | 236 | 237 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 264 | 265 | 266 | 267 | 268 | 278 | 279 | 280 | 281 | 282 | 283 |
Round 1
Bo.1  Team BRtgClub/City-3  Team CRtgClub/City0 : 0
1.1WFM 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 |
Testing, Test
33 |
1985test134-FM 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 |
Hris, Panagiotis
47 |
2227test4
1.2IM 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 |
Someone, Else
64 |
2400test3- 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 |
Trevlar, Someone
78 |
0test5
1.3 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 |
Another, Test
95 |
1900test1- 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 |
TestPlayer, Mary
109 |
1600test6
1.4 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 |
Ignore, This
126 |
1400test2- 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 |
Testing, Tester
140 |
0test7
Bo.4  Team ARtgClub/City-2  Team DRtgClub/City0 : 0
2.1 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 |
Wait, Theophilus
170 |
0Cynosure-FM 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 |
SomeoneElse, Michael
184 |
2230TestAccount1
2.2 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 |
Thibault, D
201 |
0Thibault-WCM 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 |
YetSomeoneElse, Lilly
215 |
2070TestAccount2
2.3AFM 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 |
Gkizi, Konst
232 |
1270Puzzlingpuzzler- 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 |
Unknown, Player
246 |
1300TestAccount3
2.4 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 |
Placeholder, Player
263 |
0ThisAccountDoesntExist- 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 |
Also, Unknown
277 |
1111TestAccount4
284 | -------------------------------------------------------------------------------- /src/scraper/scraper.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from 'cheerio'; 2 | 3 | export interface Player { 4 | name: string; 5 | team?: string; 6 | fideId?: string; 7 | rating?: number; 8 | lichess?: string; 9 | } 10 | 11 | export interface Pairing { 12 | white: Player; 13 | black: Player; 14 | /** Whether the pairing should be displayed as "black vs white" instead of "white vs black" */ 15 | reversed: boolean; 16 | board: string; 17 | } 18 | 19 | export interface SavedPlayerUrls { 20 | pairingsUrl: string; 21 | playersUrl?: string; 22 | } 23 | 24 | export function setCacheBuster(url: string): string { 25 | const urlObject = new URL(url); 26 | urlObject.searchParams.set('cachebust', Date.now().toString()); 27 | return urlObject.toString(); 28 | } 29 | 30 | async function fetchHtml(url: string): Promise { 31 | const response = await fetch(`https://corsproxy.io/?${encodeURIComponent(setCacheBuster(url))}`); 32 | return await response.text(); 33 | } 34 | 35 | export function setResultsPerPage(url: string, resultsPerPage: number = 99999): string { 36 | // show all players on one page 37 | const urlObject = new URL(url); 38 | urlObject.searchParams.set('zeilen', resultsPerPage.toString()); 39 | return urlObject.toString(); 40 | } 41 | 42 | export async function getPlayers(url: string): Promise { 43 | const html = await fetchHtml(setResultsPerPage(url)); 44 | const $ = cheerio.load(html); 45 | const players: Player[] = []; 46 | 47 | const headers: string[] = $('.CRs1 tr th') 48 | .first() 49 | .parent() 50 | .children() 51 | .map((_index, element) => $(element).text().trim()) 52 | .get(); 53 | 54 | $('.CRs1 tr').each((_index, element) => { 55 | // ignore heading rows 56 | if ($(element).find('th').length > 0) { 57 | return; 58 | } 59 | 60 | const fideId = headers.includes('FideID') 61 | ? $(element).find('td').eq(headers.indexOf('FideID')).text().trim() 62 | : undefined; 63 | const rating = headers.includes('Rtg') 64 | ? parseInt($(element).find('td').eq(headers.indexOf('Rtg')).text().trim()) 65 | : undefined; 66 | const lichess = headers.includes('Club/City') 67 | ? $(element).find('td').eq(headers.indexOf('Club/City')).text().trim() 68 | : undefined; 69 | 70 | const player: Player = { 71 | name: $(element).find('td').eq(headers.indexOf('Name')).text().trim(), 72 | fideId, 73 | rating, 74 | lichess, 75 | }; 76 | players.push(player); 77 | }); 78 | 79 | return players; 80 | } 81 | 82 | export async function getPairings(url: string, players?: Player[]): Promise { 83 | const html = await fetchHtml(url); 84 | const $ = cheerio.load(html); 85 | 86 | // Team Swiss pairings table has nested tables 87 | if ($('.CRs1 td').find('table').length > 1) { 88 | return parsePairingsForTeamSwiss(html); 89 | } 90 | 91 | return parsePairingsForIndividualEvent(html, players); 92 | } 93 | 94 | function parsePairingsForTeamSwiss(html: string): Pairing[] { 95 | const $ = cheerio.load(html); 96 | const pairings: Pairing[] = []; 97 | 98 | const headers: string[] = $('.CRs1 tr th') 99 | .first() 100 | .parent() 101 | .children() 102 | .map((_index, element) => $(element).text().trim()) 103 | .get(); 104 | 105 | let teams: string[] = []; 106 | 107 | $('.CRs1 tr').each((_index, element) => { 108 | // find the header rows that include the team names 109 | if ($(element).hasClass('CRg1b') && $(element).find('th').length > 0) { 110 | teams = $(element) 111 | .find('th') 112 | .filter((_index, element) => $(element).text().includes('\u00a0\u00a0')) 113 | .map((_index, element) => $(element).text().trim()) 114 | .get(); 115 | return; 116 | } 117 | 118 | // ignore rows that do not have pairings 119 | if ($(element).find('table').length === 0) { 120 | return; 121 | } 122 | 123 | const boardNumber = $(element).children().eq(0).text().trim(); 124 | const white = $(element).find('table').find('div.FarbewT').parentsUntil('table').last().text().trim(); 125 | const black = $(element).find('table').find('div.FarbesT').parentsUntil('table').last().text().trim(); 126 | 127 | const rating1 = headers.includes('Rtg') 128 | ? parseInt($(element).children().eq(headers.indexOf('Rtg')).text().trim()) 129 | : undefined; 130 | const rating2 = headers.includes('Rtg') 131 | ? parseInt($(element).children().eq(headers.lastIndexOf('Rtg')).text().trim()) 132 | : undefined; 133 | 134 | const username1 = headers.includes('Club/City') 135 | ? $(element).children().eq(headers.indexOf('Club/City')).text().trim() 136 | : undefined; 137 | const username2 = headers.includes('Club/City') 138 | ? $(element).children().eq(headers.lastIndexOf('Club/City')).text().trim() 139 | : undefined; 140 | 141 | // which color indicator comes first: div.FarbewT or div.FarbesT? 142 | const firstDiv = $(element).find('table').find('div.FarbewT, div.FarbesT').first(); 143 | 144 | if ($(firstDiv).hasClass('FarbewT')) { 145 | pairings.push({ 146 | white: { 147 | name: white, 148 | team: teams[0], 149 | rating: rating1, 150 | lichess: username1, 151 | }, 152 | black: { 153 | name: black, 154 | team: teams[1], 155 | rating: rating2, 156 | lichess: username2, 157 | }, 158 | reversed: false, 159 | board: boardNumber, 160 | }); 161 | } else if ($(firstDiv).hasClass('FarbesT')) { 162 | pairings.push({ 163 | white: { 164 | name: white, 165 | team: teams[1], 166 | rating: rating2, 167 | lichess: username2, 168 | }, 169 | black: { 170 | name: black, 171 | team: teams[0], 172 | rating: rating1, 173 | lichess: username1, 174 | }, 175 | reversed: true, 176 | board: boardNumber, 177 | }); 178 | } else { 179 | throw new Error('Could not parse Pairings table'); 180 | } 181 | }); 182 | 183 | return pairings; 184 | } 185 | 186 | function parsePairingsForIndividualEvent(html: string, players?: Player[]): Pairing[] { 187 | const $ = cheerio.load(html); 188 | const pairings: Pairing[] = []; 189 | 190 | const headers: string[] = $('.CRs1 tr th') 191 | .first() 192 | .parent() 193 | .children() 194 | .map((_index, element) => $(element).text().trim()) 195 | .get(); 196 | 197 | $('.CRs1 tr').each((_index, element) => { 198 | // ignore certain table headings: rows with less than 2 's 199 | if ($(element).find('td').length <= 2) { 200 | return; 201 | } 202 | 203 | const boardNumber = $(element).children().eq(0).text().trim(); 204 | const whiteName = $(element).children().eq(headers.indexOf('White')).text().trim(); 205 | const blackName = $(element).children().eq(headers.lastIndexOf('Black')).text().trim(); 206 | 207 | pairings.push({ 208 | white: players?.find(player => player.name === whiteName) ?? { name: whiteName }, 209 | black: players?.find(player => player.name === blackName) ?? { name: blackName }, 210 | reversed: false, 211 | board: boardNumber, 212 | }); 213 | }); 214 | 215 | return pairings; 216 | } 217 | 218 | export function saveUrls(bulkPairingId: string, pairingsUrl: string, playersUrl?: string) { 219 | const urls = new Map(); 220 | 221 | const savedUrls = localStorage.getItem('cr-urls'); 222 | if (savedUrls) { 223 | const parsed: { [key: string]: SavedPlayerUrls } = JSON.parse(savedUrls); 224 | Object.keys(parsed).forEach(key => urls.set(key, parsed[key])); 225 | } 226 | 227 | urls.set(bulkPairingId, { pairingsUrl, playersUrl }); 228 | localStorage.setItem('cr-urls', JSON.stringify(Object.fromEntries(urls))); 229 | } 230 | 231 | export function getUrls(bulkPairingId: string): SavedPlayerUrls | undefined { 232 | const savedUrls = localStorage.getItem('cr-urls'); 233 | 234 | if (!savedUrls) { 235 | return undefined; 236 | } 237 | 238 | const parsed: { [key: string]: SavedPlayerUrls } = JSON.parse(savedUrls); 239 | return parsed[bulkPairingId]; 240 | } 241 | 242 | export function filterRound(pairings: Pairing[], round: number): Pairing[] { 243 | const boardMap = new Map(); 244 | const filtered: Pairing[] = []; 245 | 246 | for (const pairing of pairings) { 247 | const count = boardMap.get(pairing.board) ?? 0; 248 | if (count === round - 1) { 249 | filtered.push(pairing); 250 | } 251 | boardMap.set(pairing.board, count + 1); 252 | } 253 | 254 | return filtered; 255 | } 256 | -------------------------------------------------------------------------------- /src/scraper/tests/fixtures/team-round-robin-pairings.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 34 | 35 | 36 | 37 | 38 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 65 | 66 | 67 | 68 | 69 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 96 | 97 | 98 | 99 | 100 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 127 | 128 | 129 | 130 | 131 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 171 | 172 | 173 | 174 | 175 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 202 | 203 | 204 | 205 | 206 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 233 | 234 | 235 | 236 | 237 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 264 | 265 | 266 | 267 | 268 | 278 | 279 | 280 | 281 | 282 | 283 |
Round 1
Bo.1  KPMG NorwayRtgClub/City-4  Nanjing Spark Chess Technology Co.Ltd.RtgClub/City0 : 4
1.1 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 |
Kyrkjebo, Hanna B.
33 |
1899watchmecheck-IM 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 |
Liu, Zhaoqi
47 |
2337lzqupup0 - 1
1.2 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 |
Grimsrud, Oyvind
64 |
1836Bruneratseth- 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 |
Du, Chunhui
78 |
2288duchunhui0 - 1
1.3 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 |
Bruvold, Cathrine
95 |
1611Cbruvold- 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 |
Wei, Siyu
109 |
2106qwqwqyg0 - 1
1.4 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 |
Holmeide, Fredrik
126 |
0yolofredrik- 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 |
Chen, Yiru
140 |
1897tongccc0 - 1
Bo.2  Golomt Bank of MongoliaRtgClub/City-3  PROBIT Sp. z o.o.RtgClub/City2½:1½
2.1FM 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 |
Gan-Od, Sereenen
170 |
2294Gan-Od_Sereenen-WGM 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 |
Zawadzka, Jolanta
184 |
2236Evil_Kitten½ - ½
2.2 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 |
Tuvshinbaatar, Dondovdorj
201 |
2080mongolian_monster-IM 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 |
Zawadzki, Stanislaw
215 |
2451rgkkk½ - ½
2.3 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 |
Amgalan, Ganbaatar
232 |
1969Amaahai0602-FM 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 |
Miroslaw, Michal
246 |
2199mireq½ - ½
2.4 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 |
Munkhtur, Dagva
263 |
1853okchessboard- 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 |
Gromek, Tomasz
277 |
2030tomsun921 - 0
284 | -------------------------------------------------------------------------------- /src/page/bulkNew.ts: -------------------------------------------------------------------------------- 1 | import { h } from 'snabbdom'; 2 | import page from 'page'; 3 | import { App } from '../app'; 4 | import type { Me } from '../auth'; 5 | import { type Feedback, formData, isSuccess, responseToFeedback } from '../form'; 6 | import { gameRuleKeys, gameRules } from '../util'; 7 | import * as form from '../view/form'; 8 | import layout from '../view/layout'; 9 | import { type Pairing, filterRound, getPairings, getPlayers, saveUrls } from '../scraper/scraper'; 10 | import { bulkPairing } from '../endpoints'; 11 | import { href } from '../view/util'; 12 | import createClient from 'openapi-fetch'; 13 | import type { paths } from '@lichess-org/types'; 14 | 15 | interface Tokens { 16 | [username: string]: string; 17 | } 18 | interface Result { 19 | id: string; 20 | games: { 21 | id: string; 22 | white: string; 23 | black: string; 24 | }[]; 25 | pairAt: number; 26 | startClocksAt: number; 27 | } 28 | 29 | export class BulkNew { 30 | feedback: Feedback = undefined; 31 | readonly app: App; 32 | readonly me: Me; 33 | constructor(app: App, me: Me) { 34 | this.app = app; 35 | this.me = me; 36 | } 37 | redraw = () => this.app.redraw(this.render()); 38 | render = () => 39 | layout( 40 | this.app, 41 | h('div', [ 42 | h('nav.mt-5.breadcrumb', [ 43 | h('span.breadcrumb-item', h('a', { attrs: href(bulkPairing.path) }, 'Schedule games')), 44 | h('span.breadcrumb-item.active', 'New bulk pairing'), 45 | ]), 46 | h('h1.mt-5', 'Schedule games'), 47 | h('p.lead', [ 48 | 'Uses the ', 49 | h( 50 | 'a', 51 | { attrs: { href: 'https://lichess.org/api#tag/Bulk-pairings/operation/bulkPairingCreate' } }, 52 | 'Lichess bulk pairing API', 53 | ), 54 | ' to create a bunch of games at once.', 55 | ]), 56 | h('p', [ 57 | 'Requires the ', 58 | h('strong', 'API Challenge admin'), 59 | ' permission to generate the player challenge tokens automatically.', 60 | ]), 61 | this.renderForm(), 62 | ]), 63 | ); 64 | 65 | private onSubmit = async (form: FormData) => { 66 | const get = (key: string) => form.get(key) as string; 67 | const dateOf = (key: string) => get(key) && new Date(get(key)).getTime(); 68 | try { 69 | const playersTxt = get('players'); 70 | let pairingNames: [string, string][]; 71 | try { 72 | pairingNames = playersTxt 73 | .toLowerCase() 74 | .split('\n') 75 | .map(line => 76 | line 77 | .trim() 78 | .replace(/[\s,]+/g, ' ') 79 | .split(' '), 80 | ) 81 | .map(names => [names[0].trim(), names[1].trim()]); 82 | } catch (err) { 83 | throw 'Invalid players format'; 84 | } 85 | const tokens = await this.adminChallengeTokens(pairingNames.flat()); 86 | const randomColor = !!get('randomColor'); 87 | const sortFn = () => (randomColor ? Math.random() - 0.5 : 0); 88 | const pairingTokens: [string, string][] = pairingNames.map( 89 | duo => 90 | duo 91 | .map(name => { 92 | if (!tokens[name]) throw `Missing token for ${name}, is that an active Lichess player?`; 93 | return tokens[name]; 94 | }) 95 | .sort(sortFn) as [string, string], 96 | ); 97 | const rules = gameRuleKeys.filter(key => !!get(key)); 98 | const req = this.me.httpClient(`${this.app.config.lichessHost}/api/bulk-pairing`, { 99 | method: 'POST', 100 | body: formData({ 101 | players: pairingTokens.map(([white, black]) => `${white}:${black}`).join(','), 102 | 'clock.limit': parseFloat(get('clockLimit')) * 60, 103 | 'clock.increment': get('clockIncrement'), 104 | variant: get('variant'), 105 | rated: !!get('rated'), 106 | fen: get('fen'), 107 | message: get('message'), 108 | pairAt: dateOf('pairAt'), 109 | startClocksAt: dateOf('startClocksAt'), 110 | rules: rules.join(','), 111 | }), 112 | }); 113 | this.feedback = await responseToFeedback(req); 114 | 115 | if (isSuccess(this.feedback)) { 116 | if (!!get('armageddon')) { 117 | const addTimeResponses = new Map(); 118 | for (const game of this.feedback.result.games) { 119 | const client = createClient({ 120 | baseUrl: this.app.config.lichessHost, 121 | headers: { 122 | Authorization: `Bearer ${tokens[game.black]}`, 123 | }, 124 | }); 125 | const resp = await client.POST('/api/round/{gameId}/add-time/{seconds}', { 126 | params: { 127 | path: { 128 | gameId: game.id, 129 | seconds: 60, 130 | }, 131 | }, 132 | }); 133 | addTimeResponses.set(game.id, resp.response.status); 134 | } 135 | 136 | const alerts: string[] = []; 137 | addTimeResponses.forEach((status, gameId) => { 138 | if (status !== 200) { 139 | alerts.push(`Failed to add armageddon time to game ${gameId}, status ${status}`); 140 | } 141 | }); 142 | if (alerts.length) { 143 | alert(alerts.join('\n')); 144 | } 145 | } 146 | 147 | saveUrls(this.feedback.result.id, get('cr-pairings-url'), get('cr-players-url')); 148 | page(`/endpoint/schedule-games/${this.feedback.result.id}`); 149 | } 150 | } catch (err) { 151 | console.warn(err); 152 | this.feedback = { 153 | error: { players: JSON.stringify(err) }, 154 | }; 155 | } 156 | this.redraw(); 157 | document.getElementById('endpoint-form')?.scrollIntoView({ behavior: 'smooth' }); 158 | }; 159 | 160 | private adminChallengeTokens = async (users: string[]): Promise => { 161 | const res = await this.me.httpClient(`${this.app.config.lichessHost}/api/token/admin-challenge`, { 162 | method: 'POST', 163 | body: formData({ 164 | users: users.join(','), 165 | description: 'Tournament pairings from the Lichess team', 166 | }), 167 | }); 168 | const json = await res.json(); 169 | if (json.error) throw json.error; 170 | return json; 171 | }; 172 | 173 | private renderForm = () => 174 | form.form(this.onSubmit, [ 175 | form.feedback(this.feedback), 176 | h('div.mb-3', [ 177 | h('div.row', [ 178 | h('div.col-md-6', [ 179 | form.label('Players', 'players'), 180 | h('textarea#players.form-control', { 181 | attrs: { 182 | name: 'players', 183 | style: 'height: 100px', 184 | required: true, 185 | spellcheck: 'false', 186 | }, 187 | }), 188 | h('p.form-text', [ 189 | 'Two usernames per line, each line is a game.', 190 | h('br'), 191 | 'First username gets the white pieces, unless randomized by the switch below.', 192 | ]), 193 | h('div.form-check.form-switch', form.checkboxWithLabel('randomColor', 'Randomize colors')), 194 | h( 195 | 'button.btn.btn-secondary.btn-sm.mt-2', 196 | { 197 | attrs: { 198 | type: 'button', 199 | }, 200 | on: { 201 | click: () => 202 | this.validateUsernames(document.getElementById('players') as HTMLTextAreaElement), 203 | }, 204 | }, 205 | 'Validate Lichess usernames', 206 | ), 207 | ]), 208 | h('div.col-md-6', [ 209 | h('details', [ 210 | h('summary.text-muted.form-label', 'Or load the players and pairings from another website'), 211 | h('div.card.card-body', [form.loadPlayersFromUrl()]), 212 | h( 213 | 'button.btn.btn-secondary.btn-sm.mt-3', 214 | { 215 | attrs: { 216 | type: 'button', 217 | }, 218 | on: { 219 | click: () => 220 | this.loadPairingsFromChessResults( 221 | document.getElementById('cr-pairings-url') as HTMLInputElement, 222 | document.getElementById('cr-players-url') as HTMLInputElement, 223 | ), 224 | }, 225 | }, 226 | 'Load pairings', 227 | ), 228 | ]), 229 | ]), 230 | ]), 231 | ]), 232 | form.clock(), 233 | h( 234 | 'div.form-check.form-switch.mb-3', 235 | form.checkboxWithLabel('armageddon', 'Armageddon? (+60 seconds for white)'), 236 | ), 237 | h('div.form-check.form-switch.mb-3', form.checkboxWithLabel('rated', 'Rated games', true)), 238 | form.variant(), 239 | form.fen(), 240 | h('div.mb-3', [ 241 | form.label('Inbox message', 'message'), 242 | h( 243 | 'textarea#message.form-control', 244 | { 245 | attrs: { 246 | name: 'message', 247 | style: 'height: 100px', 248 | }, 249 | }, 250 | 'Your game with {opponent} is ready: {game}.', 251 | ), 252 | h('p.form-text', [ 253 | 'Message that will be sent to each player, when the game is created. It is sent from your user account.', 254 | h('br'), 255 | h('code', '{opponent}'), 256 | ' and ', 257 | h('code', '{game}'), 258 | ' are placeholders that will be replaced with the opponent and the game URLs.', 259 | h('br'), 260 | 'The ', 261 | h('code', '{game}'), 262 | ' placeholder is mandatory.', 263 | ]), 264 | ]), 265 | form.specialRules(gameRules), 266 | h('div.mb-3', [ 267 | form.label('When to create the games', 'pairAt'), 268 | h('input#pairAt.form-control', { 269 | attrs: { 270 | type: 'datetime-local', 271 | name: 'pairAt', 272 | }, 273 | }), 274 | h('p.form-text', 'Leave empty to create the games immediately.'), 275 | ]), 276 | h('div.mb-3', [ 277 | form.label('When to start the clocks', 'startClocksAt'), 278 | h('input#startClocksAt.form-control', { 279 | attrs: { 280 | type: 'datetime-local', 281 | name: 'startClocksAt', 282 | }, 283 | }), 284 | h('p.form-text', [ 285 | 'Date at which the clocks will be automatically started.', 286 | h('br'), 287 | 'Note that the clocks can start earlier than specified, if players start making moves in the game.', 288 | h('br'), 289 | 'Leave empty so that the clocks only start when players make moves.', 290 | ]), 291 | ]), 292 | form.submit('Schedule the games'), 293 | ]); 294 | 295 | private validateUsernames = async (textarea: HTMLTextAreaElement) => { 296 | const usernames = textarea.value.match(/(<.*?>)|(\S+)/g); 297 | if (!usernames) return; 298 | 299 | let validUsernames: string[] = []; 300 | 301 | const chunkSize = 300; 302 | for (let i = 0; i < usernames.length; i += chunkSize) { 303 | const res = await this.me.httpClient(`${this.app.config.lichessHost}/api/users`, { 304 | method: 'POST', 305 | body: usernames.slice(i, i + chunkSize).join(', '), 306 | headers: { 307 | 'Content-Type': 'text/plain', 308 | }, 309 | }); 310 | const users = await res.json(); 311 | validUsernames = validUsernames.concat(users.filter((u: any) => !u.disabled).map((u: any) => u.id)); 312 | } 313 | 314 | const invalidUsernames = usernames.filter(username => !validUsernames.includes(username.toLowerCase())); 315 | if (invalidUsernames.length) { 316 | alert(`Invalid usernames: ${invalidUsernames.join(', ')}`); 317 | } else { 318 | alert('All usernames are valid!'); 319 | } 320 | }; 321 | 322 | private loadPairingsFromChessResults = async ( 323 | pairingsInput: HTMLInputElement, 324 | playersInput: HTMLInputElement, 325 | ) => { 326 | try { 327 | const pairingsUrl = pairingsInput.value; 328 | const playersUrl = playersInput.value; 329 | 330 | const players = playersUrl ? await getPlayers(playersUrl) : undefined; 331 | const pairings = await getPairings(pairingsUrl, players); 332 | this.insertPairings(this.selectRound(pairings)); 333 | } catch (err) { 334 | alert(err); 335 | } 336 | }; 337 | 338 | private selectRound(pairings: Pairing[]) { 339 | const numRounds = pairings.filter(p => p.board === '1.1').length; 340 | if (numRounds > 1) { 341 | const selectedRound = prompt( 342 | `There are ${numRounds} rounds in this tournament. Which round do you want to load? (1-${numRounds}, or "all" for no filtering)`, 343 | ); 344 | if (selectedRound === null) { 345 | throw new Error('Invalid round number'); 346 | } else if (selectedRound === 'all') { 347 | return pairings; 348 | } 349 | const roundNum = parseInt(selectedRound); 350 | if (isNaN(roundNum) || roundNum < 1 || roundNum > numRounds) { 351 | throw new Error('Invalid round number'); 352 | } 353 | return filterRound(pairings, roundNum); 354 | } 355 | return pairings; 356 | } 357 | 358 | private insertPairings(pairings: Pairing[]) { 359 | pairings.forEach(pairing => { 360 | const playersTxt = (document.getElementById('players') as HTMLTextAreaElement).value; 361 | 362 | const white = pairing.white.lichess || `<${pairing.white.name}>`; 363 | const black = pairing.black.lichess || `<${pairing.black.name}>`; 364 | 365 | const newLine = `${white} ${black}`; 366 | (document.getElementById('players') as HTMLTextAreaElement).value = 367 | playersTxt + (playersTxt ? '\n' : '') + newLine; 368 | }); 369 | } 370 | } 371 | -------------------------------------------------------------------------------- /src/page/bulkShow.ts: -------------------------------------------------------------------------------- 1 | import { h } from 'snabbdom'; 2 | import { App } from '../app'; 3 | import type { Me } from '../auth'; 4 | import layout from '../view/layout'; 5 | import { href, timeFormat } from '../view/util'; 6 | import type { Bulk, BulkId, Game, Player, Username } from '../model'; 7 | import { type Stream, readStream } from '../ndJsonStream'; 8 | import { bulkPairing } from '../endpoints'; 9 | import { sleep, ucfirst } from '../util'; 10 | import { loadPlayersFromUrl } from '../view/form'; 11 | import { type Pairing, getPairings, getPlayers, getUrls, saveUrls } from '../scraper/scraper'; 12 | 13 | type Result = '*' | '1-0' | '0-1' | '½-½' | '+--' | '--+'; 14 | interface FormattedGame { 15 | id: string; 16 | moves: number; 17 | result: Result; 18 | players: { white: Player; black: Player }; 19 | fullNames: { white?: string; black?: string }; 20 | } 21 | 22 | export class BulkShow { 23 | bulk?: Bulk; 24 | games: FormattedGame[] = []; 25 | gameStream?: Stream; 26 | liveUpdate = true; 27 | fullNames = new Map(); 28 | crPairings: Pairing[] = []; 29 | readonly app: App; 30 | readonly me: Me; 31 | readonly id: BulkId; 32 | constructor(app: App, me: Me, id: BulkId) { 33 | this.app = app; 34 | this.me = me; 35 | this.id = id; 36 | this.loadBulk().then(() => this.loadGames()); 37 | } 38 | loadBulk = async () => { 39 | const res = await this.me.httpClient(`${this.app.config.lichessHost}/api/bulk-pairing/${this.id}`); 40 | this.bulk = await res.json(); 41 | this.redraw(); 42 | }; 43 | loadGames = async (forceUpdate: boolean = false): Promise => { 44 | this.gameStream?.close(); 45 | if (this.bulk) { 46 | const res = await this.me.httpClient(`${this.app.config.lichessHost}/api/games/export/_ids`, { 47 | method: 'POST', 48 | body: this.bulk.games.map(game => game.id).join(','), 49 | headers: { Accept: 'application/x-ndjson' }, 50 | }); 51 | const handler = (g: Game) => { 52 | const moves = g.moves ? g.moves.split(' ').length : 0; 53 | const game: FormattedGame = { 54 | id: g.id, 55 | players: g.players, 56 | fullNames: { 57 | white: this.fullNames.get(g.players.white.user.id), 58 | black: this.fullNames.get(g.players.black.user.id), 59 | }, 60 | moves, 61 | result: this.gameResult(g.status, g.winner, moves), 62 | }; 63 | const exists = this.games.findIndex(g => g.id === game.id); 64 | if (exists >= 0) this.games[exists] = game; 65 | else this.games.push(game); 66 | this.sortGames(); 67 | this.redraw(); 68 | }; 69 | this.gameStream = readStream(res, handler); 70 | await this.gameStream.closePromise; 71 | const empty = this.games.length == 0; 72 | await sleep((empty ? 1 : 5) * 1000); 73 | this.liveUpdate = this.liveUpdate && (empty || !!this.games.find(g => g.result === '*')); 74 | if (this.liveUpdate || forceUpdate) return await this.loadGames(); 75 | } 76 | }; 77 | static renderClock = (bulk: Bulk) => `${bulk.clock.limit / 60}+${bulk.clock.increment}`; 78 | private sortGames = () => 79 | this.games.sort((a, b) => { 80 | if (a.result === '*' && b.result !== '*') return -1; 81 | if (a.result !== '*' && b.result === '*') return 1; 82 | if (a.moves !== b.moves) return a.moves < b.moves ? -1 : 1; 83 | return a.id < b.id ? -1 : 1; 84 | }); 85 | private gameResult = (status: string, winner: 'white' | 'black' | undefined, moves: number): Result => 86 | status == 'created' || status == 'started' 87 | ? '*' 88 | : !winner 89 | ? '½-½' 90 | : moves > 1 91 | ? winner == 'white' 92 | ? '1-0' 93 | : '0-1' 94 | : winner == 'white' 95 | ? '+--' 96 | : '--+'; 97 | 98 | private canStartClocks = () => 99 | (!this.bulk?.startClocksAt || this.bulk.startClocksAt > Date.now()) && this.games.find(g => g.moves < 2); 100 | private startClocks = async () => { 101 | if (this.bulk && this.canStartClocks()) { 102 | const res = await this.me.httpClient( 103 | `${this.app.config.lichessHost}/api/bulk-pairing/${this.id}/start-clocks`, 104 | { method: 'POST' }, 105 | ); 106 | if (res.status === 200) this.bulk.startClocksAt = Date.now(); 107 | } 108 | }; 109 | private onDestroy = () => { 110 | this.gameStream?.close(); 111 | this.liveUpdate = false; 112 | }; 113 | redraw = () => this.app.redraw(this.render()); 114 | render = () => { 115 | return layout( 116 | this.app, 117 | h('div', [ 118 | h('nav.mt-5.breadcrumb', [ 119 | h('span.breadcrumb-item', h('a', { attrs: href(bulkPairing.path) }, 'Schedule games')), 120 | h('span.breadcrumb-item.active', `#${this.id}`), 121 | ]), 122 | this.bulk 123 | ? h(`div.card.my-5`, [ 124 | h('h1.card-header.text-body-emphasis.py-4', `Bulk pairing #${this.id}`), 125 | h('div.card-body', [ 126 | h( 127 | 'table.table.table-borderless', 128 | h('tbody', [ 129 | h('tr', [ 130 | h('th', 'Setup'), 131 | h('td', [ 132 | BulkShow.renderClock(this.bulk), 133 | ' ', 134 | ucfirst(this.bulk.variant), 135 | ' ', 136 | this.bulk.rated ? 'Rated' : 'Casual', 137 | ]), 138 | ]), 139 | h('tr', [ 140 | h('th.w-25', 'Created at'), 141 | h('td', timeFormat(new Date(this.bulk.scheduledAt))), 142 | ]), 143 | h('tr', [ 144 | h('th', 'Games scheduled at'), 145 | h('td', this.bulk.pairAt ? timeFormat(new Date(this.bulk.pairAt)) : 'Now'), 146 | ]), 147 | h('tr', [ 148 | h('th', 'Clocks start at'), 149 | h('td', [ 150 | this.bulk.startClocksAt 151 | ? timeFormat(new Date(this.bulk.startClocksAt)) 152 | : 'When players make a move', 153 | this.canStartClocks() 154 | ? h( 155 | 'a.btn.btn-sm.btn-outline-warning.ms-3', 156 | { 157 | on: { 158 | click: () => { 159 | if (confirm('Start all clocks?')) this.startClocks(); 160 | }, 161 | }, 162 | }, 163 | 'Start all clocks now', 164 | ) 165 | : undefined, 166 | ]), 167 | ]), 168 | h('tr', [ 169 | h('th', 'Games started'), 170 | h('td.mono', [ 171 | this.games.filter(g => g.moves > 1).length, 172 | ' / ' + this.bulk.games.length, 173 | ]), 174 | ]), 175 | h('tr', [ 176 | h('th', 'Games completed'), 177 | h('td.mono', [ 178 | this.games.filter(g => g.result !== '*').length, 179 | ' / ' + this.bulk.games.length, 180 | ]), 181 | ]), 182 | h('tr', [ 183 | h('th', 'Player names'), 184 | h('td', [ 185 | h('details', [ 186 | h('summary.text-muted.form-label', 'Load player names from another site'), 187 | h('div.card.card-body', [loadPlayersFromUrl(getUrls(this.bulk.id))]), 188 | h( 189 | 'button.btn.btn-secondary.btn-sm.mt-3', 190 | { 191 | attrs: { 192 | type: 'button', 193 | }, 194 | on: { 195 | click: () => { 196 | if (!this.bulk) return; 197 | 198 | const pairingsInput = document.getElementById( 199 | 'cr-pairings-url', 200 | ) as HTMLInputElement; 201 | const playersInput = document.getElementById( 202 | 'cr-players-url', 203 | ) as HTMLInputElement; 204 | 205 | saveUrls(this.bulk.id, pairingsInput.value, playersInput.value); 206 | this.loadNamesFromChessResults(pairingsInput, playersInput); 207 | 208 | this.loadGames(true); 209 | }, 210 | }, 211 | }, 212 | 'Load names', 213 | ), 214 | ]), 215 | ]), 216 | ]), 217 | this.bulk.rules 218 | ? h('tr', [ 219 | h('th', 'Extra rules'), 220 | h( 221 | 'td', 222 | this.bulk.rules.map(r => h('span.badge.rounded-pill.text-bg-secondary.mx-1', r)), 223 | ), 224 | ]) 225 | : undefined, 226 | h('tr', [ 227 | h('th', 'Game IDs'), 228 | h('td', [ 229 | h('details', [ 230 | h( 231 | 'summary.text-muted.form-label', 232 | 'Show individual game IDs for a Lichess Broadcast', 233 | ), 234 | h('div.card.card-body', [ 235 | h( 236 | 'textarea.form-control', 237 | { attrs: { rows: 2, spellcheck: 'false', onfocus: 'this.select()' } }, 238 | this.games.map(g => g.id).join(' '), 239 | ), 240 | h( 241 | 'small.form-text.text-muted', 242 | 'Copy and paste these when setting up a Lichess Broadcast Round', 243 | ), 244 | ]), 245 | ]), 246 | ]), 247 | ]), 248 | ]), 249 | ), 250 | ]), 251 | ]) 252 | : h('div.m-5', h('div.spinner-border.d-block.mx-auto', { attrs: { role: 'status' } })), 253 | , 254 | this.bulk ? h('div', [this.renderDefaultView(), this.renderChessResultsView()]) : undefined, 255 | ]), 256 | ); 257 | }; 258 | 259 | renderDefaultView = () => { 260 | const playerLink = (player: Player) => 261 | this.lichessLink( 262 | '@/' + player.user.name, 263 | `${player.user.title ? player.user.title + ' ' : ''}${player.user.name}`, 264 | ); 265 | return h( 266 | 'table.table.table-striped.table-hover', 267 | { 268 | hook: { destroy: () => this.onDestroy() }, 269 | }, 270 | [ 271 | h('thead', [ 272 | h('tr', [ 273 | h('th', this.bulk?.games.length + ' games'), 274 | h('th', 'White'), 275 | h('th'), 276 | h('th', 'Black'), 277 | h('th'), 278 | h('th.text-center', 'Result'), 279 | h('th.text-end', 'Moves'), 280 | ]), 281 | ]), 282 | h( 283 | 'tbody', 284 | this.games.map(g => 285 | h('tr', { key: g.id }, [ 286 | h('td.mono', this.lichessLink(g.id, `#${g.id}`)), 287 | h('td', playerLink(g.players.white)), 288 | h('td', g.fullNames.white), 289 | h('td', playerLink(g.players.black)), 290 | h('td', g.fullNames.black), 291 | h('td.mono.text-center', g.result), 292 | h('td.mono.text-end', g.moves), 293 | ]), 294 | ), 295 | ), 296 | ], 297 | ); 298 | }; 299 | 300 | renderChessResultsView = () => { 301 | if (this.crPairings.length === 0) { 302 | return; 303 | } 304 | 305 | const results: { 306 | gameId?: string; 307 | board: string; 308 | name1: string; 309 | name2: string; 310 | team1?: string; 311 | team2?: string; 312 | result?: string; 313 | reversed: boolean; 314 | }[] = this.crPairings.map(pairing => { 315 | const game = this.games.find( 316 | game => 317 | game.players.white.user.id === pairing.white.lichess?.toLowerCase() && 318 | game.players.black.user.id === pairing.black.lichess?.toLowerCase(), 319 | ); 320 | 321 | if (!pairing.reversed) { 322 | return { 323 | gameId: game?.id, 324 | board: pairing.board, 325 | name1: pairing.white.name, 326 | name2: pairing.black.name, 327 | team1: pairing.white.team, 328 | team2: pairing.black.team, 329 | result: game?.result, 330 | reversed: pairing.reversed, 331 | }; 332 | } else { 333 | return { 334 | gameId: game?.id, 335 | board: pairing.board, 336 | name1: pairing.black.name, 337 | name2: pairing.white.name, 338 | team1: pairing.black.team, 339 | team2: pairing.white.team, 340 | result: game?.result.split('').reverse().join(''), 341 | reversed: pairing.reversed, 342 | }; 343 | } 344 | }); 345 | 346 | return h('div.mt-5', [ 347 | h('h4', 'Chess Results View'), 348 | h('table.table.table-striped.table-hover', [ 349 | h( 350 | 'tbody', 351 | results.map(result => 352 | h('tr', { key: result.name1 }, [ 353 | h('td.mono', result.gameId ? this.lichessLink(result.gameId) : null), 354 | h('td.mono', result.board), 355 | h('td', result.team1), 356 | h('td', result.reversed ? '' : 'w'), 357 | h('td', result.name1), 358 | h('td.mono.text-center.table-secondary', result.result), 359 | h('td', result.reversed ? 'w' : ''), 360 | h('td', result.name2), 361 | h('td', result.team2), 362 | ]), 363 | ), 364 | ), 365 | ]), 366 | ]); 367 | }; 368 | 369 | private lichessLink = (path: string, text?: string) => { 370 | const href = `${this.app.config.lichessHost}/${path}`; 371 | return h('a', { attrs: { target: '_blank', href } }, text || href); 372 | }; 373 | 374 | private loadNamesFromChessResults = async ( 375 | pairingsInput: HTMLInputElement, 376 | playersInput: HTMLInputElement, 377 | ) => { 378 | try { 379 | const pairingsUrl = pairingsInput.value; 380 | const playersUrl = playersInput.value; 381 | 382 | const players = playersUrl ? await getPlayers(playersUrl) : undefined; 383 | this.crPairings = await getPairings(pairingsUrl, players); 384 | 385 | this.crPairings.forEach(p => { 386 | p.white.lichess && this.fullNames.set(p.white.lichess.toLowerCase(), p.white.name); 387 | p.black.lichess && this.fullNames.set(p.black.lichess.toLowerCase(), p.black.name); 388 | }); 389 | } catch (err) { 390 | alert(err); 391 | } 392 | }; 393 | } 394 | -------------------------------------------------------------------------------- /src/scraper/tests/fixtures/individual-round-robin-pairings.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 |
Round 1 on 2023/06/24 at 14:00
Bo.No.RtgWhiteResultBlackRtgNo.
182586GMPonkratov, Pavel0 - 1FMGalaktionov, Artem23794
272472IMDrozdowski, Kacper0 - 1GMAndreikin, Dmitry26281
322186FMAradhya, Garg0 - 1FMSevgi, Volkan22046
432500GMMoroni, Luca Jr1 - 0Sviridov, Valery24075
Round 2 on 2023/06/24 at 14:30
Bo.No.RtgWhiteResultBlackRtgNo.
142379FMGalaktionov, Artem½ - ½Sviridov, Valery24075
262204FMSevgi, Volkan1 - 0GMMoroni, Luca Jr25003
312628GMAndreikin, Dmitry1 - 0FMAradhya, Garg21862
482586GMPonkratov, Pavel½ - ½IMDrozdowski, Kacper24727
Round 3 on 2023/06/24 at 15:00
Bo.No.RtgWhiteResultBlackRtgNo.
172472IMDrozdowski, Kacper0 - 1FMGalaktionov, Artem23794
222186FMAradhya, Garg1 - 0GMPonkratov, Pavel25868
332500GMMoroni, Luca Jr½ - ½GMAndreikin, Dmitry26281
452407Sviridov, Valery1 - 0FMSevgi, Volkan22046
Round 4 on 2023/06/24 at 15:30
Bo.No.RtgWhiteResultBlackRtgNo.
142379FMGalaktionov, Artem½ - ½FMSevgi, Volkan22046
212628GMAndreikin, Dmitry½ - ½Sviridov, Valery24075
382586GMPonkratov, Pavel1 - 0GMMoroni, Luca Jr25003
472472IMDrozdowski, Kacper1 - 0FMAradhya, Garg21862
Round 5 on 2023/06/24 at 16:00
Bo.No.RtgWhiteResultBlackRtgNo.
122186FMAradhya, Garg0 - 1FMGalaktionov, Artem23794
232500GMMoroni, Luca Jr½ - ½IMDrozdowski, Kacper24727
352407Sviridov, Valery1 - 0GMPonkratov, Pavel25868
462204FMSevgi, Volkan1 - 0GMAndreikin, Dmitry26281
Round 6 on 2023/06/24 at 16:30
Bo.No.RtgWhiteResultBlackRtgNo.
142379FMGalaktionov, Artem0 - 1GMAndreikin, Dmitry26281
282586GMPonkratov, Pavel1 - 0FMSevgi, Volkan22046
372472IMDrozdowski, Kacper½ - ½Sviridov, Valery24075
422186FMAradhya, Garg½ - ½GMMoroni, Luca Jr25003
Round 7 on 2023/06/24 at 17:00
Bo.No.RtgWhiteResultBlackRtgNo.
132500GMMoroni, Luca Jr½ - ½FMGalaktionov, Artem23794
252407Sviridov, Valery½ - ½FMAradhya, Garg21862
362204FMSevgi, Volkan0 - 1IMDrozdowski, Kacper24727
412628GMAndreikin, Dmitry1 - 0GMPonkratov, Pavel25868
476 | -------------------------------------------------------------------------------- /src/scraper/tests/scrape.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test, vi, type Mock, beforeEach } from 'vitest'; 2 | import { readFileSync } from 'fs'; 3 | import { 4 | getPlayers, 5 | getPairings, 6 | setResultsPerPage, 7 | type Player, 8 | getUrls, 9 | saveUrls, 10 | setCacheBuster, 11 | type Pairing, 12 | filterRound, 13 | } from '../scraper'; 14 | 15 | global.fetch = vi.fn(proxyUrl => { 16 | let url = new URL(decodeURIComponent(proxyUrl.split('?')[1])); 17 | let path = url.pathname; 18 | 19 | return Promise.resolve({ 20 | text: () => Promise.resolve(readFileSync(`src/scraper/tests/fixtures${path}`)), 21 | }); 22 | }) as Mock; 23 | 24 | describe('fetch players', () => { 25 | test('with lichess usernames', async () => { 26 | const players = await getPlayers('https://example.com/players-list-with-usernames.html'); 27 | 28 | expect(players[1]).toEqual({ 29 | name: 'Navara, David', 30 | fideId: '309095', 31 | rating: 2679, 32 | lichess: 'RealDavidNavara', 33 | }); 34 | expect(players).toHaveLength(71); 35 | }); 36 | 37 | test('with team columns', async () => { 38 | const players = await getPlayers('https://example.com/players-list-without-usernames.html'); 39 | 40 | expect(players[0]).toEqual({ 41 | name: 'Nepomniachtchi Ian', 42 | fideId: '4168119', 43 | rating: 2789, 44 | lichess: undefined, 45 | }); 46 | expect(players).toHaveLength(150); 47 | }); 48 | }); 49 | 50 | describe('fetch pairings', () => { 51 | test('team swiss', async () => { 52 | const pairings = await getPairings('https://example.com/team-swiss-pairings-with-usernames.html'); 53 | 54 | expect(pairings).toStrictEqual([ 55 | { 56 | black: { 57 | lichess: 'test4', 58 | name: 'Hris, Panagiotis', 59 | team: 'Team C', 60 | rating: 2227, 61 | }, 62 | white: { 63 | lichess: 'test134', 64 | name: 'Testing, Test', 65 | team: 'Team B', 66 | rating: 1985, 67 | }, 68 | reversed: false, 69 | board: '1.1', 70 | }, 71 | { 72 | black: { 73 | lichess: 'test3', 74 | name: 'Someone, Else', 75 | team: 'Team B', 76 | rating: 2400, 77 | }, 78 | white: { 79 | lichess: 'test5', 80 | name: 'Trevlar, Someone', 81 | team: 'Team C', 82 | rating: 0, 83 | }, 84 | reversed: true, 85 | board: '1.2', 86 | }, 87 | { 88 | black: { 89 | lichess: 'test6', 90 | name: 'TestPlayer, Mary', 91 | team: 'Team C', 92 | rating: 1600, 93 | }, 94 | white: { 95 | lichess: 'test1', 96 | name: 'Another, Test', 97 | team: 'Team B', 98 | rating: 1900, 99 | }, 100 | reversed: false, 101 | board: '1.3', 102 | }, 103 | { 104 | black: { 105 | lichess: 'test2', 106 | name: 'Ignore, This', 107 | team: 'Team B', 108 | rating: 1400, 109 | }, 110 | white: { 111 | lichess: 'test7', 112 | name: 'Testing, Tester', 113 | team: 'Team C', 114 | rating: 0, 115 | }, 116 | reversed: true, 117 | board: '1.4', 118 | }, 119 | { 120 | black: { 121 | lichess: 'TestAccount1', 122 | name: 'SomeoneElse, Michael', 123 | team: 'Team D', 124 | rating: 2230, 125 | }, 126 | white: { 127 | lichess: 'Cynosure', 128 | name: 'Wait, Theophilus', 129 | team: 'Team A', 130 | rating: 0, 131 | }, 132 | reversed: false, 133 | board: '2.1', 134 | }, 135 | { 136 | black: { 137 | lichess: 'Thibault', 138 | name: 'Thibault, D', 139 | team: 'Team A', 140 | rating: 0, 141 | }, 142 | white: { 143 | lichess: 'TestAccount2', 144 | name: 'YetSomeoneElse, Lilly', 145 | team: 'Team D', 146 | rating: 2070, 147 | }, 148 | reversed: true, 149 | board: '2.2', 150 | }, 151 | { 152 | black: { 153 | lichess: 'TestAccount3', 154 | name: 'Unknown, Player', 155 | team: 'Team D', 156 | rating: 1300, 157 | }, 158 | white: { 159 | lichess: 'Puzzlingpuzzler', 160 | name: 'Gkizi, Konst', 161 | team: 'Team A', 162 | rating: 1270, 163 | }, 164 | reversed: false, 165 | board: '2.3', 166 | }, 167 | { 168 | black: { 169 | lichess: 'ThisAccountDoesntExist', 170 | name: 'Placeholder, Player', 171 | team: 'Team A', 172 | rating: 0, 173 | }, 174 | white: { 175 | lichess: 'TestAccount4', 176 | name: 'Also, Unknown', 177 | team: 'Team D', 178 | rating: 1111, 179 | }, 180 | reversed: true, 181 | board: '2.4', 182 | }, 183 | ]); 184 | expect(pairings).toHaveLength(8); 185 | }); 186 | 187 | test('team another swiss', async () => { 188 | const pairings = await getPairings('https://example.com/team-swiss-pairings-with-usernames-2.html'); 189 | 190 | expect(pairings[0]).toStrictEqual({ 191 | black: { 192 | lichess: 'PeterAcs', 193 | name: 'Acs Peter', 194 | team: 'Morgan Stanley', 195 | rating: 2582, 196 | }, 197 | white: { 198 | lichess: 'joe1714', 199 | name: 'Karan J P', 200 | team: 'Accenture', 201 | rating: 1852, 202 | }, 203 | reversed: false, 204 | board: '1.1', 205 | }); 206 | expect(pairings[pairings.length - 1]).toStrictEqual({ 207 | black: { 208 | lichess: 'Dimash8888', 209 | name: 'Jexekov Dimash', 210 | team: 'Freedom Holding', 211 | rating: 0, 212 | }, 213 | white: { 214 | lichess: 'hhauks', 215 | name: 'Hauksdottir Hrund', 216 | team: 'Islandsbanki', 217 | rating: 1814, 218 | }, 219 | reversed: true, 220 | board: '7.4', 221 | }); 222 | expect(pairings).toHaveLength(28); 223 | }); 224 | 225 | test('team swiss w/o lichess usernames on the same page', async () => { 226 | const pairings = await getPairings('https://example.com/team-swiss-pairings-without-usernames.html'); 227 | 228 | expect(pairings[0]).toEqual({ 229 | white: { 230 | name: 'Berend Elvira', 231 | team: 'European Investment Bank', 232 | rating: 2326, 233 | lichess: undefined, 234 | }, 235 | black: { 236 | name: 'Nepomniachtchi Ian', 237 | team: 'SBER', 238 | rating: 2789, 239 | lichess: undefined, 240 | }, 241 | reversed: false, 242 | board: '1.1', 243 | }); 244 | expect(pairings[1]).toEqual({ 245 | black: { 246 | name: 'Sebe-Vodislav Razvan-Alexandru', 247 | team: 'European Investment Bank', 248 | rating: 2270, 249 | lichess: undefined, 250 | }, 251 | white: { 252 | name: 'Kadatsky Alexander', 253 | team: 'SBER', 254 | rating: 2368, 255 | lichess: undefined, 256 | }, 257 | reversed: true, 258 | board: '1.2', 259 | }); 260 | 261 | // check the next set of Teams 262 | expect(pairings[8]).toEqual({ 263 | black: { 264 | name: 'Delchev Alexander', 265 | team: 'Tigar Tyres', 266 | rating: 2526, 267 | lichess: undefined, 268 | }, 269 | white: { 270 | name: 'Chernikova Iryna', 271 | team: 'Airbus (FRA)', 272 | rating: 1509, 273 | lichess: undefined, 274 | }, 275 | reversed: false, 276 | board: '3.1', 277 | }); 278 | expect(pairings).toHaveLength(76); 279 | }); 280 | 281 | test('individual round robin', async () => { 282 | const pairings = await getPairings('https://example.com/individual-round-robin-pairings.html'); 283 | 284 | expect(pairings[0]).toEqual({ 285 | white: { 286 | name: 'Ponkratov, Pavel', 287 | }, 288 | black: { 289 | name: 'Galaktionov, Artem', 290 | }, 291 | reversed: false, 292 | board: '1', 293 | }); 294 | expect(pairings).toHaveLength(28); 295 | }); 296 | 297 | test('team round robin', async () => { 298 | const pairings = await getPairings('https://example.com/team-round-robin-pairings.html'); 299 | 300 | expect(pairings[0]).toEqual({ 301 | white: { 302 | name: 'Kyrkjebo, Hanna B.', 303 | team: 'KPMG Norway', 304 | lichess: 'watchmecheck', 305 | rating: 1899, 306 | }, 307 | black: { 308 | name: 'Liu, Zhaoqi', 309 | team: 'Nanjing Spark Chess Technology Co.Ltd.', 310 | lichess: 'lzqupup', 311 | rating: 2337, 312 | }, 313 | reversed: false, 314 | board: '1.1', 315 | }); 316 | expect(pairings[1]).toEqual({ 317 | white: { 318 | name: 'Du, Chunhui', 319 | team: 'Nanjing Spark Chess Technology Co.Ltd.', 320 | lichess: 'duchunhui', 321 | rating: 2288, 322 | }, 323 | black: { 324 | name: 'Grimsrud, Oyvind', 325 | team: 'KPMG Norway', 326 | lichess: 'Bruneratseth', 327 | rating: 1836, 328 | }, 329 | reversed: true, 330 | board: '1.2', 331 | }); 332 | expect(pairings).toHaveLength(8); 333 | }); 334 | 335 | test('individual swiss', async () => { 336 | const pairings = await getPairings('https://example.com/individual-swiss-pairings.html'); 337 | 338 | expect(pairings[0]).toEqual({ 339 | white: { 340 | name: 'Gunina, Valentina', 341 | }, 342 | black: { 343 | name: 'Mammadzada, Gunay', 344 | }, 345 | reversed: false, 346 | board: '1', 347 | }); 348 | expect(pairings).toHaveLength(59); 349 | }); 350 | 351 | test('individual swiss w/ player substitution', async () => { 352 | const players: Player[] = [ 353 | { 354 | name: 'Gunina, Valentina', 355 | lichess: 'test-valentina', 356 | }, 357 | { 358 | name: 'Mammadzada, Gunay', 359 | lichess: 'test-gunay', 360 | }, 361 | ]; 362 | const pairings = await getPairings('https://example.com/individual-swiss-pairings.html', players); 363 | 364 | expect(pairings[0]).toEqual({ 365 | white: { 366 | name: 'Gunina, Valentina', 367 | lichess: 'test-valentina', 368 | }, 369 | black: { 370 | name: 'Mammadzada, Gunay', 371 | lichess: 'test-gunay', 372 | }, 373 | reversed: false, 374 | board: '1', 375 | }); 376 | expect(pairings).toHaveLength(59); 377 | }); 378 | 379 | test('team ko pairings w/o usernames', async () => { 380 | const pairings = await getPairings('https://example.com/team-ko-pairings-without-usernames.html'); 381 | 382 | expect(pairings[0]).toEqual({ 383 | white: { 384 | name: 'Andriasian, Zaven', 385 | rating: 2624, 386 | team: 'Chessify', 387 | }, 388 | black: { 389 | name: 'Schneider, Igor', 390 | rating: 2206, 391 | team: 'Deutsche Bank', 392 | }, 393 | reversed: false, 394 | board: '1.1', 395 | }); 396 | 397 | expect(pairings[pairings.length - 1]).toEqual({ 398 | white: { 399 | name: 'van Eerde, Matthew', 400 | rating: 0, 401 | team: 'Microsoft E', 402 | }, 403 | black: { 404 | name: 'Asbjornsen, Oyvind Von Doren', 405 | rating: 1594, 406 | team: 'Von Doren Watch Company AS', 407 | }, 408 | reversed: true, 409 | board: '3.4', 410 | }); 411 | expect(pairings).toHaveLength(24); 412 | }); 413 | 414 | test('team ko pairings w/ usernames', async () => { 415 | const pairings = await getPairings('https://example.com/team-ko-pairings-with-usernames.html'); 416 | 417 | expect(pairings[0]).toEqual({ 418 | white: { 419 | name: 'T2, P1', 420 | lichess: 't2pl1', 421 | rating: 1678, 422 | team: 'Team 2', 423 | }, 424 | black: { 425 | name: 'T4, P1', 426 | lichess: 't4pl1', 427 | rating: 0, 428 | team: 'Team 4', 429 | }, 430 | reversed: false, 431 | board: '1.1', 432 | }); 433 | 434 | expect(pairings[pairings.length - 1]).toEqual({ 435 | white: { 436 | name: 'T3, P4', 437 | lichess: 't3pl4', 438 | rating: 0, 439 | team: 'Team 3', 440 | }, 441 | black: { 442 | name: 'T1, P4', 443 | lichess: 't1pl4', 444 | rating: 2453, 445 | team: 'Team 1', 446 | }, 447 | reversed: true, 448 | board: '2.4', 449 | }); 450 | expect(pairings).toHaveLength(16); 451 | }); 452 | }); 453 | 454 | describe('round filtering', () => { 455 | const pairings: Pairing[] = [ 456 | // round 1 457 | { board: '1.1', white: { name: 'w1' }, black: { name: 'b1' }, reversed: false }, 458 | { board: '1.2', white: { name: 'w2' }, black: { name: 'b2' }, reversed: false }, 459 | { board: '2.1', white: { name: 'w3' }, black: { name: 'b3' }, reversed: false }, 460 | { board: '2.2', white: { name: 'w4' }, black: { name: 'b4' }, reversed: false }, 461 | // round 2 462 | { board: '1.1', white: { name: 'w5' }, black: { name: 'b5' }, reversed: false }, 463 | { board: '1.2', white: { name: 'w6' }, black: { name: 'b6' }, reversed: false }, 464 | { board: '2.1', white: { name: 'w7' }, black: { name: 'b7' }, reversed: false }, 465 | { board: '2.2', white: { name: 'w8' }, black: { name: 'b8' }, reversed: false }, 466 | ]; 467 | 468 | test('round 1', () => { 469 | expect(filterRound(pairings, 1)).toEqual([ 470 | { board: '1.1', white: { name: 'w1' }, black: { name: 'b1' }, reversed: false }, 471 | { board: '1.2', white: { name: 'w2' }, black: { name: 'b2' }, reversed: false }, 472 | { board: '2.1', white: { name: 'w3' }, black: { name: 'b3' }, reversed: false }, 473 | { board: '2.2', white: { name: 'w4' }, black: { name: 'b4' }, reversed: false }, 474 | ]); 475 | }); 476 | 477 | test('round 2', () => { 478 | expect(filterRound(pairings, 2)).toEqual([ 479 | { board: '1.1', white: { name: 'w5' }, black: { name: 'b5' }, reversed: false }, 480 | { board: '1.2', white: { name: 'w6' }, black: { name: 'b6' }, reversed: false }, 481 | { board: '2.1', white: { name: 'w7' }, black: { name: 'b7' }, reversed: false }, 482 | { board: '2.2', white: { name: 'w8' }, black: { name: 'b8' }, reversed: false }, 483 | ]); 484 | }); 485 | }); 486 | 487 | test('set results per page', () => { 488 | expect(setResultsPerPage('https://example.com')).toBe('https://example.com/?zeilen=99999'); 489 | expect(setResultsPerPage('https://example.com', 10)).toBe('https://example.com/?zeilen=10'); 490 | expect(setResultsPerPage('https://example.com/?foo=bar', 10)).toBe( 491 | 'https://example.com/?foo=bar&zeilen=10', 492 | ); 493 | expect(setResultsPerPage('https://example.com/players.aspx?zeilen=10', 20)).toBe( 494 | 'https://example.com/players.aspx?zeilen=20', 495 | ); 496 | expect(setResultsPerPage('https://example.com/players.aspx?zeilen=10', 99999)).toBe( 497 | 'https://example.com/players.aspx?zeilen=99999', 498 | ); 499 | }); 500 | 501 | describe('get/set urls from local storage', () => { 502 | beforeEach(() => { 503 | localStorage.clear(); 504 | }); 505 | 506 | test('get', () => { 507 | expect(getUrls('abc1')).toBeUndefined(); 508 | }); 509 | 510 | test('set', () => { 511 | saveUrls('abc2', 'https://example.com/pairings2.html'); 512 | expect(getUrls('abc2')).toStrictEqual({ 513 | pairingsUrl: 'https://example.com/pairings2.html', 514 | }); 515 | }); 516 | 517 | test('append', () => { 518 | saveUrls('abc3', 'https://example.com/pairings3.html'); 519 | saveUrls('abc4', 'https://example.com/pairings4.html'); 520 | 521 | expect(getUrls('abc3')).toStrictEqual({ 522 | pairingsUrl: 'https://example.com/pairings3.html', 523 | }); 524 | 525 | expect(getUrls('abc4')).toStrictEqual({ 526 | pairingsUrl: 'https://example.com/pairings4.html', 527 | }); 528 | }); 529 | }); 530 | 531 | describe('test cache buster', () => { 532 | test('set cache buster', () => { 533 | expect(setCacheBuster('https://example.com')).toContain('https://example.com/?cachebust=1'); 534 | }); 535 | 536 | test('append cache buster', () => { 537 | expect(setCacheBuster('https://example.com/?foo=bar')).toContain( 538 | 'https://example.com/?foo=bar&cachebust=1', 539 | ); 540 | }); 541 | }); 542 | -------------------------------------------------------------------------------- /src/scraper/tests/fixtures/team-ko-pairings-with-usernames.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 38 | 39 | 40 | 41 | 42 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 77 | 78 | 79 | 80 | 81 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 116 | 117 | 118 | 119 | 120 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 155 | 156 | 157 | 158 | 159 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 207 | 208 | 209 | 210 | 211 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 246 | 247 | 248 | 249 | 250 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 285 | 286 | 287 | 288 | 289 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 324 | 325 | 326 | 327 | 328 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 379 | 380 | 381 | 382 | 383 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 418 | 419 | 420 | 421 | 422 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 457 | 458 | 459 | 460 | 461 | 475 | 476 | 477 | 478 | 479 | 480 | 481 | 482 | 496 | 497 | 498 | 499 | 500 | 514 | 515 | 516 | 517 | 518 | 519 | 520 | 521 | 522 | 523 | 524 | 525 | 526 | 527 | 528 | 529 | 530 | 531 | 532 | 533 | 534 | 548 | 549 | 550 | 551 | 552 | 566 | 567 | 568 | 569 | 570 | 571 | 572 | 573 | 587 | 588 | 589 | 590 | 591 | 605 | 606 | 607 | 608 | 609 | 610 | 611 | 612 | 626 | 627 | 628 | 629 | 630 | 644 | 645 | 646 | 647 | 648 | 649 | 650 | 651 | 665 | 666 | 667 | 668 | 669 | 683 | 684 | 685 | 686 | 687 | 688 |
Round 1
Bo.1  Team 2RtgClub/City-4  Team 4RtgClub/City3½: ½
1.1 25 | 26 | 27 | 28 | 29 | 34 | 35 | 36 |
30 | T2, P1 33 |
37 |
1678t2pl1- 43 | 44 | 45 | 46 | 47 | 52 | 53 | 54 |
48 | T4, P1 51 |
55 |
0t4pl11 - 0
1.2CM 64 | 65 | 66 | 67 | 68 | 73 | 74 | 75 |
69 | T2, P2 72 |
76 |
2220t2pl2- 82 | 83 | 84 | 85 | 86 | 91 | 92 | 93 |
87 | T4, P2 90 |
94 |
1500t4pl2½ - ½
1.3FM 103 | 104 | 105 | 106 | 107 | 112 | 113 | 114 |
108 | T2, P3 111 |
115 |
2234twpl3- 121 | 122 | 123 | 124 | 125 | 130 | 131 | 132 |
126 | T4, P3 129 |
133 |
0t4pl31 - 0
1.4WGM 142 | 143 | 144 | 145 | 146 | 151 | 152 | 153 |
147 | T2, P4 150 |
154 |
2350t2pl4- 160 | 161 | 162 | 163 | 164 | 169 | 170 | 171 |
165 | T4, P4 168 |
172 |
1890t4pl41 - 0
Bo.3  Team 3RtgClub/City-2  Team 1RtgClub/City3½: ½
2.1IM 194 | 195 | 196 | 197 | 198 | 203 | 204 | 205 |
199 | T3, P1 202 |
206 |
2311t3pl1-WIM 212 | 213 | 214 | 215 | 216 | 221 | 222 | 223 |
217 | T1, P1 220 |
224 |
2100t1pl11 - 0
2.2 233 | 234 | 235 | 236 | 237 | 242 | 243 | 244 |
238 | T3, P2 241 |
245 |
1890t3pl2- 251 | 252 | 253 | 254 | 255 | 260 | 261 | 262 |
256 | T1, P2 259 |
263 |
2200t1pl2½ - ½
2.3 272 | 273 | 274 | 275 | 276 | 281 | 282 | 283 |
277 | T3, P3 280 |
284 |
2110t3pl3- 290 | 291 | 292 | 293 | 294 | 299 | 300 | 301 |
295 | T1, P3 298 |
302 |
1459t1pl31 - 0
2.4 311 | 312 | 313 | 314 | 315 | 320 | 321 | 322 |
316 | T3, P4 319 |
323 |
0t3pl4-GM 329 | 330 | 331 | 332 | 333 | 338 | 339 | 340 |
334 | T1, P4 337 |
341 |
2453t1pl41 - 0
Round 2
Bo.4  Team 4RtgClub/City-1  Team 2RtgClub/City2 : 2
1.1 366 | 367 | 368 | 369 | 370 | 375 | 376 | 377 |
371 | T4, P1 374 |
378 |
0t4pl1- 384 | 385 | 386 | 387 | 388 | 393 | 394 | 395 |
389 | T2, P1 392 |
396 |
1678t2pl11 - 0
1.2 405 | 406 | 407 | 408 | 409 | 414 | 415 | 416 |
410 | T4, P2 413 |
417 |
1500t4pl2-CM 423 | 424 | 425 | 426 | 427 | 432 | 433 | 434 |
428 | T2, P2 431 |
435 |
2220t2pl20 - 1
1.3 444 | 445 | 446 | 447 | 448 | 453 | 454 | 455 |
449 | T4, P3 452 |
456 |
0t4pl3-FM 462 | 463 | 464 | 465 | 466 | 471 | 472 | 473 |
467 | T2, P3 470 |
474 |
2234twpl31 - 0
1.4 483 | 484 | 485 | 486 | 487 | 492 | 493 | 494 |
488 | T4, P4 491 |
495 |
1890t4pl4-WGM 501 | 502 | 503 | 504 | 505 | 510 | 511 | 512 |
506 | T2, P4 509 |
513 |
2350t2pl40 - 1
Bo.2  Team 1RtgClub/City-3  Team 3RtgClub/City2 : 2
2.1WIM 535 | 536 | 537 | 538 | 539 | 544 | 545 | 546 |
540 | T1, P1 543 |
547 |
2100t1pl1-IM 553 | 554 | 555 | 556 | 557 | 562 | 563 | 564 |
558 | T3, P1 561 |
565 |
2311t3pl1½ - ½
2.2 574 | 575 | 576 | 577 | 578 | 583 | 584 | 585 |
579 | T1, P2 582 |
586 |
2200t1pl2- 592 | 593 | 594 | 595 | 596 | 601 | 602 | 603 |
597 | T3, P2 600 |
604 |
1890t3pl2½ - ½
2.3 613 | 614 | 615 | 616 | 617 | 622 | 623 | 624 |
618 | T1, P3 621 |
625 |
1459t1pl3- 631 | 632 | 633 | 634 | 635 | 640 | 641 | 642 |
636 | T3, P3 639 |
643 |
2110t3pl3½ - ½
2.4GM 652 | 653 | 654 | 655 | 656 | 661 | 662 | 663 |
657 | T1, P4 660 |
664 |
2453t1pl4- 670 | 671 | 672 | 673 | 674 | 679 | 680 | 681 |
675 | T3, P4 678 |
682 |
0t3pl4½ - ½
689 | -------------------------------------------------------------------------------- /src/scraper/tests/fixtures/players-list-with-usernames.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | 476 | 477 | 478 | 479 | 480 | 481 | 482 | 483 | 484 | 485 | 486 | 487 | 488 | 489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 509 | 510 | 511 | 512 | 513 | 514 | 515 | 516 | 517 | 518 | 519 | 520 | 521 | 522 | 523 | 524 | 525 | 526 | 527 | 528 | 529 | 530 | 531 | 532 | 533 | 534 | 535 | 536 | 537 | 538 | 539 | 540 | 541 | 542 | 543 | 544 | 545 | 546 | 547 | 548 | 549 | 550 | 551 | 552 | 553 | 554 | 555 | 556 | 557 | 558 | 559 | 560 | 561 | 562 | 563 | 564 | 565 | 566 | 567 | 568 | 569 | 570 | 571 | 572 | 573 | 574 | 575 | 576 | 577 | 578 | 579 | 580 | 581 | 582 | 583 | 584 | 585 | 586 | 587 | 588 | 589 | 590 | 591 | 592 | 593 | 594 | 595 | 596 | 597 | 598 | 599 | 600 | 601 | 602 | 603 | 604 | 605 | 606 | 607 | 608 | 609 | 610 | 611 | 612 | 613 | 614 | 615 | 616 | 617 | 618 | 619 | 620 | 621 | 622 | 623 | 624 | 625 | 626 | 627 | 628 | 629 | 630 | 631 | 632 | 633 | 634 | 635 | 636 | 637 | 638 | 639 | 640 | 641 | 642 | 643 | 644 | 645 | 646 | 647 | 648 | 649 | 650 | 651 | 652 | 653 | 654 |
No.NameFideIDFEDRtgClub/City
1GMSaric, Ivan14508150CRO2685dalmatinac101
2GMNavara, David309095CZE2679RealDavidNavara
3GMPechac, Jergus14926970SVK2570GracchusJeep
4GMBalogh, Csaba718939HUN2551Csimpula
5IMSahidi, Samir14936372SVK2476samisahi
6IMHaring, Filip14933080SVK2442BestStelker
7FMBochnicka, Vladimir14943450SVK2362vladoboch
8FMDanada, Tomas14919362SVK2229NAARD
9Matejovic, Juraj14906040SVK2228CSSML-NDSMD
10WIMMaslikova, Veronika14904888SVK2185Vercinka
11Tropp, Oliver14913003SVK2159KKtreningKK
12CMFizer, Marek391980CZE2126Mafi08
13Salgovic, Simon14943581SVK2126s2305
14Verbovsky, Michal14934213SVK2124mikhailooooo
15WFMSankova, Stella14944200SVK2108pinkunicorn
16Koval, Jakub14933993SVK2106Kuklac858
17Cisko, Matej14930412SVK2105mc12345mc
18Krupa, Miroslav14944154SVK2103HiAmUndaDaWata
19Bochnickova, Andrea14901781SVK2064Andrea1977
20Pericka, Tomas14909081SVK2008pery85
21Hurtuk, Radovan14935031SVK2000RadovanHurtuk
22Slamena, Michal14908840SVK2000Snowy_At_tacker
23Kolar, Tomas14909448SVK1995TOSO17
24Vrba, Michal14909790SVK1984michalv
25Nemergut, Patrik14919524SVK1960NemkoP
26Mizicka, Matus14946238SVK1958nwb14
27Diviak, Rastislav14908204SVK1956divoky
28Paverova, Zuzana14905310SVK1873suzi81
29WCMNovomeska, Karin14952300SVK1851Karinka09
30Horvath, Marek14913682SVK1801stein111
31Fizerova, Lucie385115CZE1794Luckafizerova
32Matejka, Simon14974541SVK1792SIMONmatejka
33Brida, Lukas14920239SVK1779LukasB1988
34Vozarova, Adriana14940752SVK1772avozarova
35Pericka, Pavol14914298SVK1743Palo60
36Kramar, Matus14944618SVK1741Matko11111
37Bucor, Mark14976340SVK1725FitzTheWorldHopper
38Pisarcik, Richard14967499SVK1708VanGerven
39Kukan, Michal14951967SVK1693mikodnv
40Hrdlicka, Martin14940876SVK1687hrdlis
41Mikula, Martin14941058SVK1657REW19
42Jura, Michal73620882SVK1631Osim81
43Kapinaj, Matus14980703SVK1606TheJeromeGambit
44Nemergut, Jan14948079SVK1573Nemergutsl
45Kison, Jan14949156SVK1571Kison
46AFMNosal, Tobias14976293SVK1562Tobnos
47Paracka, Patrik14975467SVK1521x220616
48Hutyra, Tomas23762683CZE1450JezhkovyVochi
49Karaba, Rudolf14967537SVK1335Rudy22
50Gvizdova, Michala23713828CZE1305MishaGvi
51Koscelnikova, Monika14967073SVK1267monicka7
52Ksenzigh, Adam14976471SVK1226AdamKO10
53Rozboril, Alex14972638SVK1221alexr199
54Novotny, Tomas14951126SVK1135Tomas_Novotny
55Kicura, Richard14985756SVK1111RichardKicura
56Busfy, SvetozarSVK0svebus
57Cafal, PavolSVK0calfa7
58Falat, TomasSVK0Tomas1000
59Flajs, MarekSVK0fidzi
60Hrdy, Milos14984792SVK0Milos1661
61Jancik, PeterSVK0Petrik124
62Kohutiar, Milan14965011SVK0milkoh
63Mlynarcik, MarianSVK0silason
64Noga, MarianSVK0noger79
65Nosal, Peter14982102SVK0Nosso7
66Roman, Milan14958651SVK0MilanRoman
67Roth, MarianSVK0hrac123456
68Sausa, JakubSVK0ChokablockSK
69Sramko, TobiasSVK0TobiBrooo
70Tatarin, Mykola0Nik20002353
71Toriska, JurajSVK0xQ72w
655 | -------------------------------------------------------------------------------- /src/scraper/tests/fixtures/individual-swiss-pairings.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | 476 | 477 | 478 | 479 | 480 | 481 | 482 | 483 | 484 | 485 | 486 | 487 | 488 | 489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 509 | 510 | 511 | 512 | 513 | 514 | 515 | 516 | 517 | 518 | 519 | 520 | 521 | 522 | 523 | 524 | 525 | 526 | 527 | 528 | 529 | 530 | 531 | 532 | 533 | 534 | 535 | 536 | 537 | 538 | 539 | 540 | 541 | 542 | 543 | 544 | 545 | 546 | 547 | 548 | 549 | 550 | 551 | 552 | 553 | 554 | 555 | 556 | 557 | 558 | 559 | 560 | 561 | 562 | 563 | 564 | 565 | 566 | 567 | 568 | 569 | 570 | 571 | 572 | 573 | 574 | 575 | 576 | 577 | 578 | 579 | 580 | 581 | 582 | 583 | 584 | 585 | 586 | 587 | 588 | 589 | 590 | 591 | 592 | 593 | 594 | 595 | 596 | 597 | 598 | 599 | 600 | 601 | 602 | 603 | 604 | 605 | 606 | 607 | 608 | 609 | 610 | 611 | 612 | 613 | 614 | 615 | 616 | 617 | 618 | 619 | 620 | 621 | 622 | 623 | 624 | 625 | 626 | 627 | 628 | 629 | 630 | 631 | 632 | 633 | 634 | 635 | 636 | 637 | 638 | 639 | 640 | 641 | 642 | 643 | 644 | 645 | 646 | 647 | 648 | 649 | 650 | 651 | 652 | 653 | 654 | 655 | 656 | 657 | 658 | 659 | 660 | 661 | 662 | 663 | 664 | 665 | 666 | 667 | 668 | 669 | 670 | 671 | 672 | 673 | 674 | 675 | 676 | 677 | 678 | 679 | 680 | 681 | 682 | 683 | 684 | 685 | 686 | 687 | 688 | 689 | 690 | 691 | 692 | 693 | 694 | 695 | 696 | 697 | 698 | 699 | 700 | 701 | 702 | 703 | 704 | 705 | 706 | 707 | 708 | 709 | 710 | 711 | 712 | 713 | 714 | 715 | 716 | 717 | 718 | 719 | 720 | 721 | 722 | 723 | 724 | 725 | 726 | 727 | 728 | 729 | 730 | 731 | 732 | 733 | 734 | 735 | 736 | 737 | 738 | 739 | 740 | 741 | 742 | 743 | 744 | 745 | 746 | 747 | 748 | 749 | 750 | 751 | 752 | 753 | 754 | 755 | 756 | 757 | 758 | 759 | 760 | 761 | 762 | 763 | 764 | 765 | 766 | 767 | 768 | 769 | 770 | 771 | 772 | 773 | 774 | 775 | 776 | 777 | 778 | 779 | 780 | 781 | 782 | 783 | 784 | 785 | 786 | 787 | 788 | 789 | 790 | 791 | 792 | 793 | 794 | 795 | 796 | 797 | 798 | 799 | 800 | 801 | 802 | 803 | 804 | 805 | 806 | 807 | 808 | 809 | 810 | 811 | 812 | 813 | 814 | 815 | 816 | 817 | 818 | 819 | 820 | 821 | 822 | 823 | 824 | 825 | 826 | 827 | 828 | 829 | 830 | 831 | 832 | 833 | 834 | 835 | 836 | 837 | 838 | 839 | 840 | 841 | 842 | 843 | 844 | 845 | 846 |
Bo.No.WhiteRtgPts.ResultPts.BlackRtgNo.
124GMGunina, Valentina234861 - 05IMMammadzada, Gunay240815
232WGMDivya, Deshmukh231550 - 15GMKosteniuk, Alexandra24557
312GMDronavalli, Harika242051 - 05WIMOmonova, Umida230436
418GMZhu, Jiner238450 - 15IMBodnaruk, Anastasia226048
540IMNarva, Mai22925½ - ½GMLagno, Kateryna25223
66GMGoryachkina, Aleksandra24751 - 0IMSalimova, Nurgyul234327
7108WCMLesbekova, Assel18610 - 1IMMunguntuul, Batkhuyag234825
82GMJu, Wenjun25224½ - ½4IMInjac, Teodora228741
955GMBatsiashvili, Nino223340 - 14IMAssaubayeva, Bibisara24765
108GMKoneru, Humpy24524½ - ½4WGMRakshitta, Ravi222557
1160IMGarifullina, Leya221641 - 04IMMatnadze Bujiashvili, Ann242811
1214IMVaishali, Rameshbabu241040 - 14WGMMunkhzul, Turmunkh221163
1367WGMYu, Jennifer219740 - 14WGMWagner, Dinara235023
1428IMShuvalova, Polina234241 - 04WGMBerend, Elvira220265
1530GMUshenina, Anna233441 - 04Velpula, Sarayu1747112
16101FMKazarian, Anna-Maja202340 - 14WGMKamalidenova, Meruert231433
17103WFMAydin, Gulenay200940 - 1GMMuzychuk, Anna24479
1839WFMKhamdamova, Afruza22950 - 1GMTan, Zhongyi25194
1920GMKrush, Irina23591 - 0IMNomin-Erdene, Davaademberel229938
2022GMDanielian, Elina23521 - 0IMGuichard, Pauline228143
2150IMBivol, Alina22501 - 0IMBuksa, Nataliya231631
2237IMPadmini, Rout23020 - 1WFMShukhman, Anna209387
2388WIMGaboyan, Susanna2086½ - ½3GMLei, Tingjie25301
2410GMMuzychuk, Mariya244331 - 03WGMPourkashiyan, Atousa222159
2516GMStefanova, Antoaneta239831 - 03WIMBalabayeva, Xeniya220266
2672IMGvetadze, Sofio217030 - 13IMKhademalsharieh, Sarasadat239517
2768FMSahithi, Varshini M218930 - 13GMKhotenashvili, Bella235821
2876IMOvod, Evgenija215130 - 13WGMZhai, Mo234229
2934IMKiolbasa, Oliwia231331 - 03IMSoumya, Swaminathan215475
3044WGMNi, Shiqun227031 - 03WGMSavitha, Shri B218369
31110WFMKaliakhmet, Elnaz183930 - 13IMBadelka, Olga226247
3278WFMTohirjonova, Hulkar214430 - 13IMCharochkina, Daria225449
3382FMBorisova, Ekaterina213030 - 13WIMNurmanova, Alua224951
3454WGMKovanova, Baira223430 - 13WIMMkrtchyan, Mariam215077
3556WGMHejazipour, Mitra222831 - 03WCMShohradova, Leyla1882107
3658WGMPriyanka, Nutakki222431 - 03WIMNurgali, Nazerke206895
37102WFMGetman, Tatyana20140 - 1IMCori T., Deysi230435
3874FMToncheva, Nadya21620 - 1IMFataliyeva, Ulviyya226845
3946WGMTokhirjonova, Gulrukhbegim22651 - 0WIMSerikbay, Assel212683
4052IMMammadova, Gulnar2247½ - ½WIMKairbekova, Amina213181
4184WGMAbdulla, Khayala21181 - 0WGMVoit, Daria221562
4264IMBalajayeva, Khanim2207½ - ½WFMPoliakova, Varvara204999
4390WFMOvezdurdyyeva, Jemal20860 - 1WGMStrutinskaia, Galina217471
44114Aktamova, Zilola16710 - 12IMArabidze, Meri241613
4589WIMYan, Tianqi208621 - 02GMZhu, Chen237919
4626IMTsolakidou, Stavroula234521 - 02FMKurmangaliyeva, Liya207793
4742WIMLu, Miaoyi228421 - 02WFMBobomurodova, Maftuna1814111
48104WFMNurgaliyeva, Zarina200120 - 12WGMYakubbaeva, Nilufar223753
4994WFMHajiyeva, Laman207620 - 12FMPeycheva, Gergana221661
5070WGMMamedjarova, Turkan217521 - 02WIMMalikova, Marjona1730113
51116Karimova, Guldona154720 - 12WGMBeydullayeva, Govhar213279
5292WFMZhao, Shengxin20791 - 02Altynbek, Aiaru1641115
53106WIMHamrakulova, Yulduz19080 - 1WFMYakimova, Mariya209886
54117Zhunusbekova, Aimonchok14480 - 1WFMDruzhinina, Olga205597
5598WGMKurbonboeva, Sarvinoz20540 - 1WIMFranco Valencia, Angela2048100
5680Khamrakulova, Iroda213210 - 11WFMShohradova, Lala1983105
5796WGMZaksaite, Salomeja206511 - 01WIMRudzinska, Michalina211385
5891Khamrakulova, Shakhnoza208610 - 11WFMAllahverdiyeva, Ayan1852109
59118Naisanga, Sheba Valentine135800 - 1½WIMNadirjanova, Nodira216473
847 | --------------------------------------------------------------------------------