exl100

Émulateur Exelvision EXL 100 (1984) dans le navigateur : dsp-cpp en WebAssembly, cartouches et cassettes BASIC
git clone https://git.schurger.org/exl100.git
Log | Files | Refs | README

get_dcexel.py (10390B)


      1 #!/usr/bin/env python3
      2 """Complète la bibliothèque avec les programmes de DCExel (http://dcexel.free.fr, Daniel Coulom) :
      3 159 pages de programmes, chacune avec des fichiers propres <slug>_exl100k7.zip (cassette .k7
      4 transférée numériquement) ou <slug>_exl100rom.zip (cartouche). On ne prend que ce qui manque à
      5 TOSEC/webmo (même CRC ou même titre normalisé = doublon), dans roms/tapes/ et roms/cartridges/
      6 (non versionnés), et on complète les deux titles.json avec la source « DCExel ».
      7 
      8 Usage : python3 -I tools/get_dcexel.py [--dry-run] [--limit N]
      9 Les archives téléchargées sont du contenu non sûr : on n'en extrait que des .k7/.rom/.bin, à plat.
     10 """
     11 import glob
     12 import io
     13 import json
     14 import os
     15 import re
     16 import sys
     17 import unicodedata
     18 import urllib.request
     19 import zipfile
     20 import zlib
     21 from html import unescape
     22 
     23 BASE = "http://dcexel.free.fr/programmes/_html/"
     24 ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
     25 TAPES = os.path.join(ROOT, "roms", "tapes")
     26 CARTS = os.path.join(ROOT, "roms", "cartridges")
     27 CACHE = os.path.join(ROOT, "roms", "dcexel")  # téléchargements bruts (ignoré par git)
     28 UA = {"User-Agent": "Mozilla/5.0 (exl100.schurger.org ; moisson DCExel)"}
     29 CART_SIZES = {8192, 16384, 32256, 32768}
     30 
     31 
     32 def get(url, binary=False):
     33     with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=60) as r:
     34         data = r.read()
     35     return data if binary else data.decode("latin-1")
     36 
     37 
     38 def text(html):
     39     return unescape(" ".join(re.sub(r"<[^>]+>", " ", html).split()))
     40 
     41 
     42 def norm(s):
     43     s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode().lower()
     44     return re.sub(r"[^a-z0-9]+", "", s)
     45 
     46 
     47 def slug_of(title):
     48     s = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode().lower()
     49     return re.sub(r"-+", "-", re.sub(r"[^a-z0-9]+", "-", s)).strip("-") or "sans-titre"
     50 
     51 
     52 CATS = {"jeu": "jeu", "educatif": "educatif", "utilitaire": "application", "systeme": "application",
     53         "autres": "application", "demonstration": "demo"}
     54 
     55 
     56 def program_pages():
     57     index = get(BASE + "index.html")
     58     pages = set()
     59     for cat in re.findall(r'href="(categorie_[a-z_]+\.html)"', index):
     60         pages.update(re.findall(r'href="(\.\./[a-z0-9_-]+/index\.html)"', get(BASE + cat)))
     61     return sorted(pages)
     62 
     63 
     64 def parse_program(rel):
     65     html = get(BASE + rel)
     66     title = text(re.search(r"<h1[^>]*>(.*?)</h1>", html, re.S).group(1)) if re.search(r"<h1", html) else \
     67         text(re.search(r"<title>(.*?)</title>", html, re.S).group(1))
     68     t = text(html)
     69     m = {k: (re.search(k + r"\s*:\s*(.*?)\s*(?:•|•|Ann|Cat|Auteur|©|$)", t) or [None, ""])[1]
     70          for k in ("Editeur", "Année", "Catégorie")}
     71     files = sorted(set(f for f in re.findall(r'href="([^"]+\.zip)"', html)))
     72     year = m["Année"].strip()
     73     if not re.match(r"\d{4}$", year):
     74         year = ""
     75     by = m["Editeur"].strip()
     76     if norm(by) == "inconnu":
     77         by = ""
     78     return {"rel": rel, "title": title.strip(), "by": by, "y": year,
     79             "cat": CATS.get(norm(m["Catégorie"]), "application"), "files": files}
     80 
     81 
     82 def members(data):
     83     """Fichiers utiles (.k7/.rom/.bin) d'une archive zip, nom à plat, sans suivre de chemin."""
     84     out = []
     85     with zipfile.ZipFile(io.BytesIO(data)) as z:
     86         for info in z.infolist():
     87             name = os.path.basename(info.filename)
     88             if info.is_dir() or not name.lower().endswith((".k7", ".rom", ".bin")) or info.file_size > 4 << 20:
     89                 continue
     90             out.append((name, z.read(info)))
     91     return out
     92 
     93 
     94 def side_label(stem, common):
     95     """Libellé d'une face/cassette d'après ce qui distingue le nom du membre (arcade-a -> « Face A »,
     96     solfege1b -> « Cassette 1, face B », pluriel-2 -> « Cassette 2 », vol1 -> « Volume 1 », sinon le reste)."""
     97     rest = stem[len(common):].strip("-_ ").lower()
     98     m = re.fullmatch(r"([ab])", rest)
     99     if m:
    100         return "Face " + m.group(1).upper()
    101     m = re.fullmatch(r"(\d+)", rest)
    102     if m:
    103         return "Cassette " + m.group(1)
    104     m = re.fullmatch(r"(\d+)[-_ ]?([ab])", rest)
    105     if m:
    106         return f"Cassette {m.group(1)}, face {m.group(2).upper()}"
    107     m = re.fullmatch(r"vol(?:ume)?[-_ ]?(\d+)", rest)
    108     if m:
    109         return "Volume " + m.group(1)
    110     return rest.replace("-", " ").replace("_", " ").capitalize() if rest else "Principale"
    111 
    112 
    113 def link_sides(tapes):
    114     """Archives DCExel à plusieurs .k7 (faces A/B, cassettes 1/2…) : relie les entrées déjà importées
    115     (retrouvées par CRC) par une liste `sides` [{f, l}] sur chacune, pour que la page offre l'échange de
    116     cassette à chaud. Ne touche à rien d'autre."""
    117     by_crc = {}
    118     for fn in tapes:
    119         path = os.path.join(TAPES, fn)
    120         if os.path.exists(path):
    121             by_crc[zlib.crc32(open(path, "rb").read()) & 0xffffffff] = fn
    122     linked = 0
    123     for z in sorted(glob.glob(os.path.join(CACHE, "*k7.zip"))):
    124         try:
    125             items = [(n, d) for n, d in members(open(z, "rb").read()) if n.lower().endswith(".k7")]
    126         except Exception:
    127             continue
    128         if len(items) < 2 or len(items) > 6:
    129             continue  # les recueils de dizaines de programmes ne sont pas des faces
    130         stems = [re.sub(r"_exl100$", "", os.path.splitext(n)[0], flags=re.I) for n, _ in items]
    131         if any(re.search(r"\d{4}\.\d{2}", st) for st in stems):
    132             continue  # versions datées (démo Jester), pas des faces
    133         common = os.path.commonprefix(stems)
    134         sides = []
    135         for (n, d), stem in zip(items, stems):
    136             fn = by_crc.get(zlib.crc32(d) & 0xffffffff)
    137             if fn and all(x["f"] != fn for x in sides):
    138                 sides.append({"f": fn, "l": side_label(stem, common)})
    139         if len(sides) < 2:
    140             continue
    141         sides.sort(key=lambda x: (re.sub(r"\d+", lambda m: m.group(0).zfill(3), x["l"])))
    142         # Titre du programme (une seule ligne dans la bibliothèque) : celui de la première face, sans son
    143         # numéro (« Arcade (1) » -> « Arcade », « Initiation au Basic 1 » -> « Initiation au Basic »,
    144         # « Initiation a l'EXELBASIC - Volume 2 v1.1 » -> « Initiation a l'EXELBASIC »).
    145         first = tapes[sides[0]["f"]]["t"]
    146         group = re.sub(r"\s*\(\d+\)$|\s*-\s*(Volume|Tome|Cassette|Partie)\s*\d+.*$|\s+\d+$", "", first, flags=re.I).strip()
    147         for sd in sides:
    148             tapes[sd["f"]]["sides"] = sides
    149             tapes[sd["f"]]["group"] = group or first
    150         linked += 1
    151     print(f"DCExel : {linked} programmes à plusieurs faces/cassettes reliés", file=sys.stderr)
    152 
    153 
    154 def main():
    155     if "--link" in sys.argv:
    156         tapes = json.load(open(os.path.join(TAPES, "titles.json"), encoding="utf-8"))
    157         link_sides(tapes)
    158         with open(os.path.join(TAPES, "titles.json"), "w", encoding="utf-8") as f:
    159             json.dump(tapes, f, ensure_ascii=False, indent=1)
    160         return
    161     dry = "--dry-run" in sys.argv
    162     limit = int(sys.argv[sys.argv.index("--limit") + 1]) if "--limit" in sys.argv else 10 ** 6
    163     tapes = json.load(open(os.path.join(TAPES, "titles.json"), encoding="utf-8"))
    164     carts = json.load(open(os.path.join(CARTS, "titles.json"), encoding="utf-8"))
    165     have_titles = {norm(v["t"]) for v in list(tapes.values()) + list(carts.values())}
    166     have_crc = set()
    167     for d, names in ((TAPES, tapes), (CARTS, carts)):
    168         for fn in names:
    169             p = os.path.join(d, fn)
    170             if os.path.exists(p):
    171                 have_crc.add(zlib.crc32(open(p, "rb").read()) & 0xffffffff)
    172     os.makedirs(CACHE, exist_ok=True)
    173     pages = program_pages()
    174     print(f"{len(pages)} programmes sur DCExel", file=sys.stderr)
    175     added = {"k7": 0, "rom": 0}
    176     for rel in pages[:limit]:
    177         try:
    178             p = parse_program(rel)
    179         except Exception as e:  # page cassée : on continue
    180             print(f"  ! {rel}: {e}", file=sys.stderr)
    181             continue
    182         if norm(p["title"]) in have_titles:
    183             continue
    184         wanted = [f for f in p["files"] if re.search(r"_(exl100|exeltel)(k7|rom)\.zip$", f, re.I)]
    185         if not wanted:
    186             continue
    187         for f in wanted:
    188             kind = "k7" if f.lower().endswith("k7.zip") else "rom"
    189             url = BASE + rel.rsplit("/", 1)[0] + "/" + f if not f.startswith("http") else f
    190             local = os.path.join(CACHE, os.path.basename(f))
    191             try:
    192                 if not os.path.exists(local):
    193                     open(local, "wb").write(get(url, binary=True))
    194                 items = members(open(local, "rb").read())
    195             except Exception as e:
    196                 print(f"  ! {f}: {e}", file=sys.stderr)
    197                 continue
    198             for i, (name, data) in enumerate(items):
    199                 crc = zlib.crc32(data) & 0xffffffff
    200                 if crc in have_crc:
    201                     continue
    202                 if kind == "rom" and len(data) not in CART_SIZES:
    203                     print(f"  ? {name}: taille cartouche inhabituelle {len(data)}", file=sys.stderr)
    204                 suffix = "" if len(items) == 1 else f"-{i + 1}"
    205                 slug = slug_of(p["title"]) + suffix
    206                 meta = {"t": p["title"] + ("" if not suffix else f" ({i + 1})"), "by": p["by"] or "DCExel",
    207                         "y": p["y"] or "", "cat": p["cat"], "src": "DCExel"}
    208                 out, titles, d = (f"{slug}.k7", tapes, TAPES) if kind == "k7" else (f"{slug}.bin", carts, CARTS)
    209                 if out in titles:
    210                     continue
    211                 print(f"  + {out:34s} {len(data):6d} o  {meta['t']} — {meta['by']} {meta['y']} [{meta['cat']}]",
    212                       file=sys.stderr)
    213                 if not dry:
    214                     open(os.path.join(d, out), "wb").write(data)
    215                     titles[out] = meta
    216                 have_crc.add(crc)
    217                 have_titles.add(norm(p["title"]))
    218                 added[kind] += 1
    219     link_sides(tapes)
    220     if not dry:
    221         for d, titles in ((TAPES, tapes), (CARTS, carts)):
    222             with open(os.path.join(d, "titles.json"), "w", encoding="utf-8") as f:
    223                 json.dump(dict(sorted(titles.items(), key=lambda kv: kv[1]["t"].lower())), f,
    224                           ensure_ascii=False, indent=1)
    225     print(f"DCExel : {added['k7']} cassettes et {added['rom']} cartouches {'à ajouter' if dry else 'ajoutées'}",
    226           file=sys.stderr)
    227 
    228 
    229 if __name__ == "__main__":
    230     main()