Estudo de caso: Baixando livros de uma biblioteca virtual

Estudo de caso: Baixando livros de uma biblioteca virtual

A instituição em que estudo, IFCE, contratou um serviço terceirizado para fornecer livros virtuais aos estudantes. Chama-se Biblioteca Virtual Universitária (BVU) e localiza-se nesse endereço: http://bvu.ifce.edu.br/login.php.
Alguns livros dela até que são bons e os professores adotam em aula, porém, é horrível a leitura no próprio site. Não é possível ler offline e no tablet sempre fica perdendo a seção, assim forçando a relogar.
Então decidi fazer um script para efetuar o download automático desses livros, em vista desses problemas e por conta de um amigo ter duvidado em que eu conseguiria fazer isso =P
Ele encontra-se no GitHub.

Observação 0: Eu escrevi o script a um ano e meio atrás, então não lembro de todos os detalhes para implementa-lo.
Observação 1: Na primeira vez, eu implemente usando Perl, depois eu traduzir para Python. No decorrer desta postagem eu usarei códigos Python, mas você poderá ver ao final da postagem o código final em Perl.
Observação 2: Para acompanhar essa postagem, escute isso.

Login

O mais óbvio é começar pelo login. Por alguma razão misteriosa, a BVU tem duas páginas de login: a que precisa de senha, e a que não precisa de senha. Obviamente, irei usar a segunda opção.

Para o login, e noutros momentos, decidi usar o PhantomJS. O PhantomJS é uma das coisas mais fantásticas que existem no mundo. Ele é um navegador sem a parte visual. Através de códigos escritos em Python, você controla esse navegador e pode recolher o conteúdo da página acessada. O melhor de tudo é que ele consegue executar códigos JavaScript, algo essencial para o nosso caso.
Se estiver no Linux, para instalar o PhantomJS use sudo apt-get install phantomjs

O código para o login ficou assim:

phantom = webdriver.PhantomJS()
phantom.get('http://bvu.ifce.edu.br/login.php')
phantom.find_element_by_id('Login').send_keys('99999999999999')
phantom.find_element_by_class_name('btn-primary').click()
phantom.save_screenshot('foo.jpg')

O método get é para acessar determinada página.
Para selecionar o campo de login foi bem simples: usando o F12 (console do navegador) vi que ele tem um ID, então eu decidi usa-lo na seleção. Lembrando: nas aplicações web, os ID são únicos, assim não pode haver dois elementos com o mesmo ID, porém o mesmo não acontece com outros atributos, como o name. Inicialmente, eu tentei inserir a matrícula e depois da enter, desse modo:

from selenium.webdriver.common.keys import Keys
phantom.find_element_by_id('Login').send_keys('99999999999999', Keys.ENTER)

Porém, não deu certo. Então eu precisava selecionar o botão de login e clica-lo.
Analisando pelo console, vejo que ele não tem ID, nesse caso, preciso usar outros atributos para selecionar. Decidi testar as classes dele, e verifico se a classe btn-primary é usada por outro elemento além dele, usando o comando do jQuery $('.btn-primary'), e vejo que só o botão de logar usa. Excelente! Então eu a uso no código.

Se você rolar até o final da postagem, verá que acabei não usando esse código para logar, pois um amigo meu encontrou uma outra forma mais eficiente. Decidi só descrever essa forma de login porque serve de exemplo de como programar com o PhantomJS.

Download do livro

Agora que logamos, precisamos fazer o download do livro determinado pelo usuário, por exemplo, esse sobre o Pedro, o Grande.

Inicialmente, pensei em pegar a URL direta da imagem das páginas do livro, porém, elas parecem ser geradas dinamicamente e não possuem um padrão.
Então eu preciso acessar o livro e ir passando página por página. Pensei em descobrir quantas páginas tem o livro para saber quantas vezes precisaria rodar o loop de passar as páginas.
Reparei que se estiver na última página do livro e clicasse para ir para a próxima, ele automaticamente vai para a primeira página do livro. Desse modo, acredito que há algum código no JavaScript daquele botão que diz quantas páginas há, para ele saber que aquela é a última página e deve voltar. Na parte de baixo há um campo de texto para ir imediatamente para a página determinada. Se eu colocar um valor maior do que a quantidade de páginas do livro, ele vai para a última. Ali também parece ser um bom lugar para analisar.
Então fui ver o código JavaScript da página, e com a ajuda do console do navegador pude ir imediatamente para a função do botão de "próxima página".
Uma grande dificuldade é que o código JavaScript da página foi "compilado". A fim de otimização, eles apagaram os espaços e reduziram ao máximo o nome das variáveis. Isso, além de reduzir o tamanho do código, serve de ofuscação. Para resolver o problema de edentação, usei a minha IDE para organizar o código. Agora, a respeito dos nomes das variáveis, não tinha o que fazer. Precisava deduzir o que a variável c representava, por exemplo.

Cavando o JavaScript, e fazendo vários testes, executando algumas partes dele no console, cheguei a conclusão que RDP.options.pageSetLength armazenada a quantidade de páginas do livro, e precisava subtrair por 2 para da certo o meu loop. Também descobrir que o método navigate.next_page() passava de página.
Fantástico! Agora eu consigo percorrer o livro inteiro!
Para executar um código JavaScript no PhantomJS, basta usar o método execute_script, como em:

phantom.execute_script('navigate.next_page()')

Isso apenas executará o código. Caso queiramos retornar o valor, precisamos explícito, usando return:

num_pag = int(phantom.execute_script('return RDP.options.pageSetLength')) - 2

Para baixar a imagem, precisamos da URL dela. Para isso, percebi que usando $('.backgroundImg')[0].src pego a imagem da página à esquerda, enquanto é da página à direita, $('.backgroundImg')[1].src.
Não sei ao certo a razão, mas não adianta pegar exatamente o atributo src do elemento.
Já com a URL, bastava usar o método urlretrieve da biblioteca padrão urlib para fazer o download da imagem.

Um problema que tive é o loading. Assim que passa de página, tem vezes demora para a imagem ser carregada. Então eu preciso ficar verificando se ela já está presente ou não. Para isso, uso o código JavaScript if ($('.backgroundImg')[0]) { return 1 } else { return 0 }
Se retornar 1, é porque a imagem já está carregada.

Tudo finalizado? Não! Pois as vezes o login trava. Simplesmente espero, espero, e nada acontece. Então tive que implementar um timeout.

phantom.set_page_load_timeout(10)
try:
    ...
except TimeoutException:
    ...

Gerar PDF

Para gerar o PDF, há dois modos: usar a biblioteca fpdf ou o ImageMagick. No ImageMagick podemos executar no terminal convert *.jpg foo.pdf, assim todas as imagens da pasta são usadas para gerar o pdf. Usar o ImageMagick é bem mais tentador, por ser mais simples e fácil, porém, por alguma razão, o ImageMagick fica travando quando há muitas páginas para se gerar o PDF, então decidir descarta-lo depois.

Curiosamente, gerar o PDF no Perl foi bem mais fácil que no Python, pois um módulo me ajudou bastante. Bastava passar uma array com os nomes do arquivo que ele juntava tudo num único PDF. No Python, mesmo com o fpdf, tive bem mais trabalho: precisava determinar o tamanho das páginas, criar uma nova, colocar a imagem nela e passar para a próxima.

Código

Pode-se encontrar o código no meu GitHub nessa página. Agradecimentos ao Paolo por ter ajudado a melhorar o script, como a forma otimizada de login.

O código em Perl encontra-se aqui:

#!usr/bin/env perl

###############
### Modulos ###
###############
use common::sense;
use WWW::Mechanize;
use WWW::Mechanize::PhantomJS;
use WWW::Mechanize::DecodedContent;
use PDF::FromImage;
use Cwd;
use Getopt::Mixed;
use Try::Tiny;

#########################
### Variáveis globais ###
#########################
my $url = WWW::Mechanize->new();
my $phantom = WWW::Mechanize::PhantomJS->new();
my $currentDir = getcwd;
my $login;
my $bookTarget;

###########################
###  Checar argumentos  ###
###########################
Getopt::Mixed::init('l=i login>l b=i book>b h help>h');
while( my( $option, $value, $pretty ) = Getopt::Mixed::nextOption()) {
  OPTION: {
    $option eq 'h' and do {
      &showArgumentsHelp;
      exit;
    };
    $option eq 'l' and do {
      $login = $value;

      last OPTION;
    };
    $option eq 'b' and do {
      $bookTarget = $value;
      last OPTION;
    };
  }
}
Getopt::Mixed::cleanup();

if (!$login) {
  print "Erro: Especifique seu login!\n";
  &showArgumentsHelp;
  exit;
}

if (!$bookTarget) {
  print "Tem certeza que deseja efetuar download de todos os livros? (s/n)\n";
  print "Aviso: Essa ferramenta esta incompleta!\n";
  chomp (my $input = <>);
  if ($input ne 's') {
    &showArgumentsHelp;
    exit;
  }
}

sub showArgumentsHelp {
  print "Argumentos:\n";
  print "-l ou -login: Especifique o login (apenas numeros)\n";
  print "-b ou -book: Especifique o ID do livro (apenas numeros)\n";
  print "  Para descobrir qual o ID do livro que deseja baixar, acesse-o no navegador\n";
  print "  e veja na barra de endereco os numeros apos o 'publications'.\n";
  print "  Se nenhum ID de livro for especificado,\n";
  print "  ira efetuar download de todos (ferramenta incompleta!)\n";
  print "-h ou -help: Mostra essa mensagem de ajuda e finaliza o programa\n";
}

#######################
###  Iniciando BOT  ###
#######################
&login;

sub login {
  $phantom->get("http://bvu.ifce.edu.br/login.php");

  print "Logging... ";

  $phantom->submit_form(
    fields => {
      login => $login,
    }
  );

  # Conferindo o Login
  if($phantom->decoded_content =~ /Fez login com sucesso./gi) {
    print "login donenn";
    if (!$bookTarget) {
      &downloadAllBooks;
    } else {
      &downloadBook($bookTarget, $bookTarget);
    }
  } else {
    $phantom->decoded_content =~ /
(.+?)
/;
    &loginError($1);
  }
}

sub downloadAllBooks {
  # Recolher todos ID's e nomes dos livros
  # TODO: Inacabado! Apenas irá recolher os dos primeiros livros listados
  my @books_id = $phantom->decoded_content =~ /<a href="/users/publications/(d+)">decoded_content =~ /.*class="cover">.*<img alt="(.*?)" />.*>/g;
  my $i = 0;

  print "* Accessing books *\n";
  foreach (@books_id) {
    print "- " . $books_name[$i] . " (" . $_ . ")...\n";
    &downloadBook($_, $books_name[$i]);

    $i++;
    print "\n";
  }
}

# Função para efetuar download das imagens de cada página do livro
sub downloadBook {
  # Declarar variáveis
  my ($bookId, $bookName) = @_;
  my $elementPage2;
  my $srcPage2;
  my @allPagesDir;

  my $pageCheck;
  my $checkChange;

  my $bookDir = $currentDir . '\' . $bookName;
  mkdir($bookDir);

  # Acessar livro
  $phantom->get("http://ifcefortaleza.bv3.digitalpages.com.br/users/publications/" . $bookId);

  my $countPages = $phantom->eval('RDP.options.pageSetLength');
  print "Total number of pages: " . $countPages . "\n";

  my $elementPage1 = $phantom->eval("$('.backgroundImg')[0]");
  my $srcPage1 = $elementPage1->get_attribute('src');
  print "Cover...\n";
  $url->mirror($srcPage1, $bookDir . '\' . '0.png');
  push(@allPagesDir, $bookDir . '\' . '0.png');
  $phantom->eval('navigate.next_page()');

  for (my $currentPage = 1; $currentPage <= $countPages; $currentPage += 2) { # Loop para permitir que o código só seja prosseguido caso a imagem tenha sido alterada # É necesário pois a mudança demora um pouco while ($checkChange eq '' || $srcPage1 eq $checkChange) { try { $pageCheck = $phantom->eval("$('.backgroundImg')[0]");
        $checkChange = $pageCheck->get_attribute('src');
      } catch {
        print "Error when accessing the elements .backgroundImg! Trying again...\n";
      };
    }

    $srcPage1 = $checkChange;
    try {
      $url->mirror($srcPage1, $bookDir . '\' . $currentPage . '.png');
    } catch {
      print "Erro durante o download da página à esquerda! Tentando novamente\n";
      continue;
    };
    push(@allPagesDir, $bookDir . '\' . $currentPage . '.png');
    print "Page " . $currentPage . "/" . $countPages . "...\n";

    $elementPage2 = $phantom->eval("$('.backgroundImg')[1]");
    try {
      $srcPage2 = $elementPage2->get_attribute('src');
      $url->mirror($srcPage2, $bookDir . '\' . ($currentPage + 1) . '.png');
      push(@allPagesDir, $bookDir . '\' . ($currentPage + 1) . '.png');
      print "Page " . ($currentPage + 1) . "/" . $countPages . "...\n";
    } catch {
      print "Erro ao tentar acessar o atributo SRC da pagina a direita. Essa pagina realmente existe?\n";
    };

    $phantom->eval('navigate.next_page()');
  }

  # Gerar PDF
  my $pdf = PDF::FromImage->new;
  $pdf->load_images(@allPagesDir);
  $pdf->write_file($bookDir . '\' . $bookName . '.pdf');
  print "... pdf creat!n";

  # Finalizar
  print "* End! *n";
}

# loginError -> chamado em caso de erro ao tentar logar
sub loginError {
  print "erro ao realizar login!!\n";
  print "Mensagem de erro: @_\n";
  print "Tentar realizar login novamente? (s/n)\n";
  chomp (my $input = <>);

  if ($input eq 's') {
    print "Informe o seu login (apenas numeros): ";
    chomp ($login = <>);
    &login;
  }
}

__END__