Globinours/app/Services/IcadService.php

363 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
final class IcadService
{
private PDO $db;
public function __construct(PDO $db)
{
$this->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 = '/<input\b[^>]*\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 = '/<input\b[^>]*\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('/<input\b[^>]*\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 ladresse 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,
],
];
}
}