In [1]:
from glob import glob
import pandas as pd
import numpy as np
import nltk
import matplotlib.pyplot as plt
from bs4 import BeautifulSoup
from difflib import SequenceMatcher

import webnectar as wbn
from goose import Goose

%matplotlib inline
plt.rcParams['figure.figsize'] = (10,3)

%load_ext autoreload
%autoreload 2

Índice

  1. Introdução
  2. O Texto
  3. O Contexto
  4. O Conteúdo
  5. Referências

Introdução

Descrição do problema

A popularização da World Wide Web trouxe um volume crescente de publicações. Simultaneamente, cresceu a necessidade e o poder computacional para processar esse fluxo contínuo de informação. Infelizmente a primeira etapa na mineração de dados neste tipo de documento já é criticamente complicada: extração do conteúdo textual principal de documentos em HTML.

Desde a criação do HTML, os documentos publicados na Web tem sofrido acréscimos de design e estrutura. Além de inserir formatação, os provedores de conteúdo frequentemente apresentam conteúdos adicionais relacionados, permitem comentários de visitantes e possuem conteúdo dinâmico. Quanto maior a complexidade de apesentação do documento, maior a dificuldade de identificar o conteúdo principal do documento em meio ao código HTML.

Atualmente é frequente o emprego de 80% a 99% dos caracteres do documento dedicados a elementos não textuais ou conteúdo de interesse secundário como links para outras notícias. Além disso, cada provedor é responsável por embutir no documento suas formatações específicas que podem variar com o tempo. Portanto, ajustar a extração a um provedor ou a um design específico é uma solução temporária e pouco relevante no contexto do Big Data.


In [2]:
## Para validação, usar o L3S-GN1

dataset = glob('datasets/L3S-GN1/extracts/*')
dataset = [doc.replace("datasets/L3S-GN1/extracts/","") for doc in dataset]

def getHTML(doc):
    with open('datasets/L3S-GN1/original/'+doc+'.html', "r") as myfile:
        return myfile.read().decode('UTF-8')
def getTEXT(doc):
    with open('datasets/L3S-GN1/extracts/'+doc, "r") as myfile:
        return myfile.read().decode('UTF-8')

In [3]:
def measure_docs_composition(doc):
    html = getHTML(doc)
    txt = getTEXT(doc)
    rlen = len(html)
    clen = len(txt)
    return rlen,clen

docs_composition = { doc : measure_docs_composition(doc) for doc in dataset }
docs_composition = pd.DataFrame(docs_composition).transpose()
docs_composition.columns = ["documento html",u"conteúdo"]

In [4]:
fig, (ax,bx) = plt.subplots(ncols=2,figsize=(8,3))

ax.set_title(u"Número de caracteres")
bx.set_title(u"conteúdo/html")
docs_composition[["documento html",u"conteúdo"]].boxplot(ax=ax,vert=False)
docs_composition['%'] = 100.0*docs_composition[u"conteúdo"]/docs_composition["documento html"]
docs_composition[['%']].boxplot(ax=bx,return_type='dict')

ax.set_position([0,0.3,0.8,0.8])
bx.set_position([0.85,0.3,0.2,0.8])
#ax.set_xscale('log')


/usr/lib/python2.7/dist-packages/pandas/tools/plotting.py:2380: FutureWarning: 
The default value for 'return_type' will change to 'axes' in a future release.
 To use the future behavior now, set return_type='axes'.
 To keep the previous behavior and silence this warning, set return_type='dict'.
  warnings.warn(msg, FutureWarning)

Para quem está familiarizado com a natureza padrão de um documento HTML, encontrar o conteúdo principal não requer a leitura completa do documento. Inadvertidamente, ao ler usamos um algoritmo visual e intuitivo porém altamente eficiente:

Começamos do início do texto procurando por uma região densa em linguagem natural. Ao encontrar, lemos seu conteúdo e decidimos se trata do assunto a qual o documento é dedicado. Caso positivo, este provavelmente faz parte do conteúdo principal da página.

O bom funcionamento deste método é embasado nas seguintes hipóteses:

  • Um documento HTML bem indentado apresenta muitas linhas curtas contendo tags de abertura e fechamento.
  • Exceto para campos de javaScript e CSS, grandes concentrações de texto indicam concentração de informação
  • Em geral conhecemos o contexto do documento à priori desta investigação.

O que será apresentado neste trabalho é uma formalização computacional deste método, formalmente dividido em três algoritmos:

  1. Extração de texto: extração de todo e qualquer texto do documento HTML.
  2. Extração de contexto: através de uma análise quantitativa do texto extraído
  3. Extração de conteúdo: adaptação das análises qualitativas ao contexto extraído

O Texto

A etapa mais trivial do procedimento é extrair apenas o conteúdo em linguagem natural da página, ignorando HTML, scripts e mídia. Diversas ferramentas existentes realizam esta tarefa, mas é de interesse das próximos etapas de processamento que seja mantido o espaçamento de informação presente no HTML.

Em outras palavras, o objetivo é extrair o texto do documento mantendo a correspondência com as respectivas linhas de código no HTML. Assim, espera-se que o texto principal da página esteja aglutinado enquanto demais resíduos estejam esparsos.

Entretanto, por causa do inerente impacto da estrutura textual do documento HTML, é necessário uma etapa de pré-processamento para normalizar o código HTML. Assim protegemos as futuras análises da influência da indentação e estilo de organização estrutural do autor da página.

Nesta etapa de normalização, utilizaremos o método prettify() da biblioteca BeautifulSoup, que reconstrói o documento HTML após a análise de sua árvore DOM. Assim, após remover a indentação teremos uma correspondência entre comprimento da linha e número caracteres de código HTML.


In [5]:
def measure_text_extraction_composition(doc):
    try:
        raw = getHTML(doc)
        raw_lines = raw.split('\n')
        rl = np.array([len(line) for line in raw_lines])
        
        article = wbn.extract(raw_html=raw)
        
        norm = article.normdoc
        norm_lines = norm.split('\n')
        nl = np.array([len(line) for line in norm_lines])
        
        text = article.rawtext
        text_lines = text.split('\n')
        tl = np.array([len(line) for line in text_lines])
        
        return rl.mean(),nl.mean(),tl.mean()
    
    except Exception as e:
        print doc+" deu merda: "+repr(e)
        return 0,0,0
    

text_composition = { doc: measure_text_extraction_composition(doc) for doc in dataset}
text_composition = pd.DataFrame(text_composition)
text_composition = text_composition.transpose()
text_composition.columns = ["html original", "html normalizado",u"texto extraído"]

In [6]:
fig, ax = plt.subplots(figsize=(10,3))
text_composition[text_composition<300].boxplot(ax=ax,vert=False)
#ax.set_xscale('log')
pass


Como essa extração é totalmente indiscriminada, i.e. obtém todo o conteúdo textual, esperamos um alto recall e uma precisão baixa.

O recall teve uma média de 0,988568 e desvio padrão 0,039959. Apenas um documento apresentou recall fora dos 25%, mas ao averiguar a estrutura deste documento em particular foi possível observar um erro de digitação. Por não fechar aspas em um script, o parser html5lib corrigiu o documento colocando todo o conteúdo consequente dentro do script. Naturalmente, o extrator de texto ignorou o conteúdo considerando ser script.


In [7]:
def measure_text_extraction_scores(doc):
    try:
        mytry = wbn.extract(raw_html=getHTML(doc)).rawtext
    except:
        return 0,0
    
    truth = getTEXT(doc)
    
    extract_tokens = nltk.tokenize.word_tokenize(mytry)
    true_tokens = nltk.tokenize.word_tokenize(truth)
    
    Nretrieved = len(extract_tokens)
    Nrelevant = len(true_tokens)
    
    matches = SequenceMatcher(None,extract_tokens,true_tokens,autojunk=False).get_matching_blocks()
    Ncommon = 0
    for match in matches:
        Ncommon += match[2]
    
    (p,r) = (0,0)
    try:
        p = 1.0*Ncommon/Nretrieved
    except:
        pass
    try: 
        r = 1.0*Ncommon/Nrelevant
    except:
        pass
    
    return p,r

text_scores = { doc: measure_text_extraction_scores(doc) for doc in dataset}
text_scores = pd.DataFrame(text_scores)
text_scores = text_scores.transpose()
text_scores.columns = ["precision","recall"]

In [8]:
fig, ax = plt.subplots(figsize=(10,3))
text_scores.boxplot(ax=ax,vert=False)
text_scores.describe()


Out[8]:
precision recall
count 621.000000 621.000000
mean 0.502730 0.978536
std 0.216838 0.092873
min 0.000000 0.000000
25% 0.353734 0.993651
50% 0.501672 1.000000
75% 0.660702 1.000000
max 0.977520 1.000000

O Contexto

Dado que conseguimos extrar apenas o conteúdo textual de um documento HTML preservando o espaçamento original dos campos de texto, como estimar um subconjunto representativo do contexto do documento? É nesta etapa que a normalização e indentação do documento original se evidenciam críticas, pois será necessário uma análise estrutural do texto.

Análise de Picos

RASCUNHO

  • Falar sobre atenuação de curvas
  • Como usar atenuações de diferentes intensidades para detectar tendências

In [9]:
def measure_context_scores(doc):
    try:
        mytry = wbn.extract(raw_html=getHTML(doc)).context
    except:
        return 0,0
    
    truth = getTEXT(doc)
    
    extract_tokens = nltk.tokenize.word_tokenize(mytry)
    true_tokens = nltk.tokenize.word_tokenize(truth)
    
    Nretrieved = len(extract_tokens)
    Nrelevant = len(true_tokens)
    
    matches = SequenceMatcher(None,extract_tokens,true_tokens,autojunk=False).get_matching_blocks()
    Ncommon = 0
    for match in matches:
        Ncommon += match[2]
    
    (p,r) = (0,0)
    try:
        p = 1.0*Ncommon/Nretrieved
    except:
        pass
    try: 
        r = 1.0*Ncommon/Nrelevant
    except:
        pass
    
    return p,r

context_scores = { doc: measure_context_scores(doc) for doc in dataset}
context_scores = pd.DataFrame(context_scores)
context_scores = context_scores.transpose()
context_scores.columns = ["precision","recall"]

In [10]:
fig, ax = plt.subplots(figsize=(10,3))
ax.set_title("L3S-GN1")
ax.set_xlabel("My Scores")
context_scores.boxplot(ax=ax,vert=False)
context_scores.describe()


Out[10]:
precision recall
count 621.000000 621.000000
mean 0.921357 0.833227
std 0.211910 0.237533
min 0.000000 0.000000
25% 0.957346 0.799383
50% 0.996815 0.943750
75% 1.000000 0.980769
max 1.000000 1.000000

In [11]:
fdp = context_scores[context_scores.precision<0.1].index[0]
context_scores.loc[fdp]


Out[11]:
precision    0.059701
recall       0.050209
Name: 055c4531-068a-4c10-8d43-5316c874f7ef, dtype: float64

In [12]:
def measure_goose_scores(doc):
    try:
        mytry = Goose({'enable_image_fetching':False}).extract(raw_html=getHTML(doc)).cleaned_text
    except:
        return 0,0
    
    truth = getTEXT(doc)
    
    extract_tokens = nltk.tokenize.word_tokenize(mytry)
    true_tokens = nltk.tokenize.word_tokenize(truth)
    
    Nretrieved = len(extract_tokens)
    Nrelevant = len(true_tokens)
    
    matches = SequenceMatcher(None,extract_tokens,true_tokens,autojunk=False).get_matching_blocks()
    Ncommon = 0
    for match in matches:
        Ncommon += match[2]
    
    (p,r) = (0,0)
    try:
        p = 1.0*Ncommon/Nretrieved
    except:
        pass
    try: 
        r = 1.0*Ncommon/Nrelevant
    except:
        pass
    
    return p,r

goose_scores = { doc: measure_goose_scores(doc) for doc in dataset}
goose_scores = pd.DataFrame(goose_scores)
goose_scores = goose_scores.transpose()
goose_scores.columns = ["precision","recall"]

In [13]:
fig, ax = plt.subplots(figsize=(10,3))
ax.set_title("L3S-GN1")
ax.set_xlabel("Goose Scores")
goose_scores.boxplot(ax=ax,vert=False)
goose_scores.describe()


Out[13]:
precision recall
count 621.000000 621.000000
mean 0.889606 0.884048
std 0.247954 0.223326
min 0.000000 0.000000
25% 0.947368 0.902597
50% 0.993915 0.958175
75% 1.000000 0.981707
max 1.000000 1.000000

Extração de conteúdo principal

RASCUNHO

  • A preocupação no momento é a precisão, não o recall.

Valoração diferenciada

RASCUNHO

  • Valorizar uma linha de texto pelo conteúdo linguísttico
  • Detectar pico de valores supõe continuidade da informação

Aparagem de bordas

RASCUNHO

  • Atenuações aumentam a largura real do pico
  • Aparar bordas para aumentar a precisão

O Conteúdo

Próxima etapa

Usa análise de pico para encontrar todos os picos compatíveis

Valoração: anteriores + similaridade contextual com o conteúdo principal (ajustar pesos)

Extração completa

RASCUNHO

  • Depois de extrair o conteúdo principal é mais fácil extrair o conteúdo completo
  • Analogia: o título nos dá uma previsão do assunto, então permite identificar se um trecho é relevante ou não

Múltipla extração

RASCUNHO

  • Nem sempre o conteúdo completo está contido em porções contínuas
  • Fatores que atrapalham: mídia inserida, estrutura complexa, links e propagandas mistos

Valoração contextual

RASCUNHO

  • Tentativa mais agressiva de separar o joio do trigo, usando o contexto
  • Objetiva melhor definição dos picos, para extração múltipla

Referências

[1] http://www.w3.org/People/Raggett/tidy/

[2] http://tidy.sourceforge.net/docs/tidy_man.html

Christian Kohlschuetter, Peter Fankhauser, Wolfgang Nejdl Boilerplate Detection using Shallow Text Features WSDM 2010: Third ACM International Conference on Web Search and Data Mining New York City, NY USA. Disponível em <http://www.l3s.de/~kohlschuetter/publications/wsdm187-kohlschuetter.pdf > acessado em 13/10/2014.


In [29]:
html = getHTML(dataset[0])
article = wbn.extract(raw_html=html)
lens = [len(line) for line in article.rawtext.split('\n')]
pds = pd.Series(lens)
pd.rolling_mean(pds,100).shift(-50).plot()
npa = np.convolve(lens, np.ones((100,))/100, mode='same')
plt.plot(npa)
npa


Out[29]:
array([ 0.,  0.,  0., ...,  0.,  0.,  0.])
ax.annotate("global maximum",xy=(self.max_spike_line,self.spikeness[self.max_spike_line]), xytext=(self.max_spike_line-1.3*sideshift,self.spikeness[self.max_spike_line]+vertshift), arrowprops=dict(arrowstyle="->")) ax.annotate("left minimum",xy=(self.left_min_spike_line,self.spikeness[self.left_min_spike_line]), xytext=(self.left_min_spike_line-sideshift,-3*vertshift), arrowprops=dict(arrowstyle="->")) ax.annotate("right minimum",xy=(self.right_min_sipke_line,self.spikeness[self.right_min_sipke_line]), xytext=(self.right_min_sipke_line,-3*vertshift), arrowprops=dict(arrowstyle="->"))