feat: Implement migration script for renaming timestamped files to content-hash based names with deduplication options

This commit is contained in:
Ross
2025-10-20 21:36:05 +01:00
parent 9c86b32c3b
commit 1c01221a90
2 changed files with 293 additions and 34 deletions
+80 -34
View File
@@ -249,23 +249,28 @@ async def run(args):
try:
parsed = json.loads(txt)
pretty = json.dumps(parsed, ensure_ascii=False, indent=2)
fname = f"{safe}_{h}_{ts}.json"
body_path = str(output / fname)
save_text(output / fname, pretty)
try:
print(f"{now_ts()}\tSAVED\t{body_path}")
except Exception:
pass
content_bytes = pretty.encode('utf-8')
excerpt = pretty[:800]
use_text = pretty
ext = 'json'
except Exception:
fname = f"{safe}_{h}_{ts}.txt"
body_path = str(output / fname)
save_text(output / fname, txt)
content_bytes = txt.encode('utf-8')
excerpt = txt[:800]
use_text = txt
ext = 'txt'
# deterministic filename based on content hash (sha1)
content_sha1 = hashlib.sha1(content_bytes).hexdigest()
fname = f"{safe}_{content_sha1}.{ext}"
body_path = str(output / fname)
# if file already exists, don't rewrite body; just update meta/index
if not os.path.exists(body_path):
save_text(output / fname, use_text)
try:
print(f"{now_ts()}\tSAVED\t{body_path}")
except Exception:
pass
excerpt = txt[:800]
else:
try:
data = await resp.body()
@@ -278,26 +283,29 @@ async def run(args):
ext = guess_ext(ctype)
# Put images into an images/ subdirectory, other binaries into assets/
if ctype.startswith('image/'):
# Images don't need per-capture timestamps; use stable names with hash
subdir = output / 'images'
subdir.mkdir(parents=True, exist_ok=True)
fname = f"{safe}_{h}.{ext}"
content_sha1 = hashlib.sha1(data).hexdigest()
fname = f"{safe}_{content_sha1}.{ext}"
body_path = str(subdir / fname)
save_binary(subdir / fname, data)
try:
print(f"{now_ts()}\tSAVED\t{body_path}")
except Exception:
pass
if not os.path.exists(body_path):
save_binary(subdir / fname, data)
try:
print(f"{now_ts()}\tSAVED\t{body_path}")
except Exception:
pass
else:
subdir = output / 'assets'
subdir.mkdir(parents=True, exist_ok=True)
fname = f"{safe}_{h}_{ts}.{ext}"
content_sha1 = hashlib.sha1(data).hexdigest()
fname = f"{safe}_{content_sha1}.{ext}"
body_path = str(subdir / fname)
save_binary(subdir / fname, data)
try:
print(f"{now_ts()}\tSAVED\t{body_path}")
except Exception:
pass
if not os.path.exists(body_path):
save_binary(subdir / fname, data)
try:
print(f"{now_ts()}\tSAVED\t{body_path}")
except Exception:
pass
except Exception:
# final fallback: try binary
try:
@@ -340,11 +348,12 @@ async def run(args):
except Exception:
content_hash = None
# prepare the meta filename we'll write for this response
new_meta_name = f"{safe}_{h}_{ts}.meta.json"
# prepare the meta filename we'll write for this response (canonical by content when available)
meta_base = content_hash or h
new_meta_name = f"{safe}_{meta_base}.meta.json"
new_meta_abs = os.path.abspath(str(output / new_meta_name))
# If older responses exist with the same content, delete them (fast in-memory lookup)
# If older responses exist with the same content, handle them according to dedupe policy
try:
matches = in_memory_index.get(content_hash, []) if content_hash else []
for j in matches:
@@ -352,20 +361,55 @@ async def run(args):
metaf = j.get('meta_file')
try:
if body and os.path.exists(body) and os.path.abspath(body) != os.path.abspath(body_path):
os.remove(body)
if args.dry_run:
print(f"DRYRUN: would remove old body {body}")
else:
if args.dedupe_policy == 'delete':
os.remove(body)
elif args.dedupe_policy == 'move':
dupdir = output / 'duplicates'
dupdir.mkdir(parents=True, exist_ok=True)
try:
os.replace(body, dupdir / os.path.basename(body))
except Exception:
os.remove(body)
elif args.dedupe_policy == 'symlink':
# replace old body with symlink to canonical
try:
os.remove(body)
os.symlink(os.path.abspath(body_path), body)
except Exception:
pass
try:
with open(output / '_debug_events.log', 'a', encoding='utf-8') as dbg:
dbg.write(f"{now_ts()}\tDELETED_OLD_BODY\t{body}\n")
dbg.write(f"{now_ts()}\tDEDUP_BODY\t{body}\n")
except Exception:
pass
except Exception:
pass
try:
if metaf and os.path.exists(metaf) and os.path.abspath(metaf) != new_meta_abs:
os.remove(metaf)
if args.dry_run:
print(f"DRYRUN: would remove old meta {metaf}")
else:
if args.dedupe_policy == 'delete':
os.remove(metaf)
elif args.dedupe_policy == 'move':
dupdir = output / 'duplicates'
dupdir.mkdir(parents=True, exist_ok=True)
try:
os.replace(metaf, dupdir / os.path.basename(metaf))
except Exception:
os.remove(metaf)
elif args.dedupe_policy == 'symlink':
try:
os.remove(metaf)
os.symlink(new_meta_abs, metaf)
except Exception:
pass
try:
with open(output / '_debug_events.log', 'a', encoding='utf-8') as dbg:
dbg.write(f"{now_ts()}\tDELETED_OLD_META\t{metaf}\n")
dbg.write(f"{now_ts()}\tDEDUP_META\t{metaf}\n")
except Exception:
pass
except Exception:
@@ -383,10 +427,10 @@ async def run(args):
'response_excerpt': excerpt,
'content_hash': content_hash,
}
meta_name = f"{safe}_{h}_{ts}.meta.json"
save_text(output / meta_name, json.dumps(meta, ensure_ascii=False, indent=2))
# write canonical meta (update if exists)
save_text(output / new_meta_name, json.dumps(meta, ensure_ascii=False, indent=2))
try:
entry = {'url': url, 'resource_type': rtype, 'timestamp': ts, 'body_file': body_path, 'meta_file': str(output / meta_name), 'excerpt': excerpt, 'content_hash': content_hash}
entry = {'url': url, 'resource_type': rtype, 'timestamp': ts, 'body_file': body_path, 'meta_file': str(output / new_meta_name), 'excerpt': excerpt, 'content_hash': content_hash}
with open(index_path, 'a', encoding='utf-8') as idx:
idx.write(json.dumps(entry, ensure_ascii=False) + '\n')
# update in-memory index
@@ -485,6 +529,8 @@ def parse_args():
parser.add_argument('--post-login-selector', default='#ds-app', help='Selector that indicates a successful login')
parser.add_argument('--channel', default=os.getenv('PLAYWRIGHT_CHROME_CHANNEL', 'chrome'))
parser.add_argument('--capture-types', default='xhr,fetch,document,other')
parser.add_argument('--dedupe-policy', default='delete', choices=['delete', 'move', 'keep', 'symlink'], help="What to do with older duplicate files: delete/move/keep/symlink")
parser.add_argument('--dry-run', action='store_true', help='If set, show dedupe actions without performing them')
return parser.parse_args()
+213
View File
@@ -0,0 +1,213 @@
#!/usr/bin/env python3
"""Migrate timestamped captured files to content-hash deterministic names.
This script scans an output capture directory (default: xhr_captured_async),
finds body files and their meta files that include timestamps in filenames,
computes content SHA1, renames/moves them to canonical names like
`{safe}_{sha1}.json` or `{safe}_{sha1}.{ext}` under the same directory structure,
updates meta content (response_body_file) and meta filename to `{safe}_{sha1}.meta.json`,
and rewrites `capture_index.jsonl` so entries point to the canonical files.
It supports --dry-run and --dedupe-policy (delete/move/keep/symlink) for handling
old duplicates that would collide on the canonical name.
Usage:
python scrapers/migrate_captured_files.py --input-dir xhr_captured_async --dry-run
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shutil
from pathlib import Path
from typing import Dict, List
def compute_sha1(path: Path) -> str:
h = hashlib.sha1()
with open(path, "rb") as f:
while True:
b = f.read(8192)
if not b:
break
h.update(b)
return h.hexdigest()
def canonical_name_for(path: Path, safe: str, sha1: str) -> Path:
# preserve extension
ext = path.suffix.lstrip('.')
if not ext:
ext = 'bin'
return path.with_name(f"{safe}_{sha1}.{ext}")
def canonical_meta_name(safe: str, sha1: str, directory: Path) -> Path:
return directory / f"{safe}_{sha1}.meta.json"
def find_safe_from_filename(fname: str) -> str:
# safe prefix is everything before the first '_<sha_or_timestamp>' pattern
# but we can be conservative: find the substring up to the first '_[0-9a-f]{8,}' or a timestamp pattern
import re
m = re.match(r"^(.+?)_(?:[0-9a-f]{8,32}|\d{8}T\d{6}Z|\d{4}-\d{2}-\d{2}T)", fname)
if m:
return m.group(1)
# fallback: strip final numeric groups
parts = fname.split('_')
if len(parts) > 1:
return '_'.join(parts[:-1])
return fname
def load_index(index_path: Path) -> List[Dict]:
if not index_path.exists():
return []
out = []
with open(index_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if not line:
continue
try:
out.append(json.loads(line))
except Exception:
continue
return out
def write_index(index_path: Path, entries: List[Dict]):
with open(index_path, 'w', encoding='utf-8') as f:
for e in entries:
f.write(json.dumps(e, ensure_ascii=False) + '\n')
def migrate(input_dir: Path, dedupe_policy: str = 'delete', dry_run: bool = True):
# gather candidate files: top-level json/text files and files in images/ and assets/
files = list(input_dir.glob('*.json')) + list(input_dir.glob('*.txt')) + list(input_dir.glob('*.bin'))
files += list(input_dir.joinpath('images').glob('*')) if (input_dir / 'images').exists() else []
files += list(input_dir.joinpath('assets').glob('*')) if (input_dir / 'assets').exists() else []
# map sha1 -> canonical path (to detect collisions)
canonical_map: Dict[str, Path] = {}
# entries to update in index: old_path -> new_path
path_updates: Dict[str, str] = {}
for f in files:
if f.is_dir():
continue
# skip meta files
if f.name.endswith('.meta.json'):
continue
try:
sha1 = compute_sha1(f)
except Exception as e:
print(f"Failed to hash {f}: {e}")
continue
safe = find_safe_from_filename(f.name)
# canonical target
target = f.with_name(f"{safe}_{sha1}{f.suffix}")
# if already canonical, skip
if f.resolve() == target.resolve():
canonical_map[sha1] = target
continue
# if canonical target exists and is different file -> dedupe action
if target.exists() and target.resolve() != f.resolve():
print(f"Collision: {f} -> {target} (already exists)")
if dry_run:
print(f"DRYRUN would {dedupe_policy} {f}")
else:
if dedupe_policy == 'delete':
f.unlink()
elif dedupe_policy == 'move':
dupdir = input_dir / 'duplicates'
dupdir.mkdir(parents=True, exist_ok=True)
shutil.move(str(f), str(dupdir / f.name))
elif dedupe_policy == 'symlink':
try:
f.unlink()
except Exception:
pass
try:
os.symlink(str(target.resolve()), str(f))
except Exception:
pass
elif dedupe_policy == 'keep':
pass
path_updates[str(f)] = str(target)
canonical_map[sha1] = target
continue
# otherwise move/rename to canonical target
print(f"Renaming: {f} -> {target}")
if not dry_run:
try:
os.replace(str(f), str(target))
except Exception as e:
print(f"Failed to move {f} -> {target}: {e}")
continue
path_updates[str(f)] = str(target)
canonical_map[sha1] = target
# Update meta files: for any meta files that point to old body paths, rewrite them
metas = list(input_dir.glob('*.meta.json')) + list(input_dir.joinpath('images').glob('*.meta.json')) if (input_dir / 'images').exists() else []
metas += list(input_dir.joinpath('assets').glob('*.meta.json')) if (input_dir / 'assets').exists() else []
for m in metas:
try:
with open(m, 'r', encoding='utf-8') as f:
jd = json.load(f)
except Exception:
continue
bf = jd.get('response_body_file')
if bf and bf in path_updates:
newbf = path_updates[bf]
jd['response_body_file'] = newbf
# write canonical meta name
safe = find_safe_from_filename(os.path.basename(newbf))
sha1 = compute_sha1(Path(newbf)) if Path(newbf).exists() else None
if sha1:
new_meta = m.with_name(f"{safe}_{sha1}.meta.json")
else:
new_meta = m
print(f"Updating meta {m} -> {new_meta}")
if not dry_run:
with open(new_meta, 'w', encoding='utf-8') as out:
json.dump(jd, out, ensure_ascii=False, indent=2)
if new_meta.resolve() != m.resolve():
try:
m.unlink()
except Exception:
pass
path_updates[str(m)] = str(new_meta)
# Rewrite capture_index.jsonl
idx_path = input_dir / 'capture_index.jsonl'
index_entries = load_index(idx_path)
changed = False
for e in index_entries:
bf = e.get('body_file')
mf = e.get('meta_file')
if bf and bf in path_updates:
e['body_file'] = path_updates[bf]
changed = True
if mf and mf in path_updates:
e['meta_file'] = path_updates[mf]
changed = True
if changed:
print(f"Updating index {idx_path}")
if not dry_run:
write_index(idx_path, index_entries)
print('Migration complete')
if __name__ == '__main__':
p = argparse.ArgumentParser()
p.add_argument('--input-dir', default='xhr_captured_async')
p.add_argument('--dedupe-policy', default='delete', choices=['delete', 'move', 'keep', 'symlink'])
p.add_argument('--dry-run', action='store_true')
args = p.parse_args()
migrate(Path(args.input_dir), dedupe_policy=args.dedupe_policy, dry_run=args.dry_run)