Automated scoring system for UK/Irish handicap races with 8-factor analysis (T1-T8) and intelligent selection logic
Size
29.5 KB
Version
1.0.1
Created
Nov 7, 2025
Updated
about 1 month ago
1// ==UserScript==
2// @name UK/Irish Horse Racing Handicap Analyzer
3// @description Automated scoring system for UK/Irish handicap races with 8-factor analysis (T1-T8) and intelligent selection logic
4// @version 1.0.1
5// @match https://*.racingpost.com/racecards/*
6// @match https://www.racingpost.com/racecards/*
7// @icon https://www.gstatic.com/lamda/images/gemini_sparkle_aurora_33f86dc0c0257da337c63.svg
8// ==/UserScript==
9(function() {
10 'use strict';
11
12 console.log('UK/Irish Horse Racing Handicap Analyzer - Starting...');
13
14 // ========================================
15 // 1. CLASS CEILING THRESHOLD (CCT) LOOKUP
16 // ========================================
17 const CCT_FLAT = {
18 2: 110,
19 3: 95,
20 4: 85,
21 5: 75,
22 6: 65
23 };
24
25 const CCT_JUMPS = {
26 2: 140,
27 3: 135,
28 4: 120,
29 5: 100,
30 6: 80
31 };
32
33 // ========================================
34 // 2. UTILITY FUNCTIONS
35 // ========================================
36
37 function debounce(func, wait) {
38 let timeout;
39 return function executedFunction(...args) {
40 const later = () => {
41 clearTimeout(timeout);
42 func(...args);
43 };
44 clearTimeout(timeout);
45 timeout = setTimeout(later, wait);
46 };
47 }
48
49 function parseOdds(oddsString) {
50 // Convert fractional odds to decimal
51 if (!oddsString || oddsString === 'SP') return null;
52
53 if (oddsString.includes('/')) {
54 const [num, den] = oddsString.split('/').map(Number);
55 return (num / den) + 1;
56 }
57 return parseFloat(oddsString);
58 }
59
60 function parseWeight(weightElement) {
61 // Weight is in format "12-0" (12 stone 0 pounds)
62 // data-order-wgt contains total pounds
63 const totalPounds = parseInt(weightElement?.getAttribute('data-order-wgt') || '0');
64 return totalPounds;
65 }
66
67 function parseClass(classString) {
68 // Extract class number from "(Class 3)" format
69 const match = classString?.match(/Class\s+(\d+)/i);
70 return match ? parseInt(match[1]) : null;
71 }
72
73 function parseDistance(distanceString) {
74 // Parse distance like "2m4f" or "1m2f"
75 if (!distanceString) return 0;
76
77 const milesMatch = distanceString.match(/(\d+)m/);
78 const furlongsMatch = distanceString.match(/(\d+)f/);
79
80 const miles = milesMatch ? parseInt(milesMatch[1]) : 0;
81 const furlongs = furlongsMatch ? parseInt(furlongsMatch[1]) : 0;
82
83 // Convert to furlongs (1 mile = 8 furlongs)
84 return (miles * 8) + furlongs;
85 }
86
87 function isLongFlatRace(raceType, distanceInFurlongs) {
88 // Long Flat Race is 1m2f+ (10+ furlongs)
89 return raceType === 'Flat' && distanceInFurlongs >= 10;
90 }
91
92 // ========================================
93 // 3. DATA EXTRACTION FUNCTIONS
94 // ========================================
95
96 function extractRaceData() {
97 console.log('Extracting race data...');
98
99 const distanceEl = document.querySelector('[data-test-selector="RC-header__raceDistanceRound"]');
100 const titleEl = document.querySelector('[data-test-selector="RC-header__raceInstanceTitle"]');
101 const classEl = document.querySelector('[data-test-selector="RC-header__raceClass"]');
102 const agesEl = document.querySelector('[data-test-selector="RC-header__rpAges"]');
103
104 const distanceText = distanceEl?.textContent?.trim() || '';
105 const title = titleEl?.textContent?.trim() || '';
106 const classText = classEl?.textContent?.trim() || '';
107 const agesText = agesEl?.textContent?.trim() || '';
108
109 // Determine race type (Flat or Jumps)
110 const isJumps = title.toLowerCase().includes('chase') ||
111 title.toLowerCase().includes('hurdle') ||
112 title.toLowerCase().includes('national hunt');
113
114 const raceType = isJumps ? 'Jumps' : 'Flat';
115 const raceClass = parseClass(classText);
116 const distanceInFurlongs = parseDistance(distanceText);
117
118 // Extract course name from URL or page
119 const urlParts = window.location.pathname.split('/');
120 const course = urlParts[2] || 'Unknown';
121
122 console.log('Race Data:', {
123 raceType,
124 raceClass,
125 distance: distanceText,
126 distanceInFurlongs,
127 course,
128 title
129 });
130
131 return {
132 raceType,
133 raceClass,
134 distance: distanceText,
135 distanceInFurlongs,
136 course,
137 title,
138 agesText
139 };
140 }
141
142 async function extractTrainerStats(trainerName) {
143 // Try to find trainer stats in the stats accordion
144 // This might require expanding the stats section
145 const statsAccordion = document.querySelector('.RC-stats');
146
147 if (statsAccordion) {
148 const trainerRows = Array.from(statsAccordion.querySelectorAll('tr.ui-table__row'));
149
150 for (const row of trainerRows) {
151 const nameCell = row.querySelector('[data-test-selector="RC-trainerName__row"]');
152 const name = nameCell?.textContent?.trim();
153
154 if (name && name.toLowerCase().includes(trainerName.toLowerCase())) {
155 const percentCell = row.querySelector('[data-test-selector="RC-lastPercent__row"]');
156 const percentText = percentCell?.textContent?.trim() || '0%';
157 const percent = parseFloat(percentText.replace('%', ''));
158
159 console.log(`Trainer ${trainerName} L14d Win %: ${percent}%`);
160 return percent;
161 }
162 }
163 }
164
165 // Default to 0 if not found
166 console.log(`Trainer ${trainerName} stats not found, defaulting to 0%`);
167 return 0;
168 }
169
170 function extractRunnerData(runnerElement, raceData) {
171 console.log('Extracting runner data from element...');
172
173 // Basic info
174 const nameEl = runnerElement.querySelector('[data-test-selector="RC-cardPage-runnerName"]');
175 const name = nameEl?.textContent?.trim() || 'Unknown';
176
177 // Odds
178 const oddsEl = runnerElement.querySelector('[data-test-selector="RC-cardPage-runnerPrice"]');
179 const oddsDecimalAttr = oddsEl?.getAttribute('data-diffusion-decimal');
180 const oddsFractional = oddsEl?.textContent?.trim();
181 const oddsDecimal = oddsDecimalAttr ? parseFloat(oddsDecimalAttr) : parseOdds(oddsFractional);
182
183 // Days since last run
184 const dslrEl = runnerElement.querySelector('[data-test-selector="RC-cardPage-runnerStats-lastRun"]');
185 const dslr = parseInt(dslrEl?.textContent?.trim() || '0');
186
187 // Trainer
188 const trainerEl = runnerElement.querySelector('[data-test-selector="RC-cardPage-runnerTrainer-name"]');
189 const trainer = trainerEl?.textContent?.trim() || 'Unknown';
190
191 // Age
192 const ageEl = runnerElement.querySelector('[data-test-selector="RC-cardPage-runnerAge"]');
193 const age = parseInt(ageEl?.textContent?.trim() || '0');
194
195 // Official Rating (OR)
196 const orEl = runnerElement.querySelector('[data-test-selector="RC-cardPage-runnerOr"]');
197 const officialRating = parseInt(orEl?.textContent?.trim() || '0');
198
199 // Weight
200 const weightEl = runnerElement.querySelector('[data-test-selector="RC-cardPage-runnerWgt-carried"]');
201 const weightCarried = parseWeight(weightEl);
202
203 // RPR (Racing Post Rating)
204 const rprEl = runnerElement.querySelector('[data-test-selector="RC-cardPage-runnerRpr"]');
205 const rprLTO = parseInt(rprEl?.textContent?.trim() || '0');
206
207 // TS (Topspeed)
208 const tsEl = runnerElement.querySelector('[data-test-selector="RC-cardPage-runnerTs"]');
209 const ts = parseInt(tsEl?.textContent?.trim() || '0');
210
211 // Form
212 const formEl = runnerElement.querySelector('[data-test-selector="RC-cardPage-runnerForm"]');
213 const form = formEl?.textContent?.trim() || '';
214
215 // Course & Distance indicators
216 const cdIndicatorC = runnerElement.querySelector('[data-test-selector="RC-cardPage-runnerStats-c"]');
217 const cdIndicatorD = runnerElement.querySelector('[data-test-selector="RC-cardPage-runnerStats-d"]');
218 const cdIndicatorCD = runnerElement.querySelector('[data-test-selector="RC-cardPage-runnerStats-cd"]');
219
220 const hasCourseWin = !!cdIndicatorC;
221 const hasDistanceWin = !!cdIndicatorD;
222 const hasCDWin = !!cdIndicatorCD;
223
224 // Draw/Stall (Flat only)
225 const drawEl = runnerElement.querySelector('[data-test-selector="RC-cardPage-runnerNumber-draw"]');
226 const draw = drawEl?.textContent?.trim() ? parseInt(drawEl.textContent.trim()) : null;
227
228 // Last Winning Mark - extract from form or profile (simplified for now)
229 // This would require additional data extraction from horse profile
230 const lastWinningMark = officialRating; // Placeholder - would need actual LWM
231
232 const runnerData = {
233 name,
234 oddsFractional,
235 oddsDecimal,
236 dslr,
237 trainer,
238 age,
239 officialRating,
240 weightCarried,
241 rprLTO,
242 ts,
243 form,
244 hasCourseWin,
245 hasDistanceWin,
246 hasCDWin,
247 draw,
248 lastWinningMark,
249 // Will be populated later
250 trainerL14dWinPercent: 0
251 };
252
253 console.log('Runner data extracted:', name, runnerData);
254 return runnerData;
255 }
256
257 // ========================================
258 // 4. SCORING FUNCTIONS (T1-T8)
259 // ========================================
260
261 function calculateT1(dslr) {
262 // T1: 7-Day Energy Shift
263 // 1 Point if DSLR between 5 and 35 days
264 const score = (dslr >= 5 && dslr <= 35) ? 1 : 0;
265 console.log(`T1 (Energy Shift): DSLR=${dslr}, Score=${score}`);
266 return score;
267 }
268
269 function calculateT2(trainerL14dWinPercent) {
270 // T2: Trainer Sequence Pattern
271 // 1 Point if Trainer L14d Win % >= 10%
272 const score = (trainerL14dWinPercent >= 10) ? 1 : 0;
273 console.log(`T2 (Trainer Pattern): Win%=${trainerL14dWinPercent}%, Score=${score}`);
274 return score;
275 }
276
277 function calculateT3(hasCDWin, hasCourseWin, hasDistanceWin) {
278 // T3: Distance-to-Form Ratio
279 // 2 Points for CD Winner
280 // 1 Point for C or D Winner/Placed
281 let score = 0;
282
283 if (hasCDWin) {
284 score = 2;
285 } else if (hasCourseWin || hasDistanceWin) {
286 score = 1;
287 }
288
289 console.log(`T3 (Distance-to-Form): CD=${hasCDWin}, C=${hasCourseWin}, D=${hasDistanceWin}, Score=${score}`);
290 return score;
291 }
292
293 function calculateT4(raceClass, age) {
294 // T4: Bloodline/Age Trigger
295 // Class 2-4: Age 3-6 = 2 points
296 // Class 5-6: Age 3-8 = 1 point
297 let score = 0;
298
299 if (raceClass <= 4 && age >= 3 && age <= 6) {
300 score = 2;
301 } else if (raceClass >= 5 && raceClass <= 6 && age >= 3 && age <= 8) {
302 score = 1;
303 }
304
305 console.log(`T4 (Age Trigger): Class=${raceClass}, Age=${age}, Score=${score}`);
306 return score;
307 }
308
309 function calculateT6(weightCarried, lowestWeight, lastWinningMark, currentOR) {
310 // T6: Weight/Handicap Angle
311 // 2 Points if: Carrying lowest weight OR (LWM - Current OR >= 2)
312 const lwCondition = (weightCarried === lowestWeight);
313 const orCondition = (lastWinningMark - currentOR >= 2);
314
315 const score = (lwCondition || orCondition) ? 2 : 0;
316
317 console.log(`T6 (Weight/Handicap): Weight=${weightCarried}, Lowest=${lowestWeight}, LWM=${lastWinningMark}, OR=${currentOR}, Score=${score}`);
318 return score;
319 }
320
321 function calculateT7(raceType, distanceInFurlongs, draw, course) {
322 // T7: Draw Bias
323 // 0 Points for Jumps or Long Flat Races (1m2f+)
324 // 1 Point if in top 33% of favorable draws for course/distance
325
326 if (raceType === 'Jumps' || distanceInFurlongs >= 10) {
327 console.log(`T7 (Draw Bias): Jumps or Long Flat, Score=0`);
328 return 0;
329 }
330
331 if (!draw) {
332 console.log(`T7 (Draw Bias): No draw info, Score=0`);
333 return 0;
334 }
335
336 // Simplified: Low draws (1-5) are often favorable on many courses
337 // In a full implementation, this would use course-specific data
338 const score = (draw >= 1 && draw <= 5) ? 1 : 0;
339
340 console.log(`T7 (Draw Bias): Draw=${draw}, Course=${course}, Score=${score}`);
341 return score;
342 }
343
344 function calculateT8(rprLTO, raceClass, raceType) {
345 // T8: Speed/Class Edge
346 // Use CCT lookup table
347 const cctTable = raceType === 'Jumps' ? CCT_JUMPS : CCT_FLAT;
348 const cct = cctTable[raceClass] || 0;
349
350 let score = 0;
351
352 if (rprLTO >= cct) {
353 score = 1;
354
355 // Bonus point if RPR >= CCT + 5
356 if (rprLTO >= cct + 5) {
357 score = 2;
358 }
359 }
360
361 console.log(`T8 (Speed/Class): RPR=${rprLTO}, CCT=${cct}, Score=${score}`);
362 return score;
363 }
364
365 // ========================================
366 // 5. SELECTION LOGIC & FILTERING
367 // ========================================
368
369 function calculateTotalScore(scores) {
370 return scores.t1 + scores.t2 + scores.t3 + scores.t4 + scores.t6 + scores.t7 + scores.t8;
371 }
372
373 function checkT5Qualifier(oddsDecimal) {
374 // T5: Market Reversal Filter
375 // Odds between 8/1 (9.0) and 33/1 (34.0)
376 if (!oddsDecimal) return false;
377 return oddsDecimal >= 9.0 && oddsDecimal <= 34.0;
378 }
379
380 function determineContenderType(totalScore, isT5Qualifier) {
381 // Primary Contender: Total Score >= 4
382 // HVC: Primary Contender AND T5 Qualifier
383
384 const isPrimary = totalScore >= 4;
385 const isHVC = isPrimary && isT5Qualifier;
386
387 if (isHVC) return 'HVC';
388 if (isPrimary) return 'Primary';
389 return 'None';
390 }
391
392 function applyTieBreaker(runners) {
393 // Find highest score
394 const maxScore = Math.max(...runners.map(r => r.totalScore));
395 const topRunners = runners.filter(r => r.totalScore === maxScore);
396
397 if (topRunners.length === 1) {
398 return topRunners[0];
399 }
400
401 console.log(`Tie-breaker needed for ${topRunners.length} runners with score ${maxScore}`);
402
403 // Primary Tie-Breaker: Highest T8 (RPR LTO)
404 const maxRPR = Math.max(...topRunners.map(r => r.rprLTO));
405 let candidates = topRunners.filter(r => r.rprLTO === maxRPR);
406
407 if (candidates.length === 1) {
408 console.log('Tie broken by T8 (RPR)');
409 return candidates[0];
410 }
411
412 // Secondary Tie-Breaker: Highest T3 Score
413 const maxT3 = Math.max(...candidates.map(r => r.scores.t3));
414 candidates = candidates.filter(r => r.scores.t3 === maxT3);
415
416 if (candidates.length === 1) {
417 console.log('Tie broken by T3');
418 return candidates[0];
419 }
420
421 // Tertiary Tie-Breaker: T5 Status and Odds
422 const hvcs = candidates.filter(r => r.contenderType === 'HVC');
423
424 if (hvcs.length > 0) {
425 // Among HVCs, prefer shortest odds
426 hvcs.sort((a, b) => (a.oddsDecimal || 999) - (b.oddsDecimal || 999));
427 console.log('Tie broken by HVC status and odds');
428 return hvcs[0];
429 }
430
431 // If no HVCs, prefer shortest odds overall
432 candidates.sort((a, b) => (a.oddsDecimal || 999) - (b.oddsDecimal || 999));
433 console.log('Tie broken by shortest odds');
434 return candidates[0];
435 }
436
437 // ========================================
438 // 6. MAIN ANALYSIS FUNCTION
439 // ========================================
440
441 async function analyzeRace() {
442 console.log('=== Starting Race Analysis ===');
443
444 // Extract race data
445 const raceData = extractRaceData();
446
447 if (!raceData.raceClass) {
448 console.error('Could not determine race class. Aborting analysis.');
449 return;
450 }
451
452 // Get all runners
453 const runnerElements = Array.from(document.querySelectorAll('.RC-runnerRow'));
454 console.log(`Found ${runnerElements.length} runners`);
455
456 if (runnerElements.length === 0) {
457 console.error('No runners found. Aborting analysis.');
458 return;
459 }
460
461 // Extract runner data
462 const runners = runnerElements.map(el => extractRunnerData(el, raceData));
463
464 // Calculate lowest weight
465 const lowestWeight = Math.min(...runners.map(r => r.weightCarried).filter(w => w > 0));
466 console.log(`Lowest weight in race: ${lowestWeight} lbs`);
467
468 // Expand stats accordion to get trainer data (if not already expanded)
469 const statsButton = document.querySelector('.js-accordion__header');
470 if (statsButton && !statsButton.classList.contains('ui-accordion__header_active')) {
471 statsButton.click();
472 // Wait for stats to load
473 await new Promise(resolve => setTimeout(resolve, 1000));
474 }
475
476 // Get trainer stats for each runner
477 for (const runner of runners) {
478 runner.trainerL14dWinPercent = await extractTrainerStats(runner.trainer);
479 }
480
481 // Calculate scores for each runner
482 for (const runner of runners) {
483 const scores = {
484 t1: calculateT1(runner.dslr),
485 t2: calculateT2(runner.trainerL14dWinPercent),
486 t3: calculateT3(runner.hasCDWin, runner.hasCourseWin, runner.hasDistanceWin),
487 t4: calculateT4(raceData.raceClass, runner.age),
488 t6: calculateT6(runner.weightCarried, lowestWeight, runner.lastWinningMark, runner.officialRating),
489 t7: calculateT7(raceData.raceType, raceData.distanceInFurlongs, runner.draw, raceData.course),
490 t8: calculateT8(runner.rprLTO, raceData.raceClass, raceData.raceType)
491 };
492
493 runner.scores = scores;
494 runner.totalScore = calculateTotalScore(scores);
495 runner.isT5Qualifier = checkT5Qualifier(runner.oddsDecimal);
496 runner.contenderType = determineContenderType(runner.totalScore, runner.isT5Qualifier);
497
498 console.log(`${runner.name}: Total Score = ${runner.totalScore}, Type = ${runner.contenderType}`);
499 }
500
501 // Sort by total score (descending)
502 runners.sort((a, b) => b.totalScore - a.totalScore);
503
504 // Apply tie-breaker for top selection
505 const topSelection = applyTieBreaker(runners);
506 console.log(`Top Selection: ${topSelection.name} (Score: ${topSelection.totalScore})`);
507
508 // Display results
509 displayResults(runners, topSelection, raceData);
510
511 return { runners, topSelection, raceData };
512 }
513
514 // ========================================
515 // 7. UI DISPLAY FUNCTIONS
516 // ========================================
517
518 function displayResults(runners, topSelection, raceData) {
519 console.log('Displaying results in UI...');
520
521 // Add scores to each runner row
522 runners.forEach(runner => {
523 const runnerElement = Array.from(document.querySelectorAll('.RC-runnerRow')).find(el => {
524 const nameEl = el.querySelector('[data-test-selector="RC-cardPage-runnerName"]');
525 return nameEl?.textContent?.trim().includes(runner.name);
526 });
527
528 if (runnerElement) {
529 addScoreDisplay(runnerElement, runner, topSelection);
530 }
531 });
532
533 // Add summary panel
534 addSummaryPanel(runners, topSelection, raceData);
535 }
536
537 function addScoreDisplay(runnerElement, runner, topSelection) {
538 // Remove existing score display if any
539 const existing = runnerElement.querySelector('.handicap-analyzer-score');
540 if (existing) existing.remove();
541
542 // Create score display
543 const scoreDiv = document.createElement('div');
544 scoreDiv.className = 'handicap-analyzer-score';
545 scoreDiv.style.cssText = `
546 display: flex;
547 align-items: center;
548 gap: 8px;
549 padding: 8px 12px;
550 margin: 4px 0;
551 border-radius: 4px;
552 font-weight: bold;
553 font-size: 14px;
554 `;
555
556 // Color coding based on contender type
557 let bgColor = '#f5f5f5';
558 let textColor = '#333';
559 let badge = '';
560
561 if (runner.contenderType === 'HVC') {
562 bgColor = '#4CAF50';
563 textColor = '#fff';
564 badge = '🏆 HVC';
565 } else if (runner.contenderType === 'Primary') {
566 bgColor = '#2196F3';
567 textColor = '#fff';
568 badge = '⭐ PRIMARY';
569 }
570
571 if (runner === topSelection) {
572 scoreDiv.style.border = '3px solid #FF9800';
573 badge = '👑 ' + (badge || 'TOP PICK');
574 }
575
576 scoreDiv.style.backgroundColor = bgColor;
577 scoreDiv.style.color = textColor;
578
579 // Build score breakdown
580 const scoreBreakdown = `
581 <div style="display: flex; align-items: center; gap: 12px; width: 100%;">
582 <div style="font-size: 18px; font-weight: bold; min-width: 60px;">
583 ${badge ? badge : `Score: ${runner.totalScore}`}
584 </div>
585 <div style="font-size: 16px; font-weight: bold; color: ${textColor};">
586 Total: ${runner.totalScore}/10
587 </div>
588 <div style="font-size: 12px; opacity: 0.9;">
589 T1:${runner.scores.t1} T2:${runner.scores.t2} T3:${runner.scores.t3}
590 T4:${runner.scores.t4} T6:${runner.scores.t6} T7:${runner.scores.t7} T8:${runner.scores.t8}
591 </div>
592 ${runner.isT5Qualifier ? '<div style="background: #FF9800; color: white; padding: 2px 6px; border-radius: 3px; font-size: 11px;">T5✓</div>' : ''}
593 </div>
594 `;
595
596 scoreDiv.innerHTML = scoreBreakdown;
597
598 // Insert after runner info
599 const infoWrapper = runnerElement.querySelector('.RC-runnerRowInfoWrapper');
600 if (infoWrapper) {
601 infoWrapper.insertAdjacentElement('afterend', scoreDiv);
602 }
603 }
604
605 function addSummaryPanel(runners, topSelection, raceData) {
606 // Remove existing panel if any
607 const existing = document.querySelector('.handicap-analyzer-summary');
608 if (existing) existing.remove();
609
610 // Create summary panel
611 const panel = document.createElement('div');
612 panel.className = 'handicap-analyzer-summary';
613 panel.style.cssText = `
614 position: fixed;
615 top: 80px;
616 right: 20px;
617 width: 320px;
618 background: white;
619 border: 2px solid #2196F3;
620 border-radius: 8px;
621 padding: 16px;
622 box-shadow: 0 4px 12px rgba(0,0,0,0.15);
623 z-index: 10000;
624 font-family: Arial, sans-serif;
625 max-height: 80vh;
626 overflow-y: auto;
627 `;
628
629 const primaryContenders = runners.filter(r => r.contenderType === 'Primary' || r.contenderType === 'HVC');
630 const hvcContenders = runners.filter(r => r.contenderType === 'HVC');
631
632 panel.innerHTML = `
633 <div style="margin-bottom: 12px; padding-bottom: 12px; border-bottom: 2px solid #2196F3;">
634 <h3 style="margin: 0 0 8px 0; color: #2196F3; font-size: 16px;">
635 🏇 Handicap Analyzer
636 </h3>
637 <div style="font-size: 12px; color: #666;">
638 ${raceData.raceType} | Class ${raceData.raceClass} | ${raceData.distance}
639 </div>
640 </div>
641
642 <div style="margin-bottom: 12px; padding: 12px; background: #FFF3E0; border-radius: 6px; border-left: 4px solid #FF9800;">
643 <div style="font-weight: bold; color: #FF9800; margin-bottom: 6px;">👑 TOP SELECTION</div>
644 <div style="font-size: 14px; font-weight: bold;">${topSelection.name}</div>
645 <div style="font-size: 12px; color: #666; margin-top: 4px;">
646 Score: ${topSelection.totalScore}/10 | ${topSelection.oddsFractional || 'SP'}
647 </div>
648 </div>
649
650 ${hvcContenders.length > 0 ? `
651 <div style="margin-bottom: 12px; padding: 12px; background: #E8F5E9; border-radius: 6px; border-left: 4px solid #4CAF50;">
652 <div style="font-weight: bold; color: #4CAF50; margin-bottom: 6px;">🏆 HVC CONTENDERS (${hvcContenders.length})</div>
653 ${hvcContenders.map(r => `
654 <div style="font-size: 13px; padding: 4px 0; border-bottom: 1px solid #ddd;">
655 ${r.name} - ${r.totalScore}/10 @ ${r.oddsFractional || 'SP'}
656 </div>
657 `).join('')}
658 </div>
659 ` : ''}
660
661 ${primaryContenders.length > 0 ? `
662 <div style="margin-bottom: 12px; padding: 12px; background: #E3F2FD; border-radius: 6px; border-left: 4px solid #2196F3;">
663 <div style="font-weight: bold; color: #2196F3; margin-bottom: 6px;">⭐ PRIMARY CONTENDERS (${primaryContenders.length})</div>
664 ${primaryContenders.slice(0, 5).map(r => `
665 <div style="font-size: 13px; padding: 4px 0; border-bottom: 1px solid #ddd;">
666 ${r.name} - ${r.totalScore}/10
667 </div>
668 `).join('')}
669 </div>
670 ` : ''}
671
672 <div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid #ddd;">
673 <div style="font-size: 11px; color: #999; text-align: center;">
674 Analysis based on 8-factor scoring system (T1-T8)
675 </div>
676 </div>
677 `;
678
679 document.body.appendChild(panel);
680
681 // Make panel draggable (optional enhancement)
682 makeDraggable(panel);
683 }
684
685 function makeDraggable(element) {
686 let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
687
688 element.onmousedown = dragMouseDown;
689
690 function dragMouseDown(e) {
691 e = e || window.event;
692 e.preventDefault();
693 pos3 = e.clientX;
694 pos4 = e.clientY;
695 document.onmouseup = closeDragElement;
696 document.onmousemove = elementDrag;
697 }
698
699 function elementDrag(e) {
700 e = e || window.event;
701 e.preventDefault();
702 pos1 = pos3 - e.clientX;
703 pos2 = pos4 - e.clientY;
704 pos3 = e.clientX;
705 pos4 = e.clientY;
706 element.style.top = (element.offsetTop - pos2) + "px";
707 element.style.left = (element.offsetLeft - pos1) + "px";
708 element.style.right = 'auto';
709 }
710
711 function closeDragElement() {
712 document.onmouseup = null;
713 document.onmousemove = null;
714 }
715 }
716
717 // ========================================
718 // 8. INITIALIZATION
719 // ========================================
720
721 function init() {
722 console.log('Initializing Handicap Analyzer...');
723
724 // Check if we're on a race card page
725 if (!window.location.pathname.includes('/racecards/')) {
726 console.log('Not on a race card page. Extension inactive.');
727 return;
728 }
729
730 // Wait for page to load
731 if (document.readyState === 'loading') {
732 document.addEventListener('DOMContentLoaded', init);
733 return;
734 }
735
736 // Wait for runners to be present
737 const checkRunners = setInterval(() => {
738 const runners = document.querySelectorAll('.RC-runnerRow');
739 if (runners.length > 0) {
740 clearInterval(checkRunners);
741 console.log('Runners detected. Starting analysis...');
742
743 // Run analysis after a short delay to ensure all data is loaded
744 setTimeout(() => {
745 analyzeRace().catch(err => {
746 console.error('Analysis error:', err);
747 });
748 }, 2000);
749 }
750 }, 500);
751
752 // Timeout after 10 seconds
753 setTimeout(() => {
754 clearInterval(checkRunners);
755 }, 10000);
756
757 // Re-run analysis if page content changes (e.g., odds update)
758 const debouncedAnalyze = debounce(() => {
759 console.log('Page updated, re-analyzing...');
760 analyzeRace().catch(err => {
761 console.error('Re-analysis error:', err);
762 });
763 }, 3000);
764
765 // Observe DOM changes
766 const observer = new MutationObserver(debouncedAnalyze);
767 observer.observe(document.body, {
768 childList: true,
769 subtree: true
770 });
771 }
772
773 // Start the extension
774 init();
775
776})();