Accueil > Divers (et d’été) > Informatique > Scripts et outils maison > Exporter les script « scripty »

Exporter les script « scripty »

vendredi 11 septembre 2026, par Paul Courbis

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

Un message, un commentaire ?

modération a priori

Ce forum est modéré a priori : votre contribution n’apparaîtra qu’après avoir été validée par les responsables.

Qui êtes-vous ?
Votre message

Pour créer des paragraphes, laissez simplement des lignes vides.

Les spams donneront systématiquement lieu à dépôt de plainte. Les messages peu aimables ou comportant trop de fautes d'orthographe seront purement et simplement supprimés sans publication. Aucune obligation de publication ne pourra être opposée au webmaster, sauf éventuel droit de réponse dûment justifié.