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