Accueil > Divers (et d’été) > Informatique > Scripts et outils maison > Exporter les script « scripty »
Exporter les script « scripty »
vendredi 11 septembre 2026, par
Voici un script python pour experter les scripts écrits avec l’extension « Scripty »
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
r"""
Extrait les scripts utilisateur (ceux que TU as enregistrés) d'une extension
Chrome, à partir de son stockage local (chrome.storage.local et IndexedDB).
Utile pour des extensions du type "User JavaScript and CSS", "Custom JavaScript
for Websites", "Tampermonkey"/"Violentmonkey", etc.
Usage:
python save_extension_scripts.py
python save_extension_scripts.py --id
python save_extension_scripts.py --out .\dump
python save_extension_scripts.py --raw # dump brut sans essayer de deviner
Dépendance optionnelle : ccl_chromium_reader (lecture LevelDB "propre").
Si absente, le script utilise un parseur brut qui scanne les fichiers .log/.ldb
à la recherche des JSON objects — ça marche très bien tant que l'extension
stocke ses scripts en JSON dans chrome.storage.local (cas courant).
Installation (facultative) :
pip install git+https://github.com/cclgroupltd/ccl_chromium_reader
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import unicodedata
from pathlib import Path
DEFAULT_EXTENSION_ID = "milkbiaeapddfnpenedfgbfdacpbcbam"
def _chrome_user_data_candidates() -> list[Path]:
"""
Renvoie tous les dossiers 'User Data' Chromium-like connus, selon l'OS.
Un dossier 'User Data' contient un dossier par profil (Default, Profile 1, ...).
Sous Linux, le dossier de configuration EST lui-même l'équivalent de 'User Data'.
"""
home = Path.home()
candidates: list[Path] = []
if sys.platform.startswith("win"):
local = Path(os.environ.get("LOCALAPPDATA", home / "AppData" / "Local"))
candidates += [
local / "Google" / "Chrome" / "User Data",
local / "Google" / "Chrome Beta" / "User Data",
local / "Google" / "Chrome SxS" / "User Data",
local / "Chromium" / "User Data",
local / "Microsoft" / "Edge" / "User Data",
local / "BraveSoftware" / "Brave-Browser" / "User Data",
local / "Vivaldi" / "User Data",
]
elif sys.platform == "darwin":
app_support = home / "Library" / "Application Support"
candidates += [
app_support / "Google" / "Chrome",
app_support / "Google" / "Chrome Beta",
app_support / "Google" / "Chrome Canary",
app_support / "Chromium",
app_support / "Microsoft Edge",
app_support / "BraveSoftware" / "Brave-Browser",
app_support / "Vivaldi",
]
else:
# Linux (Debian, Ubuntu, Fedora, ...) — dossiers XDG classiques
xdg = Path(os.environ.get("XDG_CONFIG_HOME", home / ".config"))
candidates += [
xdg / "google-chrome",
xdg / "google-chrome-beta",
xdg / "google-chrome-unstable",
xdg / "chromium",
xdg / "microsoft-edge",
xdg / "BraveSoftware" / "Brave-Browser",
xdg / "vivaldi",
]
# Installations snap et flatpak
candidates += [
home / "snap" / "chromium" / "common" / "chromium",
home / "snap" / "chromium" / "current" / ".config" / "chromium",
home / ".var" / "app" / "com.google.Chrome" / "config" / "google-chrome",
home / ".var" / "app" / "org.chromium.Chromium" / "config" / "chromium",
]
return candidates
CHROME_USER_DATA_CANDIDATES = _chrome_user_data_candidates()
# ---------------------------------------------------------------------------
# Localisation des dossiers de stockage
# ---------------------------------------------------------------------------
def find_storage_dirs(extension_id: str) -> list[tuple[str, Path]]:
"""
Retourne la liste (label, chemin) de tous les dossiers de stockage
trouvés pour l'extension, sur tous les profils de tous les navigateurs.
"""
hits: list[tuple[str, Path]] = []
for user_data in CHROME_USER_DATA_CANDIDATES:
if not user_data.exists():
continue
for profile_dir in user_data.iterdir():
if not profile_dir.is_dir():
continue
# chrome.storage.local -> LevelDB
les = profile_dir / "Local Extension Settings" / extension_id
if les.is_dir():
hits.append((f"{user_data.parent.name}/{profile_dir.name}/Local Extension Settings", les))
# chrome.storage.sync -> LevelDB
ses = profile_dir / "Sync Extension Settings" / extension_id
if ses.is_dir():
hits.append((f"{user_data.parent.name}/{profile_dir.name}/Sync Extension Settings", ses))
# IndexedDB (utilisé par Tampermonkey/Violentmonkey, entre autres)
idb_dir = profile_dir / "IndexedDB"
if idb_dir.is_dir():
for sub in idb_dir.glob(f"chrome-extension_{extension_id}_*.indexeddb.leveldb"):
if sub.is_dir():
hits.append((f"{user_data.parent.name}/{profile_dir.name}/IndexedDB", sub))
return hits
# ---------------------------------------------------------------------------
# Lecture LevelDB
# ---------------------------------------------------------------------------
def read_leveldb_ccl(path: Path) -> list[tuple[str, bytes]]:
"""Lecture propre avec ccl_leveldb (si installé)."""
from ccl_chromium_reader.storage_formats import ccl_leveldb # type: ignore
records: list[tuple[str, bytes]] = []
db = ccl_leveldb.RawLevelDb(path)
for rec in db.iterate_records_raw():
if rec.state.name != "Live":
continue
try:
key = rec.user_key.decode("utf-8", errors="replace")
except Exception:
key = repr(rec.user_key)
records.append((key, rec.value))
return records
_LDB_BLOCK_SIZE = 32768
_LDB_HEADER_SIZE = 7 # crc(4) + length(2 LE) + type(1)
def _looks_like_leveldb_log(blob: bytes) -> bool:
"""
Un fichier .log au format LevelDB commence par un record header :
CRC(4) + Length(2 LE) + Type(1)
où Type ∈ {1=FULL, 2=FIRST, 3=MIDDLE, 4=LAST}. On teste le tout premier
header pour distinguer un vrai fichier .log d'un blob déjà "déballé".
"""
if len(blob) < _LDB_HEADER_SIZE:
return False
rec_type = blob[6]
length = int.from_bytes(blob[4:6], "little")
return rec_type in (1, 2, 3, 4) and 0 < length <= _LDB_BLOCK_SIZE - _LDB_HEADER_SIZE
def unwrap_leveldb_log(blob: bytes) -> bytes:
"""
Retire les en-têtes de bloc LevelDB (7 bytes tous les 32 KB) et concatène
les payloads pour reconstruire un flux continu de records WriteBatch.
Sans ce déballage, quand un enregistrement chevauche la frontière d'un
bloc, les 7 bytes d'en-tête (CRC + length + type) se retrouvent au milieu
d'un JSON et le rendent invalide.
Idempotent : si le blob n'a pas l'air d'être un fichier .log LevelDB
(déjà déballé, ou format inconnu), renvoie le blob tel quel.
"""
if not _looks_like_leveldb_log(blob):
return blob
out = bytearray()
for base in range(0, len(blob), _LDB_BLOCK_SIZE):
block = blob[base:base + _LDB_BLOCK_SIZE]
pos = 0
while pos + _LDB_HEADER_SIZE <= len(block):
length = int.from_bytes(block[pos + 4:pos + 6], "little")
rec_type = block[pos + 6]
# length=0 + type=0 : padding jusqu'à la fin du bloc
if length == 0 and rec_type == 0:
break
data_start = pos + _LDB_HEADER_SIZE
data_end = data_start + length
if data_end > len(block):
break # bloc tronqué
out.extend(block[data_start:data_end])
pos = data_end
return bytes(out) if out else blob
def read_leveldb_raw(path: Path) -> list[tuple[str, bytes]]:
"""
Fallback sans dépendance : lit les .log/.ldb, retire les en-têtes de bloc
LevelDB, et renvoie le payload brut. Les JSON seront extraits ensuite.
"""
blobs: list[tuple[str, bytes]] = []
for f in sorted(list(path.glob("*.log")) + list(path.glob("*.ldb"))):
try:
raw = f.read_bytes()
blobs.append((f.name, unwrap_leveldb_log(raw)))
except Exception as exc:
print(f" ! Impossible de lire {f.name}: {exc}", file=sys.stderr)
return blobs
# ---------------------------------------------------------------------------
# Extraction des scripts
# ---------------------------------------------------------------------------
# Ce qui, dans les données, ressemble à du JavaScript
JS_HINTS = re.compile(
r"(function\s*\(|=>|document\.|window\.|console\.|var\s+\w+|let\s+\w+|const\s+\w+|// ==UserScript==)"
)
def try_parse_json(data: bytes):
"""Tente de décoder une valeur LevelDB en JSON (avec préfixe binaire éventuel)."""
if not data:
return None
# Certaines valeurs chrome.storage sont préfixées d'un octet de type.
for start in (0, 1):
try:
return json.loads(data[start:].decode("utf-8"))
except Exception:
continue
return None
def walk_values(obj, path="root"):
"""Parcourt récursivement un objet JSON et yield (chemin, valeur_str)."""
if isinstance(obj, dict):
for k, v in obj.items():
yield from walk_values(v, f"{path}.{k}")
elif isinstance(obj, list):
for i, v in enumerate(obj):
yield from walk_values(v, f"{path}[{i}]")
elif isinstance(obj, str):
yield path, obj
# Champs typiquement utilisés par les extensions "User JavaScript / CSS"
# pour associer un script à ses métadonnées.
NAME_FIELDS = ("title", "name", "label", "scriptName", "displayName")
CODE_FIELDS = ("code", "script", "source", "js", "content", "body", "value")
# 'source' est aussi un champ métadonnée ("local"/"remote") -> on l'ignore comme code.
# 'script' peut être un dict imbriqué {lang, type, value} -> traité à part.
PARAM_FIELDS = (
"id", "uuid",
"enabled", "active", "disabled",
"trigger", "triggerOn", "trigger_on",
"runAt", "run_at", "runOn",
"matchType", "match_type", "type", "kind",
"matches", "match", "include", "includes", "url", "urls",
"urlPattern", "url_pattern", "pattern", "patterns",
"regex", "regexp",
"domain", "domains", "host", "hosts",
"exclude", "excludes", "excludeMatches", "exclude_matches",
"allFrames", "all_frames",
"createdAt", "created_at", "updatedAt", "updated_at",
"author", "description", "version",
)
def _extract_code_string(v) -> str | None:
"""Retourne v si c'est une chaîne JS plausible, sinon None."""
if isinstance(v, str) and len(v) >= 5 and (JS_HINTS.search(v) or len(v) >= 30):
return v
return None
def _find_code_in_record(record: dict) -> str | None:
"""
Cherche une chaîne de code dans un dict "script record". Le code peut être
directement à la racine, ou dans un sous-dict de type {lang, type, value}.
"""
for cf in CODE_FIELDS:
v = record.get(cf)
s = _extract_code_string(v)
if s is not None:
return s
if isinstance(v, dict):
for cf2 in CODE_FIELDS:
s = _extract_code_string(v.get(cf2))
if s is not None:
return s
# Balayage large : n'importe quel str long dans le dict
for v in record.values():
s = _extract_code_string(v)
if s is not None:
return s
return None
_ROUND_TRIP_HEADER = re.compile(
r"\A\s*//\s*=+\s*\n//\s*Script:\s*(?P.+?)\s*\n//\s*=+\s*\n\s*",
)
_ROUND_TRIP_FOOTER = re.compile(
r"\n//\s*-+\s*\n//\s*Param[eè]tres\s*:.*?//\s*-+\s*\n?\s*\Z",
re.DOTALL,
)
def strip_round_trip_annotations(code: str) -> tuple[str, str | None]:
"""
Si le code a été produit par un export précédent (// Script: NOM en tête,
// Paramètres: en pied), retire ces blocs et renvoie (code_nettoyé, nom).
Sinon renvoie (code_original, None).
"""
name = None
m = _ROUND_TRIP_HEADER.match(code)
if m:
name = m.group("name").strip()
code = code[m.end():]
code = _ROUND_TRIP_FOOTER.sub("", code)
return code, name
def walk_script_records(obj, path="root"):
"""
Cherche les dicts qui ressemblent à des enregistrements de script utilisateur :
ils ont un champ « nom » (title/name/...) ET du code JS quelque part.
Yield (chemin, dict_complet, code_string).
"""
if isinstance(obj, dict):
name = next(
(obj[nf] for nf in NAME_FIELDS
if isinstance(obj.get(nf), str) and obj[nf].strip()),
None,
)
code = _find_code_in_record(obj) if name else None
if name and code:
yield path, obj, code
return # ce dict est un script complet, on ne descend pas dedans
for k, v in obj.items():
yield from walk_script_records(v, f"{path}.{k}")
elif isinstance(obj, list):
for i, v in enumerate(obj):
yield from walk_script_records(v, f"{path}[{i}]")
def extract_json_objects(blob: bytes):
"""
Scanne un blob binaire à la recherche de JSON objects ({...}) valides
et les renvoie décodés. Robuste aux octets non-UTF8 qui traînent entre.
"""
text = blob.decode("utf-8", errors="replace")
n = len(text)
i = 0
while i < n:
# Un vrai JSON commence toujours par {" (une clé). On filtre les
# { fantômes qui traînent dans le bruit binaire de LevelDB (ex.
# bytes 0x7B au milieu d'un en-tête d'enregistrement) — sans ce
# filtre, mon scanner peut entrer dans une boucle interne géante
# avec depth déséquilibré et perdre TOUS les JSONs qui suivent.
if text[i] != "{" or (i + 1 < n and text[i + 1] != '"'):
i += 1
continue
depth = 0
in_str = False
escape = False
j = i
matched = False
while j < n:
ch = text[j]
if escape:
escape = False
elif in_str:
if ch == "\\":
escape = True
elif ch == '"':
in_str = False
elif ch == '"':
in_str = True
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
matched = True
break
j += 1
if not matched:
return # plus rien de fermé -> on arrête là
candidate = text[i:j + 1]
if len(candidate) >= 40:
try:
yield json.loads(candidate)
i = j + 1
continue
except json.JSONDecodeError:
pass
i += 1 # parse raté (ou objet trop court) : essaye au char suivant
TRIGGER_TYPE_LABELS = {"a": "Automatically", "m": "Manually"}
TRIGGER_VALUE_LABELS = {"pageload": "On page load"}
FILTER_IDENT_LABELS = {"pattern": "URL Pattern", "regex": "Regex", "domain": "Domain"}
FILTER_COND_LABELS = {
"contains": "Matches", "equals": "Equals", "startswith": "Starts with",
}
def format_param_value(v) -> str:
if isinstance(v, dict):
parts = [f"{k}={format_param_value(vv)}" for k, vv in v.items()]
return "{" + ", ".join(parts) + "}"
if isinstance(v, (list, tuple)):
if not v:
return "[]"
if all(isinstance(x, str) for x in v):
return ", ".join(v)
return json.dumps(v, ensure_ascii=False)
def pretty_trigger(trig: dict) -> list[str]:
lines = []
t = trig.get("type")
if isinstance(t, str):
lines.append(f"// Trigger: {TRIGGER_TYPE_LABELS.get(t, t)}")
v = trig.get("value")
if isinstance(v, str):
lines.append(f"// Run at: {TRIGGER_VALUE_LABELS.get(v, v)}")
for k, val in trig.items():
if k in ("type", "value"):
continue
lines.append(f"// Trigger.{k}: {format_param_value(val)}")
return lines
def pretty_filter(filt: dict) -> list[str]:
lines = []
ident = filt.get("identifier")
cond = filt.get("condition")
if isinstance(ident, str):
lines.append(f"// Filter type: {FILTER_IDENT_LABELS.get(ident, ident)}")
if isinstance(cond, str):
lines.append(f"// Condition: {FILTER_COND_LABELS.get(cond, cond)}")
matches = filt.get("matches")
if isinstance(matches, list) and matches:
lines.append("// Matches:")
for m in matches:
lines.append(f"// - {m}")
elif isinstance(filt.get("value"), str):
lines.append(f"// Value: {filt['value']}")
for k, val in filt.items():
if k in ("identifier", "condition", "matches", "value"):
continue
lines.append(f"// Filter.{k}: {format_param_value(val)}")
return lines
def build_script_text(record: dict, code: str) -> tuple[str, str]:
"""Renvoie (nom_suggéré_de_fichier, contenu_texte_avec_header_et_footer)."""
# 1. Nettoie le code des header/footer d'un export précédent (round-trip)
code, name_from_header = strip_round_trip_annotations(code)
# 2. Prend le nom : title stocké > nom du header > rien.
# Si le title stocké est une URL (cas d'un import qui a écrasé le nom),
# on préfère le nom du header s'il est présent.
name = next(
(record[nf] for nf in NAME_FIELDS
if isinstance(record.get(nf), str) and record[nf].strip()),
None,
)
if name_from_header and (not name or re.match(r"^https?://", name)):
name = name_from_header
header = [
"// " + "=" * 70,
f"// Script: {name if name else '(sans nom)'}",
"// " + "=" * 70,
]
footer = ["", "// " + "-" * 70, "// Paramètres:"]
if isinstance(record.get("trigger"), dict):
footer += pretty_trigger(record["trigger"])
if isinstance(record.get("filter"), dict):
footer += pretty_filter(record["filter"])
# Autres champs simples
handled = {"trigger", "filter", "script"} | set(NAME_FIELDS)
for pf in PARAM_FIELDS:
if pf in handled:
continue
if pf in record and record[pf] not in (None, "", [], {}):
footer.append(f"// {pf}: {format_param_value(record[pf])}")
handled.add(pf)
extras = {
k: v for k, v in record.items()
if k not in handled and v not in (None, "", [], {})
}
if extras:
footer.append("// ---")
for k, v in extras.items():
footer.append(f"// {k}: {format_param_value(v)}")
footer.append("// " + "-" * 70)
text = "\n".join(header) + "\n\n" + code.rstrip() + "\n" + "\n".join(footer) + "\n"
fname = sanitize_filename(name) if name else "script"
return fname, text
def _strip_accents(s: str) -> str:
"""é -> e, ñ -> n, ç -> c, etc. Décompose puis retire les marques diacritiques."""
nfkd = unicodedata.normalize("NFKD", s)
return "".join(ch for ch in nfkd if not unicodedata.combining(ch))
def sanitize_filename(name: str, max_len: int = 80) -> str:
name = _strip_accents(name)
name = re.sub(r"[^A-Za-z0-9._-]+", "_", name).strip("_")
return name[:max_len] or "script"
_LDB_INSERT_KEY = re.compile(rb"\x01(?P[\x01-\x7f])(?P[a-z0-9_\-]{4,64})")
_LDB_DELETE_KEY = re.compile(rb"\x00(?P[\x01-\x7f])(?P[a-z0-9_\-]{4,64})")
def find_deleted_ids(blob: bytes) -> set[str]:
"""
Détecte les IDs supprimés dans un blob LevelDB brut (fichier .log ou .ldb).
Format WriteBatch : []
type = 0x00 pour DELETE, 0x01 pour PUT/INSERT.
Un ID est considéré supprimé si sa dernière opération est un DELETE.
"""
inserts: dict[str, int] = {}
deletes: dict[str, int] = {}
for m in _LDB_INSERT_KEY.finditer(blob):
klen = m.group("len")[0]
# Vérifie que la longueur varint correspond à la longueur de clé capturée
key = m.group("key")
if len(key) >= klen:
inserts[key[:klen].decode("ascii", errors="ignore")] = m.start()
for m in _LDB_DELETE_KEY.finditer(blob):
klen = m.group("len")[0]
key = m.group("key")
if len(key) >= klen:
deletes[key[:klen].decode("ascii", errors="ignore")] = m.start()
return {
k for k in set(inserts) | set(deletes)
if deletes.get(k, -1) > inserts.get(k, -1)
}
def _by_id_or_name(record: dict) -> str:
"""Clé de dédup : préfère l'id explicite du script, sinon nom+début de code."""
if isinstance(record.get("id"), str):
return "id:" + record["id"]
name = next(
(record[nf] for nf in NAME_FIELDS
if isinstance(record.get(nf), str)),
"",
)
code = _find_code_in_record(record) or ""
return f"nc:{name}:{code[:200]}"
def extract_scripts_from_records(records: list[tuple[str, bytes]]) -> list[tuple[str, str]]:
"""
Renvoie (nom_suggéré, contenu_texte) pour chaque script trouvé.
Le contenu inclut un en-tête (nom du script) et un pied (paramètres).
"""
scripts: list[tuple[str, str]] = []
# On garde uniquement la version la plus récente de chaque script (par id/nom)
latest: dict[str, tuple[dict, str]] = {}
deleted_ids: set[str] = set()
def consider(record: dict, code: str):
latest[_by_id_or_name(record)] = (record, code)
for key, value in records:
# 1) La valeur est-elle un JSON complet ?
parsed = try_parse_json(value)
if parsed is not None:
for _p, rec, code in walk_script_records(parsed, key):
consider(rec, code)
continue
# 2) Fallback pour un blob brut (fichier .log entier) : on cherche
# tous les JSON objects imbriqués dans les octets, ET on repère
# les IDs qui ont été supprimés via les marqueurs LevelDB.
deleted_ids |= find_deleted_ids(value)
for obj in extract_json_objects(value):
for _p, rec, code in walk_script_records(obj, key):
consider(rec, code)
# Filtre les scripts supprimés (dernier op = DELETE dans le WAL LevelDB)
for did in deleted_ids:
latest.pop("id:" + did, None)
# 3) Toute dernière chance : rien trouvé -> scan JS brut, sans métadonnées
if not latest:
for key, value in records:
try:
text = value.decode("utf-8", errors="ignore")
except Exception:
continue
for m in re.finditer(r'"((?:[^"\\]|\\.){50,})"', text):
candidate = bytes(m.group(1), "utf-8").decode("unicode_escape", errors="replace")
if JS_HINTS.search(candidate):
fake = {"title": f"(brut - {key})", "code": candidate}
consider(fake, candidate)
for rec, code in latest.values():
fname, text = build_script_text(rec, code)
scripts.append((fname, text))
return scripts
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--id", default=DEFAULT_EXTENSION_ID,
help=f"ID de l'extension (défaut: {DEFAULT_EXTENSION_ID})")
parser.add_argument("--out", default=None,
help="Dossier de sortie (défaut: ./extension__userscripts)")
parser.add_argument("--raw", action="store_true",
help="Écrit aussi les valeurs brutes JSON complètes (une par record)")
args = parser.parse_args()
storages = find_storage_dirs(args.id)
if not storages:
print(f"Aucun stockage trouvé pour l'extension {args.id}.", file=sys.stderr)
print("Chrome doit être FERMÉ pour que les fichiers LevelDB soient lisibles.",
file=sys.stderr)
return 1
print(f"{len(storages)} emplacement(s) de stockage trouvé(s) :")
for label, p in storages:
print(f" - [{label}] {p}")
try:
import ccl_chromium_reader # noqa: F401
use_ccl = True
print("Lecture LevelDB via ccl_chromium_reader.")
except ImportError:
use_ccl = False
print("ccl_chromium_reader absent -> parseur JSON brut (fonctionnel pour")
print("les extensions qui stockent en JSON dans chrome.storage.local).")
out_base = Path(args.out) if args.out else Path.cwd() / f"extension_{args.id}_userscripts"
out_base.mkdir(parents=True, exist_ok=True)
total_scripts = 0
single = len(storages) == 1
for label, storage in storages:
# Un seul emplacement -> on écrit directement dans out_base,
# sinon on crée un sous-dossier par emplacement.
if single:
dest = out_base
else:
dest = out_base / sanitize_filename(label.replace("/", "_"))
dest.mkdir(parents=True, exist_ok=True)
try:
records = read_leveldb_ccl(storage) if use_ccl else read_leveldb_raw(storage)
except Exception as exc:
print(f" ! Échec lecture {storage}: {exc}", file=sys.stderr)
print(" (Chrome est peut-être encore ouvert et verrouille les fichiers.)",
file=sys.stderr)
continue
print(f"\n[{label}] {len(records)} record(s) lus.")
if args.raw:
raw_dir = dest / "_raw"
raw_dir.mkdir(exist_ok=True)
for i, (k, v) in enumerate(records):
(raw_dir / f"{i:04d}_{sanitize_filename(k)}.bin").write_bytes(v)
scripts = extract_scripts_from_records(records)
print(f" {len(scripts)} script(s) JS extrait(s).")
for i, (name, code) in enumerate(scripts):
fname = f"{i:03d}_{name}.js.txt"
(dest / fname).write_text(code, encoding="utf-8", errors="replace")
total_scripts += len(scripts)
print(f"\nTerminé. {total_scripts} script(s) enregistré(s) dans : {out_base}")
if total_scripts == 0:
print("Rien trouvé qui ressemble à du JS. Réessaie avec --raw pour")
print("inspecter les valeurs brutes et affiner l'extraction.")
return 0
if __name__ == "__main__":
raise SystemExit(main())


