Construire le module NPM le plus inutile au monde

Yo yo yo.

Il y a quelques mois je suis tombé sur cet article Wikipedia (grâce au alt-text de ce XKCD).

Pour les flemmards, il explique que pour toute page Wikipedia, si l'on clique sur le premier lien de la page et qu'on répète le processus sur les pages suivantes (premier lien de la seconde page, premier lien de la troisième, etc. etc., bref vous avez saisi) et bien dans environ 95% des cas on arrivera sur la page Philosophy.

Je me suis dit Naaaaaaaaaaan. Et ben en fait si.

J'ai vu qu'il existait déjà quelques outils pour tester la chose, comme cet page web, mais pas de librairie spécifique pour intégrer cette fonctionnalité dans une application plus large. Quelle tristesse ! (Non ? Ok, peut-être pas).

Alors j'en ai fait une, parce que pourquoi pas ?

Spécifions le besoin

Nous voulons donc un module qui permet de donner en paramètre le nom d'une page Wikipédia (dans sa version anglaise, on va simplifier les choses) et en retour récupérer l'ensemble des pages qui nous emmènent vers Philosophy.

Quelques contraintes sont néanmoins à respecter, car la règle "cliquer sur le premier lien" n'est pas suffisante. Il faut cliquer sur le premier lien tout en ignorant :

  • Les liens en italique (qui sont généralement des liens vers les pages d'homonymie)
  • Les liens entre parenthèse
  • Les liens externes
  • Les liens vers les pages Meta de Wikipedia (Help:, File:, Wikipedia:)
  • Les liens vers le Wiktionnaire

De plus, afin d'éviter les boucles infinies, j'ai ajouté la condition suivante qui n'est pas clairement spécifiée dans la règle de base :

  • Ignorer les liens déjà visités

Dépendances

Nous avons besoin de deux bibliothèques : un pour télécharger le code HTML d'une page Wikipédia et un pour parser ledit code.

Deux modules NPM font parfaitement l'affaire :

Let's code

Notre module NPM va donc exposer une méthode start qui va prendre 3 paramètres :

  • page : le nom de la page Wikipedia de départ
  • callbackEach : la fonction de callback qui sera appelée à chaque fois que l'on arrive sur une nouvelle page et à qui on passera en paramètre le nom de ladite page
  • callback : function de callback finale, appelée une fois que l'on a atteint Philosophy et à qui sera passé en paramètre un array contenant l'ensemble des pages traversées

J'aurais pu utiliser la technique plus moderne des Promises, mais au moment où j'ai développé ce module, je n'étais pas encore familier avec celui-ci :)

Cette méthode va appeler une fonction récursive avec 3 paramètres — l'URL de la page à appeler et les deux callbacks — qui va réaliser les tâches suivantes :

  • envoyer une requête vers la page Wikipedia désirée
  • parser le code pour récupérer l'ensemble des liens du contenu principal de la page
  • prendre le premier qui respecte les contraintes décrites plus haut
  • appeler la fonction callbackEach
  • si le lien sélectionné pointe vers la page Philosophy, appeler callback et s'arrêter
  • sinon, s'appeler elle-même avec comme paramètre l'url vers la nouvelle page

Par ailleurs nous avons besoin d'une variables internes au module :

  • path : array contenant les noms des pages traversées

Easy.

Tout d'abord la fonction exposée par le module.

/**
 * Start looping through Wikipedia pages until it reaches "Philosophy" article
 * @param  {String}  page  Starting page
 * @param  {Function}  callbackEach  Callback function for each iteration
 * @param  {Function}  callback  Callback function
 */
exports.start = function (page, callbackEach, callback) {
  // Launch the recursive function
  goToNext('http://en.wikipedia.com/wiki/' + page, callbackEach, callback);
};

Rien de bien incroyable, on appelle notre fonction récursive goToNext avec les trois paramètres.

Développons maintenant la fonction goToNext je vous met le code abusivement commenter afin de bien comprendre le fonctionnement (qui n'a rien de bien sorcier, il faut pas exagérer).


/**
 * Recursive function to go from a wikipedia page to the next by "clicking" the first link on the page
 * @param  {String}  url  URL of the Wikipedia page
 */
function goToNext(url, callbackEach, callback) {
  // Sélecteur qui permet de récupérer l'ensemble des liens du contenu de la page
  var selector = '#mw-content-text > p a, #mw-content-text > ul a';

  // On envoie la requête vers l'URL passée en paramètre
  request(url, function (error, response, body) {
    // Si on a une erreur... Hé ben on renvoie un message d'erreur
    // On aurait pu faire ça de façon plus propre avec des Promises mais chut
    if (error || response.statusCode !== 200) { 
      console.log('Article does not exist');
      if (callback) {
        callback(['Error']);
      }
      return;
    }

    // On lance le parseur !
    var $ = cheerio.load(body);
    // On vérifie qu'on est bien sur une page d'article
    // C'est à dire que la div #mw-content-text existe 
    // et que .noarticletext n'existe pas,
    // si non c'est que l'article n'existe pas
    var content = $('#mw-content-text');
    var noArticle = $('.noarticletext');
    if (!!content.html() && !noArticle.html()) {
      // Ok l'article existe, on récupère le premier lien
      var link = $(selector).eq(0);
      url = link.attr('href');

      // Si le lien n'est pas bon, on passe au suivant et on continue tant
      // que l'on ne trouve pas de lien respectant nos contraintes
      var i = 1;
      while (!isLinkOk(link)) {
        link = $(selector).eq(i);
        url = link.attr('href');
        i = i + 1;
      }

      // On ajoute l'url à la liste des pages visitées
      visited.push(url);

      // On ajoute ensuite le titre de la page à notre tableau 'path'
      var title = $('#firstHeading').text();
      path.push(title);

      // C'est le moment d'appeler le callback qui doit être appelé pour chaque page
      if (callbackEach) {
        callbackEach(title);
      }
      // Si on est à 'Philosophy', VICTOIRE
      if (title === 'Philosophy') {
        if (callback) {
          callback(path);
        }
        return;
      }

      // Si non, on recommence !
      goToNext('http://en.wikipedia.com/' + url, callbackEach, callback);
    } else {
      if (callback) {
        callback(['Error']);
      }
      return;
    }
  });
}

Ok c'est pas méga propre pour la gestion des erreurs, MAIS BON.
Si vous avez lu le code, il manque quelque chose
Oui, exact, la fonction qui permet de vérifier qu'un lien est valide : isLinkOk.
La voici, en mode over-commentée.


/**
 * Check if the link is ok
 * @param  {String}  link  Link to test
 * @return {Boolean}  True if the link is valid
 */
function isLinkOk(link) {
  // On vérifie que la cible du lien n'a pas déjà été visitée
  // n'est pas une page meta
  // ne vient pas du wiktionnaire
  // et n'est pas un lien externe
  var url = link.attr('href');
  var linkOk = visited.indexOf(url) === -1
    && url.indexOf('Help:') === -1
    && url.indexOf('File:') === -1
    && url.indexOf('Wikipedia:') === -1
    && url.indexOf('wiktionary.org/') === -1
    && url.indexOf('/wiki/') !== -1;

  // Si c'est toujours bon on continue la vérificatoin
  if (linkOk) {
    // Code un peu compliqué qui n'a d'autre intérêt 
    // que de vérifier si le lien se trouve entre des parenthèse
    // En gros on prend la balise 'p' entourant le lien
    // on regarde tout le texte précédent le lien et on compte les parenthèses ouvrantes
    // on regarde tout le texte suivant le lien et on compte les parenthèses fermantes
    // Et si on a plus de parenthèses ouvrantes que de parenthèses fermantes
    // alors le lien est entre parenthèses !
    var contentHtml = link.closest('p').length > 0 ? link.closest('p').html() : '';
    if (contentHtml !== '') {
      var linkHtml = 'href="' + url + '"';
      var contentBeforeLink = contentHtml.split(linkHtml)[0];
      var openParenthesisCount = contentBeforeLink.split('(').length - 1;
      var closeParenthesisCount = contentBeforeLink.split(')').length - 1;
      linkOk = !(openParenthesisCount > closeParenthesisCount);
    }
  }
  // Si c'est toujours bon on passe à l'italique
  if (linkOk) {
    linkOk = link.parents('i').length === 0;
  }

  // Et on renvoie le résultat !
  return linkOk;
}

Testons !

Allez, ça devrait tourner sans problème !
On va créer un nouveau fichier demo.js pour tester tout ça avec par exemple la page "Paris".

// Cela présuppose que la librairie se trouve dans le dossier ./lib 
// et se nomme getting-to-philosophy.js
// Ayant mis cette librairie en ligne, vous pouvez simplement faire un coup de
// "npm install getting-to-philosophy" et "require('getting-to-philosophy')
var gettingToPhilosophy = require('./lib/getting-to-philosophy');

gettingToPhilosophy.start('Paris', function (pageName) {
  console.log(pageName);
}, function (path) {
  console.log(path);
});

On va donc lancer notre script et logger dans la console chaque page traversée ainsi que l'array final.
Voici le résultat :

Paris
Capital city
Municipality
Administrative division
Country
Political geography
Human geography
Social science
Outline of academic disciplines
Outline (list)
Hierarchy
Path (graph theory)
Graph theory
Mathematics
Quantity
Property (philosophy)
Modern philosophy
Philosophy
[ 'Paris',
  'Capital city',
  'Municipality',
  'Administrative division',
  'Country',
  'Political geography',
  'Human geography',
  'Social science',
  'Outline of academic disciplines',
  'Outline (list)',
  'Hierarchy',
  'Path (graph theory)',
  'Graph theory',
  'Mathematics',
  'Quantity',
  'Property (philosophy)',
  'Modern philosophy',
  'Philosophy' ]

Ca marche :)

Sources et module npm

Vous trouverez les sources de ce projet sur mon compte GitHub et vous pouvez également retrouver le package sur npm et l'installer comme tout module npm.

$ npm install --save getting-to-philosophy

javascript | npm | node | nodejs | wikipedia


Strasbourg, France

Ingénieur en informatique chez Sully Group.