Web scrapping de dados das wikis de FFXIV com Selenium e Tratamento de Dados Não Estruturados
Autor
Luiz Felipe P. Figueiredo
Data de Publicação
22/04/2023
Web scrapping com Selenium e Tratamento de Dados Não Estruturados
Introdução
Este projeto tem como objetivo realizar a extração de dados sobre personagens não jogáveis (NPC) das Wikis de Final Fantasy XIV disponíveis na internet. A proposta central é utilizar técnicas de web scraping, em conjunto com o Selenium, para coletar informações relevantes e detalhadas sobre o universo do jogo. A partir desses dados não estruturados, será realizada uma análise e tratamento minucioso, visando organizar e estruturar essas informações de modo a torná-las mais acessíveis e úteis para diferentes finalidades. O foco reside em oferecer uma abordagem abrangente na captura e tratamento desses dados, contribuindo para a construção de conjuntos de informações mais coerentes e utilizáveis para os entusiastas e jogadores de Final Fantasy XIV.
Para este projeto, decidi utilizar o selenium em conjunto com o Chrome para realizar a raspagem de dados. Existem outras bibliotecas que poderiam ser utilizadas para essa tarefa, como o Beautiful Soup e o Scrapy, e cada uma delas apresenta suas vantagens e desvantagens.
Acabei optando pelo Selenium devido à simplicidade da tarefa em questão e à necessidade de realizar a raspagem em um ambiente específico. Como os dados que eu precisava extrair não eram tão grandes, não foi necessário utilizar um framework de webscraping mais complexo como o Scrapy.
Sobre o Selenium
O Selenium é uma poderosa ferramenta para a automação de testes em navegadores web. Ele é utilizado para automatizar a interação do usuário com uma página web, como clicar em botões, preencher formulários, navegar em menus e outras ações.
O Selenium requer um driver específico para o navegador que será utilizado na raspagem de dados. No caso do Chrome, é necessário fazer o download do ChromeDriver, que é um executável que permite que o Selenium se comunique com o navegador Chrome.
O ChromeDriver deve ser baixado e instalado conforme a versão do Chrome que será utilizada. Após a instalação, é necessário especificar o caminho para o executável do ChromeDriver no código do Selenium, para que o navegador possa ser aberto e utilizado para a raspagem de dados.
Dessa forma, o Selenium e o Chrome trabalham em conjunto para acessar a página web inicial, executar ações como preencher formulários e clicar em botões, e extrair os dados necessários para a análise posterior.
A maneira como os dados serão extraídos depende da estrutura da página web em que eles se encontram e da forma como esses dados estão organizados.
Por exemplo, se os dados estiverem contidos em uma tabela, é possível usar a biblioteca Pandas para extrair diretamente as informações da tabela em um formato de DataFrame. Por outro lado, se os dados estiverem espalhados em diferentes partes da página web, será necessário usar técnicas de raspagem mais avançadas, como a localização de elementos HTML específicos usando seletores de CSS ou XPath.
Com o Selenium, é possível usar várias funções para realizar a raspagem de dados em uma página web. Uma dessas funções é a .find_element(), que pode ser usada em conjunto com a função By para localizar um elemento específico na página usando uma estratégia de pesquisa específica, como XPATH, CSS_SELECTOR, ID, entre outras, depois basta extrair esses elementos com base em algum atributo que ele deveria ter, por exemplo, se eu desejo extrair o link de um elemento, utilizaria o atributo 'href'. Mais sobre o Selenium com o Python na Documentação oficial do Selenium.
Módulos
Código
# biblioteca para automação em navegador webfrom selenium import webdriver # gerenciador de driver para o navegador Chromefrom webdriver_manager.chrome import ChromeDriverManager# biblioteca para localizar elementos na página webfrom selenium.webdriver.common.by import By# biblioteca para configurar o serviço do navegadorfrom selenium.webdriver.chrome.service import Service# biblioteca para configurar as opções do navegadorfrom selenium.webdriver.chrome.options import Options# biblioteca para carregamento e manipulação de dadosimport pandas as pd # biblioteca para computação numéricaimport numpy as np # biblioteca para controlar o tempo de execução das tarefasimport time # biblioteca para esperar até que determinado elemento seja carregado na páginaimport plotly.io as piopio.renderers.default ="plotly_mimetype+notebook"import plotly.colors as colorsimport plotly.express as pximport plotly.graph_objects as go# biblioteca para interagir com o sistema operacionalimport os # biblioteca para registro de informações e depuração do códigoimport logging # biblioteca para trabalhar com expressões regulares (regex)import re
Extração
A primeira Wiki a qual irei extrair os dados dos personagens do FFXIV é a Final Fantasy Wiki. Para fazer isso primeiramente foi necessário extrair todas as páginas que continham as informações desejadas. Sendo assim, extraí o link dessas páginas através do botão NEXT na primeira página e nas subsequentes até a ultima, para realizar essa tarefa, optei por usar o seletor CSS do botão em questão. Além disso, a função abaixo, já faz uso destes dados para coletar o nome e o link da página de cada personagem.
Código
def get_pages_fandom_wiki_and_info(starter_page, wait=3):"""A partir de uma `starter_page` extrai o nome e a url de cada personagem no site `https://finalfantasy.fandom.com/wiki/Category:Characters_in_Final_Fantasy_XIV`, `wait` é o tempo de espera após a página ser acessada"""# Configuração do chrome para utilização do Selenium chrome_options = Options()# Garante que o GUI está desligado (janela do chrome não vai abrir)# comente esta linha se quiser que a janela do chrome abra chrome_options.add_argument("--no-sandbox") chrome_options.add_argument("--disable-blink-features=AutomationControlled")# chrome_options.add_argument("window-size=1920,1080") # Resolução da tela# Download dos drivers (silêncioso) logging.getLogger("WDM").setLevel(logging.NOTSET) os.environ["WDM_LOG"] ="False"# Cria o serviço do driver do chrome utilizando o ChromeDriverManager webdriver_service = Service(ChromeDriverManager().install())# Cria o driver do chrome, janela do navegador só abrir se a opção --headless estiver desabilidade driver = webdriver.Chrome(service=webdriver_service, options=chrome_options) driver.get(starter_page) # Abre o site inicial time.sleep(3) # Espera por 3 segundos# Resolve popup de cookies. Mude o "ACEITAR" para a linguagem do pop up em caso de erro driver.find_element(By.XPATH, '//div[text()="ACCEPT ALL"]').click() pages = [starter_page] # Inicial a lista pages com a primeira pagina n =False# Controlador do loop whilewhilenot n: # Loop para encontrar todas as páginas com informações dos personagens# Tentar rodar o codigo, caso erro n=True, fecha o looptry:# Encontra o botão next na página usando o seu seletor css b = driver.find_element( By.CSS_SELECTOR,"#mw-content-text > div.category-page__pagination > a.category-page__pagination-next.wds-button.wds-is-secondary", )# Acrescenta a cada loop o link do botão next a lista pages pages.append(b.get_attribute("href"))# Abre o link do botão next(link da próxima página) para continuar com o loop driver.get(b.get_attribute("href"))except: n =True# Fecha o loop character_info = [] # Inicializa a lista character_infofor page inrange(len(pages)): # Para cada página na lista de paginas driver.get(pages[page]) # Abre a page a cada iteração do loop time.sleep(wait) # Espera por 3 segundos elems = driver.find_elements( By.CLASS_NAME, "category-page__member-link" ) # Captura os elementos por nome de Classe css e salva em elemsfor elements in elems: # Para cada elemento encontrado# Extrai o link e salva na lista char_url char_url = elements.get_attribute("href") char_name = ( elements.text ) # Extrai o nome do personagem, e salva na lista char_name# Acrescenta as informações extraidas no loop atual em forma de dicionário na lista character_info character_info.append({"char_name": char_name, "url": char_url}) driver.quit() # Fecha o Driver# Retorna um dataframe pandas com as informações dos personagensreturn pd.DataFrame(character_info)df = get_pages_fandom_wiki_and_info("https://finalfantasy.fandom.com/wiki/Category:Characters_in_Final_Fantasy_XIV")
Código
df
char_name
url
0
13th Order Fugleman Zo Ga
https://finalfantasy.fandom.com/wiki/13th_Orde...
1
175th Order Alchemist Bi Bi
https://finalfantasy.fandom.com/wiki/175th_Ord...
2
269th Order Mendicant Da Za
https://finalfantasy.fandom.com/wiki/269th_Ord...
3
2B (Final Fantasy XIV)
https://finalfantasy.fandom.com/wiki/2B_(Final...
4
2P
https://finalfantasy.fandom.com/wiki/2P
...
...
...
825
Zirnberk
https://finalfantasy.fandom.com/wiki/Zirnberk
826
Zodiark (Final Fantasy XIV)
https://finalfantasy.fandom.com/wiki/Zodiark_(...
827
Zozonan
https://finalfantasy.fandom.com/wiki/Zozonan
828
Zuiko Buhen
https://finalfantasy.fandom.com/wiki/Zuiko_Buhen
829
Zurvan (Final Fantasy XIV)
https://finalfantasy.fandom.com/wiki/Zurvan_(F...
830 rows × 2 columns
A raspagem acima demorou cerca de 1:30 min no meu computador, esse tempo pode variar de acordo com as especificações da maquina.
Salvando os dados
Os dados foram salvos utilizando em em um arquivo .csv.
Código
# Exporta o dataframe da função get_pages_fandom_wiki_and_info em um arquivo .csvdf.to_csv('datasets/df.csv')# Leitura do dataframedf_characters_ff_wiki = pd.read_csv("datasets/df.csv", index_col=0)df_characters_ff_wiki
char_name
url
0
13th Order Fugleman Zo Ga
https://finalfantasy.fandom.com/wiki/13th_Orde...
1
175th Order Alchemist Bi Bi
https://finalfantasy.fandom.com/wiki/175th_Ord...
2
269th Order Mendicant Da Za
https://finalfantasy.fandom.com/wiki/269th_Ord...
3
2B (Final Fantasy XIV)
https://finalfantasy.fandom.com/wiki/2B_(Final...
4
2P
https://finalfantasy.fandom.com/wiki/2P
...
...
...
825
Zirnberk
https://finalfantasy.fandom.com/wiki/Zirnberk
826
Zodiark (Final Fantasy XIV)
https://finalfantasy.fandom.com/wiki/Zodiark_(...
827
Zozonan
https://finalfantasy.fandom.com/wiki/Zozonan
828
Zuiko Buhen
https://finalfantasy.fandom.com/wiki/Zuiko_Buhen
829
Zurvan (Final Fantasy XIV)
https://finalfantasy.fandom.com/wiki/Zurvan_(F...
830 rows × 2 columns
Depois de ter extraído as URLs de cada personagem e o seu nome a próxima etapa é extrair informações básicas de cada um deles. Em cada página, existe um painel ao lado direito com algumas informações básicas.
Código
def get_info_fandom(url_df, wait=19):"""Para cada url em `url_df`, acessa url e extrai as informações do painel direito da página, `wait` define o tempo de espera para cada nova página acessada""" results = [] # Inicializa a lista results i =0# Configuração do chrome para utilização do Selenium chrome_options = Options()# Garante que o GUI está desligado (janela do chrome não vai abrir)# comente esta linha se quiser que a janela do chrome abra chrome_options.add_argument("--headless") chrome_options.add_argument("--no-sandbox") chrome_options.add_argument("--disable-blink-features=AutomationControlled")# chrome_options.add_argument("window-size=1920,1080") # Resolução da tela# Download dos drivers (silêncioso) logging.getLogger("WDM").setLevel(logging.NOTSET) os.environ["WDM_LOG"] ="False"# Cria o serviço do driver do chrome utilizando o ChromeDriverManager webdriver_service = Service(ChromeDriverManager().install())# Cria o driver do chrome, janela do navegador só abrir se a opção --headless estiver desabilitado driver = webdriver.Chrome(service=webdriver_service, options=chrome_options)for url in url_df: # Para cada URL na lista de URLs fornecindas driver.get(url) # Abre a URL do loop atual# Espera por 19 segundos, número alto para evitar erros, pode ser mudado a depender da velocidade e estalibidade da conexão de internet time.sleep(wait)if ( i ==0 ): # Condição para resolver o popup de cookies, somente necessário quando o navegador é aberto pela primeira vez no primeiro loop# Espera dois segunds até o popup aparecer, e o resolve time.sleep(2)# Mude o "ACEITAR" para a linguagem do pop up em caso de erro driver.find_element(By.XPATH, '//div[text()="ACEITAR"]').click()# Captura os elementos por XPATH e salva em elems elems = driver.find_elements( By.XPATH, '//*[@id="mw-content-text"]/div[1]/aside' )# Tentar rodar o codigo, caso erro n=True, em caso de erro inputa np.nan e retoma o loop, isso foi necessário por existirem paginas que não são personagens na wikitry:# Extrai o código do elemento, e separa cada palavra em strings info = elems[0].text.splitlines()except: info = np.nan # Caso não seja um personagem recebe np.nancontinue# Acrescenta as informações na lista results results.append({"info": info}) i +=1# Incrementa o i para que não entrar no if acima driver.quit() # Finaliza o driverreturn pd.DataFrame(results) # Retorna um dataframe da lista resultsinfo_df_uns = get_info_fandom(df["url"])# Exporta o retorno da função get_info_fandom() para um arquivo .csvinfo_df_uns.to_csv("datasets/info_df.csv", sep ="\n")
info
0
['13th Order Fugleman Zo Ga', '(フューグルマン13 ゾ・ガ,...
1
['175th Order Alchemist Bi Bi', '(アルケミスト175 ビ・...
2
['269th Order Mendicant Da Za', '(メンディカント269 ダ...
3
['2B', '(ツービー, Tsūbī?)', 'Alternate names: YoR...
4
['2P', '(ツーピー, Tsūpī?)', 'Physical description...
...
...
690
['Zhloe Aliapoh', '(シロ・アリアポー, Shiro Ariapō?, l...
691
['Zirnberk', '(ツィルンベルク, Tsirunberuku?)', 'Alte...
692
['Zodiark', '(ゾディアーク, Zodiāku?)', 'Alternate n...
693
['Zuiko Buhen', '(ズイコウ・ブヘン, Zuikou Buhen?)', '...
694
['Zurvan', '(鬼神ズルワーン, Kishin Zuruwān?, lit. Zu...
695 rows × 1 columns
Tratamento dos Não Estruturados
Para tornar os dados não estruturados utilizáveis, é necessário fazer o tratamento apropriado, conforme abaixo:
1 - Remoção das aspas em cada cada da linha e transformação da string ( linha ) em uma lista de strings.
Código
# Importa o dataframe gerado pela função get_info_fandom()info_df = pd.read_csv("datasets/info_df.csv", index_col=0)# Limpa as strings das colunas "info"info_df["info"] = info_df["info"].apply(lambda x: x.strip("[']").replace("'", "").replace("\\'", ""))# Converte cada linha na coluna "info" em uma lista de stringsinfo_df["info"] = info_df["info"].apply(lambda x: x.split(", "))info_df
info
0
[13th Order Fugleman Zo Ga, (フューグルマン13 ゾ・ガ, Fy...
1
[175th Order Alchemist Bi Bi, (アルケミスト175 ビ・ビ, ...
2
[269th Order Mendicant Da Za, (メンディカント269 ダ・ザ,...
3
[2B, (ツービー, Tsūbī?), Alternate names: YoRHa No...
4
[2P, (ツーピー, Tsūpī?), Physical description, Rac...
...
...
690
[Zhloe Aliapoh, (シロ・アリアポー, Shiro Ariapō?, lit....
691
[Zirnberk, (ツィルンベルク, Tsirunberuku?), Alternate...
692
[Zodiark, (ゾディアーク, Zodiāku?), Alternate names:...
693
[Zuiko Buhen, (ズイコウ・ブヘン, Zuikou Buhen?), Biogr...
694
[Zurvan, (鬼神ズルワーン, Kishin Zuruwān?, lit. Zurva...
695 rows × 1 columns
2 - Separação das informações contidas em cada lista de strings em cada linhas do dataset e criação de um novo dataset com essas informações.
Código
# Define as informações a serem pesquisadas em cada linharequired_info = ["Race","Age","Gender","Type","Affiliation","Occupation","Hair color","Eye color","Job class","Weapon","Armor",]# Cria um dicionário para as informações a serem extraídasinfo_dict = {info: [] for info in required_info}info_dict["name"] = [] # Cria a coluna namefor row in info_df["info"]: # Para cada linha na coluna info# Extrai a primeira strings correspondente ao nome do personagem name = row[0] info_dict["name"].append(name) # Acrescenta o nome a coluna name# Para cada index e string da linhafor i, s inenumerate(row):# Verifica a ocorrência das informações desejadas na lista required_infoif s in required_info:if i +1<len(row):# Caso a condição é satisfeita, extrai a proxima string (resposta da informação desejada) e acrescenta no dicionário info_dict[s].append(row[i +1])else:# Caso a informação não esteja disponivel, ou não exista, imputa "Unknown/Not available" info_dict[s].append("Unknown")# Caso alguma das informações desejadas não exista, imputa "Unknown/Not available", isso é necessário para evitar erro de indexfor info in required_info:if info notin row:# Caso a informação não esteja disponivel, ou não exista, imputa "Unknown/Not available" info_dict[info].append("Unknown")# Cria um dataframe a partir do dicionárioinfo_dataframe = pd.DataFrame(info_dict)col_order = ["name"] + required_infoinfo_dataframe = info_dataframe[col_order] # Reordena o dataframe# define a function to extract the main number from a stringdef extract_main_number(s):# use regular expressions to extract the digits before the first non-digit character match = re.search(r"\d+", s)if match isnotNone:return match.group()else:returnNoneinfo_dataframe["Age"] = info_dataframe["Age"].apply(extract_main_number)info_dataframe["Age"] = info_dataframe["Age"].fillna(pd.NA)info_dataframe["Age"] = pd.to_numeric(info_dataframe["Age"], errors="coerce").astype("Int64")info_dataframe["Race"] = info_dataframe["Race"].str.replace('"', "")# Substituindo termos ambiguosrace_mapping = {"Mystel": "Miqo'te","Seekers of the Sun Miqote": "Miqo'te","Miqote / Unknown": "Miqo'te","Keeper of the Moon Miqote": "Miqo'te","Seeker of the Sun Miqote": "Miqo'te","Miqote (formerly)": "Miqo'te","Xaela Au Ra": "Au'ra","Au Ra": "Au'ra","Drahn": "Au'ra","Raen Au Ra": "Au'ra","Au Ra (formerly)": "Au'ra","Highlander Hyur": "Hyur","Hume": "Hyur","Hyur/Garlean": "Hyur","Midlander Hyur": "Hyur","Far Eastern Hyur": "Hyur","Human": "Hyur","Hyur (Lich)": "Hyur","Wildwood Elezen": "Elezen","Ishgardian Elezen": "Elezen","Elezen (formerly)": "Elezen","Elf": "Elezen","Duskwight Elezen": "Elezen","Sea Wolf Roegadyn": "Roegadyn","Roegadyn (formerly)": "Roegadyn","Galdjent": "Roegadyn","Roegadyn (Biggs)": "Roegadyn","Hellsguard Roegadyn": "Roegadyn","Viis": "Viera","Veena Viera": "Viera","Rava Viera": "Viera","Dunesfolk Lalafell": "Lalafell","Dwarf": "Lalafell","Plainsfolk Lalafell": "Lalafell","Plainsfolk Lalafell (formerly)": "Lalafell","Lalafell (formerly)": "Lalafell","Ronso": "Hrothgar","Garlean (as Rullus)": "Garlean","Dragon (Vrtra)": "Dragon","Pixie (formerly)": "Pixie","Porxie": "Familiar","Auspices": "Auspice","Blue Kojin": "Kojin","Hume/Sin eater hybrid": "Hyur/Sin eater hybrid",}info_dataframe["Race"] = info_dataframe["Race"].replace(race_mapping)info_dataframe
name
Race
Age
Gender
Type
Affiliation
Occupation
Hair color
Eye color
Job class
Weapon
Armor
0
13th Order Fugleman Zo Ga
Kobold
<NA>
Male
Non-player character
13th Order
Fugleman
Unknown
Unknown
Unknown
Unknown
Unknown
1
175th Order Alchemist Bi Bi
Kobold
<NA>
Female
Non-player character
175th Order
Alchemist
Unknown
Unknown
Unknown
Unknown
Unknown
2
269th Order Mendicant Da Za
Kobold
<NA>
Male
Non-player character
Unknown
Unknown
Unknown
Unknown
Conjurer
Unknown
Unknown
3
2B
Android
<NA>
Female
Non-player character
YoRHa
Unknown
Silver
Grey
Unknown
Virtuous Contract
Unknown
4
2P
Machine Lifeform
<NA>
Female
Non-player character
Unknown
Unknown
Black
Unknown
Unknown
Unknown
Unknown
...
...
...
...
...
...
...
...
...
...
...
...
...
690
Zhloe Aliapoh
Miqote
21
Female
Non-player character
"Menphinas Arms"
Unknown
Light brown
Light blue
Unknown
Unknown
Unknown
691
Zirnberk
Roegadyn
<NA>
Male
Non-player character
Stone Torches
Commander
White
Blue
Dark Knight
Lockheart
"Halones Armor of Fending"
692
Zodiark
Primal
<NA>
Male
Non-player character
Unknown
Unknown
Unknown
Unknown
Unknown
Unknown
Unknown
693
Zuiko Buhen
Hyur
83
Male
Non-player character
Buhen clan
Leader
Unknown
Unknown
Samurai
Tsunakiri
Unknown
694
Zurvan
Primal
<NA>
Male
Boss
Unknown
Unknown
Unknown
Unknown
Unknown
Unknown
Unknown
695 rows × 12 columns
Código
# Criando uma lista com as colunas a serem verificadascolunas_verificar = ["name","Race","Age","Gender","Type","Affiliation","Occupation","Hair color","Eye color","Job class","Weapon","Armor",]# Dicionário para armazenar a contagem de valores faltantes em cada colunacontagem_valores_faltantes = {}# Loop para verificar cada colunafor coluna in colunas_verificar:# Contagem dos valores faltantes na coluna qtd_na = info_dataframe[coluna].isna().sum() qtd_unknown = info_dataframe[coluna].astype(str).str.contains("Unknown").sum()# Armazenamento da contagem no dicionário contagem_valores_faltantes[coluna] = qtd_na + qtd_unknown# Conversão do dicionário em um dataframedf_contagem = pd.DataFrame.from_dict( contagem_valores_faltantes, orient="index", columns=["qtd_faltante"])# Plot do gráfico de barras com a contagem de valores faltantes por colunafig = px.bar(x='index', y='qtd_faltante', data_frame=df_contagem.reset_index().sort_values(by='qtd_faltante', ascending=True), title="Contagem de valores faltantes por coluna", labels={'index':'Variáveis', 'y':'Quantidade de valores faltantes', 'qtd_faltante':'Quantidade Faltante'}, hover_name=df_contagem.index, hover_data={'index':True, 'qtd_faltante':True})fig.update_traces(hovertemplate='<b>%{x}</b><br>'+'Variáveis: %{x}<br>'+'Quantidade Faltante: %{y}')fig.show()
Com os dados tratados, podemos verificar que existe uma grande quantidade de dados faltantes pelo gráfico acima. Isso ocorre devido a inconsistência dos dados, onde alguns personagens possuem todas as variáveis acima em sua descrição e outros não.
A FFXIV Gamerescape é uma wiki que conta com muito mais personagens não jogáveis catalogados, e outras informações como informações sobre as missões do jogo e dialogo contido nelas. Fazendo uso disso, decidi por extrair essas informações afim de utiliza-las em um outro momento em um modelo de linguagem natual.
Decidi começar a extrair informações sobre as missões principais do jogo. Para fazer isso, segui uma abordagem semelhante à anterior e extrai as URLs dos botões “next page” na página de quests principais. No entanto, enfrentei um pequeno problema quando a extração entrou em loop após algumas páginas, alternando continuamente entre a última e penúltima página. Isso ocorreu devido à forma como os botões foram implementados. Para resolver esse problema, implementei uma solução que consistia em comparar as URLs e, se fossem iguais, remover todas as URLs seguintes e quebrar o loop.
Código
def get_pages_gamerescape(starter_url="", number_of_pages=10, wait=3):"""Obtem a próxima página usando o xpath do botão de próxima página no site gamerescape do FFXIV. o `starter_url` deve ser fornecido. `number_of_pages` deve ser fornecido, mas se houver muitas páginas para contar, use um `number_of_pages` grande o suficiente para cobrir todas as páginas. o argumento `wait` é o tempo de espera até a próxima iteração, aumente esse valor se o site estiver muito lento."""# Configuração do chrome para utilização do Selenium chrome_options = Options()# Garante que o GUI está desligado (janela do chrome não vai abrir)# comente esta linha se quiser que a janela do chrome abra chrome_options.add_argument("--headless") chrome_options.add_argument("--no-sandbox") chrome_options.add_argument("--disable-blink-features=AutomationControlled")# chrome_options.add_argument("window-size=1920,1080") # Resolução da tela# Cria o serviço do driver do chrome utilizando o ChromeDriverManager webdriver_service = Service(ChromeDriverManager().install()) driver = webdriver.Chrome(service=webdriver_service, options=chrome_options)# Inicializa a lista `pages` com a URL inicial fornecida pages = [starter_url]# Inicia um loop que busca por links adicionais na página atual até que `number_of_pages` seja alcançadofor i inrange(number_of_pages):try:# Aguarda `wait` segundos antes de buscar por links adicionais time.sleep(wait)# Acessa a página atual usando o driver do Selenium driver.get(pages[i])# Encontra o botão de próxima página usando XPATHif i ==0: elems = driver.find_element(By.XPATH, '//*[@id="mw-pages"]/a[1]')else: elems = driver.find_element(By.XPATH, '//*[@id="mw-pages"]/a[2]')# Verifica se a página atual já foi adicionada à lista `pages` anteriormente.# Se sim, remove a página atual da lista `pages`, encerra o loop e fecha o driver do Selenium.iflen(pages) >2and pages[i] == pages[i -2]: pages.pop(-1) pages.pop(-1) driver.quit()break# Adiciona o link da próxima página à lista `pages` pages.append(elems.get_attribute("href"))except:# Se ocorrer um erro ao buscar por links adicionais, fecha o driver do Selenium e encerra o loop. driver.quit()break# Fecha o driver do Selenium antes de retornar a lista `pages` driver.quit()return pagespages = get_pages_gamerescape("https://ffxiv.gamerescape.com/wiki/Category:Main_Scenario_Quests", 10, 3)
Com as URLs das páginas que contêm os links para todas as quests principais do jogo, o próximo passo foi extrair o nome e a URL de cada uma dessas quests.
Código
def get_quest_data_gamerescape(pages_list=[], wait=3):"""Para cada página em `pages_list` extrai a url e nome das quests em cada página, 'wait' é o tempo de espera após a página ser acessada"""# Configuração do chrome para utilização do Selenium chrome_options = Options()# Garante que o GUI está desligado (janela do chrome não vai abrir)# comente esta linha se quiser que a janela do chrome abra chrome_options.add_argument("--headless") chrome_options.add_argument("--no-sandbox") chrome_options.add_argument("--disable-blink-features=AutomationControlled")# chrome_options.add_argument("window-size=1920,1080") # Resolução da tela# Cria o serviço do driver do chrome utilizando o ChromeDriverManager webdriver_service = Service(ChromeDriverManager().install()) driver = webdriver.Chrome(service=webdriver_service, options=chrome_options) results = [] # Inicializa a lista resultsdef populate_selector_list(): # cria a função populate_selector_list(), função gera os selectors css de acordo com o padrão da pagina da gamerscape selector_list = []for k inrange(300): selector_list.append("#mw-pages > div > div > div:nth-child("+str(r)+") > ul > li:nth-child("+str(k +1)+") > a" )return selector_listfor i inrange(len(pages_list)): # para cada pagina no dataframe pages r =1# Setado pra 1 para criar a primeira leva de selectors n =0# Inicializa o n r_changed_consecutive =0# inicializa a contagem de erros consecutivos# chama a função para criar a primeira leva de selectors com r = 1 selector_list = populate_selector_list()# para cada página em i pages_list o driver abre a página driver.get(pages_list[i]) time.sleep(wait) # espera wait segundos, default é 3 segundosnext=False# controle do whilewhilenotnext: # enquanto verdade# tenta rodar o código abaixotry:# extrai o elemento com base no selector n da selector_list elems = driver.find_element(By.CSS_SELECTOR, selector_list[n])# ascrescenta um dicionário contendo a url o nome da quest a lista results results.append( {"url": elems.get_attribute("href"), "quest": elems.text} )# quando os selectors acabam, um erro acontece, com isso o code block do except entra em açãoexcept: r +=1# r incrementado para gerar uma nova lista de selectors n =0# n é definido com 0 novamente, para iteração da selector_list# nova lista de selectors criada com base no r do loop atual selector_list = populate_selector_list()# controle para sair do while em caso de muitos erros consecutivos (acabaram os itens na pagina atual) r_changed_consecutive +=1else:# se o loop é rodado normalmente, r_changed_consecutive volta a ser 0 r_changed_consecutive =0 n +=1# n é incrementado +1if ( r_changed_consecutive ==3 ): # caso mais de 3 erros consecutivos, next = true e o loop while é fechado, partindo para nova iteração loop fornext=True driver.quit() # Fecha o driver do chromereturn pd.DataFrame(results) # retorna o dataframe de results
Em cada painel, há várias guias com informações gerais sobre a missão. Optei por extrair todas as informações de uma vez, devido ao longo tempo necessário para a coleta. Além disso, cada painel contém informações sobre a expansão em que a missão foi lançada, o que também foi extraído. Caso você não esteja familiarizado com o jogo, uma expansão é uma atualização significativa que adiciona novo conteúdo ao jogo, como áreas, personagens e histórias.
Código
def get_quest_data_info(url_df=[], wait=3):"""Para cada quest em `url_df` extrai o level, expansão, nome da quests, intereações, e dialogo. `wait` é o tempo de espera após a página ser acessada"""# Configuração do chrome para utilização do Selenium chrome_options = Options()# Garante que o GUI está desligado (janela do chrome não vai abrir)# comente esta linha se quiser que a janela do chrome abra chrome_options.add_argument("--headless") chrome_options.add_argument("--no-sandbox") chrome_options.add_argument("--disable-blink-features=AutomationControlled")# chrome_options.add_argument("window-size=1920,1080") # Resolução da tela# Cria o serviço do driver do chrome utilizando o ChromeDriverManager webdriver_service = Service(ChromeDriverManager().install()) driver = webdriver.Chrome(service=webdriver_service, options=chrome_options) results = [] interactions = [] quest_name = []for i inrange(len(url_df)): driver.get(url_df[i]) time.sleep(wait) quest_elems = driver.find_element( By.CSS_SELECTOR, "#page_content > div.content > div > div > h1" ) quest_name =str(quest_elems.text) time.sleep(0.5) elems = driver.find_element( By.CSS_SELECTOR,"#mw-content-text > div > table > tbody > tr:nth-child(1) > td:nth-child(1) > table > tbody > tr > td:nth-child(2) > span", ) levels = elems.text.replace("Lv.", "").replace(" ", "") levels =int(levels) time.sleep(0.5) expansion_elems = driver.find_element( By.XPATH,'//*[@id="mw-content-text"]/div/table/tbody/tr[1]/td[1]/table/tbody/tr/td[2]/div/a[3]', ) time.sleep(0.5) driver.find_element(By.LINK_TEXT, "Dialogue").location_once_scrolled_into_view driver.find_element(By.LINK_TEXT, "Dialogue").click() time.sleep(0.5) dialogue_elems = driver.find_elements(By.CLASS_NAME, "bubble") page_dialogues = []for k in dialogue_elems: page_dialogues.append(k.text) dialogue = page_dialogues time.sleep(0.5) driver.find_element( By.LINK_TEXT, "Interactions" ).location_once_scrolled_into_view driver.find_element(By.LINK_TEXT, "Interactions").click() time.sleep(0.5) interactions_elems = driver.find_elements(By.CLASS_NAME, "tabbertab") interactions =str("".join(interactions_elems[1].text)) results.append( {"quest": quest_name,"expansion": expansion_elems.text,"level": levels,"interactions": interactions,"dialogue": dialogue, } ) driver.quit()return pd.DataFrame(results)
Transformação dos nomes de cada patch presente em cada expansão para o nome da expansão em sí.
Remoção de caracteres e termos indesejados.
Código
# Um dicionário que mapeia nomes de expansão para os seus equivalentes no jogoexpansion_dict = {"Post-Ala Mhigan Liberation": "Stormblood","Newfound Adventure": "Endwalker","Seventh Astral Era": "A Realm Reborn","The Voyage Home": "Shadowbringers","Seventh Umbral Era": "A Realm Reborn","Dragonsong War": "Heavensward","Dark Reprise": "Shadowbringers","Post-Dragonsong War": "Heavensward",}# Substituição dos valores na coluna "expansion" do DataFrame usando o expansion_dictquest_info["expansion"] = quest_info["expansion"].replace(expansion_dict)# Remoção de caracteres de nova linha na coluna "interactions"quest_info["interactions"] = quest_info["interactions"].str.replace("\n", "")quest_info["interactions"] = quest_info["interactions"].str.replace("\r", "")# Particionamento dos dados após a palavra "Maps" na coluna "interactions"quest_info["interactions"] = quest_info["interactions"].str.partition("Maps")[2]# Separação dos elementos na coluna "interactions" com base na vírgulaquest_info["interactions"] = quest_info["interactions"].str.split(",")# Remoção de aspas simples e duplas na coluna "dialogue"quest_info["dialogue"] = quest_info["dialogue"].str.replace("'", "")quest_info["dialogue"] = quest_info["dialogue"].str.replace('"', "")# Limpeza da coluna "interactions" removendo "Mobs Involved" para cada interaçãofor index, row in quest_info.iterrows(): interactions = row["interactions"] interactions = [ x.replace("Mobs Involved", "").strip() if"Mobs Involved"in x else xfor x in interactions ]# Limpeza da coluna "interactions" removendo "Objects Involved Destination" para cada interaçãofor index, row in quest_info.iterrows(): interactions = row["interactions"] interactions = [ re.sub(r'\bMobs\s*Involved\b|\bObjects\s*Involved\s*Destination\b|\bMobs\s*Involved|\bObjects\s*Involved\s*Destination', '', x).strip()for x in interactions ] quest_info.at[index, "interactions"] = interactions# Retorno do DataFrame quest_info modificadoquest_info
quest
expansion
level
interactions
dialogue
0
A Bargain Struck
Stormblood
60
[Alisaie, Alphinaud, Lyse, Conrad, M'naago, Me...
[Alisaie, Come to take the measure of our frie...
1
A Beeautiful Plan
Shadowbringers
74
[Y'shtola, Thancred, Bees]
[Thancred, The bag is sealed tight, but Id rat...
2
A Blessed Instrument
Shadowbringers
70
[Alphinaud, Weeping Warbler, Thoarich, Dulia-C...
[Alphinaud, (Lady Chai has a very particular s...
3
A Blissful Arrival
Stormblood
70
[Alphinaud, Wiscar, Watt, Raubahn, Arenvald, P...
[Wiscar, Grandad and his friends are already h...
4
A Bold Decision
Endwalker
89
[Krile, Tataru, Fourchenault, Barnier, Livingw...
[Tataru, A great many of the helping hands tha...
...
...
...
...
...
...
883
Ys Iala’s Errand
Shadowbringers
72
[Soldier Crawler]
[Ys Iala, Unnngh... Im so hungry I may faint.....
884
Yugiri's Game
A Realm Reborn
50
[Alphinaud, Hozan, Yozan, Hiding Child]
[Alphinaud, As we speak, the Domans prepare fo...
885
Ziz Is So Ridiculous
A Realm Reborn
28
[Aideen Ziz]
[Aideen, So, heres what I know about the death...
886
Α Test of Wιll
Endwalker
89
[Estinien, Alphinaud, Alisaie, Y'shtola, Urian...
[Bereaved Dragon, ......, Estinien, They want ...
887
┣┨̈//̈ No┨ΦounΔ•••
Endwalker
90
[G'raha Tia, Alphinaud, Alisaie, M-017, Sir O...
[Alphinaud, Considering the Omicrons single-mi...
888 rows × 5 columns
Código
fig = px.bar(data_frame=quest_info['expansion'].value_counts().reset_index(), x='expansion', y='count', color='expansion', category_orders={'expansion':['A Realm Reborn', 'Heavensward', 'Stormblood', 'Shadowbringers', 'Endwalker']}, hover_name='expansion', labels={'expansion':'Expansão', 'count':'Número de Quests'}, title='Número de Quests por Expansão')fig.show()
A Realm Reborn, tem a maior quantidade de quests, uma das maiores reclamações dos novos jogadores.
Número de quests por level. Os níveis 50, 60, 70, 80, e 90 se sobrepõem pois todo expansão termina onde a outra parou.
Extração das características dos NPCs
Primeiramente, a função abaixo define uma lista de raças de interesse e cria uma função interna para popular uma lista de XPaths. A lista de XPaths contém os caminhos para as tabelas de cada categoria de personagem no site. A função interna usa loops para gerar esses caminhos.
Então, a função inicializa uma lista de resultados e começa a iterar através da lista de XPaths. Para cada XPath, a função tenta encontrar o elemento correspondente na página utilizando o método find_element do Selenium. Se o elemento for encontrado, a função verifica se o personagem é do gênero feminino ou masculino. Se for feminino, a função define o sexo como “Female”. Se for masculino, a função define o sexo como “Male”.
Por fim, a função adiciona as informações extraídas (URL da categoria, raça e sexo) à lista de resultados. A função retorna a lista de resultados depois de iterar através de todos os XPaths.
Código
def get_character_pages_and_info(url="", wait=2):"""Partindo da url `https://ffxiv.gamerescape.com/w/index.php?title=Category:NPC&pageuntil=Ailid#Hyur`, extrai a url para cada uma das categorias presentes no painel do site, junto com o sexo e raça """# Lista das raças de interesse races = ["Hyur","Elezen","Lalafell","Miqo'te","Roegadyn","Au Ra","Viera","Hrothgar","Other", ]# Função interna para popular a lista de XPathsdef populate_xpath_list(): xpath_list = []# Loop para percorrer os XPaths das tabelas das categorias presentes na páginafor j inrange(1, 9):for i inrange(2, 4):for k inrange(1, 4): xpath_list.append('//*[@id="tabber-7be34c4dba9d92e6cba110a0e80e7d64"]/div['+str(j)+"]/table/tbody/tr["+str(k)+"]/td["+str(i)+"]/a" )# Lista com os XPaths das categorias Ancients ancients = ['//*[@id="tabber-7be34c4dba9d92e6cba110a0e80e7d64"]/div[9]/table[1]/tbody/tr/td[1]/a','//*[@id="tabber-7be34c4dba9d92e6cba110a0e80e7d64"]/div[9]/table[1]/tbody/tr/td[2]/a','//*[@id="tabber-7be34c4dba9d92e6cba110a0e80e7d64"]/div[9]/table[1]/tbody/tr/td[3]/a','//*[@id="tabber-7be34c4dba9d92e6cba110a0e80e7d64"]/div[9]/table[1]/tbody/tr/td[4]/a','//*[@id="tabber-7be34c4dba9d92e6cba110a0e80e7d64"]/div[9]/table[1]/tbody/tr/td[5]/a', ] xpath_list += ancients# Loop para percorrer os XPaths das tabelas das categorias Ancientsfor k inrange(2, 4):for j inrange(1, 6):for i inrange(1, 7): xpath_list.append('//*[@id="tabber-7be34c4dba9d92e6cba110a0e80e7d64"]/div[9]/table['+str(k)+"]/tbody/tr["+str(j)+"]/td["+str(i)+"]/a" )return xpath_list# Chamada da função para popular a lista de XPaths xpath_list = populate_xpath_list()# Configuração do chrome para utilização do Selenium chrome_options = Options()# Garante que o GUI está desligado (janela do chrome não vai abrir)# comente esta linha se quiser que a janela do chrome abra chrome_options.add_argument("--headless") chrome_options.add_argument("--no-sandbox") chrome_options.add_argument("--disable-blink-features=AutomationControlled")# chrome_options.add_argument("window-size=1920,1080") # Resolução da tela# Cria o serviço do driver do chrome utilizando o ChromeDriverManager webdriver_service = Service(ChromeDriverManager().install())# instanciar o webdriver do Selenium driver = webdriver.Chrome(service=webdriver_service, options=chrome_options)# abrir a URL inicial driver.get(url)# aguardar o tempo especificado time.sleep(wait)# inicializar a lista de resultados results = []# para cada xpath na lista de xpaths populadafor xpath in xpath_list: elems =""# tentar encontrar o elemento usando o xpathtry: elems = driver.find_element(By.XPATH, xpath)# se não encontrar, continuar para o próximo xpathexcept:continue# verificar se o gênero é femininoif"Female"in elems.get_attribute("title").split(sep=":")[-1].split(sep=" "):# definir o sexo como "Female" sex ="Female"# obter a subraça subrace =" ".join( [ wordfor word in elems.get_attribute("title") .split(sep=":")[-1] .split()[ : elems.get_attribute("title") .split(sep=":")[-1] .split() .index("Female") ] ] )# se a subraça for igual à raça correspondente, definir a subraça como NA (valor ausente do pandas)if subrace in races: race = races[races.index(subrace)]if race == subrace: subrace = pd.NA# verificar se o gênero é masculinoelif"Male"in elems.get_attribute("title").split(sep=":")[-1].split(sep=" "):# definir o sexo como "Male" sex ="Male"# obter a subraça subrace =" ".join( [ wordfor word in elems.get_attribute("title") .split(sep=":")[-1] .split()[ : elems.get_attribute("title") .split(sep=":")[-1] .split() .index("Male") ] ] )# se a subraça estiver na lista de raças fornecida, definir a raça como a subraça correspondenteif subrace in races: race = races[races.index(subrace)]# se o gênero não for conhecidoelse:# definir o sexo como "Unknown" sex ="Unknown"# obter a raça race =" ".join( [ wordfor word in elems.get_attribute("title") .split(sep=":")[-1] .split()[ : elems.get_attribute("title") .split(sep=":")[-1] .split() .index("NPC") ] ] )# adicionar o resultado à lista de resultados results.append({"url": elems.get_attribute("href"), "race": race, "sex": sex})# fechar o webdriver driver.quit()# retornar um DataFrame do pandas com os resultadosreturn pd.DataFrame(results)characters_pages_and_info_df = get_character_pages_and_info("https://ffxiv.gamerescape.com/w/index.php?title=Category:NPC&pageuntil=Ailid#Hyur")
Código
characters_pages_and_info_df.head()
url
race
sex
0
https://ffxiv.gamerescape.com/wiki/Category:De...
Hyur
Male
1
https://ffxiv.gamerescape.com/wiki/Category:De...
Hyur
Male
2
https://ffxiv.gamerescape.com/wiki/Category:De...
Hyur
Male
3
https://ffxiv.gamerescape.com/wiki/Category:De...
Hyur
Female
4
https://ffxiv.gamerescape.com/wiki/Category:De...
Hyur
Female
Esta função recebe três dataframes como entrada: df_url, df_race e df_sex. Esses dataframes têm informações sobre personagens de um jogo, além da URL da página de cada um deles. A função extrai o nome de cada personagem em uma lista de personagens associada a cada URL no df_url, e associa a raça e sexo do personagem com base nas informações nos dataframes df_race e df_sex. O resultado é um novo dataframe com as informações dos personagens.
Código
def get_character_names(df_url, df_race, df_sex):"""Dado um DataFrame com URLs de páginas da wiki, extrai o nome dos personagens, bem como suas raças e sexo, e retorna um novo DataFrame com essas informações."""# Cria uma lista com todos os caminhos XPath que serão utilizados para encontrar os nomes dos personagens na página da wikidef populate_xpath_list(): xpath_list = []for j inrange(1, 5000): xpath_list.append('//*[@id="mw-content-text"]/div[1]/table/tbody/tr['+str(j)+"]/td[1]/a" )return xpath_list xpath_list = populate_xpath_list()# Configuração do chrome para utilização do Selenium chrome_options = Options()# Garante que o GUI está desligado (janela do chrome não vai abrir)# comente esta linha se quiser que a janela do chrome abra chrome_options.add_argument("--headless") chrome_options.add_argument("--no-sandbox") chrome_options.add_argument("--disable-blink-features=AutomationControlled")# chrome_options.add_argument("window-size=1920,1080") # Resolução da tela# Cria o serviço do driver do chrome utilizando o ChromeDriverManager webdriver_service = Service(ChromeDriverManager().install()) driver = webdriver.Chrome(service=webdriver_service, options=chrome_options)# Espera 2 segundos antes de começar a executar o código time.sleep(2) results = []# Percorre as URLs do DataFrame df_url e extrai as informações de nome, raça e sexo de cada personagemfor i inrange(len(df_url)): driver.get(df_url[i]) time.sleep(1) race = df_race.loc[i] sex = df_sex.loc[i]for xpath in xpath_list:try: elems = driver.find_element(By.XPATH, xpath) results.append( {"name": elems.get_attribute("title"), "race": race, "sex": sex} )except:break# Encerra a execução do driver do Chrome driver.quit()# Cria um DataFrame com as informações de nome, raça e sexo dos personagensreturn pd.DataFrame(results)# df_character = get_character_names(characters_pages_and_info_df["url"], characters_pages_and_info_df["race"], characters_pages_and_info_df["sex"])
Acima, o dataframe de personagens, e abaixo um gráfico com as 10 raças mais prevalentes no jogo em relação as personagens não jogaveis.
Código
top_values = df_character["race"].value_counts().nlargest(10).sort_values(ascending=False)df_filtered = df_character[df_character["race"].isin(top_values.index)]fig = px.bar(data_frame=df_filtered.race.value_counts().reset_index(), y='race', x='count', color='race', orientation='h', hover_name='race', labels={'race':'Raça', 'count':'Frequência'}, title='As 10 Raças mais Usadas em Final Fantasy XIV')fig.add_annotation(x=0.15, y=0.21, xref="paper", yref="paper", text='Raças não jogaveis.', showarrow=False)fig.add_annotation(x=0.03, y=0.15,xref="paper", yref="paper", showarrow=True, arrowhead=1, ax=90, ay=-26)fig.add_annotation(x=0.04, y=0.34,xref="paper", yref="paper", showarrow=True, arrowhead=1, ax=77, ay=26)fig.update_layout(dragmode=False)fig.show()
Hyur é a raça mais utilizada pelos desenvolvedores seguida de Elezen e Roegadyn.
Código
# Raças a manterraces_to_keep = ["Hyur","Elezen","Lalafell","Miqo'te","Roegadyn","Au Ra","Viera","Hrothgar","Non-Humanoid","Loporrit",]# cria uma nova coluna para classificar outras raças que não estão em races_to_keep como Outrasdf_character["race_new"] = df_character["race"].apply(lambda x: x if x in races_to_keep else"Outras")df_filtered = df_character[["race_new", "sex"]]df_filtered = df_filtered.rename(columns={"race_new": "race"})df_character = df_character.drop("race_new", axis=1)# grafico do sexo por raçafig = px.bar(data_frame=df_filtered.value_counts().reset_index(), x='race', y='count', color='sex', barmode='group', hover_name="race", title='Distribuição das raças por sexo em Final Fantasy XIV', labels={'sex':'Sexo', 'race':'Raça', 'count':'Frenquência'})fig.show()
Acima, um gráfico com a distribuição de raça dos personagens por sexo.