├── .github └── workflows │ ├── deploy.yml │ ├── lint.yml │ └── test.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── dist ├── favicon.ico └── index.html ├── index.html ├── package.json ├── pnpm-lock.yaml ├── rollup.config.mjs ├── scss ├── _bootstrap.scss ├── _navbar.scss └── style.scss ├── src ├── app.ts ├── auth.ts ├── endpoints.ts ├── form.ts ├── interfaces.ts ├── main.ts ├── model.ts ├── ndJsonStream.ts ├── page │ ├── bulkList.ts │ ├── bulkNew.ts │ ├── bulkShow.ts │ ├── home.ts │ ├── openChallenge.ts │ └── puzzleRace.ts ├── routing.ts ├── scraper │ ├── scraper.ts │ └── tests │ │ ├── fixtures │ │ ├── individual-round-robin-pairings.html │ │ ├── individual-swiss-pairings.html │ │ ├── players-list-with-usernames.html │ │ ├── players-list-without-usernames.html │ │ ├── team-round-robin-pairings.html │ │ ├── team-swiss-pairings-with-usernames-2.html │ │ ├── team-swiss-pairings-with-usernames.html │ │ └── team-swiss-pairings-without-usernames.html │ │ └── scrape.test.ts ├── util.ts └── view │ ├── form.ts │ ├── layout.ts │ └── util.ts ├── tsconfig.json └── vite.config.ts /.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@v4 25 | - run: npm install -g pnpm 26 | - run: pnpm install 27 | - run: pnpm run prod 28 | - uses: actions/configure-pages@v4 29 | - uses: actions/upload-pages-artifact@v3 30 | with: 31 | path: dist 32 | - uses: actions/deploy-pages@v4 33 | id: deployment 34 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint code 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: actions/setup-node@v4 11 | with: 12 | node-version: lts/* 13 | - run: npx prettier --check . 14 | 15 | check-typescript: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version: lts/* 22 | - run: npm install -g pnpm 23 | - run: pnpm install 24 | - run: pnpm run tsc 25 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: actions/setup-node@v4 11 | with: 12 | node-version: lts/* 13 | - run: npm install -g pnpm 14 | - run: pnpm install 15 | - run: pnpm test 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/index.min.js 3 | dist/style.min.css 4 | index.js 5 | style.css 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /pnpm-lock.yaml 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 110, 4 | "arrowParens": "avoid" 5 | } 6 | -------------------------------------------------------------------------------- /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 build # or pnpm watch 14 | pnpm serve 15 | ``` 16 | 17 | ## Tests 18 | 19 | ```bash 20 | pnpm test 21 | ## or 22 | pnpm test:watch 23 | ``` 24 | 25 | ```bash 26 | # run prettier 27 | pnpm format 28 | 29 | # check typescript 30 | pnpm tsc 31 | ``` 32 | 33 | ## Using a development instance of Lila 34 | 35 | Open the browser console and run: 36 | 37 | ```js 38 | localStorage.setItem('lichessHost', 'http://localhost:8080'); 39 | ``` 40 | 41 | Modify the CSP meta tag in `index.html` to add that domain. For example, change `lichess.org` to `localhost:8080`. 42 | 43 | Refresh and verify the configuration value in the footer. 44 | -------------------------------------------------------------------------------- /dist/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lichess-org/api-ui/b405130327a0822fcabf0ad555d41951703b7e68/dist/favicon.ico -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | 13 | 14 | Lichess API UI 15 | 16 | 17 | 18 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | 13 | 14 | Lichess API UI 15 | 16 | 17 | 18 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /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 | "bootstrap": "~5.3.3", 8 | "cheerio": "1.0.0-rc.12", 9 | "page": "~1.11.6", 10 | "snabbdom": "~3.6.2" 11 | }, 12 | "devDependencies": { 13 | "@rollup/plugin-commonjs": "^25.0.7", 14 | "@rollup/plugin-node-resolve": "15.2.3", 15 | "@rollup/plugin-terser": "^0.4.4", 16 | "@rollup/plugin-typescript": "^11.1.6", 17 | "@types/cheerio": "^0.22.35", 18 | "@types/node": "^20.11.30", 19 | "@types/page": "^1.11.9", 20 | "http-server": "^14.1.1", 21 | "jsdom": "^24.0.0", 22 | "prettier": "^3.2.5", 23 | "rollup": "^4.13.0", 24 | "rollup-plugin-scss": "~4.0.0", 25 | "sass": "^1.72.0", 26 | "tslib": "~2.6.2", 27 | "typescript": "^5.4.3", 28 | "vitest": "^1.4.0" 29 | }, 30 | "scripts": { 31 | "build": "rollup --config", 32 | "watch": "rollup --config --watch", 33 | "prod": "rollup --config --config-prod", 34 | "serve": "http-server", 35 | "format": "prettier --write .", 36 | "tsc": "tsc --noEmit", 37 | "test": "vitest run", 38 | "test:watch": "vitest" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import resolve from '@rollup/plugin-node-resolve'; 2 | import typescript from '@rollup/plugin-typescript'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | import scss from 'rollup-plugin-scss'; 5 | import sass from 'sass'; 6 | import terser from '@rollup/plugin-terser'; 7 | 8 | export default args => ({ 9 | input: 'src/main.ts', 10 | output: { 11 | file: args['config-prod'] ? 'dist/index.min.js' : 'index.js', 12 | format: 'iife', 13 | name: 'LichessApiUI', 14 | plugins: args['config-prod'] 15 | ? [ 16 | terser({ 17 | safari10: false, 18 | output: { comments: false }, 19 | }), 20 | ] 21 | : [], 22 | }, 23 | plugins: [ 24 | resolve(), 25 | typescript(), 26 | commonjs(), 27 | scss({ 28 | include: ['scss/*'], 29 | fileName: args['config-prod'] ? 'style.min.css' : 'style.css', 30 | runtime: sass, 31 | ...(args['config-prod'] ? { outputStyle: 'compressed' } : {}), 32 | }), 33 | ], 34 | }); 35 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /scss/_navbar.scss: -------------------------------------------------------------------------------- 1 | #navbarSupportedContent { 2 | justify-content: flex-end; 3 | } 4 | -------------------------------------------------------------------------------- /scss/style.scss: -------------------------------------------------------------------------------- 1 | @import './bootstrap'; 2 | @import './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/app.ts: -------------------------------------------------------------------------------- 1 | import { h } from 'snabbdom'; 2 | import { Auth } from './auth'; 3 | import { 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 | 13 | constructor( 14 | readonly config: Config, 15 | readonly redraw: Redraw, 16 | ) { 17 | this.auth = new Auth(config.lichessHost); 18 | } 19 | 20 | notFound = () => this.redraw(layout(this, h('div', [h('h1.mt-5', 'Not Found')]))); 21 | 22 | tooManyRequests = () => 23 | this.redraw( 24 | layout( 25 | this, 26 | h('div', [h('h1.mt-5', 'Too many requests'), h('p.lead', 'Please wait a little then try again.')]), 27 | ), 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /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 | constructor(readonly lichessHost: string) {} 18 | oauth = new OAuth2AuthCodePKCE({ 19 | authorizationUrl: `${this.lichessHost}/oauth`, 20 | tokenUrl: `${this.lichessHost}/api/token`, 21 | clientId, 22 | scopes, 23 | redirectUrl: clientUrl, 24 | onAccessTokenExpiry: refreshAccessToken => refreshAccessToken(), 25 | onInvalidGrant: console.warn, 26 | }); 27 | me?: Me; 28 | 29 | async init() { 30 | try { 31 | const accessContext = await this.oauth.getAccessToken(); 32 | if (accessContext) await this.authenticate(); 33 | } catch (err) { 34 | console.error(err); 35 | } 36 | if (!this.me) { 37 | try { 38 | const hasAuthCode = await this.oauth.isReturningFromAuthServer(); 39 | if (hasAuthCode) await this.authenticate(); 40 | } catch (err) { 41 | console.error(err); 42 | } 43 | } 44 | } 45 | 46 | async login() { 47 | await this.oauth.fetchAuthorizationCode(); 48 | } 49 | 50 | async logout() { 51 | if (this.me) await this.me.httpClient(`${this.lichessHost}/api/token`, { method: 'DELETE' }); 52 | localStorage.clear(); 53 | this.me = undefined; 54 | } 55 | 56 | private authenticate = async () => { 57 | const httpClient = this.oauth.decorateFetchHTTPClient(window.fetch); 58 | const res = await httpClient(`${this.lichessHost}/api/account`); 59 | if (res.status == 429) { 60 | location.href = clientUrl + '#!/too-many-requests'; 61 | return; 62 | } 63 | const me = { 64 | ...(await res.json()), 65 | httpClient, 66 | }; 67 | if (me.error) throw me.error; 68 | this.me = me; 69 | }; 70 | } 71 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/interfaces.ts: -------------------------------------------------------------------------------- 1 | import { VNode } from 'snabbdom'; 2 | 3 | export type MaybeVNodes = VNode | (VNode | string | undefined)[]; 4 | export type Redraw = (ui: VNode) => void; 5 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { init, attributesModule, eventListenersModule, classModule, VNode } from 'snabbdom'; 2 | import { loadingBody } from './view/util'; 3 | import '../scss/_bootstrap.scss'; 4 | import '../scss/style.scss'; 5 | import '../node_modules/bootstrap/js/dist/dropdown.js'; 6 | import '../node_modules/bootstrap/js/dist/collapse.js'; 7 | import routing from './routing'; 8 | import { App, Config } from './app'; 9 | 10 | const config: Config = { 11 | lichessHost: localStorage.getItem('lichessHost') || 'https://lichess.org', 12 | }; 13 | 14 | export default async function (element: HTMLElement) { 15 | const patch = init([attributesModule, eventListenersModule, classModule]); 16 | 17 | const app = new App(config, redraw); 18 | 19 | let vnode = patch(element, loadingBody()); 20 | 21 | function redraw(ui: VNode) { 22 | vnode = patch(vnode, ui); 23 | } 24 | 25 | await app.auth.init(); 26 | routing(app); 27 | } 28 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/page/bulkList.ts: -------------------------------------------------------------------------------- 1 | import { h } from 'snabbdom'; 2 | import { App } from '../app'; 3 | import { Me } from '../auth'; 4 | import layout from '../view/layout'; 5 | import { href, timeFormat, url } from '../view/util'; 6 | import { Bulk } from '../model'; 7 | import { bulkPairing } from '../endpoints'; 8 | import { BulkShow } from './bulkShow'; 9 | import { ucfirst } from '../util'; 10 | 11 | export class BulkList { 12 | lichessUrl: string; 13 | bulks?: Bulk[]; 14 | constructor( 15 | readonly app: App, 16 | readonly me: Me, 17 | ) { 18 | this.lichessUrl = app.config.lichessHost; 19 | this.loadBulks(); 20 | } 21 | loadBulks = async () => { 22 | const res = await this.me.httpClient(`${this.app.config.lichessHost}/api/bulk-pairing`); 23 | this.bulks = (await res.json()).bulks; 24 | this.redraw(); 25 | }; 26 | redraw = () => this.app.redraw(this.render()); 27 | render = () => 28 | layout( 29 | this.app, 30 | h('div', [ 31 | h('h1.mt-5', 'Schedule games'), 32 | h('p.lead', [ 33 | 'Uses the ', 34 | h( 35 | 'a', 36 | { attrs: { href: 'https://lichess.org/api#tag/Bulk-pairings/operation/bulkPairingCreate' } }, 37 | 'Lichess bulk pairing API', 38 | ), 39 | ' to create a bunch of games at once.', 40 | ]), 41 | h( 42 | 'a.btn.btn-primary.mt-5', 43 | { attrs: { href: url(`${bulkPairing.path}/new`) } }, 44 | 'Schedule new games', 45 | ), 46 | this.bulks 47 | ? h('table.table.table-striped.mt-5', [ 48 | h('thead', [ 49 | h('tr', [ 50 | h('th', 'Bulk'), 51 | h('th', 'Games'), 52 | h('th', 'Created'), 53 | h('th', 'Pair at'), 54 | h('th', 'Paired'), 55 | ]), 56 | ]), 57 | h( 58 | 'tbody', 59 | this.bulks.map(bulk => 60 | h('tr', [ 61 | h( 62 | 'td.mono', 63 | h('a', { attrs: href(`${bulkPairing.path}/${bulk.id}`) }, [ 64 | `#${bulk.id}`, 65 | ' ', 66 | BulkShow.renderClock(bulk), 67 | ' ', 68 | ucfirst(bulk.variant), 69 | ' ', 70 | bulk.rated ? 'Rated' : 'Casual', 71 | ]), 72 | ), 73 | h('td', bulk.games.length), 74 | h('td', timeFormat(new Date(bulk.scheduledAt))), 75 | h('td', bulk.pairAt && timeFormat(new Date(bulk.pairAt))), 76 | h('td', bulk.pairedAt && timeFormat(new Date(bulk.pairedAt))), 77 | ]), 78 | ), 79 | ), 80 | ]) 81 | : h('div.m-5', h('div.spinner-border.d-block.mx-auto', { attrs: { role: 'status' } })), 82 | ]), 83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /src/page/bulkNew.ts: -------------------------------------------------------------------------------- 1 | import { h } from 'snabbdom'; 2 | import page from 'page'; 3 | import { App } from '../app'; 4 | import { Me } from '../auth'; 5 | import { 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 { Pairing, getPairings, getPlayers, saveUrls } from '../scraper/scraper'; 10 | import { bulkPairing } from '../endpoints'; 11 | import { href } from '../view/util'; 12 | 13 | interface Tokens { 14 | [username: string]: string; 15 | } 16 | interface Result { 17 | id: string; 18 | games: { 19 | id: string; 20 | white: string; 21 | black: string; 22 | }[]; 23 | pairAt: number; 24 | startClocksAt: number; 25 | } 26 | 27 | export class BulkNew { 28 | feedback: Feedback = undefined; 29 | lichessUrl: string; 30 | constructor( 31 | readonly app: App, 32 | readonly me: Me, 33 | ) { 34 | this.lichessUrl = app.config.lichessHost; 35 | } 36 | redraw = () => this.app.redraw(this.render()); 37 | render = () => 38 | layout( 39 | this.app, 40 | h('div', [ 41 | h('nav.mt-5.breadcrumb', [ 42 | h('span.breadcrumb-item', h('a', { attrs: href(bulkPairing.path) }, 'Schedule games')), 43 | h('span.breadcrumb-item.active', 'New bulk pairing'), 44 | ]), 45 | h('h1.mt-5', 'Schedule games'), 46 | h('p.lead', [ 47 | 'Uses the ', 48 | h( 49 | 'a', 50 | { attrs: { href: 'https://lichess.org/api#tag/Bulk-pairings/operation/bulkPairingCreate' } }, 51 | 'Lichess bulk pairing API', 52 | ), 53 | ' to create a bunch of games at once.', 54 | ]), 55 | h('p', [ 56 | 'Requires the ', 57 | h('strong', 'API Challenge admin'), 58 | ' permission to generate the player challenge tokens automatically.', 59 | ]), 60 | this.renderForm(), 61 | ]), 62 | ); 63 | 64 | private onSubmit = async (form: FormData) => { 65 | const get = (key: string) => form.get(key) as string; 66 | const dateOf = (key: string) => get(key) && new Date(get(key)).getTime(); 67 | try { 68 | const playersTxt = get('players'); 69 | let pairingNames: [string, string][]; 70 | try { 71 | pairingNames = playersTxt 72 | .toLowerCase() 73 | .split('\n') 74 | .map(line => 75 | line 76 | .trim() 77 | .replace(/[\s,]+/g, ' ') 78 | .split(' '), 79 | ) 80 | .map(names => [names[0].trim(), names[1].trim()]); 81 | } catch (err) { 82 | throw 'Invalid players format'; 83 | } 84 | const tokens = await this.adminChallengeTokens(pairingNames.flat()); 85 | const randomColor = !!get('randomColor'); 86 | const sortFn = () => (randomColor ? Math.random() - 0.5 : 0); 87 | const pairingTokens: [string, string][] = pairingNames.map( 88 | duo => 89 | duo 90 | .map(name => { 91 | if (!tokens[name]) throw `Missing token for ${name}, is that an active Lichess player?`; 92 | return tokens[name]; 93 | }) 94 | .sort(sortFn) as [string, string], 95 | ); 96 | const rules = gameRuleKeys.filter(key => !!get(key)); 97 | const req = this.me.httpClient(`${this.lichessUrl}/api/bulk-pairing`, { 98 | method: 'POST', 99 | body: formData({ 100 | players: pairingTokens.map(([white, black]) => `${white}:${black}`).join(','), 101 | 'clock.limit': parseFloat(get('clockLimit')) * 60, 102 | 'clock.increment': get('clockIncrement'), 103 | variant: get('variant'), 104 | rated: !!get('rated'), 105 | fen: get('fen'), 106 | message: get('message'), 107 | pairAt: dateOf('pairAt'), 108 | startClocksAt: dateOf('startClocksAt'), 109 | rules: rules.join(','), 110 | }), 111 | }); 112 | this.feedback = await responseToFeedback(req); 113 | 114 | if (isSuccess(this.feedback)) { 115 | saveUrls(this.feedback.result.id, get('cr-pairings-url'), get('cr-players-url')); 116 | page(`/endpoint/schedule-games/${this.feedback.result.id}`); 117 | } 118 | } catch (err) { 119 | console.warn(err); 120 | this.feedback = { 121 | error: { players: JSON.stringify(err) }, 122 | }; 123 | } 124 | this.redraw(); 125 | document.getElementById('endpoint-form')?.scrollIntoView({ behavior: 'smooth' }); 126 | }; 127 | 128 | private adminChallengeTokens = async (users: string[]): Promise => { 129 | const res = await this.me.httpClient(`${this.lichessUrl}/api/token/admin-challenge`, { 130 | method: 'POST', 131 | body: formData({ 132 | users: users.join(','), 133 | description: 'Tournament pairings from the Lichess team', 134 | }), 135 | }); 136 | const json = await res.json(); 137 | if (json.error) throw json.error; 138 | return json; 139 | }; 140 | 141 | private renderForm = () => 142 | form.form(this.onSubmit, [ 143 | form.feedback(this.feedback), 144 | h('div.mb-3', [ 145 | h('div.row', [ 146 | h('div.col-md-6', [ 147 | form.label('Players', 'players'), 148 | h('textarea#players.form-control', { 149 | attrs: { 150 | name: 'players', 151 | style: 'height: 100px', 152 | required: true, 153 | spellcheck: 'false', 154 | }, 155 | }), 156 | h('p.form-text', [ 157 | 'Two usernames per line, each line is a game.', 158 | h('br'), 159 | 'First username gets the white pieces, unless randomized by the switch below.', 160 | ]), 161 | h('div.form-check.form-switch', form.checkboxWithLabel('randomColor', 'Randomize colors')), 162 | h( 163 | 'button.btn.btn-secondary.btn-sm.mt-2', 164 | { 165 | attrs: { 166 | type: 'button', 167 | }, 168 | on: { 169 | click: () => 170 | this.validateUsernames(document.getElementById('players') as HTMLTextAreaElement), 171 | }, 172 | }, 173 | 'Validate Lichess usernames', 174 | ), 175 | ]), 176 | h('div.col-md-6', [ 177 | h('details', [ 178 | h('summary.text-muted.form-label', 'Or load the players and pairings from another website'), 179 | h('div.card.card-body', [form.loadPlayersFromUrl()]), 180 | h( 181 | 'button.btn.btn-secondary.btn-sm.mt-3', 182 | { 183 | attrs: { 184 | type: 'button', 185 | }, 186 | on: { 187 | click: () => 188 | this.loadPairingsFromChessResults( 189 | document.getElementById('cr-pairings-url') as HTMLInputElement, 190 | document.getElementById('cr-players-url') as HTMLInputElement, 191 | ), 192 | }, 193 | }, 194 | 'Load pairings', 195 | ), 196 | ]), 197 | ]), 198 | ]), 199 | ]), 200 | form.clock(), 201 | h('div.form-check.form-switch.mb-3', form.checkboxWithLabel('rated', 'Rated games', true)), 202 | form.variant(), 203 | form.fen(), 204 | h('div.mb-3', [ 205 | form.label('Inbox message', 'message'), 206 | h( 207 | 'textarea#message.form-control', 208 | { 209 | attrs: { 210 | name: 'message', 211 | style: 'height: 100px', 212 | }, 213 | }, 214 | 'Your game with {opponent} is ready: {game}.', 215 | ), 216 | h('p.form-text', [ 217 | 'Message that will be sent to each player, when the game is created. It is sent from your user account.', 218 | h('br'), 219 | h('code', '{opponent}'), 220 | ' and ', 221 | h('code', '{game}'), 222 | ' are placeholders that will be replaced with the opponent and the game URLs.', 223 | h('br'), 224 | 'The ', 225 | h('code', '{game}'), 226 | ' placeholder is mandatory.', 227 | ]), 228 | ]), 229 | form.specialRules(gameRules), 230 | h('div.mb-3', [ 231 | form.label('When to create the games', 'pairAt'), 232 | h('input#pairAt.form-control', { 233 | attrs: { 234 | type: 'datetime-local', 235 | name: 'pairAt', 236 | }, 237 | }), 238 | h('p.form-text', 'Leave empty to create the games immediately.'), 239 | ]), 240 | h('div.mb-3', [ 241 | form.label('When to start the clocks', 'startClocksAt'), 242 | h('input#startClocksAt.form-control', { 243 | attrs: { 244 | type: 'datetime-local', 245 | name: 'startClocksAt', 246 | }, 247 | }), 248 | h('p.form-text', [ 249 | 'Date at which the clocks will be automatically started.', 250 | h('br'), 251 | 'Note that the clocks can start earlier than specified, if players start making moves in the game.', 252 | h('br'), 253 | 'Leave empty so that the clocks only start when players make moves.', 254 | ]), 255 | ]), 256 | form.submit('Schedule the games'), 257 | ]); 258 | 259 | private validateUsernames = async (textarea: HTMLTextAreaElement) => { 260 | const usernames = textarea.value.match(/(<.*?>)|(\S+)/g); 261 | if (!usernames) return; 262 | 263 | let validUsernames: string[] = []; 264 | 265 | const chunkSize = 300; 266 | for (let i = 0; i < usernames.length; i += chunkSize) { 267 | const res = await this.me.httpClient(`${this.lichessUrl}/api/users`, { 268 | method: 'POST', 269 | body: usernames.slice(i, i + chunkSize).join(', '), 270 | headers: { 271 | 'Content-Type': 'text/plain', 272 | }, 273 | }); 274 | const users = await res.json(); 275 | validUsernames = validUsernames.concat(users.filter((u: any) => !u.disabled).map((u: any) => u.id)); 276 | } 277 | 278 | const invalidUsernames = usernames.filter(username => !validUsernames.includes(username.toLowerCase())); 279 | if (invalidUsernames.length) { 280 | alert(`Invalid usernames: ${invalidUsernames.join(', ')}`); 281 | } else { 282 | alert('All usernames are valid!'); 283 | } 284 | }; 285 | 286 | private loadPairingsFromChessResults = async ( 287 | pairingsInput: HTMLInputElement, 288 | playersInput: HTMLInputElement, 289 | ) => { 290 | try { 291 | const pairingsUrl = pairingsInput.value; 292 | const playersUrl = playersInput.value; 293 | 294 | const players = playersUrl ? await getPlayers(playersUrl) : undefined; 295 | const pairings = await getPairings(pairingsUrl, players); 296 | this.insertPairings(pairings); 297 | } catch (err) { 298 | alert(err); 299 | } 300 | }; 301 | 302 | private insertPairings(pairings: Pairing[]) { 303 | pairings.forEach(pairing => { 304 | const playersTxt = (document.getElementById('players') as HTMLTextAreaElement).value; 305 | 306 | const white = pairing.white.lichess || `<${pairing.white.name}>`; 307 | const black = pairing.black.lichess || `<${pairing.black.name}>`; 308 | 309 | const newLine = `${white} ${black}`; 310 | (document.getElementById('players') as HTMLTextAreaElement).value = 311 | playersTxt + (playersTxt ? '\n' : '') + newLine; 312 | }); 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /src/page/bulkShow.ts: -------------------------------------------------------------------------------- 1 | import { h } from 'snabbdom'; 2 | import { App } from '../app'; 3 | import { Me } from '../auth'; 4 | import layout from '../view/layout'; 5 | import { href, timeFormat } from '../view/util'; 6 | import { Bulk, BulkId, Game, Player, Username } from '../model'; 7 | import { Stream, readStream } from '../ndJsonStream'; 8 | import { bulkPairing } from '../endpoints'; 9 | import { sleep, ucfirst } from '../util'; 10 | import { loadPlayersFromUrl } from '../view/form'; 11 | import { 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 | lichessUrl: string; 24 | bulk?: Bulk; 25 | games: FormattedGame[] = []; 26 | gameStream?: Stream; 27 | liveUpdate = true; 28 | fullNames = new Map(); 29 | crPairings: Pairing[] = []; 30 | constructor( 31 | readonly app: App, 32 | readonly me: Me, 33 | readonly id: BulkId, 34 | ) { 35 | this.lichessUrl = app.config.lichessHost; 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.lichessUrl}/${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/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 | constructor(readonly app: App) {} 9 | 10 | render = () => layout(this.app, h('div.app-home', [this.renderAbout(), this.listEndpoints()])); 11 | redraw = () => this.app.redraw(this.render()); 12 | 13 | listEndpoints = () => 14 | h( 15 | 'div.list-group.mb-7', 16 | endpoints.map(e => 17 | h('a.list-group-item.list-group-item-action', { attrs: href(e.path) }, [ 18 | h('h3', e.name), 19 | h('span', e.desc), 20 | ]), 21 | ), 22 | ); 23 | 24 | renderAbout = () => 25 | h('div.about', [ 26 | h('p.lead.mt-5', [ 27 | 'A user interface to some of the ', 28 | h('a', { attrs: { href: 'https://lichess.org/api' } }, 'Lichess API'), 29 | ' endpoints.', 30 | ]), 31 | ]); 32 | } 33 | -------------------------------------------------------------------------------- /src/page/openChallenge.ts: -------------------------------------------------------------------------------- 1 | import { h } from 'snabbdom'; 2 | import { App } from '../app'; 3 | import { 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 | constructor(readonly app: App) {} 22 | redraw = () => this.app.redraw(this.render()); 23 | render = () => 24 | layout( 25 | this.app, 26 | h('div', [ 27 | h('h1.mt-5', 'Open challenge'), 28 | h('p.lead', [ 29 | 'Uses the ', 30 | h( 31 | 'a', 32 | { attrs: { href: 'https://lichess.org/api#tag/Challenges/operation/challengeOpen' } }, 33 | 'Lichess open challenge API', 34 | ), 35 | ' to create a game that any two players can join.', 36 | ]), 37 | h('p', ['No OAuth token is required.']), 38 | this.renderForm(), 39 | ]), 40 | ); 41 | 42 | private onSubmit = async (data: FormData) => { 43 | const get = (key: string) => data.get(key) as string; 44 | const req = fetch(`${this.app.config.lichessHost}/api/challenge/open`, { 45 | method: 'POST', 46 | body: formData({ 47 | 'clock.limit': parseFloat(get('clockLimit')) * 60, 48 | 'clock.increment': get('clockIncrement'), 49 | variant: get('variant'), 50 | rated: !!get('rated'), 51 | fen: get('fen'), 52 | name: get('name'), 53 | users: get('users') 54 | .trim() 55 | .replace(/[\s,]+/g, ','), 56 | rules: gameRuleKeysExceptNoAbort.filter(key => !!get(key)).join(','), 57 | }), 58 | }); 59 | this.feedback = await responseToFeedback(req); 60 | this.redraw(); 61 | form.scrollToForm(); 62 | }; 63 | 64 | private renderForm = () => 65 | form.form(this.onSubmit, [ 66 | form.feedback(this.feedback), 67 | isSuccess(this.feedback) ? this.renderResult(this.feedback.result) : undefined, 68 | form.clock(), 69 | h('div.form-check.form-switch.mb-3', form.checkboxWithLabel('rated', 'Rated game')), 70 | form.variant(), 71 | form.fen(), 72 | h('div.mb-3', [ 73 | form.label('Challenge name', 'name'), 74 | form.input('name', { tpe: 'text' }), 75 | h('p.form-text', 'Optional text that players will see on the challenge page.'), 76 | ]), 77 | h('div.mb-3', [ 78 | form.label('Only allow these players to join', 'name'), 79 | form.input('users', { tpe: 'text' }), 80 | h( 81 | 'p.form-text', 82 | '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.', 83 | ), 84 | ]), 85 | form.specialRules(gameRulesExceptNoAbort), 86 | form.submit('Create the challenge'), 87 | ]); 88 | 89 | private renderResult = (result: Result) => { 90 | const c = result; 91 | return card( 92 | c.id, 93 | ['Challenge #', c.id], 94 | [ 95 | h('h3', 'Links'), 96 | ...(c.open?.userIds 97 | ? [copyInput('Game URL', c.url)] 98 | : [ 99 | copyInput('Game URL - random color', c.url), 100 | copyInput('Game URL for white', result.urlWhite), 101 | copyInput('Game URL for black', result.urlBlack), 102 | ]), 103 | ], 104 | ); 105 | }; 106 | } 107 | -------------------------------------------------------------------------------- /src/page/puzzleRace.ts: -------------------------------------------------------------------------------- 1 | import { h } from 'snabbdom'; 2 | import { App } from '../app'; 3 | import { 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 { Me } from '../auth'; 8 | 9 | interface Result { 10 | id: string; 11 | url: string; 12 | } 13 | 14 | export class PuzzleRace { 15 | feedback: Feedback = undefined; 16 | constructor( 17 | readonly app: App, 18 | readonly me: Me, 19 | ) {} 20 | redraw = () => this.app.redraw(this.render()); 21 | render = () => 22 | layout( 23 | this.app, 24 | h('div', [ 25 | h('h1.mt-5', 'Puzzle race'), 26 | h('p.lead', [ 27 | 'Uses the ', 28 | h( 29 | 'a', 30 | { attrs: { href: 'https://lichess.org/api#tag/Puzzles/operation/racerPost' } }, 31 | 'Lichess puzzle race API', 32 | ), 33 | ' to create a private race with an invite link.', 34 | ]), 35 | this.renderForm(), 36 | ]), 37 | ); 38 | 39 | private onSubmit = async () => { 40 | const req = this.me.httpClient(`${this.app.config.lichessHost}/api/racer`, { method: 'POST' }); 41 | this.feedback = await responseToFeedback(req); 42 | this.redraw(); 43 | form.scrollToForm(); 44 | }; 45 | 46 | private renderForm = () => 47 | form.form(this.onSubmit, [ 48 | form.feedback(this.feedback), 49 | isSuccess(this.feedback) ? this.renderResult(this.feedback.result) : undefined, 50 | form.submit('Create the race'), 51 | ]); 52 | 53 | private renderResult = (result: Result) => { 54 | return card( 55 | result.id, 56 | ['PuzzleRace #', result.id], 57 | [h('h3', 'Link'), copyInput('Invite URL', result.url)], 58 | ); 59 | }; 60 | } 61 | -------------------------------------------------------------------------------- /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 { 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/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('Name')).text().trim(); 205 | const blackName = $(element).children().eq(headers.lastIndexOf('Name')).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 | -------------------------------------------------------------------------------- /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.RtgNameResultNameRtgNo.
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.RtgNameResultNameRtgNo.
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.RtgNameResultNameRtgNo.
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.RtgNameResultNameRtgNo.
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.RtgNameResultNameRtgNo.
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.RtgNameResultNameRtgNo.
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.RtgNameResultNameRtgNo.
132500GMMoroni, Luca Jr½ - ½FMGalaktionov, Artem23794
252407Sviridov, Valery½ - ½FMAradhya, Garg21862
362204FMSevgi, Volkan0 - 1IMDrozdowski, Kacper24727
412628GMAndreikin, Dmitry1 - 0GMPonkratov, Pavel25868
476 | -------------------------------------------------------------------------------- /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.NameRtgPts.ResultPts.NameRtgNo.
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 | -------------------------------------------------------------------------------- /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/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 | 36 | 37 | 38 | 39 | 40 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 71 | 72 | 73 | 74 | 75 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 106 | 107 | 108 | 109 | 110 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 141 | 142 | 143 | 144 | 145 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 189 | 190 | 191 | 192 | 193 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 226 | 227 | 228 | 229 | 230 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 263 | 264 | 265 | 266 | 267 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 298 | 299 | 300 | 301 | 302 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 350 | 351 | 352 | 353 | 354 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 387 | 388 | 389 | 390 | 391 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 424 | 425 | 426 | 427 | 428 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 461 | 462 | 463 | 464 | 465 | 477 | 478 | 479 | 480 | 481 | 482 |
Round 1
Bo.1  Team 4RtgClub/City-6  Team 2RtgClub/City0 : 0
1.1WIM 25 | 26 | 27 | 28 | 29 | 32 | 33 | 34 |
30 | ANotehrnotTest, wadfaeefa 31 |
35 |
2100Testacct31- 41 | 42 | 43 | 44 | 45 | 48 | 49 | 50 |
46 | Teambtest, sadsaf 47 |
51 |
0Testacct11
1.2 60 | 61 | 62 | 63 | 64 | 67 | 68 | 69 |
65 | czxzszcsszc, zxcszczs 66 |
70 |
0Testacct33- 76 | 77 | 78 | 79 | 80 | 83 | 84 | 85 |
81 | Teamseers, Steasdea 82 |
86 |
1670Testacct12
1.3IM 95 | 96 | 97 | 98 | 99 | 102 | 103 | 104 |
100 | fawdacfegyx, moifamnjei 101 |
105 |
2300Testacct32-GM 111 | 112 | 113 | 114 | 115 | 118 | 119 | 120 |
116 | Testacctnsad, Wrsaasad 117 |
121 |
2670Testacct13
1.4 130 | 131 | 132 | 133 | 134 | 137 | 138 | 139 |
135 | wafdawfaw, mjhagefwves 136 |
140 |
2121Testacct34- 146 | 147 | 148 | 149 | 150 | 153 | 154 | 155 |
151 | sadsadekjg, asdbgmhkg 152 |
156 |
0Testacct14
Bo.2  Team 1RtgClub/City-5  Team 6RtgClub/City0 : 0
2.1WGM 178 | 179 | 180 | 181 | 182 | 185 | 186 | 187 |
183 | Testacctg, diasd 184 |
188 |
2300Testacct1- 194 | 195 | 196 | 197 | 198 | 203 | 204 | 205 |
199 | adwdaegtrfy, acmygfnjnfgnmfgnfg 202 |
206 |
1700Testacct51
2.2AIM 215 | 216 | 217 | 218 | 219 | 222 | 223 | 224 |
220 | Testad, fsads 221 |
225 |
1670Testacct2- 231 | 232 | 233 | 234 | 235 | 240 | 241 | 242 |
236 | awdplobvbsyhdw, vichwoaghaw 239 |
243 |
2100Testacct52
2.3WFM 252 | 253 | 254 | 255 | 256 | 259 | 260 | 261 |
257 | Testiertester, Yesme 258 |
262 |
2200Testacct4- 268 | 269 | 270 | 271 | 272 | 275 | 276 | 277 |
273 | lefov kfwakdod, adwdadcw 274 |
278 |
1565Testacct54
2.4 287 | 288 | 289 | 290 | 291 | 294 | 295 | 296 |
292 | Testytester, idkwhy 293 |
297 |
1450Testacct3- 303 | 304 | 305 | 306 | 307 | 312 | 313 | 314 |
308 | padlwmfkaw, ascfmkawfmanhui 311 |
315 |
2222Testacct53
Bo.3  Team 5RtgClub/City-4  Team 3RtgClub/City0 : 0
3.1 337 | 338 | 339 | 340 | 341 | 346 | 347 | 348 |
342 | TReafeajnku, awrghtfgfrshjw 345 |
349 |
1989Testacct41- 355 | 356 | 357 | 358 | 359 | 364 | 365 | 366 |
360 | asdawdTester, dasdTesting32 363 |
367 |
0Testacct21
3.2 376 | 377 | 378 | 379 | 380 | 383 | 384 | 385 |
381 | adawplohfaw, plgeyujwadj 382 |
386 |
0Testacct43-CM 392 | 393 | 394 | 395 | 396 | 399 | 400 | 401 |
397 | dsawdwaf, era3eqr3ea 398 |
402 |
1965Testacct23
3.3IM 411 | 412 | 413 | 414 | 415 | 420 | 421 | 422 |
416 | plaodcawjfigich, lkvhauifwjad 419 |
423 |
2400Testacct44- 429 | 430 | 431 | 432 | 433 | 436 | 437 | 438 |
434 | meshyewaef, wafjawjind 435 |
439 |
1600Testacct24
3.4 448 | 449 | 450 | 451 | 452 | 457 | 458 | 459 |
453 | wdadadadmyseyrs, fadwcafgetws 456 |
460 |
0Testacct42-WCM 466 | 467 | 468 | 469 | 470 | 473 | 474 | 475 |
471 | wdwad34grfd, fsafeadaf 472 |
476 |
1999Testacct22
483 | -------------------------------------------------------------------------------- /src/scraper/tests/fixtures/team-swiss-pairings-with-usernames-2.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 | 36 | 37 | 38 | 39 | 40 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 71 | 72 | 73 | 74 | 75 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 119 | 120 | 121 | 122 | 123 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 154 | 155 | 156 | 157 | 158 | 172 | 173 | 174 | 175 | 176 | 177 |
Round 1
Bo.3  Team 2RtgClub/City-1  Team 3RtgClub/City0 : 0
1.1 25 | 26 | 27 | 28 | 29 | 32 | 33 | 34 |
30 | cybosu, dsad 31 |
35 |
0cynosure- 41 | 42 | 43 | 44 | 45 | 48 | 49 | 50 |
46 | ttrvraw, ttrvdae 47 |
51 |
0ttrv
1.2 60 | 61 | 62 | 63 | 64 | 67 | 68 | 69 |
65 | someonesalt, somealt 66 |
70 |
0e4-IM 76 | 77 | 78 | 79 | 80 | 83 | 84 | 85 |
81 | lovlaswa, lovlasdw 82 |
86 |
2400lovlas
Bo.2  Team 1RtgClub/City-4  Team 4RtgClub/City0 : 0
2.1 108 | 109 | 110 | 111 | 112 | 115 | 116 | 117 |
113 | thibault1, test1 114 |
118 |
0thibault- 124 | 125 | 126 | 127 | 128 | 131 | 132 | 133 |
129 | carpentumsaw, carpentumsad 130 |
134 |
0carpentum
2.2 143 | 144 | 145 | 146 | 147 | 150 | 151 | 152 |
148 | neio123, neioe2qe 149 |
153 |
0neio- 159 | 160 | 161 | 162 | 163 | 168 | 169 | 170 |
164 | puzzlingpuzzlerpux, puzzler 167 |
171 |
0Puzzlingpuzzler
178 | -------------------------------------------------------------------------------- /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 | 36 | 37 | 38 | 39 | 40 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 71 | 72 | 73 | 74 | 75 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 106 | 107 | 108 | 109 | 110 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 141 | 142 | 143 | 144 | 145 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 189 | 190 | 191 | 192 | 193 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 224 | 225 | 226 | 227 | 228 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 259 | 260 | 261 | 262 | 263 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 294 | 295 | 296 | 297 | 298 | 310 | 311 | 312 | 313 | 314 | 315 |
Round 1
Bo.1  Team BRtgClub/City-3  Team CRtgClub/City0 : 0
1.1WFM 25 | 26 | 27 | 28 | 29 | 32 | 33 | 34 |
30 | Testing, Test 31 |
35 |
1985test134-FM 41 | 42 | 43 | 44 | 45 | 48 | 49 | 50 |
46 | Hris, Panagiotis 47 |
51 |
2227test4
1.2IM 60 | 61 | 62 | 63 | 64 | 67 | 68 | 69 |
65 | Someone, Else 66 |
70 |
2400test3- 76 | 77 | 78 | 79 | 80 | 83 | 84 | 85 |
81 | Trevlar, Someone 82 |
86 |
0test5
1.3 95 | 96 | 97 | 98 | 99 | 102 | 103 | 104 |
100 | Another, Test 101 |
105 |
1900test1- 111 | 112 | 113 | 114 | 115 | 118 | 119 | 120 |
116 | TestPlayer, Mary 117 |
121 |
1600test6
1.4 130 | 131 | 132 | 133 | 134 | 137 | 138 | 139 |
135 | Ignore, This 136 |
140 |
1400test2- 146 | 147 | 148 | 149 | 150 | 153 | 154 | 155 |
151 | Testing, Tester 152 |
156 |
0test7
Bo.4  Team ARtgClub/City-2  Team DRtgClub/City0 : 0
2.1 178 | 179 | 180 | 181 | 182 | 185 | 186 | 187 |
183 | Wait, Theophilus 184 |
188 |
0Cynosure-FM 194 | 195 | 196 | 197 | 198 | 201 | 202 | 203 |
199 | SomeoneElse, Michael 200 |
204 |
2230TestAccount1
2.2 213 | 214 | 215 | 216 | 217 | 220 | 221 | 222 |
218 | Thibault, D 219 |
223 |
0Thibault-WCM 229 | 230 | 231 | 232 | 233 | 236 | 237 | 238 |
234 | YetSomeoneElse, Lilly 235 |
239 |
2070TestAccount2
2.3AFM 248 | 249 | 250 | 251 | 252 | 255 | 256 | 257 |
253 | Gkizi, Konst 254 |
258 |
1270Puzzlingpuzzler- 264 | 265 | 266 | 267 | 268 | 271 | 272 | 273 |
269 | Unknown, Player 270 |
274 |
1300TestAccount3
2.4 283 | 284 | 285 | 286 | 287 | 290 | 291 | 292 |
288 | Placeholder, Player 289 |
293 |
0ThisAccountDoesntExist- 299 | 300 | 301 | 302 | 303 | 306 | 307 | 308 |
304 | Also, Unknown 305 |
309 |
1111TestAccount4
316 | -------------------------------------------------------------------------------- /src/scraper/tests/scrape.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test, vi, Mock, beforeEach } from 'vitest'; 2 | import { readFileSync } from 'fs'; 3 | import { 4 | getPlayers, 5 | getPairings, 6 | setResultsPerPage, 7 | Player, 8 | getUrls, 9 | saveUrls, 10 | setCacheBuster, 11 | } from '../scraper'; 12 | 13 | global.fetch = vi.fn(proxyUrl => { 14 | let url = new URL(decodeURIComponent(proxyUrl.split('?')[1])); 15 | let path = url.pathname; 16 | 17 | return Promise.resolve({ 18 | text: () => Promise.resolve(readFileSync(`src/scraper/tests/fixtures${path}`)), 19 | }); 20 | }) as Mock; 21 | 22 | describe('fetch players', () => { 23 | test('with lichess usernames', async () => { 24 | const players = await getPlayers('https://example.com/players-list-with-usernames.html'); 25 | 26 | expect(players).toHaveLength(71); 27 | expect(players[1]).toEqual({ 28 | name: 'Navara, David', 29 | fideId: '309095', 30 | rating: 2679, 31 | lichess: 'RealDavidNavara', 32 | }); 33 | }); 34 | 35 | test('with team columns', async () => { 36 | const players = await getPlayers('https://example.com/players-list-without-usernames.html'); 37 | 38 | expect(players).toHaveLength(150); 39 | expect(players[0]).toEqual({ 40 | name: 'Nepomniachtchi Ian', 41 | fideId: '4168119', 42 | rating: 2789, 43 | lichess: undefined, 44 | }); 45 | }); 46 | }); 47 | 48 | describe('fetch pairings', () => { 49 | test('team swiss', async () => { 50 | const pairings = await getPairings('https://example.com/team-swiss-pairings-with-usernames.html'); 51 | 52 | expect(pairings).toHaveLength(8); 53 | expect(pairings).toStrictEqual([ 54 | { 55 | black: { 56 | lichess: 'test4', 57 | name: 'Hris, Panagiotis', 58 | team: 'Team C', 59 | rating: 2227, 60 | }, 61 | white: { 62 | lichess: 'test134', 63 | name: 'Testing, Test', 64 | team: 'Team B', 65 | rating: 1985, 66 | }, 67 | reversed: false, 68 | board: '1.1', 69 | }, 70 | { 71 | black: { 72 | lichess: 'test3', 73 | name: 'Someone, Else', 74 | team: 'Team B', 75 | rating: 2400, 76 | }, 77 | white: { 78 | lichess: 'test5', 79 | name: 'Trevlar, Someone', 80 | team: 'Team C', 81 | rating: 0, 82 | }, 83 | reversed: true, 84 | board: '1.2', 85 | }, 86 | { 87 | black: { 88 | lichess: 'test6', 89 | name: 'TestPlayer, Mary', 90 | team: 'Team C', 91 | rating: 1600, 92 | }, 93 | white: { 94 | lichess: 'test1', 95 | name: 'Another, Test', 96 | team: 'Team B', 97 | rating: 1900, 98 | }, 99 | reversed: false, 100 | board: '1.3', 101 | }, 102 | { 103 | black: { 104 | lichess: 'test2', 105 | name: 'Ignore, This', 106 | team: 'Team B', 107 | rating: 1400, 108 | }, 109 | white: { 110 | lichess: 'test7', 111 | name: 'Testing, Tester', 112 | team: 'Team C', 113 | rating: 0, 114 | }, 115 | reversed: true, 116 | board: '1.4', 117 | }, 118 | { 119 | black: { 120 | lichess: 'TestAccount1', 121 | name: 'SomeoneElse, Michael', 122 | team: 'Team D', 123 | rating: 2230, 124 | }, 125 | white: { 126 | lichess: 'Cynosure', 127 | name: 'Wait, Theophilus', 128 | team: 'Team A', 129 | rating: 0, 130 | }, 131 | reversed: false, 132 | board: '2.1', 133 | }, 134 | { 135 | black: { 136 | lichess: 'Thibault', 137 | name: 'Thibault, D', 138 | team: 'Team A', 139 | rating: 0, 140 | }, 141 | white: { 142 | lichess: 'TestAccount2', 143 | name: 'YetSomeoneElse, Lilly', 144 | team: 'Team D', 145 | rating: 2070, 146 | }, 147 | reversed: true, 148 | board: '2.2', 149 | }, 150 | { 151 | black: { 152 | lichess: 'TestAccount3', 153 | name: 'Unknown, Player', 154 | team: 'Team D', 155 | rating: 1300, 156 | }, 157 | white: { 158 | lichess: 'Puzzlingpuzzler', 159 | name: 'Gkizi, Konst', 160 | team: 'Team A', 161 | rating: 1270, 162 | }, 163 | reversed: false, 164 | board: '2.3', 165 | }, 166 | { 167 | black: { 168 | lichess: 'ThisAccountDoesntExist', 169 | name: 'Placeholder, Player', 170 | team: 'Team A', 171 | rating: 0, 172 | }, 173 | white: { 174 | lichess: 'TestAccount4', 175 | name: 'Also, Unknown', 176 | team: 'Team D', 177 | rating: 1111, 178 | }, 179 | reversed: true, 180 | board: '2.4', 181 | }, 182 | ]); 183 | }); 184 | 185 | test('team another swiss', async () => { 186 | const pairings = await getPairings('https://example.com/team-swiss-pairings-with-usernames-2.html'); 187 | 188 | expect(pairings).toHaveLength(4); 189 | expect(pairings).toStrictEqual([ 190 | { 191 | black: { 192 | lichess: 'ttrv', 193 | name: 'ttrvraw, ttrvdae', 194 | team: 'Team 3', 195 | rating: 0, 196 | }, 197 | white: { 198 | lichess: 'cynosure', 199 | name: 'cybosu, dsad', 200 | team: 'Team 2', 201 | rating: 0, 202 | }, 203 | reversed: false, 204 | board: '1.1', 205 | }, 206 | { 207 | black: { 208 | lichess: 'e4', 209 | name: 'someonesalt, somealt', 210 | team: 'Team 2', 211 | rating: 0, 212 | }, 213 | white: { 214 | lichess: 'lovlas', 215 | name: 'lovlaswa, lovlasdw', 216 | team: 'Team 3', 217 | rating: 2400, 218 | }, 219 | reversed: true, 220 | board: '1.2', 221 | }, 222 | { 223 | black: { 224 | lichess: 'carpentum', 225 | name: 'carpentumsaw, carpentumsad', 226 | team: 'Team 4', 227 | rating: 0, 228 | }, 229 | white: { 230 | lichess: 'thibault', 231 | name: 'thibault1, test1', 232 | team: 'Team 1', 233 | rating: 0, 234 | }, 235 | reversed: false, 236 | board: '2.1', 237 | }, 238 | { 239 | black: { 240 | lichess: 'neio', 241 | name: 'neio123, neioe2qe', 242 | team: 'Team 1', 243 | rating: 0, 244 | }, 245 | white: { 246 | lichess: 'Puzzlingpuzzler', 247 | name: 'puzzlingpuzzlerpux, puzzler', 248 | team: 'Team 4', 249 | rating: 0, 250 | }, 251 | reversed: true, 252 | board: '2.2', 253 | }, 254 | ]); 255 | }); 256 | 257 | test('team swiss w/o lichess usernames on the same page', async () => { 258 | const pairings = await getPairings('https://example.com/team-swiss-pairings-without-usernames.html'); 259 | 260 | expect(pairings).toHaveLength(76); 261 | expect(pairings[0]).toEqual({ 262 | white: { 263 | name: 'Berend Elvira', 264 | team: 'European Investment Bank', 265 | rating: 2326, 266 | lichess: undefined, 267 | }, 268 | black: { 269 | name: 'Nepomniachtchi Ian', 270 | team: 'SBER', 271 | rating: 2789, 272 | lichess: undefined, 273 | }, 274 | reversed: false, 275 | board: '1.1', 276 | }); 277 | expect(pairings[1]).toEqual({ 278 | black: { 279 | name: 'Sebe-Vodislav Razvan-Alexandru', 280 | team: 'European Investment Bank', 281 | rating: 2270, 282 | lichess: undefined, 283 | }, 284 | white: { 285 | name: 'Kadatsky Alexander', 286 | team: 'SBER', 287 | rating: 2368, 288 | lichess: undefined, 289 | }, 290 | reversed: true, 291 | board: '1.2', 292 | }); 293 | 294 | // check the next set of Teams 295 | expect(pairings[8]).toEqual({ 296 | black: { 297 | name: 'Delchev Alexander', 298 | team: 'Tigar Tyres', 299 | rating: 2526, 300 | lichess: undefined, 301 | }, 302 | white: { 303 | name: 'Chernikova Iryna', 304 | team: 'Airbus (FRA)', 305 | rating: 1509, 306 | lichess: undefined, 307 | }, 308 | reversed: false, 309 | board: '3.1', 310 | }); 311 | }); 312 | 313 | test('individual round robin', async () => { 314 | const pairings = await getPairings('https://example.com/individual-round-robin-pairings.html'); 315 | 316 | expect(pairings).toHaveLength(28); 317 | expect(pairings[0]).toEqual({ 318 | white: { 319 | name: 'Ponkratov, Pavel', 320 | }, 321 | black: { 322 | name: 'Galaktionov, Artem', 323 | }, 324 | reversed: false, 325 | board: '1', 326 | }); 327 | }); 328 | 329 | test('team round robin', async () => { 330 | const pairings = await getPairings('https://example.com/team-round-robin-pairings.html'); 331 | 332 | expect(pairings).toHaveLength(12); 333 | expect(pairings[0]).toEqual({ 334 | white: { 335 | name: 'ANotehrnotTest, wadfaeefa', 336 | team: 'Team 4', 337 | lichess: 'Testacct31', 338 | rating: 2100, 339 | }, 340 | black: { 341 | name: 'Teambtest, sadsaf', 342 | team: 'Team 2', 343 | lichess: 'Testacct11', 344 | rating: 0, 345 | }, 346 | reversed: false, 347 | board: '1.1', 348 | }); 349 | expect(pairings[1]).toEqual({ 350 | white: { 351 | name: 'Teamseers, Steasdea', 352 | team: 'Team 2', 353 | lichess: 'Testacct12', 354 | rating: 1670, 355 | }, 356 | black: { 357 | name: 'czxzszcsszc, zxcszczs', 358 | team: 'Team 4', 359 | lichess: 'Testacct33', 360 | rating: 0, 361 | }, 362 | reversed: true, 363 | board: '1.2', 364 | }); 365 | }); 366 | 367 | test('individual swiss', async () => { 368 | const pairings = await getPairings('https://example.com/individual-swiss-pairings.html'); 369 | 370 | expect(pairings).toHaveLength(59); 371 | expect(pairings[0]).toEqual({ 372 | white: { 373 | name: 'Gunina, Valentina', 374 | }, 375 | black: { 376 | name: 'Mammadzada, Gunay', 377 | }, 378 | reversed: false, 379 | board: '1', 380 | }); 381 | }); 382 | 383 | test('individual swiss w/ player substitution', async () => { 384 | const players: Player[] = [ 385 | { 386 | name: 'Gunina, Valentina', 387 | lichess: 'test-valentina', 388 | }, 389 | { 390 | name: 'Mammadzada, Gunay', 391 | lichess: 'test-gunay', 392 | }, 393 | ]; 394 | const pairings = await getPairings('https://example.com/individual-swiss-pairings.html', players); 395 | 396 | expect(pairings).toHaveLength(59); 397 | expect(pairings[0]).toEqual({ 398 | white: { 399 | name: 'Gunina, Valentina', 400 | lichess: 'test-valentina', 401 | }, 402 | black: { 403 | name: 'Mammadzada, Gunay', 404 | lichess: 'test-gunay', 405 | }, 406 | reversed: false, 407 | board: '1', 408 | }); 409 | }); 410 | }); 411 | 412 | test('set results per page', () => { 413 | expect(setResultsPerPage('https://example.com')).toBe('https://example.com/?zeilen=99999'); 414 | expect(setResultsPerPage('https://example.com', 10)).toBe('https://example.com/?zeilen=10'); 415 | expect(setResultsPerPage('https://example.com/?foo=bar', 10)).toBe( 416 | 'https://example.com/?foo=bar&zeilen=10', 417 | ); 418 | expect(setResultsPerPage('https://example.com/players.aspx?zeilen=10', 20)).toBe( 419 | 'https://example.com/players.aspx?zeilen=20', 420 | ); 421 | expect(setResultsPerPage('https://example.com/players.aspx?zeilen=10', 99999)).toBe( 422 | 'https://example.com/players.aspx?zeilen=99999', 423 | ); 424 | }); 425 | 426 | describe('get/set urls from local storage', () => { 427 | beforeEach(() => { 428 | localStorage.clear(); 429 | }); 430 | 431 | test('get', () => { 432 | expect(getUrls('abc1')).toBeUndefined(); 433 | }); 434 | 435 | test('set', () => { 436 | saveUrls('abc2', 'https://example.com/pairings2.html'); 437 | expect(getUrls('abc2')).toStrictEqual({ 438 | pairingsUrl: 'https://example.com/pairings2.html', 439 | }); 440 | }); 441 | 442 | test('append', () => { 443 | saveUrls('abc3', 'https://example.com/pairings3.html'); 444 | saveUrls('abc4', 'https://example.com/pairings4.html'); 445 | 446 | expect(getUrls('abc3')).toStrictEqual({ 447 | pairingsUrl: 'https://example.com/pairings3.html', 448 | }); 449 | 450 | expect(getUrls('abc4')).toStrictEqual({ 451 | pairingsUrl: 'https://example.com/pairings4.html', 452 | }); 453 | }); 454 | }); 455 | 456 | describe('test cache buster', () => { 457 | test('set cache buster', () => { 458 | expect(setCacheBuster('https://example.com')).toContain('https://example.com/?cachebust=1'); 459 | }); 460 | 461 | test('append cache buster', () => { 462 | expect(setCacheBuster('https://example.com/?foo=bar')).toContain( 463 | 'https://example.com/?foo=bar&cachebust=1', 464 | ); 465 | }); 466 | }); 467 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { 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/view/form.ts: -------------------------------------------------------------------------------- 1 | import { h } from 'snabbdom'; 2 | import { variants } from '../util'; 3 | import { MaybeVNodes } from '../interfaces'; 4 | import { Failure, Feedback, isFailure } from '../form'; 5 | import { Rule } from '../model'; 6 | import { 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/view/layout.ts: -------------------------------------------------------------------------------- 1 | import { h, VNode } from 'snabbdom'; 2 | import { Me } from '../auth'; 3 | import { App } from '../app'; 4 | import { 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/util.ts: -------------------------------------------------------------------------------- 1 | import { h } from 'snabbdom'; 2 | import { 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 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "noImplicitReturns": true, 5 | "noImplicitThis": true, 6 | "moduleResolution": "node", 7 | "target": "ES2017", 8 | "module": "esnext", 9 | "lib": ["DOM", "ES2019"], 10 | "skipLibCheck": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: 'jsdom', 6 | }, 7 | }); 8 | --------------------------------------------------------------------------------