Das Ende des Papierbuchs (Linux-Magazin, Dezember 2012)

Google Drive mit seinen 5GB an kostenlosem Speicherplatz bietet sich als Online-Lager für eingescannter Papierbücher an. Clients für alle gängigen mobilen Tablets sowie eine umfassende API helfen beim Lesen von unterwegs und automatischem Einspeichern.

Noch zieren sich die deutschen Verlage etwas mit der Umstellung auf digitale Bücher, doch die Weichen sind längst gestellt. Wer hat schon Lust, ein paar Kilo Bücher mit in den Urlaub zu schleppen, wenn Übergepäck Unsummen kostet? Warum zuhause Regale mit staubfangenden Büchern zustellen, die eh keiner mehr herauszieht?

Allerdings habe ich bereits jahrelang Regale mit ungelesenen Papierbüchern zugestellt, und möchte nun nicht alle neu im Digitalformat kaufen, zumal die meisten älteren wohl nie digital erscheinen werden. Deshalb habe ich mir einen vierhundert Dollar teurer Wunderscanner von Fujitsu angeschafft, sowie ein 'Guillotine' genanntes Papiermesser (Abbildung 3), mit dem man Bücher bis zu etwa 600 Seiten von Umschlag und Bindung befreien kann. Mit einem Teppichmesser trenne ich dazu zunächst Hardcover vom Buchrumpf, die Guillotine aus chinesischer Produktion schneidet dann den geklebten oder gebundenen Buchrücken ab und die so enstandene Loseblattsammlung zieht anschließend der Fujitsu S1500 ein (Abbildungen 1-3), fährt eine OCR-Zeichenerkennung auf das Digitalformat und speichert sie als einzelnes PDF ab.

Abbildung 1: Mit einem Teppichmesser wird der Umschlagdeckel abgetrennt.

Abbildung 2: Eine sogenannte Guillotine schneidet den geklebten Buchrücken ab.

Abbildung 3: Der Fujitsu-Scanner S1500 zieht die losen Buchseiten ein und fährt einen OCR-Durchgang.

Platzhirsch Dropbox

Ein dicker Wälzer wie "Algorithmen in C++" schlägt so als PDF mit etwa 200-300 MB zu Buche. Damit der User das Nachschlagewerk auf allen Geräten, vom heimischen PC bis zum iPad im Urlaub, einsehen kann, bieten Firmen wie Dropbox Applikationen an, die einmal eingespeicherte Dateien magisch über das Netz verteilen, ohne dass der Anwender dies groß anordnen muss. Google ist mit "Google Drive" relativ neu in diesem Storage-Geschäft. Die für PC, Mac, iPad, Android-Geräte und Web-Browser verfügbaren Applikationen sind noch nicht so ausgereift wie die vom Platzhirschen Dropbox, aber durchaus funktionsfähig. Eine native Applikation für Linux fehlt noch, wird aber wahrscheinlich irgendwann nachgeliefert. Beide Konkurrenten stellen interessierten Bastlern hervorragen dokumentierte APIs zur Verfügung, damit diese nach Herzenslust selbst Applikationen zaubern können.

Abbildung 4: Im Web-Browser zeigt Google Drive die gespeicherten PDF-Dateien.

Abbildung 4 zeigt die auf Google Drive hochgeladenen PDF-Dateien in einem Chrome-Browser, aber auch Firefox wird unterstützt. Ein Mausklick auf eine Datei löst einen Download aus, und einige Zeit später springt der PDF-Reader an, um das gescannte Buch anzuzeigen. Im iPad speichert die Applikation "Google Drive" die PDF-Daten zwischen, bis zu einer einstellbaren Grenze. Beim Download der App ist darauf zu achten, dass es sich um das Original von Google handelt, es sind einige minderwertige Klone von Drittfirmen im Umlauf.

Abbildung 5: Das gescannte Algorithmus-Buch im PDF-Reader "Goodreader" auf dem iPad.

Abbildung 6: Die Google Drive-App auf dem iPad, die gescannte Papierbücher als PDFs anzeigt.

Automatisch per API

Als PDF-Reader empfiehlt sich bei großen PDF-Dateien nicht das Original von Adobe, da es nicht für schwachbrüstige Mobilgeräte ausgelegt ist. Kostenpflichtige Apps wie Goodreader oder ezPDF auf einem Android-Telefon bieten da mehr Komfort und Performance. Das Hochladen der gescannten Bücher gestaltet sich im Browser recht einfach, allerdings kann es je nach Netzwerkverbindung eine Weile dauern. Statt dies jedes Mal manuell durchzuführen, empfiehlt sich der Einsatz einer handgeschriebenen Applikation. Dank der umfangreichen API mit hervorragender Dokumentation ([3]) ist dies leicht möglich. Leider liefert Google nur SDKs für Java, Ruby, PHP und Python, doch die REST-basierte Web-API lässt sich auch leicht von einem Perl-Skript nutzen.

Wer hat Zugriff?

Nur berechtigte User mit registrierten Applikationen dürfen programmatisch auf die unter Umständen sensiblen Daten auf dem Google Drive zugreifen. Google regelt die Berechtigung mit dem OAuth-Protokoll, mit dem Web-Applikationen von Drittanbietern Userdaten lesen und manipulieren können ohne dass der User dem Drittanbieter sein Passwort mitteilen muss.

Hierzu holt sich eine registrierte Applikation zunächst von Google einen Request-Token ab und leitet den User auf die Google-Login-Seite weiter. Dort trägt dieser seinen Usernamen und sein Passwort ein, falls er noch nicht eingeloggt ist, und erhält anschließend einen Dialog vorgesetzt, der nachfragt, ob er wirklich gewillt ist, dem Drittanbieter Zugriff auf seine Daten zu gewähren. Nach der Bestätigung leitet Google den Browser des Benutzers wieder zurück zur Applikation des Drittanbieters und schickt diesem einen Access-Token und einen Refresh-Token mit.

Alles frisch?

Der Access-Token gilt für eine bestimmte Zeitspanne (zum Beispiel eine Stunde) und erlaubt dem Drittanbieter, entsprechend dem vom User gewählten Berechtigungs-"Scope" auf dessen Daten herumzuorgeln. Manche Scopes erlauben nur das Lesen der Email-Adresse, andere das Lesen aller Dateien auf Google Drive und einer sogar das Lesen und Schreiben aller Daten. Erlischt die Gültigkeit des Access-Tokens nach der eingestellten Zeitspanne, schickt die Applikation den Refresh-Token zurück an Google und erhält postwendend einen neuen Access-Token, falls der User der Applikation auf Google noch nicht den Zugriff entzogen hat.

Registrierung

Auf der "API Console" ([4]) meldet der Experimentierfreudige Applikationen an, die Zugriff auf die Google-APIs brauchen. Neue Projekte dürfen aus einer Vielzahl von APIs wählen (Abbildung 5). Für Google Drive ist der Schalter "Google Drive API" umzulegen, der für das darunter stehende Google Drive SDK bleibt auf "off".

Abbildung 7: Registrierung der Applikation, die die Google-Drive-API nutzt, bei Google.

Abbildung 8: Anschalten der Google Drive API (nicht des SDKs) für das Perl-Projekt.

Nach dem Akzeptieren der allgemeinen Nutzungsbedingungen erscheinen zwei Schlüssel als Hex-Strings: Die Client-ID und das "Client Secret". Da beide fest im Client verdrahtet sind, handelt es sich bei letzterem wohl kaum um ein Geheimnis. Scripts mit diesen beiden Werten dürfen von den Google-API-Servern nun Request-Tokens verlangen, um User zu registrieren. Das Skript in Listing 1 startet einen Mojolicious-Server auf Port 8082 des lokalen Rechners, mit dem sich der Browser in Abbildung Y verbindet. Dieses Verfahren wurde im Perl-Snapshot schon einmal in [2] mit der Dropbox-API besprochen und dient dazu, Google den Request-Token zu entlocken, weil es sich bei dem Skript zum Zugriff auf die Google-Drive-Dateien nicht um eine Web-Applikation handelt. Klickt der User auf den dargestellten Link "Login on Google Drive", springt der Browser zu Google, das den User authentifiziert und fragt, ob er der Applikation "splitsync" (so der Name der Applikation) Zugriff auf sein Drive gewährt (Abbildung Z). Nach dieser Bestätigung schickt Google den Browser wieder zurück auf die Redirect-URL, in diesem Fall den Mojolicious-Server auf http://localhost:8082, der die ebenfalls übermittelten Access- und Refresh-Tokens in der YAML-Datei ~/.google-drive.yml abspeichert und die Erfolgsmeldung (Abbildung (A)) ausgibt.

Abbildung 9: (Y) Zum Einloggen wendet sich der User an den Mojolicious-Server.

Abbildung 10: (Z) Google fragt den User, ob er der Perl-Applikation "splitsync" Zugriff auf seine Drive-Dateien gewährt.

Abbildung 11: (A) Der Mojolicious-Server hat Access- und Refresh-Tokens von Google erhalten und speichert sie in einer YAML-Datei ab.

Skript als Web-App

Die bei der Projekt-Registrierung erhaltene Client-ID und das Client-Secret verdrahtet google-drive-init in den Zeilen 13 und 14. Als Scope gibt es "www.googleapis.com/auth/drive" an und verlangt demnach Schreib-Lese-Zugang zum Drive des Users. Entsprechende Rechte holt Google in Abbildung (Z) ein.

Eingehende Browseranfragen unter dem Pfad "/" landen im Handler ab Zeile 34, der den Parameter login_url für das Template am unteren Rand des Skripts angibt. Später stellt der Browser einen Link darauf dar (Abbildung Y). Die Rücksprungadresse auf dem Mojolicious-Server liegt unter dem Pfad /callback. Der zugehörige, ab Zeile 44 definierte Handler schnappt sich den von Google gesandten Parameter code und gibt ihn der Funktion tokens_get. Sie schickt einen POST-Request and die Accounts-API in Zeile 73, packt die Client-ID und das Client-Secret bei, und bekommt einen Request- und einen Refresh-Token im JSON-Format zurück.

Beide legt sie zusammen mit dem Verfallsdatum und den Client-Daten in der YAML-Datei "~/.google-drive.yml" im Home-Verzeichnis des Users ab, damit später Skripts wie das in Listing 2 mit den Drive-Daten des Users spielen dürfen. Da das Verfallsdatum des Request-Tokens in relativ nach der aktuellen Zeit verstrichenen Sekunden eintrifft, addiert Zeile 59 die aktuelle Zeit in Sekunden seit 1970 dazu und speichert den Zeitstempel unter dem Schlüssel expires ab. Später muss ein Skript nur noch prüfen, ob die aktuelle Sekundenzeit den Zeitstempel überschritten hat.

Listing 1: google-drive-init

    001 #!/usr/local/bin/perl -w
    002 use strict;
    003 use Mojolicious::Lite;
    004 use YAML qw( DumpFile );
    005 use Log::Log4perl qw(:easy);
    006 use HTTP::Request::Common;
    007 use JSON qw( from_json );
    008 use URI;
    009 
    010 my $client_id     = "XXX";
    011 my $client_secret = "YYY";
    012 my $scope    = 
    013   "https://www.googleapis.com/auth/drive";
    014 my $listen   = "http://localhost:8082";
    015 my($home)    = glob '~';
    016 my $CFG_FILE = 
    017   "$home/.google-drive.yml";
    018 my $redir_uri = "$listen/callback";
    019 
    020 my $login_uri = URI->new( 
    021   "https://accounts.google.com" .
    022   "/o/oauth2/auth" );
    023 
    024 $login_uri->query_form (
    025   response_type => "code",
    026   client_id     => $client_id,
    027   redirect_uri  => $redir_uri,
    028   scope         => $scope,
    029 );
    030 
    031 @ARGV = (qw(daemon --listen), $listen);
    032 
    033 ###########################################
    034 get '/' => sub {
    035 ###########################################
    036   my ( $self ) = @_;
    037 
    038   $self->stash->{login_url} = 
    039       $login_uri->as_string();
    040 
    041 } => 'index';
    042 
    043 ###########################################
    044 get '/callback' => sub {
    045 ###########################################
    046   my ( $self ) = @_;
    047 
    048   my $code = $self->param( "code" );
    049 
    050   my( $access_token, $refresh_token, 
    051       $expires_in ) = 
    052     tokens_get( $self->param( "code" ) );
    053 
    054   DumpFile $CFG_FILE, { 
    055     access_token  => $access_token,
    056     refresh_token => $refresh_token,
    057     client_id     => $client_id,
    058     client_secret => $client_secret,
    059     expires => time() + $expires_in,
    060     code => $code 
    061   };
    062 
    063   $self->render_text( "Tokens saved.",
    064         layout => 'default' );
    065 };
    066 
    067 ###########################################
    068 sub tokens_get {
    069 ###########################################
    070   my( $code ) = @_;
    071 
    072   my $req = &HTTP::Request::Common::POST(
    073     'https://accounts.google.com/o/' .
    074     'oauth2/token',
    075     [
    076       code => $code,
    077       client_id => $client_id,
    078       client_secret => $client_secret,
    079       redirect_uri => $redir_uri,
    080       grant_type => 'authorization_code',
    081     ]
    082   );
    083 
    084   my $ua = LWP::UserAgent->new();
    085   my $resp = $ua->request($req);
    086 
    087   if( $resp->is_success() ) {
    088     my $data = 
    089       from_json( $resp->content() );
    090 
    091     return ( $data->{ access_token }, 
    092              $data->{ refresh_token },
    093              $data->{ expires_in } );
    094   }
    095 
    096   warn $resp->status_line();
    097   return undef;
    098 }
    099 
    100 app->start;
    101 
    102 __DATA__
    103 ###########################################
    104 @@ index.html.ep
    105 % layout 'default';
    106 <a href="<%= $login_url %>"
    107 >Login on Google Drive</a>
    108 
    109 @@ layouts/default.html.ep
    110 <!doctype html><html>
    111   <head><title>Token Fetcher</title></head>
    112     <body>
    113       <pre>
    114       <%== content %>
    115       </pre>
    116     </body>
    117 </html>

Auf Daten orgeln

Listing 2 implementiert eine praxisorientierte Applikation, die prüft, ob alle in einem lokalen Verzeichnis gespeicherten Dateien bereits auf das Google Drive hochgespielt wurden. Mittels der API schickt sie eine Anfrage an den Google Drive API server unter dem Pfad /files, der ein Listing aller unter dem Account des autentisierten Users liegenden Dateien anfordert.

Listing 2: google-drive-check

    01 #!/usr/local/bin/perl -w
    02 use strict;
    03 use LWP::UserAgent;
    04 use HTTP::Request;
    05 use HTTP::Headers;
    06 use HTTP::Request::Common;
    07 use File::Basename;
    08 use YAML qw( LoadFile DumpFile );
    09 use JSON qw( from_json );
    10 
    11 my( $home )  = glob "~";
    12 my $cfg_file = "$home/.google-drive.yml";
    13 my $syncdir  = "$home/books";
    14 my @books_local = 
    15   map { basename $_ } <$syncdir/*.pdf>;
    16 
    17 my $cfg = LoadFile $cfg_file;
    18 
    19 if( $cfg->{ expires } - 60 < time() ) {
    20     warn "Token needs to be refreshed.";
    21     token_refresh( $cfg );
    22     DumpFile( $cfg_file, $cfg );
    23 }
    24 
    25 my $req = HTTP::Request->new(
    26   GET => 'https://www.googleapis.com/' .
    27     'drive/v2/files?maxResults=3000',
    28   HTTP::Headers->new( Authorization => 
    29       "Bearer " . $cfg->{ access_token })
    30 );
    31 
    32 my $ua = LWP::UserAgent->new();
    33 my $resp = $ua->request( $req );
    34 
    35 if( ! $resp->is_success() ) {
    36     die $resp->message();
    37 }
    38 
    39 my $data = from_json( $resp->content() );
    40 
    41 my %books_remote = ();
    42 
    43 for my $item ( @{ $data->{ items } } ) {
    44   if( $item->{ kind } eq "drive#file" ) {
    45     my $file = $item->{ originalFilename };
    46     next if !defined $file; 
    47     $books_remote{ $file } = 1;
    48   }
    49 }
    50 
    51 for my $book ( @books_local ) {
    52   if( !exists $books_remote{ $book } ) {
    53     print "Book not saved yet: [$book]\n";
    54   }
    55 }
    56 
    57 ###########################################
    58 sub token_refresh {
    59 ###########################################
    60   my( $cfg ) = @_;
    61 
    62   my $req = &HTTP::Request::Common::POST(
    63     'https://accounts.google.com/o' .
    64     '/oauth2/token',
    65     [
    66       refresh_token => 
    67         $cfg->{ refresh_token },
    68       client_id     => 
    69         $cfg->{ client_id },
    70       client_secret => 
    71         $cfg->{ client_secret },
    72       grant_type    => 'refresh_token',
    73     ]
    74   );
    75 
    76   my $ua = LWP::UserAgent->new();
    77   my $resp = $ua->request($req);
    78 
    79   if ( $resp->is_success() ) {
    80     my $data = 
    81       from_json( $resp->content() );
    82     $cfg->{ access_token } = 
    83       $data->{ access_token };
    84     $cfg->{ expires } = 
    85       time() + $data->{ expires_in };
    86     return 1;
    87   }
    88 
    89   warn $resp->status_line();
    90   return undef;
    91 }

Liegt ein gültiger Request-Token vor, kommt von Google ein JSON-String nach Abbildung C zurück, der alle auf dem Drive liegenden Dateien anzeigt, unabhängig davon, in welchem Verzeichnis sie liegen. Zeile 44 prüft, ob der Parameter kind auf "drive#file" gesetzt ist und Zeile 45 extrahiert den Wert von originalFilename, den Namen der hochgeladenen Datei. Jeder Treffer wird im Hash %books_remote abgespeichert, damit die For-Schleife ab Zeile 51, die über alle lokal im vorgegebenen Verzeichnis ~/books gefundenen Bücher läuft, blitzschnell feststellen kann, welche Bücher noch nicht gesichert wurden.

Ist der in der YAML-Datei gesicherte Token abgelaufen, weil seit dem Lauf von google-drive-init mehr als eine Stunde vergangen ist, holt die Funktion token_refresh ab Zeile 58 einen frischen Access-Token vom Server. Sie benötigt außer dem Refresh-Token nur noch die Client-Daten, aber keine Eingaben seitens des Benutzers mehr. Dieser kann weitere Token-Auffrischer nur verhindern, indem er auf Google der Applikation "splitsync" die Rechte hierzu entzieht.

Abbildung 12: (C) JSON-Daten mit auf Google Drive gespeicherten Dokumenten, die die Google-API auf eine Datei-Listing-Anfrage zurückschickt.

Zuviel Krempel kostet

Abbildung 13: Volltextsuche auf den PDF-Dateien im Google-Drive.

Eine schöner Nebeneffekt der Datenspeicherung auf Google Drive ist, dass Google den Inhalt der Dateien indiziert und eine Volltextsuche darauf erlaubt. Zu beachten ist allerdings, dass dies nicht mit 200MB-Monsterdateien funktioniert. Als weiteres Testprojekt habe ich deshalb ein Skript erstellt, das die PDF-Dateien an Seitengrenzen in Einzelstücke von etwa 25MB Größe aufteilt. Dies beschleunigt nicht nur das Herunterladen bestimmter Buchteile, sondern wirft auch Googles Indizierungsmechanismus an, so dass nun, wie in Abbildung X ersichtlich, eine Suche nach "Heuschnupfen" tatsächlich Treffer im ebenfalls eingescannten "Medizinischen Hausbuch" liefert.

Infos

[1]

Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2012/12/Perl

[2]

"Ab in die Kiste", Michael Schilli, Linux-Magazin 2011/07, http://www.linux-magazin.de/Heft-Abo/Ausgaben/2011/07/Perl-Snapshot

[3]

"Integrate your app with Google Drive", https://developers.google.com/drive/

[4]

"API Console", https://code.google.com/apis/console#access

Michael Schilli

arbeitet als Software-Engineer bei Yahoo in Sunnyvale, Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen der Skriptsprache Perl. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.