Streicht der User Textstellen auf Amazons eBook-Reader Kindle an, legt das Gerät diese persönlichen Markierungen in einer Datei ab. Steckt der Kindle im USB-Port des Linux-Rechners, saugt ein Perlskript die Daten ab, speichert sie in einer Datenbank und erlaubt später Volltext-Suchabfragen.
E-Books bilden immer mehr Eigenschaften von Papierbüchern nach, der Käufer eines Werks darf es auf Amazons Kindle heutzutage nicht nur zeitweise an Freunde "ausleihen", sondern seit geraumer Zeit auch Buchzeichen ("Bookmarks") setzen und Passagen anstreichen ("Highlights" oder "Clippings"). Praktischerweise schickt der Kindle diese Markierungen auch noch an den zentralen Server, sodass sie dem User auch auf anderen Lesegeräten zur Verfügung stehen.
Abbildung 1: Streicht der Leser Stellen im Text an, speichert der Kindle diese Markierungen in einer Textdatei. |
Eine kompakte Sammlung angestrichener Stellen aus verschiedensten Büchern hilft zudem, nach Begriffen zu fahnden, von denen der Leser zwar weiß, dass er sie irgendwo markiert hat, sich aber nicht an den zugehörigen Buchtitel erinnern kann. In [2] schlägt ein Produktivitätsguru deswegen vor, die Highlights auf der personalisierten Kindle-Webseite auf Amazon.com mittels Cut-and-Paste zu extrahieren. Doch es geht auch eleganter: Der Kindle legt diese Informationen in einer Klartextdatei auf dem Dateisystem des Geräts ab und die heute vorgestellten Perl-Skripts greifen sie einfach von dort ab, sobald der User seinen Kindle in die USB-Buchse eines Linux-Rechners einstöpselt.
Abbildung 2: Dieser nervige Popup lässt sich durch die Auswahl von "Do Nothing" und "Always perform this action" zum Schweigen bringen. |
Nach dem Einstöpseln des Kindle
in die USB-Buchse eines Ubuntu-Systems kommt zunächst ein Dialog nach Abbildung
2 hoch, der vorschlägt, den Kindle mittels des MP3-Spielers Rhythmbox
oder anderer Applikationen anzusteuern. Eine weitere Auswahlmöglichkeit
in der angezeigten Liste weist Ubuntu an, nichts dergleichen zu tun und
ein Klick auf "Always perform this action" hält Ubuntu auch in Zukunft von
derartigem Unsinn ab. Ubuntu mounted das Dateisystem des Kindle anschließend
unter /media/Kindle
.
Abbildung 3 zeigt, dass die Datei mit den gespeicherten Highlight-Informationen
im Ordner documents
unter dem Kindle-Rootverzeichnis liegt und
My Clippings.txt
(mit Leerzeichen) heißt. Ein Blick hinein offenbart
dass es sich um Klartext handelt, die einzelnen Einträge notieren
zeilenorientiert und grenzen sich vom folgenden Eintrag durch eine Kette
von "="-Zeichen ab (Abbildung 4).
Abbildung 3: Die Datei "My Clippings.txt" im Ordner "documents" auf dem Kindle speichert die angestrichenen Stellen im Textformat. |
Abbildung 4: Textstellen, die der Leser angestrichen hat, erscheinen in der Datei "My Clippings.txt" im Dateisystem des Kindle-Readers. |
In der Textdatei finden sich nicht nur markierte Textpassagen, sondern auch Lesezeichen und Randnotizen, die der Leser über die Kindle-Tastatur eingegeben hat um sich über Druckfehler lustig zu machen oder den Buchinhalt besserwisserisch zu kommentieren.
Zur späteren maschinellen Umwandlung des Kindle-Klartextformats in Datenbankeinträge, die später Suchabfragen zulassen, dient das Modul ClippingsParser.pm in Listing 1. Es nimmt einen File-Deskriptor der Clippings-Datei auf dem Kindle entgegen, durchforstet das undokumentierte Format und gibt die Einzelteile wie Buchtitel, Autor, Seite, Highlight-Datum und Highlight-Text an den Aufrufer zurück.
Die Methode parse_fh
nimmt einen File-Deskriptor auf die geöffnete
Textdatei und eine Codereferenz als Callback entgegen, den sie bei jedem
gefundenen Eintrag anspringt. Die erste Zeile der Kindle-Datei kann vor
dem ersten Eintrag am Anfang der Zeile einige unlesbare Zeichen enthalten,
die das Ersetzkommando in Zeile 30 verschwinden lässt.
Entspricht der Inhalt der aktuell untersuchten Textzeile in Skriptzeile 33
nicht dem aus "="-Zeichen zusammengesetzten Eintragstrenner, hängt Zeile
38 den Inhalt an die Variable $entry
an. Stößt das Skript auf eine
Trennzeile zwischen Einträgen, ruft Zeile 34 die Methode parse_entry
auf und übergibt ihr die bislang aufgesammelten Textdaten und den
Callback, der nach erfolgreicher Analyse anzuspringen ist.
In parse_entry()
teilt der split
-Befehl in Zeile 49 einen
Eintrag in Überschrift ($head
), Buchseite und Erfassungsdatum ($whence
),
eine Leerzeile und den markierten Text ($text
) auf. Da die Kindle-Datei
das Windows-Format mit \r\n
als Zeilentrenner verwendet, nutzt split
diese
Kombination im regulären Ausdruck in Zeile 49.
01 ########################################### 02 package ClippingsParser; 03 ########################################### 04 # Mike Schilli, 2012 (m@perlmeister.com) 05 ########################################### 06 use strict; 07 use warnings; 08 09 ########################################### 10 sub new { 11 ########################################### 12 my( $class ) = @_; 13 14 bless {}, $class; 15 } 16 17 ########################################### 18 sub parse_fh { 19 ########################################### 20 my( $self, $fh, $callback ) = @_; 21 22 my $line_sep = "==========\r\n"; 23 my $entry = ""; 24 my $first = 1; 25 26 while( my $line = <$fh> ) { 27 28 if( $first ) { 29 $first = 0; 30 $line =~ s/^\W+//; 31 } 32 33 if( $line eq $line_sep ) { 34 $self->parse_entry( $entry, 35 $callback ); 36 $entry = ""; 37 } else { 38 $entry .= $line; 39 } 40 } 41 } 42 43 ########################################### 44 sub parse_entry { 45 ########################################### 46 my( $self, $entry, $callback ) = @_; 47 48 my( $head, $whence, $empty, $text ) = 49 split /\r\n/, $entry, 4; 50 51 # format error? 52 die "format error" if !defined $text; 53 54 $text =~ s/\r\n\Z//; 55 56 my( $title, $author ) = 57 ( $head =~ /^(.*) \((.*?)\)$/ ); 58 59 # sometimes there's no author 60 if( !defined $author ) { 61 $author = ""; 62 $title = $head; 63 } 64 65 my @whence = split /\s*\|\s*/, $whence; 66 my $when = pop @whence; 67 my $what = join "|", @whence; 68 69 my( $type, $loc ) = 70 ( $what =~ /^- (\w+) (.*)/ ); 71 72 $callback->( $type, $loc, $author, 73 $title, $when, $text ); 74 } 75 76 1;
Manche E-Books, wie zum Beispiel Wörterbücher, weisen keine Autoren in Klammern hinter dem Titel auf, also bleibt das Autorenfeld in Zeile 61 in diesem Fall leer. Das letzte, durch den Trenner "|" abgesonderte Feld in der zweiten Zeile mit den Location- und Seitenangaben enthält das Datum, an dem die Textstelle markiert oder das Lesezeichen gesetzt wurde. In Zeile 72 stehen alle Felder bereit und der Callback-Aufruf übergibt ihre Werte an die vom Hauptprogramm als Referenz hereingereichte Funktion.
Mit dem Parser-Modul ist es nun ein leichtes, die Kindle-Daten
umzumodeln und in ein Format zu überführen, das schnelle Abfragen
gestattet.
Damit der User später mit der Volltextsuche nach
Begriffen in markierten Textabschnitten
suchen kann, speist das Skript in Listing 3 die gefundenen
Highlight-Daten in eine SQLite-Datenbank mit Volltext-Engine ein.
Listing 2 zeigt, dass besondere CREATE-Kommandos mit dem
Kommandozeilen-Tool sqlite3
notwendig sind, damit SQLite, das
Datenbanken in flachen Binärdateien
anlegt, die abgelegten Textdaten so indiziert,
dass Suchabfragen nach mehreren Worten mit Google-Geschwindigkeit ablaufen.
SQL-Datenbanken erlauben im Normalmodus Suchkriterien wie
LIKE %begriff%
, kramen also schon gemächlich Einträge hervor, deren
Textspalten bestimmte Zeichenketten enthalten. Auch wenn man
von der schleppenden
Geschwindikgeit absieht, zeigt sich noch ein weiterer Nachteil: Sucht man
zum Beispiel nach Einträgen, die die beiden Wörter "Perl" und "Data"
irgendwo im Text verstreut enhalten, ist dies nur umständlich möglich.
Ein Volltext-Engine hingegen spezialisiert sich auf derartige Abfragen,
und sucht grundsätzlich nur nach ganzen Wörtern. Sucht man also nach
"Data", fände der Engine keine Einträge mit "Database", sucht man aber
nach "Date Time", findet er alle Texte, die die Wörter "Date" und
"Time" beliebig verstreut und in beliebiger Reihenfolge enthalten.
01 file=highlights.sqlite 02 rm -f $file 03 04 sqlite3 $file <<EOT 05 CREATE VIRTUAL TABLE highlights USING FTS3 ( type TEXT, loc TEXT, 06 author TEXT, title TEXT, date TEXT, text TEXT ); 07 08 CREATE TABLE seen ( type TEXT, loc TEXT, title TEXT, 09 UNIQUE(type, loc, title) ); 10 EOT
Damit SQLite eine Tabelle mit Volltextindex anlegt, fordert Listing 2 statt einer normalen Tabelle eine "virtual table" an, die mit "USING FTS3" Version 3 des "Full Text Search"-(FTS)-Plugins einsetzt. FTS3 liegt SQLite seit Version 3.5.0 bei (seit 2007) und mit SQLite 3.7.4 kam im Jahr 2010 der Release FTS4 hinzu, der Performanceverbesserungen aber auch erhöhte Komplexität mit sich bringt ([3]).
Die Volltexttabelle lässt sich wie normale Tabellen auch mit INSERT-Aufrufen füllen und mit SELECT-Queries abfragen. Sie bietet allerdings zwei zusätzliche Funktionen. Erstens verfügt sie über ein virtuelle Spalte, deren Name mit dem Tabellennamen identisch ist und die sich bei Abfragen wie eine Zusammenfassung aller Tabellenspalten verhält. Im vorliegenden Fall liefert die SELECT-Bedingung
WHERE highlights MATCH(?)
alle Einträge, bei denen irgendeine Spalte auf den angegebenen
MATCH-Operator anspricht. Der zweite Unterschied ist der MATCH-Operator,
der eine wortorientierte Volltextsuche implementiert. So sucht
MATCH("katze hund")
nach Texten, die beide Wörter irgendwo
und in beliebiger Reihenfolge enthalten, und ist außerdem schlau genug,
Groß- und Kleinschreibung zu ignorieren. Es lässt sich sogar ein Stemming
einstellen, bei dem "katze" auch auf "katzen" passt.
Listing 3 zeichnet für das Auslesen der Clippings-Datei auf dem Kindle und
das Auffüllen der SQLite-Datenbank auf dem Linux-Rechner verantwortlich.
Damit es sowohl das Modul ClippingsParser.pm
als auch die Datenbankdatei
highlights.sqlite
findet, die sich im gleichen Verzeichnis wie
das Skript selbst befinden, wechselt der BEGIN-Block ab Zeile 12 zu Anfang
in das Verzeichnis, in dem das Skript steht. In die Logdatei
/var/log/kindle.log
schreibt es, was es gerade treibt, was besonders
hilfreich beim Debuggen ist, falls das Skript im
Hintergrund läuft. Dies ist notwendig, da es später vom udev-System des
Linux-Kernels automatisch aufgerufen wird, sobald der User den Kindle
in die USB-Buchse des Rechners einstöpselt. Dies erfordert auch, dass
es sofort zurückkehrt und im Hintergrund weiterläuft, um dann die
Clipping-Daten auszulesen und der Reihe nach in der Datenbank abzulegen.
Hierzu setzt es in Zeile 35 einen Fork-Befehl ab und erzeugt einen
Child-Prozess, der die anstehende Arbeit erledigt, während der Prozessvater
sich verabschiedet. Da der Kernel das Skript als root
aufruft, setzt
Zeile 33 mit dem Kommando setuid
aus dem POSIX-Modul die Rechte zurück
auf den in Zeile 8 gesetzten User, um keine unnötigen
Sicherheitslöcher aufzureißen.
01 #!/usr/local/bin/perl -w 02 use strict; 03 use local::lib qw(/home/mschilli/perl5); 04 use Log::Log4perl qw(:easy); 05 use POSIX; 06 use DBI; 07 08 my $run_as = "mschilli"; 09 my $clip_path = 10 "/media/Kindle/documents/My Clippings.txt"; 11 12 BEGIN { 13 use FindBin qw($RealBin); 14 chdir $RealBin; 15 } 16 use ClippingsParser; 17 18 my $logfile = "/var/log/kindle.log"; 19 20 Log::Log4perl->easy_init({ 21 file => ">>$logfile", 22 level => $DEBUG, 23 }); 24 25 my ( $name, $passwd, $uid, $gid ) = 26 getpwnam( $run_as); 27 28 chown $uid, $gid, $logfile or 29 LOGDIE "Cannot chown $logfile: $!"; 30 chmod 0644, $logfile or 31 LOGDIE "Cannot chmod $logfile: $!"; 32 33 POSIX::setuid( $uid ); 34 35 my $pid = fork(); 36 die "fork failed" if !defined $pid; 37 exit 0 if $pid; # parent 38 39 # wait until kindle root dir is mounted 40 for( 1..10) { 41 if( -f $clip_path) { 42 last; 43 } else { 44 DEBUG "Waiting for $clip_path"; 45 sleep 5; 46 next; 47 } 48 } 49 50 LOGDIE "$clip_path not found" 51 if !-f $clip_path; 52 53 my $dbh = DBI->connect( 54 "dbi:SQLite:highlights.sqlite", "", "", 55 { RaiseError => 1, PrintError => 0 } 56 ); 57 58 open my $fh, "<", $clip_path or 59 LOGDIE "Cannot open $clip_path ($!)"; 60 61 my $cp = ClippingsParser->new(); 62 63 my $items_added = 0; 64 65 $cp->parse_fh( $fh, sub { 66 my( $type, $loc, $author, $title, 67 $when, $text ) = @_; 68 69 my $sth = $dbh->prepare( 70 "INSERT INTO seen VALUES (?, ?, ?)"); 71 72 eval { 73 $sth->execute( $type, $loc, $title ); 74 }; 75 76 return if $@; # most likely a dupe 77 78 $sth = $dbh->prepare( "INSERT INTO " . 79 "highlights VALUES (?, ?, ?, ?, ?, ?)"); 80 $sth->execute( $type, $loc, $author, 81 $title, $when, $text ); 82 $items_added++; 83 } ); 84 85 INFO "$items_added items added"; 86 close $fh;
Da das Kindle-Rootverzeichnis zum Zeitpunkt des Skriptstarts vielleicht noch gar nicht existiert, probiert die For-Schleife ab Zeile 40 zehnmal hintereinander, ob es die Clippings-Datei lesen kann. Falls dies fehlschlägt, wartet das Skript jeweils 4 Sekunden, bevor es einen neuen Versuch unternimmt.
Schließt der User das gleiche Kindle-Gerät etliche Male hintereinander an,
stehen die meisten Einträge bereits in der Datenbank und blindes Kopieren
würde zu Duplikaten in der Datenbank führen, die diese nur unnötig aufschwemmen
würden. Deshalb hat das Shell-Skript in Listing 2 vorher eine zweite
(reale) Tabelle seen
definiert, die mit einer UNIQUE-Anweisung festlegt,
dass die Kombination aus type
(also "Highlight", "Bookmark" oder "Note"),
loc
(Seitenangabe) und title
(Buchtitel) eindeutig sein müssen.
Fügt nun Zeile 69 einen alten Record in die Tabelle seen
ein, führt dies
zu einem Fehler, denn das Datenbanksystem weigert sich, ein Duplikat
einzuspeisen. Das eval-Konstrukt in Zeile 72 fängt diesen Fehler ab, und
die if-Bedingung in Zeile 76 kehrt in Fehlerfall vorzeitig aus dem
Callback zurück. Ist der Eintrag hingegen neu, bereitet Zeile 78 einen
Neueintrag vor und Zeile 80 füht ihn in die Datenbank ein.
Listing 4 nimmt schließlich Suchanfragen entgegen, fieselt die passenden
Einträge aus der Datenbank heraus und zeigt sie in Form eines
Literaturverzeichnisses an. Abbildung 5 zeigt, wie clipfind
auf die
Anfrage hin, alle Einträge zu finden, die sowohl das Wort file
als auch
das Wort directory
enthalten, zwei Einträge zum Vorschein bringt. Der
erste ist ein Absatz aus dem hervorragenden Linux-Buch "The Linux Programming
Interface" von Michael Kerrisk und der zweite eine Passage aus dem ebenfalls
sehr empfehlenswerten "OpenVPN 2 Cookbook" von Jan Just Keijser.
01 #!/usr/local/bin/perl -w 02 use strict; 03 use Text::Wrap qw(fill); 04 use DBI; 05 06 BEGIN { 07 use FindBin qw($RealBin); 08 chdir $RealBin; 09 } 10 11 my $query = join " ", @ARGV; 12 13 my $dbh = DBI->connect( 14 "dbi:SQLite:highlights.sqlite", "", "", 15 { RaiseError => 1 } ); 16 17 my $sth = $dbh->prepare( 18 "SELECT * FROM highlights " . 19 "WHERE type = 'Highlight' AND " . 20 "highlights MATCH(?)" ); 21 22 $sth->execute( $query ); 23 24 my $serial = 1; 25 26 while( my $ref = 27 $sth->fetchrow_hashref() ) { 28 29 my $output = "[$serial] " . 30 "\"$ref->{title}\", $ref->{author}, " . 31 "($ref->{loc}), \"$ref->{text}\""; 32 33 print fill("", " ", ($output)), 34 "\n\n"; 35 36 $serial++; 37 }
Abbildung 5: Die Volltextsuche gibt alle angestrichenen Textstellen aus, die die Worte 'file' und 'directory' enthalten. |
Abbildung 6: Mit dem USB-Stecker lässt sich der Kindle an eine Linux-Box anschließen. |
Damit das Skript sofort loslegt, sobald der User den Kindle einsteckt,
stellt der Automatisierungsfachmann
die udev
-Regel in Listing 5 ins Verzeichnis /etc/udev/rules.d
und ruft anschließend
sudo service udev restart
auf, um den udev
-Service auf die geänderte Konfiguration aufmerksam zu
machen. Der Eintrag
ENV{DEVTYPE}=="usb_device"
verhindert, dass der Handler in der RUN-Direktive ein halbes Dutzend mal angesprungen wird, da ein frisch eingesteckter Kindle eine ganze Reihe von Aktionen auslöst.
1 SUBSYSTEM=="usb", ACTION=="add", ENV{DEVTYPE}=="usb_device", ATTRS{idVendor}=="1949", ATTRS{idProduct}=="0004", RUN+="/home/mschilli/git/articles/kindle-highlights/eg/kindle-connected"
Abbildung 7: Beim Einstöpseln des Kindle erscheinen Meldungen des USB-Subsystems in der Logdatei /var/log/messages. |
Erst wird ein USB-Eintrag angelegt, dann eine Festplatte zugewiesen, dann eine Partition und weiterer Heckmeck. Jedes Mal die Clippings-Datei zu durchforsten wäre Verschwendung, ganz zu schweigen von der Systembelastung durch mehrere Parallelprozesse, die abwarten, bis das Verzeichnis mit dem Kindle-Filesystem bereit steht. Eine weitere Möglichkeit, das Anschließen des Kindle abzufangen wäre der in [4] vorgestellte Dämon, der sich auf den D-Bus des Linux-Systems setzt und den Mount-Event abfängt.
Abbildung 8: Das /sys-Verzeichnis offenbart die USB-Parameter des angeschlossenen Kindle-Readers. |
Die in der udev
-Regel angegebenen
Werte für Vendor-ID und Product-ID kann man einfach herausfinden, indem
man, wie in Abbildung 6 gezeigt, neue Einträge in der Datei
/var/log/messages
verfolgt, die dem Kindle zugewiesene Nummer im
USB-Baum ausfindig macht (im Beispiel 1-4.4.4-2) und anschließend im
Verzeichnis /sys/bus/usb die entsprechenden Dateiinhalte ausliest, wie
in Abbildung 7 gezeigt. Ab dann sollte alles automatisch funktionieren,
und die lokale Datenbank die Kindle-Daten sofort beim Einstöpseln spiegeln.
Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2012/05/Perl
"How to get your kindle highlights into Evernote", Michael Hyatt, http://michaelhyatt.com/how-to-get-your-kindle-highlights-into-evernote.html
"SQLite FTS3 and FTS4 Extensions", http://www.sqlite.org/fts3.html
"Bus-Touristik", Michael Schilli, April 2011, http://www.linux-magazin.de/Heft-Abo/Ausgaben/2011/04/Bus-Touristik