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,
],
];
}
}