Usando Selenium, Chrome Driver e Capybara para automatizar relatórios web
Recentemente, precisei automatizar e extrair dados de um relatório que, infelizmente, não poderia ser acessado de outra maneira se não acessando-se diretamente a página, preenchendo os dados a serem consultados e fazendo o download de um arquivo em PDF. Esse roteiro tomava cerca de cinco minutos para se executar manualmente, mas com o grande volume de vezes que o mesmo se repetia diariamente, esse tempo poderia aumentar rapidamente.
Outro motivo para se automatizar essa tarefa era que o relatório em questão não estava sendo extraído de maneira alguma após ser salvo, o que significa que ao scrappear esses dados programaticamente, se tornaria possível consultar seu conteúdo parcialmente sem a necessidade de manualmente observar o arquivo.
Primeiramente, irei mostrar algumas ferramentas que utilizei para fazer um crawler confiável, e depois irei explicar alguns dos desafios encontrados ao implementa-lo.
Capybara
Se você é familiar com testes de requisitos no ambiente Rails, você provavelmente também está familiarizado com o Capybara, um framework utilizado em testes de integração web que fornece uma API robusta e de fácil uso, resultando não apenas em um comportamento mais confiável por automaticamente executar tarefas braçais em testes de integração como busca de elementos em uma página, mas também o fazendo de maneira muito legível, como se pode ver no exemplo abaixo (copiado do GitHub do Capybara, com alguns trechos traduzidos):
describe "o processo de logar", type: :feature do
before :each do
User.make(email: 'usuario@exemplo.com', password: 'senha')
endit "me loga" do
visit '/sessions/new'
within("#session") do
fill_in 'Email', with: 'usuario@exemplo.com'
fill_in 'Senha', with: 'senha'
end
click_button 'Logar'
expect(page).to have_content 'Sucesso'
end
end
Enquanto o Capybara utiliza o Rack Test por padrão, se você busca simular corretamente a experiência de um usuário comum via código (significando vários scripts que rodam na máquina do usuário, e muito comportamento assíncrono), você provavelmente vai precisar alterar o driver padrão para um mais robusto, que nos leva para a próxima ferramenta:
Selenium Webdriver
Selenium Webdriver é exatamente isso: Ele disponibiliza uma maneira simplificada de se interagir com browsers comumente utilizados, como Google Chrome, Mozilla Firefox e Internet Explorer, fazendo com que quando você começar a explorar as páginas você tenha uma garantia de que é uma experiência bem próxima da qual um usuário teria, com todas as regalias do JavaScript e do processamento da máquina do usuário.
Selenium funciona com todos os tipos de linguagens de programação, como Java, Javascript, C#, Perl, e claro, o Ruby. Quando o usa, você pode especificar qual Webdriver você vai utilizar, resultando em maneiras robustas de se testar os comuns erros de compatibilidade que tão frequentemente assolam as aplicações web, especialmente as legado.
Nesse post, irei utilizar o ChromeDriver, uma ferramenta de código aberto que possibilita a comunicação do selenium com o Google Chrome. Você também pode ir de Geckodriver para o Firefox, ou até mesmo o InternetExplorerDriver, dependendo apenas de qual ambiente você sabe que vai proporcionar melhores resultados em uma dada página.O Capybara permite até mesmo que você altere esse driver em tempo de execução, como veremos logo mais.
Nokogiri
Não muito distante do Capybara, quando se fala de testes web com Rails, Nokogiri também é uma gema bem conhecida para se ler HTML e XML programaticamente, seja ela lida de um arquivo, diretamente de um browser ou uma string, tornando fácil (bem, pelo menos ‘mais’ fácil), de se encontrar elementos, attributos e conteúdo de todos os objetos contidos em uma página. Tome como exemplo o seguinte código pego da página do projeto:
Isso facilita o trabalho de se encontrar elementos específicos de uma página, ou até mesmo iterar sobre todos os elementos de um dado tipo sem ter que sujar as mão lendo o HTML diretamente do código fonte.
Implementação
Tendo as ferramentas a serem utilizadas a mão, ainda serão necessários alguns ajustes para se navegar a web para uma experiência tranquila, e no meu caso, basicamente foram quatro principais problemas:
- Subir o ambiente de navegação
- Configurar o driver corretamente
- Saber como navegar para o conteúdo desejado, e
- Saber a melhor maneira de se extrair esses dados.
Então, irei explicar cada sub-tópico separadamente focando nas maiores dificuldades que cada um proporcionou.
Subindo o ambiente
Após decidir qual driver você irá utilizar, você vai precisar preparar o terreno: Enquanto o código sabe como interagir com o navegador, ele ainda precisa do navegador e do webdriver em si configurados. Nesse exemplo, vou mostrar como configurar o Chrome Driver em uma máquina rodando Ubuntu. A maioria dos passos aqui mostrados foram pegos do seguinte post da página TecAdmin
Primeiramente, vamos instalar as dependências necessárias:
sudo apt-get update
sudo apt-get install -y unzip xvfb libxi6 libgconf-2-4
sudo apt-get install default-jdk
Então, o Google Chrome em si:
sudo curl -sS -o - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add
sudo echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google-chrome.list
sudo apt-get -y update
sudo apt-get -y install google-chrome-stable
E por último, o Chrome Driver:
wget https://chromedriver.storage.googleapis.com/2.41/chromedriver_linux64.zip
unzip chromedriver_linux64.zipsudo mv chromedriver /usr/bin/chromedriver
sudo chown root:root /usr/bin/chromedriver
sudo chmod +x /usr/bin/chromedriver
Já que o ChromeDriver é apenas um binário, você pode extrai-lo para onde desejar, desde que o Selenium possa encontra-lo. Além disso, o post do TecAdmin também mostra como instalar o Selenium Server via .jar, mas já que iremos utilizar uma gema do Selenium para ruby, ele não será necessário.
Como uma observação, existe uma gema chamada Chromedriver-Helper, que ajuda a instalar o ambiente mais facilmente, mas não tive sucesso ao usar a mesma.
As gemas necessárias são as seguintes:
gem 'nokogiri'
gem 'selenium-webdriver'
Preferências do Driver
Quando uma nova instância do webdriver é criada, você também pode alterar várias opções de como o driver e o browser em si operam. No meu caso específico era necessário fazer o download de um arquivo e o salvar em um banco diferente, então precisei configurar o Chrome de uma maneira que ele permitisse o download automático, sem precisar clicar em qualquer formulário, e o salvar em uma pasta específica. O código (simplificado) segue:
class CapybaraCrawler
require 'capybara'
require 'selenium-webdriver'
include Capybara::DSL MAX_WAIT_TIME = 5 # Inicializa o driver
def initialize
Capybara.register_driver :chrome do |app|
driver = Capybara::Selenium::Driver.new(app, browser: :chrome, options: chrome_options )
headless_download_setup(driver)
driver
end Capybara.default_max_wait_time = MAX_WAIT_TIME
Capybara.run_server = false
Capybara.current_driver = :chrome
end # Config necessária para o download headless
def headless_download_setup(driver)
bridge = driver.browser.send(:bridge) path = '/session/:session_id/chromium/send_command'
path[':session_id'] = bridge.session_id bridge.http.call(:post, path, cmd: 'Page.setDownloadBehavior',
params: {
behavior: 'allow',
downloadPath: download_path
}) driver
end# Configurações e perfil do browser
def chrome_options
opts = Selenium::WebDriver::Chrome::Options.new
opts.add_argument('--headless') unless ENV['UI']
opts.add_argument('--no-sandbox')
opts.add_argument('--disable-gpu')
opts.add_argument('--disable-dev-shm-usage')
opts.add_argument('--window-size=1400,1400') opts.add_preference(:download,
directory_upgrade: true,
prompt_for_download: false,
default_directory: download_path) opts.add_preference(:browser, set_download_behavior: { behavior: 'allow' })
opts
end
Como o chrome possui inúmeras opções, irei apenas cobrir as mais importantes
driver = Capybara::Selenium::Driver.new(app, browser: :chrome, options: chrome_options )
Instância um novo driver com as opções especificadas. O objeto de opções é específico do chromedriver (Selenium::WebDriver::Chrome::Options), e se você estiver utilizando outro driver, vai ser necessário mudar tanto a classe do objeto quanto as opções em si. As opções especificadas são:
- headless para rodar o driver sem uma interface gráfica.
- no-sandbox que desabilita o modo sandbox do chrome(O que é sandbox? O Google tem uma síntese aqui)
- disable-gpu para desabilitar a aceleração via hardware, e
- window-size para especificar a resolução desejada da tela.
As preferências do browser também são especificadas para permitir o download de arquivos no modo headless. Você também pode alterar essas preferências para visitar a página em um idioma específico, por exemplo. As várias preferências permitidas para o chromedriver podem ser encontradas na documentação do chromium.
def headless_download_setup(driver)
bridge = driver.browser.send(:bridge) path = '/session/:session_id/chromium/send_command'
path[':session_id'] = bridge.session_id bridge.http.call(:post, path, cmd: 'Page.setDownloadBehavior',
params: {
behavior: 'allow',
downloadPath: download_path
}) driver
end
O diretório de download é onde os arquivos baixados serão salvos, e o comportamento é configurado para aceitar esses downloads. Também temos directory_upgrade e prompt_for_download para desabilitar o formulário de download.
Pode parecer que são muitas opções só para habilitar o download de arquivos, e realmente são. O Chrome não permite downloads no modo headless por padrão por razões de segurança. Então se você não precisar mesmo do download, recomendo ficar só com o HTML fonte mesmo.
Sabendo como chegar na página desejada
Agora, vamos para a parte mais divertida: Navegar a web! 🎉 Nesse início, recomendo desabilitar a opção headless do driver para que seja possível observar como o Capybara interage com a página. Também ajuda no caso de você não saber exatamente como encontrar um objeto específico, como um botão ou um campo de formulário.
def login
visit 'https://sitelegal.org'
wait_for_ajax
fill_in 'USUARIO', with: 'usuario'
fill_in 'SENHA', with: 'senha'
click_on 'Logar'
wait_for_ajax
end
Como é possível perceber, o Capybara trivializa muito a interação com a página, igual como nos testes de integração. Uma pequena observação é o método wait_for_ajax.
# Em vez de utilizar o sleep, espera os scripts ajax terminarem
def wait_for_ajax
Timeout.timeout(Capybara.default_max_wait_time) do
loop until finished_all_ajax_requests?
end
enddef finished_all_ajax_requests?
page.evaluate_script('jQuery.active').zero?
end
Quando eu estava testando o crawler, e encontrando dificuldades relacionadas a Javascript e condições de corrida, o método wait_for_ajax se tornou um grande aliado. Enquanto o Capybara possui muitas maneiras de previnir esses problemas que funcionam por baixo dos panos (e na maioria das vezes é o suficiente), muitas vezes é necessário aguardar um tempo para que a página termine de executar vários scripts assíncronos de maneira confiável. Muitas vezes eu precisava interagir com formulários que resultavam em que os objetos com que eu precisava utilizar fossem atualizados após o Capybara o encontrar, bloqueando assim outras ações com esse mesmo objeto.
Em um primeiro momento, eu utilizava laços combinados do método sleep, mas essa solução não apenas era muito devagar e engessada, esperando mais ou menos do que o necessário em alguns casos, mas também é uma maneira indesejável de se resolver esse tipo de problema. Como a página utilizava muitos scripts assíncronos, esperar até que a página não estivesse mais rodando script algum fez com que o crawler não ficasse apenas mais rápido, mas também mais confiável.
Sabendo como extrair os dados da página
Então agora temos um browser e um roteiro para se chegar na página desejada, mas ainda precisamos extrair os dados necessários da mesma. É nesse momento que o Nokogiri entra em ação:
def scrape_results(raw_html)
contents = sanitize_raw_html(raw_html)
tables = contents.css('table')
scrapped_tables = tables.map do |table|
nokogiri_table_to_hash(table)
end.compact
scrapped_tables.reduce({}, :merge!)
enddef sanitize_raw_html(raw_html)
full_noko_html = Nokogiri::HTML(raw_html)
full_noko_html.css('#dontwantthiselement').remove
full_noko_html
enddef nokogiri_table_to_hash(table)
resulting_array = table.css('tr').map do |tr|
row_to_array(tr)
end.reject(&:empty?)
return {} if resulting_array.empty? key = resulting_array.shift.flatten.first.sub(/\?.*/, '').strip return_hash = {}
return_hash.store(key,resulting_array) unless resulting_array.empty?
return_hash
enddef row_to_array(row)
if row.css('th').empty?
row.css('td').map do |td|
if cell_contains_bang_icon?(td)
' ! ' << td.text.squish
else
td.text.squish
end
end.reject(&:blank?)
else
row.css('th').map{ |td| td.text.squish }.reject(&:blank?)
end
end
O código acima parece ser muito complexo, mas seu objetivo é simples: Ele lê todos os elementos do tipo tabela presentes na página e os converte para um hash de vetores, também buscando ícones de exclamação e os traduzindo para texto, para que possam ser lidos via string. Com esse tipo de tradução, você pode salvar esses dados em um banco NoSQL, ou até mesmo buscar apenas um trecho específico de conteúdo e o salvar em algum banco relacional. Nokogiri proporciona uma maneira simples de se buscar esses objetos e extrair seus conteúdos de texto, possibilitando você estruturar o a página da maneira que melhor atender suas necessidades.
Espero que esse post tenha ajudado qualquer um que acabe encontrando as mesmas dificuldades que eu tive ao implementar um crawler/scrapper em Ruby. Se você tiver qualquer dúvida com algum ponto, ou qualquer sugestão, fique a vontade para deixar um comentário! Se você gostou desse post, deixe suas 👏, e feliz crawling!