Générer un extrait de texte (snippet) autour d’une requête ou d’un mot recherché en Python

Mathieu Chartier Programmation 0 commentaire

Presque 5 ans après avoir publié un article sur un programme similaire en PHP, revoilà donc une nouvelle version plus aboutie et plus complète d'un "wrapper de requête" en Python. L'idée est de pouvoir créer dynamiquement un extrait de texte (snippet) autour d'un mot précis ou d'une requête de plusieurs mots, avec n mots avant et après cette requête. Et si vous le voulez, la fonction est adaptable pour générer une mise en gras de la requête centrale un peu comme le fait Google. :-)

Sans plus attendre, vous pouvez télécharger un ZIP contenant 2 versions de ce générateur de snippets en Python (dont une adaptation Jupyter Notebook si besoin). Je ne vous présenterai par la suite que la version la plus efficace sans la mise en gras (qui sera présentée uniquement dans cet article).

Télécharger “GenerateWrapText en Python”generateWrapTextPython.zip – Téléchargé 191 fois – 9,75 Ko

La fonction proposée résulte de certains des programmes que je dois réaliser dans le cadre de mon doctorat en humanités numériques. Elle est donc adaptée à mes besoins à l'origine, mais je propose quelques ajustements en fin d'article, notamment pour gérer la mise en gras des mots encadrés par l'extrait de texte, etc.

Générer un extrait de texte autour d'un mot précis en Python

Tout d'abord, voici ce que la fonction Python suivante permet de réaliser :

  1. Créer un snippet de n mots qui encadre la requête initiale (text wrapper). Vous pouvez évidemment choisir le nombre de mots qui précèdent et suivent la requête (sachant que si votre requête se situe en début ou fin de phrase, le générateur s'adapte automatiquement et n'ajoute que le nombre de mots adapté).
  2. Possibilité d'ajouter des requêtes complexes, d'un ou plusieurs mots, des requêtes étoilées (ex : "bataill*" pour mettre en gras "bataille", "batailles" ou "bataillon"), voire même des expressions régulières (regex) pour les plus aguerris.

Bien entendu, pour pouvoir générer des extraits, il vous faudra un texte et une requête. L'idéal étant d'avoir des textes récupérés à la volée dans une base de données ou dans des documents (csv, json...) afin de pouvoir fouiller à l'intérieur.

Principe de fonctionnement

La génération de texte se fait en découpant le texte mot à mot, et l'outil va rechercher le numéro d'index d'un mot correspondant à la requête initiale pour générer le texte autour.

Prenons un exemple simple : si vous recherchez "maison" dans le texte "Voici la construction d'une superbe maison en bois près de la ville de Nantes", le mot "maison" à l'index 5 ici (on commence par 0 pour "Voici" et on remonte pour chaque mot... ^^). Techniquement, le générateur réalise plusieurs opérations successives pour créer un snippet :

  1. Il découpe le texte mot à mot en créant une liste des mots : ["Voici", "la", "construction", "d'une", "superbe", "maison", "en", "bois", "près", "de", "la", "ville", "de", "nantes"]
  2. Il collecte les indices de chaque mot (0 pour "Voici", 1 pour "la", etc.)
  3. Il vérifie si le mot recherché, "maison" dans notre exemple", est bien présent dans la liste créée à l'étape 1. Si tel est le cas, il récupère son index.
  4. Il génère l'extrait de texte en ajoutant un maximum de n mots avant et après l'index trouvé à l'étape 3. Ainsi, il effectue un calcul pour récupérer automatiquement les mots qui précédent et suivent la requête.

Le principe général est relativement simple à comprendre, voire même à réaliser pour les habitués de la programmation (en Python ou dans un autre langage). En revanche, cela fonctionne très bien uniquement si la requête initiale ne contient qu'un seul mot précis (pour que les étapes 1 et 3 s'accordent et permettent de trouver l'index du mot recherché).

Spécificités du générateur de snippets présenté dans l'article

Comme je l'ai indiqué précédemment, les text wrapper habituels ne permettent de générer un extrait de texte que si la requête initiale n'est composée que d'un seul mot. Ceci est relativement contraignant et réducteur dès que l'on souhaite réaliser un extrait autour d'une requête plus complexe (plusieurs mots ou regex par exemple). Le programme Python présenté ci-après contre totalement cette limitation et permet de générer un extrait quelle que soit la requête initiale...

De plus, les générateurs d'extraits retournent en général un seul extrait, à savoir le premier extrait généré quand une occurrence de la requête a été effectuée. Dans le générateur que je vous propose, vous allez récupérer la liste complète des extraits possibles générés à partir de votre texte. Vous pouvez donc récupérer de 0 à n snippets les correspondances existantes entre le texte et la requête.

Générateur de snippet autour d'une requête en Python

Voici le programme Python complet, et je vous présenterai juste après un exemple d'utilisation du générateur d'extraits de texte.

import string
import re

def generateWrapText(text, search = "", limit = 10, exactMatch = False, removedPunct = "'-", splitChars = " ", prev = "(...) ", next = "..."):
    if text != "" and search != "":
        # Récupération et nettoyage de la ponctuation globale
        punctuations = string.punctuation
        punctuations = re.sub("["+removedPunct+"]", "", punctuations)

        # Gestion des requêtes étoilées
        search = re.sub("([\\*](\s|\"|$))", r".\1", search)
        search = re.sub("((\s|\"|^)[\\*])", r".\1", search)

        # Gestion de la requête recherchée (requête étoilée, expression de plusieurs mots...)
        regex = "["+splitChars+"]" # Regex de base
        query_splitted = list(filter(str.strip, re.split(regex, search, flags=re.IGNORECASE)))
        query_splitted_nb = len(query_splitted)

        # Découpe la phrase en mots pour trouver les occurrences de la recherche
        split = re.split(regex, text, flags=re.IGNORECASE) # Découpe à chaque espace
        wordsFromSentence = list(filter(str.strip, split))
        wordsForSequences = list(re.sub("["+punctuations+"\n\t\r]+", "", w) for w in wordsFromSentence)

        # Récupère la liste des index pour chaque occurrence du mot recherché
        regex_search = "^"+search+"$" if exactMatch == True else search
        if query_splitted_nb == 1:
            indexes = [(i,i+1) for i, x in enumerate(wordsForSequences) if re.match(regex_search, x, flags=re.IGNORECASE)]
        else:
            indexes = [(i,i+query_splitted_nb) for i, x in enumerate(wordsForSequences) if re.match(regex_search, " ".join(wordsForSequences[i:i+query_splitted_nb]), flags=re.IGNORECASE)]

        # Liste des phrases à récupérer
        sentences = []

        for found_index in indexes:
            index_start = max(found_index[0] - limit, 0) # Index du premier mot de la requête
            index_end = max(found_index[1] + limit, 0) # Index du dernier mot de la requête

            sentence = " ".join(wordsFromSentence[index_start:index_end]) # Création des séquences de mots autour de la requête
            if index_start < limit:
                sentence = prev + sentence # Ajoute le préfixe si nécessaire
            if (index_end + limit) > len(wordsForSequences):
                sentence = sentence + next # Ajoute le suffixe si nécessaire
            sentences.append(sentence) # Ajoute la séquence dans la liste des phrases retenues

        return sentences

Et donc un exemple d'utilisation et de génération d'extraits à la volée (autour d'un texte de Martin Luther King pour les plus passionnés d'entre vous ^^). Ici, je précise que la recherche doit être exacte (avec le paramètre exactMatch) et que l'extrait doit ajouter 5 mots avant et après la requête (avec le paramètre limit).

# Texte de base
texte = """De toutes parts, nous sommes appelés à travailler sans repos afin d'exceller dans notre carrière. Tout le monde n'est pas fait pour un travail spécialisé ; moins encore parviennent aux hauteurs du génie dans les arts et les sciences ; beaucoup sont appelés à être travailleurs dans les usines, les champs et les rues.

Mais il n'y a pas de travail insignifiant. Tout travail qui aide l'humanité a de la dignité et de l'importance. Il doit donc être entrepris avec une perfection qui ne recule pas devant la peine. Celui qui est appelé à être balayeur de rues doit balayer comme Michel-Ange peignait ou comme Beethoven composait, ou comme Shakespeare écrivait. Il doit balayer les rues si parfaitement que les hôtes des cieux et de la terre s'arrêteront pour dire : "Ici vécut un grand balayeur de rues qui fit bien son travail."

C'est ce que voulait dire Douglas Mallock quand il écrivait :
"Si tu ne peux être pin au sommet du coteau,
Sois broussaille dans la vallée.
Mais sois la meilleure petite broussaille
Au bord du ruisseau.
Sois buisson, si tu ne peux être arbre.
Si tu ne peux être route, sois sentier ;
Si tu ne peux être soleil, sois étoile ;
Ce n'est point par la taille que tu vaincras ;
Sois le meilleur, quoi que tu sois."

Examinez-vous sérieusement afin de découvrir ce pour quoi vous êtes faits, et alors donnez-vous avec passion à son exécution. Ce programme clair conduit à la réalisation de soi dans la longueur d'une vie d'homme.
"""

# Exemple de requête
requete = 'travail'

# Génération des extraits
extraits = generateWrapText(texte, requete, limit=5, exactMatch=True)

# Affichage des extraits
if extraits:
    for extrait in extraits:
        print(extrait)

Je vous ai fait une capture d'écran d'une requête plus complexe que "travail" (dans le code ci-dessus) pour vous montrer que les extraits se génèrent bien si la requête contient plusieurs mots.

Génération d'extraits de texte en Python autour d'une requête précise

Gestion de la mise en gras et des sauts de ligne dans le générateur

Suppression des sauts de ligne dans les extraits

Le générateur de snippets que je vous présente conserve la mise en forme initiale, dont les sauts de ligne. Or, vous voulez peut-être supprimer ces sauts de ligne par exemple dans les extraits de texte obtenus. Pour ce faire, c'est très simple, il suffit d'utiliser les fonctions Python replace() ou re.sub() et de supprimer les "\n" ou "\r" qui peuvent traîner, soit en amont en traitant le texte, soit en ajoutant cela dans la fonction (c'est sûrement mieux ainsi). Vous pouvez donc ajouter les lignes suivantes en tout début de fonction :

# Suppression des sauts de ligne du texte
text = re.sub("(\n|\r)", "", text)
Gestion de la mise en gras de la requête recherchée

Pour mettre en gras la requête, il existe plusieurs possibilités mais je vous en fournis une toute faite qui semble bien fonctionner (je l'ai fait rapidement, il persistera peut-être quelques bugs sur des requêtes complexes que je n'ai pas testées). Attention, veillez bien à ajouter ce petit morceau de code avant la ligne .

#############################################################
### PLACER LE CODE JUSTE AVANT sentences.append(sentence) ###
#############################################################
# Gestion de la mise en surbrillance (gras) de la requête
search = re.sub("(?<!\^)[ ]", "[ "+punctuations+"]+", search) # Adds punctuation in query if necessary (to avoid bad <strong>)
if exactMatch == True:
    sentence = re.sub('([^\w\'-]+|^)('+search+')([^\w\'-]+)', r'\1<strong>\2</strong>\3', sentence, flags=re.IGNORECASE)
else:
    sentence = re.sub('([^ '+punctuations+']+)?('+search+')([^ '+punctuations+']+)?', r'<strong>\1\2\3</strong>', sentence, flags=re.IGNORECASE)

Conclusion

Vous savez désormais générer des extraits de texte pour des types de requête multiples en Python, avec ou sans mise en surbrillance (gras) de la requête initiale, etc. Vous avez déjà une bonne base de travail pour vous amuser pendant des heures. :-)