Este proyecto implementa un algoritmo personalizado para extraer las oraciones y palabras clave más importantes de los artículos de noticias español e inglesas.
Se desarrolló completamente en Python y está inspirado en proyectos similares vistos en los subreddits de noticias Reddit que utilizan la frecuencia de documentos inverso de frecuencia ( tf–idf ).
Los 3 archivos más importantes son:
scraper.py : un script de Python que realiza raspado web en una fuente HTML dada, extrae el título, la fecha y el cuerpo del artículo.
summary.py : un script de Python que aplica un algoritmo personalizado a una cadena de texto y extrae las oraciones y palabras mejor clasificadas.
bot.py : un bot Reddit que verifica un subreddit para obtener sus últimas presentaciones. Gestiona una lista de presentaciones ya procesadas para evitar duplicados.
Este proyecto utiliza las siguientes bibliotecas de Python
spaCy : se usa para tokenizar el artículo en oraciones y palabras.PRAW : hace que el uso de la API Reddit sea muy fácil.Requests : Para realizar HTTP, get solicitudes de las URL de artículos.BeautifulSoup : utilizado para extraer el texto del artículo.html5lib : Este analizador obtuvo una mejor compatibilidad cuando se usó con BeautifulSoup .tldextract : se usa para extraer el dominio de una URL.wordcloud : se usa para crear nubes de palabras con el texto del artículo. Después de instalar la biblioteca spaCy , debe instalar un modelo de idioma para poder tokenizar el artículo.
Para Spanish puedes ejecutar este:
python -m spacy download es_core_news_sm
Para otros idiomas, consulte el siguiente enlace: https://spacy.io/usage/models
El bot es de naturaleza simple, usa la biblioteca PRAW que es muy sencilla de usar. El bot enciende un subreddit cada 10 minutos para obtener sus últimas presentaciones.
Primero detecta si la presentación aún no se ha procesado y luego verifica si la URL de envío está en la lista blanca. Esta lista blanca está actualmente curada por mí mismo.
Si la publicación y su URL pasan ambas comprobaciones, entonces se aplica un proceso de raspado web a la URL, aquí es donde las cosas comienzan a ponerse interesantes.
Antes de responder a la presentación original, verifica el porcentaje de la reducción alcanzada, si es demasiado bajo o demasiado alto, lo omite y se mueve a la próxima presentación.
Actualmente en la lista blanca ya hay más de 300 sitios web diferentes de artículos de noticias y blogs. Crear raspadores web especializados para cada uno simplemente no es factible.
La segunda mejor cosa que hacer es hacer que el raspador sea lo más preciso posible.
Comenzamos el raspador web de la manera habitual, con las Requests y las bibliotecas 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" ) Muy pocas veces obtuve problemas de codificación causados por una suposición de codificación incorrecta. Para evitar este problema, obligo Requests para decodificar con utf-8 .
Ahora que tenemos nuestro artículo analizado en un objeto soup , comenzaremos extrayendo el título y el tiempo publicado.
Utilicé métodos similares para extraer ambos valores, primero verifico las etiquetas más comunes y el retroceso a las siguientes alternativas comunes.
No todos los sitios web exponen su fecha publicada, a veces terminamos con una cadena vacía.
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 :
passAl extraer el texto de diferentes etiquetas, a menudo obtuve las cuerdas sin separación. Implementé un pequeño truco para agregar nuevas líneas a cada etiqueta que generalmente contiene texto. Esto mejoró significativamente la precisión general del tokenizador.
Mi idea original era aceptar solo sitios web que usaban la etiqueta <article> . Funcionó bien para los primeros sitios web que probé, pero pronto me di cuenta de que muy pocos sitios web lo usan y aquellos que lo usan no lo usan correctamente.
article = soup . find ( "article" ). text Al acceder a la propiedad .text de la etiqueta <article> noté que también estaba obteniendo el código JavaScript. Retrocedí un poco y eliminé todas las etiquetas que podrían agregar ruido al texto del artículo.
[ 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 ()El código anterior eliminó la mayoría de los subtítulos, que generalmente repiten lo que hay dentro del artículo.
Después de eso apliqué un proceso de 3 pasos para obtener el texto del artículo.
Primero revisé todas las etiquetas <article> y tomé la que con el texto más largo.
article = ""
for article_tag in soup . find_all ( "article" ):
if len ( article_tag . text ) >= len ( article ):
article = article_tag . text Eso funcionó bien para sitios web que usaron correctamente la etiqueta <article> . La etiqueta más larga casi siempre contiene el artículo principal.
Pero eso no funcionó como se esperaba, noté una mala calidad en los resultados, a veces estaba recibiendo extractos para otros artículos.
Fue entonces cuando decidí agregar el retroceso, ya que solo busca la etiqueta <article> . Estaré buscando <div> y <section> etiquetas con id's de uso común.
# 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 Eso aumentó bastante la precisión, repití el código, pero en lugar del atributo id también estaba buscando el atributo 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 . textEl uso de todos los métodos anteriores aumentó en gran medida la precisión general del raspador. En algunos casos utilicé palabras parciales que comparten las mismas letras en inglés y español (artic -> artículo/articulo). El raspador ahora era compatible con todas las URL que probé.
Hacemos una verificación final y si el artículo aún es demasiado corto, abortamos el proceso y pasamos a la siguiente URL, de lo contrario pasamos al algoritmo de resumen.
Este algoritmo fue diseñado para funcionar principalmente en artículos escritos en español. Consiste en varios pasos:
Antes de comenzar, necesitamos inicializar la biblioteca spaCy .
NLP = spacy . load ( "es_core_news_sm" ) Esa línea de código cargará el modelo Spanish que más uso. Si está utilizando otro idioma, consulte la sección Requirements para saber cómo instalar el modelo apropiado.
Al extraer el texto del artículo, generalmente obtenemos muchos espacios en blanco, principalmente de los descansos de línea ( n ).
Dividimos el texto por ese personaje, luego desnudamos todos los espacios en blanco y nos unimos nuevamente. Esto no es estrictamente obligado a hacer, pero ayuda mucho mientras depugga todo el proceso.
En la parte superior del script declaramos la ruta de los archivos de texto Stop Words. Estas palabras de parada se agregarán a un set , garantizando que no hay duplicados.
También agregué una lista con algunas palabras en español e inglés que no son palabras de parada, pero no agregan nada sustancial al artículo. Mi preferencia personal era codificarlos en forma minúscula.
Luego agregué una copia de cada palabra en forma de proceso y título. Lo que significa que el set será 3 veces el tamaño original.
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 ) Antes de comenzar a tokenizar nuestras palabras, primero debemos pasar nuestro artículo limpio a la tubería NLP , esto se hace con una línea de código.
doc = NLP ( cleaned_article ) Este objeto doc contiene varios iteradores, los 2 que usaremos son tokens y sents (oraciones).
En este punto, agregué un toque personal al algoritmo. Primero hice una copia del artículo y luego eliminé todas las palabras comunes.
Luego utilicé una collections.Counter Objetivo de cuentas para hacer la puntuación inicial.
Luego apliqué una bonificación de multiplicador a las palabras que comienzan en mayúsculas y son iguales o más de 4 caracteres. La mayoría de las veces esas palabras son nombres de lugares, personas u organizaciones.
Finalmente, configuré para cero el puntaje para todas las palabras que en realidad son números.
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 ] = 0Ahora que tenemos los puntajes finales para cada palabra, es hora de obtener cada oración del artículo.
Para hacer esto, primero necesitamos dividir el artículo en oraciones. Probé varios enfoques, incluido RegEx , pero el que mejor funcionó fue la Biblioteca spaCy .
Iteraremos nuevamente sobre el objeto doc que definimos en el paso anterior, pero esta vez iteraremos sobre su propiedad sents .
Algo a tener en cuenta es que creamos una lista de tokens de oraciones y dentro de esos tokens podemos recuperar el texto de las oraciones accediendo a su propiedad 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 es una lista de listas. Cada lista interna contiene 3 valores. El puntaje de la oración, su índice y la oración misma. Esos valores se utilizarán en el siguiente paso.
El siguiente código muestra cómo se califican las líneas.
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 Aplicamos un multiplicador a las oraciones que contienen cualquier palabra que se refiera al dinero o a las finanzas.
Esta es la parte final del algoritmo, utilizamos la función sorted() para obtener las oraciones superiores y luego reordenarlas en sus posiciones originales.
Ordedamos scored_sentences en orden inverso, esto nos dará las oraciones puntadas primero. Comenzamos una pequeña variable de contador para que rompa el bucle una vez que golpea 5. También descartamos todas las oraciones que son 3 caracteres o menos (a veces hay personajes de ancho cero astutos).
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 )]Al final, utilizamos una comprensión de la lista para devolver solo las oraciones que ya están ordenadas en orden cronológico.
Solo por diversión agregué una nube de palabras a cada artículo. Para hacerlo, usé la biblioteca wordcloud . Esta biblioteca es muy fácil de usar, solo debe declarar un objeto WordCloud y usar el método generate con una cadena de texto como parámetro.
wc = wordcloud . WordCloud () # See cloud.py for full parameters.
wc . generate ( prepared_article )
wc . to_file ( "./temp.png" ) Después de generar la imagen, la subí a Imgur , recuperé el enlace de URL y lo agregué al mensaje Markdown .

Este fue un proyecto muy divertido e interesante para trabajar. Puede que haya reinventado la rueda, pero al menos aprendí algunas cosas interesantes.
Estoy satisfecho con la calidad general de los resultados y seguiré ajustando el algoritmo y aplicando mejoras de compatibilidad.
Como nota al margen, al probar el guión, solicité accidentalmente tweets, publicaciones de Facebook y artículos escritos en inglés. Todos ellos obtuvieron resultados aceptables, pero dado que esos sitios no eran el objetivo, los quité de la lista blanca.
Después de algunas semanas de comentarios, decidí agregar soporte para el idioma inglés. Esto requirió un poco de refactorización.
Para que funcione con otros idiomas, solo requerirá un archivo de texto que contenga todas las palabras de parada de dicho idioma y copie algunas líneas de código (consulte la sección Eliminar las palabras comunes y detener las palabras).