Retour d'expérience sur la création d'un gentil petit robot récupérateur de données.
Bien entendu il n'y a pas de mauvaises intentions de notre part, nous ne récupérerons que des données publiques, disponibles librement sur des sites internet divers. Il ne s'agit pas de piratage ni d'aucune forme d'intrusion. Soit une forme de web scraping. La récupération automatique de données peut d'ailleurs être utile pour de nombreuses tâches très honorables (statistiques, santé, social...) ou marketing.
Nous travaillerons, dans cet exemple simple, sur un site contenant des données de contacts (email, téléphone, métier...). Certains sites considèrent ce type d'annuaire comme vendeur et mettent en avant la disponibilité de données de contact.
Nous tairons son nom de domaine, mais prenons l'exemple d'une fiche de contact à l'URL bien lisible :
http://site-indiscret.com/contacts/45988
La forme de l'URL est très standard et se termine par une chaîne numérique. Sans doute un identifiant unique ! C'est le terrain d'action idéal pour un jeune robot récupérateur de données.
Ce site au webmaster un peu pressé sera donc un bon cobaye pour notre 1er bot. Et en modifiant le script final, vous pourrez sans doute l'adapter à vos propres besoins.
Les grandes étapes d'enfantement seront les suivantes :
- Identification des données
- Formatage des données
- Import des données dans un boucle
C'est parti !
PS : ce site contient un autre exemple de web scraping mais en Python cette fois, et sur un site un peu plus complexe (avec une pagination), ici.
Récupération des données
La bibliothèque simple_html_dom
Pour exécuter ce type de tâche, nous allons devoir parser les pages du site. La bibliothèque PHP simple_html_dom sera nécessaire.
Téléchargez-là et dézippez-la. Nous partons du principe que vous avez le fichier simple_html_dom.php à la racine de votre application.
Pour l'utiliser, il vous faudra ensuite l'appeler dans vos fichiers :
include_once('simple_html_dom.php');
Où sont les données
Dans les pages du site, vous devez observer le code (F12) afin de comprendre comment sont diffuser les données : dans des balises HTML, des classes ou des identifiants CSS...
À terme nous les récupérerons en PHP de cette façon. Observez les classes et identifiants CSS utilisés :
// Appel des contenus foreach($html->find('.org') as $society) ; foreach($html->find('.fn') as $name) ; foreach($html->find('.street-address') as $address) ; foreach($html->find('.postal-code') as $postal) ; foreach($html->find('.locality') as $city) ; foreach($html->find('.email') as $email) ; foreach($html->find('.grill-row .value') as $category) ; foreach($html->find('.tel') as $phone) ; foreach($html->find('#op-info span.url span') as $website) ;
Mais testez d'abord que vous êtes capable de récupérer les données organisées d'une seule ligne (d'une seule page du site internet).
Ici par exemple en HTML, nous appelons la page dans un simple file_get_html
, puis nous récuperons les contenus plein texte de classes et identifiants CSS (.org
, .tel
, #op-info span.url span
...). Ne laissez qu'un seul foreach
pour vos 1ers tests, en fonction de ce que vous observez dans le code de votre site, ce sera plus simple.
<?php include_once('simple_html_dom.php'); $html = file_get_html('http://site-indiscret.com/contacts/45988') ; foreach($html->find('.org') as $name) { echo '<td>'.$name->plaintext.'</td>' ; } foreach($html->find('.fn') as $contact) { echo '<td>'.$contact->plaintext.'</td>' ; } foreach($html->find('.street-address') as $address) { echo '<td>'.$address->plaintext.'</td>' ; } foreach($html->find('.postal-code') as $postal) { echo '<td>'.$postal->plaintext.'</td>' ; } foreach($html->find('.locality') as $city) { echo '<td>'.$city->plaintext.'</td>' ; } foreach($html->find('.email') as $email) { echo '<td>'.$email->plaintext.'</td>' ; } foreach($html->find('.tel') as $tel) { echo '<td>'.$tel->plaintext.'</td>' ; } foreach($html->find('#op-info span.url span') as $url) { echo '<td>'.$url->plaintext.'</td>' ; } foreach($html->find('.grill-row .value') as $category) { echo '<td>'.$category->plaintext.'</td>' ; } ?>
Sur votre navigateur vous devez être capable d'y voir les données de la page.
Bien sûr ici l'URL utilisée est fausse (dans le file_get_html
). Avec une vraie URL il vous faut uniquement adapter les classes CSS utilisées.
Formatage des données
Vous êtes donc capable de récupérez les données d'une page. C'est déjà bien, mais il nous faut les formatter un minimum, car fournies en HTML elles contiennent ici des balises, tabulations, sauts de ligne et autres espaces en début ou fin de ligne. Pas très lisible tout ça...
Avec ce type de code pour chaque champ (ici un exemple sur un champ city) :
... $city_ = $city->plaintext ; $city_ = str_replace("\n","",$city_); $city_ = str_replace("\r","",$city_); $city_ = str_replace("\t","",$city_); $city_ = str_replace('"',' ',$city_); $city_ = str_replace(';',' ',$city_); $city_ = str_replace(' ',' ',$city_); $city_ = trim($city_); $city_ = mb_convert_case($city_, MB_CASE_TITLE) ; ...
- Le
plaintext
est nécessaire pour évacuer le HTML. - Les
replaces
nettoient les tabulations, sauts de ligne, guillemets doubles, points-virgules et doubles espaces. - Le
trim
supprime les espaces en début et fin de ligne.
J'utilise ici un petit caractère de différenciation des variables (le underscore
, c'est juste pour renommer de nouvelles variables à partir des 1ères), cela pour forcer la prise en compte du plaintext
dans nos variables (exemple : $city_
reprend $city
). En effet j'ai remarqué que dans certains cas, sans cela, la variable n'était pas prise en compte, mais c'est sans doute à cause d'une erreur dans mon code... Peu importe pour la suite.
Import des données
PDO
L'import des données de fera ensuite classiquement, comme ici dans notre nouveau fichier robot.php :
<?php include_once('simple_html_dom.php'); // Connexion MySQL $bdd = new PDO('mysql:host=localhost; dbname=extractor; charset=utf8', 'root', ''); ... // Préparation de la requête $req = $bdd->prepare("INSERT INTO mon_annuaire (society, name, address, postal, city, email1, category, phone, website) VALUES (:society, :name, :address, :postal, :city, :email, :category, :phone, :website)"); // Exécution de la requête $req->execute(array( // Variables contenues "society" => $society_, "name" => $name_, "address" => $address_, "postal" => $postal_, "city" => $city_, "email" => $email_, "category" => $category_, "phone" => $phone_, "website" => $website_ )); ?>
Adaptez bien sûr la connexion à la base de données et la requête $req
(table et nom de champs). Vous pouvez aussi faire plus simple, en exportant un fichier TXT au lieu de stocker dans une base, mais je préfère cette méthode.
Clé et index uniques
En base de données, ajoutez une clé unique et auto-incrémenté. Ceci nous permettra d'utiliser plus tard l'ordre de chargement des lignes dans un éventuel nettoyage du fichier final (qui s'avèrera, nous le verrons, nécessaire).
Autre chose : selon ce que vous recherchez (au hasard : des emails...), vous pouvez brider les INSERT
:
En BDD, ajoutez un index unique sur le champ email par exemple. Ceci empêchera l'ajout de lignes aux emails vides ou redondants.
La boucle
C'est sans doute pour des raisons de sécurité que les informations de contact ne sont pas visibles immédiatement dans une liste, mais uniquement après clic sur les liens de cette liste... Arf !
Bon : le robot devra simplement feuilleter une-à-une chaque page du site internet. Heureusement nous connaissons déjà la forme des URLs qui nous intéressent : de toute évidence le chiffre de fin est l'identifiant utilisé par la base de données du site.
http://site-indiscret.com/contacts/[IDENTIFIANT]
Nous allons donc faire une boucle sur les identifiants de pages afin de les tester une par une.
Nous incrémentons donc (++
) une concaténation ($content_
), en commençant ci-dessous par la page http://site-indiscret.com/contacts/1 jusqu'à 10000.
... $nb_lignes = 0 ; while($nb_lignes<=10000){ // Préfixe de l'URL $content = 'http://site-indiscret.com/contacts/' ; // URL incrémentée $content_ = str_get_html($content.$nb_lignes++) ; // Appel de la page $html = @file_get_html($content_) ; ...
Nous ajoutons un contexte permettant la gestion des éventuelles erreurs (les pages inexistantes). Afin de passer outre certaines errerus et poursuivre les imports.
Ce qui donnera cela :
... $nb_lignes = 0 ; while($nb_lignes<=10000){ // Préfixe de l'URL $content = 'http://site-indiscret.com/contacts/' ; // URL incrémentée $content_ = str_get_html($content.$nb_lignes++) ; // Création d'un contexte avant l'appel de la page afin de pouvoir ignorer les erreurs d'import $context = stream_context_create(array( 'http' => array('ignore_errors' => true), )); // Appel de la page $html = @file_get_html($content_, false, $context) ; ...
Script final
Voici un exemple de script complet facilement adaptable pour des sites de structure similaire (1 page = 1 enregistrement et URL suffixée par un identifiant chiffré) :
<?php include_once('simple_html_dom.php'); // Connexion MySQL $bdd = new PDO('mysql:host=localhost; dbname=extractor; charset=utf8', 'root', ''); // Boucle gérant les pages html $nb_lignes = 0 ; while($nb_lignes<=10000) { // Préfixe de l'URL $content = 'http://site-indiscret.com/contacts/' ; // URL incrémentée $content_ = str_get_html($content.$nb_lignes++) ; // Création d'un contexte avant l'appel de la page afin de pouvoir ignorer les erreurs d'import $context = stream_context_create(array( 'http' => array('ignore_errors' => true), )); // Appel de la page $html = @file_get_html($content_, false, $context) ; // Appel des contenus foreach($html->find('.org') as $society) ; foreach($html->find('.fn') as $name) ; foreach($html->find('.street-address') as $address) ; foreach($html->find('.email') as $email) ; foreach($html->find('.grill-row .value') as $category) ; foreach($html->find('.tel') as $phone) ; // Corrections de champs $society_ = $society->plaintext ; $society_ = str_replace("\n"," ",$society_); $society_ = str_replace("\r"," ",$society_); $society_ = str_replace("\t"," ",$society_); $society_ = str_replace('"',' ',$society_); $society_ = str_replace(';',' ',$society_); $society_ = str_replace(' ',' ',$society_); $society_ = trim($society_); $society_ = mb_convert_case($society_, MB_CASE_TITLE) ; $address_ = $address->plaintext ; $address_ = str_replace("\n"," ",$address_); $address_ = str_replace("\r"," ",$address_); $address_ = str_replace("\t"," ",$address_); $address_ = str_replace('"',' ',$address_); $address_ = str_replace(';',' ',$address_); $address_ = str_replace(' ',' ',$address_); $address_ = trim($address_); $address_ = mb_convert_case($address_, MB_CASE_TITLE) ; $email_ = $email->plaintext ; $email_ = str_replace("\n"," ",$email_); $email_ = str_replace("\r"," ",$email_); $email_ = str_replace("\t"," ",$email_); $email_ = trim($email_); $email_ = strtolower($email_); $category_ = $category->plaintext ; $category_ = str_replace("\n"," ",$category_); $category_ = str_replace("\r"," ",$category_); $category_ = str_replace("\t"," ",$category_); $category_ = str_replace('"',' ',$category_); $category_ = str_replace(';',' ',$category_); $category_ = str_replace(' ',' ',$category_); $category_ = trim($category_); $category_ = mb_convert_case($category_, MB_CASE_TITLE) ; $phone_ = $phone->plaintext ; $phone_ = str_replace("\n"," ",$phone_); $phone_ = str_replace("\r"," ",$phone_); $phone_ = str_replace("\t"," ",$phone_); $phone_ = str_replace('"',' ',$phone_); $phone_ = str_replace(';',' ',$phone_); $phone_ = str_replace(' ',' ',$phone_); $phone_ = trim($phone_); // Préparation de la requête $req = $bdd->prepare("INSERT INTO agencebio (society, name, address, email1, category, phone) VALUES (:society, :name, :address, :email, :category, :phone)"); // Exécution de la requête $req->execute(array( // Variables contenues "society" => $society_, "name" => $name_, "address" => $address_, "email" => $email_, "category" => $category_, "phone" => $phone_, ) ); // Fin de la boucle gérant les pages html } ?>
Le script PHP intègre quelques corrections de champs. Adaptez-les là encore à vos besoins. La gestion du HTML bien sûr (suppression des balises, plaintext
) mais aussi la gestion des tabulations, sauts de lignes...
Et voilà ! Exécutez maintenant votre script (en vous rendant à son URL via un navigateur), il va aspirer les données balisées de chaque page testée dans vos champs BDD.
Modifiez la boucle while
et vos temps d'exécution maximum pour syphonner toutes les données qu'il vous faut.
Correction des dernières erreurs
Malgré un bon fonctionnement du script, j'observe parfois quelques erreurs étranges : certains champs vides ou inexistants dans les pages web, ont tendance à se remplir en BDD avec les valeurs respectives du dernier enregistrement non-vide. Très pénible... Cela ne se produit pas depuis tous les sites inspectés, je n'ai pas compris pourquoi, mais vérifiez vos données avant de les utiliser.
Heureusement nous avons ajouté un identifiant auto-incrémenté dans notre table. Avec le code ci-dessous nous allons pouvoir corriger les erreurs en vidant les doublons en fonction de leur ordre d'apparition en base. Exemple sur un champ website, la table d'origine s'appelle ici ma_table :
DROP TABLE IF EXISTS tmp_website_duplicates ; CREATE TABLE tmp_website_duplicates AS SELECT website, min(id) as min_id, count(id) as total FROM ma_table GROUP BY website HAVING website NOT LIKE '' AND total > 1 ORDER BY total DESC ; DROP TABLE IF EXISTS tmp_id_website_to_delete ; CREATE TABLE tmp_id_website_to_delete AS SELECT tmp_website_duplicates.website, min_id, id as id_website_to_delete FROM tmp_website_duplicates INNER JOIN ma_table ON tmp_website_duplicates.website = ma_table.website AND min_id <> id ORDER BY min_id, id ; UPDATE ma_table INNER JOIN tmp_id_website_to_delete ON ma_table.id = tmp_id_website_to_delete.id_website_to_delete SET ma_table.website = '' ; DROP TABLE IF EXISTS tmp_website_duplicates, tmp_id_website_to_delete ;
La 1ère table liste les websites en doublons, les compte, mais surtout renvoie l'id du 1er enregistrement de chaque lot de doublons apparus en base (l'id le plus bas, celui qui ne doit pas être modifié).
La 2nd trie les ids des websites en doublons : d'un côté les 1ers apparus en base, de l'autre les derniers ids suivants (ceux qui doivent être modifiés).
Ensuite une requête de mise-à-jour vide les champs en doublons des derniers ids des lots de doublons de votre table d'origine, sans toucher aux premiers ids.
Hop ! Faîtes ainsi pour tous les champs ayant subi des intrusions.
Autre exemple de récupération à partir d'un json
<?php include_once('simple_html_dom.php'); // Connexion MySQL $bdd = new PDO('mysql:host=localhost; dbname=webscraping; charset=utf8', 'root', 'geo_local'); // Boucle gérant les pages html $nb_lignes = 29900 ; while($nb_lignes<= 30100 ) { // Préfixe de l'URL $content = 'http://www.xxxx.org.br/xxxx/profil?cod=' ; // URL incrémentée $content_ = str_get_html($content.$nb_lignes++) ; // Création d'un contexte avant l'appel de la page afin de pouvoir ignorer les erreurs d'import $context = stream_context_create(array( 'http' => array('ignore_errors' => true), )); // Appel de la page $json = file_get_html($content_, false, $context) ; $parsed_json = json_decode($json) ; // Appel des contenus $ii=0; foreach($parsed_json as $item) { $objectToArray = (array)$item; if($ii==0) $name = $objectToArray[0]; else if($ii==1) $email = $objectToArray[0]; else if($ii==2) $Telefone1 = $objectToArray[0]; $ii++; } // Préparation de la requête $req = $bdd->prepare("INSERT IGNORE INTO extract (name, email, tel1) VALUES (:name, :email, :Telefone1)"); // Exécution de la requête $req->execute(array( // Variables contenues "name" => $name, "email" => $email, "Telefone1" => $Telefone1, ) ); // Fin de la boucle gérant les pages html } ?>
Foreach sur des id définis
<?php include_once('simple_html_dom.php'); // Connexion MySQL $bdd = new PDO('mysql:host=localhost; dbname=webscraping; charset=utf8', 'root', 'geo_local'); // ------------- class MyClass { public $var1 = '51051799'; public $var2 = '54830967'; public $var4 = '54851231'; public $var5 = '54233927'; public $var6 = '51387124'; public $var7 = '54238347'; public $var8 = '53957864'; public $var9 = '50887120'; } $class = new MyClass(); // ------------- // Boucle gérant les pages html foreach($class as $key => $value) { // Préfixe de l'URL $content = 'https://www.xxx.org/profile/' ; // URL incrémentée $content_ = str_get_html($content.$value) ; // Création d'un contexte avant l'appel de la page afin de pouvoir ignorer les erreurs d'import $context = stream_context_create(array( 'http' => array('ignore_errors' => true), )); // Appel de la page $html = @file_get_html($content_, false, $context) ; // Appel des contenus foreach($html->find('#content_element_0_main_column_0_ctl09_Name') as $name) ; foreach($html->find('#content_element_0_main_column_0_ctl09_Email') as $email) ; foreach($html->find('#content_element_0_main_column_0_ctl09_WebAddress') as $website) ; foreach($html->find('#content_element_0_main_column_0_ctl09_AssistantWrap') as $assistant) ; foreach($html->find('#content_element_0_main_column_0_ctl09_AssistantEmail') as $email_assistant) ; foreach($html->find('#content_element_0_main_column_0_ctl09_Specialty') as $speciality) ; foreach($html->find('div.acsCol5.acsCol12Mobile') as $phone) ; // Corrections de champs $name_ = $name->plaintext ; $name_ = str_replace("\n"," ",$name_); $name_ = str_replace("\r"," ",$name_); $name_ = str_replace("\t"," ",$name_); $name_ = str_replace('"',' ',$name_); $name_ = str_replace(';',' ',$name_); $name_ = str_replace(' ',' ',$name_); $name_ = trim($name_); $name_ = mb_convert_case($name_, MB_CASE_TITLE) ; $email_ = $email->plaintext ; $email_ = str_replace("\n"," ",$email_); $email_ = str_replace("\r"," ",$email_); $email_ = str_replace("\t"," ",$email_); $email_ = trim($email_); $email_ = strtolower($email_); $website_ = $website->plaintext ; $website_ = str_replace("\n"," ",$website_); $website_ = str_replace("\r"," ",$website_); $website_ = str_replace("\t"," ",$website_); $website_ = str_replace('"',' ',$website_); $website_ = str_replace(';',' ',$website_); $website_ = str_replace(' ',' ',$website_); $website_ = trim($website_); $website_ = strtolower($website_); $email_assistant_ = $email_assistant->plaintext ; $email_assistant_ = str_replace("\n"," ",$email_assistant_); $email_assistant_ = str_replace("\r"," ",$email_assistant_); $email_assistant_ = str_replace("\t"," ",$email_assistant_); $email_assistant_ = trim($email_assistant_); $email_assistant_ = strtolower($email_assistant_); $speciality_ = $speciality->plaintext ; $speciality_ = str_replace("\n"," ",$speciality_); $speciality_ = str_replace("\r"," ",$speciality_); $speciality_ = str_replace("\t"," ",$speciality_); $speciality_ = trim($speciality_); $speciality_ = mb_convert_case($speciality_, MB_CASE_TITLE) ; $assistant_ = $assistant->plaintext ; $assistant_ = str_replace("\n"," ",$assistant_); $assistant_ = str_replace("\r"," ",$assistant_); $assistant_ = str_replace("\t"," ",$assistant_); $assistant_ = str_replace('"',' ',$assistant_); $assistant_ = str_replace(';',' ',$assistant_); $assistant_ = str_replace(' ',' ',$assistant_); $assistant_ = trim($assistant_); $assistant_ = mb_convert_case($assistant_, MB_CASE_TITLE) ; $phone_ = $phone->plaintext ; $phone_ = str_replace("\n"," ",$phone_); $phone_ = str_replace("\r"," ",$phone_); $phone_ = str_replace("\t"," ",$phone_); $phone_ = str_replace('"',' ',$phone_); $phone_ = str_replace(';',' ',$phone_); $phone_ = str_replace(' ',' ',$phone_); $phone_ = trim($phone_); $phone_ = strtolower($phone_); //var_dump($email_); //exit; // Préparation de la requête $req = $bdd->prepare("INSERT IGNORE INTO facs (name, email, website, speciality, phone, email_assistant, assistant) VALUES (:name, :email, :website, :speciality, :phone, :email_assistant, :assistant)"); // Exécution de la requête $req->execute(array( // Variables contenues "name" => $name_, "email" => $email_, "website" => $website_, "speciality" => $speciality_, "phone" => $phone_, "email_assistant" => $email_assistant_, "assistant" => $assistant_, ) ); // Fin de la boucle gérant les pages html } ?>