Last active
April 11, 2026 03:09
-
-
Save XueshiQiao/8319f4e4afb61058768e86353e49b4c9 to your computer and use it in GitHub Desktop.
Bulk list & delete all saved tab groups from Chrome on macOS (no extension API available — works by reading/writing Chrome's internal LevelDB)
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 node | |
| /** | |
| * Chrome Saved Tab Groups Manager | |
| * ================================ | |
| * Bulk list and delete all saved tab groups from a Chrome profile on macOS. | |
| * | |
| * Chrome does NOT expose saved tab groups to extensions or DevTools — there is | |
| * no public API for this. This script works by directly reading/writing the | |
| * LevelDB that Chrome Sync uses internally. | |
| * | |
| * | |
| * How it works | |
| * ------------ | |
| * Chrome stores saved tab groups in two places: | |
| * | |
| * 1. Sync Data LevelDB | |
| * ~/Library/Application Support/Google/Chrome/<Profile>/Sync Data/LevelDB | |
| * Keys prefixed with "saved_tab_group-" hold the actual data: | |
| * - saved_tab_group-dt-<uuid> → group definitions (title, color) and | |
| * individual tab entries (URL, page title) | |
| * - saved_tab_group-md-<uuid> → sync metadata per entry | |
| * - saved_tab_group-GlobalMetadata → global sync state | |
| * Values are protobuf-encoded. This script extracts readable strings | |
| * (titles, URLs) via regex without a full protobuf decode. | |
| * | |
| * 2. Preferences JSON | |
| * ~/Library/Application Support/Google/Chrome/<Profile>/Preferences | |
| * The "saved_tab_groups" key holds auxiliary state such as | |
| * closed_remote_group_ids and deleted_group_ids. | |
| * | |
| * "list" mode copies the LevelDB to a temp directory (to avoid lock conflicts | |
| * with a running Chrome) and reads from the copy. | |
| * | |
| * "delete" mode requires Chrome to be fully closed. It deletes all | |
| * saved_tab_group-* keys from the LevelDB in place, and removes the | |
| * saved_tab_groups key from Preferences. A timestamped backup of both is | |
| * created automatically before any modification. | |
| * | |
| * | |
| * Chrome Sync caveat | |
| * ------------------ | |
| * This script only deletes LOCAL data. If Chrome Sync is enabled for saved tab | |
| * groups, Google's sync server still retains a copy. When you re-enable sync | |
| * (or it was never disabled), Chrome will re-download everything from the | |
| * server and the tab groups will reappear. | |
| * | |
| * To handle this, you have two options: | |
| * | |
| * Option A — Keep "Saved Tab Groups" sync turned off (recommended) | |
| * 1. chrome://settings/syncSetup → disable "Saved Tab Groups" | |
| * 2. Close Chrome (Cmd+Q) | |
| * 3. Run: node chrome-clear-saved-tab-groups.mjs delete | |
| * 4. Open Chrome — done. Tab groups stay gone as long as sync is off. | |
| * | |
| * Option B — Nuke Google sync server data, then delete locally | |
| * 1. Open Chrome, go to chrome.google.com/sync | |
| * 2. Scroll to the bottom → click "Clear Data" | |
| * (This wipes ALL Chrome sync data from Google's servers. Local data | |
| * on this machine — bookmarks, passwords, etc. — is untouched and | |
| * will be re-uploaded as the new baseline when sync restarts.) | |
| * 3. Close Chrome (Cmd+Q) | |
| * 4. Run: node chrome-clear-saved-tab-groups.mjs delete | |
| * 5. Open Chrome — sync will re-upload your now-clean local state. | |
| * | |
| * | |
| * Usage | |
| * ----- | |
| * node chrome-clear-saved-tab-groups.mjs list List all saved tab groups | |
| * node chrome-clear-saved-tab-groups.mjs list -v List with full details | |
| * node chrome-clear-saved-tab-groups.mjs delete Delete all saved tab groups | |
| * node chrome-clear-saved-tab-groups.mjs list --profile "Profile 1" | |
| * | |
| * Options: | |
| * --profile <name> Chrome profile (default: "Default") | |
| * Examples: "Default", "Profile 1", "Profile 3" | |
| * -v, --verbose Show UUIDs, individual tabs, and URLs | |
| * | |
| * Prerequisites | |
| * ------------- | |
| * npm install classic-level (run once in the same directory) | |
| * | |
| * Platform: macOS only (Chrome profile path is hardcoded to ~/Library/...). | |
| */ | |
| import { ClassicLevel } from "classic-level"; | |
| import { readFileSync, writeFileSync, copyFileSync, existsSync } from "fs"; | |
| import { join } from "path"; | |
| import { homedir } from "os"; | |
| import { execSync } from "child_process"; | |
| import { cpSync, mkdirSync, rmSync } from "fs"; | |
| // --------------------------------------------------------------------------- | |
| // Config | |
| // --------------------------------------------------------------------------- | |
| const CHROME_BASE = join( | |
| homedir(), | |
| "Library", | |
| "Application Support", | |
| "Google", | |
| "Chrome" | |
| ); | |
| function profileDir(profileName) { | |
| return join(CHROME_BASE, profileName); | |
| } | |
| function syncLevelDBPath(profileName) { | |
| return join(profileDir(profileName), "Sync Data", "LevelDB"); | |
| } | |
| function preferencesPath(profileName) { | |
| return join(profileDir(profileName), "Preferences"); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Helpers | |
| // --------------------------------------------------------------------------- | |
| function isChromeRunning() { | |
| try { | |
| const out = execSync("pgrep -x 'Google Chrome'", { encoding: "utf8" }); | |
| return out.trim().length > 0; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| /** Extract readable ASCII strings from a buffer */ | |
| function extractStrings(buf, minLen = 4) { | |
| const text = buf.toString("utf8", 0, Math.min(buf.length, 5000)); | |
| return (text.match(/[\x20-\x7e]{3,}/g) || []).filter( | |
| (s) => | |
| !s.match(/^[a-f0-9\-]{20,}$/) && | |
| !s.match(/^[\+\/=A-Za-z0-9]{20,}$/) && | |
| !s.startsWith("$") | |
| ); | |
| } | |
| /** Extract URLs from a buffer */ | |
| function extractUrls(buf) { | |
| const text = buf.toString("utf8", 0, Math.min(buf.length, 5000)); | |
| return (text.match(/https?:\/\/[^\x00-\x1f"'\s\\)]{5,}/g) || []); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // List | |
| // --------------------------------------------------------------------------- | |
| async function listSavedTabGroups(profileName) { | |
| const dbPath = syncLevelDBPath(profileName); | |
| if (!existsSync(dbPath)) { | |
| console.error(`LevelDB not found at: ${dbPath}`); | |
| process.exit(1); | |
| } | |
| // Copy the DB so we don't need Chrome to be closed | |
| const tmpDir = join("/tmp", `chrome-stg-list-${Date.now()}`); | |
| mkdirSync(tmpDir, { recursive: true }); | |
| cpSync(dbPath, tmpDir, { recursive: true }); | |
| // Remove lock from copy | |
| const lockFile = join(tmpDir, "LOCK"); | |
| if (existsSync(lockFile)) rmSync(lockFile); | |
| const db = new ClassicLevel(tmpDir, { | |
| createIfMissing: false, | |
| keyEncoding: "buffer", | |
| valueEncoding: "buffer", | |
| }); | |
| await db.open(); | |
| // Collect all saved_tab_group-dt entries | |
| const dtEntries = new Map(); | |
| for await (const [key, value] of db.iterator()) { | |
| const keyStr = key.toString("latin1"); | |
| const m = keyStr.match(/saved_tab_group-dt-([0-9a-f\-]{36})/); | |
| if (m) { | |
| dtEntries.set(m[1], value); | |
| } | |
| } | |
| await db.close(); | |
| // Clean up temp copy | |
| rmSync(tmpDir, { recursive: true, force: true }); | |
| // Classify into groups vs tabs | |
| const groups = []; // entries with no URLs (group definitions) | |
| const tabs = []; // entries with URLs (tab definitions) | |
| for (const [uuid, value] of dtEntries) { | |
| const urls = extractUrls(value); | |
| const strings = extractStrings(value); | |
| if (urls.length > 0) { | |
| // This is a tab entry | |
| // Try to get the page title from strings | |
| const titleCandidates = strings.filter((s) => !s.startsWith("http")); | |
| const pageTitle = titleCandidates.find(s => s.length > 2 && !s.match(/^[\W]{1,3}$/)) || "(untitled)"; | |
| tabs.push({ uuid, urls, title: pageTitle }); | |
| } else { | |
| // This is a group entry | |
| const nonUuidStrings = strings.filter( | |
| (s) => !s.match(/^[0-9a-f]{8}-[0-9a-f]{4}/) | |
| ); | |
| const raw = nonUuidStrings[0] || "(untitled)"; | |
| // Clean leading # or digits that are protobuf artifacts, and trailing quote | |
| const groupName = raw.replace(/^[#\d]+/, "").replace(/["']$/, "").trim() || raw; | |
| groups.push({ uuid, title: groupName }); | |
| } | |
| } | |
| // Display results | |
| console.log(`\nProfile: ${profileName}`); | |
| console.log(`${"=".repeat(60)}`); | |
| console.log(`Saved Tab Groups: ${groups.length}`); | |
| console.log(`Total Tabs: ${tabs.length}`); | |
| console.log(`Total LevelDB entries (dt + md + global): ${dtEntries.size} dt entries`); | |
| console.log(`${"=".repeat(60)}\n`); | |
| // Deduplicate group names and count | |
| const nameCount = {}; | |
| for (const g of groups) { | |
| nameCount[g.title] = (nameCount[g.title] || 0) + 1; | |
| } | |
| const sortedNames = Object.entries(nameCount).sort((a, b) => b[1] - a[1]); | |
| console.log("Groups by name:"); | |
| for (const [name, count] of sortedNames) { | |
| const suffix = count > 1 ? ` (x${count})` : ""; | |
| console.log(` - ${name}${suffix}`); | |
| } | |
| console.log(); | |
| // Show detailed list | |
| if (process.argv.includes("--verbose") || process.argv.includes("-v")) { | |
| console.log("Detailed group list:"); | |
| for (const g of groups) { | |
| console.log(` [${g.uuid}] ${g.title}`); | |
| } | |
| console.log("\nTab list:"); | |
| for (const t of tabs) { | |
| console.log(` [${t.uuid}] ${t.title}`); | |
| for (const u of t.urls) { | |
| console.log(` ${u}`); | |
| } | |
| } | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Delete | |
| // --------------------------------------------------------------------------- | |
| async function deleteSavedTabGroups(profileName) { | |
| // Safety check: Chrome must be closed | |
| if (isChromeRunning()) { | |
| console.error( | |
| "\n*** ERROR: Chrome is still running! ***\n" + | |
| "Please fully quit Chrome (Cmd+Q) before running delete mode.\n" + | |
| "Just closing the window is not enough.\n" | |
| ); | |
| process.exit(1); | |
| } | |
| const dbPath = syncLevelDBPath(profileName); | |
| const prefsPath = preferencesPath(profileName); | |
| if (!existsSync(dbPath)) { | |
| console.error(`LevelDB not found at: ${dbPath}`); | |
| process.exit(1); | |
| } | |
| // ---- Step 1: Backup ---- | |
| const backupSuffix = new Date().toISOString().replace(/[:.]/g, "-"); | |
| const dbBackup = `${dbPath}.backup-${backupSuffix}`; | |
| const prefsBackup = `${prefsPath}.backup-${backupSuffix}`; | |
| console.log("\n--- Step 1: Creating backups ---"); | |
| cpSync(dbPath, dbBackup, { recursive: true }); | |
| console.log(` LevelDB backed up to: ${dbBackup}`); | |
| if (existsSync(prefsPath)) { | |
| copyFileSync(prefsPath, prefsBackup); | |
| console.log(` Preferences backed up to: ${prefsBackup}`); | |
| } | |
| // ---- Step 2: Delete from LevelDB ---- | |
| console.log("\n--- Step 2: Deleting saved tab group entries from LevelDB ---"); | |
| const db = new ClassicLevel(dbPath, { | |
| createIfMissing: false, | |
| keyEncoding: "buffer", | |
| valueEncoding: "buffer", | |
| }); | |
| await db.open(); | |
| let deleteCount = 0; | |
| const keysToDelete = []; | |
| for await (const [key] of db.iterator()) { | |
| const keyStr = key.toString("latin1"); | |
| if (keyStr.includes("saved_tab_group")) { | |
| keysToDelete.push(key); | |
| } | |
| } | |
| const batch = db.batch(); | |
| for (const key of keysToDelete) { | |
| batch.del(key); | |
| deleteCount++; | |
| } | |
| await batch.write(); | |
| await db.close(); | |
| console.log(` Deleted ${deleteCount} LevelDB entries`); | |
| // ---- Step 3: Clean up Preferences JSON ---- | |
| console.log("\n--- Step 3: Cleaning up Preferences ---"); | |
| if (existsSync(prefsPath)) { | |
| const prefs = JSON.parse(readFileSync(prefsPath, "utf8")); | |
| let changed = false; | |
| if (prefs.saved_tab_groups) { | |
| delete prefs.saved_tab_groups; | |
| changed = true; | |
| console.log(" Removed 'saved_tab_groups' from Preferences"); | |
| } | |
| if (prefs.tab_group_saves_ui_update_migrated !== undefined) { | |
| delete prefs.tab_group_saves_ui_update_migrated; | |
| changed = true; | |
| } | |
| if (changed) { | |
| writeFileSync(prefsPath, JSON.stringify(prefs, null, 3), "utf8"); | |
| console.log(" Preferences file updated"); | |
| } else { | |
| console.log(" No saved_tab_groups found in Preferences (already clean)"); | |
| } | |
| } | |
| // ---- Done ---- | |
| console.log("\n--- Done! ---"); | |
| console.log("You can now open Chrome. All saved tab groups should be gone."); | |
| console.log( | |
| "\nNOTE: If Chrome Sync is enabled for saved tab groups, they may re-appear." | |
| ); | |
| console.log("See the comment block at the top of this script for how to handle sync."); | |
| console.log(`\nIf something went wrong, restore from backup:`); | |
| console.log(` rm -rf "${dbPath}"`); | |
| console.log(` mv "${dbBackup}" "${dbPath}"`); | |
| console.log(` cp "${prefsBackup}" "${prefsPath}"`); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Main | |
| // --------------------------------------------------------------------------- | |
| const args = process.argv.slice(2); | |
| const command = args.find((a) => !a.startsWith("-")); | |
| const profileIdx = args.indexOf("--profile"); | |
| const profileName = | |
| profileIdx >= 0 && args[profileIdx + 1] ? args[profileIdx + 1] : "Default"; | |
| if (!command || !["list", "delete"].includes(command)) { | |
| console.log(` | |
| Chrome Saved Tab Groups Manager | |
| Usage: | |
| node chrome-clear-saved-tab-groups.mjs list List all saved tab groups | |
| node chrome-clear-saved-tab-groups.mjs list -v List with full details (UUIDs, URLs) | |
| node chrome-clear-saved-tab-groups.mjs delete Delete all saved tab groups | |
| Options: | |
| --profile <name> Chrome profile to use (default: "Default") | |
| Examples: "Default", "Profile 1", "Profile 3" | |
| -v, --verbose Show detailed output (UUIDs, individual tabs, URLs) | |
| Notes: | |
| - "list" works while Chrome is running (reads a snapshot) | |
| - "delete" requires Chrome to be fully closed (Cmd+Q) | |
| - Backups are created automatically before deletion | |
| - See the comment block at the top of this file for Chrome Sync handling | |
| `); | |
| process.exit(0); | |
| } | |
| if (command === "list") { | |
| await listSavedTabGroups(profileName); | |
| } else if (command === "delete") { | |
| await deleteSavedTabGroups(profileName); | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Quick start