Firefoxのブックマークを外部から操作する

# はじめに
Firefoxのブックマーク情報は、ブラウザ上で参照・編集する以外に、外部からプログラムを使用して参照・編集することもできます。
ここでは、
- Firefoxにおけるブックマーク情報管理の仕組み
- Pythonを使用してブックマーク情報の参照・編集を行う例

を紹介します。

# ブックマーク情報が記録されているファイル
Firefoxのブックマーク情報は、SQLite3を使用してRDBMSに保存されています。
SQLite3ではデータを単一のファイルに保存しているため、通常のファイル操作と同じような感覚でデータへのアクセスを行うことが出来ます。
データが保存されているファイルのパスは次の通りです。
`C:\Users\（ユーザー名）\AppData\Roaming\Mozilla\Firefox\Profiles\（8桁の英数字）.default-release\places.sqlite`

# ブックマーク情報のテーブル定義
`places.sqlite`には18個のテーブルが定義されていますが、ここではブックマーク情報に直接関係のある部分についてのみ説明します。
（したがって、テーブル定義には存在するものの、下表には記載のないカラムがあります。）

## moz_places
`moz_places`テーブルには、アクセスしたページの履歴情報が記録されています。
テーブル内の各カラムの意味は次のようになっています。
| カラム名 | 説明 |
|:-|:-|
| id | 履歴情報ID（主キー） |
| url | ページのURL |
| title | ページまたはフォルダの名前（ブラウザ上で表示される名前） |

## moz_bookmarks 
`moz_bookmarks`テーブルには、ブックマークしたページおよびブックマーク内のフォルダについての情報が記録されています。
テーブル内の各カラムの意味は次のようになっています。
| カラム名 | 説明 |
|:-|:-|
| id | ブックマークID（主キー） |
| type | ページのブックマークの場合1、フォルダの場合2 |
| fk | moz_placesテーブルのidカラムの値（ブックマークの場合はNULL） |
| parent | ページまたはフォルダが属するフォルダのid |
| position | フォルダ内の位置（上から順に0, 1, 2, ...） |
| title | ページまたはフォルダの名前（ブラウザ上で表示される名前） |

したがって、この2つのテーブルの情報を、`moz_bookmarks.fk = moz_places.id`を結合条件として結合することで、ブックマークしたページの名前とURLを取得することが出来ます。

# ブックマーク情報・フォルダ情報の読み出し
以上の説明に基づき、Firefoxのブックマーク情報を読み出す処理のPythonでの実装例を紹介します。
なお、今回の各処理では、`places.sqlite`はPythonスクリプトと同じディレクトリに配置して実行するものとしています。

:::note
Python のバージョンは3.12.8を使用しています。
使用しているライブラリはすべて標準ライブラリのため、pip等でのインストールは不要です。
:::

## ブックマークのフォルダ情報読み出し
ブックマークのフォルダ情報を読み出し、データクラスBookmarkFolderに格納、その情報を表示するまでの例を示します。
```python:show_folder.py
import sqlite3
from contextlib import closing
from dataclasses import dataclass
from pathlib import Path


@dataclass
class BookmarkFolder:
    id: int
    title: str
    parent: int
    position: int


def get_folders() -> list[BookmarkFolder]:
    db_file_path = Path(__file__).with_name("places.sqlite")  # DBファイルのパス
    bookmark_folders: list[BookmarkFolder] = list()  # ブックマークフォルダ一覧

    # DBファイルを開く
    # contextlib.closing() を使うことで、DBへのアクセスが終わったら
    # 自動的にconn.close() を呼び出すようにすることができる。
    with closing(sqlite3.connect(db_file_path)) as conn:
        cursor = conn.cursor()

        # ブックマークフォルダ一覧を取得
        cursor.execute(
            """SELECT id, title, parent, position """
            """FROM moz_bookmarks """
            """WHERE type IS 2"""
        )

        # 取得したブックマーク情報からBookmarkFolderクラスのインスタンスを作成し
        # 一覧リストに追加する
        for row in cursor.fetchall():
            bookmark_folders.append(BookmarkFolder(*row))

    # 結果を返却する
    return bookmark_folders


if __name__ == "__main__":
    bookmark_folders = get_folders()
    for bookmark_folder in bookmark_folders:
        print(bookmark_folder)

```

## ブックマークページの情報読み出し
ブックマークフォルダの情報読み出しと同様に、ブックマークページの情報を表示する例を示します。
```python:show_bookmarks.py
import sqlite3
from contextlib import closing
from dataclasses import dataclass
from pathlib import Path


@dataclass
class Bookmark:
    id: int
    title: str
    url: str
    parent: int
    position: int


def get_bookmarks() -> list[Bookmark]:
    db_file_path = Path(__file__).with_name("places.sqlite")  # DBファイルのパス
    bookmarks: list[Bookmark] = list()  # ブックマーク一覧

    # DBファイルを開く
    # contextlib.closing() を使うことで、DBへのアクセスが終わったら
    # 自動的にconn.close() を呼び出すようにすることができる。
    with closing(sqlite3.connect(db_file_path)) as conn:
        cursor = conn.cursor()

        # ブックマーク一覧を取得
        cursor.execute(
            """SELECT moz_bookmarks.id, moz_bookmarks.title, moz_places.url, """
            """moz_bookmarks.parent, moz_bookmarks.position """
            """FROM moz_bookmarks """
            """INNER JOIN moz_places ON moz_bookmarks.fk = moz_places.id"""
        )

        # 取得したブックマーク情報からBookmarkクラスのインスタンスを作成し
        # 一覧リストに追加する
        for row in cursor.fetchall():
            bookmarks.append(Bookmark(*row))

    # 結果を返却する
    return bookmarks


if __name__ == "__main__":
    bookmarks = get_bookmarks()
    for bookmark in bookmarks:
        print(bookmark)

```

# ブックマーク情報の編集
上記で作成したブックマーク情報の参照機能を応用することで、ブックマーク情報の編集を行うこともできます。
例として、ブックマークしているページのドメインが変更されてしまい、ブックマークのURLを変更しなければならない状況を考えます。
このような場合、ブラウザ上の操作で修正を行おうとすると、ブックマークを1つ1つ手作業で修正する必要があり、非常に面倒です。
そのため、Pythonスクリプトで一括修正を行います。

:::note warn
ブックマーク情報を編集する際は、URL指定ミス等により処理に失敗した場合に備えて、`places.sqlite`をバックアップしておくことをお勧めします。
バックアップを行わないと、最悪の場合破損したブックマーク情報の修復が困難となる可能性もあります。
以下で紹介する例でも、バックアップを行っていますので、自分で機能を作成する際の参考にしてください。
:::

```python:edit_bookmark.py
import re
import shutil
import sqlite3
from contextlib import closing
from dataclasses import dataclass
from pathlib import Path

# データクラスおよびget_bookmarks() の定義は前節の通りのため記載割愛

def edit_bookmarks(url_pattern: str, url_replacement: str):
    db_file_path = Path(__file__).with_name("places.sqlite")  # DBファイルのパス
    backup_path = db_file_path.with_name("places.sqlite.bak")  # バックアップのパス

    # 破損時に備えてDBファイルをバックアップしておく
    shutil.copy(db_file_path, backup_path)

    # ブックマーク一覧を取得する
    bookmarks = get_bookmarks()

    # DBファイルを開く
    # contextlib.closing() を使うことで、DBへのアクセスが終わったら
    # 自動的にconn.close() を呼び出すようにすることができる。
    with closing(sqlite3.connect(db_file_path)) as conn:
        cursor = conn.cursor()

        # ブックマークの内容を編集する
        for bookmark in bookmarks:
            # URLを置換する
            fixed_url = re.sub(url_pattern, url_replacement, bookmark.url)
            if bookmark.url != fixed_url:
                # 置換によりURLが変更された場合は登録する
                cursor.execute(
                    "UPDATE moz_places SET url = ? WHERE url = ?",
                    (fixed_url, bookmark.url),
                )
                print(f'(id:{bookmark.id}) "{bookmark.url}" -> "{fixed_url}"')

        conn.commit()


if __name__ == "__main__":
    url_pattern = r"https://foo.com/"  # 変更前のドメイン
    url_replacement = r"https://bar.com/"  # 変更後のドメイン
    edit_bookmarks(url_pattern, url_replacement)

```

# まとめ
Firefoxのブックマーク情報を外部から操作する際に調べた内容をまとめました。

今回は調査を行いませんでしたが、`places.sqlite`に保存されている他の情報も活用することで、より高度な編集処理も行えるようになるはずです。
例えば、Firefoxに搭載されているタグ機能を絡めた編集機能や、URLのドメインにより自動的にブックマークフォルダを分類する、といった機能が考えられます。

今回紹介した内容が皆様の開発の参考となれば幸いです。