Ex Manga Downloadr - Parte 3: Suporte ao Mangafox!
Eu achava que a Parte 2 seria meu último artigo sobre essa ferramentinha, mas no fim das contas é divertido demais para largar tão rápido. Como sempre, todo o código fonte está no meu repositório do Github. E o resumo do post é que agora dá pra fazer isso:
git pull
mix escript.build
./ex_manga_downloadr -n onepunch -u http://mangafox.me/manga/onepunch_man/ -d /tmp/onepunch -s mangafoxE pronto: download direto do Mangafox embutido! \o/
Tudo começou quando eu quis baixar um mangá que não existe no MangaReader mas está disponível no Mangafox.
Então a empreitada inicial foi copiar os módulos do parser do MangaReader (IndexPage, ChapterPage e Page) e colar numa pasta específica “lib/ex_manga_downloadr/mangafox”. A mesma coisa na pasta de testes unitários. Só copiar e colar os arquivos e trocar o nome do módulo “MangaReader” por “Mangafox”.
Claro que os formatos de URL são diferentes, os seletores CSS do Floki são um pouquinho diferentes, então é isso que precisa mudar no parser. Por exemplo, é assim que eu faço o parsing dos links de capítulo da página principal no MangaReader:
defp fetch_chapters(html) do
Floki.find(html, "#listing a")
|> Enum.map fn {"a", [{"href", url}], _} -> url end
endE essa é a mesma coisa, só que para o Mangafox:
defp fetch_chapters(html) do
html
|> Floki.find(".chlist a[class='tips']")
|> Enum.map fn {"a", [{"href", url}, {"title", _}, {"class", "tips"}], _} -> url end
endExatamente a mesma lógica, mas a estrutura de pattern matching muda porque os nodes do DOM HTML retornados são diferentes.
Outra diferença é que o MangaReader devolve tudo em texto puro por padrão, enquanto o Mangafox devolve tudo Gzipado independente de eu mandar o header HTTP “Accept-Encoding” (curiosamente, se eu tento várias vezes ele muda de comportamento e às vezes manda texto puro).
O que eu fiz de diferente foi checar se a estrutura %HTTPotion.Response{} retornada tinha o header “Content-Encoding” setado como “gzip” e, se sim, descomprimir usando o pacote “zlib” embutido do Erlang (sem precisar importar nada!):
def gunzip(body, headers) do
if headers[:"Content-Encoding"] == "gzip" do
:zlib.gunzip(body)
else
body
end
endEu teria preferido que o HTTPotion já fizesse isso pronto pra mim (#OpportunityToContribute!), mas foi fácil o suficiente.
Depois que os testes unitários começaram a passar corretamente, com o scrapper (requisições HTTPotion) e o parser (seletores Floki) ajustados, chegou a hora de fazer meu Worker reconhecer a existência desse novo conjunto de módulos.
O módulo Workflow só chama o Worker, que por sua vez faz o trabalho pesado de buscar páginas e baixar imagens. O Worker chamava o módulo MangaReader diretamente, assim:
defmodule PoolManagement.Worker do
use GenServer
use ExMangaDownloadr.MangaReader
require Logger
...
def chapter_page(chapter_link) do
Task.async fn ->
:poolboy.transaction :worker_pool, fn(server) ->
GenServer.call(server, {:chapter_page, chapter_link}, @timeout_ms)
end, @transaction_timeout_ms
end
end
...
def handle_call({:chapter_page, chapter_link}, _from, state) do
{:reply, ChapterPages.pages(chapter_link), state}
end
...
endAquele “use ExMangaDownloadr.MangaReader” lá em cima é só uma macro que cria os aliases para os módulos correspondentes:
defmodule ExMangaDownloadr.MangaReader do
defmacro __using__(_opts) do
quote do
alias ExMangaDownloadr.MangaReader.IndexPage
alias ExMangaDownloadr.MangaReader.ChapterPage
alias ExMangaDownloadr.MangaReader.Page
end
end
endEntão quando eu chamo “ChapterPages.pages(chapter_link)” é um atalho pra usar o nome qualificado completo do módulo, tipo: “ExMangaDownloadr.MangaReader.ChapterPages.pages(chapter_link)”.
Um namespace de módulo Elixir é só um Atom. Nomes de módulos aninhados têm o nome completo separado por pontos, prefixado com seu pai. Por exemplo:
defmodule Foo do
defmodule Bar do
defmodule Xyz do
def teste do
end
end
end
endVocê pode chamar “Foo.Bar.Xyz.teste()” e pronto. Mas tem uma pequena pegadinha. O Elixir também prefixa transparentemente o nome completo do módulo com “Elixir”. Então na real, o nome completo do módulo é “Elixir.Foo.Bar.Xyz”, pra garantir que nenhum módulo Elixir conflite com algum módulo Erlang existente.
Isso é importante por causa dessa nova função que eu adicionei primeiro no módulo Worker:
def manga_source(source, module) do
case source do
"mangareader" -> String.to_atom("Elixir.ExMangaDownloadr.MangaReader.#{module}")
"mangafox" -> String.to_atom("Elixir.ExMangaDownloadr.Mangafox.#{module}")
end
endÉ assim que eu mapeio de “mangafox” pro novo namespace “ExMangaDownloadr.Mangafox.”. E por causa da natureza dinâmica e baseada em troca de mensagens do Elixir, eu consigo trocar esse código:
def handle_call({:chapter_page, chapter_link}, _from, state) do
{:reply, ChapterPages.pages(chapter_link), state}
endPor esse:
def handle_call({:chapter_page, chapter_link, source}, _from, state) do
links = source
|> manga_source("ChapterPage")
|> apply(:pages, [chapter_link])
{:reply, links, state}
endAgora eu posso escolher entre os módulos “Elixir.ExMangaDownloadr.Mangafox.ChapterPage” ou “Elixir.ExMangaDownloadr.MangaReader.ChapterPage”, chamar a função pages/1 e mandar o mesmo argumento de antes. Só preciso garantir que consigo receber a string “source” pela linha de comando agora, então mudo o módulo CLI assim:
defp parse_args(args) do
parse = OptionParser.parse(args,
switches: [name: :string, url: :string, directory: :string, source: :string],
aliases: [n: :name, u: :url, d: :directory, s: :source]
)
case parse do
{[name: manga_name, url: url, directory: directory, source: source], _, _} -> process(manga_name, url, directory, source)
{[name: manga_name, directory: directory], _, _} -> process(manga_name, directory)
{_, _, _ } -> process(:help)
end
endComparado com a versão anterior eu só adicionei o argumento string “:source” no OptionParser e passei o valor capturado para process/4. Eu deveria adicionar alguma validação aqui para evitar strings diferentes de “mangareader” ou “mangafox”, mas vou deixar isso pra outro momento.
E no módulo Workflow, em vez de começar só com a URL do mangá, agora preciso começar com a URL e a fonte do mangá:
[url, source]
|> Workflow.chapters
|> Workflow.pages
|> Workflow.images_sourcesO que significa que cada uma dessas funções precisa não só retornar a nova lista de URLs como também passar a source adiante:
def chapters([url, source]) do
{:ok, _manga_title, chapter_list} = source
|> Worker.manga_source("IndexPage")
|> apply(:chapters, [url])
[chapter_list, source]
endEssa era a única função do módulo Workflow hardcoded pro MangaReader, então eu também a deixei dinâmica usando a mesma função manga_source/2 do Worker, e repare que o valor de retorno é “[chapter_list, source]” em vez de só “chapter_list”.
E agora eu finalmente posso testar com “mix test” e criar o novo binário executável de linha de comando com “mix escript.build” e rodar a nova versão assim:
./ex_manga_downloadr -n onepunch -u http://mangafox.me/manga/onepunch_man/ -d /tmp/onepunch -s mangafoxO site do Mangafox é bem instável com várias conexões concorrentes e dá timeout rapidinho às vezes, cuspindo erros feios assim:
15:58:46.637 [error] Task #PID<0.2367.0> started from #PID<0.124.0> terminating
** (stop) exited in: GenServer.call(#PID<0.90.0>, {:page_download_image, {"http://z.mfcdn.net/store/manga/11362/TBD-053.2/compressed/h006.jpg", "Onepunch-Man 53.2: 53rd Punch [Fighting Spirit] (2) at MangaFox.me-h006.jpg"}, "/tmp/onepunch"}, 1000000)
** (EXIT) an exception was raised:
** (HTTPotion.HTTPError) connection_closing
(httpotion) lib/httpotion.ex:209: HTTPotion.handle_response/1Ainda não descobri como fazer retry de requisições HTTPotion direito. Mas uma coisinha que eu fiz foi adicionar um check de disponibilidade no módulo Worker. Assim você pode simplesmente rodar o mesmo comando de novo e ele vai retomar baixando só os arquivos que faltam:
defp download_image({image_src, image_filename}, directory) do
filename = "#{directory}/#{image_filename}"
if File.exists?(filename) do
Logger.debug("Image #{filename} already downloaded, skipping.")
{:ok, image_src, filename}
else
Logger.debug("Downloading image #{image_src} to #{filename}")
case HTTPotion.get(image_src,
[headers: ["User-Agent": @user_agent], timeout: @http_timeout]) do
%HTTPotion.Response{ body: body, headers: _headers, status_code: 200 } ->
File.write!(filename, body)
{:ok, image_src, filename}
_ ->
{:err, image_src}
end
end
endIsso pelo menos reduz retrabalho. Outra coisa que ainda estou trabalhando é nesse outro pedaço da função principal “CLI.process”:
defp process(manga_name, url, directory, source) do
File.mkdir_p!(directory)
dump_file = "#{directory}/images_list.dump"
images_list = if File.exists?(dump_file) do
:erlang.binary_to_term(File.read(dump_file))
else
list = [url, source]
|> Workflow.chapters
|> Workflow.pages
|> Workflow.images_sources
File.write(dump_file, :erlang.term_to_binary(list))
list
end
images_list
|> Workflow.process_downloads(directory)
|> Workflow.optimize_images
|> Workflow.compile_pdfs(manga_name)
|> finish_process
endComo dá pra ver, a ideia é serializar os links finais das URLs das imagens num arquivo usando o serializador embutido “:erlang.binary_to_term/1” e checar se esse arquivo de dump existe, e desserializar com “:erlang.term_to_binary/1” antes de buscar todas as páginas de novo. Agora o processo pode retomar direto da função process_downloads/2.
O Mangafox é terrivelmente instável e eu vou precisar descobrir uma forma melhor de fazer retry de conexões que deram timeout, sem precisar quebrar e reiniciar manualmente da linha de comando. Ou é um site ruim ou um site esperto que derruba scrappers como o meu, embora eu chute que seja só infraestrutura ruim do lado deles.
Se eu reduzo de 50 processos para 5 no pool, parece que ele consegue lidar melhor (mas o processo fica mais lento, claro):
pool_options = [
name: {:local, :worker_pool},
worker_module: PoolManagement.Worker,
size: 5,
max_overflow: 0
]Se você ver erros de timeout, mude esse parâmetro. O MangaReader ainda aguenta 50 ou mais de concorrência.
E agora você sabe como adicionar suporte pra mais fontes de mangá. Fique à vontade pra mandar um Pull Request! :-)