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()