Este projeto implementa um algoritmo personalizado para extrair as frases e palavras -chave mais importantes de artigos de notícias em espanhol e inglês.
Foi totalmente desenvolvido no Python e é inspirado em projetos semelhantes vistos nos subreddits Reddit News que usam o termo frequência de frequência -inverso ( tf–idf ).
Os três arquivos mais importantes são:
scraper.py : um script python que executa a eliminação da web em uma determinada fonte HTML, ele extrai o título do artigo, a data e o corpo.
summary.py : Um script python que aplica um algoritmo personalizado a uma sequência de texto e extrai as frases e palavras mais bem classificadas.
bot.py : um bot do Reddit que verifica um subreddit para obter seus últimos envios. Ele gerencia uma lista de envios já processados para evitar duplicatas.
Este projeto usa as seguintes bibliotecas Python
spaCy : Usado para tokenizar o artigo em frases e palavras.PRAW : facilita muito o uso da API do Reddit.Requests : Para executar o HTTP, get solicitações nos URLs de artigos.BeautifulSoup : Usado para extrair o texto do artigo.html5lib : Este analisador obteve melhor compatibilidade quando usado com BeautifulSoup .tldextract : Usado para extrair o domínio de um URL.wordcloud : Usado para criar nuvens de palavras com o texto do artigo. Depois de instalar a biblioteca spaCy , você deve instalar um modelo de idioma para poder tokenizar o artigo.
Para Spanish você pode executar este:
python -m spacy download es_core_news_sm
Para outros idiomas, verifique o seguinte link: https://spacy.io/usage/models
O bot é de natureza simples, usa a biblioteca PRAW , que é muito direta de usar. O Bot pesquisa um subreddit a cada 10 minutos para receber seus últimos envios.
Primeiro, ele detecta se o envio ainda não foi processado e depois verifica se o URL de envio está na lista de permissões. Esta lista de permissões é atualmente com curadoria por mim.
Se a postagem e seu URL passarem os dois cheques, um processo de raspagem na web é aplicado ao URL, é aqui que as coisas começam a ficar interessantes.
Antes de responder ao envio original, ele verifica a porcentagem da redução alcançada, se estiver muito baixo ou muito alto, ele o pula e se move para o próximo envio.
Atualmente na lista de permissões, já existem mais de 300 sites diferentes de artigos de notícias e blogs. Criar raspadores especializados da Web para cada um simplesmente não é viável.
A segunda melhor coisa a fazer é tornar o raspador o mais preciso possível.
Iniciamos o raspador da Web da maneira usual, com as Requests e 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" ) Muito poucas vezes, obtive problemas de codificação causados por um palpite incorreto de codificação. Para evitar esse problema, forço Requests a decodificar com utf-8 .
Agora que temos nosso artigo analisado em um objeto soup , começaremos extraindo o título e o tempo publicado.
Usei métodos semelhantes para extrair ambos os valores, primeiro verifico as tags mais comuns e o fallback das próximas alternativas comuns.
Nem todos os sites expõem a data publicada, às vezes terminamos com uma string vazia.
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 :
passAo extrair o texto de diferentes tags, muitas vezes recebi as cordas sem separação. Implementei um pequeno hack para adicionar novas linhas a cada tag que geralmente contém texto. Isso melhorou significativamente a precisão geral do tokenizer.
Minha idéia original era aceitar apenas sites que usavam a tag <article> . Funcionou bem para os primeiros sites que testei, mas logo percebi que muito poucos sites o usam e aqueles que o usam não o usam corretamente.
article = soup . find ( "article" ). text Ao acessar a propriedade .text da tag <article> , notei que também estava recebendo o código JavaScript. Voltei um pouco e removi todas as tags que poderiam adicionar ruído ao texto do artigo.
[ 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 ()O código acima removeu a maioria das legendas, que geralmente repetem o que está dentro do artigo.
Depois disso, apliquei um processo de 3 etapas para obter o texto do artigo.
Primeiro, verifiquei todas as tags <article> e peguei uma com o texto mais longo.
article = ""
for article_tag in soup . find_all ( "article" ):
if len ( article_tag . text ) >= len ( article ):
article = article_tag . text Isso funcionou bem para sites que usavam corretamente a tag <article> . A tag mais longa quase sempre contém o artigo principal.
Mas isso não funcionou como o esperado, notei baixa qualidade nos resultados, às vezes estava recebendo trechos para outros artigos.
Foi quando eu decidi adicionar o fallback, lnstead de procurar apenas a tag <article> que procurarei <div> e <section> tags com id's comumente usados.
# 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 Isso aumentou bastante a precisão, repeti o código, mas, em vez do atributo id eu também estava procurando o 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 . textO uso de todos os métodos anteriores aumentou bastante a precisão geral do raspador. Em alguns casos, usei palavras parciais que compartilham as mesmas letras em inglês e espanhol (Artic -> Artigo/Articulo). O raspador agora era compatível com todos os URLs que testei.
Fizemos uma verificação final e, se o artigo ainda for muito curto, abortaremos o processo e avançamos para o próximo URL, caso contrário, passamos para o algoritmo de resumo.
Esse algoritmo foi projetado para funcionar principalmente em artigos escritos em espanhol. Consiste em várias etapas:
Antes de começar, precisamos inicializar a biblioteca spaCy .
NLP = spacy . load ( "es_core_news_sm" ) Essa linha de código carregará o modelo Spanish que eu mais uso. Se você estiver usando outro idioma, consulte a seção Requirements para saber como instalar o modelo apropriado.
Ao extrair o texto do artigo, geralmente obtemos muitos espaços em branco, principalmente das quebras de linha ( n ).
Dividimos o texto por esse personagem, depois tiramos todos os espaços em branco e juntamos -o novamente. Isso não é estritamente necessário, mas ajuda muito enquanto depura todo o processo.
Na parte superior do script, declaramos o caminho dos arquivos de texto Stop Words. Essas palavras de parada serão adicionadas a um set , garantindo não duplicatas.
Também adicionei uma lista com algumas palavras em espanhol e inglês que não são palavras de parada, mas elas não adicionam nada substancial ao artigo. Minha preferência pessoal era codificá -los difícil na forma minúscula.
Em seguida, adicionei uma cópia de cada palavra em maiúsculas e formulários de título. O que significa que o set será 3 vezes o tamanho 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 iniciar o tokenizar nossas palavras, devemos primeiro passar nosso artigo limpo no pipeline NLP , isso é feito com uma linha de código.
doc = NLP ( cleaned_article ) Esse objeto doc contém vários iteradores, os 2 que usaremos são tokens e sents (frases).
Nesse ponto, adicionei um toque pessoal ao algoritmo. Primeiro, fiz uma cópia do artigo e depois removi todas as palavras comuns.
Depois, usei um objeto de collections.Counter para fazer a pontuação inicial.
Em seguida, apliquei um bônus multiplicador a palavras que começam na mancha e são iguais ou mais de 4 caracteres. Na maioria das vezes, essas palavras são nomes de lugares, pessoas ou organizações.
Finalmente, eu defini para zero a pontuação para todas as palavras que são realmente 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 ] = 0Agora que temos as pontuações finais para cada palavra, é hora de marcar cada frase do artigo.
Para fazer isso, primeiro precisamos dividir o artigo em frases. Eu tentei várias abordagens, incluindo RegEx , mas a que melhor funcionou foi a biblioteca spaCy .
Iteraremos novamente sobre o objeto doc que definimos na etapa anterior, mas desta vez iremos iterar sobre sua propriedade sents .
Algo a observar é que criamos uma lista de tokens de frase e dentro desses tokens, podemos recuperar o texto das frases acessando sua propriedade 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 é uma lista de listas. Cada lista interna contém 3 valores. A pontuação da frase, seu índice e a própria frase. Esses valores serão usados na próxima etapa.
O código abaixo mostra como as linhas são pontuadas.
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 um multiplicador a frases que contêm qualquer palavra que se refere a dinheiro ou finanças.
Esta é a parte final do algoritmo, utilizamos a função sorted() para obter as frases principais e depois reordená -las em suas posições originais.
Nós classificamos scored_sentences do Ordem Reversa, isso nos dará as frases mais marcadas primeiro. Iniciamos uma pequena variável de contador, por isso quebra o loop quando atinge 5. Também descartamos todas as frases que são 3 caracteres ou menos (às vezes existem caracteres sorrateiros de largura zero).
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 )]No final, usamos uma compreensão da lista para retornar apenas as frases que já estão classificadas em ordem cronológica.
Apenas por diversão, adicionei uma nuvem de palavras a cada artigo. Para isso, usei a biblioteca wordcloud . Esta biblioteca é muito fácil de usar, você precisa apenas declarar um objeto WordCloud e usar o método generate com uma sequência de texto como seu parâmetro.
wc = wordcloud . WordCloud () # See cloud.py for full parameters.
wc . generate ( prepared_article )
wc . to_file ( "./temp.png" ) Depois de gerar a imagem, enviei -a para Imgur , recebi o link URL e a adicionei à mensagem Markdown .

Este foi um projeto muito divertido e interessante para trabalhar. Eu posso ter reinventado a roda, mas pelo menos aprendi algumas coisas legais.
Estou satisfeito com a qualidade geral dos resultados e continuarei ajustando o algoritmo e aplicando aprimoramentos de compatibilidade.
Como nota lateral, ao testar o roteiro, solicitei acidentalmente tweets, postagens do Facebook e artigos escritos em inglês. Todos eles obtiveram saídas aceitáveis, mas como esses sites não eram o alvo, removi -os da lista de permissões.
Depois de algumas semanas de feedback, decidi adicionar suporte ao idioma inglês. Isso exigiu um pouco de refatoração.
Para fazê -lo funcionar com outros idiomas, você precisará apenas de um arquivo de texto que contenha todas as palavras de parada do referido idioma e copie algumas linhas de código (consulte a seção Remover comuns e paradas de palavras).