#!/usr/bin/env python3 """ Rassemble toutes les Memories d'un export Snapchat ("My Data") et remet les vraies dates de prise de vue sur les fichiers. Snapchat livre deux formats d'export, et le script gere les deux tout seul : MEDIAS INCLUS (exports recents) : le ZIP contient un dossier memories/ avec les fichiers. Rien a telecharger. Les dates viennent de l'horodatage des entrees du ZIP (de l'UTC a 2 s pres, granularite du format MS-DOS), recale sur memories_history.json qui donne la seconde exacte et le GPS. Les fichiers -overlay sont les calques de texte/stickers : on les ignore. LIENS (ancien format) : le ZIP ne contient que des liens temporaires (~7 jours), et ces liens ne sont pas directs : il faut faire un POST dessus, qui renvoie une URL AWS signee, qu'on telecharge ensuite. Zero dependance : stdlib Python uniquement (teste sur 3.12). Si exiftool est present sur le PATH, les dates + GPS sont aussi ecrits dans les metadonnees EXIF. Sinon on se rabat sur le nom de fichier + date de modification, ce qui suffit a la plupart des galeries. Usage typique : python snap_memories.py --export "C:\\...\\mydata" --limit 5 # test python snap_memories.py --export "C:\\...\\mydata" # tout python snap_memories.py --export "C:\\...\\mydata" --out D:\\Snaps Relancer la commande est sans risque : les fichiers deja telecharges sont ignores (reprise apres interruption). """ import argparse import concurrent.futures import json import os import re import shutil import subprocess import sys import threading import urllib.error import urllib.parse import urllib.request from datetime import datetime, timedelta, timezone UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/126.0 Safari/537.36") CT_EXT = { "image/jpeg": ".jpg", "image/jpg": ".jpg", "image/png": ".png", "image/webp": ".webp", "image/heic": ".heic", "image/gif": ".gif", "video/mp4": ".mp4", "video/quicktime": ".mov", "video/webm": ".webm", } print_lock = threading.Lock() name_lock = threading.Lock() taken_names = set() # Snapchat date tout en UTC. Mais un nom de fichier et un champ EXIF # DateTimeOriginal se lisent conventionnellement en heure LOCALE : ecrire de # l'UTC decalerait toutes les photos dans Google Photos. On convertit donc. USE_LOCAL_TIME = True # "year" -> un sous-dossier par annee (bon pour archiver sur PC) # "flat" -> tout dans un seul dossier (bon pour un album unique sur telephone) LAYOUT = "year" def display_dt(dt): """Datetime a utiliser pour le nom de fichier et l'EXIF.""" if dt is None: return None return dt.astimezone() if USE_LOCAL_TIME else dt def log(msg): with print_lock: print(msg, flush=True) # -------------------------------------------------------------------------- # 1. Trouver et lire le fichier d'index des memories # -------------------------------------------------------------------------- def find_index_file(export_path): """Localise memories_history.json (ou .html) dans le dossier d'export.""" p = os.path.abspath(export_path) if os.path.isfile(p): return p candidates = [ os.path.join("json", "memories_history.json"), "memories_history.json", os.path.join("html", "memories_history.html"), "memories_history.html", ] for rel in candidates: full = os.path.join(p, rel) if os.path.isfile(full): return full # recherche large en dernier recours for root, _dirs, files in os.walk(p): for f in files: if f.lower().startswith("memories_history"): return os.path.join(root, f) return None def parse_json_index(path): # utf-8-sig : tolere un BOM eventuel en tete de fichier with open(path, "r", encoding="utf-8-sig", errors="replace") as fh: data = json.load(fh) # La cle est en general "Saved Media", mais elle a change selon les # versions d'export : on prend la premiere liste de dicts qui a une "Date". entries = None if isinstance(data, list): entries = data elif isinstance(data, dict): if isinstance(data.get("Saved Media"), list): entries = data["Saved Media"] else: for value in data.values(): if (isinstance(value, list) and value and isinstance(value[0], dict) and any("date" in k.lower() for k in value[0])): entries = value break if entries is None: raise SystemExit(f"Structure JSON inattendue dans {path} " f"(cles: {list(data)[:10] if isinstance(data, dict) else type(data)})") out = [] for e in entries: if not isinstance(e, dict): continue out.append({ "date": pick(e, ["Date", "date", "Capture Date"]), "media_type": pick(e, ["Media Type", "media_type", "Type"]) or "", "link": pick(e, ["Download Link", "download_link", "Media Download Url", "Download Url", "url"]), "location": pick(e, ["Location", "location"]) or "", }) # On renvoie TOUTES les entrees, y compris sans lien : dans les exports # recents le champ est vide, et ce sont ces entrees-la qui portent la date # exacte et le GPS des fichiers livres dans le ZIP. return out def parse_html_index(path): """Repli si l'export ne contient que le HTML (moins fiable).""" with open(path, "r", encoding="utf-8-sig", errors="replace") as fh: html = fh.read() rows = re.split(r"]*>", html, flags=re.I)[1:] out = [] for row in rows: link = re.search(r"https://[^\s'\"\\)]+", row) if not link: continue date = re.search(r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?: UTC)?)", row) mtype = re.search(r">\s*(Image|Video|PHOTO|VIDEO)\s*<", row, flags=re.I) loc = re.search(r"Latitude,\s*Longitude:\s*([-\d.]+)\s*,\s*([-\d.]+)", row) out.append({ "date": date.group(1) if date else None, "media_type": mtype.group(1) if mtype else "", "link": link.group(0).replace("&", "&"), "location": f"{loc.group(1)}, {loc.group(2)}" if loc else "", }) return out def pick(d, keys): for k in keys: if d.get(k): return d[k] return None # -------------------------------------------------------------------------- # 1 bis. Exports recents : les medias sont livres DANS le ZIP # -------------------------------------------------------------------------- # 2018-10-16_-main.jpg : la date est dans le nom, l'heure non. # Les -overlay.png sont les calques de texte et de stickers, pas des souvenirs. MAIN_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})_.*-main\.[A-Za-z0-9]+$") def find_media_dir(export_path): """Localise le dossier memories/ d'un export recent.""" p = os.path.abspath(export_path) if os.path.isfile(p): p = os.path.dirname(os.path.dirname(p)) # .../json/memories_history.json direct = os.path.join(p, "memories") if os.path.isdir(direct): return direct for root, dirs, _files in os.walk(p): dirs[:] = [d for d in dirs if not d.startswith(".")] if os.path.basename(root).lower() == "memories": return root return None def list_media(media_dir): return [os.path.join(media_dir, f) for f in sorted(os.listdir(media_dir)) if MAIN_RE.match(f)] def align_to_index(files, entries): """Associe chaque fichier a son entree du JSON -> [(chemin, date, lieu)]. Deux ecueils, traites ensemble. Le ZIP horodate a 2 s pres (granularite du format MS-DOS), et certains outils de decompression reinterpretent cet horodatage UTC en heure locale, decalant tout le lot d'un nombre entier d'heures. On cherche donc le decalage qui fait coincider le plus de fichiers avec les dates du JSON, puis on lit sur l'entree trouvee la seconde exacte et le GPS. Si les mtime ont ete perdus a la decompression, plus rien ne coincide : on se rabat sur la date du nom de fichier, qui donne le bon jour a defaut de l'heure. """ by_time = {} for e in entries: dt = parse_date(e["date"]) if dt: by_time.setdefault(dt.strftime("%Y-%m-%d %H:%M:%S"), e) def lookup(dt): for s in (0, 1): # arrondi a la seconde paire exact = dt + timedelta(seconds=s) hit = by_time.get(exact.strftime("%Y-%m-%d %H:%M:%S")) if hit: return hit, exact return None, None stamps = [datetime.fromtimestamp(os.path.getmtime(f), timezone.utc) for f in files] def name_dt(path): m = MAIN_RE.match(os.path.basename(path)) return parse_date(m.group(1) + " 12:00:00") if m else None if not by_time: log("! Pas de date exploitable dans le JSON : on garde l'horodatage des fichiers.") return [(f, dt, "") for f, dt in zip(files, stamps)] shift, best = 0, -1 for h in range(-14, 15): n = sum(1 for dt in stamps if lookup(dt + timedelta(hours=h))[0]) if n > best: shift, best = h, n if best < max(1, len(files) // 5): log(f"! Horodatage des fichiers inutilisable ({best}/{len(files)} reconnus) : " f"il a ete perdu a la decompression.") log(" Repli sur la date du nom de fichier : bon jour, heure approximative.") log(" Pour l'heure exacte, redecompresse le ZIP avec snap_album.py.") return [(f, name_dt(f), "") for f in files] if shift: log(f"! Horodatage decale de {shift:+d} h par la decompression : recale.") out, matched = [], 0 for path, dt in zip(files, stamps): hit, exact = lookup(dt + timedelta(hours=shift)) if hit: out.append((path, exact, hit["location"])) matched += 1 else: out.append((path, dt + timedelta(hours=shift), "")) log(f"Dates : {matched}/{len(files)} recalees au JSON (seconde exacte + GPS)") return out def install_one(job, index, out_dir): """Copie un media de l'export vers la destination, a la bonne date.""" src, dt, location = job ddt = display_dt(dt) existing = already_downloaded(out_dir, ddt, index) if existing: return ("skip", existing, ddt, location) ext = os.path.splitext(src)[1].lower() path = reserve_path(out_dir, ddt, ext, index) tmp = path + ".part" try: shutil.copy2(src, tmp) os.replace(tmp, path) if dt: ts = dt.timestamp() os.utime(path, (ts, ts)) return ("ok", path, ddt, location) except Exception as exc: # noqa: BLE001 if os.path.exists(tmp): try: os.remove(tmp) except OSError: pass return ("fail", f"{os.path.basename(src)} :: {exc}", ddt, location) # Liste BLANCHE : seules ces cles voient leur valeur affichee en clair. Tout le # reste est masque par defaut. Une liste noire laisserait fuiter tout champ # inattendu (c'est ce qui est arrive avec les coordonnees GPS). SAFE_VALUE_KEYS = {"date", "media type", "media_type", "type", "capture date"} def redact(value, key=""): """Rend une valeur partageable : structure conservee, donnee masquee.""" if not isinstance(value, str): if isinstance(value, (list, dict)): return f"<{type(value).__name__} de {len(value)} elements>" return f"<{type(value).__name__}>" if value.startswith("http"): # On ne garde que l'hote, le chemin et les NOMS des parametres : # les valeurs sont des cles d'acces aux medias prives. p = urllib.parse.urlsplit(value) params = ",".join(sorted({q.split("=")[0] for q in p.query.split("&") if q})) return f"{p.scheme}://{p.netloc}{p.path}?<{params}>" if key.strip().lower() in SAFE_VALUE_KEYS: return value[:60] return f"" def inspect_index(path): """Affiche la structure du fichier d'index, sans divulguer de donnees. Sortie concue pour etre copiee/partagee sans risque : aucun lien complet, aucune coordonnee, aucun identifiant. """ print(f"Fichier : {os.path.basename(path)}") print(f"Taille : {os.path.getsize(path)} octets") if not path.lower().endswith(".json"): with open(path, "r", encoding="utf-8-sig", errors="replace") as fh: head = fh.read(4000) cols = re.findall(r"]*>\s*([^<]{1,40})\s*", head, flags=re.I) print(f"Format : HTML") print(f"Colonnes : {cols if cols else 'non detectees'}") print(f"Lignes : {head.lower().count(' URL AWS reelle # -------------------------------------------------------------------------- def http(url, method="GET", body=None, headers=None, timeout=90): req = urllib.request.Request(url, data=body, method=method) req.add_header("User-Agent", UA) for k, v in (headers or {}).items(): req.add_header(k, v) return urllib.request.urlopen(req, timeout=timeout) def resolve_media_url(link): """ Renvoie (url_directe, reponse_deja_ouverte). Trois strategies, parce que le format varie selon l'anciennete de l'export : 1. POST sur le lien complet, corps vide -> renvoie l'URL en texte brut 2. POST avec la query string en form-urlencoded 3. GET avec l'en-tete X-Snap-Route-Tag Si la reponse est deja le media lui-meme, on la reutilise directement. """ # Deja une URL directe (certains exports recents) if "app.snapchat.com" not in link and re.search(r"\.(jpg|jpeg|png|mp4|mov|webp)", link, re.I): return link, None attempts = [] parts = urllib.parse.urlsplit(link) attempts.append(("POST", link, b"", {})) if parts.query: base = urllib.parse.urlunsplit((parts.scheme, parts.netloc, parts.path, "", "")) attempts.append(("POST", base, parts.query.encode(), {"Content-Type": "application/x-www-form-urlencoded"})) attempts.append(("GET", link, None, {"X-Snap-Route-Tag": "mem-dmd"})) last_err = None for method, url, body, headers in attempts: try: resp = http(url, method=method, body=body, headers=headers) except Exception as exc: # noqa: BLE001 last_err = exc continue ctype = (resp.headers.get("Content-Type") or "").lower() # Reponse = le media directement if ctype.startswith(("image/", "video/", "application/octet-stream")): return None, resp raw = resp.read(20000).decode("utf-8", "replace").strip() resp.close() if raw.startswith("http"): return raw.split()[0], None if raw.startswith("{"): try: blob = json.loads(raw) for key in ("url", "downloadUrl", "download_url", "signedUrl"): if isinstance(blob.get(key), str) and blob[key].startswith("http"): return blob[key], None except json.JSONDecodeError: pass last_err = RuntimeError(f"reponse inattendue: {raw[:120]!r}") raise RuntimeError(f"resolution impossible ({last_err})") # -------------------------------------------------------------------------- # 3. Nommage / dates # -------------------------------------------------------------------------- def parse_date(s): if not s: return None s = s.strip().replace(" UTC", "").replace("Z", "") for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M"): try: return datetime.strptime(s, fmt).replace(tzinfo=timezone.utc) except ValueError: continue return None def guess_ext(resp, url, media_type): cd = resp.headers.get("Content-Disposition") if resp else None if cd: m = re.search(r'filename\*?="?([^";]+)', cd) if m: ext = os.path.splitext(m.group(1).strip())[1] if 1 < len(ext) <= 6: return ext.lower() ct = (resp.headers.get("Content-Type") or "").split(";")[0].strip().lower() if resp else "" if ct in CT_EXT: return CT_EXT[ct] m = re.search(r"\.(jpg|jpeg|png|mp4|mov|webp|heic)(?:\?|$)", url or "", re.I) if m: return "." + m.group(1).lower() return ".mp4" if "video" in (media_type or "").lower() else ".jpg" def target_folder_and_stem(out_dir, dt, index): """Dossier + racine du nom, selon la disposition choisie.""" if dt: stem = dt.strftime("%Y-%m-%d_%H-%M-%S") folder = out_dir if LAYOUT == "flat" else os.path.join(out_dir, str(dt.year)) else: stem = f"memory_{index:05d}" folder = out_dir if LAYOUT == "flat" else os.path.join(out_dir, "sans-date") return folder, stem def reserve_path(out_dir, dt, ext, index): folder, stem = target_folder_and_stem(out_dir, dt, index) os.makedirs(folder, exist_ok=True) with name_lock: candidate = os.path.join(folder, stem + ext) n = 1 while candidate.lower() in taken_names or os.path.exists(candidate): candidate = os.path.join(folder, f"{stem}_{n}{ext}") n += 1 taken_names.add(candidate.lower()) return candidate def already_downloaded(out_dir, dt, index): """Reprise : renvoie le chemin du fichier deja telecharge, sinon None. On renvoie le chemin (et pas un booleen) pour que la passe EXIF puisse aussi traiter les fichiers deja presents lors d'une relance. """ folder, stem = target_folder_and_stem(out_dir, dt, index) if not os.path.isdir(folder): return None try: for f in sorted(os.listdir(folder)): full = os.path.join(folder, f) if f.startswith(stem) and not f.endswith(".part") and os.path.getsize(full) > 0: return full except OSError: pass return None # -------------------------------------------------------------------------- # 4. Telechargement d'une memory # -------------------------------------------------------------------------- def download_one(entry, index, out_dir, retries=3): dt = parse_date(entry["date"]) # UTC, source de verite pour l'epoch ddt = display_dt(dt) # heure locale, pour nom + EXIF existing = already_downloaded(out_dir, ddt, index) if existing: return ("skip", existing, ddt, entry["location"]) last_err = None for attempt in range(1, retries + 1): tmp = None try: url, resp = resolve_media_url(entry["link"]) if resp is None: resp = http(url, method="GET") with resp: ext = guess_ext(resp, url, entry["media_type"]) path = reserve_path(out_dir, ddt, ext, index) tmp = path + ".part" with open(tmp, "wb") as fh: shutil.copyfileobj(resp, fh, 1024 * 256) if os.path.getsize(tmp) == 0: raise RuntimeError("fichier vide") os.replace(tmp, path) if dt: ts = dt.timestamp() # epoch : independant du fuseau os.utime(path, (ts, ts)) return ("ok", path, ddt, entry["location"]) except Exception as exc: # noqa: BLE001 last_err = exc if tmp and os.path.exists(tmp): try: os.remove(tmp) except OSError: pass if attempt < retries: import time time.sleep(2 * attempt) return ("fail", f"{entry['date']} :: {last_err}", ddt, entry["location"]) # -------------------------------------------------------------------------- # 5. EXIF (optionnel, si exiftool present) # -------------------------------------------------------------------------- def write_exif(pairs, exiftool): """pairs = [(chemin, datetime, 'lat, lon' ou '')]""" argfile = os.path.join(os.path.dirname(pairs[0][0]) or ".", "_exif_args.txt") lines = [] for path, dt, loc in pairs: if not dt: continue stamp = dt.strftime("%Y:%m:%d %H:%M:%S") lines += [path, f"-DateTimeOriginal={stamp}", f"-CreateDate={stamp}", f"-ModifyDate={stamp}", f"-XMP:DateTimeOriginal={stamp}"] m = re.search(r"([-\d.]+)\s*,\s*([-\d.]+)", loc or "") if m: lat, lon = float(m.group(1)), float(m.group(2)) if lat or lon: lines += [f"-GPSLatitude={abs(lat)}", f"-GPSLatitudeRef={'N' if lat >= 0 else 'S'}", f"-GPSLongitude={abs(lon)}", f"-GPSLongitudeRef={'E' if lon >= 0 else 'W'}"] lines.append("-execute") if not lines: return with open(argfile, "w", encoding="utf-8") as fh: fh.write("\n".join(lines + ["-overwrite_original", "-q", "-m"])) try: subprocess.run([exiftool, "-@", argfile, "-common_args", "-overwrite_original"], capture_output=True, timeout=1800) finally: try: os.remove(argfile) except OSError: pass # -------------------------------------------------------------------------- def main(): ap = argparse.ArgumentParser(description="Sauvegarde des Memories Snapchat") ap.add_argument("--export", required=True, help="dossier de l'export decompresse (ou chemin direct du memories_history)") ap.add_argument("--out", default=os.path.join(os.path.expanduser("~"), "Snapchat-Memories"), help="dossier de destination") ap.add_argument("--workers", type=int, default=8, help="telechargements simultanes") ap.add_argument("--limit", type=int, default=0, help="ne traiter que les N premieres (test)") ap.add_argument("--dry-run", action="store_true", help="lister sans telecharger") ap.add_argument("--utc", action="store_true", help="nommer/dater en UTC brut au lieu de l'heure locale") ap.add_argument("--layout", choices=("year", "flat"), default="year", help="year = un dossier par annee (archive PC) ; " "flat = tout dans un dossier (album telephone)") ap.add_argument("--inspect", action="store_true", help="afficher la structure du fichier d'index (sortie partageable, " "liens et GPS masques)") args = ap.parse_args() global USE_LOCAL_TIME, LAYOUT USE_LOCAL_TIME = not args.utc LAYOUT = args.layout index_file = find_index_file(args.export) if not index_file: sys.exit(f"Aucun memories_history.json/.html trouve dans {args.export}") log(f"Index : {index_file}") if args.inspect: inspect_index(index_file) return if index_file.lower().endswith(".json"): entries = parse_json_index(index_file) else: log("! Seul le HTML est disponible : lecture moins fiable que le JSON.") entries = parse_html_index(index_file) if not entries: sys.exit("Index vide ou illisible. Verifie que 'Memories' etait bien coche " "dans la demande d'export.") # Les deux formats decrits en tete de fichier : soit des liens a resoudre, # soit les medias deja livres dans l'export. linked = [e for e in entries if e["link"]] media_dir = None if linked else find_media_dir(args.export) if linked: mode = "liens" jobs = linked log(f"Format : liens de telechargement ({len(linked)} sur {len(entries)} entrees)") elif media_dir: mode = "local" files = list_media(media_dir) if not files: sys.exit(f"Dossier {media_dir} sans fichier -main : rien a installer.") log("Format : medias inclus dans l'export (rien a telecharger)") log(f"Source : {media_dir}") jobs = align_to_index(files, entries) if len(files) < len(entries): log(f"! {len(entries) - len(files)} souvenirs de l'index sont absents des " f"fichiers : il manque probablement une partie de l'export " f"(mydata~...-2.zip, -3.zip...).") else: sys.exit("Ni lien de telechargement dans l'index, ni dossier memories/ dans " "l'export : rien a recuperer.\n" "Verifie que 'Memories' etait bien coche dans la demande d'export.") if args.limit: jobs = jobs[:args.limit] if mode == "liens": known = [d for d in (parse_date(e["date"]) for e in jobs) if d] else: known = [dt for _path, dt, _loc in jobs if dt] log(f"Memories : {len(jobs)}") if known: log(f"Periode : {min(known).date()} -> {max(known).date()}") log(f"Destination: {args.out}") if args.dry_run: for j in jobs[:20]: if mode == "liens": log(f" {j['date']} {j['media_type']:6} {j['link'][:80]}") else: log(f" {j[1]} {os.path.basename(j[0])}") log("(dry-run : rien copie ni telecharge)") return os.makedirs(args.out, exist_ok=True) ok, already, failures, done = [], [], [], 0 work = download_one if mode == "liens" else install_one with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as pool: futures = [pool.submit(work, j, i, args.out) for i, j in enumerate(jobs)] for fut in concurrent.futures.as_completed(futures): status, payload, ddt, location = fut.result() done += 1 if status == "ok": ok.append((payload, ddt, location)) elif status == "skip": already.append((payload, ddt, location)) else: failures.append(payload) if done % 25 == 0 or done == len(jobs): log(f" {done}/{len(jobs)} ok={len(ok)} deja={len(already)} echecs={len(failures)}") log(f"\n{'Telecharges' if mode == 'liens' else 'Copies'} : {len(ok)}") log(f"Deja presents: {len(already)}") log(f"Echecs : {len(failures)}") if failures: report = os.path.join(args.out, "_echecs.txt") with open(report, "w", encoding="utf-8") as fh: fh.write("\n".join(failures)) log(f"Detail des echecs -> {report}") log("Relance la meme commande pour reessayer (les fichiers OK sont ignores).") # La passe EXIF traite aussi les fichiers deja presents : ainsi, installer # exiftool puis relancer la commande suffit a metadater tout le lot. exif_targets = ok + already exiftool = shutil.which("exiftool") if exif_targets and exiftool: log(f"\nEcriture des metadonnees EXIF (dates + GPS) sur {len(exif_targets)} fichiers...") write_exif(exif_targets, exiftool) log("EXIF ecrit.") elif exif_targets: log("\nexiftool absent : dates posees via le nom de fichier + date de modification.") log("(suffisant pour Windows et la plupart des galeries)") log("Pour de vraies metadonnees EXIF/GPS : winget install OliverBetz.ExifTool") log("puis relance la meme commande : elle repassera l'EXIF sur tout le lot") log("sans rien retelecharger.") if __name__ == "__main__": main()