Created
March 20, 2026 01:44
-
-
Save KorigamiK/5fa609a605e53531242eba7961da8d93 to your computer and use it in GitHub Desktop.
Extract the files from a firefox profile
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """Extract tab details from a Firefox/Zen profile or sessionstore file.""" | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import struct | |
| import sys | |
| from datetime import datetime | |
| from pathlib import Path | |
| from typing import Any | |
| MAGIC = b"mozLz40\0" | |
| SESSION_CHOICES = { | |
| "recovery": "sessionstore-backups/recovery.jsonlz4", | |
| "sessionstore": "sessionstore.jsonlz4", | |
| "recovery-bak": "sessionstore-backups/recovery.baklz4", | |
| "previous": "sessionstore-backups/previous.jsonlz4", | |
| } | |
| def decompress_mozlz4(payload: bytes) -> bytes: | |
| if not payload.startswith(MAGIC): | |
| raise ValueError("Not a Mozilla jsonlz4 file") | |
| expected_size = struct.unpack_from("<I", payload, len(MAGIC))[0] | |
| data = payload[len(MAGIC) + 4 :] | |
| out = bytearray() | |
| i = 0 | |
| n = len(data) | |
| while i < n: | |
| token = data[i] | |
| i += 1 | |
| literal_length = token >> 4 | |
| if literal_length == 15: | |
| while True: | |
| extra = data[i] | |
| i += 1 | |
| literal_length += extra | |
| if extra != 255: | |
| break | |
| out.extend(data[i : i + literal_length]) | |
| i += literal_length | |
| if i >= n: | |
| break | |
| offset = data[i] | (data[i + 1] << 8) | |
| i += 2 | |
| if offset <= 0 or offset > len(out): | |
| raise ValueError(f"Invalid LZ4 offset: {offset}") | |
| match_length = (token & 0x0F) + 4 | |
| if (token & 0x0F) == 15: | |
| while True: | |
| extra = data[i] | |
| i += 1 | |
| match_length += extra | |
| if extra != 255: | |
| break | |
| start = len(out) - offset | |
| while match_length > 0: | |
| chunk = out[start : start + min(match_length, offset)] | |
| if not chunk: | |
| raise ValueError("Empty overlap chunk while decompressing") | |
| out.extend(chunk) | |
| match_length -= len(chunk) | |
| if len(out) != expected_size: | |
| raise ValueError( | |
| f"Decompressed size {len(out)} does not match expected {expected_size}" | |
| ) | |
| return bytes(out) | |
| def load_session(path: Path) -> dict[str, Any]: | |
| raw = path.read_bytes() | |
| if path.suffix == ".json": | |
| return json.loads(raw.decode("utf-8")) | |
| return json.loads(decompress_mozlz4(raw).decode("utf-8")) | |
| def resolve_session_file(path: Path, session_name: str) -> Path: | |
| if path.is_file(): | |
| return path | |
| if not path.is_dir(): | |
| raise FileNotFoundError(f"No such file or directory: {path}") | |
| if session_name != "auto": | |
| candidate = path / SESSION_CHOICES[session_name] | |
| if candidate.exists(): | |
| return candidate | |
| raise FileNotFoundError(f"Could not find {candidate}") | |
| candidates = [path / relative for relative in SESSION_CHOICES.values()] | |
| candidates.extend(sorted(path.glob("sessionstore-backups/upgrade.jsonlz4-*"))) | |
| existing = [candidate for candidate in candidates if candidate.exists()] | |
| if not existing: | |
| raise FileNotFoundError( | |
| f"No Firefox session files found under profile directory: {path}" | |
| ) | |
| return max(existing, key=lambda candidate: candidate.stat().st_mtime) | |
| def current_entry(tab: dict[str, Any]) -> dict[str, Any]: | |
| entries = tab.get("entries") or [] | |
| if not entries: | |
| return {} | |
| index = (tab.get("index") or 1) - 1 | |
| if 0 <= index < len(entries): | |
| return entries[index] | |
| return entries[-1] | |
| def iso_time(unix_ms: Any) -> str | None: | |
| if not unix_ms: | |
| return None | |
| return datetime.fromtimestamp(unix_ms / 1000).astimezone().isoformat() | |
| def extract_tab( | |
| tab: dict[str, Any], tab_index: int, selected_index: int, include_history: bool | |
| ) -> dict[str, Any]: | |
| entry = current_entry(tab) | |
| ext_data = tab.get("extData") or {} | |
| storage = tab.get("storage") or {} | |
| item = { | |
| "tab_index": tab_index, | |
| "active": tab_index == selected_index, | |
| "title": entry.get("title"), | |
| "url": entry.get("url"), | |
| "original_uri": entry.get("originalURI"), | |
| "pinned": bool(tab.get("pinned")), | |
| "hidden": bool(tab.get("hidden")), | |
| "user_context_id": tab.get("userContextId"), | |
| "last_accessed_unix_ms": tab.get("lastAccessed"), | |
| "last_accessed_local": iso_time(tab.get("lastAccessed")), | |
| "history_length": len(tab.get("entries") or []), | |
| "workspace_id": tab.get("zenWorkspace"), | |
| "extension_group_id": ext_data.get( | |
| "extension:{dcdaadfa-21f1-4853-9b34-aad681fff6f3}:groupId" | |
| ), | |
| "has_formdata": "formdata" in tab, | |
| "has_storage": "storage" in tab, | |
| "storage_origins": sorted(storage.keys()), | |
| "attributes": tab.get("attributes") or {}, | |
| "ext_data": ext_data, | |
| } | |
| if include_history: | |
| item["history"] = [ | |
| { | |
| "index": idx, | |
| "title": entry_item.get("title"), | |
| "url": entry_item.get("url"), | |
| "original_uri": entry_item.get("originalURI"), | |
| } | |
| for idx, entry_item in enumerate(tab.get("entries") or [], start=1) | |
| ] | |
| return item | |
| def build_report( | |
| source_path: Path, session_file: Path, include_history: bool | |
| ) -> dict[str, Any]: | |
| session = load_session(session_file) | |
| windows = session.get("windows") or [] | |
| report: dict[str, Any] = { | |
| "source_path": str(source_path), | |
| "session_file": str(session_file), | |
| "session_file_mtime_local": datetime.fromtimestamp( | |
| session_file.stat().st_mtime | |
| ).astimezone().isoformat(), | |
| "window_count": len(windows), | |
| "selected_window": session.get("selectedWindow"), | |
| "session": session.get("session") or {}, | |
| "tabs_total": 0, | |
| "windows": [], | |
| } | |
| for window_index, window in enumerate(windows, start=1): | |
| tabs = window.get("tabs") or [] | |
| selected_index = window.get("selected") or 1 | |
| window_item = { | |
| "window_index": window_index, | |
| "selected_tab_index": selected_index, | |
| "tab_count": len(tabs), | |
| "pinned_count": sum(1 for tab in tabs if tab.get("pinned")), | |
| "hidden_count": sum(1 for tab in tabs if tab.get("hidden")), | |
| "tabs": [ | |
| extract_tab(tab, idx, selected_index, include_history) | |
| for idx, tab in enumerate(tabs, start=1) | |
| ], | |
| } | |
| report["tabs_total"] += len(tabs) | |
| report["windows"].append(window_item) | |
| return report | |
| def print_summary(report: dict[str, Any]) -> None: | |
| print(f"Source: {report['source_path']}") | |
| print(f"Session file: {report['session_file']}") | |
| print( | |
| f"Windows: {report['window_count']} | Tabs: {report['tabs_total']} | " | |
| f"Saved: {report['session_file_mtime_local']}" | |
| ) | |
| for window in report["windows"]: | |
| print( | |
| f"\nWindow {window['window_index']}: " | |
| f"{window['tab_count']} tabs, " | |
| f"{window['pinned_count']} pinned, " | |
| f"{window['hidden_count']} hidden" | |
| ) | |
| for tab in window["tabs"]: | |
| flags = [] | |
| if tab["active"]: | |
| flags.append("ACTIVE") | |
| if tab["pinned"]: | |
| flags.append("PINNED") | |
| if tab["hidden"]: | |
| flags.append("HIDDEN") | |
| suffix = f" [{' | '.join(flags)}]" if flags else "" | |
| print(f"{tab['tab_index']}. {tab['title'] or '(no title)'}{suffix}") | |
| print(f" URL: {tab['url'] or '(no url)'}") | |
| print( | |
| " Last accessed: " | |
| f"{tab['last_accessed_local'] or 'unknown'} | " | |
| f"Workspace: {tab['workspace_id'] or '-'}" | |
| ) | |
| def parse_args() -> argparse.Namespace: | |
| parser = argparse.ArgumentParser( | |
| description=( | |
| "Extract tab details from a Firefox or Zen profile directory, or from " | |
| "a direct sessionstore*.jsonlz4 file." | |
| ) | |
| ) | |
| parser.add_argument( | |
| "path", | |
| help=( | |
| "Profile directory such as ~/.mozilla/firefox/xxxx.default-release or " | |
| "~/.zen/xxxx, or a direct sessionstore*.jsonlz4 file." | |
| ), | |
| ) | |
| parser.add_argument( | |
| "--session-file", | |
| choices=["auto", *SESSION_CHOICES], | |
| default="auto", | |
| help="Which session file to use when you pass a profile directory.", | |
| ) | |
| parser.add_argument( | |
| "--include-history", | |
| action="store_true", | |
| help="Include simplified navigation history for each tab.", | |
| ) | |
| parser.add_argument( | |
| "--summary", | |
| action="store_true", | |
| help="Print a readable summary instead of full JSON.", | |
| ) | |
| return parser.parse_args() | |
| def main() -> int: | |
| args = parse_args() | |
| source_path = Path(args.path).expanduser() | |
| try: | |
| session_file = resolve_session_file(source_path, args.session_file) | |
| report = build_report(source_path, session_file, args.include_history) | |
| except Exception as exc: | |
| print(f"error: {exc}", file=sys.stderr) | |
| return 1 | |
| if args.summary: | |
| print_summary(report) | |
| else: | |
| print(json.dumps(report, indent=2, ensure_ascii=False)) | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment