読者です 読者をやめる 読者になる 読者になる

argius note

プログラミング関連

Firefoxの履歴をHTMLで書き出す簡易ツール (Java)

Firefoxの履歴を書き出すツールをJavaで書いてみました。

経緯

Firefoxの履歴は、URLバーからURLかタイトルをインクリメンタルサーチで検索するか、履歴サイドバーから検索することができます。履歴の検索は良く使っていて、そのために履歴を何年も消さないで取っておくのですが、さすがに何年も蓄積されると少し処理が重くなるような気がします(根拠なし)。

Firefoxの履歴をSQLiteファイルから取り出せることは知っていましたが、これまではDBツールを使ってSQLiteファイルにアクセスして事足りていました。ツールを作るのも面倒だったので…
それが、今回はDBツールでアクセスする方の面倒が勝ってしまいました。

HTMLで書き出せば、テキストファイルなので扱いも容易だし、ブラウザーで開けば直接リンク先に飛ぶのも簡単なので、HTMLで書き出す方式にしました。

環境

新日時APIとStreamを少々使っているので、Java8(以降)です1SQLite JDBCのバージョンも、特にこだわりはありません。極端に古くなければ大丈夫でしょう。

作業は基本的にWindowsで行なっていますが、Macでも動作確認済みです。

作業手順

履歴ファイル(SQLiteファイル)の取り出し

まずは、places.sqliteというファイルを、Firefoxのプロファイルディレクトリーから取り出します。念のため、取り出す際はFirefoxを終了させておきます。

Windowsでは、
%AppData%\Roaming\Mozilla\Firefox\Profiles\*.default\places.sqlite
にあります。
Macでは、
~/Library/Application Support/Firefox/Profiles/*.default/places.sqlite
にあります。
(※*.defaultは環境によって異なる。)

取り出したファイルは、places.sqliteでは分かりにくいので、名前をfirefox-history-20170328.sqliteにします。WindowsではD:\firefox-history\に置きました。Macなら、~/firefox-history/などにすると良いでしょう。

SQLiteデータベースからデータを取り出す

SQLiteデータベースから、URL・タイトル・訪問回数・最終訪問日時を全件分、取り出します。ただし、訪問回数がゼロのレコードは、システム用の特別なレコードなので、無視します。

select
  url, title, visit_count, last_visit
from
  moz_places
where
  visit_count > 0

あとは、これを好きな形式でファイルに書き出すだけです。
最終訪問日時の形式は、UNIX時間 ですが2、単位がマイクロ秒(1/1,000,000秒)ですので、そこだけ注意してください。 .

履歴データをHTML形式で書き出す

HTMLファイルに書き出しておけば、Firefox以外のブラウザーでも参照できます。
GREPもできるようにするために、1件は1行で書き出すようにします。

全件を1ファイルにすると大きすぎるので、今回は年単位にしました。最終訪問日時の「年」をグループのキーにして、ファイルを分割します。
詳しくは、後述のコード例を参照してください。

実装例

コード

  • App.java
import java.io.*;
import java.sql.*;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.Map.Entry;
import java.util.stream.Collectors;

/**
 * Firefoxの履歴をHTMLファイルに出力する。
 * (クラス名はテキトー)
 */
public final class App {

    /**
     * エントリーポイント。
     * @param args 1: SQLiteファイル(*.sqlite)
     */
    public static void main(String[] args) {
        try {
            if (args.length < 1) {
                System.err.println("usage: (cmd) sqlite-file");
                return;
            }
            System.out.println("*****  Firefox履歴書き出しツール  *****");
            String sqlitePath = args[0].replace('\\', '/');
            File sqliteFile = new File(sqlitePath);
            if (!sqliteFile.exists() || !sqliteFile.isFile()) {
                System.err.printf("エラー: 指定したファイルは存在しません。ファイル=[%s]%n", sqliteFile.getAbsolutePath());
                return;
            }
            System.out.println("Firefoxの履歴を書き出しています ...");
            export(extractData(sqlitePath), sqlitePath);
            System.out.println("終了しました。");
        } catch (IllegalArgumentException | FileNotFoundException ex) {
            System.err.println(ex.getMessage());
            System.exit(1);
        } catch (Throwable th) {
            System.err.println("エラー: 予期しない問題が発生");
            th.printStackTrace();
            System.exit(255);
        }
    }

    /**
     * ファイルのエクスポート。
     * @param data 履歴データ(履歴リストをグループ化したもの)
     * @param sqlitePath SQLiteのパス
     * @throws IOException I/Oエラー
     */
    static void export(Map<String, List<Place>> data, String sqlitePath) throws IOException {
        File dir = new File(sqlitePath.replaceFirst(".sqlite$", "-htmls"));
        if (!dir.exists() && !dir.mkdirs() || dir.exists() && !dir.isDirectory()) {
            throw new IllegalArgumentException("error: can't create dir, path=" + dir);
        }
        DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
        for (Entry<String, List<Place>> entry : data.entrySet()) {
            String key = entry.getKey();
            System.out.println("  グループ: " + key);
            String pageTitle = "Firefox 履歴 - グループ [" + key + "]";
            List<Place> sorted = entry.getValue().stream().sorted().collect(Collectors.toList());
            try (PrintWriter out = new PrintWriter(new File(dir, key + ".html"))) {
                out.println("<!DOCTYPE html><html lang=\"jp\">"
                            + "<head>"
                            + "<meta charset=\"utf-8\">"
                            + "<title>"
                            + pageTitle
                            + "</title>"
                            + "</head>"
                            + "<body>"
                            + "<h1>Firefox 履歴</h1>");
                out.printf("<h2>グループ: %s</h2><p>件数: %d</p>", key, sorted.size());
                out.println("<ul>");
                for (Place p : sorted) {
                    String title = p.title;
                    String fixedTitle = (title == null) ? "(タイトルなし)" : title;
                    String lastVisitString = dtf.format(p.lastVisitDT);
                    String url = p.url;
                    out.printf("<li><a href=\"%s\" target=\"_blank\">%s</a><br />[%s] (%d回) %s",
                               url,
                               fixedTitle,
                               lastVisitString,
                               p.visitCount,
                               url);
                    out.println("</li>");
                }
                out.println("</ul>");
                out.println("</body></html>");
            }
        }
    }

    /**
     * SQLiteファイルからデータを抽出する。
     * @param sqlitePath SQLiteファイルのパス
     * @return 履歴データ(履歴リストをグループ化したもの)
     * @throws SQLException SQLエラー
     */
    static Map<String, List<Place>> extractData(String sqlitePath) throws SQLException {
        Map<String, List<Place>> m = new HashMap<>();
        String sql = "select url, title, visit_count, last_visit_date from moz_places"
                     + " where visit_count > 0";
        try (Connection conn = DriverManager.getConnection("jdbc:sqlite:" + sqlitePath);
             Statement stmt = conn.createStatement();
             ResultSet rs = stmt.executeQuery(sql)) {
            while (rs.next()) {
                int index = 0;
                Place p = new Place();
                p.url = rs.getString(++index);
                p.title = rs.getString(++index);
                p.visitCount = rs.getInt(++index);
                p.lastVisit = rs.getLong(++index);
                p.lastVisitDT = prtimeToLocalDateTime(p.lastVisit);
                String key = createGroupKey(p);
                m.putIfAbsent(key, new ArrayList<>());
                m.get(key).add(p);
            }
        }
        return m;
    }

    /**
     * グループキーを生成する。
     * @param p 場所情報
     * @return グループキー
     */
    static String createGroupKey(Place p) {
        // 最終訪問日時の年4桁をキーとする
        return DateTimeFormatter.ofPattern("yyyy").format(p.lastVisitDT);
    }

    /**
     * <code>PRTime(long)</code>を日時に変換する。
     * @param prtime <code>PRTime</code>
     * @return 日時(システムのタイムゾーンのローカル日時)
     */
    static LocalDateTime prtimeToLocalDateTime(long prtime) {
        long sec = prtime / 1000000;
        LocalDateTime ldt = LocalDateTime.ofEpochSecond(sec, 0, ZoneOffset.UTC);
        ZonedDateTime utcZdt = ZonedDateTime.of(ldt, ZoneId.of("UTC"));
        return utcZdt.withZoneSameInstant(ZoneId.systemDefault()).toLocalDateTime();
    }

}

/**
 * 場所情報。
 * URL、タイトル、訪問回数、最終訪問日時(<code>PRTime(long)</code><code>LocalDateTime</code>)を保持。
 */
final class Place implements Comparable<Place> {

    String url;
    String title;
    int visitCount;
    long lastVisit;
    LocalDateTime lastVisitDT;

    @Override
    public int compareTo(Place o) {
        if (o == null) {
            return -1;
        }
        if (lastVisit == o.lastVisit) {
            return Objects.compare(title, o.title, String.CASE_INSENSITIVE_ORDER);
        } else {
            return lastVisit > o.lastVisit ? -1 : 1;
        }
    }

}

実行結果

実行時には、クラスパスにSQLiteJDBCドライバーを設定してください。

実行結果は、以下の通りです。

  • ファイルツリー
D:\FIREFOX-HISTORY
│  firefox-history-20170328.sqlite
│
└─firefox-history-20170328-htmls
        2015.html
        2016.html
        2017.html
  • HTML出力例

f:id:argius:20170328115720p:plain


おわりに

そんなに大変じゃなかったので、もっと早く作っておけば良かったと思いました。

この作業が終わった後で、Firefoxの古い履歴(6ヶ月より前)を削除しました。
幾らか快適になったような気がします。

(おわり)


  1. 今後はおそらくJava8以降だけを扱います。古くてもJava7までです。try-with-resourcesが無いJava6以前のバージョンは使うのツラいです。

  2. これは、厳密にはUNIX時間ではなく、PRTimeという形式で、Netscape ポータブル・ランタイム(NSPR)のエポックからの経過マイクロ秒、という定義になっています。今回は、PRTime/1,000,000≒UNIX時間としています。参考URLのページを参照。