{
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 | Round 1 on 2023/06/24 at 14:00 |
7 |
8 |
9 | Bo. |
10 | No. |
11 | Rtg |
12 | |
13 | Name |
14 | Result |
15 | |
16 | Name |
17 | Rtg |
18 | No. |
19 |
20 |
21 | 1 |
22 | 8 |
23 | 2586 |
24 | GM |
25 | Ponkratov, Pavel |
26 | 0 - 1 |
27 | FM |
28 | Galaktionov, Artem |
29 | 2379 |
30 | 4 |
31 | |
32 |
33 |
34 | 2 |
35 | 7 |
36 | 2472 |
37 | IM |
38 | Drozdowski, Kacper |
39 | 0 - 1 |
40 | GM |
41 | Andreikin, Dmitry |
42 | 2628 |
43 | 1 |
44 | |
45 |
46 |
47 | 3 |
48 | 2 |
49 | 2186 |
50 | FM |
51 | Aradhya, Garg |
52 | 0 - 1 |
53 | FM |
54 | Sevgi, Volkan |
55 | 2204 |
56 | 6 |
57 | |
58 |
59 |
60 | 4 |
61 | 3 |
62 | 2500 |
63 | GM |
64 | Moroni, Luca Jr |
65 | 1 - 0 |
66 | |
67 | Sviridov, Valery |
68 | 2407 |
69 | 5 |
70 | |
71 |
72 |
73 | Round 2 on 2023/06/24 at 14:30 |
74 |
75 |
76 | Bo. |
77 | No. |
78 | Rtg |
79 | |
80 | Name |
81 | Result |
82 | |
83 | Name |
84 | Rtg |
85 | No. |
86 |
87 |
88 | 1 |
89 | 4 |
90 | 2379 |
91 | FM |
92 | Galaktionov, Artem |
93 | ½ - ½ |
94 | |
95 | Sviridov, Valery |
96 | 2407 |
97 | 5 |
98 | |
99 |
100 |
101 | 2 |
102 | 6 |
103 | 2204 |
104 | FM |
105 | Sevgi, Volkan |
106 | 1 - 0 |
107 | GM |
108 | Moroni, Luca Jr |
109 | 2500 |
110 | 3 |
111 | |
112 |
113 |
114 | 3 |
115 | 1 |
116 | 2628 |
117 | GM |
118 | Andreikin, Dmitry |
119 | 1 - 0 |
120 | FM |
121 | Aradhya, Garg |
122 | 2186 |
123 | 2 |
124 | |
125 |
126 |
127 | 4 |
128 | 8 |
129 | 2586 |
130 | GM |
131 | Ponkratov, Pavel |
132 | ½ - ½ |
133 | IM |
134 | Drozdowski, Kacper |
135 | 2472 |
136 | 7 |
137 | |
138 |
139 |
140 | Round 3 on 2023/06/24 at 15:00 |
141 |
142 |
143 | Bo. |
144 | No. |
145 | Rtg |
146 | |
147 | Name |
148 | Result |
149 | |
150 | Name |
151 | Rtg |
152 | No. |
153 |
154 |
155 | 1 |
156 | 7 |
157 | 2472 |
158 | IM |
159 | Drozdowski, Kacper |
160 | 0 - 1 |
161 | FM |
162 | Galaktionov, Artem |
163 | 2379 |
164 | 4 |
165 | |
166 |
167 |
168 | 2 |
169 | 2 |
170 | 2186 |
171 | FM |
172 | Aradhya, Garg |
173 | 1 - 0 |
174 | GM |
175 | Ponkratov, Pavel |
176 | 2586 |
177 | 8 |
178 | |
179 |
180 |
181 | 3 |
182 | 3 |
183 | 2500 |
184 | GM |
185 | Moroni, Luca Jr |
186 | ½ - ½ |
187 | GM |
188 | Andreikin, Dmitry |
189 | 2628 |
190 | 1 |
191 | |
192 |
193 |
194 | 4 |
195 | 5 |
196 | 2407 |
197 | |
198 | Sviridov, Valery |
199 | 1 - 0 |
200 | FM |
201 | Sevgi, Volkan |
202 | 2204 |
203 | 6 |
204 | |
205 |
206 |
207 | Round 4 on 2023/06/24 at 15:30 |
208 |
209 |
210 | Bo. |
211 | No. |
212 | Rtg |
213 | |
214 | Name |
215 | Result |
216 | |
217 | Name |
218 | Rtg |
219 | No. |
220 |
221 |
222 | 1 |
223 | 4 |
224 | 2379 |
225 | FM |
226 | Galaktionov, Artem |
227 | ½ - ½ |
228 | FM |
229 | Sevgi, Volkan |
230 | 2204 |
231 | 6 |
232 | |
233 |
234 |
235 | 2 |
236 | 1 |
237 | 2628 |
238 | GM |
239 | Andreikin, Dmitry |
240 | ½ - ½ |
241 | |
242 | Sviridov, Valery |
243 | 2407 |
244 | 5 |
245 | |
246 |
247 |
248 | 3 |
249 | 8 |
250 | 2586 |
251 | GM |
252 | Ponkratov, Pavel |
253 | 1 - 0 |
254 | GM |
255 | Moroni, Luca Jr |
256 | 2500 |
257 | 3 |
258 | |
259 |
260 |
261 | 4 |
262 | 7 |
263 | 2472 |
264 | IM |
265 | Drozdowski, Kacper |
266 | 1 - 0 |
267 | FM |
268 | Aradhya, Garg |
269 | 2186 |
270 | 2 |
271 | |
272 |
273 |
274 | Round 5 on 2023/06/24 at 16:00 |
275 |
276 |
277 | Bo. |
278 | No. |
279 | Rtg |
280 | |
281 | Name |
282 | Result |
283 | |
284 | Name |
285 | Rtg |
286 | No. |
287 |
288 |
289 | 1 |
290 | 2 |
291 | 2186 |
292 | FM |
293 | Aradhya, Garg |
294 | 0 - 1 |
295 | FM |
296 | Galaktionov, Artem |
297 | 2379 |
298 | 4 |
299 | |
300 |
301 |
302 | 2 |
303 | 3 |
304 | 2500 |
305 | GM |
306 | Moroni, Luca Jr |
307 | ½ - ½ |
308 | IM |
309 | Drozdowski, Kacper |
310 | 2472 |
311 | 7 |
312 | |
313 |
314 |
315 | 3 |
316 | 5 |
317 | 2407 |
318 | |
319 | Sviridov, Valery |
320 | 1 - 0 |
321 | GM |
322 | Ponkratov, Pavel |
323 | 2586 |
324 | 8 |
325 | |
326 |
327 |
328 | 4 |
329 | 6 |
330 | 2204 |
331 | FM |
332 | Sevgi, Volkan |
333 | 1 - 0 |
334 | GM |
335 | Andreikin, Dmitry |
336 | 2628 |
337 | 1 |
338 | |
339 |
340 |
341 | Round 6 on 2023/06/24 at 16:30 |
342 |
343 |
344 | Bo. |
345 | No. |
346 | Rtg |
347 | |
348 | Name |
349 | Result |
350 | |
351 | Name |
352 | Rtg |
353 | No. |
354 |
355 |
356 | 1 |
357 | 4 |
358 | 2379 |
359 | FM |
360 | Galaktionov, Artem |
361 | 0 - 1 |
362 | GM |
363 | Andreikin, Dmitry |
364 | 2628 |
365 | 1 |
366 | |
367 |
368 |
369 | 2 |
370 | 8 |
371 | 2586 |
372 | GM |
373 | Ponkratov, Pavel |
374 | 1 - 0 |
375 | FM |
376 | Sevgi, Volkan |
377 | 2204 |
378 | 6 |
379 | |
380 |
381 |
382 | 3 |
383 | 7 |
384 | 2472 |
385 | IM |
386 | Drozdowski, Kacper |
387 | ½ - ½ |
388 | |
389 | Sviridov, Valery |
390 | 2407 |
391 | 5 |
392 | |
393 |
394 |
395 | 4 |
396 | 2 |
397 | 2186 |
398 | FM |
399 | Aradhya, Garg |
400 | ½ - ½ |
401 | GM |
402 | Moroni, Luca Jr |
403 | 2500 |
404 | 3 |
405 | |
406 |
407 |
408 | Round 7 on 2023/06/24 at 17:00 |
409 |
410 |
411 | Bo. |
412 | No. |
413 | Rtg |
414 | |
415 | Name |
416 | Result |
417 | |
418 | Name |
419 | Rtg |
420 | No. |
421 |
422 |
423 | 1 |
424 | 3 |
425 | 2500 |
426 | GM |
427 | Moroni, Luca Jr |
428 | ½ - ½ |
429 | FM |
430 | Galaktionov, Artem |
431 | 2379 |
432 | 4 |
433 | |
434 |
435 |
436 | 2 |
437 | 5 |
438 | 2407 |
439 | |
440 | Sviridov, Valery |
441 | ½ - ½ |
442 | FM |
443 | Aradhya, Garg |
444 | 2186 |
445 | 2 |
446 | |
447 |
448 |
449 | 3 |
450 | 6 |
451 | 2204 |
452 | FM |
453 | Sevgi, Volkan |
454 | 0 - 1 |
455 | IM |
456 | Drozdowski, Kacper |
457 | 2472 |
458 | 7 |
459 | |
460 |
461 |
462 | 4 |
463 | 1 |
464 | 2628 |
465 | GM |
466 | Andreikin, Dmitry |
467 | 1 - 0 |
468 | GM |
469 | Ponkratov, Pavel |
470 | 2586 |
471 | 8 |
472 | |
473 |
474 |
475 |
476 |
--------------------------------------------------------------------------------
/src/scraper/tests/fixtures/individual-swiss-pairings.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Bo. |
7 | No. |
8 | |
9 | Name |
10 | Rtg |
11 | Pts. |
12 | Result |
13 | Pts. |
14 | |
15 | Name |
16 | Rtg |
17 | No. |
18 |
19 |
20 | 1 |
21 | 24 |
22 | GM |
23 | Gunina, Valentina |
24 | 2348 |
25 | 6 |
26 | 1 - 0 |
27 | 5 |
28 | IM |
29 | Mammadzada, Gunay |
30 | 2408 |
31 | 15 |
32 |
33 |
34 | 2 |
35 | 32 |
36 | WGM |
37 | Divya, Deshmukh |
38 | 2315 |
39 | 5 |
40 | 0 - 1 |
41 | 5 |
42 | GM |
43 | Kosteniuk, Alexandra |
44 | 2455 |
45 | 7 |
46 |
47 |
48 | 3 |
49 | 12 |
50 | GM |
51 | Dronavalli, Harika |
52 | 2420 |
53 | 5 |
54 | 1 - 0 |
55 | 5 |
56 | WIM |
57 | Omonova, Umida |
58 | 2304 |
59 | 36 |
60 |
61 |
62 | 4 |
63 | 18 |
64 | GM |
65 | Zhu, Jiner |
66 | 2384 |
67 | 5 |
68 | 0 - 1 |
69 | 5 |
70 | IM |
71 | Bodnaruk, Anastasia |
72 | 2260 |
73 | 48 |
74 |
75 |
76 | 5 |
77 | 40 |
78 | IM |
79 | Narva, Mai |
80 | 2292 |
81 | 5 |
82 | ½ - ½ |
83 | 4½ |
84 | GM |
85 | Lagno, Kateryna |
86 | 2522 |
87 | 3 |
88 |
89 |
90 | 6 |
91 | 6 |
92 | GM |
93 | Goryachkina, Aleksandra |
94 | 2475 |
95 | 4½ |
96 | 1 - 0 |
97 | 4½ |
98 | IM |
99 | Salimova, Nurgyul |
100 | 2343 |
101 | 27 |
102 |
103 |
104 | 7 |
105 | 108 |
106 | WCM |
107 | Lesbekova, Assel |
108 | 1861 |
109 | 4½ |
110 | 0 - 1 |
111 | 4½ |
112 | IM |
113 | Munguntuul, Batkhuyag |
114 | 2348 |
115 | 25 |
116 |
117 |
118 | 8 |
119 | 2 |
120 | GM |
121 | Ju, Wenjun |
122 | 2522 |
123 | 4 |
124 | ½ - ½ |
125 | 4 |
126 | IM |
127 | Injac, Teodora |
128 | 2287 |
129 | 41 |
130 |
131 |
132 | 9 |
133 | 55 |
134 | GM |
135 | Batsiashvili, Nino |
136 | 2233 |
137 | 4 |
138 | 0 - 1 |
139 | 4 |
140 | IM |
141 | Assaubayeva, Bibisara |
142 | 2476 |
143 | 5 |
144 |
145 |
146 | 10 |
147 | 8 |
148 | GM |
149 | Koneru, Humpy |
150 | 2452 |
151 | 4 |
152 | ½ - ½ |
153 | 4 |
154 | WGM |
155 | Rakshitta, Ravi |
156 | 2225 |
157 | 57 |
158 |
159 |
160 | 11 |
161 | 60 |
162 | IM |
163 | Garifullina, Leya |
164 | 2216 |
165 | 4 |
166 | 1 - 0 |
167 | 4 |
168 | IM |
169 | Matnadze Bujiashvili, Ann |
170 | 2428 |
171 | 11 |
172 |
173 |
174 | 12 |
175 | 14 |
176 | IM |
177 | Vaishali, Rameshbabu |
178 | 2410 |
179 | 4 |
180 | 0 - 1 |
181 | 4 |
182 | WGM |
183 | Munkhzul, Turmunkh |
184 | 2211 |
185 | 63 |
186 |
187 |
188 | 13 |
189 | 67 |
190 | WGM |
191 | Yu, Jennifer |
192 | 2197 |
193 | 4 |
194 | 0 - 1 |
195 | 4 |
196 | WGM |
197 | Wagner, Dinara |
198 | 2350 |
199 | 23 |
200 |
201 |
202 | 14 |
203 | 28 |
204 | IM |
205 | Shuvalova, Polina |
206 | 2342 |
207 | 4 |
208 | 1 - 0 |
209 | 4 |
210 | WGM |
211 | Berend, Elvira |
212 | 2202 |
213 | 65 |
214 |
215 |
216 | 15 |
217 | 30 |
218 | GM |
219 | Ushenina, Anna |
220 | 2334 |
221 | 4 |
222 | 1 - 0 |
223 | 4 |
224 | |
225 | Velpula, Sarayu |
226 | 1747 |
227 | 112 |
228 |
229 |
230 | 16 |
231 | 101 |
232 | FM |
233 | Kazarian, Anna-Maja |
234 | 2023 |
235 | 4 |
236 | 0 - 1 |
237 | 4 |
238 | WGM |
239 | Kamalidenova, Meruert |
240 | 2314 |
241 | 33 |
242 |
243 |
244 | 17 |
245 | 103 |
246 | WFM |
247 | Aydin, Gulenay |
248 | 2009 |
249 | 4 |
250 | 0 - 1 |
251 | 3½ |
252 | GM |
253 | Muzychuk, Anna |
254 | 2447 |
255 | 9 |
256 |
257 |
258 | 18 |
259 | 39 |
260 | WFM |
261 | Khamdamova, Afruza |
262 | 2295 |
263 | 3½ |
264 | 0 - 1 |
265 | 3½ |
266 | GM |
267 | Tan, Zhongyi |
268 | 2519 |
269 | 4 |
270 |
271 |
272 | 19 |
273 | 20 |
274 | GM |
275 | Krush, Irina |
276 | 2359 |
277 | 3½ |
278 | 1 - 0 |
279 | 3½ |
280 | IM |
281 | Nomin-Erdene, Davaademberel |
282 | 2299 |
283 | 38 |
284 |
285 |
286 | 20 |
287 | 22 |
288 | GM |
289 | Danielian, Elina |
290 | 2352 |
291 | 3½ |
292 | 1 - 0 |
293 | 3½ |
294 | IM |
295 | Guichard, Pauline |
296 | 2281 |
297 | 43 |
298 |
299 |
300 | 21 |
301 | 50 |
302 | IM |
303 | Bivol, Alina |
304 | 2250 |
305 | 3½ |
306 | 1 - 0 |
307 | 3½ |
308 | IM |
309 | Buksa, Nataliya |
310 | 2316 |
311 | 31 |
312 |
313 |
314 | 22 |
315 | 37 |
316 | IM |
317 | Padmini, Rout |
318 | 2302 |
319 | 3½ |
320 | 0 - 1 |
321 | 3½ |
322 | WFM |
323 | Shukhman, Anna |
324 | 2093 |
325 | 87 |
326 |
327 |
328 | 23 |
329 | 88 |
330 | WIM |
331 | Gaboyan, Susanna |
332 | 2086 |
333 | 3½ |
334 | ½ - ½ |
335 | 3 |
336 | GM |
337 | Lei, Tingjie |
338 | 2530 |
339 | 1 |
340 |
341 |
342 | 24 |
343 | 10 |
344 | GM |
345 | Muzychuk, Mariya |
346 | 2443 |
347 | 3 |
348 | 1 - 0 |
349 | 3 |
350 | WGM |
351 | Pourkashiyan, Atousa |
352 | 2221 |
353 | 59 |
354 |
355 |
356 | 25 |
357 | 16 |
358 | GM |
359 | Stefanova, Antoaneta |
360 | 2398 |
361 | 3 |
362 | 1 - 0 |
363 | 3 |
364 | WIM |
365 | Balabayeva, Xeniya |
366 | 2202 |
367 | 66 |
368 |
369 |
370 | 26 |
371 | 72 |
372 | IM |
373 | Gvetadze, Sofio |
374 | 2170 |
375 | 3 |
376 | 0 - 1 |
377 | 3 |
378 | IM |
379 | Khademalsharieh, Sarasadat |
380 | 2395 |
381 | 17 |
382 |
383 |
384 | 27 |
385 | 68 |
386 | FM |
387 | Sahithi, Varshini M |
388 | 2189 |
389 | 3 |
390 | 0 - 1 |
391 | 3 |
392 | GM |
393 | Khotenashvili, Bella |
394 | 2358 |
395 | 21 |
396 |
397 |
398 | 28 |
399 | 76 |
400 | IM |
401 | Ovod, Evgenija |
402 | 2151 |
403 | 3 |
404 | 0 - 1 |
405 | 3 |
406 | WGM |
407 | Zhai, Mo |
408 | 2342 |
409 | 29 |
410 |
411 |
412 | 29 |
413 | 34 |
414 | IM |
415 | Kiolbasa, Oliwia |
416 | 2313 |
417 | 3 |
418 | 1 - 0 |
419 | 3 |
420 | IM |
421 | Soumya, Swaminathan |
422 | 2154 |
423 | 75 |
424 |
425 |
426 | 30 |
427 | 44 |
428 | WGM |
429 | Ni, Shiqun |
430 | 2270 |
431 | 3 |
432 | 1 - 0 |
433 | 3 |
434 | WGM |
435 | Savitha, Shri B |
436 | 2183 |
437 | 69 |
438 |
439 |
440 | 31 |
441 | 110 |
442 | WFM |
443 | Kaliakhmet, Elnaz |
444 | 1839 |
445 | 3 |
446 | 0 - 1 |
447 | 3 |
448 | IM |
449 | Badelka, Olga |
450 | 2262 |
451 | 47 |
452 |
453 |
454 | 32 |
455 | 78 |
456 | WFM |
457 | Tohirjonova, Hulkar |
458 | 2144 |
459 | 3 |
460 | 0 - 1 |
461 | 3 |
462 | IM |
463 | Charochkina, Daria |
464 | 2254 |
465 | 49 |
466 |
467 |
468 | 33 |
469 | 82 |
470 | FM |
471 | Borisova, Ekaterina |
472 | 2130 |
473 | 3 |
474 | 0 - 1 |
475 | 3 |
476 | WIM |
477 | Nurmanova, Alua |
478 | 2249 |
479 | 51 |
480 |
481 |
482 | 34 |
483 | 54 |
484 | WGM |
485 | Kovanova, Baira |
486 | 2234 |
487 | 3 |
488 | 0 - 1 |
489 | 3 |
490 | WIM |
491 | Mkrtchyan, Mariam |
492 | 2150 |
493 | 77 |
494 |
495 |
496 | 35 |
497 | 56 |
498 | WGM |
499 | Hejazipour, Mitra |
500 | 2228 |
501 | 3 |
502 | 1 - 0 |
503 | 3 |
504 | WCM |
505 | Shohradova, Leyla |
506 | 1882 |
507 | 107 |
508 |
509 |
510 | 36 |
511 | 58 |
512 | WGM |
513 | Priyanka, Nutakki |
514 | 2224 |
515 | 3 |
516 | 1 - 0 |
517 | 3 |
518 | WIM |
519 | Nurgali, Nazerke |
520 | 2068 |
521 | 95 |
522 |
523 |
524 | 37 |
525 | 102 |
526 | WFM |
527 | Getman, Tatyana |
528 | 2014 |
529 | 2½ |
530 | 0 - 1 |
531 | 2½ |
532 | IM |
533 | Cori T., Deysi |
534 | 2304 |
535 | 35 |
536 |
537 |
538 | 38 |
539 | 74 |
540 | FM |
541 | Toncheva, Nadya |
542 | 2162 |
543 | 2½ |
544 | 0 - 1 |
545 | 2½ |
546 | IM |
547 | Fataliyeva, Ulviyya |
548 | 2268 |
549 | 45 |
550 |
551 |
552 | 39 |
553 | 46 |
554 | WGM |
555 | Tokhirjonova, Gulrukhbegim |
556 | 2265 |
557 | 2½ |
558 | 1 - 0 |
559 | 2½ |
560 | WIM |
561 | Serikbay, Assel |
562 | 2126 |
563 | 83 |
564 |
565 |
566 | 40 |
567 | 52 |
568 | IM |
569 | Mammadova, Gulnar |
570 | 2247 |
571 | 2½ |
572 | ½ - ½ |
573 | 2½ |
574 | WIM |
575 | Kairbekova, Amina |
576 | 2131 |
577 | 81 |
578 |
579 |
580 | 41 |
581 | 84 |
582 | WGM |
583 | Abdulla, Khayala |
584 | 2118 |
585 | 2½ |
586 | 1 - 0 |
587 | 2½ |
588 | WGM |
589 | Voit, Daria |
590 | 2215 |
591 | 62 |
592 |
593 |
594 | 42 |
595 | 64 |
596 | IM |
597 | Balajayeva, Khanim |
598 | 2207 |
599 | 2½ |
600 | ½ - ½ |
601 | 2½ |
602 | WFM |
603 | Poliakova, Varvara |
604 | 2049 |
605 | 99 |
606 |
607 |
608 | 43 |
609 | 90 |
610 | WFM |
611 | Ovezdurdyyeva, Jemal |
612 | 2086 |
613 | 2½ |
614 | 0 - 1 |
615 | 2½ |
616 | WGM |
617 | Strutinskaia, Galina |
618 | 2174 |
619 | 71 |
620 |
621 |
622 | 44 |
623 | 114 |
624 | |
625 | Aktamova, Zilola |
626 | 1671 |
627 | 2½ |
628 | 0 - 1 |
629 | 2 |
630 | IM |
631 | Arabidze, Meri |
632 | 2416 |
633 | 13 |
634 |
635 |
636 | 45 |
637 | 89 |
638 | WIM |
639 | Yan, Tianqi |
640 | 2086 |
641 | 2 |
642 | 1 - 0 |
643 | 2 |
644 | GM |
645 | Zhu, Chen |
646 | 2379 |
647 | 19 |
648 |
649 |
650 | 46 |
651 | 26 |
652 | IM |
653 | Tsolakidou, Stavroula |
654 | 2345 |
655 | 2 |
656 | 1 - 0 |
657 | 2 |
658 | FM |
659 | Kurmangaliyeva, Liya |
660 | 2077 |
661 | 93 |
662 |
663 |
664 | 47 |
665 | 42 |
666 | WIM |
667 | Lu, Miaoyi |
668 | 2284 |
669 | 2 |
670 | 1 - 0 |
671 | 2 |
672 | WFM |
673 | Bobomurodova, Maftuna |
674 | 1814 |
675 | 111 |
676 |
677 |
678 | 48 |
679 | 104 |
680 | WFM |
681 | Nurgaliyeva, Zarina |
682 | 2001 |
683 | 2 |
684 | 0 - 1 |
685 | 2 |
686 | WGM |
687 | Yakubbaeva, Nilufar |
688 | 2237 |
689 | 53 |
690 |
691 |
692 | 49 |
693 | 94 |
694 | WFM |
695 | Hajiyeva, Laman |
696 | 2076 |
697 | 2 |
698 | 0 - 1 |
699 | 2 |
700 | FM |
701 | Peycheva, Gergana |
702 | 2216 |
703 | 61 |
704 |
705 |
706 | 50 |
707 | 70 |
708 | WGM |
709 | Mamedjarova, Turkan |
710 | 2175 |
711 | 2 |
712 | 1 - 0 |
713 | 2 |
714 | WIM |
715 | Malikova, Marjona |
716 | 1730 |
717 | 113 |
718 |
719 |
720 | 51 |
721 | 116 |
722 | |
723 | Karimova, Guldona |
724 | 1547 |
725 | 2 |
726 | 0 - 1 |
727 | 2 |
728 | WGM |
729 | Beydullayeva, Govhar |
730 | 2132 |
731 | 79 |
732 |
733 |
734 | 52 |
735 | 92 |
736 | WFM |
737 | Zhao, Shengxin |
738 | 2079 |
739 | 1½ |
740 | 1 - 0 |
741 | 2 |
742 | |
743 | Altynbek, Aiaru |
744 | 1641 |
745 | 115 |
746 |
747 |
748 | 53 |
749 | 106 |
750 | WIM |
751 | Hamrakulova, Yulduz |
752 | 1908 |
753 | 1½ |
754 | 0 - 1 |
755 | 1½ |
756 | WFM |
757 | Yakimova, Mariya |
758 | 2098 |
759 | 86 |
760 |
761 |
762 | 54 |
763 | 117 |
764 | |
765 | Zhunusbekova, Aimonchok |
766 | 1448 |
767 | 1½ |
768 | 0 - 1 |
769 | 1½ |
770 | WFM |
771 | Druzhinina, Olga |
772 | 2055 |
773 | 97 |
774 |
775 |
776 | 55 |
777 | 98 |
778 | WGM |
779 | Kurbonboeva, Sarvinoz |
780 | 2054 |
781 | 1½ |
782 | 0 - 1 |
783 | 1½ |
784 | WIM |
785 | Franco Valencia, Angela |
786 | 2048 |
787 | 100 |
788 |
789 |
790 | 56 |
791 | 80 |
792 | |
793 | Khamrakulova, Iroda |
794 | 2132 |
795 | 1 |
796 | 0 - 1 |
797 | 1 |
798 | WFM |
799 | Shohradova, Lala |
800 | 1983 |
801 | 105 |
802 |
803 |
804 | 57 |
805 | 96 |
806 | WGM |
807 | Zaksaite, Salomeja |
808 | 2065 |
809 | 1 |
810 | 1 - 0 |
811 | 1 |
812 | WIM |
813 | Rudzinska, Michalina |
814 | 2113 |
815 | 85 |
816 |
817 |
818 | 58 |
819 | 91 |
820 | |
821 | Khamrakulova, Shakhnoza |
822 | 2086 |
823 | 1 |
824 | 0 - 1 |
825 | 1 |
826 | WFM |
827 | Allahverdiyeva, Ayan |
828 | 1852 |
829 | 109 |
830 |
831 |
832 | 59 |
833 | 118 |
834 | |
835 | Naisanga, Sheba Valentine |
836 | 1358 |
837 | 0 |
838 | 0 - 1 |
839 | ½ |
840 | WIM |
841 | Nadirjanova, Nodira |
842 | 2164 |
843 | 73 |
844 |
845 |
846 |
847 |
--------------------------------------------------------------------------------
/src/scraper/tests/fixtures/players-list-with-usernames.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | No. |
7 | |
8 | Name |
9 | FideID |
10 | FED |
11 | Rtg |
12 | Club/City |
13 |
14 |
15 | 1 |
16 | GM |
17 | Saric, Ivan |
18 | 14508150 |
19 | CRO |
20 | 2685 |
21 | dalmatinac101 |
22 |
23 |
24 | 2 |
25 | GM |
26 | Navara, David |
27 | 309095 |
28 | CZE |
29 | 2679 |
30 | RealDavidNavara |
31 |
32 |
33 | 3 |
34 | GM |
35 | Pechac, Jergus |
36 | 14926970 |
37 | SVK |
38 | 2570 |
39 | GracchusJeep |
40 |
41 |
42 | 4 |
43 | GM |
44 | Balogh, Csaba |
45 | 718939 |
46 | HUN |
47 | 2551 |
48 | Csimpula |
49 |
50 |
51 | 5 |
52 | IM |
53 | Sahidi, Samir |
54 | 14936372 |
55 | SVK |
56 | 2476 |
57 | samisahi |
58 |
59 |
60 | 6 |
61 | IM |
62 | Haring, Filip |
63 | 14933080 |
64 | SVK |
65 | 2442 |
66 | BestStelker |
67 |
68 |
69 | 7 |
70 | FM |
71 | Bochnicka, Vladimir |
72 | 14943450 |
73 | SVK |
74 | 2362 |
75 | vladoboch |
76 |
77 |
78 | 8 |
79 | FM |
80 | Danada, Tomas |
81 | 14919362 |
82 | SVK |
83 | 2229 |
84 | NAARD |
85 |
86 |
87 | 9 |
88 | |
89 | Matejovic, Juraj |
90 | 14906040 |
91 | SVK |
92 | 2228 |
93 | CSSML-NDSMD |
94 |
95 |
96 | 10 |
97 | WIM |
98 | Maslikova, Veronika |
99 | 14904888 |
100 | SVK |
101 | 2185 |
102 | Vercinka |
103 |
104 |
105 | 11 |
106 | |
107 | Tropp, Oliver |
108 | 14913003 |
109 | SVK |
110 | 2159 |
111 | KKtreningKK |
112 |
113 |
114 | 12 |
115 | CM |
116 | Fizer, Marek |
117 | 391980 |
118 | CZE |
119 | 2126 |
120 | Mafi08 |
121 |
122 |
123 | 13 |
124 | |
125 | Salgovic, Simon |
126 | 14943581 |
127 | SVK |
128 | 2126 |
129 | s2305 |
130 |
131 |
132 | 14 |
133 | |
134 | Verbovsky, Michal |
135 | 14934213 |
136 | SVK |
137 | 2124 |
138 | mikhailooooo |
139 |
140 |
141 | 15 |
142 | WFM |
143 | Sankova, Stella |
144 | 14944200 |
145 | SVK |
146 | 2108 |
147 | pinkunicorn |
148 |
149 |
150 | 16 |
151 | |
152 | Koval, Jakub |
153 | 14933993 |
154 | SVK |
155 | 2106 |
156 | Kuklac858 |
157 |
158 |
159 | 17 |
160 | |
161 | Cisko, Matej |
162 | 14930412 |
163 | SVK |
164 | 2105 |
165 | mc12345mc |
166 |
167 |
168 | 18 |
169 | |
170 | Krupa, Miroslav |
171 | 14944154 |
172 | SVK |
173 | 2103 |
174 | HiAmUndaDaWata |
175 |
176 |
177 | 19 |
178 | |
179 | Bochnickova, Andrea |
180 | 14901781 |
181 | SVK |
182 | 2064 |
183 | Andrea1977 |
184 |
185 |
186 | 20 |
187 | |
188 | Pericka, Tomas |
189 | 14909081 |
190 | SVK |
191 | 2008 |
192 | pery85 |
193 |
194 |
195 | 21 |
196 | |
197 | Hurtuk, Radovan |
198 | 14935031 |
199 | SVK |
200 | 2000 |
201 | RadovanHurtuk |
202 |
203 |
204 | 22 |
205 | |
206 | Slamena, Michal |
207 | 14908840 |
208 | SVK |
209 | 2000 |
210 | Snowy_At_tacker |
211 |
212 |
213 | 23 |
214 | |
215 | Kolar, Tomas |
216 | 14909448 |
217 | SVK |
218 | 1995 |
219 | TOSO17 |
220 |
221 |
222 | 24 |
223 | |
224 | Vrba, Michal |
225 | 14909790 |
226 | SVK |
227 | 1984 |
228 | michalv |
229 |
230 |
231 | 25 |
232 | |
233 | Nemergut, Patrik |
234 | 14919524 |
235 | SVK |
236 | 1960 |
237 | NemkoP |
238 |
239 |
240 | 26 |
241 | |
242 | Mizicka, Matus |
243 | 14946238 |
244 | SVK |
245 | 1958 |
246 | nwb14 |
247 |
248 |
249 | 27 |
250 | |
251 | Diviak, Rastislav |
252 | 14908204 |
253 | SVK |
254 | 1956 |
255 | divoky |
256 |
257 |
258 | 28 |
259 | |
260 | Paverova, Zuzana |
261 | 14905310 |
262 | SVK |
263 | 1873 |
264 | suzi81 |
265 |
266 |
267 | 29 |
268 | WCM |
269 | Novomeska, Karin |
270 | 14952300 |
271 | SVK |
272 | 1851 |
273 | Karinka09 |
274 |
275 |
276 | 30 |
277 | |
278 | Horvath, Marek |
279 | 14913682 |
280 | SVK |
281 | 1801 |
282 | stein111 |
283 |
284 |
285 | 31 |
286 | |
287 | Fizerova, Lucie |
288 | 385115 |
289 | CZE |
290 | 1794 |
291 | Luckafizerova |
292 |
293 |
294 | 32 |
295 | |
296 | Matejka, Simon |
297 | 14974541 |
298 | SVK |
299 | 1792 |
300 | SIMONmatejka |
301 |
302 |
303 | 33 |
304 | |
305 | Brida, Lukas |
306 | 14920239 |
307 | SVK |
308 | 1779 |
309 | LukasB1988 |
310 |
311 |
312 | 34 |
313 | |
314 | Vozarova, Adriana |
315 | 14940752 |
316 | SVK |
317 | 1772 |
318 | avozarova |
319 |
320 |
321 | 35 |
322 | |
323 | Pericka, Pavol |
324 | 14914298 |
325 | SVK |
326 | 1743 |
327 | Palo60 |
328 |
329 |
330 | 36 |
331 | |
332 | Kramar, Matus |
333 | 14944618 |
334 | SVK |
335 | 1741 |
336 | Matko11111 |
337 |
338 |
339 | 37 |
340 | |
341 | Bucor, Mark |
342 | 14976340 |
343 | SVK |
344 | 1725 |
345 | FitzTheWorldHopper |
346 |
347 |
348 | 38 |
349 | |
350 | Pisarcik, Richard |
351 | 14967499 |
352 | SVK |
353 | 1708 |
354 | VanGerven |
355 |
356 |
357 | 39 |
358 | |
359 | Kukan, Michal |
360 | 14951967 |
361 | SVK |
362 | 1693 |
363 | mikodnv |
364 |
365 |
366 | 40 |
367 | |
368 | Hrdlicka, Martin |
369 | 14940876 |
370 | SVK |
371 | 1687 |
372 | hrdlis |
373 |
374 |
375 | 41 |
376 | |
377 | Mikula, Martin |
378 | 14941058 |
379 | SVK |
380 | 1657 |
381 | REW19 |
382 |
383 |
384 | 42 |
385 | |
386 | Jura, Michal |
387 | 73620882 |
388 | SVK |
389 | 1631 |
390 | Osim81 |
391 |
392 |
393 | 43 |
394 | |
395 | Kapinaj, Matus |
396 | 14980703 |
397 | SVK |
398 | 1606 |
399 | TheJeromeGambit |
400 |
401 |
402 | 44 |
403 | |
404 | Nemergut, Jan |
405 | 14948079 |
406 | SVK |
407 | 1573 |
408 | Nemergutsl |
409 |
410 |
411 | 45 |
412 | |
413 | Kison, Jan |
414 | 14949156 |
415 | SVK |
416 | 1571 |
417 | Kison |
418 |
419 |
420 | 46 |
421 | AFM |
422 | Nosal, Tobias |
423 | 14976293 |
424 | SVK |
425 | 1562 |
426 | Tobnos |
427 |
428 |
429 | 47 |
430 | |
431 | Paracka, Patrik |
432 | 14975467 |
433 | SVK |
434 | 1521 |
435 | x220616 |
436 |
437 |
438 | 48 |
439 | |
440 | Hutyra, Tomas |
441 | 23762683 |
442 | CZE |
443 | 1450 |
444 | JezhkovyVochi |
445 |
446 |
447 | 49 |
448 | |
449 | Karaba, Rudolf |
450 | 14967537 |
451 | SVK |
452 | 1335 |
453 | Rudy22 |
454 |
455 |
456 | 50 |
457 | |
458 | Gvizdova, Michala |
459 | 23713828 |
460 | CZE |
461 | 1305 |
462 | MishaGvi |
463 |
464 |
465 | 51 |
466 | |
467 | Koscelnikova, Monika |
468 | 14967073 |
469 | SVK |
470 | 1267 |
471 | monicka7 |
472 |
473 |
474 | 52 |
475 | |
476 | Ksenzigh, Adam |
477 | 14976471 |
478 | SVK |
479 | 1226 |
480 | AdamKO10 |
481 |
482 |
483 | 53 |
484 | |
485 | Rozboril, Alex |
486 | 14972638 |
487 | SVK |
488 | 1221 |
489 | alexr199 |
490 |
491 |
492 | 54 |
493 | |
494 | Novotny, Tomas |
495 | 14951126 |
496 | SVK |
497 | 1135 |
498 | Tomas_Novotny |
499 |
500 |
501 | 55 |
502 | |
503 | Kicura, Richard |
504 | 14985756 |
505 | SVK |
506 | 1111 |
507 | RichardKicura |
508 |
509 |
510 | 56 |
511 | |
512 | Busfy, Svetozar |
513 | |
514 | SVK |
515 | 0 |
516 | svebus |
517 |
518 |
519 | 57 |
520 | |
521 | Cafal, Pavol |
522 | |
523 | SVK |
524 | 0 |
525 | calfa7 |
526 |
527 |
528 | 58 |
529 | |
530 | Falat, Tomas |
531 | |
532 | SVK |
533 | 0 |
534 | Tomas1000 |
535 |
536 |
537 | 59 |
538 | |
539 | Flajs, Marek |
540 | |
541 | SVK |
542 | 0 |
543 | fidzi |
544 |
545 |
546 | 60 |
547 | |
548 | Hrdy, Milos |
549 | 14984792 |
550 | SVK |
551 | 0 |
552 | Milos1661 |
553 |
554 |
555 | 61 |
556 | |
557 | Jancik, Peter |
558 | |
559 | SVK |
560 | 0 |
561 | Petrik124 |
562 |
563 |
564 | 62 |
565 | |
566 | Kohutiar, Milan |
567 | 14965011 |
568 | SVK |
569 | 0 |
570 | milkoh |
571 |
572 |
573 | 63 |
574 | |
575 | Mlynarcik, Marian |
576 | |
577 | SVK |
578 | 0 |
579 | silason |
580 |
581 |
582 | 64 |
583 | |
584 | Noga, Marian |
585 | |
586 | SVK |
587 | 0 |
588 | noger79 |
589 |
590 |
591 | 65 |
592 | |
593 | Nosal, Peter |
594 | 14982102 |
595 | SVK |
596 | 0 |
597 | Nosso7 |
598 |
599 |
600 | 66 |
601 | |
602 | Roman, Milan |
603 | 14958651 |
604 | SVK |
605 | 0 |
606 | MilanRoman |
607 |
608 |
609 | 67 |
610 | |
611 | Roth, Marian |
612 | |
613 | SVK |
614 | 0 |
615 | hrac123456 |
616 |
617 |
618 | 68 |
619 | |
620 | Sausa, Jakub |
621 | |
622 | SVK |
623 | 0 |
624 | ChokablockSK |
625 |
626 |
627 | 69 |
628 | |
629 | Sramko, Tobias |
630 | |
631 | SVK |
632 | 0 |
633 | TobiBrooo |
634 |
635 |
636 | 70 |
637 | |
638 | Tatarin, Mykola |
639 | |
640 | |
641 | 0 |
642 | Nik20002353 |
643 |
644 |
645 | 71 |
646 | |
647 | Toriska, Juraj |
648 | |
649 | SVK |
650 | 0 |
651 | xQ72w |
652 |
653 |
654 |
655 |
--------------------------------------------------------------------------------
/src/scraper/tests/fixtures/team-round-robin-pairings.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Round 1 |
7 |
8 |
9 | Bo. |
10 | 1 |
11 | Team 4 |
12 | Rtg |
13 | Club/City |
14 | - |
15 | 6 |
16 | Team 2 |
17 | Rtg |
18 | Club/City |
19 | 0 : 0 |
20 |
21 |
22 | 1.1 |
23 | WIM |
24 |
25 |
35 | |
36 | 2100 |
37 | Testacct31 |
38 | - |
39 | |
40 |
41 |
51 | |
52 | 0 |
53 | Testacct11 |
54 | |
55 |
56 |
57 | 1.2 |
58 | |
59 |
60 |
70 | |
71 | 0 |
72 | Testacct33 |
73 | - |
74 | |
75 |
76 |
86 | |
87 | 1670 |
88 | Testacct12 |
89 | |
90 |
91 |
92 | 1.3 |
93 | IM |
94 |
95 |
105 | |
106 | 2300 |
107 | Testacct32 |
108 | - |
109 | GM |
110 |
111 |
121 | |
122 | 2670 |
123 | Testacct13 |
124 | |
125 |
126 |
127 | 1.4 |
128 | |
129 |
130 |
140 | |
141 | 2121 |
142 | Testacct34 |
143 | - |
144 | |
145 |
146 |
156 | |
157 | 0 |
158 | Testacct14 |
159 | |
160 |
161 |
162 | Bo. |
163 | 2 |
164 | Team 1 |
165 | Rtg |
166 | Club/City |
167 | - |
168 | 5 |
169 | Team 6 |
170 | Rtg |
171 | Club/City |
172 | 0 : 0 |
173 |
174 |
175 | 2.1 |
176 | WGM |
177 |
178 |
188 | |
189 | 2300 |
190 | Testacct1 |
191 | - |
192 | |
193 |
194 |
206 | |
207 | 1700 |
208 | Testacct51 |
209 | |
210 |
211 |
212 | 2.2 |
213 | AIM |
214 |
215 |
216 |
217 |
218 | |
219 |
220 | Testad, fsads
221 | |
222 |
223 |
224 |
225 | |
226 | 1670 |
227 | Testacct2 |
228 | - |
229 | |
230 |
231 |
243 | |
244 | 2100 |
245 | Testacct52 |
246 | |
247 |
248 |
249 | 2.3 |
250 | WFM |
251 |
252 |
262 | |
263 | 2200 |
264 | Testacct4 |
265 | - |
266 | |
267 |
268 |
278 | |
279 | 1565 |
280 | Testacct54 |
281 | |
282 |
283 |
284 | 2.4 |
285 | |
286 |
287 |
297 | |
298 | 1450 |
299 | Testacct3 |
300 | - |
301 | |
302 |
303 |
315 | |
316 | 2222 |
317 | Testacct53 |
318 | |
319 |
320 |
321 | Bo. |
322 | 3 |
323 | Team 5 |
324 | Rtg |
325 | Club/City |
326 | - |
327 | 4 |
328 | Team 3 |
329 | Rtg |
330 | Club/City |
331 | 0 : 0 |
332 |
333 |
334 | 3.1 |
335 | |
336 |
337 |
349 | |
350 | 1989 |
351 | Testacct41 |
352 | - |
353 | |
354 |
355 |
367 | |
368 | 0 |
369 | Testacct21 |
370 | |
371 |
372 |
373 | 3.2 |
374 | |
375 |
376 |
386 | |
387 | 0 |
388 | Testacct43 |
389 | - |
390 | CM |
391 |
392 |
402 | |
403 | 1965 |
404 | Testacct23 |
405 | |
406 |
407 |
408 | 3.3 |
409 | IM |
410 |
411 |
423 | |
424 | 2400 |
425 | Testacct44 |
426 | - |
427 | |
428 |
429 |
439 | |
440 | 1600 |
441 | Testacct24 |
442 | |
443 |
444 |
445 | 3.4 |
446 | |
447 |
448 |
460 | |
461 | 0 |
462 | Testacct42 |
463 | - |
464 | WCM |
465 |
466 |
476 | |
477 | 1999 |
478 | Testacct22 |
479 | |
480 |
481 |
482 |
483 |
--------------------------------------------------------------------------------
/src/scraper/tests/fixtures/team-swiss-pairings-with-usernames-2.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Round 1 |
7 |
8 |
9 | Bo. |
10 | 3 |
11 | Team 2 |
12 | Rtg |
13 | Club/City |
14 | - |
15 | 1 |
16 | Team 3 |
17 | Rtg |
18 | Club/City |
19 | 0 : 0 |
20 |
21 |
22 | 1.1 |
23 | |
24 |
25 |
35 | |
36 | 0 |
37 | cynosure |
38 | - |
39 | |
40 |
41 |
51 | |
52 | 0 |
53 | ttrv |
54 | |
55 |
56 |
57 | 1.2 |
58 | |
59 |
60 |
70 | |
71 | 0 |
72 | e4 |
73 | - |
74 | IM |
75 |
76 |
86 | |
87 | 2400 |
88 | lovlas |
89 | |
90 |
91 |
92 | Bo. |
93 | 2 |
94 | Team 1 |
95 | Rtg |
96 | Club/City |
97 | - |
98 | 4 |
99 | Team 4 |
100 | Rtg |
101 | Club/City |
102 | 0 : 0 |
103 |
104 |
105 | 2.1 |
106 | |
107 |
108 |
118 | |
119 | 0 |
120 | thibault |
121 | - |
122 | |
123 |
124 |
134 | |
135 | 0 |
136 | carpentum |
137 | |
138 |
139 |
140 | 2.2 |
141 | |
142 |
143 |
153 | |
154 | 0 |
155 | neio |
156 | - |
157 | |
158 |
159 |
171 | |
172 | 0 |
173 | Puzzlingpuzzler |
174 | |
175 |
176 |
177 |
178 |
--------------------------------------------------------------------------------
/src/scraper/tests/fixtures/team-swiss-pairings-with-usernames.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Round 1 |
7 |
8 |
9 | Bo. |
10 | 1 |
11 | Team B |
12 | Rtg |
13 | Club/City |
14 | - |
15 | 3 |
16 | Team C |
17 | Rtg |
18 | Club/City |
19 | 0 : 0 |
20 |
21 |
22 | 1.1 |
23 | WFM |
24 |
25 |
35 | |
36 | 1985 |
37 | test134 |
38 | - |
39 | FM |
40 |
41 |
51 | |
52 | 2227 |
53 | test4 |
54 | |
55 |
56 |
57 | 1.2 |
58 | IM |
59 |
60 |
70 | |
71 | 2400 |
72 | test3 |
73 | - |
74 | |
75 |
76 |
86 | |
87 | 0 |
88 | test5 |
89 | |
90 |
91 |
92 | 1.3 |
93 | |
94 |
95 |
105 | |
106 | 1900 |
107 | test1 |
108 | - |
109 | |
110 |
111 |
121 | |
122 | 1600 |
123 | test6 |
124 | |
125 |
126 |
127 | 1.4 |
128 | |
129 |
130 |
131 |
132 |
133 | |
134 |
135 | Ignore, This
136 | |
137 |
138 |
139 |
140 | |
141 | 1400 |
142 | test2 |
143 | - |
144 | |
145 |
146 |
156 | |
157 | 0 |
158 | test7 |
159 | |
160 |
161 |
162 | Bo. |
163 | 4 |
164 | Team A |
165 | Rtg |
166 | Club/City |
167 | - |
168 | 2 |
169 | Team D |
170 | Rtg |
171 | Club/City |
172 | 0 : 0 |
173 |
174 |
175 | 2.1 |
176 | |
177 |
178 |
188 | |
189 | 0 |
190 | Cynosure |
191 | - |
192 | FM |
193 |
194 |
204 | |
205 | 2230 |
206 | TestAccount1 |
207 | |
208 |
209 |
210 | 2.2 |
211 | |
212 |
213 |
214 |
215 |
216 | |
217 |
218 | Thibault, D
219 | |
220 |
221 |
222 |
223 | |
224 | 0 |
225 | Thibault |
226 | - |
227 | WCM |
228 |
229 |
239 | |
240 | 2070 |
241 | TestAccount2 |
242 | |
243 |
244 |
245 | 2.3 |
246 | AFM |
247 |
248 |
249 |
250 |
251 | |
252 |
253 | Gkizi, Konst
254 | |
255 |
256 |
257 |
258 | |
259 | 1270 |
260 | Puzzlingpuzzler |
261 | - |
262 | |
263 |
264 |
274 | |
275 | 1300 |
276 | TestAccount3 |
277 | |
278 |
279 |
280 | 2.4 |
281 | |
282 |
283 |
293 | |
294 | 0 |
295 | ThisAccountDoesntExist |
296 | - |
297 | |
298 |
299 |
300 |
301 |
302 | |
303 |
304 | Also, Unknown
305 | |
306 |
307 |
308 |
309 | |
310 | 1111 |
311 | TestAccount4 |
312 | |
313 |
314 |
315 |
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 |
--------------------------------------------------------------------------------
|