db = $db; } // ========= CACHE ========= public function getCachedByAnimal(int $animalId): ?array { $st = $this->db->prepare('SELECT * FROM icad_cache WHERE animal_id=:id LIMIT 1'); $st->execute([':id' => $animalId]); $row = $st->fetch(PDO::FETCH_ASSOC); if (!$row) { return null; } $data = $row['data_json'] ? json_decode($row['data_json'], true) : null; return [ 'animal_id' => (int) $row['animal_id'], 'chip_id' => (string) ($row['chip_id'] ?? ''), 'status' => (string) ($row['status'] ?? 'empty'), // ok|error|empty 'error' => (string) ($row['error_msg'] ?? ''), 'fetched_at' => (string) ($row['fetched_at'] ?? ''), 'data' => is_array($data) ? $data : null, ]; } public function isStale(?array $cache, int $ttlSeconds = 86400): bool { if (!$cache || empty($cache['fetched_at'])) { return true; } // si en erreur → on retente if (($cache['status'] ?? '') !== 'ok') { return true; } $t = strtotime($cache['fetched_at']); if ($t === false) { return true; } return time() - $t > $ttlSeconds; } private function saveCache(int $animalId, string $chipId, string $status, ?string $err, ?array $data): void { $st = $this->db->prepare(" INSERT INTO icad_cache(animal_id, chip_id, data_json, fetched_at, status, error_msg) VALUES(:aid,:chip,:json,datetime('now'),:st,:err) ON CONFLICT(animal_id) DO UPDATE SET chip_id = excluded.chip_id, data_json = excluded.data_json, fetched_at = excluded.fetched_at, status = excluded.status, error_msg = excluded.error_msg "); $st->execute([ ':aid' => $animalId, ':chip' => $chipId, ':json' => $data ? json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null, ':st' => $status, ':err' => $err, ]); } // ========= PUBLIC API ========= public function refreshByChip(int $animalId, string $chipId): array { $chipId = preg_replace('/\D+/', '', $chipId); if ($chipId === '') { throw new RuntimeException(t('service.icad.invalid_chip')); } $login = getenv('ICAD_LOGIN') ?: ''; $pass = getenv('ICAD_PASSWORD') ?: ''; if ($login === '' || $pass === '') { throw new RuntimeException(t('service.icad.credentials_missing')); } $cookie = sys_get_temp_dir() . '/twix_icad_' . bin2hex(random_bytes(6)) . '.cookie'; try { // 1) GET HOME => token _token du formulaire login ACTEUR $r1 = $this->req('https://www.i-cad.fr/', $cookie); $token = $this->extractToken($r1['body'], '_token'); // prend le 1er _token trouvé // 2) POST login/check/acteur (XHR) $post = http_build_query([ '_token' => $token, 'login' => $login, 'password' => $pass, ]); $r2 = $this->req('https://www.i-cad.fr/login/check/acteur', $cookie, 'POST', $post, [ 'Accept: */*', 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With: XMLHttpRequest', 'Origin: https://www.i-cad.fr', 'Referer: https://www.i-cad.fr/', ]); // 3) Vérif session $r2b = $this->req('https://www.i-cad.fr/pro', $cookie); if (stripos($r2b['body'], 'Se connecter') !== false) { throw new RuntimeException(t('service.icad.login_failed')); } // 4) GET recherche. I-CAD a renommé le formulaire // search_animal en animal_search en 2026 : on accepte les deux. $r3 = $this->req('https://www.i-cad.fr/identification/recherche', $cookie); [$searchForm, $searchToken] = $this->extractAnimalSearchForm($r3['body']); // 5) POST recherche $post2 = http_build_query([ $searchForm . '[insert]' => $chipId, $searchForm . '[tatou]' => '', $searchForm . '[espece]' => '', $searchForm . '[sexe]' => '', $searchForm . '[robe]' => '', $searchForm . '[_token]' => $searchToken, ]); $r4 = $this->req('https://www.i-cad.fr/identification/recherche', $cookie, 'POST', $post2, [ 'Content-Type: application/x-www-form-urlencoded', 'Origin: https://www.i-cad.fr', 'Referer: https://www.i-cad.fr/identification/recherche', ]); $animalPath = $this->extractAnimalPath($r4); if (!$animalPath) { throw new RuntimeException(t('service.icad.no_result')); } // 6) GET fiche animal $r5 = $this->req('https://www.i-cad.fr' . $animalPath, $cookie); $data = $this->parseAnimalHtml($r5['body'], $animalPath); $this->saveCache($animalId, $chipId, 'ok', null, $data); return $data; } catch (Throwable $e) { $this->saveCache($animalId, $chipId, 'error', $e->getMessage(), null); throw $e; } finally { @unlink($cookie); } } // ========= HTTP ========= private function req( string $url, string $cookieFile, string $method = 'GET', ?string $body = null, array $headers = [], ): array { $ch = curl_init($url); $defaultHeaders = [ 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language: fr,en-US;q=0.9,en;q=0.8', 'Connection: keep-alive', 'DNT: 1', 'Sec-GPC: 1', ]; $allHeaders = array_merge($defaultHeaders, $headers); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 10, CURLOPT_HEADER => true, CURLOPT_COOKIEJAR => $cookieFile, CURLOPT_COOKIEFILE => $cookieFile, CURLOPT_USERAGENT => 'Mozilla/5.0 (X11; Linux x86_64; rv:147.0) Gecko/20100101 Firefox/147.0', CURLOPT_TIMEOUT => 25, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_ENCODING => '', // gzip/br si dispo CURLOPT_HTTPHEADER => $allHeaders, ]); if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $body ?? ''); } $raw = curl_exec($ch); if ($raw === false) { $err = curl_error($ch); curl_close($ch); throw new RuntimeException(t('service.icad.network_error', ['error' => $err])); } $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); $hs = curl_getinfo($ch, CURLINFO_HEADER_SIZE); $effUrl = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL); curl_close($ch); return [ 'status' => $status, 'headers' => substr($raw, 0, $hs), 'body' => substr($raw, $hs), 'effective_url' => $effUrl, ]; } // ========= TOKENS / PARSING ========= private function extractToken(string $html, string $fieldName): string { $field = preg_quote($fieldName, '/'); // 1) cas standard: name="X" value="Y" $re = '/]*\bname=(["\'])' . $field . '\1[^>]*\bvalue=(["\'])(.*?)\2[^>]*>/is'; if (preg_match($re, $html, $m)) { return html_entity_decode($m[3], ENT_QUOTES); } // 2) fallback: value avant name $re2 = '/]*\bvalue=(["\'])(.*?)\1[^>]*\bname=(["\'])' . $field . '\3[^>]*>/is'; if (preg_match($re2, $html, $m)) { return html_entity_decode($m[2], ENT_QUOTES); } // 3) si fieldName == _token : on prend le 1er _token trouvé if ($fieldName === '_token') { if (preg_match('/]*\bname=(["\'])_token\1[^>]*\bvalue=(["\'])(.*?)\2[^>]*>/is', $html, $m)) { return html_entity_decode($m[3], ENT_QUOTES); } } throw new RuntimeException(t('service.icad.token_missing', ['field' => $fieldName])); } private function extractAnimalSearchForm(string $html): array { foreach (['animal_search', 'search_animal'] as $formName) { try { return [$formName, $this->extractToken($html, $formName . '[_token]')]; } catch (RuntimeException $e) { // Essayer le nom historique suivant. } } throw new RuntimeException(t('service.icad.form_missing')); } private function extractAnimalPath(array $resp): ?string { // souvent une redirection finale /animal/XXXX dans effective_url $p = parse_url($resp['effective_url'] ?? '', PHP_URL_PATH); if (is_string($p) && preg_match('~^/animal/\d+~', $p)) { return $p; } // fallback HTML if (preg_match('~href=["\'](/animal/\d+)["\']~', $resp['body'], $m)) { return $m[1]; } if (preg_match('~(/animal/\d+)~', $resp['body'], $m)) { return $m[1]; } return null; } private function parseAnimalHtml(string $html, string $animalPath): array { libxml_use_internal_errors(true); $dom = new DOMDocument(); $dom->loadHTML($html); $xp = new DOMXPath($dom); $txt = function (string $xpath) use ($xp): string { $n = $xp->query($xpath); if (!$n || $n->length === 0) { return ''; } return trim(preg_replace('~\s+~', ' ', $n->item(0)->textContent)); }; $name = $txt('//*[@id="animal_informations"]//*[contains(@class,"title")]//span[contains(@class,"emphasis")]'); $insertRaw = $txt('//*[@id="animal_informations"]//*[contains(@class,"id")]//span[contains(@class,"number")]'); $insert = preg_replace('~\s+~', '', $insertRaw); // infos label/value $rows = $xp->query('//*[@id="animal_informations"]//*[contains(@class,"table-row")]'); $map = []; foreach ($rows as $row) { $ln = $xp->query('.//*[contains(@class,"label")]', $row)->item(0); $vn = $xp->query('.//*[contains(@class,"value")]', $row)->item(0); if (!$ln || !$vn) { continue; } $label = trim(preg_replace('~\s+~', ' ', $ln->textContent)); $value = trim(preg_replace('~\s+~', ' ', $vn->textContent)); if ($label !== '') { $map[$label] = $value; } } // events $events = []; $trs = $xp->query('//*[contains(@class,"event-row")]'); foreach ($trs as $tr) { $tds = $xp->query('./td', $tr); if (!$tds || $tds->length < 3) { continue; } $events[] = [ 'date' => trim($tds->item(0)->textContent), 'label' => trim(preg_replace('~\s+~', ' ', $tds->item(1)->textContent)), 'recorded_at' => trim($tds->item(2)->textContent), ]; } // Coordonnées du détenteur. $ownerName = $txt('//*[@id="down_right_block"]//*[contains(@class,"coords")]//*[contains(@class,"name")]'); $ownerEmail = $txt('//*[@id="down_right_block"]//*[contains(@class,"coords")]//*[contains(@class,"address")]'); $ownerPhone = $txt( '//*[@id="down_right_block"]//*[contains(@class,"coords")]//*[contains(@class,"phone_numbers")]', ); $ownerPhone = preg_replace('~\s+~', ' ', $ownerPhone); // Adresse postale: i-CAD ne la met pas toujours dans un bloc unique, mais on tente $ownerPostal = $txt('//*[@id="down_right_block"]//*[contains(@class,"coords")]//*[contains(@class,"postal")]'); if ($ownerPostal === '') { // fallback: parfois l’adresse est dans le même bloc coords mais pas taggée $coordsText = $txt('//*[@id="down_right_block"]//*[contains(@class,"coords")]'); // on évite de tout recracher, juste si on détecte des numéros/rue $ownerPostal = $coordsText; } return [ 'icad_url' => 'https://www.i-cad.fr' . $animalPath, 'name' => $name, 'insert' => $insert, 'species' => $map['Espèce'] ?? '', 'sex' => $map['Sexe'] ?? '', 'birth_date' => $map['Né le'] ?? '', 'coat_type' => $map['Type de robe'] ?? '', 'appearance' => $map['Apparence raciale'] ?? '', 'events' => $events, 'owner' => [ 'name' => $ownerName, 'phone' => $ownerPhone, 'email' => $ownerEmail, 'postal' => $ownerPostal, ], ]; } }