Publier Globinours 1.0.0-rc.3
This commit is contained in:
parent
ea8c24d622
commit
9a2b4068da
325 changed files with 38230 additions and 20 deletions
363
app/Services/IcadService.php
Normal file
363
app/Services/IcadService.php
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
<?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 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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue