Scrape the web

Scrape the web

Kleine on-the-fly Dokumentation, wie ich einen Webcomic lokal verfügbar mache, um ihn mit einem Bildbetrachter ansehen zu können (rant: weil jemand die Webseite mobile first designed hat und dabei vergaß, dass die Gäste möglicherweise mit einem 27" Bildschirm daher kommen).

Zum Vorgehen

Was

Zunächst müssen wir wissen, was genau wir abziehen möchten. In meinem Fall sind das Bilder (die Comicseiten) und etwas Struktur. Die Cover sind zweitrangig, aber auch nett zu haben.

Wo

Den Namen des genauen Webcomics lasse ich aus, bevor mich jemand verklagt, dass ich frei verfügbare Bilder herunterlade (die werden auch digital verkauft, vermutlich in höherer Auflösung und in Form eines eBooks, was es wohl ebenso besser lesbar machen würde). Der Einfachheit halber sind sämtliche Adressen auf den Pfad reduziert.

Wie

Der Comic wird immer noch aktualisiert, also wird eine Randbedingung sein, dass ich das Ergebnis aktualisieren kann (ohne gleich alles erneut herunterzuladen). Meine Lieblingsstrategie dafür ist ein (langlebiger) cache mit allen html-Dateien. Das hilft zunächst beim Entwickeln des Scripts, weil ich dann die Rohdaten nicht immer wieder vom Server laden muss, und später kann ich ggf. daran festmachen, welche Dateien ich bereits habe. Verbessern kann man den Ansatz später, indem man nur Metainformationen hinterlegt (wie Kapitel, Seite, ...).

Mein Tool der Wahl für den Anfang ist oft bash, aber sobald lokal Metainformationen verarbeitet werden (oder das XML zu kompliziert wird zum parsen mit grep), ist python (und beautifulsoup) deutlich flexibler. Ich vermute bereits, dass ich den Switch recht früh machen werde, beginne aber dennoch mit curl und bash. Ich kenne übrigens auch xmlstarlet und Konsorten, aber da landen wir wieder bei "komplexen Datenstrukturen" im Ergebnis, und python macht das einfach besser.

Los geht's

Erster Einblick

Webseite öffnen und sich den Quellcode und die Adresse anschauen: einmal die Hierarchie durchwühlen. /archives/ verlinkt zu Sammlung 1-X via /archives/1/, das Cover befindet sich bei /wp-content/uploads/2012/01/1.jpg. Letzteres entspricht nicht der Hierarchie, wir müssen diese Information dann aus dem HTML der jeweiligen Sammlung entnehmen. Die einzelnen Seiten sind über /page/X/ erreichbar, von dort ist aber kein Rückschluss auf die Sammlungsnummer möglich. Es könnte aber dennoch einfacher sein, über Seitennummern zu iterieren und die Metainformationen getrennt abzuholen. Die Bild-URLs sind wie bei den Covern durch wordpress generiert und nicht berechenbar, muss also ebenso aus dem HTML extrahiert werden. Alle Bilder sind per inline-css als background-image hinterlegt.

Also?

  1. Herausfinden, welche Sammlungen existieren, dazu genügt ein einzelner Aufruf zum Server
  2. Die Sammlungen abrufen und jeweils die URL zum Cover sowie die enthaltenen Seitennummern finden und abspeichern
  3. Die Seiten abrufen, die URL zum Bild finden und abspeichern

Das sollte es dann schon gewesen sein.

V1: Shell

Sammlungsübersicht

Für den Anfang genügt $ inline code (das $ stellt den Prompt der Shell da, ich gebe diese Befehle direkt ein, nicht in eine Datei - was aber letztlich nur geringe Unterschiede ausmacht).

Ich speichere erst einmal die Basisdomain in einer Variable, der Handhabbarkeit halber: $ export URL=http://example.net Dann rufe ich in geübter Manier mit curl die Einstiegsseite ab und besorge mit einem regulärem Ausdruck die Links zu den Sammlungen (das -s steht für silent, die Parameter bei grep für Perl RegExp und print match only.): $ curl -s $URL/archives/ | grep -Po archives/[0-9]+/ Das Ergebnis:

archives/1/
archives/2/
archives/3/
archives/4/

usw. Da ich die Zahlen gerne einzeln hätte, passe ich meinen regulären Ausdruck an: $ curl -s $URL/archives/ | grep -Po '(?<=archives/)[0-9]+'

Den Slash am Ende konnte ich folgenlos entfernen, und den Teil am Anfang habe ich durch eine lookbehind-assertion (Infos hier) ersetzt. Dadurch erhalte ich nur noch die Zahlen selbst als Ergebnis. Diese speichere ich in einem Array: $ declare -a sammlungen=$(curl -s $URL/archives/ | grep -Po '(?<=archives/)[0-9]+')

Kurzer Test, ob das funktioniert hat:

$ echo ${sammlungen[0]}
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42

Nein, so sollte das nicht aussehen - es sollte nur die erste Zahl kommen. Was dafür aber kommt, ist die Erkenntnis, dass das völliger Schwachsinn ist - mir reicht die letzte Zahl (und einfache Arithmetik).

$ unset sammlungen
$ declare -i sammlungen=$(curl -s $URL/archives/ | grep -Po '(?<=archives/)[0-9]+' | tail -n1)
$ echo $sammlungen
42

Wie schön. Nächster Schritt. declare -i gibt mir möglicherweise etwas Typsicherheit für integer, ich weiß das nicht sicher und überlasse es dem Leser, das nachzuschlagen - vielleicht hätte es auch ohne ganz gut funktioniert.

Ich habe bisher auch noch nichts in Fehlerresistenz investiert - die Annahme ist, dass ein Fehler im Script zur Laufzeit vermutlich nur entsteht, weil die Seite entweder nicht mehr existiert oder komplett umgebaut wurde, und damit muss ich eh von vorne anfangen.

Sammlungsdetails

Wir iterieren also über die Seiten: $ for s in $(seq 1 $sammlungen); do echo $URL/archive/$s; done Sollte uns als kurze Kontrolle die richtigen URLs auflisten. Das ist eine Fingerübung für die Erzeugung der Schleife und ballert im Fehlerfall nicht sofort den Server zu. Wir nehmen auch erst einmal wieder den Inhalt der Schleife für ein Element und testen, wie wir an die gesuchten Infos kommen:

$ HTML=$(curl -s $URL/archive/1/)
$ echo $HTML | grep -Po '/wp-content/uploads/[^)]*'

Der Ausdruck zeigt mir alle Vorkommen von /wp-content/uploads/ bis exklusive des nächsten ) an. Im Ergebnis sind jetzt alle möglichen Dateien, aber die URLs sind hinreichend gut unterscheidbar, dass ich hier noch mit mehr Regex auskomme (es wäre mit CSS-Klassen auch semantischer erreichbar):

$ echo $HTML | grep -Po '/wp-content/uploads/[0-9]{4}/[0-9]{1,2}/[0-9]{1,2}.jpg'
/wp-content/uploads/2013/01/1.jpg
$ curl -so cover-1.jpg ${URL}$(echo $HTML | grep -Po '/wp-content/uploads/[0-9]{4}/[0-9]{1,2}/[0-9]{1,2}.jpg')
$ file cover-1.jpg
cover-1.jpg: JPEG image data, Exif standard: [TIFF image data, little-endian, direntries=0], baseline, precision 8, 334x491, components 3

Den Dateinamen werde ich später noch durch die Laufvariable erzeugen lassen. Das Cover haben wir schonmal! Jetzt noch die Seitennumnern. Da wir den Link kennen, nutzen wir einfach den:

$ echo $HTML | grep -Po 'page/[0-9/]+'
page/1/
page/2/
page/3/
page/4/
page/5/

Das Schema hatten wir schon, also nutzen wir das wieder:

echo $HTML | grep -Po '(?<=page/)[0-9]+'

Diesmal brauchen wir aber die erste und die letzte Seite. Mit head und tail geht das, aber wir brauchen zwei Aufrufe. Mit awk schaffen wir es in einem, und können das Ergebnis mit readarray (ein bash-builtin) in einem Array speichern:

$ readarray pages <(echo $HTML | grep -Po '(?<=page/)[0-9]+' | awk 'FNR==1 { print } END { print }')

Das wäre in python schon etwas einfacher und kürzer gewesen. Alternativ hätte ich zwei Aufrufe von grep verwenden können. Der Weg dahin war auch nicht einfach, weil ich es erst mit read versuchte - das liest aber nur eine Zeile. Da hilft auch die Angabe eines anderen delimiters/IFS nicht.

Wir iterieren über die Seiten und wiederholen, was wir für das Cover bereits getan haben (ich springe zum relevanten Punkt):

$ curl -so page-1.jpg ${URL}$(echo $HTML | grep -Po '/wp-content/uploads/[0-9]{4}/[0-9]{1,2}/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{2}.gif' | head -n1)
$ file page-1.jpg
page-1.jpg: GIF image data, version 89a, 700 x 1000

head war notwendig, da das Bild mehrfach eingebunden war und ich nur eine Zeile möchte. Prinzipiell habe ich nun alles, was ich für den vollen Abzug brauche, und beginne mit dem Shellscript. Was ich bisher getan habe, steht in der shell-history (von wo ich es normalerweise wieder abrufen würde - jetzt habe ich ja dieses Dokument zum spicken). history -a sorgt dafür, dass es da auch bleibt (bis zum normalen Beenden ist die history nur im Arbeitsspeicher, der Befehl synchronisiert sofort).

Le Script
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#!/usr/bin/env bash

set -e

URL="https://hidden"

declare -i volumes
volumes=$(curl -s $URL/archives/ | grep -Po '(?<=archives/)[0-9]+' | tail -n1)
echo "volumes found: ${volumes}"

for vol in $(seq 1 "$volumes"); do
  echo "processing: $URL/archives/${vol}"
  mkdir -p "volumes/${vol}"
  curl -so "volumes/${vol}/index.html" "$URL/archives/${vol}/"
  html=$(cat "volumes/${vol}/index.html")
  curl -so volumes/${vol}/cover.jpg ${URL}$(echo $html | grep -Po '/wp-content/uploads/[0-9]{4}/[0-9]{1,2}/[0-9]{1,2}.jpg')
  readarray pages < <(echo $html | grep -Po '(?<=page/)[0-9]+')

  echo "pages in volume ${vol}: ${pages[0]}-${pages[1]}"

  for page in $(seq ${pages[0]} ${pages[1]}); do
    echo "Getting page ${page}"
    curl -so "volumes/${vol}/page-${page}.html" "$URL/page/${page}/"
    page_html=$(cat "volumes/${vol}/page-${page}.html")
    curl -so "volumes/${vol}/page-${page}.jpg" ${URL}$(echo ${page_html} | grep -Po '/wp-content/uploads/[0-9]{4}/[0-9]{1,2}/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{2}.gif' | head -n1)
  done

  unset pages html

done

Nicht schön, aber damit hab ich dann den ersten Abzug durchgeführt. Leider habe ich dann auch gelernt, dass auch hier wieder nicht alles so perfekt gepasst hat: der reguläre Ausdruck für die URL zum Bild hat bei mehr als der Hälfte der Bilder nicht gepasst. Analysiert habe ich das mit find volumes -name page\*.gif | xargs file. Als ich mir die einzelnen html-Dateien angeschaut habe, fiel mir auch auf, dass es ein meta-Attribut in den Dateien gibt, welches auf die Datei zeigt. Das ist mir zu Beginn entgangen, weil die Zeile zu lang war und ich gezielt nach der Einbindung gesucht habe, nicht nach dem Dateinamen. Wieder mit Ausnahmen, aber nur einer Handvoll. Kurzer Blick darauf zeigte, dass auch hier wieder eine Annahme zerbröselt: die Seitenzahl in der URL hängt nicht 100%ig mit der Seitennummerierung zusammen - vermutlich reuploads. Da aber pages sowieso schon ein array ist, kann ich da auch gleich die Seitenzahlen ohne head und tail herausziehen. Keine Ahnung, wieso ich das nicht gleich gemacht habe. Allerdings muss ich die Dateinamen in der Reihenfolge anordnen, wie es auch auf der Webseite getan wurde (und wie es in meinem Array steht). An der Stelle vermisse ich dann enumerate(). Eine weitere Laufvariable wäre möglich, aber eigentlich ist's mir schon zu umständlich und ich wechsle zu python.

Montys Schlange

Zunächst erstelle ich mit $ mkvirtualenv -p $(which python3) scrapeenv ein neues virtualenv zum Arbeiten und konfiguriere das in der IDE. Dazu installiere ich dann mit pip noch requests. Später kommen iterativ noch ein paar weitere dazu, beautifulsoup4 und mezmorize.

Die Vorgehensweise hat sich nicht geändert, dafür die Implementierungsdetails: statt regulären Ausdrücken suche ich nun mit HTML-Attributen und CSS-Klassen. Das ist in meinem Beispiel viel verlässlicher (ich habe noch weitere "Ausnahmen" gefunden). Leider hat es den Code sowie die Abhängigkeiten deutlich vergrößert. Was meine Implementierung in bash einfach "gefressen" hat, war beispielsweise der Umstand, dass manche "Seitenzahlen" nicht rein numerisch waren (es gibt Zahlen mit Bindestrichen, eine Fortsetzung).

Ohne Umschweife also hier die Neuimplementierung in python:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#!/usr/bin/env python3

import multiprocessing as mp  
import os  
import re  
import requests  
import shutil  

import bs4  
from mezmorize import Cache  

BASEURL = "https://irgendwo"  
cache = Cache(CACHE_TYPE='filesystem', CACHE_DIR='cache')  


@cache.memoize()  
def get_cached_page(url):  
    return requests.get(url)  


def download(url, filepath):  
    if not os.path.exists(filepath):  
        r = requests.get(url, stream=True)  
        r.raise_for_status()  
        with open(filepath, "wb") as f:  
            r.raw.decode_content = True  
  shutil.copyfileobj(r.raw, f)  


def download_page(volume, index, page_no):  
    print(index, page_no)  
    page_soup = bs4.BeautifulSoup(get_cached_page("{}/page/{}".format(BASEURL, page_no)).text, 'html.parser')  
    comic_img = page_soup.find('div', {"id": "comic-img"})  
    page_image_url = comic_img.img["src"]  
    filetype = page_image_url.split(".")[-1]  
    page_filename = os.path.join("volumes", volume, "page-{}-{}.{}".format(index, page_no, filetype))  
    download(page_image_url, page_filename)  


def main():  
    archive_html = get_cached_page(BASEURL + '/archives/').text
    volume_numbers = re.findall(r'(?:archives/)([0-9]+)', archive_html)

    for volume in volume_numbers:  
        print("processing volume {}".format(volume))  
        os.makedirs(os.path.join("volumes", volume), exist_ok=True)  

        volume_html = get_cached_page('{}/archives/{}/'.format(BASEURL, volume)).text  
        volume_soup = bs4.BeautifulSoup(volume_html, 'html.parser')  

        cover_url = re.findall('http.*.jpg', volume_soup.find('div', class_="archive-book-cover")["style"])[0]  

        cover_filename = os.path.join("volumes", volume, "cover.jpg")  
        download(cover_url, cover_filename)  

        pages = map(lambda match: re.search(BASEURL + '/page/([0-9-]+)/', match["onclick"]).groups()[0],  
                    volume_soup.find_all('div', class_="archive-thumb-wrap"))
        pool.starmap(download_page, [(volume, i, p) for (i, p) in enumerate(pages)])

pool = mp.Pool(mp.cpu_count())  

if __name__ == '__main__':  
    main()  
    pool.close()

Ein paar Worte dazu:

  • die grundlegende Reihenfolge ist gleich geblieben
  • reguläre Ausdrücke werden nur noch zur Nachbearbeitung eingesetzt
  • beautifulsoup hat die kritischen Pfade zur Navigation ersetzt
  • dadurch auch mehr semantischen Zugriff (nicht unbedingt "simpler", leider - nur das image-tag war wirklich einfach)
  • Caching (auf Platte) wird durch eine lib erledigt
  • multiprocessing funktioniert mit python-Bordmitteln
  • bereits heruntergeladenes wird nicht erneut geladen

Und damit habe ich dann wirklich erhalten, was ich haben wollte.

Fazit

Auf Anhieb kann ich sagen, dass Parallelisierung, Caching und allgemein Filehandling in der shell einfacher möglich sind (GNU parallel/sem, [ -e file ] || do_something und ähnliche Konstrukte). Was die erste Variante auch massiv kürzer macht, ist die Reduzierung auf reguläre Ausdrücke, um irgendwelche Muster im Text zu finden - was ja leider nicht so einfach und sicher war, wie angenommen. Was man jetzt eigentlich noch tun müsste, wäre die Aktualisierung für die letzte Seite geschickter zu bauen - also den Cache für die letzte (und nur die) Sammlung zu invalidieren, und/oder die vorhergehenden gar nicht erst zu iterieren.

The End

Ich hoffe, ich konnte dem einen oder anderen was neues zeigen. Wenn ihr die Variablen nicht versteht, liegt das übrigens an der teilweise auch einfach falschen Benennung - überlegt euch was passenderes :)