手作りiPhoneTracker

これは不気味―iPhoneには過去の位置情報が逐一記録されていることが判明 | TechCrunch Japanという記事が話題に。
iPhoneで取得した位置情報が記録されている、というもの。そのデータを抜き出して可視化するツールが公開されている。
petewarden/iPhoneTracker @ GitHub
ソースが公開されているので覗いてみたところ、どうやら"$HOME/Library/Application Support/MobileSync/Backup"以下のファイルにそれらの情報を格納しているsqliteのファイルがあるらしく、そこからすべて抜き出しているらしい。ただBackupディレクトリ以下には無数のファイルがあり、どれがどれか分からない。それを判別するために"Manifest.mbdb", "Manifest.mbdx"というファイルを解析しているようだ。解析方法自体はiphone - How to parse the Manifest.mbdb file in an iOS 4.0 iTunes Backup - Stack Overflowにあるpythonのコードを参考にしているらしい。ここからsqliteのファイルが割り出せれば、比較的簡単にデータを抜き出して自由に扱うことができそう。
ということで作ってみた。Perlで。

#!/usr/bin/env perl
use strict;
use warnings;

use File::HomeDir;
use Path::Class 'dir';

my $home = File::HomeDir->my_home;
my @dir = sort {
    $b->stat->mtime <=> $a->stat->mtime
} dir("$home/Library/Application Support/MobileSync/Backup")->children;

my $mbdb = process_mbdb_file($dir[0]->file('Manifest.mbdb'));
my $mbdx = process_mbdx_file($dir[0]->file('Manifest.mbdx'));

my $dbfile;
for my $key (keys %{ $mbdb }) {
    $dbfile = $dir[0]->file($mbdx->{$mbdb->{$key}{start_offset}})->stringify;
}
die unless $dbfile;
print "dbfile: $dbfile\n";


sub process_mbdb_file {
    my ($mbdb) = @_;

    my $fh = $mbdb->openr;
    $fh->binmode;

    my $buffer;
    $fh->read($buffer, 4);
    die if $buffer ne 'mbdb';

    $fh->read($buffer, 2);
    my $offset = 6;

    my $data = +{};
    while ($offset < $mbdb->stat->size) {
        my $fileinfo = +{};
        $fileinfo->{start_offset} = $offset;
        $fileinfo->{domain}       = getstring($fh, \$offset);
        $fileinfo->{filename}     = getstring($fh, \$offset);
        $fileinfo->{linktarget}   = getstring($fh, \$offset);
        $fileinfo->{datahash}     = getstring($fh, \$offset);
        $fileinfo->{unknown1}     = getstring($fh, \$offset);
        $fileinfo->{mode}         = getint($fh, 2, \$offset);
        $fileinfo->{unknown2}     = getint($fh, 4, \$offset);
        $fileinfo->{unknown3}     = getint($fh, 4, \$offset);
        $fileinfo->{userid}       = getint($fh, 4, \$offset);
        $fileinfo->{groupid}      = getint($fh, 4, \$offset);
        $fileinfo->{mtime}        = getint($fh, 4, \$offset);
        $fileinfo->{atime}        = getint($fh, 4, \$offset);
        $fileinfo->{ctime}        = getint($fh, 4, \$offset);
        $fileinfo->{filelen}      = getint($fh, 8, \$offset);
        $fileinfo->{flag}         = getint($fh, 1, \$offset);
        $fileinfo->{numprops}     = getint($fh, 1, \$offset);
        $fileinfo->{properties}   = +{};
        for (1 .. $fileinfo->{numprops}) {
            my $key   = getstring($fh, \$offset);
            my $value = getstring($fh, \$offset);
            $fileinfo->{properties}{$key} = $value;
        }
        # 必要なのはこれが含まれているものだけ
        if ($fileinfo->{filename} eq 'Library/Caches/locationd/consolidated.db') {
            $data->{$fileinfo->{start_offset}} = $fileinfo;
        }
    };

    return $data;
}

sub process_mbdx_file {
    my ($mbdx) = @_;

    my $fh = $mbdx->openr;
    $fh->binmode;

    my $buffer;
    $fh->read($buffer, 4);
    die if $buffer ne 'mbdx';

    $fh->read($buffer, 2);
    my $offset = 6;

    my $filecount = getint($fh, 4, \$offset);
    my $data = +{};
    while ($offset < $mbdx->stat->size) {
        $fh->read($buffer, 20);
        $offset += 20;
        my $file_id = unpack("H*", $buffer);
        my $mbdb_offset = getint($fh, 4, \$offset);
        my $mode = getint($fh, 2, \$offset);
        $data->{$mbdb_offset + 6} = $file_id;
    }

    return $data;
}

sub getint {
    my ($fh, $size, $offset) = @_;

    $fh->read(my $buffer, $size);
    $$offset += $size;
    return oct('0x' . unpack("H*", $buffer));
}

sub getstring {
    my ($fh, $offset) = @_;

    my $buffer;

    $fh->read($buffer, 2);
    $$offset += 2;
    my $unpacked = unpack('H*', $buffer);
    return '' if $unpacked eq 'ffff';

    my $length = oct("0x${unpacked}");
    $fh->read($buffer, $length);
    $$offset += $length;
    return $buffer;
}

元のを普通に翻訳しただけ。これで対象のsqliteファイルを突き止めることができる。

$ perl dbfile.pl
dbfile: /Users/sugyan/Library/Application Support/MobileSync/Backup/****************************************/****************************************

ここからWifiLocation, CellLocationなどのテーブルの情報を読み取ることで位置情報の記録が辿れるようだ。
そういえばGoogle Fusion TablesっていうのがあってGoogleMapsのLayerとして使えるんだっけ、というのを思い出したので、取得したデータをそこに突っ込むスクリプトも書いてみた。

#!/usr/bin/env perl
use strict;
use warnings;

use Config::Pit;
use Data::Section::Simple;
use Furl;
use List::Util 'shuffle';
use Text::Xslate;

my $dbfile = shift or die;
my @data = ();
for my $table (qw/WifiLocation CellLocation/) {
    my $result = qx{ sqlite3 '$dbfile' 'SELECT Timestamp, Latitude, Longitude FROM $table;' };
    for my $row (split /\n/, $result) {
        my @col = split /\|/, $row;
        $col[0] += 31 * 365.25 * 24 * 60 * 60;
        push @data, \@col;
    }
}
@data = shuffle(@data);

my $token   = get_token();
my $tableid = create_table($token);

for (1 .. 20) {
    warn $_;
    my @queries = ();
    for (1 .. 500) {
        my $record = shift @data;
        push @queries, qq{INSERT INTO $tableid (timestamp, location) VALUES ($record->[0], '$record->[1],$record->[2]')};
    }
    my $query = join(';', @queries);
    insert_rows($token, $tableid, $query);
    warn 'insert ok';
    sleep 1;
}

my $tx = Text::Xslate->new(
    path => [
        Data::Section::Simple->new()->get_data_section(),
    ],
);
print $tx->render('tracker.tx', { tableid => $tableid });


sub get_token {
    my $conf = pit_get('google.com', require => {
        username => 'google user name',
        password => 'password',
    });

    my $furl = Furl->new;
    my $url  = 'https://www.google.com/accounts/ClientLogin';
    my $res  = $furl->post($url, [], [
        Email  => $conf->{username},
        Passwd => $conf->{password},
        accountType => 'GOOGLE',
        service     => 'fusiontables',
    ]);
    die unless $res->is_success;
    my ($token) = (split /\n/, $res->content)[2] =~ /^Auth=(.*)$/;

    return $token;
}

sub create_table {
    my ($token) = @_;

    my $furl = Furl->new;
    my $url  = 'https://www.google.com/fusiontables/api/query';
    my $res  = $furl->post($url, [
        Authorization  => "GoogleLogin auth=$token",
        'Content-Type' => 'application/x-www-form-urlencoded',
    ], [
        sql => q{CREATE TABLE tracker (location: Location, timestamp: DATETIME)},
    ]);
    die unless $res->is_success;
    my $tableid = (split /\n/, $res->content)[1];

    return $tableid;
}

sub insert_rows {
    my ($token, $tableid, $query) = @_;

    my $furl = Furl->new;
    my $url  = 'https://www.google.com/fusiontables/api/query';
    my $res  = $furl->post($url, [
        Authorization  => "GoogleLogin auth=$token",
        'Content-Type' => 'application/x-www-form-urlencoded',
    ], [
        sql => $query,
    ]);
    die unless $res->is_success;
}


__DATA__
@@ tracker.tx
<html>
  <head>
    <title>iPhone Tracker</title>
    <script type="text/javascript" src="https://www.google.com/jsapi"></script> 
    <script type="text/javascript">google.load("jquery", "1.5.2");</script> 
    <script type="text/javascript" src="http://maps.google.com/maps/api/js?sensor=false"></script>
    <script type="text/javascript">
function initialize() {
    var latlng = new google.maps.LatLng(36, 140);
    var options = {
        zoom: 6,
        center: latlng,
        mapTypeId: google.maps.MapTypeId.ROADMAP
    };
    var map = new google.maps.Map(
        document.getElementById("map_canvas"), options
    );
    var layer = new google.maps.FusionTablesLayer(<: $tableid :>, {
        map: map
    });
    google.maps.event.addListener(layer, 'click', function(e) {
        var date = new Date(e.row.timestamp.value * 1000);
        var div = $(e.infoWindowHtml);
        div.append('<br>').append(date.toLocaleString());
        e.infoWindowHtml = div.html();
    });
}
    </script>
  </head>
  <body onload="initialize();">
    <div id="map_canvas" style="width:100%; height:100%"></div>
  </body>
</html>

残念なことにPerlでFusionTablesAPIを扱うライブラリは無いようだったけど、INSERTするだけだし普通にリクエストを生成して送る。データサイズの制限などもあるようなのでシャッフルして10000件程度だけINSERTするようにした。
完了後、生成したテーブルidをFusionTablesLayerとして扱うmapsのHTMLを出力する。

$ perl fusion.pl '/Users/sugyan/Library/Application Support/MobileSync/Backup/****************************************/****************************************' > result.html
$ open result.html

おそらくVisibilityがPrivateだとデータが参照出来ないので、 http://www.google.com/fusiontables/Home から上記で生成したテーブルを選択し、"Unlisted"に設定する(他人から普通にアクセスできるようになってしまうので扱いには注意)。

昨年夏に九州に行ったのとか、秋に青森に行ったのとか、先月仙台に帰ったのとか、すべて記録されているのが確認出来た。


https://gist.github.com/934815

追記

sqlite3のデータを読むならDBIモジュールを使いましょう!!
多くの方からご指摘うけました。ありがとうございました。