src/Service/Scraper/HaysScraper.php line 364

Open in your IDE?
  1. <?php
  2. namespace App\Service\Scraper;
  3. use App\Entity\Enum\FrenchRegion;
  4. use App\Entity\Offer;
  5. use App\Repository\OfferRepository;
  6. use App\Repository\SocietyRepository;
  7. use App\Repository\UserRepository;
  8. use App\Service\CityExtractor;
  9. use App\Service\Scraper\ScraperInterface;
  10. use App\Service\SlugService;
  11. use Doctrine\ORM\EntityManagerInterface;
  12. use Symfony\Component\BrowserKit\HttpBrowser;
  13. use Symfony\Component\HttpClient\HttpClient;
  14. use DateTime;
  15. class HaysScraper implements ScraperInterface
  16. {
  17.     private $client;
  18.     private $entityManager;
  19.     private $societyRepository;
  20.     private $slugService;
  21.     private $userRepository;
  22.     private $offerRepository;
  23.     private $cityExtractor;
  24.     public function __construct(
  25.         EntityManagerInterface $entityManager,
  26.         SocietyRepository      $societyRepository,
  27.         SlugService            $slugService,
  28.         UserRepository         $userRepository,
  29.         OfferRepository        $offerRepository,
  30.         CityExtractor          $cityExtractor
  31.     ) {
  32.         $this->client HttpClient::create();
  33.         $this->entityManager $entityManager;
  34.         $this->societyRepository $societyRepository;
  35.         $this->slugService $slugService;
  36.         $this->userRepository $userRepository;
  37.         $this->offerRepository $offerRepository;
  38.         $this->cityExtractor $cityExtractor;
  39.     }
  40.     public function supports(string $url): bool
  41.     {
  42.         // Logique pour vérifier si le scraper doit gérer cette URL
  43.         return strpos($url'https://mapi.hays.com/jobportalapi/int/s/fr/fr/jobportal/job/browse/v1/jobsweb') !== false;
  44.     }
  45.     public function scrape(string $url): array
  46.     {
  47.         $authData $this->getToken();
  48.         $token $authData['token'] ?? null;
  49.         $sessionId $authData['sessionId'] ?? null;
  50.         // dump("Auth Data - Token: " . substr($token, 0, 10) . "..., SessionId: " . $sessionId);
  51.         $pageToken "";
  52.         $data = [];
  53.         while (true) {
  54.             $response $this->callApi($url$pageToken$token$sessionId);
  55.             dump("Jobs found in this page: " count($response['content']['jobs'] ?? []));
  56.             foreach ($response['content']['jobs'] ?? [] as $result) {
  57.                 // $currentTitle = $result['nonFilterableCustomFields']['JobTitle']['values'][0] ?? 'N/A';
  58.                 // dump("Checking: " . $currentTitle);
  59.                 // if ($currentTitle === 'Ingénieur Bases de Données (DBA) H/F') {
  60.                 //     dd($result);
  61.                 // }
  62.                 $title $result['nonFilterableCustomFields']['JobTitle']['values'][0];
  63.                 $link $result['applicationUrl'];
  64.                 $createDate $result['createDate'];
  65.                 $date DateTime::createFromFormat('Y-m-d'"{$createDate['year']}-{$createDate['month']}-{$createDate['day']}");
  66.                 if ($this->isOlderThanOneMonth($date)) {
  67.                     echo "date dépassé" "\n";
  68.                     return $data;
  69.                 }
  70.                 $typeContrat $result['nonFilterableCustomFields']['xjobType']['values'][0];
  71.                 switch ($typeContrat) {
  72.                     case 'T':
  73.                         $typeContrat "CDD";
  74.                         break;
  75.                     case 'P':
  76.                         $typeContrat "CDI";
  77.                         break;
  78.                     case 'C':
  79.                         $typeContrat "Freelance";
  80.                         break;
  81.                 }
  82.                 $input $this->getCurrentRegionName($result['location']);
  83.                 $location "Toute la France";
  84.                 $ville null;
  85.                 if ($this->getDepartementKey($result['location']) !== null) {
  86.                     $location $this->getDepartementKey($input);
  87.                 } elseif ($this->getRegionKey($input) !== null) {
  88.                     $location $this->getRegionKey($input);
  89.                 } else {
  90.                     // Priorité spéciale pour Vitrolles
  91.                     if (strpos(strtolower($input), 'vitrolles') !== false) {
  92.                         $location '13 Bouches-du-Rhône';
  93.                         $ville 'Vitrolles';
  94.                     } else {
  95.                         // Utiliser le CityExtractor service (qui a maintenant un timeout et un fallback)
  96.                         $details $this->cityExtractor->extractCityDetails($input);
  97.                         if ($details['city'] !== null) {
  98.                             $ville $details['city'];
  99.                         }
  100.                         if ($details['postcode'] !== null) {
  101.                             $location $this->getDepartmentDetails($details['postcode']) ?? "Toute la France";
  102.                         } elseif ($details['region'] !== null) {
  103.                             $location $details['region'];
  104.                             // Si c'est le contexte de l'API Adresse, on prend le dernier élément si c'est une chaîne longue
  105.                             if (strpos($location',') !== false) {
  106.                                 $contextParts explode(", "$location);
  107.                                 $location end($contextParts);
  108.                             }
  109.                         }
  110.                     }
  111.                 }
  112.                 $tjmMax null;
  113.                 $tjmMin null;
  114.                 $salaireMax null;
  115.                 $salaireMin null;
  116.                 if (array_key_exists('DaySalaryMax'$result['nonFilterableCustomFields'])) {
  117.                     $tjmMax intval($result['nonFilterableCustomFields']['DaySalaryMax']['values'][0]);
  118.                 }
  119.                 if (array_key_exists('DaySalaryMin'$result['nonFilterableCustomFields'])) {
  120.                     $tjmMin intval($result['nonFilterableCustomFields']['DaySalaryMin']['values'][0]);
  121.                 }
  122.                 if (array_key_exists('AnnualSalaryMax'$result['nonFilterableCustomFields'])) {
  123.                     $salaireMax intval($result['nonFilterableCustomFields']['AnnualSalaryMax']['values'][0]);
  124.                 }
  125.                 if (array_key_exists('AnnualSalaryMin'$result['nonFilterableCustomFields'])) {
  126.                     $salaireMin intval($result['nonFilterableCustomFields']['AnnualSalaryMin']['values'][0]);
  127.                 }
  128.                 $data[] = [
  129.                     'title' => $title,
  130.                     'link' => $link,
  131.                     'publicationDate' => $date,
  132.                     'contractTypes' => [$typeContrat],
  133.                     'ville' => $ville != null $ville null,
  134.                     'location' => $location,
  135.                     'salaire_min' => $salaireMin,
  136.                     'salaire_max' => $salaireMax,
  137.                     'tjm_min' => $tjmMin,
  138.                     'tjm_max' => $tjmMax,
  139.                     'teletravail' => 'A discuter',
  140.                     'experience' => 'Selon profil',
  141.                     'description' => $result['description']
  142.                 ];
  143.             }
  144.         }
  145.         return $data;
  146.     }
  147.     public function insertion(array $data): void
  148.     {
  149.         $existSlug = [];
  150.         foreach ($data as $offerData) {
  151.             $offer = new Offer();
  152.             if (!in_array('CDI'$offerData['contractTypes']) && ($offerData['salaire_max'] !== null || $offerData['salaire_min'] !== null)) {
  153.                 $offerData['contractTypes'][] = 'CDI';
  154.             }
  155.             $offer->setContrats($offerData['contractTypes']);
  156.             $offer->setLocation($offerData['location']);
  157.             $offer->setVille($offerData['ville']);
  158.             $offer->setTitle($offerData['title']);
  159.             $offer->setDescription($offerData['description']);
  160.             $offer->setMaxTjm($offerData['tjm_max']);
  161.             $offer->setMinTjm($offerData['tjm_min']);
  162.             $offer->setMontantMax($offerData['salaire_max']);
  163.             $offer->setMontantMin($offerData['salaire_min']);
  164.             $offer->setLinkExtern($offerData['link']);
  165.             $offer->setCheckLinkExtern(true);
  166.             $offer->setDurationType('mois');
  167.             $offer->setModeRemote($offerData['teletravail']);
  168.             $experience $offerData['experience']; // Supposons que $offerData['experience'] contienne la valeur d'expérience
  169.             $yearRequired $experience;
  170.             $offer->setYearRequired($yearRequired);
  171.             $offer->setRenew(true);
  172.             $offer->setStartingType('ASAP');
  173.             // Récupérer la date de publication et la convertir en objet DateTime
  174.             $publicationDate $offerData['publicationDate'];
  175.             // Définir la date de création (CreatedAt)
  176.             $offer->setCreatedAt(clone $publicationDate);
  177.             // Ajouter 2 mois à la copie pour définir la date d'expiration (ExpireAt)
  178.             $expirationDate = clone $publicationDate;
  179.             $expirationDate->add(new \DateInterval('P2M'));
  180.             // Définir la date d'expiration (ExpireAt)
  181.             $offer->setExpireAt($expirationDate);
  182.             // Récupérer l'email de l'utilisateur
  183.             $email 'hays@workdispo.com'// Remplace par l'email de l'utilisateur
  184.             // Récupérer l'utilisateur en fonction de son email
  185.             $user $this->userRepository->findOneBy(['email' => $email]);
  186.             $offer->setSociety($user->getSociety());
  187.             $offer->setOpenSociety(false);
  188.             $existingOffer $this->entityManager->getRepository(Offer::class)
  189.                 ->findOneBy(['linkExtern' => $offerData['link']]);
  190.             if (!$existingOffer) {
  191.                 $this->entityManager->persist($offer);
  192.                 // UPDATE URL
  193.                 $slug $this->slugService->generateUniqueSlug($offer->getTitle(), Offer::class, $existSlug);
  194.                 $existSlug[] = $slug;
  195.                 $offer->setSlug($slug);
  196.                 $this->entityManager->persist($offer);
  197.             } else {
  198.                 if (!$existingOffer->getEditedByAdmin()) {
  199.                     // L'offre existe déjà
  200.                     $existingOffer->setContrats($offerData['contractTypes']);
  201.                     $existingOffer->setLocation($offerData['location']);
  202.                     $existingOffer->setVille($offerData['ville']);
  203.                     $existingOffer->setTitle($offerData['title']);
  204.                     $existingOffer->setDescription($offerData['description']);
  205.                     $existingOffer->setMaxTjm($offerData['tjm_max']);
  206.                     $existingOffer->setMinTjm($offerData['tjm_min']);
  207.                     $existingOffer->setMontantMax($offerData['salaire_max']);
  208.                     $existingOffer->setMontantMin($offerData['salaire_min']);
  209.                     $existingOffer->setLinkExtern($offerData['link']);
  210.                     $existingOffer->setCheckLinkExtern(true);
  211.                     $existingOffer->setDurationType('mois');
  212.                     $existingOffer->setModeRemote($offerData['teletravail']);
  213.                     $existingOffer->setYearRequired($yearRequired);
  214.                     $existingOffer->setRenew(true);
  215.                     $existingOffer->setStartingType('ASAP');
  216.                     $existingOffer->setCreatedAt(clone $publicationDate);
  217.                     $existingOffer->setExpireAt($expirationDate);
  218.                     $existingOffer->setSociety($user->getSociety());
  219.                     $existingOffer->setOpenSociety(false);
  220.                     $this->entityManager->persist($existingOffer);
  221.                 }
  222.                 echo "L'offre existe déjà dans la base de données.";
  223.             }
  224.         }
  225.         $this->entityManager->flush();
  226.     }
  227.     private function getDepartementKey(string $departementNom)
  228.     {
  229.         $exactMatch null;
  230.         $partialMatch null;
  231.         foreach (FrenchRegion::DEPARTEMENT as $key => $departement) {
  232.             // Extraire uniquement le nom du département (sans le numéro)
  233.             $nomSansNumero preg_replace('/^\d+\s/'''$key);
  234.             // Vérification exacte (prioritaire)
  235.             if (strcasecmp($nomSansNumero$departementNom) === 0) {
  236.                 return $key// On retourne directement si c'est une correspondance exacte
  237.             }
  238.             // Vérification partielle (si aucune correspondance exacte n'a été trouvée)
  239.             if (stripos($nomSansNumero$departementNom) !== false) {
  240.                 $partialMatch $key// On stocke la première correspondance partielle trouvée
  241.             }
  242.         }
  243.         // Retourner la correspondance partielle si disponible, sinon null
  244.         return $partialMatch;
  245.     }
  246.     private function getToken()
  247.     {
  248.         // Créer un client HTTP
  249.         $client HttpClient::create();
  250.         // Créer un HttpBrowser
  251.         $browser = new HttpBrowser($client);
  252.         // Effectuer la requête GET sur la page cible
  253.         $browser->request('GET''https://www.hays.fr/recherche-emploi');
  254.         $cookies $browser->getCookieJar()->all();
  255.         // Chercher le cookie 'AhaysToken'
  256.         $token null;
  257.         $sessionId null;
  258.         // Boucle sur les cookies pour récupérer les valeurs souhaitées
  259.         foreach ($cookies as $cookie) {
  260.             if ($cookie->getName() === 'AhaysToken') {
  261.                 $token $cookie->getValue();
  262.             }
  263.             if ($cookie->getName() === 'Asessionid') {
  264.                 $sessionId $cookie->getValue();
  265.             }
  266.         }
  267.         // Retourner un tableau contenant à la fois le token et le sessionId
  268.         return [
  269.             'token' => $token,
  270.             'sessionId' => $sessionId
  271.         ];
  272.     }
  273.     private function callApi(string $url, ?string $pageToken ""string $BearerTokenstring $sessionId)
  274.     {
  275.         $client HttpClient::create();
  276.         $data = [
  277.             "cookieDomain" => ".hays.fr",
  278.             "crossCountryUrl" => "",
  279.             "facetLocation" => "",
  280.             "flexibleWorking" => "false",
  281.             "fullTime" => "false",
  282.             "industry" => ["Nouvelles Technologies Et Internet"],
  283.             "isCrossCountry" => false,
  284.             "isResponseCountry" => false,
  285.             "isSponsored" => false,
  286.             "jobId" => "",
  287.             "jobRefrence" => "",
  288.             "jobType" => [],
  289.             "locations" => "",
  290.             "pageToken" => $pageToken,
  291.             "partTime" => "false",
  292.             "payType" => "",
  293.             "query" => "",
  294.             "radius" => 100,
  295.             "responseSiteLocale" => "",
  296.             "salMax" => "",
  297.             "salMin" => "",
  298.             "sortType" => "PUBLISHED_DATE_DESC",
  299.             "specialismId" => "",
  300.             "subSpecialismId" => "",
  301.             "type" => "search",
  302.             "typeOnlyFilter" => "",
  303.             "userAgent" => "-Desktop"
  304.         ];
  305.         try {
  306.             $response $client->request('POST'$url, [
  307.                 'headers' => [
  308.                     'Authorization' => 'Bearer ' $BearerToken// Ajouter le token
  309.                     'Content-Type' => 'application/json'// Facultatif si Guzzle le gère déjà
  310.                     'x-session' => $sessionId
  311.                 ],
  312.                 'json' => $data// Spécifiez le body en JSON
  313.             ]);
  314.             // Récupérer le contenu de la réponse
  315.             $statusCode $response->getStatusCode();
  316.             $content $response->toArray(); // Décoder automatiquement le JSON
  317.             return [
  318.                 'statusCode' => $statusCode,
  319.                 'content' => $content['data']['result'],
  320.             ];
  321.         } catch (\Exception $e) {
  322.             // Gérer les erreurs
  323.             return [
  324.                 'error' => $e->getMessage(),
  325.             ];
  326.         }
  327.     }
  328.     // Fonction pour vérifier si une date est déjà passée depuis plus d'un mois
  329.     private function isOlderThanOneMonth(DateTime $publishDate): bool
  330.     {
  331.         $now = new DateTime();
  332.         $interval $publishDate->diff($now);
  333.         return $interval->>= || $interval->0// Plus d'un mois
  334.     }
  335.     private function getDepartmentDetails(string $postcode): ?string
  336.     {
  337.         $department 'France';
  338.         // Extraire les 2 premiers chiffres du code postal
  339.         $departmentCode substr($postcode02);
  340.         // Vérifier si le code postal extrait existe dans la constante DEPARTEMENT
  341.         foreach (FrenchRegion::DEPARTEMENT as $key => $value) {
  342.             // Extraire le code département du nom du département, ici par exemple '75' pour Paris
  343.             $deptCode substr($key02);
  344.             // Comparer le code postal avec le code département
  345.             if ($deptCode === $departmentCode) {
  346.                 $department $value// Retourner le département trouvé
  347.             }
  348.         }
  349.         return $department;
  350.     }
  351.     private function getRegionKey($regionName)
  352.     {
  353.         foreach (FrenchRegion::REGION_DEPARTEMENT_WITH_ALL as $region => $departements) {
  354.             if (strtolower($region) == strtolower($regionName)) {
  355.                 return $region// Retourne la clé de la région trouvée
  356.             }
  357.         }
  358.         return null// Retourne null si la région n'est pas trouvée
  359.     }
  360.     private function getCurrentRegionName($oldRegionName)
  361.     {
  362.         // Tableau associatif des anciennes régions avec leur nouvelle région
  363.         $regionsMapping = [
  364.             'Alsace' => 'Grand Est',
  365.             'Champagne-Ardenne' => 'Grand Est',
  366.             'Lorraine' => 'Grand Est',
  367.             'Aquitaine' => 'Nouvelle-Aquitaine',
  368.             'Limousin' => 'Nouvelle-Aquitaine',
  369.             'Poitou-Charentes' => 'Nouvelle-Aquitaine',
  370.             'Auvergne' => 'Auvergne-Rhône-Alpes',
  371.             'Rhône-Alpes' => 'Auvergne-Rhône-Alpes',
  372.             'Bourgogne' => 'Bourgogne-Franche-Comté',
  373.             'Franche-Comté' => 'Bourgogne-Franche-Comté',
  374.             'Midi-Pyrénées' => 'Occitanie',
  375.             'Languedoc-Roussillon' => 'Occitanie',
  376.             'Basse-Normandie' => 'Normandie',
  377.             'Haute-Normandie' => 'Normandie',
  378.             'Nord-Pas-de-Calais' => 'Hauts-de-France',
  379.             'Picardie' => 'Hauts-de-France',
  380.         ];
  381.         // Si l'ancienne région est dans le tableau, retourner la nouvelle région
  382.         if (array_key_exists($oldRegionName$regionsMapping)) {
  383.             return $regionsMapping[$oldRegionName];
  384.         }
  385.         // Sinon, retourner le nom donné si pas dans la liste
  386.         return $oldRegionName;
  387.     }
  388. }