I’m a (script driven) robot

Vous l’avez sans doute compris, j’aime beaucoup le scraping.  Pour des cas compliqués, l’émulation d’un véritable browser devient de plus difficile. Certaines protections vont tellement loin dans construction des requêtes successives pour obtenir un résultat que cela devient pénible et aussi instable.

Je suis donc parti, pour ces cas, dans l’approche Selenium, c’est à dire un véritable navigateur sans tête piloté par script. 

Au final je suis parti parti la sous-branche qui procède à cette construction en utilisant google-chrome (c’est fallacieux et amusant à la fois, sachant que je n’utilise jamais ce navigateur).

Il y a des exemples dans beaucoup de langages, mais finalement assez peu sur les bindings en perl.

Grâce à cet article, https://www.perl.com/article/spidering-websites-with-headless-chrome-and-selenium/ la lumière m’est apparue et j’ai gagné beaucoup. It’s more fun to compute en perl. https://en.perlzemi.com/blog/20211119124656.html est aussi très sympa.

J’ai suivi sa procédure, avec quelques adaptations pour mon Ubuntu server. J’ai pris une version plus récente du chrome driver, installation chrome et module Selenium::Remote::Driver sans problèmes. J’avais sur mon système une version de java (pour mon solr) pas compatible semble t’il avec le serveur selenium standalone. J’ai donc mis un openjdk 11 et tout s’est bien emboité.

C’est assez tranquille mais j’ai pas mal erré pour ce qui est de cacher le caractère headless de mon chrome en écrasant la chaîne user-agent d’origine car sinon c’est pas du jeu. Pour une chaîne user-agent dans une variable $ua, je me retrouve avec le constructeur suivant, avec un peu de random en plus dans la taille de la fenêtre virtuelle:

$sx = sprintf("%d",1600 + ((rand() - rand()) * 303 ));
$sy = sprintf("%d",900 + ((rand() - rand()) * 256 ));
my $driver;
eval {
$driver = Selenium::Remote::Driver->new(
  browser_name => $ua,
  extra_capabilities => {
    chromeSwitches => [ "--user-agent= '$ua'" ],
    chromeOptions => {
      args => [
        'window-size='.$sx.','.$sy,
        'headless',
        'user-agent='.$ua,
      ],
    },
  },
  );
};

A partir de là, j’ai pu réellement jouer. Le driver Selenium permet pas mal de choses, mais, pour le moment, je récupère surtout la source de la page que je passe ensuite à une XML::LibXML comme à mes habitudes des dernières années. Il est possible de jouer avec des XPaths, des screenshots, des evals de javascript donc plein de choses très amusantes en perspective. Cela permet notamment d’obtenir le DOM interprété plutôt que la source de la page $driver->execute_script("return document.documentElement.outerHTML");

Ne pas oublier à la fin de faire un $driver->quit() pour libérer de la mémoire

Firefox // Manifest V3 //

Compliqués rapports entre firefox et le ManifestV3. Maintenant que le support est là (v109) et même dans les versions ESR, il restait pour moi le problème lié au permissions d’accéder à tous les sites. Dans le Manifest.json, cela ne suffit pas:

"host_permissions": ["<all_urls>"],

Grosse prise de tête donc je mets ici le résultat de mes explorations:

  1. Mettre dans le popup un bandeau pour avoir un point d’entrée, par exemple:
    <center id="perm" style="color: yellow; background: #840; clear: both; font-weight: 404; padding: .5em; margin,: .5em; cursor: pointer;">Finaliser</center>
  2. Dans le javascript du popup, mettre ce bout de code dans handler de l’event DOMContentLoaded avec pour objectif de cacher le bandeau défini précédemment si les permissions d’hôte sont bonnes, et sinon de les obtenir grâce à un clic sur le bandeau (action de l’utilisateur nécessaire):
    chrome.permissions.contains({
    origins: ['<all_urls>']
    }, (result) => {
    if (result) {
    document.getElementById('perm').style.display = 'none';
    } else {
    document.getElementById('perm').addEventListener('click',function() {
    chrome.permissions.request({
    origins: ['<all_urls>']
    }, (granted) => {
    if(!granted) {
    browser.permissions.request({
    origins: ['<all_urls>']
    });
    }
    });
    });
    }
    });

  3. Fonctionne aujourd’hui avec Firefox 119. Transparent pour browser moins regardants, type Chromium/Chrome.

Cheers

robots.txt

Historiquement, un fichier texte posé à la racine d’un serveur Web, nommé robots.txt, est une pseudo norme supposée déclarer le comportement toléré de la part des agents logiciels le visitant.

Grand amateur de scraping, je connais cette norme mais elle ne m’est d’utilité que pour refuser à mes intermédiaires certaines taches. Au fond je ne les respecte pas. Je suis même surpris que cette idée perdure. A vrai dire, je n’ai jamais participé à un site sur lequel ce serait une préoccupation.

L’idée est de lister les URLs autorisées/interdites. Le format de ce fichier fait même en sorte qu’il soit possible de décrire des politiques différentes robot part robot (agent par agent).

Imaginons un site public (administration d’Etat). Accepterions nous ouvertement qu’il procède à des distinctions agent par agent ? Discriminations non acceptables. Notons que j’ai vu un paquet de sites répondant à de tels critère mettre en place des restrictions type captcha sur des fichiers type robots.txt ou même des flux RSS !

Derrière chaque robot/agent il y a une personne/structure. Cela en ferait un sous-citoyen ? On ne scrape pas pour assouvir un désir malsain d’accrétion. On le fait globalement pour des traitements postérieurs que l’on aurait autrement fait sur des jeux de données, sous réserve qu’ils soient d’accès intelligibles et techniquement raisonnables. Surtout sur des sites étatiques. 

Je fais ce focus sur des sites publics car ils sont plus loin de ces parties du web où la monétisation par la publicité s’impose (?!?), et car ils sont redevables aux citoyens dont ils relèvent. 

Ce n’est pas un plaidoyer contre les distinctions droïds/humains, ni browser/agents. La distinction logiciels/agents est de plus en plus complexe. Un proxy vaut il un script à fin d’accessibilité, un curl pour archivage ?

Un dernier mot sur la tendance actuelle qui est de passer de jeux de données à des API publiques. Je n’ai pas de religion sur ce point mais au final j’aime que les cohabitent et que cela n’exclue pas le scraping.

Resistance is futile.

Let it be dark in javascript

Au début on se dit qu’il faut questionner le choix utilisateur avec une mediaQuery:

 (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)

Reste que si l’on est dans un contexte incertain, développement d’un add-on dans mon cas, se résoudre à cette simple requête est une mauvaise stratégies car le site sur lequel on vient se pluger lui peut ne pas adapter son style aux choix utilisateur (dark mode ou non).

Sur Firefox qui est mon browser de dev, je ne comprenais pas pourquoi j’obtenais « rgba(0, 0, 0, 0) » avec la ligne suivante sur des pages où le fond est blanc mais le système est en dark mode:

window.getComputedStyle(document.body,null).getPropertyValue('background-color');

A vrai dire, mon erreur était de comprendre cette ligne comme parlant de la couleur noire (0,0,0) alors que le dernier 0 indique en rgba que le fond est transparent, or le fond d’écran d’une page web est blanc. La proprieté (‘background’) retourne d’ailleurs ‘none’. Chrome et safari retournent une valeur rgb qui est plus simple à comprendre.

Au final, je pense être pas mal avec ce bloc:

var dark = false;
let bs = window.getComputedStyle(document.body,null);
let baco = bs.getPropertyValue('background-color');
  if(bs.getPropertyValue('background') == 'none') {
  if(!(/rgba.*,\s?0\)$/.test(baco))) {
    dark = true;
  }
} else {
  let sacos = baco.split(/,/);
  let moy = 128;
  if((sacos != null) && (sacos.length > 2)) {
    moy = (parseInt(sacos[0].replace(/\D+/g,''))+parseInt(sacos[1].replace(/\D/g,''))+parseInt(sacos[2].replace(/\D/g,''))) / 3;
  }
 
  if((moy < 128) && !(/rgba.*,\s?0\)$/.test(baco))) {
    dark = true;
  }
}

Au final, j’aime bien mon petit calcul pour essayer d’évaluer si la couleur est plutôt sombre ou claire.

Linkedin’s code tag

Mon user script puis mon prototype d’add-on avait de manière récurrente des problèmes avec Linkedin.

Après moultes débugs, j’ai fini par comprendre qu’ils font en fait un usage très spécial de la balise <code>

https://developer.mozilla.org/en-US/docs/Web/HTML/Element/code

Pour linkedin, code se retrouve être une balise cachée dans laquelle ils embarquent des données.

Mon content script évitait déjà les balises script et autres styles mais de la à penser que le contenu des balises code doivent être ignorés, il faut vraiment avoir l’esprit tordu.

Avant ou après le rachat par MSFT ?

https://stackoverflow.com/questions/42530879/why-does-lindkedin-use-hidden-code-tags-in-their-updated-website

DOMParser()

Je travaille sur un addon pour différents browser donc avec du code oecuménique si possible. J’ai une liste d’ouvrages dont les noms contiennent des caractères accentués. Initialement la liste était dans la source (mauvaise idée) puis, après des déboires sur l’encodage de ces accents (JSON est explicitement encodé dans un certain charset, charset mentionné dans les headers, donc problème en local) (notoirement avec Safari), je suis passé à un fichier .xml inclus. Tout allait bien en utilisant un DOMParser() qui est la solution typique dans ce cas. (ceux qui font du innerHTML peuvent sortir). Reste qu’ensuite, sur ma route vers les bas-fonds, je suis tombé sur un problème avec Edge, en fin de tests de plates-formes.

Toujours est-il qu’Edge refuse mon script back (background/serviceworker) car selon lui « Uncaught (in promise) ReferenceError: DOMParser is not defined ». Edge est bien le seul à soulever une difficulté sur ce point. Sur mon chemin de croix vers une solution, je vois que certains, dans d’autres contextes inapplicables ici, utilisent new window.DOMParser(), on conséquence je finis par croire/comprendre que DOMParser() n’est pas disponible dans mon contexte de script d’arrière plan, un peu de la même manière qu’il n’est pas disponible pour un node.js, mode headless en quelque sorte.

https://stackoverflow.com/questions/68964543/chrome-extension-domparser-is-not-defined-with-manifest-v3

Bref, pas sympa. J’aurais sans doute dû passer par la nouvelle API offscreen mais bon, quelques regexps plus tard, j’étais sorti d’affaire car ma structure n’était pas compliquée. JSON/CSV/XML, la question ?

Dans le même ordre d’idée, les workers manifestV3 de Google Chrome ne supportent pas « URL.createObjectURL » ?

Dirty MSWord

Je déteste MSWord mais il a une sorte de magie à l’usage: Alors que la structure de ses formats récents est un encapsulage d’XML dans une archive, il trouve le moyen de produire des documents HTML non valides: pas de xhtml signifie qu’il trouve le moyen de faire faire du sale avec du propre ce qui est absolument remarquable du point de vue informatique. Bref, tagsoup !

Microsoft: plus d’avocats que de programmeurs (comme le dit l’adage des temps anciens)

ManfestV3, notes dans la jungle

Je suis lancé dans l’écriture d’un add-on browser pour aller plus loin qu’un user-script (monkey).

Vu le climat actuel, je suis parti sur une web-extension, format manifestV3, histoire de partir sur de bonnes bases, ce malgré des documentations parfois obscures. Mon browser de test est bien évidemment Firefox, qui a introduit très récemment (v109) le support de ce nouveau format (donc les ESR doivent patienter).

Le projet fait que l’adaptation du développement aux navigateurs de types Chromium/Chrome/Edge se fait sans trop de problème de compatibilité, réduisant au final le travail à changer une ligne dans le manifest.json, remplacer « scripts »:[« scripts/back.js »] par « service_worker »: « scripts/back.js » pour avoir quelque chose qui tourne ce qui est très gratifiant. C’est pas très stable mais cela fonctionne.

Reste Safari que j’aime bien, mais pour lequel le développement et ses outils (Xcode) est bien différent à finaliser (xcrun safari-web-extension-converter). Quelle ne fut ma surprise quand j’ai constaté qu’avec la version actuelle, 16.3, il faut partir d’un manifest type Firefox et non celui des cousins de Safari mentionnés plus haut.

Dans un browser, ça a l’apparence du web mais ce n’est pas du web

Faites une requête avec le mot « emprise » dans un moteur de recherche type google. Vous obtenez des résultats. C’est sur le web et dans un browser. Mon test pour dire que c’est du web est que c’est modifiable/remixable: par exemple:

document.evaluate('//text()[contains(.,"emprise")]',document.body,null,XPathResult.ORDERED_NODE_SNAPSHOT_TYPE);

Cela retourne des résultats avec lesquels ont peut jouer (console JS/DOM, user scripts, ..etc)

Par contre, si je vais sur docs.google.com, que je crée un document dans lequel je tape juste le mot « emprise », même en faisant attention à la case du texte, je n’obtiens rien avec le test précédent, mis à part un script de données qui contient ce même mot. En clair, il ne trouve pas de nodes textuelles contenant le mot en question. Et pour cause, l’affichage est en fait une balise canvas donc hors du DOM qui est l’essence du web/html. Autant mettre un binaire qui pointerai vers une VM. Ici le web n’est qu’un transport et le browser un simple réceptacle.

Ensuite le site en question trouve malin d’intercepter tous les événements clavier. Tout y passe sauf bien sûr ceux qui sont interceptés auparavant par le browser. Bref, on ne peux pas jouer ici. Ce n’est pas du web, désolé. Mauvais karma.