Web Scraping com Python - Comics digitais do Comixology

May 24, 2016

O código completo pode ser encontrado no Github, em: https://github.com/felipegalvao/comixology_scraping_and_analysis

Introdução

Bem, depois de ter falado sobre a análise prévia que fiz sobre o site da Comixology no post anterior desta série, vamos falar agora de web scraping com Python, do código de scraping em si. Como já falamos no primeiro post, vamos usar requests para fazer o request nos links desejados e o lxml com Xpath para fazer a depuração e extração das informações desejadas das páginas. Vamos lá.

Começando a escrever o código

Para começo de conversa, vamos importar os pacotes que necessitamos, que são o requests e, do lxml, o método html. Para acessar as partes do HTML que desejamos, vamos usar Xpath. Bem, então vamos falar um pouquinho de Xpath antes de partir para o código. Vou tentar ser resumido.

Xpath: O que é e como usar

Xpath é uma linguagem de consulta para extração de conteúdo de documentos XML / HTML. Ele pode ser usado para extrair informação de nós e atributos, sendo bastante útil em tarefas de web scraping. O Xpath utiliza expressões que fazem a associação com itens do código em HTML ou XML.

Documentos XML / HTML são tratados como árvores de nós. Os nós podem apresentar relações de parentesco, podendo ser pais, filhos, irmãos, ancestrais e descendentes. Considerando o seguinte código XML:

<loja>
  <produto>
    <classe>Camisa</classe>
    <cor>Vermelha</cor>
    <preco>25.00</preco>
  </produto>
</loja>```

As relações são as seguintes:

- Pai - São elementos que contém outros elementos. O elemento loja é pai do produto, e o elemento produto é pai da classe, cor e preço
- Filho - Analogamente, são elementos que estão contidos em outro elemento. Os elementos classe, cor e preço são filhos do elemento produto
- Irmãos - São elementos dentro do mesmo nó. Os elementos classe, cor e preço são irmãos
- Ancestrais - Todos os elementos que contém um determinado elemento, como o pai de um elemento, o pai do pai de um elemento, e assim por diante. Em nosso exemplo, os ancestrais de cor são produto e loja.
- Descendentes - Elementos contidos dentro de outro elemento, em todos os níveis, como os filhos, filhos dos filhos. Os descendentes de loja são o produto, e também classe, cor e preço.

Para selecionar nós utilizamos alguns símbolos, com os quais vamos construindo expressões que cheguem até onde está a informação que desejamos. Cada símbolo tem uma determinada função, que mostro na tabela abaixo:

| Símbolo | Função                                                                                          |
|---------|-------------------------------------------------------------------------------------------------|
| nome    | Seleciona todos os nós / elementos com este nome                                                |
| /       | Faz a seleção partir de um nó raiz                                                              |
| //      | Seleciona nós a partir do nó atual que atendam à condição de seleção, não importa onde estejam. |
| .       | Seleciona o nó atual                                                                            |
| ..      | Seleciona o pai do nó atual                                                                     |
| @       | Seleciona um atributo                                                                           |

Desta forma, podemos construir uma expressão que vá selecionando os nós que desejamos para chegar na informação que desejamos extrair. Vamos olhar um exemplo com código HTML:

```html
<html>
  <body>
    <div class="style-div">
      <p>Texto 1</p>
    </div>
    <div class="style-div">
      <p>Texto 2</p>
    </div>
    <div>
      <p id="p-paragrafo">Texto 3</p>
    </div>
  </body>
</html>

Considerando este código e os elementos que já citamos, temos alguns exemplos:

//div Seleciona todos os divs do documento
/html Seleciona o elemento raíz html
html//p Seleciona todos os elementos p dentro do elemento HTML, não importa onde estejam
html/div Seleciona todos os elementos div filhos do elemento html

Você também pode combinar elementos e atributos para seleção. Para extrair o texto dentro de elementos, usamos text() ao final, e esta é uma das funcionalidades que mais utilizaremos. Vejamos:

//@class Retorna todos os atributos class no documento
//div[@class=’style-div’]/p Retorna todos os elementos p que são filhos de elementos div que possuem um atributo class com valor igual a ‘style-div’
//p[@id=’p-paragrafo’]/text() Retorna o texto dentro de cada elemento p que possui um atributo id com valor igual a ‘p-paragrafo’

Fiz o upload de uma página HTML simples para podermos construir os códigos em Python e verificar estes exemplos na prática. O endereço é o seguinte: https://felipegalvao.com.br/pt/scraping/

E agora vamos ao código de scraping para estes simples exemplos:

import requests
from lxml import html

page = requests.get('https://felipegalvao.com.br/pt/scraping')
tree = html.fromstring(page.content)

exemplo1 = tree.xpath('//p/text()')
print(exemplo1)
>> ['Texto 1', 'Texto 2', 'Texto 3']

exemplo2 = tree.xpath('/html/body/div/p/text()')
print(exemplo2)
>> ['Texto 1', 'Texto 2', 'Texto 3']

exemplo3 = tree.xpath('//div[@class="style-div"]/p/text()')
print(exemplo3)
>> ['Texto 1', 'Texto 2']

exemplo4 = tree.xpath('//p[@id="p-paragrafo"]/text()')
print(exemplo4)
>> ['Texto 3']

exemplo5 = tree.xpath('//p[@id="p-paragrafo-foo"]/text()')
print(exemplo5)
>> []

Em resumo, primeiro fazemos o request passando o endereço para a função get() da library requests. Depois usamos a função html() do lxml para extrair o código fonte em HTML do endereço passado. Depois basta usar a função xpath() para extrair as informações com sentenças utilizando a sintaxe que já aprendemos.

Como é possível notar, o valor que é retornado é sempre uma lista, e basta tratar estes resultados como uma lista normal do Python. Nota-se também que, ao buscarmos no documento algo que não existe, retorna-se uma lista vazia.

E agora que vimos os exemplos mais simples, vamos para uma coisa mais complexa e interessante.

Retornando ao código

Agora que já vimos o que é o Xpath e como utiliza-lo, vamos para a parte mais legal, que é o scraping do site da Comixology em si. Para tarefas de web scraping, um dos nossos melhores amigos será a função de Inspecionar Elemento do navegador. Ela está presente tanto no Chrome quanto no Firefox e nos permite ver a estrutura do código fonte.

Vamos logo para a parte de Publishers do Comixology (se preferir clique aqui). Como resumo, vamos iniciar nosso código definindo a função e criando uma lista vazia, onde serão guardados os links que vamos extrair. Como falamos na primeira parte, vamos definir a quantidade de páginas manualmente (são 4, e portanto, nosso teremos um for page in range(1,5)), e vamos passar por cada uma delas, usando para isso um for. Se formos até o site e passarmos para a segunda página dos Publishers, vamos descobrir que o link vira: https://www.comixology.com/browse-publisher?publisherList_pg=2. Se alteramos a parte final de volta para 1, voltamos para a primeira página, como esperado. As primeiras páginas do código ficam assim, então:

from lxml import html
import requests

def get_publishers_links():
    publisher_links = []
    
    # Iterate through the pages on the browse by Publisher    
    for i in range(1,5):
        # Set the link, make a request to it and extract the html code
        link = 'https://www.comixology.com/browse-publisher?publisherList_pg='
        page = requests.get(link+str(i))
        tree = html.fromstring(page.content)

Até agora tudo simples. Precisamos agora encontrar, em cada página, a quantidade de Publishers. Para isto, vamos inspecionar o elemento que constitui um Publisher e analisa-lo, não esquecendo que não vamos considerar os Featured Publishers, que estão na tabela de cima da página. Ao inspecionar um elemento da tabela de All Publishers e compara-lo com um elemento da tabela Featured Publishers, descobrimos que o que os diferencia é a classe de um div intermediário. Enquanto itens dos Featured Publishers ficam dentro de um div de classe list FeaturedPublisherList, os itens da tabela All Publishers ficam em um div de classe list publisherList. Vamos então, partir daí para fazer nossa seleção.

Inspecionando editoras no Comixology
Inspecionando editoras no Comixology

Vou criar a string que indicará a expressão Xpath para coleta de elementos, buscando inicialmente um div com esta classe (//div[@class="list publisherList"]), seguindo então através dos elementos HTML até chegar ao elemento <a>, onde pegaremos seu atributo href, que é o link em si para cada Publisher. Vou dividir a criação da string em 2 linhas pois ela é razoavelmente extensa. Um detalhe importante que ainda não havia comentado é que os comics vêm com um atributo ref em seu link. Vamos criar uma função somente para remover este atributo e retornar a lista de links “limpos”, e vamos passar nossa lista de links extraídos para esta função, para depois fazer o extend na lista que criamos lá no início (ou seja, adicionar uma lista à outra). Esta função usará Regex (regular expressions; vamos importar a library re para trabalhar com Regex em Python), que não explicarei neste post, mas basicamente ele substitui tudo que está após o ponto de interrogação (inclusive o ponto) por nada. Vejamos como fica tudo:

from lxml import html
import requests
import re

# Remover atributos dos links de uma lista de links e retornar lista "limpa"
def remove_attributes_from_link(comic_link_list):
    clean_comic_link_list = []   
    for comic_link in comic_link_list:
        # Substituir tudo após a "?" por nada
        new_comic_link = re.sub("(\?.+)","",comic_link)
        clean_comic_link_list.append(new_comic_link)
    return(clean_comic_link_list)

# Retornar lista de Links dos Publishers
def get_publishers_links():
    publisher_links = []
    
    # Iterar por cada página do link Browse > by Publisher
    for i in range(1,5):
        # Definição do link a ser explorado
        link = 'https://www.comixology.com/browse-publisher?publisherList_pg='
        page = requests.get(link+str(i))
        tree = html.fromstring(page.content)
        
        # Expressão Xpath para extração dos links dos Publishers        
        quantity_pub_xpath = '//div[@class="list publisherList"]/ul'
        quantity_pub_xpath += '/li[@class="content-item"]/figure/div/a/@href'
        
        # Extração dos links através da função Xpath    
        extracted_publishers_links = tree.xpath(quantity_pub_xpath)
        clean_publisher_links = remove_attributes_from_link(
            extracted_publishers_links)
        # Inserir os links na lista já criada
        publisher_links.extend(clean_publisher_links)
            
    return(publisher_links)

Os comentários ajudam a entender cada parte do código. Se tudo der certo, esta função extrairá todos os links para os Publishers e você os terá na lista retornada pela função. Primeira parte, concluída :).

Vou aproveitar este momento para falar de mais uma library que vai nos ajudar neste trabalho, que é a library pickle (para saber mais, clique aqui). Esta library nos permite exportar informações para arquivos para depois recupera-las. Neste primeiro momento pode não parecer muito útil, pois para os links de Publishers a função roda bem rápido e não toma muito tempo, pois são apenas 4 páginas a serem percorridas, mas para os próximos passos ele será importante. Vamos então exportar nossa lista de links para um arquivo. Usaremos a função dump do pickle, passando o objeto a ser exportado e o arquivo a ser criado:

import pickle

publishers_links = get_publishers_links()
pickle.dump(publishers_links, open("publishers_links.p","wb"))

Para recuperar o objeto exportado, basta usar a função load() do pickle, passando o arquivo desejado:

publishers_links = pickle.load(open("publishers_links.p","rb"))

Dos Publishers para as Series

Bem, agora que já temos os links para os Publishers, nosso próximo passo deve ser bastante semelhante, repetido para cada Publisher. Criaremos a função, que receberá como argumento a lista de links dos Publishers, que conseguimos com a função anterior. Vamos começar:

def get_series_links_from_publisher(publisher_links):
    series_links = []
    
    for link in publisher_links:
        page = requests.get(link)
        tree = html.fromstring(page.content)

Até aí, tudo tranquilo. Depois disso, precisamos descobrir o número de páginas. Acessando os Publishers, descobre-se que existem três casos possíveis: A - quando o Publisher só possui uma página, B - quando possui uma quantidade pequena de páginas (de 2 a mais ou menos 10), C - quando possui uma grande quantidade de páginas. Veja na imagem:

Editora com 1 página
Editora com 1 página
Editora com poucas páginas
Editora com poucas páginas
Editora com muitas páginas
Editora com muitas páginas

Esta configuração faz com que conseguir o número de páginas diretamente se torne um pouquinho mais complexo. Fiz de uma forma que achei um pouco mais fácil. Nos dois casos onde temos mais de uma página, o número de Series totais daquele Publisher fica no canto inferior direito da tabela, como é possível ver nas figuras B e C. Se uma página sempre tem 36 Series (com exceção da última ou se o Publisher possui apenas uma página), o número de páginas se torna fácil de ser calculado. Se a página é divisível por 36, a quantidade de páginas é igual à quantidade de Series do Publisher sobre 36, em divisão inteira (em Python 3, usamos //). Se não for divisível por 36, basta somar mais um ao resultado da divisão. E se o Xpath não encontrar nada, significa que o número de Series não está na página e este Publisher tem apenas uma página. Simples, não? Bem, vamos novamente Inspecionar aquele elemento onde está a quantidade de Series para construir nossa expressão Xpath. Depois podemos ir para o código:

Quantidade de séries em uma página
Quantidade de séries em uma página

# String xpath para extração da quantidade total de Series
        xpath_series = '//div[@class="list seriesList"]/div[@class="pager"]'
        xpath_series += '/div[@class="pager-text"]/text()'
        
        total_series = tree.xpath(xpath_series)
        
        # Se a extração retornou a quantidade total:
        if total_series:
            # O único item da lista é a string, cujo último elemento é a 
            # quantidade de Series
            total_series = total_series[0].split()
            total_series = int(total_series[len(total_series)-1])
            # Divide-se o total de Series por 36, afim de descobrir o número
            # de páginas de Series neste Publisher
            if total_series % 36 == 0:
                number_of_pages = (total_series // 36)
            else:
                number_of_pages = (total_series // 36) + 1
        # Se a extração retornou uma lista vazia, só existe uma página de Series
        else:
            number_of_pages = 1

A função split() que foi usada divide a string que extraímos em uma lista, onde o último valor da lista é o total de Series deste Publisher. Como já falamos, se o Xpath não encontrar nada, significa que este Publisher só tem uma página de Series.

Agora, de posse do número de páginas, podemos iterar por elas, e então, extrair os links de cada Serie para então passar para a próxima página, e assim por diante. A função final fica assim:

def get_series_links_from_publisher(publisher_links):
    series_links = []
    
    for link in publisher_links:
        page = requests.get(link)
        tree = html.fromstring(page.content)
        
        # String xpath para extração da quantidade total de Series
        xpath_series = '//div[@class="list seriesList"]/div[@class="pager"]'
        xpath_series += '/div[@class="pager-text"]/text()'
        
        total_series = tree.xpath(xpath_series)
        
        # Se a extração retornou a quantidade total:
        if total_series:
            # O único item da lista é a string, cujo último elemento é a 
            # quantidade de Series
            total_series = total_series[0].split()
            total_series = int(total_series[len(total_series)-1])
            # Divide-se o total de Series por 36, afim de descobrir o número
            # de páginas de Series neste Publisher
            if total_series % 36 == 0:
                number_of_pages = (total_series // 36)
            else:
                number_of_pages = (total_series // 36) + 1
        # Se a extração retornou uma lista vazia, só existe uma página de Series
        else:
            number_of_pages = 1     
        for page_number in range(1,number_of_pages+1):
            page = requests.get(link+'?seriesList_pg='+str(page_number))                
            tree = html.fromstring(page.content)            
            
            # String Xpath para extração dos links das Series nesta página
            xpath_series_links = '//div[@class="list seriesList"]/ul/'
            xpath_series_links += 'li[@class="content-item"]/figure/'
            xpath_series_links += 'div[@class="content-cover"]/a/@href'
            extracted_series_links = tree.xpath(xpath_series_links) 
            clean_series_links = remove_attributes_from_link(extracted_series_links)                
            series_links.extend(clean_series_links)
    return(series_links)

Novamente, usaremos pickle para guardar nossa lista de links em um arquivo que podemos recuperar posteriormente. Este passo deve demorar mais do que a parte dos Publishers, já que uma quantidade bem maior de links têm de ser acessada, mas não deve demorar muito mais do que alguns minutos.

PS: A exportação dos objetos será essencial nos passos posteriores do scraping, pois erros inesperados podem ocorrer (falha de conexão, indisponibilidade do site, etc), e apesar de podermos tratar estes erros, é bastante útil guardarmos os dados extraídos até um determinado momento para que não haja retrabalho em erros / problemas não previstos.

pickle.dump(series_links, open("series_links.p","wb"))

Das Series para os Comics

Agora, temos que dar mais um passo, para passar pelos links das Series e extrair os links dos comics. Preparem-se, a execução desse passo será longa, pois são muitos links a serem visitados. Apesar disso, a ideia é basicamente a mesma. Passar por cada página de cada uma das Series e extrair cada um dos links que necessitamos. No caso das Series, como vimos no primeiro post de análise do site, vamos precisar extrair links para diferentes tipos de comics, como Issues separadas, Collected Editions, entre outros. Vamos considerar que os links das Series estão na variável series_links, conforme a parte anterior do post, e vamos começar com nosso código:

def get_issues_links_from_series(series_links):
    comics_links = []    
    
    for counter, link in enumerate(series_links):
        page = requests.get(link)
    
        tree = html.fromstring(page.content)

Para o próximo passo, vamos criar mais uma função, pois os blocos serão bem semelhantes, com apenas algumas alterações com relação a links e o caminho do Xpath para o div que abriga determinado tipo de comic. Mas primeiro, para entendermos bem, vamos fazer o código para um bloco específico. Depois, vemos as partes do código que devem se repetir e como estruturamos nossa função.

Vamos iniciar então o primeiro bloco, para o scraping dos links de Collected Editions. A primeira coisa que iremos fazer será verificar se este bloco realmente existe para a Series atual. Nem todas as Series possuem todos os tipos de comics, e na realidade, a maioria delas possui apenas o bloco de Issues. Desta forma, se o bloco nem existe, não precisamos executar este código de extração. Vamos inspecionar o elemento para entender como é a estrutura que contém os links que queremos, e então, através do Xpath, vamos checar se esta estrutura existe:

Links para Collected editions
Links para Collected editions

# Div onde estão as Collected Editions
collected_div = tree.xpath('//div[@class="list CollectedEditions"]')
        
if collected_div:

Como vimos na imagem, a estrutura que procuramos é um div, com o atributo class="list CollectedEditions". Checamos então se este div existe através do Xpath e, se existir, continuamos a executar nosso código.

Como cada tipo de comic pode ter uma ou mais páginas, precisamos seguir a mesma lógica que já usamos para as Series. Vamos checar se existe o número total daquele tipo de comic. Se existir, dividimos tal quantidade por 18, que é a quantidade de comics para cada página de um determinado tipo. Caso a quantidade daquele tipo de comic não esteja na página, significa que aquele tipo tem somente uma página.

# Caminho até o total de Collected Editions
xpath_total_collected = '//div[@class="list CollectedEditions"]/'
xpath_total_collected += 'div[@class="pager"]/'
xpath_total_collected += 'div[@class="pager-text"]/text()'
total_collected = tree.xpath()
if total_collected:
    total_collected = total_collected[0].split()
    total_collected = int(total_collected[len(total_collected)-1])
    if total_collected % 18 == 0:
        number_of_pages = (total_collected // 18)
    else:
        number_of_pages = (total_collected // 18) + 1
else:
    number_of_pages = 1

Agora, vamos para a parte final do bloco, o scraping em si. Vamos iterar por cada página (se houver mais de uma) ou acessar a página única para aquele tipo de comic. Vamos então extrair os links desse tipo fazendo referência ao div correto, passa-los pela nossa função que remove atributos dos links e adiciona-los à lista que já havíamos criado para armazenar os links:

for page_number in range(1,number_of_pages+1):
    page = requests.get(link+'?CollectedEditions_pg='+str(page_number))                
    tree = html.fromstring(page.content)
    # Caminho para Xpath com os links para as Collected Editions da página
    collected_links_xpath = '//div[@class="list CollectedEditions"]/ul/li/figure/'
    collected_links_xpath += 'div/a/@href'
    collected_links = tree.xpath(collected_links_xpath)
    clean_collected_links = remove_attributes_from_link(collected_links)            
    comics_links.extend(clean_collected_links)

Apenas para recordar, aquela variável link é o link da Series, que vem definida no bloco for.

Vamos analisar então este pedaço de código e verificar o que mudaria para os outros blocos. Abaixo, segue o código completo:

collected_div = tree.xpath('//div[@class="list CollectedEditions"]')
        
if collected_div:
    xpath_total_collected = '//div[@class="list CollectedEditions"]/'
    xpath_total_collected += 'div[@class="pager"]/'
    xpath_total_collected += 'div[@class="pager-text"]/text()'
    total_collected = tree.xpath()
    if total_collected:
        total_collected = total_collected[0].split()
        total_collected = int(total_collected[len(total_collected)-1])
        if total_collected % 18 == 0:
            number_of_pages = (total_collected // 18)
        else:
            number_of_pages = (total_collected // 18) + 1
    else:
        number_of_pages = 1
    for page_number in range(1,number_of_pages+1):            
        page = requests.get(link+'?CollectedEditions_pg='+str(page_number))                
        tree = html.fromstring(page.content)
        # Caminho para Xpath com os links para as Collected Editions da página
        collected_links_xpath = '//div[@class="list CollectedEditions"]/ul/li/figure/'
        collected_links_xpath += 'div/a/@href'
        collected_links = tree.xpath(collected_links_xpath)
        clean_collected_links = remove_attributes_from_link(collected_links)            
        comics_links.extend(clean_collected_links)

Avaliando o código é possível notar que apenas duas coisas mudarão. O pedaço que compõe o link juntamente com a página (no caso acima, ?CollectedEditions_pg=) e os caminhos para o Xpath. No caso dos caminhos para Xpath, adicionalmente, podemos notar inspecionando elementos na página, que o que muda é apenas a classe do div, e todos os outros caminhos partem dele, permanecendo inalterados de tipo para tipo de comic. Desta forma, nossa função precisará de 2 informações, que serão o caminho do Xpath até o div e o pedaço do link a ser acessado correspondente a este tipo de comic. Adicionalmente, também forneceremos o objeto tree e o link atual, para que não precisemos fazer qualquer request novamente desnecessariamente. Nossa função, então, ficará assim:

def extract_comics_links(link, div_xpath, page_link_str, tree):
    type_comics_links = []
    # Checa se o div para o tipo de comic em questão existe
    type_div = tree.xpath(div_xpath)
    
    if type_div:
        # Busca a quantidade total de comics para este tipo de comic
        total_quantity_xpath = div_xpath + '/div[@class="pager"]/'
        total_quantity_xpath += 'div[@class="pager-text"]/text()'
        total_type = tree.xpath(total_quantity_xpath)
        if total_type:
            total_type = total_type[0].split()
            total_type = int(total_type[len(total_type)-1])
            if total_type % 18 == 0:
                number_of_pages = (total_type // 18)
            else:
                number_of_pages = (total_type // 18) + 1
        else:
            number_of_pages = 1            
        for page_number in range(1,number_of_pages+1):            
            page = requests.get(link+page_link_str+str(page_number))                
            tree = html.fromstring(page.content)
            # Path para os links deste tipo de comic
            type_links_xpath = div_xpath + '/ul/li/figure/div/a/@href'
            type_links = tree.xpath(type_links_xpath)
            clean_type_links = remove_attributes_from_link(type_links)            
            type_comics_links.extend(clean_type_links)
    return(type_comics_links)

Esta função retornará uma lista com todos os links para este determinado tipo de comic. Agora, tudo que precisamos fazer é repetir esta função para cada tipo com o pedaço do link e o caminho para o div adequados, que vamos descobrindo inspecionando cada tipo de comic.

O código final dessa função fica assim:

def get_issues_links_from_series(series_links):
    comics_links = []
    
    for counter, link in enumerate(series_links):
        page = requests.get(link)    
        tree = html.fromstring(page.content)
        
# -------------------------------------------------------------- #
        # Código de scraping para collected editions
# -------------------------------------------------------------- #        
        
        collected_div_xpath = '//div[@class="list CollectedEditions"]'
        collected_link_str = '?CollectedEditions_pg='        
                    
        collected_links = extract_comics_links(link, collected_div_xpath, 
                                               collected_link_str, tree)
                
# -------------------------------------------------------------- #
        # Código de scraping para Issues
# -------------------------------------------------------------- #
        issues_div_xpath = '//div[@class="list Issues"]'
        issues_link_str = '?Issues_pg='
                    
        issues_links = extract_comics_links(link, issues_div_xpath,
                                            issues_link_str, tree)
                
# -------------------------------------------------------------- #
        # Código de scraping para Omnibuses
# -------------------------------------------------------------- #
        omnibuses_div_xpath = '//div[@class="list Omnibuses"]'
        omnibuses_link_str = '?Omnibuses_pg='               
                    
        omnibuses_links = extract_comics_links(link, omnibuses_div_xpath, 
                                               omnibuses_link_str, tree)
        
# -------------------------------------------------------------- #
        # Código de scraping para One-Shots
# -------------------------------------------------------------- #
        oneshots_div_xpath = '//div[@class="list OneShots"]'
        oneshots_link_str = '?Oneshots_pg='
                    
        oneshots_links = extract_comics_links(link, oneshots_div_xpath,
                                              oneshots_link_str, tree)
                    
# -------------------------------------------------------------- #
        # Código de scraping para Bandees Dessinees
# -------------------------------------------------------------- #
        bandes_div_xpath = '//div[@class="list BandesDessines"]'
        bandes_link_str = '?BandeesDessinees_pg='
                    
        bandes_links = extract_comics_links(link, bandes_div_xpath,
                                              bandes_link_str, tree)

# -------------------------------------------------------------- #
        # Código de scraping para Graphic Novels
# -------------------------------------------------------------- #
        graphicnovels_div_xpath = '//div[@class="list GraphicNovels"]'
        graphicnovels_link_str = '?GraphicNovels_pg='
                    
        graphicnovels_links = extract_comics_links(link, graphicnovels_div_xpath,
                                              graphicnovels_link_str, tree)

# -------------------------------------------------------------- #
        # Código de scraping para Extras
# -------------------------------------------------------------- #
        extras_div_xpath = '//div[@class="list Extras"]'
        extras_link_str = '?Extras_pg='
                    
        extras_links = extract_comics_links(link, extras_div_xpath,
                                              extras_link_str, tree)
                    
# -------------------------------------------------------------- #
        # Código de scraping para Artbooks
# -------------------------------------------------------------- #
        artbooks_div_xpath = '//div[@class="list Artbooks"]'
        artbooks_link_str = '?Artbooks_pg='
                    
        artbooks_links = extract_comics_links(link, artbooks_div_xpath,
                                              artbooks_link_str, tree)
                                              
        # Juntar os links de todos os tipos de comics na lista previamente criada
        comics_links.extend(collected_links + issues_links + omnibuses_links + 
                            oneshots_links + bandes_links + graphicnovels_links + 
                            extras_links + artbooks_links)
        
        # Exportar os links de 100 em 100, evitando a perda de informação
        # decorridos de possíveis erros
        if counter % 100 == 0 and counter != 0:
            pickle.dump(comics_links, open("comics_links_files/comics_links" + "_" + 
                        str((counter + series_current_counter) // 100) + ".p","wb"))
            pickle.dump(counter + series_current_counter, 
                        open("comics_links_files/series_current_counter.p","wb"))
            comics_links = []
        if counter == len(series_links) - 1:
            pickle.dump(comics_links, open("comics_links_files/comics_links" + "_" + 
                        str((counter + series_current_counter) // 100 + 1) + ".p","wb"))
            pickle.dump(counter + series_current_counter, 
                        open("comics_links_files/series_current_counter.p","wb"))
            comics_links = []
    return(comics_links)

Aqui, usamos o pickle.dump para ir exportando os links que já extraímos para arquivos, de 100 em 100. Fazemos isso porque com a quantidade de links que temos que visitar, é bastante possível que ocorra algum problema de conexão, o site fique fora do ar por alguns instantes, ou qualquer coisa do tipo. Qualquer destes erros pode fazer toda a informação gerada pelo código se perder. Por isso, fazemos essa exportação. O código também exporta o contador no qual ocorreu a última exportação. Desta forma, quando formos chamar a função, verificaremos se já existem dados exportados e, através do contador, poderemos retomar de onde paramos.

Uma pequena otimização que podemos fazer, vendo como a exportação dos links e do contador se repete, é transformar este pedaço do código em mais uma função.

def comics_links_dump(comics_links, counter, comics_links_counter):
    pickle.dump(comics_links, open("comics_links_files/comics_links" + 
                "_" + str((counter + comics_links_counter) // 100 + 1) 
                + ".p","wb"))
    pickle.dump(counter + comics_links_counter, 
                open("comics_links_files/comics_links_counter.p","wb"))
    comics_links = []
    return(comics_links)

E aquele finalzinho da função fica assim:

# Exportar os links de comics de 100 em 100 ou quando o contador chega
        # ao último link para evitar perda de informações em possíveis erros / 
        # problemas
        if (counter % 100 == 0 and counter != 0) or (
            counter == len(series_links) - 1):
            comics_links = comics_links_dump(comics_links, counter, 
                                             comics_links_counter)

Agora, para o passo final! A extração de informações de cada comic.

O último passo: a extração das informações dos comics

O último passo é relativamente simples. Teremos que entrar em cada link de comic e extrair todas as informações que pudermos destes links. Uma das coisas que aprendi enquanto fazia o scraping é que comics podem ser removidos do site. Desta forma, ao tentar fazer um request, vamos retornar uma página de erro 404, e nada do nosso código de scraping funcionará. Vamos primeiro extrair o título do documento HTML, para verificar se estamos em uma página de erro. Vamos definir o caminho base a partir do qual extrairemos todas as informações. Nossa informação ficará toda em um dictionary, que será posteriormente incluído em uma lista. Cada key do Dict será uma informação do comic. Essa configuração se dá porque o Pandas permite criar facilmente um Dataframe a partir de uma lista de Dictionaries. A partir daqui, nossa tarefa basicamente se resumirá a inspecionar elementos através do navegador e montar o Xpath correspondente no código. Vamos lá:

def get_comic_info_from_page(link):
    comic_info = {}    
    
    page = requests.get(link)    
    tree = html.fromstring(page.content)    
    
    # Definir o caminho base que contém o conteúdo da página
    base_path = '//div[@class="comic_view detail-container"]'
    
    # Extração do título da página, para verificar se este endereço retorna
    # um erro 404 ou não. Se não for um erro, continua o código
    page_title = tree.xpath('//title/text()')[0]    
    if page_title != 'Site Error - Comics by comiXology':
        # Extrair o nome do comic
        name = tree.xpath('//h2[@itemprop="name"]/text()')
        comic_info['Name'] = name[0]

E assim, extraímos as primeiras informações sobre o comic. Nos próximos passos, vamos consertar alguns nomes que ficam escondidos nos créditos do comic, na barra da direita, e vamos usar Regex para remover algumas sequências escapadas de HTML que são extraídas. Aqui, as informações disponíveis variam de comic para comic. Então, nosso código pegará o nome de cada informação dos créditos (Art by, Written by, etc - este é o Xpath definido na variável credits_tasks) e qual o valor dela (as pessoas em si - Xpath definido na variável credits_names). Vamos continuar:

# Extrair lista das tarefas dos créditos e nomes que fazem cada uma
credits_tasks_str = base_path + '/div[@id="column3"]/'
credits_tasks_str += 'div[@class="credits"]/div/dl/dt/text()'
credits_tasks = tree.xpath(credits_tasks_str)

credits_names_str = base_path + '/div[@id="column3"]/'
credits_names_str += 'div[@class="credits"]/div/dl/dd/a/text()'        
credits_names = tree.xpath(credits_names_str)

# ---------------------------------------------------------------------
# Consertar os nomes, remover sequências escapadas e criar nova lista 
# de nomes
# ---------------------------------------------------------------------
credits_names_lists = []

first_item = 0
for counter, name in enumerate(credits_names):
    if name == 'HIDE...':
        credits_names_lists.append(credits_names[first_item:counter])
        first_item = counter+1

new_names = []
new_names_credits = []
        
for names_list in credits_names_lists:
    for name in names_list:
        new_name = re.sub("^\\n\\t\\t\\t\\t\\t\\t\\t","", name)
        new_name = re.sub("\\t\\t\\t\\t\\t\\t$", "", new_name)
        if new_name != "More...":
            new_names.append(new_name)
    new_names_credits.append(new_names)
    new_names = []
# ---------------------------------------------------------------------
# Fim do ajuste dos nomes
# ---------------------------------------------------------------------

# Inserir cada informação de crédito no dict comic_info
for counter, item in enumerate(credits_tasks):        
    comic_info[item] = new_names_credits[counter]

Se você olhar no código fonte, na parte onde localizam-se os créditos, todas as listas possuem um item com o nome “HIDE…”, que fica escondido. Desta forma, fazemos nosso for ir até este item, e quando chegamos nele, adicionamos os nomes à lista até aquele ponto, o índice onde está o item “HIDE…“. No fim, apenas adicionamos tudo no Dictionary.

Agora, vamos extrair as demais informações do Comic, como contagem de páginas, Publisher, entre outros. Estas informações encontram-se abaixo dos Créditos, e vamos extrai-las todas de uma vez. Os nomes das informações estão dentro de um elemento h4 de classe subtitle e os valores dentro de um elemento div de classe aboutText. Vamos criar o caminho completo para evitar que coletemos informações de outros lugares.

# Extrair o Publisher do comic
publisher = tree.xpath('//*[@id="column3"]/div/div[1]/a[2]/span/text()')
publisher = re.sub("^\\n\\t\\t\\t","",publisher[0])
publisher = re.sub("\\t\\t$","",publisher)
comic_info['Publisher'] = publisher

# Extrair lista de informações sobre o comic, como contagem de página, 
# faixa etária, etc
comics_infos_names_xpath = base_path + '/div[@id="column3"]/'
comics_infos_names_xpath += 'div[@class="credits"]/'
comics_infos_names_xpath += 'h4[@class="subtitle"]/text()'
comics_infos_names = tree.xpath(comics_infos_names_xpath)

comics_infos_values_xpath = base_path + '/div[@id="column3"]/'
comics_infos_values_xpath += 'div[@class="credits"]/'
comics_infos_values_xpath += 'div[@class="aboutText"]/text()'        
comics_infos_values = tree.xpath(comics_infos_values_xpath)

# Inserir a informação do comic no dictionary
for counter, item in enumerate(comics_infos_names):
    if item == 'Page Count':            
        comic_info[item] = int(comics_infos_values[counter].split()[0])
    else:
        comic_info[item] = comics_infos_values[counter]

Agora, para captarmos o preço, temos que considerar três situações. A primeira, mais comum, comics com um preço definido e sem desconto. A segunda, comics com desconto. E a terceira, comics gratuitos. A forma que encontrei para organizar esta situação foi utilizar três campos, um de preço original, outra de preço final e outra de Desconto (esta um boolean, que indica se um comic tem desconto ou não). Onde não há desconto, o preço original e o preço final ficam iguais. Ainda tratamos alguns comics que são exclusivos de bundles, que ficam então sem valores.

# Extrair os preços do comic
full_price_xpath = '//h6[@class="item-full-price"]/text()'
# Extrair preço cheio
full_price = tree.xpath(full_price_xpath)
discounted_price_xpath = '//div[@class="pricing-info"]/'
discounted_price_xpath += 'h5[@class="item-price"]/text()'
# Extrair preço descontado, se houver
discounted_price = tree.xpath(discounted_price_xpath)
if discounted_price:
    # Se preço descontado é igual a string FREE, esse é um comic gratuito
    if discounted_price[0] == 'FREE':
        final_price = 0.0
    # Se não, extrair o preço final
    else:
        final_price = float(discounted_price[0][1:])
    # Se existe um preço cheio, o comic tem um preço descontado
    if full_price:
        original_price = float(full_price[0][1:])
        discounted = True
    # Se não, os preços são iguais e não há desconto para este comic
    else:
        original_price = final_price
        discounted = False            
# Estes casos se aplicam a comics exclusivos de Bundles
else:
    final_price = None
    original_price = None
    discounted = False
comic_info['Original_price'] = original_price
comic_info['Final_price'] = final_price
comic_info['Discounted'] = discounted

Por fim, vamos extrair a avaliação média recebida por aquele comic e a quantidade de avaliações que o mesmo possui. No início, parecia que ia ter que contar a quantidade de classes que determinavam, mas com um pouco de inspeção e visualização do código fonte, descobri que existia um elemento escondido com o valor da avaliação. Aí ficou fácil. A quantidade de avaliações também é simples, visto que existe um elemento claro que contém este número. Vejamos:

# Extrair a avaliação do comic do elemento escondido
ratings_value_xpath = '//*[@id="column2"]/div[2]/div[2]/div[2]/text()'
ratings_value = tree.xpath(ratings_value_xpath)
if ratings_value:
    ratings_value = ratings_value[0]
    ratings_value = re.sub("^\\n\\t\\t\\t\\t\\t\\t\\t","",ratings_value)
    ratings_value = int(re.sub("\\t\\t\\t\\t\\t\\t$","",ratings_value))
    comic_info['Rating'] = ratings_value
else:
    comic_info['Rating'] = None

# Extrair quantidade de avaliações no comic
ratings_quantity_xpath = '//*[@id="column2"]/div[2]/div[2]/div[1]/text()'
ratings_quantity = tree.xpath(ratings_quantity_xpath)    
if ratings_quantity:
    ratings_quantity = ratings_quantity[0].split()[2]
    ratings_quantity = ratings_quantity[1:][:-2]        
    comic_info['Ratings_Quantity'] = int(ratings_quantity)
else:
    comic_info['Ratings_Quantity'] = 0

Por fim, vamos precisar de mais três funções para juntar informações. A primeira função deve percorrer todos os links de comics e extrair a informação do comic com a última função que criamos. Aqui, também exportaremos nossa informação de 100 em 100 links extraídos, então, a função não tem nada de novo que ainda não tenhamos visto. Vamos lá:

def get_all_comics_info(comics_links, start_counter):
    all_comics_info = []
    for counter, link in enumerate(comics_links):        
        comic_info = get_comic_info_from_page(link)
        if comic_info:
            all_comics_info.append(comic_info)
        if (counter % 100 == 0 and counter != 0) or (counter + 
            start_counter == len(comics_links)-1):
            print(comic_info)
            pickle.dump(all_comics_info, open("Comics_info/comics_infos_" + "_" +
                        str((counter + start_counter) // 100) + ".p","wb"))
            pickle.dump(counter + start_counter, 
                        open("Comics_info/counter_comics_info.p","wb"))
            all_comics_info = []

E assim, terminam as funções de coleta de informações. Agora, vamos ajustar algumas coisas para que todas elas fiquem encadeadas.

Toques finais

Vamos precisar de mais três funções principais. A primeira vai simplesmente juntar os links dos comics, que estão espalhados em arquivos, em uma única lista. A segunda função receber a lista de comics que já extraímos e passar por cada link extraindo as informações e depois, exportar estas informações para arquivos. E a última vai juntar as informações destes arquivos em mais uma nova variável, para podermos enfim utilizar para análise. Vamos lá, à primeira função:

def join_comics_links():
    comics_links = []
    # Para cada arquivo na pasta "comics_links_folder"
    for file in os.listdir("comics_links_folder"):
        temp_comics_links = pickle.load(open("comics_links_folder/" + file,"rb"))                
        comics_links.append(temp_comics_links)
    return(comics_links)

Bem simples, somente carregar cada arquivo e juntar suas informações em uma única lista. Agora, para a segunda função, a que irá passar pela lista de links extraindo as informações:

def get_all_comics_info(comics_links, start_counter):
    all_comics_info = []
    for counter, link in enumerate(comics_links):        
        comic_info = get_comic_info_from_page(link)
        if comic_info:
            all_comics_info.append(comic_info)
        if (counter % 100 == 0 and counter != 0) or (counter + 
            start_counter == len(comics_links)-1):
            print(comic_info)
            pickle.dump(all_comics_info, open("Comics_info/comics_infos_" + "_" +
                        str((counter + start_counter) // 100) + ".p","wb"))
            pickle.dump(counter + start_counter, 
                        open("Comics_info/counter_comics_info.p","wb"))
            all_comics_info = []

Nada que ainda não tenhamos visto. Para a última função, juntar as informações de comics, em uma lista de dictionaries e, enfim, exportar esta lista para um arquivo:

def join_comics_info():
    comics_info = []
    # Para cada arquivo na pasta "comics_info_files"
    for file in os.listdir("comics_info_files"):        
        # Juntar todos os arquivos, menos o contador        
        if file != "counter_comics_info.p":
            print(file)
            comic_info = pickle.load(open("comics_info_files/" + file,"rb"))
            for comic in comic_info:    
                comics_info.append(comic)
    pickle.dump(comics_info, open("all_comics_info.p", "wb"))

Para finalizar, precisamos encadear tudo isso que fizemos. Para isso, faremos basicamente uma coisa. Vamos utilizar código Python para checar se cada arquivo de links exportado existe. Se este existir e estiver completo, vamos carrega-lo em uma variável. Caso não estejam, vamos rodar cada função para extrair as informações. Para os arquivos que são divididos em vários outros arquivos, checaremos se a pasta que os contém existe, e se não existir, vamos cria-la também. Vamos usar a função os.path.isdir() para checar a existência das pastas e os.path.isfile() para checar a existência de arquivos. Para checar se um scraping que é dividido em vários arquivos está concluído, vamos carregar o arquivo que guarda o contador e checar se este é igual à quantidade de itens presentes em determinada lista. E assim fecharemos nosso scraping. Vamos ao código:

# Checar se o arquivo publisher_links.p existe   
if os.path.isfile('publisher_links.p') == True:
    print("Arquivo de Links de Publishers já existe... carregando arquivo")
    publisher_links = pickle.load(open("publisher_links.p","rb"))
# Se não existir, iniciar o scraping
else:
    print("Arquivo de Links de Publishers não existe.")
    print("Iniciando o Scraping do site para links de Publishers.")
    print("Links serão exportados para o arquivo publisher_links.p")
    publisher_links = get_publishers_links()
    pickle.dump(publisher_links, open("publisher_links.p","wb"))

# Checar se o arquivo series_links.p existe    
if os.path.isfile('series_links.p') == True:
    print("Arquivo de Links de Series já existe... carregando arquivo")
    series_links = pickle.load(open("series_links.p","rb"))
# Se não existir, iniciar o scraping  
else:
    print("Arquivo de Links de Series não existe.")
    print("Iniciando o Scraping do site para links de Series.")
    print("Links serão exportados para o arquivo series_links.p")
    series_links = get_series_links_from_publisher(publisher_links)
    pickle.dump(series_links, open("series_links.p","wb"))
 
# Checar se a pasta "comics_links_files" existe   
if os.path.isdir('comics_links_files'):
    print("Pasta comics_links_files já existe. Checando arquivos.")
    # Checar se o arquivo comics_links_counter.p (contador) existe
    if os.path.isfile('comics_links_files/comics_links_counter.p'):
        # Carregar o contador
        comics_links_counter = pickle.load(open("comics_links_files/comics_links_counter.p","rb"))        
        print("Contagem atual: " + str(comics_links_counter+1) + " de " +
              str(len(series_links)))
        # Checar, através da comparação do contador com a quantidade de itens, 
        # se o scraping está completo
        if comics_links_counter + 1 == len(series_links):
            print("Scraping completo. Carregando arquivos.")
            comics_links = join_comics_links()
        # Se não estiver, continuar o scraping de onde parou
        else:
            print("Scraping iniciado mas não completo. Continuando...")
            get_issues_links_from_series(series_links[comics_links_counter+1:], 
                                         comics_links_counter)
            comics_links = join_comics_links()
    # Se o contador não existir, iniciar o scraping        
    else:
        print("Scraping de links de comics não iniciado.")
        print("Iniciando scraping de links de comics.")
        comics_links_counter = 0
        get_issues_links_from_series(series_links, comics_links_counter)
        comics_links = join_comics_links()
# Se a pasta não existir, cria-la e iniciar o scraping
else:
    print("Pasta comics_links_files não existe.")
    print("Criando pasta comics_links_files")
    os.makedirs("comics_links_files")
    print("Iniciando scraping de links de comics.")
    comics_links_counter = 0
    get_issues_links_from_series(series_links, comics_links_counter)
    comics_links = join_comics_links()

# Checar se a pasta 'comics_info_files' existe    
if os.path.isdir('comics_info_files'):
    print("Pasta comics_info_files já existe. Checando arquivos.")
    # Checar se o arquivo 'comics_info_counter.p' (contador) existe
    if os.path.isfile('comics_info_files/comics_info_counter.p'):
        # Carregar o contador
        comics_info_counter = pickle.load(open("comics_info_counter.p"))        
        print("Contagem atual: " + str(comics_info_counter+1) + " de " +
              str(len(comics_links)))
        # Checar, através da comparação do contador com a quantidade de itens, 
        # se o scraping está completo
        if comics_info_counter + 1 == len(comics_links):
            print("Scraping já completo. Carregando arquivos.")
        # Se não estiver completo, continuar o scraping de onde parou            
        else:
            print("Scraping iniciado mas não completo. Continuando...")
            get_all_comics_info(comics_links[comics_info_counter+1:], 
                                comics_info_counter)
    # Se o contador não existir, iniciar o scraping
    else:
        print("Scraping de informações de comics não iniciado.")
        print("Iniciando scraping de informações de comics.")
        comics_info_counter = 0
        get_all_comics_info(comics_links, comics_info_counter)
# Se a pasta não existir, cria-la e iniciar o scraping
else:
    print("Pasta comics_info_files não existe.")
    print("Criando pasta comics_info_files")
    os.makedirs("comics_info_files")
    print("Iniciando scraping de informações de comics.")
    comics_info_counter = 0
    get_all_comics_info(comics_links, comics_info_counter)
    
# Juntar as informações dos comics
join_comics_info()
# Carregar as informações de todos os comics
comics_info = pickle.load(open("comics_info_files/all_comics_info.p","rb"))
# Criar um DataFrame com a lista de dictionaries
comics_df = pd.DataFrame(comics_info)

E com isto, chega ao fim nosso scraping. No próximo post, farei uma análise com os dados para podermos entender um pouco melhor o mundo dos comics digitais e chegar a algumas conclusões bem interessantes.

PS: Sugestões e correções para melhoria do código são muito bem-vindas, então, se as tiverem, fiquem a vontade para mandar nos comentários.