Last active
January 9, 2026 05:58
-
-
Save bluwy/90d3fe350158d83f315c2bb50921616f to your computer and use it in GitHub Desktop.
Script to clear Safari history older than N days (and deletes other unnecessary data)
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
| import fs from 'node:fs' | |
| import path from 'node:path' | |
| import { DatabaseSync } from 'node:sqlite' | |
| /* | |
| How to run? | |
| 1. Copy from ~/Library/Safari/History.db to current directory | |
| 2. Run: node clear-safari-history.js (Node.js v24 required) | |
| 3. Paste the updated History.db back to ~/Library/Safari/History.db (backup the original file if needed) | |
| Adjust the const variables below as needed. | |
| */ | |
| const historyDbPath = path.resolve('./History.db') | |
| const deleteItemsOlderThanDays = 90 | |
| const deleteTombstone = true | |
| const deleteAllTags = false | |
| if (!fs.existsSync(historyDbPath)) { | |
| console.error('Safari history database not found.') | |
| process.exit(1) | |
| } | |
| /* | |
| Notable Database Structure: | |
| - `history_visits`: The history record. | |
| - `visit_time`: Timestamp of the visit (in seconds since 2001-01-01) | |
| - `history_items`: Metadata of each history url entry | |
| - `history_items_to_tags`: Tags associated with history items | |
| - `history_tags`: The available tags | |
| */ | |
| /* | |
| Steps: | |
| 1. Query and delete `history_visits` for entries older than the cutoff date. | |
| 2. For each deleted visits' `history_item`, use as foreign key to query and delete from `history_items` `id`. | |
| 3. For each deleted history items' `id`, use as foreign key to query and delete from `history_items_to_tags` `history_item`. | |
| 4. For each deleted tag associations' `tag_id`, use as foreign key to query from `history_tags` `id`, and reduce the `item_count` by 1. | |
| */ | |
| const db = new DatabaseSync(historyDbPath) | |
| // debugDatabase(db) | |
| // process.exit(0) | |
| const currentMacEpochTime = getCurrentMacEpochTime() | |
| const cutoffMacEpochTime = currentMacEpochTime - deleteItemsOlderThanDays * 24 * 60 * 60 | |
| const result = db | |
| .prepare('DELETE FROM history_visits WHERE visit_time < ? RETURNING history_item') | |
| .all(cutoffMacEpochTime) | |
| console.log( | |
| `Deleted ${result.length} history_visits entries older than ${deleteItemsOlderThanDays} days.` | |
| ) | |
| const deletedHistoryIds = unique(result.map((row) => row.history_item)) | |
| if (deletedHistoryIds.length > 0) { | |
| const result = db | |
| .prepare( | |
| `DELETE FROM history_items WHERE id IN (${deletedHistoryIds.map(() => '?').join(',')})` | |
| ) | |
| .run(...deletedHistoryIds) | |
| console.log(`Deleted ${result.changes} history_items entries.`) | |
| } | |
| if (deleteAllTags) { | |
| const result = db.prepare('DELETE FROM history_tags').run() | |
| console.log(`Deleted ${result.changes} history_tags entries.`) | |
| const result2 = db.prepare('DELETE FROM history_items_to_tags').run() | |
| console.log(`Deleted ${result2.changes} history_items_to_tags entries.`) | |
| } else if (deletedHistoryIds.length > 0) { | |
| const result2 = db | |
| .prepare( | |
| `DELETE FROM history_items_to_tags WHERE history_item IN (${deletedHistoryIds | |
| .map(() => '?') | |
| .join(',')}) RETURNING tag_id` | |
| ) | |
| .all(...deletedHistoryIds) | |
| console.log(`Deleted ${result2.length} history_items_to_tags entries.`) | |
| const tagIdToNum = {} | |
| for (const row of result2) { | |
| if (!(row.tag_id in tagIdToNum)) { | |
| tagIdToNum[row.tag_id] = 0 | |
| } | |
| tagIdToNum[row.tag_id] += 1 | |
| } | |
| for (const [tagId, num] of Object.entries(tagIdToNum)) { | |
| db.prepare('UPDATE history_tags SET item_count = MAX(item_count - ?, 0) WHERE id = ?').run( | |
| num, | |
| tagId | |
| ) | |
| console.log(`Updated history_tags id=${tagId}, decreased item_count by ${num}.`) | |
| } | |
| // Empty all soft delete data | |
| if (deleteTombstone) { | |
| const result = db.prepare('DELETE FROM history_tombstones').run() | |
| console.log(`Deleted ${result.changes} history_tombstones entries.`) | |
| } | |
| // Vacuum the database to reclaim space | |
| db.prepare('VACUUM').run() | |
| console.log('Database vacuumed to reclaim space.') | |
| console.log('Safari history cleanup completed.') | |
| } | |
| /** | |
| * @param {number} time | |
| */ | |
| function getDateFromMacEpochTime(time) { | |
| const macEpoch = new Date('2001-01-01T00:00:00Z') | |
| const date = new Date(macEpoch.getTime() + time * 1000) | |
| return date | |
| } | |
| function getCurrentMacEpochTime() { | |
| const macEpoch = new Date('2001-01-01T00:00:00Z') | |
| const now = new Date() | |
| const secondsSinceMacEpoch = Math.floor((now.getTime() - macEpoch.getTime()) / 1000) | |
| return secondsSinceMacEpoch | |
| } | |
| /** | |
| * @param {DatabaseSync} db | |
| */ | |
| function debugDatabase(db) { | |
| const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() | |
| // Log the first 10 rows of each table | |
| for (const table of tables) { | |
| const tableName = table.name | |
| const rows = db.prepare(`SELECT * FROM ${tableName} LIMIT 10`).all() | |
| console.log(`Table: ${tableName}`) | |
| console.table(rows) | |
| } | |
| } | |
| /** | |
| * @param {T[]} array | |
| * @template T | |
| * @return {T[]} | |
| */ | |
| function unique(array) { | |
| return Array.from(new Set(array)) | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment