nix

configuration files that power my machines
Log | Files | Refs | README | LICENSE

ffpurge (7220B)


      1 #!/usr/bin/env python3
      2 """
      3 ffpurge: remove cookies and associated site data not used in the last N days
      4 
      5 Usage:
      6   python ffpurge            # dry run
      7   python ffpurge --purge    # delete
      8   python ffpurge --days 14  # threshold (default: 7)
      9 """
     10 
     11 import argparse
     12 import shutil
     13 import sqlite3
     14 import sys
     15 import time
     16 from pathlib import Path
     17 
     18 
     19 def get_profiles() -> list[Path]:
     20     base = Path.home() / ".mozilla/firefox"
     21     if not base.exists():
     22         sys.exit(f"Firefox profiles not found: {base}")
     23     return [p for p in base.iterdir() if p.is_dir()]
     24 
     25 
     26 def get_cache_dirs(profile: Path) -> list[Path]:
     27     cache_root = Path.home() / f".cache/mozilla/firefox/{profile.name}"
     28     return [
     29         cache_root / "cache2",
     30         cache_root / "startupCache",
     31         cache_root / "thumbnails",
     32         profile / "shader-cache",
     33     ]
     34 
     35 
     36 def days_to_us(days: int) -> int:
     37     return int((time.time() - days * 86400) * 1_000_000)
     38 
     39 
     40 def dir_size(path: Path) -> int:
     41     return sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
     42 
     43 
     44 def fmt_size(b: int) -> str:
     45     f = float(b)
     46     for u in ["B", "KB", "MB", "GB"]:
     47         if f < 1024:
     48             return f"{f:.1f} {u}"
     49         f /= 1024
     50     return f"{f:.1f} GB"
     51 
     52 
     53 def query_cookies(profile: Path, threshold: int) -> list[tuple[str, str, int]]:
     54     db = profile / "cookies.sqlite"
     55     if not db.exists():
     56         return []
     57     try:
     58         con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
     59         rows = con.execute(
     60             "SELECT host, name, lastAccessed FROM moz_cookies WHERE lastAccessed < ? ORDER BY host",
     61             (threshold,),
     62         ).fetchall()
     63         con.close()
     64         return rows
     65     except sqlite3.OperationalError as e:
     66         print(f"  cannot read cookies.sqlite: {e}")
     67         return []
     68 
     69 
     70 def delete_cookies(profile: Path, threshold: int) -> int:
     71     db = profile / "cookies.sqlite"
     72     if not db.exists():
     73         return 0
     74     try:
     75         con = sqlite3.connect(db)
     76         n = con.execute(
     77             "DELETE FROM moz_cookies WHERE lastAccessed < ?", (threshold,)
     78         ).rowcount
     79         con.commit()
     80         con.close()
     81         return n
     82     except sqlite3.OperationalError as e:
     83         print(f"  cannot write cookies.sqlite: {e}")
     84         return 0
     85 
     86 
     87 def get_ls_scopes(profile: Path, hosts: set[str]) -> list[str]:
     88     db = profile / "webappsstore.sqlite"
     89     if not db.exists():
     90         return []
     91     try:
     92         con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
     93         scopes = [
     94             r[0]  # type: ignore[unknown-variable-type]
     95             for r in con.execute("SELECT DISTINCT scope FROM webappsstore2").fetchall()
     96         ]
     97         con.close()
     98         result: list[str] = []
     99         for scope in scopes:
    100             rev = scope.split(":")[0].rstrip(".")  # type: ignore[unknown-variable-type]
    101             host = ".".join(reversed(rev.split(".")))  # type: ignore[unknown-variable-type]
    102             if any(h in host or host in h for h in hosts):
    103                 result.append(scope)  # type: ignore[unknown-argument-type]
    104         return result
    105     except sqlite3.OperationalError:
    106         return []
    107 
    108 
    109 def delete_ls(profile: Path, hosts: set[str]) -> int:
    110     db = profile / "webappsstore.sqlite"
    111     scopes = get_ls_scopes(profile, hosts)
    112     if not scopes:
    113         return 0
    114     try:
    115         con = sqlite3.connect(db)
    116         n = sum(
    117             con.execute("DELETE FROM webappsstore2 WHERE scope = ?", (s,)).rowcount
    118             for s in scopes
    119         )
    120         con.commit()
    121         con.close()
    122         return n
    123     except sqlite3.OperationalError as e:
    124         print(f"  cannot write webappsstore.sqlite: {e}")
    125         return 0
    126 
    127 
    128 def get_stale_folders(profile: Path, hosts: set[str]) -> list[Path]:
    129     root = profile / "storage/default"
    130     if not root.exists():
    131         return []
    132     result: list[Path] = []
    133     for folder in root.iterdir():
    134         if not folder.is_dir():
    135             continue
    136         fhost = folder.name.replace("https+++", "").replace("http+++", "").split("+")[0]
    137         if any(h in fhost or fhost in h for h in hosts):
    138             result.append(folder)
    139     return result
    140 
    141 
    142 def main():
    143     ap = argparse.ArgumentParser()
    144     ap.add_argument("--purge", action="store_true")  # type: ignore[unused-call-result]
    145     ap.add_argument("--days", type=int, default=7)  # type: ignore[unused-call-result]
    146     args = ap.parse_args()
    147 
    148     threshold = days_to_us(args.days)  # type: ignore[arg-type, unknown-variable-type]
    149     cutoff = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(threshold / 1_000_000))
    150     purge = args.purge  # type: ignore[attr-defined, unknown-variable-type]
    151     days = args.days  # type: ignore[attr-defined, unknown-variable-type]
    152     print(
    153         f"\nMode: {'PURGE' if purge else 'DRY RUN'}  |  threshold: {days} days (before {cutoff})\n"
    154     )
    155 
    156     profiles = get_profiles()
    157     totals: dict[str, int] = dict(cookies=0, ls=0, storage=0, cache=0)
    158 
    159     for profile in profiles:
    160         print(f"--- Profile: {profile.name} ---")
    161 
    162         # cookies
    163         rows = query_cookies(profile, threshold)
    164         hosts = set(r[0].lstrip(".") for r in rows)
    165         print(f"  cookies      : {len(rows)} ({len(hosts)} hosts)")
    166         for host, name, us in rows:
    167             print(
    168                 f"    {host}  {name}  last={time.strftime('%Y-%m-%d', time.localtime(us / 1e6))}"
    169             )
    170         if purge:
    171             n = delete_cookies(profile, threshold)
    172             print(f"  deleted {n} cookies")
    173         totals["cookies"] += len(rows)
    174 
    175         # localStorage
    176         scopes = get_ls_scopes(profile, hosts)
    177         print(f"  localStorage : {len(scopes)} scope(s)")
    178         for s in scopes:
    179             print(f"    {s}")
    180         if purge and scopes:
    181             n = delete_ls(profile, hosts)
    182             print(f"  deleted {n} localStorage rows")
    183         totals["ls"] += len(scopes)
    184 
    185         # site storage
    186         folders = get_stale_folders(profile, hosts)
    187         storage_bytes = sum(dir_size(f) for f in folders)
    188         print(f"  site storage : {len(folders)} folder(s), {fmt_size(storage_bytes)}")
    189         for f in folders:
    190             print(f"    {f.name}")
    191         if purge:
    192             for f in folders:
    193                 shutil.rmtree(f, ignore_errors=True)
    194             if folders:
    195                 print(f"  deleted {len(folders)} folder(s)")
    196         totals["storage"] += storage_bytes
    197 
    198         # cache
    199         for cache_path in get_cache_dirs(profile):
    200             if not cache_path.exists():
    201                 continue
    202             cache_size = dir_size(cache_path)
    203             print(f"  cache        : {fmt_size(cache_size)}  ({cache_path.name})")
    204             if purge:
    205                 shutil.rmtree(cache_path, ignore_errors=True)
    206                 print(f"  deleted {cache_path.name}")
    207             totals["cache"] += cache_size
    208         print()
    209 
    210     print("--- Summary ---")
    211     action = "deleted" if purge else "would delete"
    212     print(
    213         f"  {action}: {totals['cookies']} cookies, {totals['ls']} localStorage scopes, {fmt_size(totals['storage'])} site storage, {fmt_size(totals['cache'])} cache"
    214     )
    215     if not purge:
    216         print("\n  dry run - use --purge to delete")
    217     else:
    218         print("\n  done")
    219 
    220 
    221 if __name__ == "__main__":
    222     main()