Skip to content

Instantly share code, notes, and snippets.

@fdstevex
Created February 17, 2026 12:00
Show Gist options
  • Select an option

  • Save fdstevex/e0ec128b0342ee3b1772dd0c85b372b3 to your computer and use it in GitHub Desktop.

Select an option

Save fdstevex/e0ec128b0342ee3b1772dd0c85b372b3 to your computer and use it in GitHub Desktop.
Chrome Cache Viewer
#!/usr/bin/env python3
"""Chrome Cache Viewer - A web tool for browsing Chrome's disk cache."""
import http.server
import json
import mimetypes
import os
import struct
import sys
import urllib.parse
from datetime import datetime, timezone
from pathlib import Path
CACHE_DIR = Path.home() / "Library/Caches/Google/Chrome/Default/Cache/Cache_Data"
SIMPLE_CACHE_MAGIC = 0xFCFB6D1BA7725C30
EOF_MAGIC = 0xF4FA6F45970D41D8
BODY_PREFIX_SIZE = 4 # Chrome adds a 4-byte prefix before the body data
PORT = 8088
def parse_cache_entry(filepath):
"""Parse a Chrome Simple Cache entry file and return metadata."""
try:
with open(filepath, "rb") as f:
data = f.read()
except OSError:
return None
if len(data) < 24:
return None
magic, version, key_len, key_hash = struct.unpack("<QIII", data[:20])
if magic != SIMPLE_CACHE_MAGIC:
return None
if 20 + key_len > len(data):
return None
key_raw = data[20 : 20 + key_len]
key_str = key_raw.lstrip(b"\x00").decode("utf-8", errors="replace")
# Extract the actual URL from the partition key format
# Format: "1/0/_dk_<top-frame-origin> <requesting-origin> <url>"
# or just a plain URL
url = key_str
if "_dk_" in key_str:
parts = key_str.split(" ")
url = parts[-1] if len(parts) >= 3 else parts[-1]
elif key_str.startswith("1/0/"):
url = key_str[4:]
# Find the body region (between key end and first EOF magic)
body_start = 20 + key_len
eof_pos = None
for i in range(body_start, min(body_start + 10_000_000, len(data) - 8)):
if struct.unpack("<Q", data[i : i + 8])[0] == EOF_MAGIC:
eof_pos = i
break
# Extract HTTP headers (find "HTTP/" in the data after the first EOF)
headers = {}
status_line = ""
http_pos = data.find(b"HTTP/", body_start)
if http_pos > 0:
hdr_end = data.find(b"\x00\x00", http_pos)
if hdr_end < 0:
hdr_end = min(http_pos + 4000, len(data))
for h in data[http_pos:hdr_end].split(b"\x00"):
hs = h.decode("utf-8", errors="replace")
if hs.startswith("HTTP/"):
status_line = hs
elif ":" in hs:
name, _, value = hs.partition(":")
headers[name.strip().lower()] = value.strip()
content_type = headers.get("content-type", "")
content_length = headers.get("content-length", "")
# Body data: skip the 4-byte prefix
body_offset = body_start + BODY_PREFIX_SIZE
body_end = eof_pos if eof_pos else len(data)
body_size = max(0, body_end - body_offset)
# File modification time
try:
mtime = os.path.getmtime(filepath)
modified = datetime.fromtimestamp(mtime, tz=timezone.utc).isoformat()
except OSError:
modified = ""
return {
"filename": os.path.basename(filepath),
"url": url,
"full_key": key_str,
"status": status_line,
"content_type": content_type,
"content_length": content_length,
"body_size": body_size,
"file_size": len(data),
"modified": modified,
"headers": headers,
"body_offset": body_offset,
"body_end": body_end,
}
def get_body_data(filepath, entry):
"""Extract the body data from a cache entry file."""
try:
with open(filepath, "rb") as f:
f.seek(entry["body_offset"])
return f.read(entry["body_end"] - entry["body_offset"])
except OSError:
return b""
def scan_cache(limit=2000):
"""Scan the cache directory and return parsed entries."""
entries = []
cache_files = sorted(
(f for f in CACHE_DIR.iterdir() if f.name.endswith("_0") and f.name != "index"),
key=lambda f: f.stat().st_mtime,
reverse=True,
)
for fpath in cache_files[:limit]:
entry = parse_cache_entry(fpath)
if entry and entry["url"]:
entries.append(entry)
return entries
HTML_PAGE = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chrome Cache Viewer</title>
<style>
:root {
--bg: #1a1a2e;
--surface: #16213e;
--surface2: #0f3460;
--accent: #e94560;
--accent2: #533483;
--text: #eee;
--text2: #aab;
--border: #2a2a4a;
--green: #4ecca3;
--yellow: #f0c040;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
background: var(--bg);
color: var(--text);
font-size: 13px;
line-height: 1.5;
}
header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 12px 20px;
display: flex;
align-items: center;
gap: 16px;
position: sticky;
top: 0;
z-index: 100;
}
header h1 {
font-size: 16px;
font-weight: 600;
color: var(--accent);
white-space: nowrap;
}
.controls {
display: flex;
gap: 10px;
flex: 1;
align-items: center;
}
input[type="text"] {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 6px 12px;
border-radius: 4px;
font-family: inherit;
font-size: 13px;
flex: 1;
max-width: 500px;
}
input[type="text"]:focus {
outline: none;
border-color: var(--accent);
}
select {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 6px 10px;
border-radius: 4px;
font-family: inherit;
font-size: 13px;
}
.stats {
color: var(--text2);
font-size: 12px;
white-space: nowrap;
}
.main {
display: flex;
height: calc(100vh - 49px);
}
.list-panel {
width: 55%;
overflow-y: auto;
border-right: 1px solid var(--border);
}
.detail-panel {
width: 45%;
overflow-y: auto;
background: var(--surface);
}
table {
width: 100%;
border-collapse: collapse;
}
th {
background: var(--surface);
padding: 6px 10px;
text-align: left;
font-weight: 600;
color: var(--text2);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
position: sticky;
top: 0;
z-index: 10;
border-bottom: 1px solid var(--border);
cursor: pointer;
user-select: none;
}
th:hover { color: var(--accent); }
th.sorted { color: var(--accent); }
td {
padding: 5px 10px;
border-bottom: 1px solid var(--border);
max-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
tr.entry-row { cursor: pointer; }
tr.entry-row:hover { background: var(--surface2); }
tr.entry-row.selected { background: var(--accent2); }
.url-cell {
color: var(--green);
}
.type-cell {
color: var(--yellow);
}
.size-cell {
text-align: right;
color: var(--text2);
}
.date-cell {
color: var(--text2);
font-size: 12px;
}
.status-ok { color: var(--green); }
.status-redirect { color: var(--yellow); }
.status-error { color: var(--accent); }
.detail-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text2);
}
.detail-content { padding: 16px; }
.detail-section {
margin-bottom: 16px;
}
.detail-section h3 {
font-size: 12px;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
padding-bottom: 4px;
border-bottom: 1px solid var(--border);
}
.detail-url {
word-break: break-all;
color: var(--green);
font-size: 12px;
margin-bottom: 8px;
}
.detail-url a {
color: var(--green);
text-decoration: none;
}
.detail-url a:hover { text-decoration: underline; }
.header-table {
width: 100%;
border-collapse: collapse;
}
.header-table td {
padding: 3px 8px;
border-bottom: 1px solid var(--border);
font-size: 12px;
max-width: none;
white-space: normal;
word-break: break-all;
}
.header-table td:first-child {
color: var(--yellow);
white-space: nowrap;
width: 160px;
}
.preview-container {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 12px;
text-align: center;
min-height: 60px;
}
.preview-container img {
max-width: 100%;
max-height: 400px;
image-rendering: auto;
}
.preview-container pre {
text-align: left;
white-space: pre-wrap;
word-break: break-all;
max-height: 400px;
overflow-y: auto;
font-size: 12px;
color: var(--text);
}
.preview-container audio,
.preview-container video {
max-width: 100%;
}
.btn {
background: var(--surface2);
color: var(--text);
border: 1px solid var(--border);
padding: 4px 12px;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
font-size: 12px;
}
.btn:hover { background: var(--accent2); }
.loading {
color: var(--text2);
padding: 40px;
text-align: center;
}
.badge {
display: inline-block;
padding: 1px 6px;
border-radius: 3px;
font-size: 11px;
font-weight: 600;
}
.badge-img { background: #2d4a3e; color: #4ecca3; }
.badge-js { background: #4a4a2d; color: #f0c040; }
.badge-css { background: #2d3a5a; color: #60a0ff; }
.badge-html { background: #4a2d3a; color: #e94560; }
.badge-json { background: #3a2d4a; color: #b060ff; }
.badge-font { background: #3a3a2d; color: #c0a060; }
.badge-other { background: #2d2d3a; color: #8888aa; }
</style>
</head>
<body>
<header>
<h1>Chrome Cache Viewer</h1>
<div class="controls">
<input type="text" id="search" placeholder="Filter by URL or content type..." autofocus>
<select id="type-filter">
<option value="">All Types</option>
<option value="image">Images</option>
<option value="text/javascript">JavaScript</option>
<option value="text/css">CSS</option>
<option value="text/html">HTML</option>
<option value="application/json">JSON</option>
<option value="font">Fonts</option>
<option value="video">Video</option>
<option value="audio">Audio</option>
</select>
<span class="stats" id="stats">Loading...</span>
</div>
</header>
<div class="main">
<div class="list-panel">
<table>
<thead>
<tr>
<th data-sort="type" style="width:70px">Type</th>
<th data-sort="url">URL</th>
<th data-sort="status" style="width:50px">Status</th>
<th data-sort="size" style="width:80px" class="size-cell">Size</th>
<th data-sort="modified" style="width:130px">Modified</th>
</tr>
</thead>
<tbody id="entries"></tbody>
</table>
</div>
<div class="detail-panel" id="detail">
<div class="detail-empty">Select a cache entry to view details</div>
</div>
</div>
<script>
let allEntries = [];
let sortCol = 'modified';
let sortAsc = false;
let selectedFile = null;
async function loadEntries() {
const resp = await fetch('/api/entries');
allEntries = await resp.json();
renderEntries();
}
function getTypeBadge(ct) {
if (!ct) return '<span class="badge badge-other">???</span>';
if (ct.includes('image')) return '<span class="badge badge-img">IMG</span>';
if (ct.includes('javascript')) return '<span class="badge badge-js">JS</span>';
if (ct.includes('css')) return '<span class="badge badge-css">CSS</span>';
if (ct.includes('html')) return '<span class="badge badge-html">HTML</span>';
if (ct.includes('json')) return '<span class="badge badge-json">JSON</span>';
if (ct.includes('font') || ct.includes('woff') || ct.includes('ttf')) return '<span class="badge badge-font">FONT</span>';
if (ct.includes('video')) return '<span class="badge badge-img">VID</span>';
if (ct.includes('audio')) return '<span class="badge badge-img">AUD</span>';
return '<span class="badge badge-other">' + ct.split('/').pop().split(';')[0].substring(0,6).toUpperCase() + '</span>';
}
function formatSize(bytes) {
if (!bytes || bytes <= 0) return '-';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024*1024) return (bytes/1024).toFixed(1) + ' KB';
return (bytes/(1024*1024)).toFixed(1) + ' MB';
}
function getStatusClass(status) {
if (!status) return '';
const code = parseInt(status.split(' ')[1]);
if (code >= 200 && code < 300) return 'status-ok';
if (code >= 300 && code < 400) return 'status-redirect';
return 'status-error';
}
function getStatusCode(status) {
if (!status) return '';
const parts = status.split(' ');
return parts.length > 1 ? parts[1] : '';
}
function formatDate(iso) {
if (!iso) return '';
const d = new Date(iso);
const month = String(d.getMonth()+1).padStart(2,'0');
const day = String(d.getDate()).padStart(2,'0');
const hours = String(d.getHours()).padStart(2,'0');
const mins = String(d.getMinutes()).padStart(2,'0');
return `${d.getFullYear()}-${month}-${day} ${hours}:${mins}`;
}
function getShortUrl(url) {
try {
const u = new URL(url);
let path = u.pathname + u.search;
if (path.length > 80) path = path.substring(0, 80) + '...';
return u.hostname + path;
} catch {
if (url.length > 100) return url.substring(0, 100) + '...';
return url;
}
}
function filterEntries() {
const q = document.getElementById('search').value.toLowerCase();
const tf = document.getElementById('type-filter').value.toLowerCase();
return allEntries.filter(e => {
if (q && !e.url.toLowerCase().includes(q) && !e.content_type.toLowerCase().includes(q)) return false;
if (tf && !e.content_type.toLowerCase().includes(tf)) return false;
return true;
});
}
function sortEntries(entries) {
return entries.sort((a, b) => {
let va, vb;
switch(sortCol) {
case 'url': va = a.url; vb = b.url; break;
case 'type': va = a.content_type; vb = b.content_type; break;
case 'status': va = a.status; vb = b.status; break;
case 'size': va = a.body_size; vb = b.body_size; break;
case 'modified': va = a.modified; vb = b.modified; break;
default: va = a.modified; vb = b.modified;
}
if (va < vb) return sortAsc ? -1 : 1;
if (va > vb) return sortAsc ? 1 : -1;
return 0;
});
}
function renderEntries() {
let entries = filterEntries();
entries = sortEntries(entries);
document.getElementById('stats').textContent =
`${entries.length} of ${allEntries.length} entries`;
const tbody = document.getElementById('entries');
// Render in chunks for performance
const frag = document.createDocumentFragment();
const limit = Math.min(entries.length, 2000);
for (let i = 0; i < limit; i++) {
const e = entries[i];
const tr = document.createElement('tr');
tr.className = 'entry-row' + (e.filename === selectedFile ? ' selected' : '');
tr.dataset.filename = e.filename;
tr.innerHTML = `
<td>${getTypeBadge(e.content_type)}</td>
<td class="url-cell" title="${e.url}">${getShortUrl(e.url)}</td>
<td class="${getStatusClass(e.status)}">${getStatusCode(e.status)}</td>
<td class="size-cell">${formatSize(e.body_size)}</td>
<td class="date-cell">${formatDate(e.modified)}</td>
`;
tr.onclick = () => showDetail(e);
frag.appendChild(tr);
}
tbody.replaceChildren(frag);
// Update sort headers
document.querySelectorAll('th[data-sort]').forEach(th => {
th.classList.toggle('sorted', th.dataset.sort === sortCol);
th.textContent = th.textContent.replace(/ [▲▼]$/, '');
if (th.dataset.sort === sortCol) {
th.textContent += sortAsc ? ' ▲' : ' ▼';
}
});
}
async function showDetail(entry) {
selectedFile = entry.filename;
renderEntries();
const detail = document.getElementById('detail');
detail.innerHTML = `<div class="detail-content">
<div class="detail-section">
<h3>URL</h3>
<div class="detail-url"><a href="${entry.url}" target="_blank">${entry.url}</a></div>
</div>
<div class="detail-section">
<h3>Response ${entry.status}</h3>
<table class="header-table">
${Object.entries(entry.headers).map(([k,v]) =>
`<tr><td>${k}</td><td>${v}</td></tr>`
).join('')}
</table>
</div>
<div class="detail-section">
<h3>Preview <button class="btn" onclick="downloadEntry('${entry.filename}', '${entry.content_type}')">Download</button></h3>
<div class="preview-container" id="preview">Loading...</div>
</div>
<div class="detail-section">
<h3>Cache File</h3>
<table class="header-table">
<tr><td>File</td><td>${entry.filename}</td></tr>
<tr><td>File Size</td><td>${formatSize(entry.file_size)}</td></tr>
<tr><td>Body Size</td><td>${formatSize(entry.body_size)}</td></tr>
<tr><td>Full Key</td><td style="font-size:11px">${entry.full_key}</td></tr>
</table>
</div>
</div>`;
loadPreview(entry);
}
async function loadPreview(entry) {
const container = document.getElementById('preview');
const ct = entry.content_type.toLowerCase();
const url = '/api/body/' + entry.filename;
if (entry.body_size <= 0) {
container.textContent = '(empty body)';
return;
}
if (ct.includes('image')) {
const img = document.createElement('img');
img.src = url;
img.alt = 'Cached image';
img.onerror = () => { container.textContent = '(could not render image)'; };
container.replaceChildren(img);
} else if (ct.includes('video')) {
container.innerHTML = `<video controls src="${url}"></video>`;
} else if (ct.includes('audio')) {
container.innerHTML = `<audio controls src="${url}"></audio>`;
} else if (ct.includes('javascript') || ct.includes('css') || ct.includes('json') ||
ct.includes('html') || ct.includes('xml') || ct.includes('text') ||
ct.includes('svg')) {
try {
const resp = await fetch(url);
let text = await resp.text();
if (text.length > 50000) text = text.substring(0, 50000) + '\n... (truncated)';
const pre = document.createElement('pre');
pre.textContent = text;
container.replaceChildren(pre);
} catch {
container.textContent = '(could not load text content)';
}
} else if (ct.includes('font') || ct.includes('woff') || ct.includes('ttf') || ct.includes('otf')) {
container.textContent = `Font file (${formatSize(entry.body_size)}) - use Download to save`;
} else {
container.textContent = `Binary data (${formatSize(entry.body_size)}) - use Download to save`;
}
}
function downloadEntry(filename, contentType) {
const a = document.createElement('a');
a.href = '/api/body/' + filename;
a.download = filename;
a.click();
}
document.querySelectorAll('th[data-sort]').forEach(th => {
th.addEventListener('click', () => {
if (sortCol === th.dataset.sort) {
sortAsc = !sortAsc;
} else {
sortCol = th.dataset.sort;
sortAsc = sortCol === 'url' || sortCol === 'type';
}
renderEntries();
});
});
let searchTimeout;
document.getElementById('search').addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(renderEntries, 200);
});
document.getElementById('type-filter').addEventListener('change', renderEntries);
loadEntries();
</script>
</body>
</html>"""
class CacheViewerHandler(http.server.BaseHTTPRequestHandler):
_entries_cache = None
_entries_cache_time = 0
def log_message(self, fmt, *args):
# Quiet logging
pass
def _set_headers(self, content_type="text/html", status=200):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Cache-Control", "no-cache")
self.end_headers()
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
if path == "/" or path == "":
self._set_headers("text/html")
self.wfile.write(HTML_PAGE.encode())
elif path == "/api/entries":
# Cache the scan results for 30 seconds
now = __import__("time").time()
if (
CacheViewerHandler._entries_cache is None
or now - CacheViewerHandler._entries_cache_time > 30
):
entries = scan_cache()
CacheViewerHandler._entries_cache = entries
CacheViewerHandler._entries_cache_time = now
else:
entries = CacheViewerHandler._entries_cache
self._set_headers("application/json")
# Send only what the frontend needs (exclude body_offset/body_end for listing)
slim = []
for e in entries:
slim.append(
{
"filename": e["filename"],
"url": e["url"],
"full_key": e["full_key"],
"status": e["status"],
"content_type": e["content_type"],
"body_size": e["body_size"],
"file_size": e["file_size"],
"modified": e["modified"],
"headers": e["headers"],
}
)
self.wfile.write(json.dumps(slim).encode())
elif path.startswith("/api/body/"):
filename = path[len("/api/body/") :]
if "/" in filename or ".." in filename:
self._set_headers("text/plain", 400)
self.wfile.write(b"Invalid filename")
return
filepath = CACHE_DIR / filename
if not filepath.exists():
self._set_headers("text/plain", 404)
self.wfile.write(b"Not found")
return
entry = parse_cache_entry(filepath)
if not entry:
self._set_headers("text/plain", 500)
self.wfile.write(b"Could not parse cache entry")
return
body = get_body_data(filepath, entry)
ct = entry["content_type"] or "application/octet-stream"
# Strip charset for binary types
mime = ct.split(";")[0].strip()
self._set_headers(mime)
self.wfile.write(body)
else:
self._set_headers("text/plain", 404)
self.wfile.write(b"Not found")
def main():
port = PORT
if len(sys.argv) > 1:
port = int(sys.argv[1])
server = http.server.HTTPServer(("127.0.0.1", port), CacheViewerHandler)
print(f"Chrome Cache Viewer running at http://127.0.0.1:{port}")
print(f"Cache directory: {CACHE_DIR}")
print("Press Ctrl+C to stop")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nStopped.")
server.server_close()
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment