/ Développement

Créez votre web scraper full JS & récupérez/aspirez des pages web

Un web scraper (concepte du web scraping) est un script qui permet d'accéder à une page web et de récupérer son contenu. Son utilisation en terme de légalité est encore assez ambiguë.

Utilisé avec modération et sans perturber le fonctionnement des serveurs cible est toléré. Beaucoup en utilise pour :

  • Gérer des comparateurs de prix.
  • Analyser les tendances.
  • Construire des APIs.

Dans notre cas le web scraper sera destiné à des usages éducatifs et de formation.

Objectif

Le but de ce tuto sera de créer un web scraper capable de récupérer le contenu du site communautaire github.com via les $(selecteur) Jquery et de les structurer. Nous allons ensuite générer un fichier Json contenant toutes nos informations stucturées.

A la fin de ce tuto vous obtiendrez un web scraper adaptable à n'importe quel site.

sosnoob_scraper

BONUS: je vais également vous apprendre à packager votre scraper en un exécutable pour simplifier son utilisation et pouvoir l'utiliser sans avoir NodeJs et Npm d'installés, formidable non ?! 🤭🤫

Technologies et langages utilisés

Nous allons utiliser essentiellement du javascript :

Technologie Version
Javascript ecmascript 6
NodeJS 8.9.4
NPM 5.6.0

Cas d'utilisation

Voici un exemple de web scraper que j'ai développé pour la communauté du jeu Dofus.

crawlit_API

Ce scraper, permet d'accéder à chacune des pages de l'encyclopédie du jeu Dofus et de récupérer les équipements et leurs statistiques pour les stocker dans un objet JSON. Cet objet JSON sera exporté par le programme en un fichier JSON intégrable dans n'importe quelle base de données.

Comme vous pouvez le voir, le scraper peut être très interactif et permet de récupérer un contenu spécifique sur une page web. Dans le cadre de ce projet l'intérêt était de récupérer le contenu de l'encyclopédie Dofus de la structurer et de générer des fichiers .json de celle-ci.

Pour les personnes intéressées, voici le lien du github de ce projet : repository Github.

Architecture / Structure du projet

Pour mieux comprendre les instructions qui vont suivre, voici à quoi va ressembler l'arborescence de notre projet final :

SosNoob Web Scraper
└───package.json
|
└───lib
│   └───app.js
|   └───getItems.js
|   |
│   └───cli-view
│       └───  cmd.js
└───node_modules

N'hésitez pas plus tard dans ce tutoriel à remonter à l'arborescence si vous êtes perdu dans la création des fichiers. 🧐

Initialisation du projet

Dans premier temps vérifiez que vous avez bien nodeJS et Npm d'installés sur votre machine.
Dans un second temps créez un dossier sur votre machine et nommez le comme vous le souhaiter : dans mon cas sosnoob_scraper.

Dans ce dossier, exécutez les actions suivantes :

  1. Ouvrez un terminal de commande (dans votre dossier) et exécutez la commande npm init.
  2. Répondez aux questions parce que vous voulez ce n'est pas le plus important sauf pour la question entry point répondez lib/app.js (vous pourrez les éditer plus tard).

A ce niveau-là, vous avez créé votre projet et généré le package.json qui contient les informations sur votre projet et ses dépendances (aucune pour le moment).

Il est temps d'ajouter les dépendances indispensables à la création de notre web scraper. Nous allons avoir besoin des modules node suivant :

Node module Utilité
Cheerio Permet d'utiliser Jquery côté serveur
Clui Améliore l'affichage de votre programme via des jauges, progressbar...
Fs-path Permet d'intéragir avec votre système de fichier
Inquirer Simplifie l'intéraction avec votre programme (Entrer des caractères...)
Bluebird Etand et améliore les promises de javascript
Request-promise Améliore la lisibilité et l'utilisation du module request
Request Dépendance sur laquelle se base le module request-promise
Pkg (Bonus) Permet de packager l'app en executable

Installons ces modules, allez dans votre dossier sosnoob_scraper et exécutez les commandes suivantes :

npm install --save cheerio clui fs-path inquirer
npm install --save-dev bluebird request-promise request

Editez le fichier package.json et dans script ajoutez le script suivant :

"scripts": {
    "start": "node lib/app.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },

Ensuite toujours dans le fichier package.json, ajoutez les lignes suivantes à la fin du fichier, je vous expliquerai leurs utilités plus tard :

...
"bin": "lib/app.js",
"pkg": {
    "targets": [
      "node8"
    ]
}

Après ces modifications, votre package.json devrait ressembler à ça :

{
  "name": "sosnoob_scraper",
  "version": "1.0.0",
  "description": "Scrap all web site",
  "main": "lib/app.js",
  "scripts": {
    "start": "node lib/app.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "SosNoob Author",
  "license": "ISC",
  "dependencies": {
    "cheerio": "^1.0.0-rc.2",
    "clui": "^0.3.6",
    "fs": "0.0.1-security",
    "fs-path": "0.0.23",
    "inquirer": "^5.1.0"
  },
  "devDependencies": {
    "bluebird": "^3.5.1",
    "request": "^2.83.0",
    "request-promise": "^4.2.2"
  },
  "bin": "lib/app.js",
  "pkg": {
    "targets": [
      "node8"
    ]
  }
}

Partie code & développement - fichier app.js

Maintenant que nous avons pausé les bases de notre application, nous allons débuter son développement.

Créez un dossier lib, dans ce dossier créez un sous-dossier cli-view.

A la racine du dossier lib créez un fichier app.js et getItems.js, ces fichiers vont contenir la logique principale de notre application.

app.js sera le point d'entrée de l'application tendis que getItems.js permettra de gérer les différentes natures de données.

Dans le fichier app.js ajoutez le contenu suivant :

var fs = require('fs');
var fsPath = require('fs-path');
var gi = require('./getItems');
var cm = require('./cli-view/cmd');
var request = require('request-promise');
var cheerio = require('cheerio');
var CLI = require('clui'),
    Spinner = CLI.Spinner;

var globalUrl = 'https://github.com';
var itemCategory;
var currentPage = 1;
var pageslinks = [];
var requestOpts = {
    url: 'https://github.com/search?utf8=%E2%9C%93&q=javascript&type=',
    method: 'GET',
    transform: function (body) {
        return cheerio.load(body);
    }
};

main();

function main() {
	asciiArt();
	cm.getCategory().then(
    function(cmdResponse) {
      crawlerInit(cmdResponse);
    }).catch(
      function(err) {
        console.log('\x1b[31m%s\x1b[0m' ,'/!\\Broken promise from cmd');
        console.log(err);
		process.exit();
      });
}

function crawlerInit (cmdResponse) {
	cmdResponse = JSON.parse(cmdResponse);
	var countdown = new Spinner('Scraper in progress... It could take some time ', ['⣾','⣽','⣻','⢿','⡿','⣟','⣯','⣷']);
	countdown.start();
	maxPage = cmdResponse.pages;
	currentPage = cmdResponse.fromPage;
	var realMaxPagePromise	= request(requestOpts).then(function ($) {
		var realMaxPage = $('div.paginate-container').find('div.pagination a:last-child').prev().text().trim();
		return realMaxPage;
	});
	getAllLinks(realMaxPagePromise);
}

function getAllLinks(realMaxPagePromise) {
	realMaxPagePromise.then(
    function(realMaxPage) {
    	if (realMaxPage == '') {
    		realMaxPage = 1;
    	}
		if (realMaxPage >= maxPage && currentPage <= maxPage) {
			var callback =  function(values) {
				pageslinks.push(values);
				currentPage++;
		        if(currentPage <= maxPage) {
		         	getPageLinks(currentPage, callback); 
		        }else {
					pageslinks = concatToOneArray(pageslinks);
					console.log('\x1b[36m%s\x1b[0m' ,'\n SUCCESS : all item(s) links crawled.');
					console.log('\x1b[36m%s\x1b[0m' ,'\n START of item(s) crawling.');
					getItems(pageslinks);
				}
			}
			getPageLinks(currentPage, callback);
		}else {
				console.log('\x1b[31m%s\x1b[0m' ,'\n /!\\ Max page of this category is ' + realMaxPage + ' so '+ maxPage + ' is to much :(');
				process.exit();
		}

    }).catch(
      function(err) {
        console.log(err);
        console.log('\x1b[31m%s\x1b[0m' ,'/!\\Broken promise from getAllLinks');
		process.exit();
      });
}

function getPageLinks(currentPage, callback) {
	requestOpts.url = 'https://github.com/search?' + 'p=' + currentPage + '&q=javascript&type=Repositories';
	return request(requestOpts).then(function ($) {
		var links = [];
		$('ul.repo-list').find('div.repo-list-item').each(function(i, tr){
			var link = globalUrl + $(this).find('h3').find('a').attr('href');
			links.push(link);
		});
		return links;
	}).then(function(val) {
		callback(val);
	}).catch(function(err) {
	    console.log('\x1b[31m%s\x1b[0m' ,'/!\\Broken promise from getPageLinks');
	    console.log(err);
		process.exit();
	  });
}

function getItems(pageslinks) {
	gi.getItems(pageslinks, function(items){
		fsPath.writeFile('./data/repoList.json', JSON.stringify(items), function(err){
			if (err) console.log(err);
			console.log('\x1b[32m%s\x1b[0m' ,'\n SUCCESS : ' +pageslinks.length+ ' item(s) were scraped.');
			console.log('\x1b[33m%s\x1b[0m' ,'File repoList.json' + ' was generated under "data/" folder.');
			process.exit();
		});
	});
}

function concatToOneArray(arrToConvert) {
	var newArr = [];
	for(var i = 0; i < arrToConvert.length; i++) {
		newArr = newArr.concat(arrToConvert[i]);
	}
	const noDuplicateItemArray = newArr.filter((val,id,array) => array.indexOf(val) == id);
	return noDuplicateItemArray;
}

function asciiArt() {
	console.log('   ▄████████  ▄██████▄     ▄████████ ███▄▄▄▄    ▄██████▄   ▄██████▄  ▀█████████▄  ');
	console.log('  ███    ███ ███    ███   ███    ███ ███▀▀▀██▄ ███    ███ ███    ███   ███    ███ ');
	console.log('  ███    █▀  ███    ███   ███    █▀  ███   ███ ███    ███ ███    ███   ███    ███ ');
	console.log('  ███        ███    ███   ███        ███   ███ ███    ███ ███    ███  ▄███▄▄▄██▀  ');
	console.log('▀███████████ ███    ███ ▀███████████ ███   ███ ███    ███ ███    ███ ▀▀███▀▀▀██▄  ');
	console.log('         ███ ███    ███          ███ ███   ███ ███    ███ ███    ███   ███    ██▄ ');
	console.log('   ▄█    ███ ███    ███    ▄█    ███ ███   ███ ███    ███ ███    ███   ███    ███ ');
	console.log(' ▄████████▀   ▀██████▀   ▄████████▀   ▀█   █▀   ▀██████▀   ▀██████▀  ▄█████████▀  ');
	console.log('                                                                                  ');
}

Détails du code, partie initialisation

Dans un premier temps, j'ai initialisé les dépendances du projet, celles évoqué en introduction. J'ai ensuite défini quelques variables importantes, cependant celle à retenir est requestOpts. Elle permet de charger les arguments importants pour les requêtes via le module request-promise, dont l'url, la méthode pour la requête (GET dans notre cas) et une fonction à appliquer après l'exécution de la requête.

Il est à noter qu'il n'est pas nécessaire de comprendre l'ensemble du code dans ses détails, seul les grands axes (l'algorithmie) est importante à comprendre.

La fonction main

Cette fonction s'occupe de jouer le rôle de chef d'orchestre :

  1. Elle charge l'affichage interactif (CLI).
  2. Récupère les réponses de cette affichage (les réponses que l'on a fournis).
  3. Appelle la fonction crawlerInit avec les réponses de l'affichage pour paramètres.

En cas d'erreur la fonction main catch, affiche l'erreur et stop le processus.

La fonction crawlerInit

Elle lance le spinner (effet de chargement, pour un peu d'esthétisme haha), puis :

  1. Charge dans les variables globales les valeurs de nos réponses.
  2. Elle lance ensuite une requête de vérification de nos réponses sur le site cible.
  3. Enfin elle charge la fonction getAllLinks avec la requête de vérification en paramètre.

Ces fonctions vont se charger de collecter tous les liens de la page github (contenant la liste des dépôts) et de les stocker dans un tableau. Elles vont également se charger de vérifier si le nombre de page demandé à scraper existe ou non.

la fonction getItems

Cette dernière va envoyer le tableau de liens construits par les fonctions précédentes à la fonction getItems stocker dans un autre fichier.

Voilà tout ce que vous avez besoin de comprendre du fichier app.js. Le reste n'est que de la mise en forme et du réordonnement de tableau.

Partie code & développement - fichier getItems.js

var fs = require('fs');
var request = require('request-promise');
var cheerio = require('cheerio');

var CLI = require('clui'),
    Progress = CLI.Progress;

var currentPosition = 0;
var progressbarPosition = 0;
var itemsList = [];
var thisProgressBar = new Progress(20);
var requestOpts = {
    url: '',
    transform: function (body) {
        return cheerio.load(body);
    }
};

var getItems = exports.getItems = function(links, back) {
	getData(links[currentPosition],back, function(item){
		itemsList.push(item);
		if (progressbarPosition >= links.length * 0.05) {
			progressbarPosition = 0;
			console.log(thisProgressBar.update(currentPosition, links.length));
		}
		progressbarPosition++;
		currentPosition++;
        if(currentPosition < links.length) { // any more items in array?
         	getItems(links, back);   
        }else {
        	console.log(thisProgressBar.update(100, 100));
			back(itemsList);
		}
	});
};

function getData(url, back, callback) {
	requestOpts.url = url;
	var itemPromise = request(requestOpts).then(function ($) {
		var itemId = $('h1.public').text().trim();
		var name = $('div.repository-content').find('div.repository-meta').eq(0).text().trim();
		
		var item = {
			item_identifiant: itemId,
			name: name,
			url: url
		}
		
		item["labels"] = [];
		$('div.repository-topics-container').find('div.list-topics-container.f6.mt-1').eq(0).find('a').each(function(i, element){
			var label = $(this).text().trim();
			item["labels"].push(label);
		});
		return item;
	});

	itemPromise.then(function(item) {
    	callback(item);
    }).catch(function(err) {
        if (err.statusCode == '404') {
        	console.log('\x1b[33m%s\x1b[0m' ,'\n Error 404 detected ! Maybe empty item (Encyclopedia error).');
        	callback();
        }else if(err.statusCode == '429') {
        	console.log('\x1b[31m%s\x1b[0m' ,'\n /!\\ Error 429 detected ! Too many request, be careful Github can ban your IP. /!\\ Never parse more than 20 pages/hour');
        	process.exit();
        }else {
        	console.log(err);
        	console.log('\x1b[31m%s\x1b[0m' ,'/!\\Broken promise all');
        	process.exit();
        }
      });
}

Le fichier getItems.js va contenir le script de parsing ainsi que la gestion de l'affichage progression (barre de chargement). Le fichier contient deux fonctions, chacune d'entre elle est responsable d'une tâche détaillée plus bas.

La fonction getItems()

A ce niveau du code, cette fonction gère l'affichage de la progressBar en affichant la progression tous les 5% d'avancement. La fonction s'occupe également de stocker l'item récupérer par la fonction qui va suivre getData() dans un tableau.

La fonction getData()

getData gère le parsing et exécute la requête http d'accès au site parsé. Il va par la suite traiter le contenu scrapé avec le node module cheerio (un jquery côté serveur pour scanner le DOM).

Une fois les informations récupérées et ordonnées dans un objet Json, elles sont envoyées à la fonction getItems() qui va stocker l'objet Json dans un tableau d'objet.

Gestion de l'erreur

Dans l'ensemble du code, l'algorithme mis en place permet de traiter les erreurs par catégories. Les erreurs 404 et 429 sont parfaitement traitées et ne sont pas bloquante. En revanche, toutes autre erreur stop le processus et s'affiche dans le terminal de commandes.

Pour aller plus loin : NightmareJS

Si vous souhaitez avoir un web sraper capable totalement de simuler le comportement d'un utilisateur normal avec clique sur bouton, remplissage de formulaire, gestion de l'ajax et des boutons cachés par Jquery, alors NightmareJs est fait pour vous !

Voici si cela vous intéresse le lien vers leur site et leur tuto de démarrage rapide.

N'hésitez pas à poser vos questions en cas de bug ou d'incompréhension ! 🤩✌️

Pour les plus préssé d'entre vous ou ceux rencontrant des problèmes, voici le projet terminé sur github.

Zakaria Rachedi

Zakaria Rachedi

Apprendre sans oublier, c'est impossible. Alors, pourquoi ne pas immortaliser ses connaissances et en profiter pour les partager, tel a été mon souhait lors de la création de sosnoob.

Read More