aboutsummaryrefslogtreecommitdiffstats
path: root/bookmarks/bookmarks.py
blob: 36db7b61c2f3c3127dba40a448b9debffde8f475 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
#!/usr/bin/python3
# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.

"""Firefox bookmarks as JSON, grouped by folder in Firefox's own order, each
with its favicon extracted to a cache file and the result of the last link
check.

    bookmarks.py           print the list
    bookmarks.py --links   check every http(s) bookmark, write the results
    bookmarks.py --check   run the self-check

The databases are read from a copy: Firefox holds them in exclusive lock
mode while it runs, so even a read-only open fails, and this only reads. A
write would need Firefox closed and is not attempted.
"""

import concurrent.futures
import hashlib
import json
import os
import shutil
import socket
import sqlite3
import ssl
import sys
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path

FIREFOX = Path("~/.mozilla/firefox").expanduser()
CACHE = Path(os.environ.get("XDG_CACHE_HOME", "~/.cache")).expanduser() / "keystone/bookmarks"
LINKS = CACHE / "links.json"

# Root folders by guid, in the order they are shown. Tags are left out: they
# are not bookmarks, and each one repeats a bookmark found elsewhere.
ROOTS = {"toolbar_____": "Toolbar", "menu________": "Menu",
         "unfiled_____": "Other Bookmarks", "mobile______": "Mobile"}

MAGIC = [(b"\x89PNG", "png"), (b"<svg", "svg"), (b"<?xml", "svg"),
         (b"\x00\x00\x01\x00", "ico"), (b"GIF8", "gif"), (b"\xff\xd8", "jpg"),
         (b"RIFF", "webp")]


def running(prof):
    """Whether a live Firefox holds this profile. Its `lock` symlink points at
    "<ip>:+<pid>" and outlives a crash, so the pid is checked too."""
    try:
        pid = os.readlink(prof / "lock").rpartition("+")[2]
        return "firefox" in Path(f"/proc/{pid}/comm").read_text()
    except OSError:
        return False


def profile():
    """BOOKMARKS_PROFILE, else the profile a running Firefox holds, else the
    one with the largest places.sqlite. Not installs.ini's Default, which
    names whichever profile the last plain launch picked, here the near-empty
    guest one; nor the last-modified places.sqlite, which a stray launch of
    that guest profile makes newest."""
    if os.environ.get("BOOKMARKS_PROFILE"):
        return Path(os.environ["BOOKMARKS_PROFILE"]).expanduser()
    places = sorted(FIREFOX.glob("*/places.sqlite"), key=lambda p: p.stat().st_size)
    if not places:
        sys.exit("no Firefox profile with a places.sqlite")
    live = [p for p in places if running(p.parent)]
    # ponytail: two profiles running at once, the bigger wins; a toggle in
    # the overlay if both are ever wanted.
    return (live or places)[-1].parent


_copies = tempfile.TemporaryDirectory()


def connect(path):
    """A copy of the database with its write-ahead log. Firefox writes every
    change to the -wal first and folds it in at a checkpoint, maybe minutes
    later. `immutable=1` on the original skipped the log, so a bookmark
    deleted in Firefox stayed listed until then. Copied into a directory of
    this process's own, so the list and a link check never share one."""
    # ponytail: a copy taken mid-checkpoint could be torn; a reopen fixes it.
    dst = Path(_copies.name) / path.name
    for suffix in ("", "-wal"):
        if Path(f"{path}{suffix}").exists():
            shutil.copyfile(f"{path}{suffix}", f"{dst}{suffix}")
    return sqlite3.connect(dst)


def tree(rows):
    """rows: (id, parent, type, title, position, guid, url, added, visits).
    Returns [(folder path, [(title, url, added, visits)])], one group per folder holding bookmarks:
    the folder's own bookmarks first, then its subfolders, each in Firefox's
    order. A folder's bookmarks and subfolders interleave in Firefox; grouping
    them is what makes the list readable."""
    kids = {}
    for r in sorted(rows, key=lambda r: r[4]):
        kids.setdefault(r[1], []).append(r)
    groups = []

    def walk(fid, path):
        links = [(r[3] or r[6], r[6], r[7], r[8]) for r in kids.get(fid, []) if r[2] == 1 and r[6]]
        if links:
            groups.append((" / ".join(path), links))
        for r in kids.get(fid, []):
            if r[2] == 2:
                walk(r[0], path + [r[3] or "(untitled)"])

    by_guid = {r[5]: r for r in rows}
    for guid, name in ROOTS.items():
        if guid in by_guid:
            walk(by_guid[guid][0], [name])
    return groups


def pick_icon(icons):
    """icons: [(width, data)]. The smallest at least 32px wide, else the
    largest. An SVG has width 65535, so it wins only when nothing is 32-64."""
    big = [i for i in icons if i[0] >= 32]
    return min(big)[1] if big else max(icons)[1]


def icon_file(data):
    """Write data to the cache under its hash, return the path, or "" for a
    format Qt cannot show. Content-addressed, so a changed icon is a new file
    and nothing needs invalidating."""
    ext = next((e for m, e in MAGIC if data.startswith(m)), None)
    if not ext:
        return ""
    path = CACHE / "icons" / f"{hashlib.sha1(data).hexdigest()}.{ext}"
    if not path.exists():
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_bytes(data)
    return str(path)


def icons(prof, urls):
    """{url: icon path}: the page's own icon, else any icon Firefox holds for
    the same host, from another page or the site's root /favicon.ico (stored
    once per site, linked to no page). The host fallback, scheme ignored,
    found 52 more of 504 bookmarks; the rest have no icon in Firefox at all,
    which expires them."""
    db = connect(prof / "favicons.sqlite")
    by_page, by_host = {}, {}
    for page, width, data in db.execute(
            "select p.page_url, i.width, i.data from moz_pages_w_icons p "
            "join moz_icons_to_pages ip on ip.page_id = p.id "
            "join moz_icons i on i.id = ip.icon_id where i.data is not null"):
        by_page.setdefault(page, []).append((width, data))
        by_host.setdefault(host(page), []).append((width, data))
    for url, width, data in db.execute(
            "select icon_url, width, data from moz_icons where root = 1 and data is not null"):
        by_host.setdefault(host(url), []).append((width, data))
    db.close()
    out = {}
    for u in urls:
        found = by_page.get(u) or by_host.get(host(u))
        if found:
            out[u] = icon_file(pick_icon(found))
    return out


def host(url):
    return url.partition("://")[2].split("/", 1)[0]


def main():
    prof = profile()
    db = connect(prof / "places.sqlite")
    rows = db.execute(
        "select b.id, b.parent, b.type, b.title, b.position, b.guid, p.url, "
        "b.dateAdded / 1000000, coalesce(p.visit_count, 0) "
        "from moz_bookmarks b left join moz_places p on p.id = b.fk").fetchall()
    db.close()
    groups = tree(rows)
    found = icons(prof, [e[1] for _, entries in groups for e in entries])
    links = json.loads(LINKS.read_text()) if LINKS.exists() else {}
    items = [{"folder": folder, "title": title, "url": url,
              "added": added, "visits": visits,
              "icon": found.get(url, ""),
              "state": links.get(url, {}).get("state", ""),
              "reason": links.get(url, {}).get("reason", "")}
             for folder, entries in groups for title, url, added, visits in entries]
    checked = time.strftime("%Y-%m-%d %H:%M", time.localtime(LINKS.stat().st_mtime)) \
        if LINKS.exists() else ""
    json.dump({"checked": checked, "items": items}, sys.stdout)


def classify(error=None, code=None):
    """(state, reason) for one request: "ok", "dead" for a page or host that
    is gone, "warn" for anything that may only be refusing a script.
    A 403 or 429 is usually a bot wall and a timeout may be a slow site, so
    neither is called dead."""
    if code is not None:
        if code < 400:
            return "ok", ""
        return ("dead" if code in (404, 410) else "warn"), f"HTTP {code}"
    reason = getattr(error, "reason", error)
    if isinstance(reason, socket.gaierror):
        # Only "name unknown" is an answer; EAI_AGAIN is the resolver
        # failing, which says nothing about the site.
        if reason.errno in (socket.EAI_NONAME, getattr(socket, "EAI_NODATA", None)):
            return "dead", "no such host"
        return "warn", "DNS failure"
    if isinstance(reason, ConnectionRefusedError):
        return "dead", "connection refused"
    if isinstance(reason, (TimeoutError, socket.timeout)):
        return "warn", "timeout"
    if isinstance(reason, ssl.SSLError):
        return "warn", "TLS error"
    return "warn", (getattr(reason, "strerror", None) or str(reason))[:60]


def probe(url):
    """HEAD first; any HEAD error gets a GET, body unread, so a server that
    mishandles HEAD is not called dead for it."""
    for method in ("HEAD", "GET"):
        req = urllib.request.Request(url, method=method, headers={
            "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0"})
        try:
            with urllib.request.urlopen(req, timeout=15) as r:
                return classify(code=r.status)
        except urllib.error.HTTPError as e:
            if method == "HEAD":
                continue
            return classify(code=e.code)
        except Exception as e:
            return classify(error=e)


def check_links():
    db = connect(profile() / "places.sqlite")
    urls = sorted({u for (u,) in db.execute(
        "select p.url from moz_bookmarks b join moz_places p on p.id = b.fk "
        "where b.type = 1 and (p.url like 'http://%' or p.url like 'https://%')")})
    db.close()
    # ponytail: 32 at once, one pass, no retry; a site down for a minute reads
    # dead until the next check.
    with concurrent.futures.ThreadPoolExecutor(32) as pool:
        results = dict(zip(urls, pool.map(probe, urls)))
    LINKS.parent.mkdir(parents=True, exist_ok=True)
    tmp = LINKS.with_suffix(".tmp")
    tmp.write_text(json.dumps({u: {"state": s, "reason": r} for u, (s, r) in results.items()}))
    tmp.replace(LINKS)
    dead = sum(s == "dead" for s, _ in results.values())
    print(f"{len(urls)} checked, {dead} dead")


def check():
    rows = [(1, 0, 2, "", 0, "root________", None, 0, 0),
            (2, 1, 2, "menu", 0, "menu________", None, 0, 0),
            (3, 1, 2, "toolbar", 1, "toolbar_____", None, 0, 0),
            (4, 1, 2, "tags", 2, "tags________", None, 0, 0),
            (10, 3, 1, "B", 1, "b", "https://b.example.org/", 0, 0),
            (11, 3, 2, "Dev", 0, "dev", None, 0, 0),
            (12, 11, 1, "", 0, "c", "https://c.example.org/x", 0, 0),
            (13, 3, 1, "A", 2, "a", "https://a.example.org/", 0, 0),
            (14, 3, 3, "", 3, "sep", None, 0, 0),
            (15, 4, 1, "tagged", 0, "t", "https://t.example.org/", 0, 0),
            (16, 2, 2, "Empty", 0, "e", None, 0, 0)]
    assert tree(rows) == [
        ("Toolbar", [("B", "https://b.example.org/", 0, 0), ("A", "https://a.example.org/", 0, 0)]),
        ("Toolbar / Dev", [("https://c.example.org/x", "https://c.example.org/x", 0, 0)]),
    ], tree(rows)

    assert pick_icon([(16, b"s"), (32, b"m"), (64, b"l"), (65535, b"v")]) == b"m"
    assert pick_icon([(16, b"s"), (24, b"m")]) == b"m"
    assert host("https://a.example.org/x/y?z") == "a.example.org"

    assert classify(code=200) == ("ok", "")
    assert classify(code=404) == ("dead", "HTTP 404")
    assert classify(code=403) == ("warn", "HTTP 403")
    assert classify(error=urllib.error.URLError(socket.gaierror(-2, "x"))) == ("dead", "no such host")
    assert classify(error=urllib.error.URLError(socket.gaierror(socket.EAI_AGAIN, "x"))) == ("warn", "DNS failure")
    assert classify(error=urllib.error.URLError(ConnectionRefusedError())) == ("dead", "connection refused")
    assert classify(error=TimeoutError()) == ("warn", "timeout")
    assert classify(error=urllib.error.URLError(OSError(113, "No route to host"))) == ("warn", "No route to host")
    print("ok")


if __name__ == "__main__":
    if "--check" in sys.argv:
        check()
    elif "--links" in sys.argv:
        check_links()
    else:
        main()