hkjc 0.3.21__tar.gz → 0.3.22__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hkjc
3
- Version: 0.3.21
3
+ Version: 0.3.22
4
4
  Summary: Library for scrapping HKJC data and perform basic analysis
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: beautifulsoup4>=4.14.2
@@ -32,21 +32,24 @@ app = Flask(__name__)
32
32
  def disp_race_info():
33
33
  race_info = _fetch_live_races('', '')
34
34
 
35
- df_speedpro = speedpro_energy(race_info['Date'])
36
- for race_num, race in race_info['Races'].items():
37
- for i, runner in enumerate(race['Runners']):
38
- df = (df_speedpro
39
- .filter(pl.col('RaceNo') == race_num)
40
- .filter(pl.col('RunnerNumber') == int(runner['No']))
41
- )
42
- race_info['Races'][race_num]['Runners'][i]['SPEnergy'] = df['SpeedPRO_Energy_Difference'].item(
43
- 0)
44
- race_info['Races'][race_num]['Runners'][i]['Fitness'] = df['FitnessRatings'].item(
45
- 0)
46
-
47
- # TODO: add horse running style favorite from horse info
48
- # get_horse_data
49
- # pack favorite running style
35
+ try:
36
+ df_speedpro = speedpro_energy(race_info['Date'])
37
+ for race_num, race in race_info['Races'].items():
38
+ for i, runner in enumerate(race['Runners']):
39
+ df = (df_speedpro
40
+ .filter(pl.col('RaceNo') == race_num)
41
+ .filter(pl.col('RunnerNumber') == int(runner['No']))
42
+ )
43
+ race_info['Races'][race_num]['Runners'][i]['SPEnergy'] = df['SpeedPRO_Energy_Difference'].item(
44
+ 0)
45
+ race_info['Races'][race_num]['Runners'][i]['Fitness'] = df['FitnessRatings'].item(
46
+ 0)
47
+ except: # fill with dummy value if SpeedPro not available
48
+ for race_num, race in race_info['Races'].items():
49
+ for i, runner in enumerate(race['Runners']):
50
+ race_info['Races'][race_num]['Runners'][i]['SPEnergy'] = 0
51
+ race_info['Races'][race_num]['Runners'][i]['Fitness'] = 0
52
+
50
53
  return render_template('index.html',
51
54
  race_info=race_info)
52
55
 
@@ -6,10 +6,41 @@
6
6
  // CONFIGURATION
7
7
  // ============================================================================
8
8
  const CONFIG = {
9
- ODDS_POLL_INTERVAL: 90000, // 90 seconds
10
- REQUEST_DELAY: 100, // Delay between sequential requests (ms)
9
+ ODDS_POLL_INTERVAL: 90000, // 90 seconds (base)
10
+ POLL_JITTER: 10000, // ±5 seconds jitter for polling
11
+ MIN_REQUEST_DELAY: 80, // Min delay between requests (ms)
12
+ MAX_REQUEST_DELAY: 250, // Max delay between requests (ms)
11
13
  MAX_CONCURRENT_REQUESTS: 3, // Max simultaneous requests
12
- REQUEST_TIMEOUT: 10000 // 10 second timeout
14
+ REQUEST_TIMEOUT: 10000, // 10 second timeout
15
+ DEBUG: false // Enable debug logging
16
+ };
17
+
18
+ // ============================================================================
19
+ // UTILITIES
20
+ // ============================================================================
21
+ const Utils = {
22
+ /**
23
+ * Get runner row data from HTML element
24
+ */
25
+ getRunnerData(row) {
26
+ return {
27
+ raceNum: row.dataset.race,
28
+ horseNo: row.dataset.horse,
29
+ going: row.dataset.going || '',
30
+ track: row.dataset.track || '',
31
+ distance: row.dataset.dist || '',
32
+ runnerNo: row.querySelector('.odds-win')?.dataset.horse || row.dataset.horse
33
+ };
34
+ },
35
+
36
+ /**
37
+ * Conditional debug logging
38
+ */
39
+ log(...args) {
40
+ if (CONFIG.DEBUG) {
41
+ console.log(...args);
42
+ }
43
+ }
13
44
  };
14
45
 
15
46
  // ============================================================================
@@ -24,7 +55,7 @@ const State = {
24
55
  polling: {
25
56
  oddsIntervalId: null
26
57
  },
27
- activeRequests: new Set()
58
+ raceReadiness: new Map()
28
59
  };
29
60
 
30
61
  // ============================================================================
@@ -34,9 +65,6 @@ const RequestQueue = {
34
65
  queue: [],
35
66
  activeCount: 0,
36
67
 
37
- /**
38
- * Add request to queue with priority support
39
- */
40
68
  async enqueue(requestFn, priority = 0) {
41
69
  return new Promise((resolve, reject) => {
42
70
  this.queue.push({ requestFn, resolve, reject, priority });
@@ -45,9 +73,6 @@ const RequestQueue = {
45
73
  });
46
74
  },
47
75
 
48
- /**
49
- * Process queued requests with concurrency limit
50
- */
51
76
  async process() {
52
77
  if (this.activeCount >= CONFIG.MAX_CONCURRENT_REQUESTS || this.queue.length === 0) {
53
78
  return;
@@ -63,22 +88,19 @@ const RequestQueue = {
63
88
  reject(error);
64
89
  } finally {
65
90
  this.activeCount--;
66
- // Small delay between requests
67
- await this.delay(CONFIG.REQUEST_DELAY);
91
+ await this.delay(this.getRandomDelay());
68
92
  this.process();
69
93
  }
70
94
  },
71
95
 
72
- /**
73
- * Clear all pending requests
74
- */
96
+ getRandomDelay() {
97
+ return Math.random() * (CONFIG.MAX_REQUEST_DELAY - CONFIG.MIN_REQUEST_DELAY) + CONFIG.MIN_REQUEST_DELAY;
98
+ },
99
+
75
100
  clear() {
76
101
  this.queue = [];
77
102
  },
78
103
 
79
- /**
80
- * Helper delay function
81
- */
82
104
  delay(ms) {
83
105
  return new Promise(resolve => setTimeout(resolve, ms));
84
106
  }
@@ -88,9 +110,6 @@ const RequestQueue = {
88
110
  // API MODULE - All server communication
89
111
  // ============================================================================
90
112
  const API = {
91
- /**
92
- * Generic fetch wrapper with timeout and error handling
93
- */
94
113
  async fetch(url, options = {}) {
95
114
  const controller = new AbortController();
96
115
  const timeoutId = setTimeout(() => controller.abort(), CONFIG.REQUEST_TIMEOUT);
@@ -117,19 +136,13 @@ const API = {
117
136
  }
118
137
  },
119
138
 
120
- /**
121
- * Fetch live odds for a race
122
- */
123
139
  async fetchOdds(raceNum) {
124
140
  return RequestQueue.enqueue(async () => {
125
141
  const response = await this.fetch(`/live_odds/${raceNum}`);
126
142
  return await response.json();
127
- }, 1); // Priority 1 for odds
143
+ }, 1);
128
144
  },
129
145
 
130
- /**
131
- * Fetch horse history
132
- */
133
146
  async fetchHorseHistory(horseNo, going, track, distance) {
134
147
  return RequestQueue.enqueue(async () => {
135
148
  const params = new URLSearchParams({
@@ -139,17 +152,14 @@ const API = {
139
152
  });
140
153
  const response = await this.fetch(`/horse_info/${horseNo}?${params}`);
141
154
  return await response.text();
142
- }, 0); // Priority 0 for history
155
+ }, 0);
143
156
  },
144
157
 
145
- /**
146
- * Fetch speedmap
147
- */
148
158
  async fetchSpeedmap(raceNum) {
149
159
  return RequestQueue.enqueue(async () => {
150
160
  const response = await this.fetch(`/speedmap/${raceNum}`);
151
161
  return await response.text();
152
- }, 0); // Priority 0 for speedmaps
162
+ }, 0);
153
163
  }
154
164
  };
155
165
 
@@ -181,14 +191,51 @@ const Cache = {
181
191
  }
182
192
  };
183
193
 
194
+ // ============================================================================
195
+ // RACE READINESS MODULE - Check if speed pro data is available
196
+ // ============================================================================
197
+ const RaceReadiness = {
198
+ isReady(raceNum) {
199
+ if (State.raceReadiness.has(raceNum)) {
200
+ return State.raceReadiness.get(raceNum);
201
+ }
202
+
203
+ const ready = this.checkRaceData(raceNum);
204
+ State.raceReadiness.set(raceNum, ready);
205
+ return ready;
206
+ },
207
+
208
+ checkRaceData(raceNum) {
209
+ const raceContent = document.getElementById(`race-${raceNum}`);
210
+ if (!raceContent) return false;
211
+
212
+ const runnerRows = raceContent.querySelectorAll('.runner-row');
213
+ if (runnerRows.length === 0) return false;
214
+
215
+ // Check if any runner has non-zero fitness or energy
216
+ return Array.from(runnerRows).some(row => {
217
+ const fitness = parseFloat(row.cells[8]?.textContent.trim()) || 0;
218
+ const energy = parseFloat(row.cells[9]?.textContent.trim()) || 0;
219
+ return fitness !== 0 || energy !== 0;
220
+ });
221
+ },
222
+
223
+ recheck(raceNum) {
224
+ State.raceReadiness.delete(raceNum);
225
+ return this.isReady(raceNum);
226
+ }
227
+ };
228
+
184
229
  // ============================================================================
185
230
  // ODDS MODULE - Live odds management
186
231
  // ============================================================================
187
232
  const Odds = {
188
- /**
189
- * Fetch and cache odds for a race
190
- */
191
233
  async load(raceNum) {
234
+ if (!RaceReadiness.isReady(raceNum)) {
235
+ Utils.log(`Skipping odds for race ${raceNum} - race not ready`);
236
+ return null;
237
+ }
238
+
192
239
  try {
193
240
  const data = await API.fetchOdds(raceNum);
194
241
  Cache.set('odds', raceNum, data);
@@ -200,33 +247,23 @@ const Odds = {
200
247
  }
201
248
  },
202
249
 
203
- /**
204
- * Update odds display in UI
205
- */
206
250
  updateDisplay(raceNum, oddsData) {
207
251
  if (!oddsData?.Raw || !oddsData?.Fit) return;
208
252
 
209
- // Update WIN columns
210
253
  this.updateCells(`.odds-win[data-race="${raceNum}"]`, oddsData.Raw.WIN);
211
254
  this.updateCells(`.odds-win-fit[data-race="${raceNum}"]`, oddsData.Fit.WIN);
212
-
213
- // Update PLA columns
214
255
  this.updateCells(`.odds-pla[data-race="${raceNum}"]`, oddsData.Raw.PLA);
215
256
  this.updateCells(`.odds-pla-fit[data-race="${raceNum}"]`, oddsData.Fit.PLA);
216
257
 
217
- // Update odds tables in expanded sections
218
258
  document.querySelectorAll(`.odds-table[data-race="${raceNum}"]`).forEach(table => {
219
- const horseNo = table.getAttribute('data-horse');
259
+ const horseNo = table.dataset.horse;
220
260
  this.updateOddsTable(table, horseNo, oddsData);
221
261
  });
222
262
  },
223
263
 
224
- /**
225
- * Helper to update cells with odds data
226
- */
227
264
  updateCells(selector, oddsData) {
228
265
  document.querySelectorAll(selector).forEach(cell => {
229
- const horseNo = cell.getAttribute('data-horse');
266
+ const horseNo = cell.dataset.horse;
230
267
  const odds = oddsData?.[horseNo];
231
268
  if (odds !== undefined) {
232
269
  cell.textContent = odds;
@@ -234,9 +271,6 @@ const Odds = {
234
271
  });
235
272
  },
236
273
 
237
- /**
238
- * Update combination odds table
239
- */
240
274
  updateOddsTable(table, horseNo, oddsData) {
241
275
  const allHorses = Object.keys(oddsData.Raw.WIN || {}).sort((a, b) => parseInt(a) - parseInt(b));
242
276
  const otherHorses = allHorses.filter(h => h !== horseNo);
@@ -254,15 +288,12 @@ const Odds = {
254
288
  });
255
289
  };
256
290
 
257
- updateRow(0, oddsData.Raw.QIN); // QIN
258
- updateRow(1, oddsData.Fit.QIN); // QIN (fit)
259
- updateRow(2, oddsData.Raw.QPL); // QPL
260
- updateRow(3, oddsData.Fit.QPL); // QPL (fit)
291
+ updateRow(0, oddsData.Raw.QIN);
292
+ updateRow(1, oddsData.Fit.QIN);
293
+ updateRow(2, oddsData.Raw.QPL);
294
+ updateRow(3, oddsData.Fit.QPL);
261
295
  },
262
296
 
263
- /**
264
- * Create HTML for odds section
265
- */
266
297
  createSection(raceNum, horseNo, oddsData) {
267
298
  if (!oddsData?.Raw || !oddsData?.Fit) return '';
268
299
 
@@ -271,15 +302,6 @@ const Odds = {
271
302
 
272
303
  if (otherHorses.length === 0) return '';
273
304
 
274
- let html = '<div class="odds-table-container">';
275
- html += `<table class="odds-table" data-race="${raceNum}" data-horse="${horseNo}">`;
276
- html += '<thead><tr><th>Type</th>';
277
-
278
- // Header row
279
- otherHorses.forEach(h => html += `<th>vs ${h}</th>`);
280
- html += '</tr></thead><tbody>';
281
-
282
- // Data rows
283
305
  const createRow = (label, data) => {
284
306
  let row = `<tr><td class="odds-type-label">${label}</td>`;
285
307
  otherHorses.forEach(h => {
@@ -289,18 +311,26 @@ const Odds = {
289
311
  return row + '</tr>';
290
312
  };
291
313
 
292
- html += createRow('QIN', oddsData.Raw.QIN);
293
- html += createRow('QIN (fit)', oddsData.Fit.QIN);
294
- html += createRow('QPL', oddsData.Raw.QPL);
295
- html += createRow('QPL (fit)', oddsData.Fit.QPL);
296
-
297
- html += '</tbody></table></div>';
298
- return html;
314
+ return `
315
+ <div class="odds-table-container">
316
+ <table class="odds-table" data-race="${raceNum}" data-horse="${horseNo}">
317
+ <thead>
318
+ <tr>
319
+ <th>Type</th>
320
+ ${otherHorses.map(h => `<th>vs ${h}</th>`).join('')}
321
+ </tr>
322
+ </thead>
323
+ <tbody>
324
+ ${createRow('QIN', oddsData.Raw.QIN)}
325
+ ${createRow('QIN (fit)', oddsData.Fit.QIN)}
326
+ ${createRow('QPL', oddsData.Raw.QPL)}
327
+ ${createRow('QPL (fit)', oddsData.Fit.QPL)}
328
+ </tbody>
329
+ </table>
330
+ </div>
331
+ `;
299
332
  },
300
333
 
301
- /**
302
- * Start polling for live odds
303
- */
304
334
  startPolling() {
305
335
  const allRaceNums = Array.from(document.querySelectorAll('.race-content'))
306
336
  .map(el => el.id.replace('race-', ''));
@@ -310,21 +340,26 @@ const Odds = {
310
340
 
311
341
  // Clear existing interval
312
342
  if (State.polling.oddsIntervalId) {
313
- clearInterval(State.polling.oddsIntervalId);
343
+ clearTimeout(State.polling.oddsIntervalId);
314
344
  }
315
345
 
316
- // Set up polling
317
- State.polling.oddsIntervalId = setInterval(() => {
318
- allRaceNums.forEach(raceNum => this.load(raceNum));
319
- }, CONFIG.ODDS_POLL_INTERVAL);
346
+ // Set up polling with jitter
347
+ const scheduleNextPoll = () => {
348
+ const jitter = Math.random() * CONFIG.POLL_JITTER - (CONFIG.POLL_JITTER / 2);
349
+ const nextInterval = CONFIG.ODDS_POLL_INTERVAL + jitter;
350
+
351
+ State.polling.oddsIntervalId = setTimeout(() => {
352
+ allRaceNums.forEach(raceNum => this.load(raceNum));
353
+ scheduleNextPoll();
354
+ }, nextInterval);
355
+ };
356
+
357
+ scheduleNextPoll();
320
358
  },
321
359
 
322
- /**
323
- * Stop polling
324
- */
325
360
  stopPolling() {
326
361
  if (State.polling.oddsIntervalId) {
327
- clearInterval(State.polling.oddsIntervalId);
362
+ clearTimeout(State.polling.oddsIntervalId);
328
363
  State.polling.oddsIntervalId = null;
329
364
  }
330
365
  }
@@ -334,19 +369,86 @@ const Odds = {
334
369
  // RUNNER DETAILS MODULE - Horse history and details
335
370
  // ============================================================================
336
371
  const RunnerDetails = {
337
- /**
338
- * Load runner details (history)
339
- */
372
+ extractRunningStyles(historyHtml) {
373
+ const parser = new DOMParser();
374
+ const doc = parser.parseFromString(historyHtml, 'text/html');
375
+ const table = doc.querySelector('table');
376
+
377
+ if (!table) return [];
378
+
379
+ // Find Style column
380
+ const headerRow = table.querySelector('thead tr');
381
+ if (!headerRow) return [];
382
+
383
+ const headers = Array.from(headerRow.querySelectorAll('th'));
384
+ const styleColumnIndex = headers.findIndex(th =>
385
+ th.textContent.trim().toLowerCase() === 'style'
386
+ );
387
+
388
+ if (styleColumnIndex === -1) return [];
389
+
390
+ // Extract styles from top 5 rows
391
+ const rows = Array.from(table.querySelectorAll('tbody tr'));
392
+ const styles = [];
393
+
394
+ for (let i = 0; i < Math.min(5, rows.length); i++) {
395
+ const cells = rows[i].querySelectorAll('td');
396
+ if (styleColumnIndex < cells.length) {
397
+ const styleText = cells[styleColumnIndex].textContent.trim();
398
+ if (styleText) {
399
+ styles.push(styleText);
400
+ }
401
+ }
402
+ }
403
+
404
+ return styles;
405
+ },
406
+
407
+ calculateMode(arr) {
408
+ if (!arr || arr.length === 0) return null;
409
+
410
+ const frequency = {};
411
+ let maxFreq = 0;
412
+ let mode = null;
413
+
414
+ arr.forEach(value => {
415
+ frequency[value] = (frequency[value] || 0) + 1;
416
+ if (frequency[value] > maxFreq) {
417
+ maxFreq = frequency[value];
418
+ mode = value;
419
+ }
420
+ });
421
+
422
+ return mode;
423
+ },
424
+
425
+ updateFavStyle(raceNum, horseNo, historyHtml) {
426
+ const cell = document.querySelector(`.fav-style[data-race="${raceNum}"][data-horse="${horseNo}"]`);
427
+ if (!cell) return;
428
+
429
+ try {
430
+ const styles = this.extractRunningStyles(historyHtml);
431
+ const favStyle = this.calculateMode(styles);
432
+ cell.textContent = favStyle || '-';
433
+ } catch (error) {
434
+ console.error(`Error updating fav style for horse ${horseNo}:`, error);
435
+ cell.textContent = '-';
436
+ }
437
+ },
438
+
340
439
  async load(raceNum, horseNo, going, track, distance) {
341
440
  const cacheKey = `${raceNum}-${horseNo}`;
342
441
 
343
442
  if (Cache.has('runnerDetails', cacheKey)) {
344
- return Cache.get('runnerDetails', cacheKey);
443
+ const cachedHtml = Cache.get('runnerDetails', cacheKey);
444
+ this.updateFavStyle(raceNum, horseNo, cachedHtml);
445
+ return cachedHtml;
345
446
  }
346
447
 
347
448
  try {
348
449
  const html = await API.fetchHorseHistory(horseNo, going, track, distance);
349
450
  Cache.set('runnerDetails', cacheKey, html);
451
+ this.updateFavStyle(raceNum, horseNo, html);
350
452
  return html;
351
453
  } catch (error) {
352
454
  console.error(`Error loading history for horse ${horseNo}:`, error);
@@ -354,52 +456,29 @@ const RunnerDetails = {
354
456
  }
355
457
  },
356
458
 
357
- /**
358
- * Create complete details content (odds + history)
359
- */
360
459
  createContent(raceNum, runnerNo, horseNo, oddsData, historyHtml) {
361
- let content = '<div class="runner-details-wrapper">';
362
-
363
- // Add odds section if available
364
- if (oddsData) {
365
- content += '<div class="details-left">';
366
- content += Odds.createSection(raceNum, runnerNo, oddsData);
367
- content += '</div>';
368
- }
369
-
370
- // Add history section
371
- content += '<div class="details-right">';
372
- content += historyHtml;
373
- content += '</div>';
374
-
375
- content += '</div>';
376
-
377
- return content;
460
+ const oddsSection = oddsData ? `<div class="details-left">${Odds.createSection(raceNum, runnerNo, oddsData)}</div>` : '';
461
+ const historySection = `<div class="details-right">${historyHtml}</div>`;
462
+ return `<div class="runner-details-wrapper">${oddsSection}${historySection}</div>`;
378
463
  },
379
464
 
380
- /**
381
- * Toggle single runner details
382
- */
383
465
  async toggle(rowElement, raceNum, horseNo, going, track, distance) {
384
466
  const detailsRow = document.getElementById(`runner-details-${raceNum}-${horseNo}`);
385
467
  const isExpanded = detailsRow.classList.contains('expanded');
386
468
 
387
469
  if (isExpanded) {
388
- // Collapse
389
470
  detailsRow.classList.remove('expanded');
390
471
  rowElement.querySelector('.expand-icon').classList.remove('expanded');
391
472
  } else {
392
- // Expand
393
473
  detailsRow.classList.add('expanded');
394
474
  rowElement.querySelector('.expand-icon').classList.add('expanded');
395
475
 
396
476
  const detailsContent = document.getElementById(`runner-details-content-${raceNum}-${horseNo}`);
397
- const runnerNo = rowElement.querySelector('.odds-win')?.getAttribute('data-horse') || horseNo;
477
+ const runnerNo = rowElement.querySelector('.odds-win')?.dataset.horse || horseNo;
398
478
  const oddsData = Cache.get('odds', raceNum);
399
479
  const cacheKey = `${raceNum}-${horseNo}`;
400
480
 
401
481
  if (!Cache.has('runnerDetails', cacheKey)) {
402
- // Show loading state
403
482
  detailsContent.innerHTML = this.createContent(
404
483
  raceNum, runnerNo, horseNo, oddsData,
405
484
  '<div class="details-loading">Loading race history...</div>'
@@ -411,7 +490,7 @@ const RunnerDetails = {
411
490
  } catch (error) {
412
491
  detailsContent.innerHTML = this.createContent(
413
492
  raceNum, runnerNo, horseNo, oddsData,
414
- `<div class="details-loading error">Error loading race history: ${error.message}</div>`
493
+ `<div class="details-loading error">Error: ${error.message}</div>`
415
494
  );
416
495
  }
417
496
  } else {
@@ -423,100 +502,111 @@ const RunnerDetails = {
423
502
  UI.updateExpandAllButtonState(raceNum);
424
503
  },
425
504
 
426
- /**
427
- * Toggle all runners with sequential loading
428
- */
505
+ async loadRunnerData(raceNum, runnerData, oddsData) {
506
+ const { horseNo, runnerNo, going, track, distance } = runnerData;
507
+ const cacheKey = `${raceNum}-${horseNo}`;
508
+ const detailsContent = document.getElementById(`runner-details-content-${raceNum}-${horseNo}`);
509
+ const detailsRow = document.getElementById(`runner-details-${raceNum}-${horseNo}`);
510
+
511
+ if (!Cache.has('runnerDetails', cacheKey)) {
512
+ try {
513
+ const historyHtml = await this.load(raceNum, horseNo, going, track, distance);
514
+
515
+ if (detailsRow.classList.contains('expanded')) {
516
+ const currentOddsData = Cache.get('odds', raceNum);
517
+ detailsContent.innerHTML = this.createContent(raceNum, runnerNo, horseNo, currentOddsData, historyHtml);
518
+ }
519
+ } catch (error) {
520
+ if (detailsRow.classList.contains('expanded')) {
521
+ const currentOddsData = Cache.get('odds', raceNum);
522
+ detailsContent.innerHTML = this.createContent(
523
+ raceNum, runnerNo, horseNo, currentOddsData,
524
+ `<div class="details-loading error">Error: ${error.message}</div>`
525
+ );
526
+ }
527
+ }
528
+ }
529
+ },
530
+
429
531
  async toggleAll(raceNum, buttonElement) {
430
532
  const raceContent = document.getElementById(`race-${raceNum}`);
431
533
  const runnerRows = Array.from(raceContent.querySelectorAll('.runner-row'));
432
534
  const isExpanding = !buttonElement.classList.contains('all-expanded');
433
535
 
434
536
  if (isExpanding) {
435
- // Expand all
436
537
  const oddsData = Cache.get('odds', raceNum);
437
538
 
438
- // First, expand UI immediately for all runners
539
+ // Expand UI immediately for all runners
439
540
  const runners = runnerRows.map(row => {
440
- const params = row.getAttribute('onclick').match(/'([^']+)'/g).map(s => s.replace(/'/g, ''));
441
- const horseNo = params[1];
442
- const runnerNo = row.querySelector('.odds-win')?.getAttribute('data-horse') || horseNo;
443
- const detailsRow = document.getElementById(`runner-details-${raceNum}-${horseNo}`);
444
- const detailsContent = document.getElementById(`runner-details-content-${raceNum}-${horseNo}`);
445
- const cacheKey = `${raceNum}-${horseNo}`;
541
+ const runnerData = Utils.getRunnerData(row);
542
+ const cacheKey = `${raceNum}-${runnerData.horseNo}`;
543
+ const detailsRow = document.getElementById(`runner-details-${raceNum}-${runnerData.horseNo}`);
544
+ const detailsContent = document.getElementById(`runner-details-content-${raceNum}-${runnerData.horseNo}`);
446
545
 
447
- // Expand UI
448
546
  detailsRow.classList.add('expanded');
449
547
  row.querySelector('.expand-icon').classList.add('expanded');
450
548
 
451
- // Show initial state
452
549
  if (Cache.has('runnerDetails', cacheKey)) {
453
550
  const historyHtml = Cache.get('runnerDetails', cacheKey);
454
- detailsContent.innerHTML = this.createContent(raceNum, runnerNo, horseNo, oddsData, historyHtml);
551
+ detailsContent.innerHTML = this.createContent(raceNum, runnerData.runnerNo, runnerData.horseNo, oddsData, historyHtml);
455
552
  } else {
456
553
  detailsContent.innerHTML = this.createContent(
457
- raceNum, runnerNo, horseNo, oddsData,
554
+ raceNum, runnerData.runnerNo, runnerData.horseNo, oddsData,
458
555
  '<div class="details-loading">Loading race history...</div>'
459
556
  );
460
557
  }
461
558
 
462
- return { horseNo, runnerNo, params, detailsContent, cacheKey, detailsRow };
559
+ return runnerData;
463
560
  });
464
561
 
465
- // Update button state immediately after expanding UI
466
562
  UI.updateExpandAllButtonState(raceNum);
467
563
 
468
- // Load uncached histories sequentially in background
564
+ // Load uncached histories in background
469
565
  (async () => {
470
- for (const runner of runners) {
471
- // Check if still expanded before loading
472
- if (!runner.detailsRow.classList.contains('expanded')) {
473
- continue; // Skip if user collapsed this runner
474
- }
475
-
476
- if (!Cache.has('runnerDetails', runner.cacheKey)) {
477
- try {
478
- const historyHtml = await this.load(
479
- raceNum,
480
- runner.horseNo,
481
- runner.params[2],
482
- runner.params[3],
483
- runner.params[4]
484
- );
485
-
486
- // Check again if still expanded after async load
487
- if (runner.detailsRow.classList.contains('expanded')) {
488
- const currentOddsData = Cache.get('odds', raceNum);
489
- runner.detailsContent.innerHTML = this.createContent(
490
- raceNum, runner.runnerNo, runner.horseNo, currentOddsData, historyHtml
491
- );
492
- }
493
- } catch (error) {
494
- // Check if still expanded before showing error
495
- if (runner.detailsRow.classList.contains('expanded')) {
496
- const currentOddsData = Cache.get('odds', raceNum);
497
- runner.detailsContent.innerHTML = this.createContent(
498
- raceNum, runner.runnerNo, runner.horseNo, currentOddsData,
499
- `<div class="details-loading error">Error: ${error.message}</div>`
500
- );
501
- }
502
- }
503
- }
566
+ for (const runnerData of runners) {
567
+ await this.loadRunnerData(raceNum, runnerData, oddsData);
504
568
  }
505
569
  })();
506
570
  } else {
507
571
  // Collapse all
508
572
  runnerRows.forEach(row => {
509
- const params = row.getAttribute('onclick').match(/'([^']+)'/g).map(s => s.replace(/'/g, ''));
510
- const horseNo = params[1];
573
+ const horseNo = row.dataset.horse;
511
574
  const detailsRow = document.getElementById(`runner-details-${raceNum}-${horseNo}`);
512
-
513
575
  detailsRow.classList.remove('expanded');
514
576
  row.querySelector('.expand-icon').classList.remove('expanded');
515
577
  });
516
578
 
517
- // Update button state immediately after collapsing
518
579
  UI.updateExpandAllButtonState(raceNum);
519
580
  }
581
+ },
582
+
583
+ async prefetchAll() {
584
+ Utils.log('Starting background prefetch of runner histories...');
585
+
586
+ const allRaces = Array.from(document.querySelectorAll('.race-content'));
587
+ if (allRaces.length === 0) return;
588
+
589
+ for (const raceContent of allRaces) {
590
+ const raceNum = raceContent.id.replace('race-', '');
591
+ const runnerRows = Array.from(raceContent.querySelectorAll('.runner-row'));
592
+
593
+ for (const row of runnerRows) {
594
+ const { horseNo, going, track, distance } = Utils.getRunnerData(row);
595
+ if (!horseNo) continue;
596
+
597
+ const cacheKey = `${raceNum}-${horseNo}`;
598
+ if (Cache.has('runnerDetails', cacheKey)) continue;
599
+
600
+ try {
601
+ await this.load(raceNum, horseNo, going, track, distance);
602
+ Utils.log(`✓ Prefetched race ${raceNum} - horse ${horseNo}`);
603
+ } catch (error) {
604
+ console.error(`✗ Error prefetching race ${raceNum} - horse ${horseNo}:`, error);
605
+ }
606
+ }
607
+ }
608
+
609
+ Utils.log('✓ Background prefetch completed');
520
610
  }
521
611
  };
522
612
 
@@ -524,36 +614,45 @@ const RunnerDetails = {
524
614
  // SPEEDMAP MODULE
525
615
  // ============================================================================
526
616
  const Speedmap = {
527
- /**
528
- * Load speedmap for a race
529
- */
530
- async load(raceNum) {
531
- const speedmapImg = document.getElementById(`speedmap-${raceNum}`);
532
- if (!speedmapImg) return;
617
+ ensureImageElement(raceNum, container) {
618
+ if (!container.querySelector('img')) {
619
+ container.innerHTML = `<img id="speedmap-${raceNum}" class="speedmap-image" alt="Race ${raceNum} Speed Map" />`;
620
+ }
621
+ return document.getElementById(`speedmap-${raceNum}`);
622
+ },
533
623
 
534
- // Check cache first
624
+ async load(raceNum) {
625
+ const speedmapContainer = document.querySelector(`#race-${raceNum} .speedmap-container`);
626
+ const speedmapImg = document.getElementById(`speedmap-${raceNum}`);
627
+ if (!speedmapImg || !speedmapContainer) return;
628
+
629
+ if (!RaceReadiness.isReady(raceNum)) {
630
+ speedmapImg.style.display = 'none';
631
+ speedmapContainer.innerHTML = '<div class="race-not-ready">Race not ready</div>';
632
+ return;
633
+ }
634
+
535
635
  if (Cache.has('speedmaps', raceNum)) {
536
- speedmapImg.src = Cache.get('speedmaps', raceNum);
537
- speedmapImg.style.display = 'block';
538
- return;
539
- }
540
-
541
- speedmapImg.style.display = 'none';
542
-
636
+ const img = this.ensureImageElement(raceNum, speedmapContainer);
637
+ img.src = Cache.get('speedmaps', raceNum);
638
+ img.style.display = 'block';
639
+ return;
640
+ }
641
+
642
+ speedmapImg.style.display = 'none';
643
+
543
644
  try {
544
645
  const base64String = await API.fetchSpeedmap(raceNum);
545
646
  Cache.set('speedmaps', raceNum, base64String);
546
- speedmapImg.src = base64String;
547
- speedmapImg.style.display = 'block';
647
+ const img = this.ensureImageElement(raceNum, speedmapContainer);
648
+ img.src = base64String;
649
+ img.style.display = 'block';
548
650
  } catch (error) {
549
651
  console.error(`Error loading speedmap for race ${raceNum}:`, error);
550
652
  speedmapImg.style.display = 'none';
551
653
  }
552
654
  },
553
655
 
554
- /**
555
- * Load all speedmaps sequentially via queue
556
- */
557
656
  async loadAll() {
558
657
  const speedmapImages = document.querySelectorAll('.speedmap-image');
559
658
  for (const img of speedmapImages) {
@@ -567,24 +666,17 @@ const Speedmap = {
567
666
  // UI MODULE - User interface interactions
568
667
  // ============================================================================
569
668
  const UI = {
570
- /**
571
- * Show a specific race tab
572
- */
573
669
  showRace(raceNum) {
574
670
  document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
575
671
  document.getElementById(`tab-${raceNum}`).classList.add('active');
576
672
  document.querySelectorAll('.race-content').forEach(content => content.classList.remove('active'));
577
673
  document.getElementById(`race-${raceNum}`).classList.add('active');
578
674
 
579
- // Fetch odds if not cached
580
675
  if (!Cache.has('odds', raceNum)) {
581
676
  Odds.load(raceNum);
582
677
  }
583
678
  },
584
679
 
585
- /**
586
- * Update expand/collapse all button state
587
- */
588
680
  updateExpandAllButtonState(raceNum) {
589
681
  const raceContent = document.getElementById(`race-${raceNum}`);
590
682
  const expandAllBtn = raceContent.querySelector('.expand-all-btn');
@@ -599,59 +691,53 @@ const UI = {
599
691
  }
600
692
  },
601
693
 
602
- /**
603
- * Sort table by column
604
- */
605
694
  sortTable(raceNum, columnIndex, type) {
606
- const table = document.querySelector(`#race-${raceNum} .runners-table`);
607
- const tbody = table.querySelector('tbody');
608
- const header = table.querySelectorAll('thead th')[columnIndex];
609
- const allHeaders = table.querySelectorAll('thead th.sortable');
610
- const rows = Array.from(tbody.querySelectorAll('tr')).filter((row, index) => index % 2 === 0);
611
- const isAscending = !header.classList.contains('sort-asc');
612
-
695
+ const table = document.querySelector(`#race-${raceNum} .runners-table`);
696
+ const tbody = table.querySelector('tbody');
697
+ const header = table.querySelectorAll('thead th')[columnIndex];
698
+ const allHeaders = table.querySelectorAll('thead th.sortable');
699
+ const rows = Array.from(tbody.querySelectorAll('tr')).filter((row, index) => index % 2 === 0);
700
+ const isAscending = !header.classList.contains('sort-asc');
701
+
613
702
  // Reset all headers
614
- allHeaders.forEach(h => {
615
- h.classList.remove('sort-asc', 'sort-desc');
616
- const arrow = h.querySelector('.sort-arrow');
617
- if (arrow) arrow.textContent = '⇅';
618
- });
619
-
620
- // Sort rows
621
- rows.sort((a, b) => {
622
- let aComp, bComp;
623
-
624
- if (type === 'number') {
625
- aComp = parseFloat(a.cells[columnIndex].textContent.trim());
626
- bComp = parseFloat(b.cells[columnIndex].textContent.trim());
627
- if (isNaN(aComp)) aComp = -Infinity;
628
- if (isNaN(bComp)) bComp = -Infinity;
629
- } else {
630
- aComp = a.cells[columnIndex].textContent.trim().toLowerCase();
631
- bComp = b.cells[columnIndex].textContent.trim().toLowerCase();
632
- }
703
+ allHeaders.forEach(h => {
704
+ h.classList.remove('sort-asc', 'sort-desc');
705
+ const arrow = h.querySelector('.sort-arrow');
706
+ if (arrow) arrow.textContent = '⇅';
707
+ });
633
708
 
709
+ // Sort rows
710
+ rows.sort((a, b) => {
711
+ let aComp, bComp;
712
+
713
+ if (type === 'number') {
714
+ aComp = parseFloat(a.cells[columnIndex].textContent.trim());
715
+ bComp = parseFloat(b.cells[columnIndex].textContent.trim());
716
+ if (isNaN(aComp)) aComp = -Infinity;
717
+ if (isNaN(bComp)) bComp = -Infinity;
718
+ } else {
719
+ aComp = a.cells[columnIndex].textContent.trim().toLowerCase();
720
+ bComp = b.cells[columnIndex].textContent.trim().toLowerCase();
721
+ }
722
+
634
723
  return isAscending ?
635
724
  (aComp > bComp ? 1 : aComp < bComp ? -1 : 0) :
636
725
  (aComp < bComp ? 1 : aComp > bComp ? -1 : 0);
637
- });
638
-
726
+ });
727
+
639
728
  // Update header
640
- header.classList.add(isAscending ? 'sort-asc' : 'sort-desc');
641
- const arrow = header.querySelector('.sort-arrow');
642
- if (arrow) arrow.textContent = isAscending ? '▲' : '▼';
643
-
729
+ header.classList.add(isAscending ? 'sort-asc' : 'sort-desc');
730
+ const arrow = header.querySelector('.sort-arrow');
731
+ if (arrow) arrow.textContent = isAscending ? '▲' : '▼';
732
+
644
733
  // Reorder rows
645
- rows.forEach(row => {
734
+ rows.forEach(row => {
646
735
  const detailsRow = row.nextElementSibling;
647
- tbody.appendChild(row);
736
+ tbody.appendChild(row);
648
737
  if (detailsRow) tbody.appendChild(detailsRow);
649
738
  });
650
739
  },
651
740
 
652
- /**
653
- * Open track workout video
654
- */
655
741
  openTrackWorkoutVideo(raceDate, raceNum) {
656
742
  const dateParts = raceDate.split('-');
657
743
  const dateFormatted = dateParts.join('');
@@ -665,24 +751,18 @@ const UI = {
665
751
  // APPLICATION INITIALIZATION
666
752
  // ============================================================================
667
753
  const App = {
668
- /**
669
- * Initialize the application
670
- */
671
754
  init() {
672
755
  console.log('Initializing HKJC Race Info...');
673
756
 
674
- // Load speedmaps
675
757
  Speedmap.loadAll();
676
-
677
- // Start odds polling
678
758
  Odds.startPolling();
759
+ RunnerDetails.prefetchAll().catch(error => {
760
+ console.error('Error in background prefetch:', error);
761
+ });
679
762
 
680
763
  console.log('Application initialized successfully');
681
764
  },
682
765
 
683
- /**
684
- * Cleanup on page unload
685
- */
686
766
  cleanup() {
687
767
  Odds.stopPolling();
688
768
  RequestQueue.clear();
@@ -156,6 +156,14 @@ body {
156
156
  margin-top: -280px;
157
157
  }
158
158
 
159
+ .race-not-ready {
160
+ padding: 20px;
161
+ color: #666;
162
+ font-size: 1em;
163
+ font-style: italic;
164
+ text-align: center;
165
+ }
166
+
159
167
  .expand-all-container {
160
168
  margin-bottom: 10px;
161
169
  }
@@ -247,6 +255,11 @@ body {
247
255
  text-align: center;
248
256
  color: #667eea;
249
257
  font-size: 0.7em;
258
+ transition: transform 0.2s ease;
259
+ }
260
+
261
+ .expand-icon.expanded {
262
+ transform: rotate(90deg);
250
263
  }
251
264
 
252
265
  .runner-details-row {
@@ -89,16 +89,17 @@
89
89
  <th class="sortable" onclick="sortTable('{{ race_num }}', 9, 'number')">
90
90
  Energy Diff <span class="sort-arrow">⇅</span>
91
91
  </th>
92
- <th class="sortable" onclick="sortTable('{{ race_num }}', 10, 'number')">
92
+ <th>Fav Style</th>
93
+ <th class="sortable" onclick="sortTable('{{ race_num }}', 11, 'number')">
93
94
  WIN <span class="sort-arrow">⇅</span>
94
95
  </th>
95
- <th class="sortable" onclick="sortTable('{{ race_num }}', 11, 'number')">
96
+ <th class="sortable" onclick="sortTable('{{ race_num }}', 12, 'number')">
96
97
  WIN (fit) <span class="sort-arrow">⇅</span>
97
98
  </th>
98
- <th class="sortable" onclick="sortTable('{{ race_num }}', 12, 'number')">
99
+ <th class="sortable" onclick="sortTable('{{ race_num }}', 13, 'number')">
99
100
  PLA <span class="sort-arrow">⇅</span>
100
101
  </th>
101
- <th class="sortable" onclick="sortTable('{{ race_num }}', 13, 'number')">
102
+ <th class="sortable" onclick="sortTable('{{ race_num }}', 14, 'number')">
102
103
  PLA (fit) <span class="sort-arrow">⇅</span>
103
104
  </th>
104
105
  </tr>
@@ -106,6 +107,11 @@
106
107
  <tbody>
107
108
  {% for runner in race.Runners %}
108
109
  <tr class="runner-row"
110
+ data-race="{{ race_num }}"
111
+ data-horse="{{ runner.HorseNo }}"
112
+ data-going="{{ race.Going }}"
113
+ data-track="{{ race.Track }}"
114
+ data-dist="{{ race.Dist }}"
109
115
  onclick="toggleRunnerHistory(this, '{{ race_num }}', '{{ runner.HorseNo }}', '{{ race.Going }}', '{{ race.Track }}', '{{ race.Dist }}')">
110
116
  <td>
111
117
  <span class="expand-icon">▶</span>
@@ -122,13 +128,14 @@
122
128
  <td>{{ runner.Rtg }}</td>
123
129
  <td>{{ runner.Fitness }}</td>
124
130
  <td>{{ runner.SPEnergy }}</td>
131
+ <td class="fav-style" data-race="{{ race_num }}" data-horse="{{ runner.HorseNo }}">-</td>
125
132
  <td class="odds-win" data-race="{{ race_num }}" data-horse="{{ runner.No }}">-</td>
126
133
  <td class="odds-win-fit" data-race="{{ race_num }}" data-horse="{{ runner.No }}">-</td>
127
134
  <td class="odds-pla" data-race="{{ race_num }}" data-horse="{{ runner.No }}">-</td>
128
135
  <td class="odds-pla-fit" data-race="{{ race_num }}" data-horse="{{ runner.No }}">-</td>
129
136
  </tr>
130
137
  <tr class="runner-details-row" id="runner-details-{{ race_num }}-{{ runner.HorseNo }}">
131
- <td colspan="14" class="runner-details-cell">
138
+ <td colspan="15" class="runner-details-cell">
132
139
  <div class="runner-details-content" id="runner-details-content-{{ race_num }}-{{ runner.HorseNo }}">
133
140
  <div class="details-loading">Loading details...</div>
134
141
  </div>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hkjc"
3
- version = "0.3.21"
3
+ version = "0.3.22"
4
4
  description = "Library for scrapping HKJC data and perform basic analysis"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -15,12 +15,21 @@ HKJC_HORSE_URL_TEMPLATE = "https://racing.hkjc.com/racing/information/English/Ho
15
15
  incidents = ['DISQ', 'DNF', 'FE', 'ML', 'PU', 'TNP', 'TO',
16
16
  'UR', 'VOID', 'WR', 'WV', 'WV-A', 'WX', 'WX-A', 'WXNR']
17
17
 
18
+ REQUEST_TIMEOUT = 10
19
+
20
+ HTML_HEADERS = {
21
+ "Origin": "https://racing.hkjc.com",
22
+ "Referer": "https://racing.hkjc.com",
23
+ "Content-Type": "text/plain",
24
+ "Accept": "*/*",
25
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
26
+ }
18
27
 
19
28
  @ttl_cache(maxsize=100, ttl=3600)
20
29
  def _soupify(url: str) -> BeautifulSoup:
21
30
  """Fetch and parse a webpage and return BeautifulSoup object
22
31
  """
23
- response = requests.get(url, timeout=180)
32
+ response = requests.get(url, timeout=REQUEST_TIMEOUT, headers=HTML_HEADERS)
24
33
  response.raise_for_status()
25
34
  return BeautifulSoup(response.content, 'html.parser')
26
35
 
@@ -43,6 +52,9 @@ def _soupify_horse_page(horse_no: str) -> BeautifulSoup:
43
52
  def _classify_running_style(df: pl.DataFrame, running_pos_col="RunningPosition") -> pl.DataFrame:
44
53
  """Classify running style based on RunningPosition column
45
54
  """
55
+ if df.height == 0:
56
+ return df
57
+
46
58
  # Split the RunningPosition column into separate columns and convert to integers
47
59
  df = df.with_columns(
48
60
  pl.col(running_pos_col)
@@ -77,9 +89,9 @@ def _extract_horse_data(horse_no: str) -> pl.DataFrame:
77
89
  table = soup.find('table', class_='bigborder')
78
90
  horse_data = _parse_html_table(table).filter(
79
91
  pl.col('Date') != '') # Remove empty rows
80
- horse_data = _classify_running_style(horse_data)
81
-
82
- horse_data = horse_data.with_columns(pl.lit(horse_no).alias('HorseNo'))
92
+ if horse_data.height > 0:
93
+ horse_data = _classify_running_style(horse_data)
94
+ horse_data = horse_data.with_columns(pl.lit(horse_no).alias('HorseNo'))
83
95
 
84
96
  return horse_data
85
97
 
@@ -87,6 +99,9 @@ def _extract_horse_data(horse_no: str) -> pl.DataFrame:
87
99
  def _clean_horse_data(df: pl.DataFrame) -> pl.DataFrame:
88
100
  """ Clean and convert horse data to suitable data types
89
101
  """
102
+ if df.height == 0:
103
+ return df
104
+
90
105
  df = df.with_columns(
91
106
  pl.col('Pla').str.split(' ').list.first().alias('Pla')
92
107
  ).filter(~pl.col('Pla').is_in(incidents))
@@ -105,7 +120,8 @@ def _clean_horse_data(df: pl.DataFrame) -> pl.DataFrame:
105
120
  .with_columns(
106
121
  (
107
122
  pl.col("FinishTime").str.splitn(".", 2).struct.field("field_0").cast(pl.Int64) * 60 +
108
- pl.col("FinishTime").str.splitn(".", 2).struct.field("field_1").cast(pl.Float64)
123
+ pl.col("FinishTime").str.splitn(
124
+ ".", 2).struct.field("field_1").cast(pl.Float64)
109
125
  ).cast(pl.Float64).round(2).alias("FinishTime")
110
126
  ))
111
127
 
@@ -134,6 +150,9 @@ def get_horse_data(horse_no: str) -> pl.DataFrame:
134
150
  def _clean_race_data(df: pl.DataFrame) -> pl.DataFrame:
135
151
  """ Clean and convert horse data to suitable data types
136
152
  """
153
+ if df.height == 0:
154
+ return df
155
+
137
156
  df = df.with_columns(
138
157
  pl.col('Pla').str.split(' ').list.first().alias('Pla')
139
158
  ).filter(~pl.col('Pla').is_in(incidents))
@@ -150,7 +169,8 @@ def _clean_race_data(df: pl.DataFrame) -> pl.DataFrame:
150
169
  df = df.with_columns(
151
170
  (
152
171
  pl.col("FinishTime").str.splitn(":", 2).struct.field("field_0").cast(pl.Int64) * 60 +
153
- pl.col("FinishTime").str.splitn(":", 2).struct.field("field_1").cast(pl.Float64)
172
+ pl.col("FinishTime").str.splitn(
173
+ ":", 2).struct.field("field_1").cast(pl.Float64)
154
174
  ).cast(pl.Float64).round(2).alias("FinishTime")
155
175
  )
156
176
 
@@ -7,6 +7,8 @@ import requests
7
7
  from cachetools.func import ttl_cache
8
8
  import numpy as np
9
9
 
10
+ from .utils import _try_int
11
+
10
12
  HKJC_LIVEODDS_ENDPOINT = "https://info.cld.hkjc.com/graphql/base/"
11
13
 
12
14
  RACEMTG_PAYLOAD = {
@@ -241,25 +243,29 @@ query racing($date: String, $venueCode: String, $oddsTypes: [OddsType], $raceNo:
241
243
  }""",
242
244
  }
243
245
 
246
+ JSON_HEADERS = {
247
+ "Origin": "https://bet.hkjc.com",
248
+ "Referer": "https://bet.hkjc.com",
249
+ "Content-Type": "application/json",
250
+ "Accept": "application/json",
251
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
252
+ }
253
+
254
+ REQUEST_TIMEOUT = 30
255
+
244
256
 
245
257
  @ttl_cache(maxsize=12, ttl=1000)
246
- def _fetch_live_races(date: str=None, venue_code: str=None) -> dict:
258
+ def _fetch_live_races(date: str = None, venue_code: str = None) -> dict:
247
259
  """Fetch live race data from HKJC GraphQL endpoint."""
248
260
  payload = RACEMTG_PAYLOAD.copy()
249
261
  payload["variables"] = payload["variables"].copy()
250
262
  payload["variables"]["date"] = date
251
263
  payload["variables"]["venueCode"] = venue_code
252
264
 
253
- headers = {
254
- "Origin": "https://bet.hkjc.com",
255
- "Referer": "https://bet.hkjc.com",
256
- "Content-Type": "application/json",
257
- "Accept": "application/json",
258
- "User-Agent": "python-hkjc-fetch/0.1",
259
- }
265
+ headers = JSON_HEADERS
260
266
 
261
267
  r = requests.post(HKJC_LIVEODDS_ENDPOINT, json=payload,
262
- headers=headers, timeout=10)
268
+ headers=headers, timeout=REQUEST_TIMEOUT)
263
269
  if r.status_code != 200:
264
270
  raise RuntimeError(f"Request failed: {r.status_code} - {r.text}")
265
271
 
@@ -279,9 +285,9 @@ def _fetch_live_races(date: str=None, venue_code: str=None) -> dict:
279
285
  runners = [{'No': runner['no'],
280
286
  'Name': runner['name_en'],
281
287
  'Dr': runner['barrierDrawNumber'],
282
- 'Rtg': int(runner['currentRating']),
283
- 'Wt': int(runner['currentWeight']),
284
- 'Handicap': int(runner['handicapWeight']),
288
+ 'Rtg': _try_int(runner['currentRating']),
289
+ 'Wt': _try_int(runner['currentWeight']),
290
+ 'Handicap': _try_int(runner['handicapWeight']),
285
291
  'HorseNo': runner['horse']['code']
286
292
  } for runner in race['runners'] if runner['status'] != "Standby"]
287
293
  race_info['Races'][race_num] = {
@@ -307,16 +313,10 @@ def _fetch_live_odds(date: str, venue_code: str, race_number: int, odds_type: Tu
307
313
  payload["variables"]["raceNo"] = race_number
308
314
  payload["variables"]["oddsTypes"] = odds_type
309
315
 
310
- headers = {
311
- "Origin": "https://bet.hkjc.com",
312
- "Referer": "https://bet.hkjc.com",
313
- "Content-Type": "application/json",
314
- "Accept": "application/json",
315
- "User-Agent": "python-hkjc-fetch/0.1",
316
- }
316
+ headers = JSON_HEADERS
317
317
 
318
318
  r = requests.post(HKJC_LIVEODDS_ENDPOINT, json=payload,
319
- headers=headers, timeout=10)
319
+ headers=headers, timeout=REQUEST_TIMEOUT)
320
320
  if r.status_code != 200:
321
321
  raise RuntimeError(f"Request failed: {r.status_code} - {r.text}")
322
322
 
@@ -354,7 +354,8 @@ def live_odds(date: str, venue_code: str, race_number: int, odds_type: List[str]
354
354
  N = len(race_info['Races'][race_number]['Runners'])
355
355
 
356
356
  if (race_info['Date'] != date) or (race_info['Venue'] != venue_code):
357
- print(f"[WARNING] Requested {date} {venue_code} but server returned {race_info['Date']} {race_info['Venue']}.")
357
+ print(
358
+ f"[WARNING] Requested {date} {venue_code} but server returned {race_info['Date']} {race_info['Venue']}.")
358
359
  date = race_info['Date']
359
360
  venue_code = race_info['Venue']
360
361
 
@@ -4,6 +4,12 @@ from datetime import datetime as dt
4
4
  import bs4
5
5
  import re
6
6
 
7
+ def _try_int(value: str) -> int:
8
+ try:
9
+ return int(value)
10
+ except:
11
+ return 0
12
+
7
13
 
8
14
  def _validate_date(date_str: str) -> bool:
9
15
  # validate date format
@@ -148,7 +148,7 @@ wheels = [
148
148
 
149
149
  [[package]]
150
150
  name = "hkjc"
151
- version = "0.3.21"
151
+ version = "0.3.22"
152
152
  source = { editable = "." }
153
153
  dependencies = [
154
154
  { name = "beautifulsoup4" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes