Sunday Query : utiliser SPARQL et Python pour corriger des coquilles sur Wikidata
À mon tour de faire une #SundayQuery! Comme Harmonia Amanda l’a dit dans son propre billet, j’envisageais de faire un article expliquant comment créer un script Python permettant de corriger les résultats de sa requête.. Finalement, j’ai préféré en faire un autre, au fonctionnement similaire mais plus court et plus simple à comprendre. Le script pour Harmonia est cependant disponible en ligne ici.
Jeudi, j’ai publié un article au sujet des batailles du Moyen Âge, et depuis, j’ai commencé à corriger les éléments correspondants sur Wikidata
L’une des corrections les plus répétitives était la capitalisation des libellés en français : comme ils ont été importés de Wikipédia, ils ont une majuscule inutile au début ( « Bataille de Saint-Pouilleux en Binouze » au lieu de « bataille de Saint-Pouilleux en Binouze »…)
La requête
Commençons par trouver tous les éléments présentant cette coquille.
SELECT ?item ?label WHERE { ?item (wdt:P31/(wdt:P279*)) wd:Q178561; rdfs:label ?label. FILTER((LANG(?label)) = "fr") FILTER(STRSTARTS(?label, "Bataille ")) }
Quelques explications de base :
?item wdt:P31/wdt:P279* wd:Q178561 .
cherche les éléments qui sont des batailles ou des sous-classes de batailles, pour être bien sûr que je ne vais pas virer sa majucule à un bouquin intitulé « Bataille de Perpète-les-Olivettes »…- Sue la ligne suivante, je demande les libellés pour les éléments en question
?item rdfs:label ?label .
et les filtre pour ne garder que ceux en françaisFILTER(LANG(?label) = « fr ») .
. Comme j’ai besoin d’utiliser le libellé dans la requête et pas juste de l’afficher (et comme Harmonia Amanda l’a expliqué dans son billet de dimanche), je ne peux pas utiliser le servicewikibase:label
, et je me rabats donc sur le standard du web sémantiquerdfs:label
. - La dernière ligne est un
FILTER
(filtre), qui ne garde que les résultats qui répondent à la fonction à l’intérieur. Ici,STRSTARTS
vérifie si?label
commence avec « Bataille ».
Au moment où j’ai écrit la version anglaise de ce texte, la requête renvoyait 3521 résultats. Beaucoup trop pour les corriger à la main, et je ne connais aucun outil déjà existant qui pourrait faire ça pour moi… Je suppose qu’il est temps de dégainer Python, du coup !
Le script Python
J’aime Python. J’adore carrément Python, même. Ce langage est génial pour créer une une application utile en une poignée de minutes, facile à lire (pour peu qu’on lise l’anglais), pas constellé de séries d’accolades ou de points-virgules, et a des libs géniales pour les choses que je fais le plus avec : récupérer le contenus de pages web, trier des données, vérifier des ISBNs[1] et faire des sites web. Oh, et pour faire des requêtes SPARQL et traiter les résultats[2] .
Premières remarques
Si vous ne connaissez pas du tout le Python, cet article n’est pas le bon endroit pour ça, mais il y a de nombreuses ressources disponibles en ligne[3] . Assurez-vous juste qu’elles sont à jour et pensées pour Python 3. La suite de cet article part du principe que vous avez une connaissance basique de Python (indentation, variables, chaînes de caractères, listes, dictionnaires, imports et boucles for.), et que Python 3 et pip sont installés sur votre machine.
Pourquoi Python 3 ? Parce que nous allons manipuler des chaînes qui viennent de Wikidata et sont donc encodées en UTF-8 et que Python 2 n’est pas hyper pratique pour ça. Et puis mince, on est en 2016, par Belenos !
Pourquoi pip ? Parce qu’on a besoin d’une libraire non-standard pour faire des requêtes SPARQL, appelée SPARQLwrapper, et que cette commande est le moyen le plus simple de l’installer :
pip install sparqlwrapper
Allez, on commence à scripter !
Pour commencer, un script qui fait une requête Sparql retournant la liste des sièges à corriger[4] :
#!/usr/bin/env python3 from SPARQLWrapper import SPARQLWrapper, JSON from pprint import pprint endpoint = "https://query.wikidata.org/bigdata/namespace/wdq/sparql" sparql = SPARQLWrapper(endpoint) sparql.setQuery(""" SELECT ?item ?label WHERE {{ ?item wdt:P31/wdt:P279* wd:Q178561 . ?item rdfs:label ?label . FILTER(LANG(?label) = "fr") . FILTER(STRSTARTS(?label, "Siège ")) . }} """) # Link to query: http://tinyurl.com/z8bd26h sparql.setReturnFormat(JSON) results = sparql.query().convert() pprint(results)
Ça fait un bon petit paquet de lignes, mais que font-elles ? Comme on va le voir, la plupart vont en fait être incluses à l’identique dans tout script qui fait une requête SPARQL.
- Pour commencer, on importe deux choses du module SPARQLWrapper : la classe
SPARQLWrapper
elle-même et la constante «JSON
» qu’elle va utiliser plus tard (pas d’inquiétude, on n’aura pas à manipuler du json directement.) - On import aussi le module « Pretty printer » pour afficher les résultats de manière plus lisible.
- Ensuite, on crée une variable qu’on nomme «
endpoint
», qui contient l’URL complète vers le point d’accès SPARQL de Wikidata[5] . - Ensuite, on crée une instance de la classe SPARQLWrapper qui utilisera ce point d’accès pour faire des requêtes, et on les met dans une variable simplement appelée «
sparql
». - On applique à cette variable la fonction setQuery, qui est l’endroit où l’on rentre la requête de tout à l’heure. Attention, il faut doublonner les accolades (remplacer
{
et}
par{{
et}}
, car elles sont des caractères réservés dans les chaînes Python. sparql.setReturnFormat(JSON)
dit au script que le résultat sera retourné en json.results = sparql.query().convert()
, enfin, fait la requête elle-même et convertit la réponse dans un dictionnaire Python appelé «results
».- Et pour l’instant, on va juste afficher le résultat à l’écran pour voir ce qu’on obtient.
Ouvrons un terminal et lançons le script :
$ python3 fix-battle-labels.py {'head': {'vars': ['item', 'label']}, 'results': {'bindings': [{'item': {'type': 'uri', 'value': 'http://www.wikidata.org/entity/Q815196'}, 'label': {'type': 'literal', 'value': 'Siège de Pskov', 'xml:lang': 'fr'}}, {'item': {'type': 'uri', 'value': 'http://www.wikidata.org/entity/Q815207'}, 'label': {'type': 'literal', 'value': 'Siège de Silistra', 'xml:lang': 'fr'}}, {'item': {'type': 'uri', 'value': 'http://www.wikidata.org/entity/Q815233'}, 'label': {'type': 'literal', 'value': 'Siège de Tyr', 'xml:lang': 'fr'}}, {'item': {'type': 'uri', 'value': 'http://www.wikidata.org/entity/Q608163'}, 'label': {'type': 'literal', 'value': 'Siège de Cracovie', 'xml:lang': 'fr'}}, {'item': {'type': 'uri', 'value': 'http://www.wikidata.org/entity/Q1098377'}, 'label': {'type': 'literal', 'value': 'Siège de Narbonne', 'xml:lang': 'fr'}}, {'item': {'type': 'uri', 'value': 'http://www.wikidata.org/entity/Q2065069'}, 'label': {'type': 'literal', 'value': 'Siège de Hloukhiv', 'xml:lang': 'fr'}}, {'item': {'type': 'uri', 'value': 'http://www.wikidata.org/entity/Q4087405'}, 'label': {'type': 'literal', 'value': "Siège d'Avaricum", 'xml:lang': 'fr'}}, {'item': {'type': 'uri', 'value': 'http://www.wikidata.org/entity/Q2284279'}, 'label': {'type': 'literal', 'value': 'Siège de Fort Pulaski', 'xml:lang': 'fr'}}, {'item': {'type': 'uri', 'value': 'http://www.wikidata.org/entity/Q4337397'}, 'label': {'type': 'literal', 'value': 'Siège de Liakhavitchy', 'xml:lang': 'fr'}}, {'item': {'type': 'uri', 'value': 'http://www.wikidata.org/entity/Q4337448'}, 'label': {'type': 'literal', 'value': 'Siège de Smolensk', 'xml:lang': 'fr'}}, {'item': {'type': 'uri', 'value': 'http://www.wikidata.org/entity/Q701067'}, 'label': {'type': 'literal', 'value': 'Siège de Rhodes', 'xml:lang': 'fr'}}, {'item': {'type': 'uri', 'value': 'http://www.wikidata.org/entity/Q7510162'}, 'label': {'type': 'literal', 'value': 'Siège de Cracovie', 'xml:lang': 'fr'}}, {'item': {'type': 'uri', 'value': 'http://www.wikidata.org/entity/Q23013145'}, 'label': {'type': 'literal', 'value': 'Siège de Péronne', 'xml:lang': 'fr'}}, {'item': {'type': 'uri', 'value': 'http://www.wikidata.org/entity/Q10428014'}, 'label': {'type': 'literal', 'value': 'Siège de Pskov', 'xml:lang': 'fr'}}, {'item': {'type': 'uri', 'value': 'http://www.wikidata.org/entity/Q3090571'}, 'label': {'type': 'literal', 'value': 'Siège du Hōjūjidono', 'xml:lang': 'fr'}}, {'item': {'type': 'uri', 'value': 'http://www.wikidata.org/entity/Q3485893'}, 'label': {'type': 'literal', 'value': 'Siège de Fukuryūji', 'xml:lang': 'fr'}}, {'item': {'type': 'uri', 'value': 'http://www.wikidata.org/entity/Q4118683'}, 'label': {'type': 'literal', 'value': "Siège d'Algésiras", 'xml:lang': 'fr'}}, {'item': {'type': 'uri', 'value': 'http://www.wikidata.org/entity/Q5036985'}, 'label': {'type': 'literal', 'value': 'Siège de Berwick', 'xml:lang': 'fr'}}, {'item': {'type': 'uri', 'value': 'http://www.wikidata.org/entity/Q17627724'}, 'label': {'type': 'literal', 'value': "Siège d'Ilovaïsk", 'xml:lang': 'fr'}}, {'item': {'type': 'uri', 'value': 'http://www.wikidata.org/entity/Q815112'}, 'label': {'type': 'literal', 'value': "Siège d'Antioche", 'xml:lang': 'fr'}}]}}
C’est un gros paquet de résultats mais on peut voir que c’est un dictionnaire qui contient deux entrées :
- «
head
», qui contient les noms des deux variables renvoyées par la requête, - et «
results
», qui contient lui-même un autre dictionnaire avec la clef «bindings
», associée avec la liste des résultats eux-mêmes, chacun d’entre eux étant lui-même un dictionnaire Python. Pfiou…
Examinons un desdits résultats :
{'item': {'type': 'uri', 'value': 'http://www.wikidata.org/entity/Q17627724'}, 'label': {'type': 'literal', 'value': "Siège d'Ilovaïsk", 'xml:lang': 'fr'}}
C’est un dictionnaire avec deux clefs (label
et item
), chacune ayant pour valeur un autre dictionnaire qui à son tour a une clef « value
» associée avec, cette fois, la valeur qu’on veut au final. Enfin !
Parcourir les résultats
Parcourons la liste « bindings » avec une boucle « for » de Python, pour pouvoir en extraire les résultats.
for result in results["results"]["bindings"]: qid = result['item']['value'].split('/')[-1] label = result['label']['value'] print(qid, label)
Rapide explication sur la ligne qid = result[‘item’][‘value’].split(‘/’)[-1]
: comme l’identifiant de l’élément est en fait stocké sous la forme d’une URL complète (« https://www.wikidata.org/entity/Q17627724 » et pas juste « Q17627724 »), il nous faut séparer cette chaîne à chaque caractère ‘/’, ce qu’on fait à l’aide de la fonction « split()« , qui transforme la chaîne en une liste Python contenant ceci :
['https:', '', 'www.wikidata.org', 'entity', 'Q17627724']
Nous ne voulons que le dernier élément de cette liste. En Python, c’est celui avec l’index -1, d’où le [-1]
à la fin de la ligne. Enfin, nous stockons cette valeur dans la variable qid.
Lançons le script ainsi modifié :
$ python3 fix-battle-labels.py Q815196 Siège de Pskov Q815207 Siège de Silistra Q815233 Siège de Tyr Q608163 Siège de Cracovie Q1098377 Siège de Narbonne Q2065069 Siège de Hloukhiv Q4087405 Siège d'Avaricum Q2284279 Siège de Fort Pulaski Q4337397 Siège de Liakhavitchy Q4337448 Siège de Smolensk Q701067 Siège de Rhodes Q7510162 Siège de Cracovie Q23013145 Siège de Péronne Q10428014 Siège de Pskov Q3090571 Siège du Hōjūjidono Q3485893 Siège de Fukuryūji Q4118683 Siège d'Algésiras Q5036985 Siège de Berwick Q17627724 Siège d'Ilovaïsk Q815112 Siège d'Antioche
Corriger le problème
On y est presque ! Maintenant, il reste à remplacer cet orgueilleux « S » majuscule par un plus modeste « s » minuscule :
label = label[:1].lower() + label[1:]
Que se passe-t-il ici ? Une chaîne Python fonctionne comme une liste, on peut donc lui demander de prendre la partie située entre le début de la chaîne « label
» et la position qui suit le premier caractère (« label[:1]
») et forcer celui-ci en bas-de-casse (« .lower()
»). Ensuite, on y concatène le reste de la chaîne (de la position 1 à la fin, donc « label[1:]
») et on réassigne ce résultat à la variable « label
».
Dernière chose, formater le résultat de manière compatible à QuickStatements:
out = "{}\tLfr\t{}".format(qid, label) print(out)
Cette ligne semble barbare ? Elle est en fait assez simple : « {}\tLfr\t{}
» est une chaîne qui contient un premier emplacement pour le résultat d’une variable (« {}
»), puis une tabulation, (« \t
»), puis le mot-clef Quickstatements pour le libellé français (« Lfr
»), une autre tabulation et enfin le second emplacement pour une variable. Ensuite, la fonction « format()
» se charge de mettre le contenu des variables « qid
» et « label
» dedans. Le script final devrait ressembler à ça :
#!/usr/bin/env python3 from SPARQLWrapper import SPARQLWrapper, JSON endpoint = "https://query.wikidata.org/bigdata/namespace/wdq/sparql" sparql = SPARQLWrapper(endpoint) sparql.setQuery(""" SELECT ?item ?label WHERE {{ ?item wdt:P31/wdt:P279* wd:Q178561 . ?item rdfs:label ?label . FILTER(LANG(?label) = "fr") . FILTER(STRSTARTS(?label, "Siège ")) . }} """) # Link to query: http://tinyurl.com/z8bd26h sparql.setReturnFormat(JSON) results = sparql.query().convert() for result in results["results"]["bindings"]: qid = result['item']['value'].split('/')[-1] label = result['label']['value'] label = label[:1].lower() + label[1:] out = "{}\tLfr\t{}".format(qid, label) print(out)
C’est parti :
$ python3 fix-battle-labels.py Q815196 Lfr siège de Pskov Q815207 Lfr siège de Silistra Q815233 Lfr siège de Tyr Q2065069 Lfr siège de Hloukhiv Q2284279 Lfr siège de Fort Pulaski Q1098377 Lfr siège de Narbonne Q608163 Lfr siège de Cracovie Q4087405 Lfr siège d'Avaricum Q4337397 Lfr siège de Liakhavitchy Q4337448 Lfr siège de Smolensk Q701067 Lfr siège de Rhodes Q10428014 Lfr siège de Pskov Q17627724 Lfr siège d'Ilovaïsk Q23013145 Lfr siège de Péronne Q815112 Lfr siège d'Antioche Q3090571 Lfr siège du Hōjūjidono Q3485893 Lfr siège de Fukuryūji Q4118683 Lfr siège d'Algésiras Q5036985 Lfr siège de Berwick
On est bons ! Il ne reste plus qu’à copier-coller le résultat dans QuickStatements et attendre qu’il fasse le boulot tout seul.
Image d’en-tête:
Photographie de fontes de caractères par Andreas Praefcke (domaine public)