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()