- 1scraper.php:
- 2
- 3<?php
- 4require_once 'utils.php';
- 5require_once __DIR__ . '/app/db.php';
- 6
- 7error_reporting(E_ALL);
- 8ini_set('display_errors', '1');
- 9
- 10function e($v){
- 11 return htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8');
- 12}
- 13
- 14/* ✅ LOG */
- 15function logAction($accion,$detalle){
- 16 try{
- 17 gm_db()->prepare("INSERT INTO scraping_logs (accion,detalle) VALUES(?,?)")
- 18 ->execute([$accion,$detalle]);
- 19 }catch(Exception $e){}
- 20}
- 21
- 22/* ✅ PARSER */
- 23function parseHistorico($html){
- 24
- 25 libxml_use_internal_errors(true);
- 26
- 27 $data = [
- 28 'evento'=>'',
- 29 'pista'=>'',
- 30 'distancia'=>'',
- 31 'hora'=>'',
- 32 'carrera'=>'',
- 33 'carrera_numero'=> null, // ✅ AÑADE ESTO
- 34 'estado'=>'Finalizada',
- 35 'runners'=>[]
- 36 ];
- 37
- 38 // ✅ PISTA
- 39 if(preg_match('/\b(Manawatu|Doncaster|Hove|Sunderland|Yarmouth)\b/i',$html,$m)){
- 40 $data['pista'] = $m[1];
- 41 }
- 42
- 43 // ✅ EVENTO
- 44 if(preg_match('/\n([A-Za-z\s]+Heat)/',$html,$m)){
- 45 $data['evento'] = trim($m[1]);
- 46 }
- 47
- 48 // ✅ DISTANCIA
- 49 if(preg_match('/(\d{2,4})m/',$html,$m)){
- 50 $data['distancia'] = $m[1];
- 51 }
- 52
- 53 // ✅ HORA
- 54 if(preg_match('/Empezada\s*(\d{2}:\d{2}:\d{2})/',$html,$m)){
- 55 $data['hora'] = $m[1];
- 56 }
- 57
- 58 // ✅ ESTADO
- 59 if(stripos($html,'Finalizada') !== false){
- 60 $data['estado'] = 'Finalizada';
- 61 }
- 62
- 63 // ✅ CARRERA
- 64 if(preg_match('/Carrera\s*(\d+)/',$html,$m)){
- 65 $data['carrera_numero'] = (int)$m[1];
- 66 }
- 67
- 68 $data['runners'] = [];
- 69
- 70 if(preg_match_all('/(\d+)º\s+(\d+)\s+([A-Za-z\s]+)/',$html,$matches,PREG_SET_ORDER)){
- 71
- 72 foreach($matches as $m){
- 73
- 74 $posicion = (int)$m[1];
- 75 $trap = (int)$m[2];
- 76 $nombre = trim($m[3]);
- 77
- 78 if($posicion < 1 || $posicion > 8) continue;
- 79
- 80 $data['runners'][] = [
- 81 'nombre' => $nombre,
- 82 'posicion' => $posicion,
- 83 'trap' => $trap
- 84 ];
- 85 }
- 86 }
- 87
- 88 // ✅ PARSER GLOBAL (robusto para Bet365 moderno)
- 89
- 90 // 🏟 pista (ej: Doncaster)
- 91 if(empty($data['pista']) && preg_match('/\b(Doncaster|Manukau|Hove|Sunderland|Yarmouth)\b/i',$html,$m)){
- 92 $data['pista'] = $m[1];
- 93 }
- 94
- 95 // 📏 distancia (ej: 275m)
- 96 if(empty($data['distancia']) && preg_match('/(\d{2,4})\s*m/i',$html,$m)){
- 97 $data['distancia'] = $m[1];
- 98 }
- 99
- 100 // 🕒 hora (ej: 08:26:23)
- 101 if(empty($data['hora']) && preg_match('/(\d{2}:\d{2}:\d{2})/',$html,$m)){
- 102 $data['hora'] = $m[1];
- 103 }
- 104
- 105 // 📊 estado
- 106 if(stripos($html,'finalizada') !== false){
- 107 $data['estado'] = 'Finalizada';
- 108 }elseif(stripos($html,'empezada') !== false){
- 109 $data['estado'] = 'En curso';
- 110 }
- 111
- 112 // 🏁 número de carrera (DE LAS BURBUJAS)
- 113 if(empty($data['carrera_numero'])){
- 114
- 115 if(preg_match('/>\s*(\d{1,2})\s*</',$html,$m)){
- 116 $data['carrera_numero'] = (int)$m[1];
- 117 }
- 118 }
- 119
- 120 if(empty($data['pista'])){
- 121 if(preg_match('/\b([A-Z][a-z]+)\b\s*</',$html,$m)){
- 122 $data['pista'] = $m[1];
- 123 }
- 124 }
- 125
- 126
- 127 // ✅ detectar número desde burbujas
- 128 if(empty($data['carrera_numero']) && !empty($html)){
- 129 if(preg_match('/>\s*(\d{1,2})\s*</',$html,$m)){
- 130 $data['carrera_numero'] = (int)$m[1];
- 131 }
- 132 }
- 133
- 134
- 135 // ✅ FALLBACK GLOBAL (muy importante)
- 136 if(empty($data['carrera_numero'])){
- 137
- 138 if(preg_match('/\b(\d{1,2})\b/', $html, $m)){
- 139 $data['carrera_numero'] = (int)$m[1];
- 140 }
- 141 }
- 142
- 143 return $data;
- 144}
- 145
- 146/* ✅ GUARDADO */
- 147function saveRaceDB($data){
- 148
- 149 $pdo = gm_db();
- 150 $pdo->beginTransaction();
- 151
- 152 try{
- 153
- 154 if(empty($data['runners'])){
- 155 throw new Exception("No hay runners");
- 156 }
- 157
- 158 $hash = md5(uniqid().serialize($data));
- 159
- 160 $stmt = $pdo->prepare("
- 161 INSERT INTO carreras
- 162 (pista, distancia, evento, hash, tipo, fuente, estado, fecha_carrera, pais, carrera_numero, codigo_unico)
- 163 VALUES (?, ?, ?, ?, 'Greyhound','html_manual','finalizada', ?, ?, ?, ?)
- 164 ");
- 165
- 166
- 167 // ✅ mapa de pistas
- 168 $TRACK_MAP = [
- 169 'Manukau' => ['pais'=>'Nueva Zelanda'],
- 170 'Healesville' => ['pais'=>'Australia'],
- 171 'Capalaba' => ['pais'=>'Australia'],
- 172 'Mount Gambier' => ['pais'=>'Australia'],
- 173 'Sale' => ['pais'=>'Australia'],
- 174 'Rockhampton' => ['pais'=>'Australia'],
- 175 'Richmond' => ['pais'=>'Australia'],
- 176 'Nowra' => ['pais'=>'Australia'],
- 177 'Q2 Parklands' => ['pais'=>'Australia'],
- 178 'Gawler' => ['pais'=>'Australia'],
- 179 'Temora' => ['pais'=>'Australia'],
- 180 'Darwin' => ['pais'=>'Australia'],
- 181 'Valley' => ['pais'=>'UK'],
- 182 'Doncaster' => ['pais'=>'UK'],
- 183 'Yarmouth' => ['pais'=>'UK'],
- 184 'Sunderland' => ['pais'=>'UK'],
- 185 'Hove' => ['pais'=>'UK'],
- 186 ];
- 187
- 188 // ✅ obtener país
- 189 $trackInfo = $TRACK_MAP[$data['pista']] ?? ['pais'=>'Desconocido'];
- 190
- 191 // ✅ generar código único
- 192 $codigo = generarCodigoCarrera([
- 193 'pais' => $trackInfo['pais'],
- 194 'pista' => $data['pista'],
- 195 'carrera_numero' => $data['carrera_numero'] ?? 0,
- 196 'evento' => $data['evento'],
- 197 'fecha_carrera' => $data['hora']
- 198 ? date('Y-m-d').' '.$data['hora']
- 199 : date('Y-m-d') // ✅ fallback seguro
- 200 ]);
- 201
- 202 // ✅ asegurar unicidad del código
- 203 $stmtCheck = $pdo->prepare("
- 204 SELECT COUNT(*) FROM carreras WHERE codigo_unico = ?
- 205 ");
- 206 $stmtCheck->execute([$codigo]);
- 207
- 208 if($stmtCheck->fetchColumn() > 0){
- 209
- 210 // 🔥 generar sufijo incremental limpio
- 211 $suffix = 1;
- 212
- 213 do {
- 214 $nuevoCodigo = $codigo . str_pad($suffix, 2, '0', STR_PAD_LEFT);
- 215
- 216 $stmtCheck = $pdo->prepare("
- 217 SELECT COUNT(*) FROM carreras WHERE codigo_unico = ?
- 218 ");
- 219 $stmtCheck->execute([$nuevoCodigo]);
- 220
- 221 $existe = $stmtCheck->fetchColumn();
- 222 $suffix++;
- 223
- 224 } while($existe > 0);
- 225
- 226 $codigo = $nuevoCodigo;
- 227 }
- 228
- 229 // ✅ generar fecha carrera
- 230 $fechaCarrera = $data['hora']
- 231 ? date('Y-m-d').' '.$data['hora']
- 232 : date('Y-m-d H:i:s');
- 233
- 234 // ✅ ejecutar
- 235 $stmt->execute([
- 236 $data['pista'], // 1
- 237 $data['distancia'], // 2
- 238 $data['evento'], // 3
- 239 $hash, // 4
- 240 $fechaCarrera, // 5
- 241 $trackInfo['pais'], // 6
- 242 $data['carrera_numero'] ?? null, // 7
- 243 $codigo // ✅ NUEVO
- 244 ]);
- 245
- 246
- 247 $carreraId = $pdo->lastInsertId();
- 248
- 249 foreach($data['runners'] as $r){
- 250
- 251 $nombre = trim($r['nombre']);
- 252
- 253 $stmt=$pdo->prepare("SELECT id_galgo FROM galgos WHERE nombre=?");
- 254 $stmt->execute([$nombre]);
- 255 $gid=$stmt->fetchColumn();
- 256
- 257 if(!$gid){
- 258
- 259 $slug = strtolower(trim(preg_replace('/[^A-Za-z0-9]+/', '-', $nombre)));
- 260 $slug = trim($slug, '-');
- 261
- 262 $stmt=$pdo->prepare("SELECT COUNT(*) FROM galgos WHERE slug=?");
- 263 $stmt->execute([$slug]);
- 264
- 265 if($stmt->fetchColumn()>0){
- 266 $slug .= "-".uniqid();
- 267 }
- 268
- 269 $stmt=$pdo->prepare("INSERT INTO galgos (nombre,slug) VALUES (?,?)");
- 270 $stmt->execute([$nombre,$slug]);
- 271
- 272 $gid=$pdo->lastInsertId();
- 273 }
- 274
- 275 $stmt=$pdo->prepare("
- 276 INSERT INTO participaciones (carrera_id,galgo_id,posicion,trap)
- 277 VALUES (?,?,?,?)
- 278 ");
- 279
- 280 $stmt->execute([
- 281 $carreraId,
- 282 $gid,
- 283 $r['posicion'],
- 284 $r['trap']
- 285 ]);
- 286 }
- 287
- 288 $pdo->commit();
- 289
- 290 return "✅ Guardado correctamente";
- 291
- 292 }catch(Exception $e){
- 293
- 294 $pdo->rollBack();
- 295
- 296 echo "<pre style='color:red'>";
- 297 echo "ERROR:\n".$e->getMessage();
- 298 echo "</pre>";
- 299 die();
- 300 }
- 301}
- 302
- 303/* ✅ CONTROL */
- 304$data=null;
- 305$msg=null;
- 306$html=$_POST['html'] ?? '';
- 307
- 308if($_SERVER['REQUEST_METHOD']=='POST'){
- 309
- 310 if($_POST['action']=='preview'){
- 311 $data=parseHistorico($html);
- 312 }
- 313
- 314 if($_POST['action']=='save'){
- 315 $payload=json_decode(base64_decode($_POST['payload']),true);
- 316 $msg=saveRaceDB($payload);
- 317 $data=$payload;
- 318 }
- 319
- 320}
- 321
- 322$logs = gm_db()->query("SELECT * FROM scraping_logs ORDER BY id DESC LIMIT 10")->fetchAll();
- 323?>
- 324
- 325<!doctype html>
- 326<html>
- 327<head>
- 328<meta charset="utf-8">
- 329<title>GalgoMaster PRO</title>
- 330
- 331<style>
- 332body{
- 333 background:linear-gradient(135deg,#020617,#0f172a);
- 334 color:#e2e8f0;
- 335 font-family:system-ui;
- 336}
- 337
- 338/* nombre galgo */
- 339.dog-name{
- 340 display:block;
- 341 margin-top:6px;
- 342 font-size:15px;
- 343 font-weight:600;
- 344}
- 345
- 346/* cajón */
- 347.dog-trap{
- 348 margin-top:6px;
- 349 font-size:13px;
- 350 font-weight:700;
- 351 color:#ef4444;
- 352
- 353 background:rgba(239,68,68,0.1);
- 354 padding:4px 8px;
- 355 border-radius:6px;
- 356
- 357 display:inline-block;
- 358}
- 359
- 360/* hover más elegante */
- 361.box:hover .dog-trap{
- 362 background:#ef4444;
- 363 color:white;
- 364}
- 365
- 366.box{
- 367 background:#020617;
- 368 padding:20px;
- 369 border-radius:12px;
- 370 text-align:center;
- 371 transition:0.2s;
- 372 border:1px solid transparent;
- 373}
- 374
- 375.box:hover{
- 376 transform:translateY(-4px);
- 377 border:1px solid #22c55e;
- 378}
- 379
- 380/* layout */
- 381.container{max-width:1100px;margin:auto;}
- 382
- 383.card{
- 384 background:#111827;
- 385 padding:20px;
- 386 margin:20px 0;
- 387 border-radius:16px;
- 388 box-shadow:0 10px 30px rgba(0,0,0,.5);
- 389}
- 390
- 391/* textarea */
- 392textarea{
- 393 width:100%;
- 394 height:220px;
- 395 background:#020617;
- 396 color:#22c55e;
- 397 border-radius:12px;
- 398 padding:12px;
- 399}
- 400
- 401/* botones */
- 402button{
- 403 background:#22c55e;
- 404 border:0;
- 405 padding:12px 18px;
- 406 border-radius:10px;
- 407 font-weight:bold;
- 408 cursor:pointer;
- 409}
- 410
- 411/* header */
- 412.topbar{
- 413 display:flex;
- 414 justify-content:space-between;
- 415 align-items:center;
- 416}
- 417
- 418/* ✅ GRID */
- 419.grid{
- 420 display:grid;
- 421 grid-template-columns:repeat(4,1fr);
- 422 gap:15px;
- 423}
- 424
- 425/* ✅ BOX */
- 426.box{
- 427 background:#020617;
- 428 padding:20px;
- 429 border-radius:12px;
- 430 text-align:center;
- 431 transition:0.2s;
- 432}
- 433.box:hover{
- 434 transform:scale(1.05);
- 435}
- 436
- 437/* posiciones */
- 438.first{border-top:4px solid gold;}
- 439.second{border-top:4px solid silver;}
- 440.third{border-top:4px solid #f59e0b;}
- 441.normal{border-top:4px solid #334155;}
- 442
- 443.icon{font-size:32px;margin-bottom:10px;}
- 444
- 445/* logs */
- 446.logsBox{
- 447 max-height:200px;
- 448 overflow-y:auto;
- 449}
- 450.log{
- 451 font-size:12px;
- 452 padding:6px;
- 453 border-bottom:1px solid #333;
- 454}
- 455
- 456</style>
- 457</head>
- 458
- 459<body>
- 460
- 461<div class="container">
- 462
- 463<div class="topbar">
- 464<h1>🐕 GalgoMaster PRO</h1>
- 465<button onclick="location.href='historial.php'">📊 Historial</button>
- 466</div>
- 467
- 468<?php if($msg): ?>
- 469<div class="card"><?= e($msg) ?></div>
- 470<?php endif; ?>
- 471
- 472<div class="card">
- 473<form method="post">
- 474<textarea name="html"><?= e($html) ?></textarea>
- 475<br><br>
- 476<button name="action" value="preview">🔍 Analizar</button>
- 477</form>
- 478</div>
- 479
- 480<?php if($data): ?>
- 481
- 482<div class="card">
- 483
- 484<h2><?= e($data['evento']) ?></h2>
- 485<p>
- 486📍 <?= e($data['pista']) ?> — <?= e($data['distancia'] ?? '-') ?> m.
- 487🏁 N° Carrera: <?= e($data['carrera'] ?? '-') ?>
- 488🕒 Hora de Inicio: <?= e($data['hora'] ?? '-') ?>
- 489📊 Estado: - <?= e($data['estado'] ?? '-') ?> -
- 490</p>
- 491
- 492<div class="grid">
- 493
- 494<?php foreach($data['runners'] as $r):
- 495
- 496$class="normal";
- 497$icon="🐕";
- 498
- 499if($r['posicion']==1){ $class="first"; $icon="🥇"; }
- 500elseif($r['posicion']==2){ $class="second"; $icon="🥈"; }
- 501elseif($r['posicion']==3){ $class="third"; $icon="🥉"; }
- 502
- 503?>
- 504
- 505<div class="box <?= $class ?>">
- 506<div class="icon"><?= $icon ?></div>
- 507<b class="dog-name"><?= e($r['nombre']) ?></b>
- 508
- 509<?php if(!empty($r['trap'])): ?>
- 510<div class="dog-trap">
- 511 Cajón N° <?= e($r['trap']) ?>
- 512</div>
- 513<?php endif; ?>
- 514</div>
- 515
- 516<?php endforeach; ?>
- 517
- 518</div>
- 519
- 520<br>
- 521
- 522<form method="post">
- 523<input type="hidden" name="action" value="save">
- 524<input type="hidden" name="payload" value="<?= base64_encode(json_encode($data)) ?>">
- 525<button>💾 Guardar</button>
- 526</form>
- 527
- 528</div>
- 529
- 530<?php endif; ?>
- 531
- 532<div class="card">
- 533<h3>📜 Logs</h3>
- 534
- 535<div class="logsBox">
- 536
- 537<?php foreach($logs as $l): ?>
- 538<div class="log">
- 539<?= e($l['fecha'] ?? '') ?> → <?= e($l['accion'] ?? '') ?>
- 540</div>
- 541<?php endforeach; ?>
- 542
- 543</div>
- 544
- 545</div>
- 546
- 547</div>
- 548
- 549</body>
- 550</html>
- 551
- 552
- 553
- 554
- 555app/Bet365Parser.php:
- 556
- 557<?php
- 558
- 559declare(strict_types=1);
- 560
- 561require_once __DIR__ . '/StyleDetector.php';
- 562require_once __DIR__ . '/RatingService.php';
- 563
- 564final class Bet365Parser
- 565{
- 566 public function parse(string $html): array
- 567 {
- 568 $html = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8');
- 569 $html = trim($html);
- 570
- 571 if ($html === '') {
- 572 throw new RuntimeException('El HTML está vacío.');
- 573 }
- 574
- 575 libxml_use_internal_errors(true);
- 576
- 577 $dom = new DOMDocument('1.0', 'UTF-8');
- 578
- 579 $loaded = $dom->loadHTML(
- 580 '<?xml encoding="UTF-8">' . $html,
- 581 LIBXML_NOWARNING | LIBXML_NOERROR | LIBXML_COMPACT
- 582 );
- 583
- 584 if (!$loaded) {
- 585 throw new RuntimeException('No se pudo interpretar el HTML.');
- 586 }
- 587
- 588 $xpath = new DOMXPath($dom);
- 589
- 590 $race = $this->parseRace($xpath);
- 591 $runners = $this->parseRunners($xpath, $race);
- 592
- 593 return [
- 594 'html_hash' => hash('sha256', $html),
- 595 'race' => $race,
- 596 'runners' => $runners,
- 597 ];
- 598 }
- 599
- 600 public function parseHistorico(string $html): array
- 601 {
- 602 libxml_use_internal_errors(true);
- 603
- 604 $dom = new DOMDocument();
- 605 $dom->loadHTML($html);
- 606
- 607 $xpath = new DOMXPath($dom);
- 608
- 609 /* ✅ INFO BÁSICA */
- 610 $pista = '';
- 611 $evento = '';
- 612 $hora = null;
- 613 $carrera = null;
- 614 $distancia = null;
- 615
- 616 // pista
- 617 $n = $xpath->query("//*[contains(@class,'rcr-20')]");
- 618 if ($n->length) $pista = trim($n->item(0)->textContent);
- 619
- 620 // nombre evento
- 621 $n = $xpath->query("//*[contains(@class,'rcr-67')]");
- 622 if ($n->length) $evento = trim($n->item(0)->textContent);
- 623
- 624 // info carrera
- 625 $info = $xpath->query("//*[contains(@class,'rcr-14')]");
- 626 foreach ($info as $i) {
- 627 $txt = trim($i->textContent);
- 628
- 629 if (strpos($txt, 'Carrera') !== false) {
- 630 preg_match('/(\d+)/', $txt, $m);
- 631 $carrera = $m[1] ?? null;
- 632 }
- 633
- 634 if (strpos($txt, 'Empezada') !== false) {
- 635 preg_match('/(\d{2}:\d{2}:\d{2})/', $txt, $m);
- 636 $hora = $m[1] ?? null;
- 637 }
- 638
- 639 if (preg_match('/(\d+)m/', $txt, $m)) {
- 640 $distancia = (int)$m[1];
- 641 }
- 642 }
- 643
- 644 /* ✅ RESULTADOS */
- 645 $runners = [];
- 646
- 647 $rows = $xpath->query("//*[contains(@class,'rrs-610')]");
- 648
- 649 foreach ($rows as $row) {
- 650
- 651 $posNode = $xpath->query(".//*[contains(@class,'rrs-1a')]", $row);
- 652 $nameNode = $xpath->query(".//*[contains(@class,'rrs-44')]", $row);
- 653 $trapNode = $xpath->query(".//img", $row);
- 654
- 655 if (!$nameNode->length) continue;
- 656
- 657 $name = trim($nameNode->item(0)->textContent);
- 658 if ($name === '') continue;
- 659
- 660 $pos = $posNode->length ? trim($posNode->item(0)->textContent) : null;
- 661
- 662 $posNum = null;
- 663 if ($pos && preg_match('/(\d+)/', $pos, $m)) {
- 664 $posNum = (int)$m[1];
- 665 }
- 666
- 667 $trap = $trapNode->length ? $trapNode->item(0)->getAttribute('alt') : null;
- 668
- 669 /* ✅ ODDS */
- 670 $oddNodes = $xpath->query(".//*[contains(@class,'rrs-763')]", $row);
- 671
- 672 $odds = [];
- 673 foreach ($oddNodes as $o) {
- 674 $val = trim($o->textContent);
- 675 if ($val !== '-' && $val !== '') {
- 676 $odds[] = (float)str_replace(',', '.', $val);
- 677 }
- 678 }
- 679
- 680 $avgOdds = count($odds) ? array_sum($odds)/count($odds) : null;
- 681
- 682 $runners[] = [
- 683 'trap' => $trap,
- 684 'nombre' => $name,
- 685 'posicion' => $pos,
- 686 'posicion_num' => $posNum,
- 687 'odds_promedio' => $avgOdds,
- 688 'odds' => $odds
- 689 ];
- 690 }
- 691
- 692 /* ✅ DISTANCIA ENTRE GALGOS */
- 693 $gap = null;
- 694 $gapNode = $xpath->query("//*[contains(@class,'rrs-035')]");
- 695 if ($gapNode->length) {
- 696 $gap = trim($gapNode->item(0)->textContent);
- 697 }
- 698
- 699 return [
- 700 'html_hash' => hash('sha256', $html),
- 701 'race' => [
- 702 'pista' => $pista,
- 703 'evento' => $evento,
- 704 'hora' => $hora,
- 705 'carrera' => $carrera,
- 706 'distancia' => $distancia,
- 707 'gap' => $gap
- 708 ],
- 709 'runners' => $runners
- 710 ];
- 711}
- 712
- 713 private function parseRace(DOMXPath $xpath): array
- 714 {
- 715 $pista = $this->firstText($xpath, "//*[contains(@class,'rcr-20')]");
- 716 $hora = $this->firstText($xpath, "//*[contains(@class,'rcr-31')]");
- 717
- 718 $carreraNumero = null;
- 719 $distancia = null;
- 720
- 721 $raceLabels = $this->texts($xpath, "//*[contains(@class,'rcr-4b')]");
- 722
- 723 foreach ($raceLabels as $label) {
- 724 if (stripos($label, 'Carrera') !== false) {
- 725 $carreraNumero = trim(str_replace('•', '', $label));
- 726 }
- 727
- 728 if (preg_match('/(\d{3,4})\s*m/i', $label, $m)) {
- 729 $distancia = (int) $m[1];
- 730 }
- 731 }
- 732
- 733 $preview = $this->firstText($xpath, "//*[contains(@class,'sr-0f27e2')]");
- 734
- 735 $fechaHora = null;
- 736
- 737 if ($hora && preg_match('/^\d{1,2}:\d{2}$/', $hora)) {
- 738 $fechaHora = date('Y-m-d') . ' ' . $hora . ':00';
- 739 }
- 740
- 741 return [
- 742 'pista' => $pista ?: 'Unknown',
- 743 'hora_texto' => $hora ?: null,
- 744 'fecha_texto' => date('Y-m-d'),
- 745 'fecha_hora' => $fechaHora,
- 746 'carrera_numero' => $carreraNumero,
- 747 'distancia' => $distancia,
- 748 'grado' => null,
- 749 'tipo' => 'Greyhound',
- 750 'going' => null,
- 751 'preview' => $preview ?: null,
- 752 'comentario_experto' => $preview ?: null,
- 753 ];
- 754 }
- 755
- 756 private function parseRunners(DOMXPath $xpath, array $race): array
- 757 {
- 758 $nodes = $xpath->query("//*[contains(concat(' ', normalize-space(@class), ' '), ' rg-cb ')]");
- 759
- 760 $runners = [];
- 761 $seen = [];
- 762
- 763 foreach ($nodes as $node) {
- 764 if (!$node instanceof DOMElement) {
- 765 continue;
- 766 }
- 767
- 768 $name = $this->firstTextRelative($xpath, $node, ".//*[contains(@class,'rg-0d')]");
- 769 $form = $this->firstAttributeRelative($xpath, $node, ".//*[contains(@class,'rg-0e')]", 'data-content');
- 770
- 771 if (!$form) {
- 772 $form = $this->firstTextRelative($xpath, $node, ".//*[contains(@class,'rg-0e')]");
- 773 }
- 774
- 775 if (!$name || !$form || !preg_match('/^?:-[1-6]{2,}$/', $form)) {
- 776 continue;
- 777 }
- 778
- 779 $trap = $this->extractTrap($xpath, $node);
- 780 if (!$trap) {
- 781 $trap = count($runners) + 1;
- 782 }
- 783
- 784 $oddsFinalText = $this->firstTextRelative($xpath, $node, ".//*[contains(@class,'rul-ce0412')]");
- 785 $oddsFinal = RatingService::decimalFromFraction($oddsFinalText);
- 786
- 787 $oddsHistory = $this->textsRelative($xpath, $node, ".//*[contains(@class,'sr-dc6edd')]//span");
- 788 $oddsInicial = null;
- 789
- 790 if (!empty($oddsHistory[0])) {
- 791 $oddsInicial = RatingService::decimalFromFraction($oddsHistory[0]);
- 792 }
- 793
- 794 if ($oddsInicial === null) {
- 795 $oddsInicial = $oddsFinal;
- 796 }
- 797
- 798 $history = $this->parseHistory($xpath, $node, $race['pista'] ?? null);
- 799
- 800 $remarksList = [];
- 801
- 802 foreach ($history as $row) {
- 803 if (!empty($row['remarks'])) {
- 804 $remarksList[] = $row['remarks'];
- 805 }
- 806 }
- 807
- 808 $style = StyleDetector::detectFromMany($remarksList);
- 809
- 810 $rating = RatingService::calculate([
- 811 'forma_reciente' => $form,
- 812 'historial' => $history,
- 813 'estilo_detectado' => $style,
- 814 ], $race['distancia'] ?? null);
- 815
- 816 $value = RatingService::valueSignal($rating['rating'], $oddsFinal);
- 817
- 818 $latest = $history[0] ?? [];
- 819
- 820 $runner = [
- 821 'trap' => (int) $trap,
- 822 'nombre' => trim($name),
- 823 'forma_reciente' => trim($form),
- 824 'odds_inicial' => $oddsInicial,
- 825 'odds_final' => $oddsFinal,
- 826 'implied_probability' => RatingService::impliedProbability($oddsFinal),
- 827 'odds_movimiento' => $oddsHistory,
- 828 'cal_tm' => isset($latest['cal_tm']) ? $latest['cal_tm'] : null,
- 829 'win_time' => isset($latest['win_tm']) ? $latest['win_tm'] : null,
- 830 'splits' => isset($latest['splits']) ? $latest['splits'] : null,
- 831 'db' => isset($latest['db']) ? $latest['db'] : null,
- 832 'remarks' => implode(' | ', array_slice($remarksList, 0, 3)),
- 833 'estilo_detectado' => $style,
- 834 'rating_pre' => $rating['rating'],
- 835 'score_forma' => $rating['score_forma'],
- 836 'score_velocidad' => $rating['score_velocidad'],
- 837 'score_estilo' => $rating['score_estilo'],
- 838 'value_score' => $value['value_score'],
- 839 'value_flag' => $value['value_flag'],
- 840 'historial' => $history,
- 841 'raw_text' => $this->cleanText($node->textContent),
- 842 ];
- 843
- 844 $key = $runner['trap'] . '-' . mb_strtolower($runner['nombre'], 'UTF-8');
- 845
- 846 if (!isset($seen[$key])) {
- 847 $seen[$key] = true;
- 848 $runners[] = $runner;
- 849 }
- 850 }
- 851
- 852 usort($runners, fn ($a, $b) => $a['trap'] <=> $b['trap']);
- 853
- 854 return $runners;
- 855 }
- 856
- 857 private function parseHistory(DOMXPath $xpath, DOMElement $runnerNode, ?string $pista): array
- 858 {
- 859 $columns = [];
- 860
- 861 $columnNodes = $xpath->query(
- 862 ".//*[contains(concat(' ', normalize-space(@class), ' '), ' rg-a3 ')]",
- 863 $runnerNode
- 864 );
- 865
- 866 foreach ($columnNodes as $columnNode) {
- 867 if (!$columnNode instanceof DOMElement) {
- 868 continue;
- 869 }
- 870
- 871 $header = $this->firstTextRelative($xpath, $columnNode, ".//*[contains(@class,'rg-58')]");
- 872 $values = $this->textsRelative($xpath, $columnNode, ".//*[contains(@class,'rg-7f')]");
- 873
- 874 $header = trim($header);
- 875
- 876 if ($header !== '') {
- 877 $columns[$header] = $values;
- 878 }
- 879 }
- 880
- 881 if (!$columns) {
- 882 return [];
- 883 }
- 884
- 885 $maxRows = 0;
- 886
- 887 foreach ($columns as $values) {
- 888 $maxRows = max($maxRows, count($values));
- 889 }
- 890
- 891 $history = [];
- 892
- 893 for ($i = 0; $i < $maxRows; $i++) {
- 894 $dateText = $columns['Date'][$i] ?? null;
- 895 $disTrp = $columns['Dis / Trp'][$i] ?? null;
- 896 $fin = $columns['Fin'][$i] ?? null;
- 897 $sp = $columns['SP'][$i] ?? null;
- 898 $gr = $columns['Gr'][$i] ?? null;
- 899 $calTm = $columns['CalTm'][$i] ?? null;
- 900 $splitTm = $columns['SplitTm'][$i] ?? null;
- 901 $splits = $columns['Splits'][$i] ?? null;
- 902 $db = $columns['DB'][$i] ?? null;
- 903 $remarks = $columns['Remarks'][$i] ?? null;
- 904 $winTm = $columns['WinTm'][$i] ?? null;
- 905 $going = $columns['G'][$i] ?? null;
- 906
- 907 $distance = null;
- 908 $trap = null;
- 909
- 910 if ($disTrp && preg_match('/(\d{3,4})\s*m\s*\[(\d)\]/i', $disTrp, $m)) {
- 911 $distance = (int) $m[1];
- 912 $trap = (int) $m[2];
- 913 }
- 914
- 915 $history[] = [
- 916 'fecha_texto' => $dateText,
- 917 'fecha' => $this->normaliseShortDate($dateText),
- 918 'pista' => $pista,
- 919 'distancia' => $distance,
- 920 'trap' => $trap,
- 921 'posicion' => $fin,
- 922 'posicion_num' => $this->positionToInt($fin),
- 923 'sp' => $sp,
- 924 'sp_decimal' => RatingService::decimalFromFraction($sp),
- 925 'grade' => $gr ?: null,
- 926 'cal_tm' => is_numeric($calTm) ? (float) $calTm : null,
- 927 'split_tm' => $splitTm ?: null,
- 928 'splits' => $splits ?: null,
- 929 'db' => $db ?: null,
- 930 'remarks' => $remarks ?: null,
- 931 'win_tm' => is_numeric($winTm) ? (float) $winTm : null,
- 932 'going' => $going ?: null,
- 933 ];
- 934 }
- 935
- 936 return $history;
- 937 }
- 938
- 939 private function extractTrap(DOMXPath $xpath, DOMElement $node): ?int
- 940 {
- 941 $alt = $this->firstAttributeRelative(
- 942 $xpath,
- 943 $node,
- 944 ".//img[contains(@src,'dog') or contains(@src,'traps_uk')]",
- 945 'alt'
- 946 );
- 947
- 948 if ($alt && preg_match('/^[1-6]$/', trim($alt))) {
- 949 return (int) $alt;
- 950 }
- 951
- 952 $src = $this->firstAttributeRelative(
- 953 $xpath,
- 954 $node,
- 955 ".//img[contains(@src,'dog') or contains(@src,'traps_uk')]",
- 956 'src'
- 957 );
- 958
- 959 if ($src && preg_match('/dog([1-6])\.svg/i', $src, $m)) {
- 960 return (int) $m[1];
- 961 }
- 962
- 963 return null;
- 964 }
- 965
- 966 private function normaliseShortDate(?string $date): ?string
- 967 {
- 968 $date = trim((string) $date);
- 969
- 970 if (!preg_match('/^(\d{1,2})\/(\d{1,2})$/', $date, $m)) {
- 971 return null;
- 972 }
- 973
- 974 $day = (int) $m[1];
- 975 $month = (int) $m[2];
- 976 $year = (int) date('Y');
- 977
- 978 if (!checkdate($month, $day, $year)) {
- 979 return null;
- 980 }
- 981
- 982 return sprintf('%04d-%02d-%02d', $year, $month, $day);
- 983 }
- 984
- 985 private function positionToInt(?string $position): ?int
- 986 {
- 987 if (!$position) {
- 988 return null;
- 989 }
- 990
- 991 if (preg_match('/(\d+)/', $position, $m)) {
- 992 return (int) $m[1];
- 993 }
- 994
- 995 return null;
- 996 }
- 997
- 998 private function firstText(DOMXPath $xpath, string $query): ?string
- 999 {
- 1000 $nodes = $xpath->query($query);
- 1001
- 1002 if (!$nodes || $nodes->length === 0) {
- 1003 return null;
- 1004 }
- 1005
- 1006 return $this->cleanText($nodes->item(0)->textContent ?? '');
- 1007 }
- 1008
- 1009 private function firstTextRelative(DOMXPath $xpath, DOMNode $context, string $query): ?string
- 1010 {
- 1011 $nodes = $xpath->query($query, $context);
- 1012
- 1013 if (!$nodes || $nodes->length === 0) {
- 1014 return null;
- 1015 }
- 1016
- 1017 return $this->cleanText($nodes->item(0)->textContent ?? '');
- 1018 }
- 1019
- 1020 private function firstAttributeRelative(DOMXPath $xpath, DOMNode $context, string $query, string $attr): ?string
- 1021 {
- 1022 $nodes = $xpath->query($query, $context);
- 1023
- 1024 if (!$nodes || $nodes->length === 0) {
- 1025 return null;
- 1026 }
- 1027
- 1028 $node = $nodes->item(0);
- 1029
- 1030 if (!$node instanceof DOMElement) {
- 1031 return null;
- 1032 }
- 1033
- 1034 $value = $node->getAttribute($attr);
- 1035
- 1036 return $value !== '' ? trim($value) : null;
- 1037 }
- 1038
- 1039 private function texts(DOMXPath $xpath, string $query): array
- 1040 {
- 1041 $nodes = $xpath->query($query);
- 1042 $out = [];
- 1043
- 1044 if (!$nodes) {
- 1045 return $out;
- 1046 }
- 1047
- 1048 foreach ($nodes as $node) {
- 1049 $text = $this->cleanText($node->textContent ?? '');
- 1050
- 1051 if ($text !== '') {
- 1052 $out[] = $text;
- 1053 }
- 1054 }
- 1055
- 1056 return $out;
- 1057 }
- 1058
- 1059 private function textsRelative(DOMXPath $xpath, DOMNode $context, string $query): array
- 1060 {
- 1061 $nodes = $xpath->query($query, $context);
- 1062 $out = [];
- 1063
- 1064 if (!$nodes) {
- 1065 return $out;
- 1066 }
- 1067
- 1068 foreach ($nodes as $node) {
- 1069 $text = $this->cleanText($node->textContent ?? '');
- 1070 $out[] = $text;
- 1071 }
- 1072
- 1073 return $out;
- 1074 }
- 1075
- 1076 private function cleanText(string $text): string
- 1077 {
- 1078 $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
- 1079 $text = preg_replace('/\s+/u', ' ', $text);
- 1080
- 1081 return trim((string) $text);
- 1082 }
- 1083}
- 1084
- 1085
- 1086
- 1087app/db.php:
- 1088
- 1089<?php
- 1090
- 1091declare(strict_types=1);
- 1092
- 1093function gm_config(): array
- 1094{
- 1095 static $config = null;
- 1096
- 1097 if ($config === null) {
- 1098 $config = require __DIR__ . '/config.php';
- 1099
- 1100 if (!empty($config['timezone'])) {
- 1101 date_default_timezone_set($config['timezone']);
- 1102 }
- 1103 }
- 1104
- 1105 return $config;
- 1106}
- 1107
- 1108function gm_db(): PDO
- 1109{
- 1110 static $pdo = null;
- 1111
- 1112 if ($pdo instanceof PDO) {
- 1113 return $pdo;
- 1114 }
- 1115
- 1116 $config = gm_config();
- 1117
- 1118 $dsn = sprintf(
- 1119 'mysql:host=%s;dbname=%s;charset=utf8mb4',
- 1120 $config['db_host'],
- 1121 $config['db_name']
- 1122 );
- 1123
- 1124 $pdo = new PDO($dsn, $config['db_user'], $config['db_pass'], [
- 1125 PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
- 1126 PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
- 1127 PDO::ATTR_EMULATE_PREPARES => false,
- 1128 ]);
- 1129
- 1130 return $pdo;
- 1131}
- 1132
- 1133function gm_slug(string $value): string
- 1134{
- 1135 $value = trim(mb_strtolower($value, 'UTF-8'));
- 1136 $ascii = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value);
- 1137
- 1138 if ($ascii !== false) {
- 1139 $value = $ascii;
- 1140 }
- 1141
- 1142 $value = preg_replace('/[^a-z0-9]+/i', '-', $value);
- 1143 $value = trim((string) $value, '-');
- 1144
- 1145 return $value !== '' ? $value : 'galgo-' . bin2hex(random_bytes(4));
- 1146}
Raw Paste