Ce projet met en œuvre un algorithme personnalisé pour extraire les phrases et mots clés les plus importants des articles de presse espagnol et anglais.
Il a été entièrement développé à Python et il est inspiré par des projets similaires observés sur les sous-verres Reddit News qui utilisent la fréquence de document à terme fréquence-inverse ( tf–idf ).
Les 3 fichiers les plus importants sont:
scraper.py : un script Python qui effectue un grattage Web sur une source HTML donnée, il extrait le titre de l'article, la date et le corps.
summary.py : un script Python qui applique un algorithme personnalisé à une chaîne de texte et extrait les phrases et les mots les plus classés.
bot.py : un bot Reddit qui vérifie un subreddit pour ses dernières soumissions. Il gère une liste de soumissions déjà traitées pour éviter les doublons.
Ce projet utilise les bibliothèques Python suivantes
spaCy : Utilisé pour tokensiner l'article en phrases et mots.PRAW : facilite l'utilisation de l'API Reddit.Requests : Pour effectuer des demandes HTTP, get les URL des articles.BeautifulSoup : Utilisé pour extraire le texte de l'article.html5lib : Cet analyseur a obtenu une meilleure compatibilité lorsqu'il est utilisé avec BeautifulSoup .tldextract : utilisé pour extraire le domaine d'une URL.wordcloud : utilisé pour créer des nuages de mots avec le texte de l'article. Après avoir installé la bibliothèque spaCy , vous devez installer un modèle de langue pour pouvoir tokeniser l'article.
Pour Spanish vous pouvez gérer celui-ci:
python -m spacy download es_core_news_sm
Pour les autres langues, veuillez consulter le lien suivant: https://spacy.io/usage/Models
Le bot est de nature simple, il utilise la bibliothèque PRAW qui est très simple à utiliser. Le bot interroge un subreddit toutes les 10 minutes pour obtenir ses dernières observations.
Il détecte d'abord si la soumission n'a pas déjà été traitée, puis vérifie si l'URL de soumission est dans la liste blanche. Cette liste blanche est actuellement organisée par moi-même.
Si le message et son URL passent les deux vérifications, un processus de grattage Web est appliqué à l'URL, c'est là que les choses commencent à devenir intéressantes.
Avant de répondre à la soumission d'origine, il vérifie le pourcentage de la réduction obtenue, s'il est trop faible ou trop élevé, il le saute et passe à la soumission suivante.
Actuellement dans la liste blanche, il existe déjà plus de 300 sites Web différents d'articles de presse et de blogs. Créer des grattoirs Web spécialisés pour chacun n'est tout simplement pas possible.
La deuxième meilleure chose à faire est de rendre le grattoir aussi précis que possible.
Nous commençons le grattoir Web sur la voie habituelle, avec les Requests et les bibliothèques BeautifulSoup .
with requests . get ( article_url ) as response :
if response . encoding == "ISO-8859-1" :
response . encoding = "utf-8"
html_source = response . text
for item in [ "</p>" , "</blockquote>" , "</div>" , "</h2>" , "</h3>" ]:
html_source = html_source . replace ( item , item + " n " )
soup = BeautifulSoup ( html_source , "html5lib" ) Très peu de fois, j'ai eu des problèmes d'encodage causés par une supposition de codage incorrecte. Pour éviter ce problème, je force Requests à décoder avec utf-8 .
Maintenant que nous avons notre article analysé dans un objet soup , nous commencerons par extraire le titre et l'heure publiée.
J'ai utilisé des méthodes similaires pour extraire les deux valeurs, je vérifie d'abord les balises les plus courantes et le repli aux alternatives courantes suivantes.
Tous les sites Web n'exposent pas leur date publiée, nous nous terminons parfois par une chaîne vide.
article_title = soup . find ( "title" ). text . replace ( " n " , " " ). strip ()
# If our title is too short we fallback to the first h1 tag.
if len ( article_title ) <= 5 :
article_title = soup . find ( "h1" ). text . replace ( " n " , " " ). strip ()
article_date = ""
# We look for the first meta tag that has the word 'time' in it.
for item in soup . find_all ( "meta" ):
if "time" in item . get ( "property" , "" ):
clean_date = item [ "content" ]. split ( "+" )[ 0 ]. replace ( "Z" , "" )
# Use your preferred time formatting.
article_date = "{:%d-%m-%Y a las %H:%M:%S}" . format (
datetime . fromisoformat ( clean_date ))
break
# If we didn't find any meta tag with a datetime we look for a 'time' tag.
if len ( article_date ) <= 5 :
try :
article_date = soup . find ( "time" ). text . strip ()
except :
passLors de l'extraction du texte de différentes balises, j'ai souvent obtenu les cordes sans séparation. J'ai implémenté un petit piratage pour ajouter de nouvelles lignes à chaque balise qui contient généralement du texte. Cela a considérablement amélioré la précision globale du tokenzer.
Mon idée originale était d'accepter uniquement des sites Web qui utilisaient la balise <article> . Cela a bien fonctionné pour les premiers sites Web que j'ai testés, mais je me suis vite rendu compte que très peu de sites Web l'utilisent et ceux qui l'utilisent ne l'utilisent pas correctement.
article = soup . find ( "article" ). text Lors de l'accès à la propriété .text de la balise <article> , j'ai remarqué que j'obtenais également le code JavaScript. J'ai un peu en arrière et supprimé toutes les balises qui pourraient ajouter du bruit au texte de l'article.
[ tag . extract () for tag in soup . find_all (
[ "script" , "img" , "ul" , "time" , "h1" , "h2" , "h3" , "iframe" , "style" , "form" , "footer" , "figcaption" ])]
# These class names/ids are known to add noise or duplicate text to the article.
noisy_names = [ "image" , "img" , "video" , "subheadline" ,
"hidden" , "tract" , "caption" , "tweet" , "expert" ]
for tag in soup . find_all ( "div" ):
tag_id = tag [ "id" ]. lower ()
for item in noisy_names :
if item in tag_id :
tag . extract ()Le code ci-dessus a supprimé la plupart des légendes, qui répétent généralement ce qu'il y a à l'intérieur de l'article.
Après cela, j'ai appliqué un processus en 3 étapes pour obtenir le texte de l'article.
J'ai d'abord vérifié toutes les balises <article> et saisi celui avec le texte le plus long.
article = ""
for article_tag in soup . find_all ( "article" ):
if len ( article_tag . text ) >= len ( article ):
article = article_tag . text Cela a bien fonctionné pour les sites Web qui ont correctement utilisé la balise <article> . La balise la plus longue contient presque toujours l'article principal.
Mais cela n'a pas tout à fait fonctionné comme prévu, j'ai remarqué une mauvaise qualité sur les résultats, parfois je recevais des extraits pour d'autres articles.
C'est à ce moment-là que j'ai décidé d'ajouter le repli, à la recherche uniquement de la balise <article> que je rechercherai des balises <div> et <section> avec id's couramment utilisés.
# These names commonly hold the article text.
common_names = [ "artic" , "summary" , "cont" , "note" , "cuerpo" , "body" ]
# If the article is too short we look somewhere else.
if len ( article ) <= 650 :
for tag in soup . find_all ([ "div" , "section" ]):
tag_id = tag [ "id" ]. lower ()
for item in common_names :
if item in tag_id :
# We guarantee to get the longest div.
if len ( tag . text ) >= len ( article ):
article = tag . text Cela a beaucoup augmenté la précision, j'ai répété le code mais au lieu de l'attribut id , je cherchais également l'attribut class .
# The article is still too short, let's try one more time.
if len ( article ) <= 650 :
for tag in soup . find_all ([ "div" , "section" ]):
tag_class = "" . join ( tag [ "class" ]). lower ()
for item in common_names :
if item in tag_class :
# We guarantee to get the longest div.
if len ( tag . text ) >= len ( article ):
article = tag . textL'utilisation de toutes les méthodes précédentes a considérablement augmenté la précision globale du grattoir. Dans certains cas, j'ai utilisé des mots partiels qui partagent les mêmes lettres en anglais et en espagnol (Artic -> Article / Articulo). Le grattoir était désormais compatible avec toutes les URL que j'ai testées.
Nous effectuons un contrôle final et si l'article est encore trop court, nous interdisons le processus et passons à l'URL suivante, sinon nous passons à l'algorithme de résumé.
Cet algorithme a été conçu pour travailler principalement sur des articles écrits espagnols. Il consiste sur plusieurs étapes:
Avant de commencer, nous devons initialiser la bibliothèque spaCy .
NLP = spacy . load ( "es_core_news_sm" ) Cette ligne de code chargera le modèle Spanish que j'utilise le plus. Si vous utilisez une autre langue, veuillez vous référer à la section Requirements afin que vous sachiez comment installer le modèle approprié.
Lors de l'extraction du texte de l'article, nous obtenons généralement beaucoup d'espaces blancs, principalement des pauses de ligne ( n ).
Nous avons divisé le texte par ce personnage, puis en déplions tous les espaces blancs et nous le joignons à nouveau. Ce n'est pas strictement nécessaire de le faire, mais aide beaucoup lors de la débogage de l'ensemble du processus.
En haut du script, nous déclarons le chemin d'accès des fichiers texte des mots stop. Ces mots d'arrêt seront ajoutés à un set , ne garantissant aucun doublons.
J'ai également ajouté une liste avec des mots espagnols et anglais qui ne sont pas des mots d'arrêt mais ils n'ajoutent rien de substantiel à l'article. Ma préférence personnelle était de les coder dur sous forme minuscule.
Ensuite, j'ai ajouté une copie de chaque mot en majuscules et formulaire de titre. Ce qui signifie que l' set sera 3 fois la taille d'origine.
with open ( ES_STOPWORDS_FILE , "r" , encoding = "utf-8" ) as temp_file :
for word in temp_file . read (). splitlines ():
COMMON_WORDS . add ( word )
with open ( EN_STOPWORDS_FILE , "r" , encoding = "utf-8" ) as temp_file :
for word in temp_file . read (). splitlines ():
COMMON_WORDS . add ( word )
extra_words = list ()
for word in COMMON_WORDS :
extra_words . append ( word . title ())
extra_words . append ( word . upper ())
for word in extra_words :
COMMON_WORDS . add ( word ) Avant de commencer à tokeniser nos mots, nous devons d'abord passer notre article nettoyé dans le pipeline NLP , cela se fait avec une ligne de code.
doc = NLP ( cleaned_article ) Cet objet doc contient plusieurs itérateurs, les 2 que nous utiliserons sont tokens et sents (phrases).
À ce stade, j'ai ajouté une touche personnelle à l'algorithme. J'ai d'abord fait une copie de l'article, puis j'ai retiré tous les mots courants.
Ensuite, j'ai utilisé un objet collections.Counter pour faire la notation initiale.
Ensuite, j'ai appliqué un bonus multiplicateur aux mots qui commencent en majuscules et sont égaux ou plus longs que 4 caractères. La plupart du temps, ces mots sont des noms de lieux, de personnes ou d'organisations.
Enfin, je me suis mis à zéro le score pour tous les mots qui sont réellement des nombres.
words_of_interest = [
token . text for token in doc if token . text not in COMMON_WORDS ]
scored_words = Counter ( words_of_interest )
for word in scored_words :
if word [ 0 ]. isupper () and len ( word ) >= 4 :
scored_words [ word ] *= 3
if word . isdigit ():
scored_words [ word ] = 0Maintenant que nous avons les scores finaux pour chaque mot, il est temps de marquer chaque phrase de l'article.
Pour ce faire, nous devons d'abord diviser l'article en phrases. J'ai essayé diverses approches, notamment RegEx , mais celle qui a le mieux fonctionné était la bibliothèque spaCy .
Nous allons itérer à nouveau sur l'objet doc que nous avons défini à l'étape précédente, mais cette fois, nous allons itérer la propriété sents .
Quelque chose à noter est que nous créons une liste de tokens de phrase et à l'intérieur de ces jetons, nous pouvons récupérer le texte des phrases en accédant à leur propriété text .
article_sentences = [ sent for sent in doc . sents ]
scored_sentences = list ()
or index , sent in enumerate ( article_sentences ):
# In some edge cases we have duplicated sentences, we make sure that doesn't happen.
if sent . text not in [ sent for score , index , sent in scored_sentences ]:
scored_sentences . append (
[ score_line ( sent , scored_words ), index , sent . text ]) scored_sentences est une liste de listes. Chaque liste intérieure contient 3 valeurs. Le score de la phrase, son index et la phrase elle-même. Ces valeurs seront utilisées à l'étape suivante.
Le code ci-dessous montre comment les lignes sont notées.
def score_line ( line , scored_words ):
# We remove the common words.
cleaned_line = [
token . text for token in line if token . text not in COMMON_WORDS ]
# We now sum the total number of ocurrences for all words.
temp_score = 0
for word in cleaned_line :
temp_score += scored_words [ word ]
# We apply a bonus score to sentences that contain financial information.
line_lowercase = line . text . lower ()
for word in FINANCIAL_WORDS :
if word in line_lowercase :
temp_score *= 1.5
break
return temp_score Nous appliquons un multiplicateur aux phrases qui contiennent tout mot qui se réfère à l'argent ou à la finance.
Il s'agit de la dernière partie de l'algorithme, nous utilisons la fonction sorted() pour obtenir les phrases les plus élevées, puis les réorganisons dans leurs positions d'origine.
Nous trie-t-on scored_sentences dans l'ordre inverse, cela nous donnera d'abord les phrases les plus marquées. Nous commençons une petite variable de contre-variable pour que elle casse la boucle une fois qu'elle atteint 5. Nous jetons également toutes les phrases qui sont 3 caractères ou moins (parfois il y a des caractères zéro-largeur sournois).
top_sentences = list ()
counter = 0
for score , index , sentence in sorted ( scored_sentences , reverse = True ):
if counter >= 5 :
break
# When the article is too small the sentences may come empty.
if len ( sentence ) >= 3 :
# We append the sentence and its index so we can sort in chronological order.
top_sentences . append ([ index , sentence ])
counter += 1
return [ sentence for index , sentence in sorted ( top_sentences )]À la fin, nous utilisons une compréhension de la liste pour retourner uniquement les phrases qui sont déjà triées par ordre chronologique.
Juste pour le plaisir, j'ai ajouté un cloud de mots à chaque article. Pour ce faire, j'ai utilisé la bibliothèque wordcloud . Cette bibliothèque est très facile à utiliser, il vous suffit de déclarer un objet WordCloud et d'utiliser la méthode generate avec une chaîne de texte comme paramètre.
wc = wordcloud . WordCloud () # See cloud.py for full parameters.
wc . generate ( prepared_article )
wc . to_file ( "./temp.png" ) Après avoir généré l'image, je l'ai téléchargé sur Imgur , j'ai récupéré le lien URL et l'a ajouté au message Markdown .

Ce fut un projet très amusant et intéressant sur lequel travailler. J'ai peut-être réinventé la roue mais au moins j'ai appris quelques choses cool.
Je suis satisfait de la qualité globale des résultats et je continuerai à peaufiner l'algorithme et à appliquer des améliorations de compatibilité.
En tant que note secondaire, lors du test du script, j'ai accidentellement demandé des tweets, des publications Facebook et des articles écrits en anglais. Tous ont obtenu des sorties acceptables, mais comme ces sites n'étaient pas la cible, je les ai retirés de la liste blanche.
Après quelques semaines de commentaires, j'ai décidé d'ajouter un soutien à la langue anglaise. Cela a nécessité un peu de refactorisation.
Pour le faire fonctionner avec d'autres langues, vous n'aurez besoin que d'un fichier texte contenant tous les mots d'arrêt de ladite langue et copiez quelques lignes de code (voir la section de supprimer des mots communs et d'arrêt).