Files
morrowind/scripts/read_archive_plugins.py
Oliver Hamlet f5f1f07f04 Add scripts to download and read Morrowind Modding History mods
The script takes the CSV export of the index of mods that were hosted on
Morrowind Modding History and Great House Fliggerty before they went
down, as downloaded from <https://modlist.altervista.org/mmh/>. That CSV
is included in this commit, and results in 61 GB of downloaded files.

This commit also includes a script that reads the index CSV and
downloads directory and outputs a TSV file of all the plugin files in
the downloaded files, looking inside archives. The output TSV file is
also included in this commit.

Both scripts include a few hacks to workaround issues affecting a small
minority of mods:

- The mod IDs 2770, 2771 and 2772 are three separate uploads but form
  the three parts of a multipart 7z archive, so their downloaded files
  are named to allow the archive to be read by 7-zip.
- There are a few downloads that are just bare plugin files: these are
  written into zip files so that the plugin filenames can be preserved,
  since the download file name needs to be the mod ID.
- Some download URLs use tinyurl: these have a hardcoded mapping to the
  URLs that tinyurl points to, in case the tinyurl URLs stop working.
- Nexus Mods URLs are skipped as they're already covered by the
  fetch_nexus_plugins_index.py script.

The download script also handles the many cases where mod IDs point to
downloads that are no longer available, by skipping a mod entirely if
its ID appears in a hardcoded whitelist of known invalid downloads.
2026-01-16 00:36:31 +00:00

317 lines
12 KiB
Python

#!/usr/bin/env python
# /// script
# requires-python = ">=3"
# dependencies = ["acefile==0.6.13"]
# ///
# https://modlist.altervista.org/mmh/index.php allows you to download a CSV of
# all listed mods. This script takes that CSV file and a directory of mod file
# downloads. Each file must have a name that's equal to the relevant mod ID in
# the CSV file. This script will read the files, looking inside archives to
# extract plugin names, and write a tab-separated file that maps plugins to
# mods.
#
# The script expects 7-zip to be installed and for the 7z executable to be
# accessible on the PATH.
#
# This script will also work with other input CSV/TSV files and downloads
# directories, so long as the file has Id, Name, Site and Link columns. However,
# the script has:
#
# - special handling for transforming the input file's URLs when the Site cell's
# value is 'GHF', 'Fliggerty' or 'MMH'
# - hardcoded file type overrides for the download filenames 2273, 4246, 4247,
# 8202, 10811, 12076, 12972, 13138 and 13375
# - special handling for the download filenames 2770.7z.001, 2770.7z.002 and
# 2770.7z.003
#
# The output TSV file has fileName, modId, url, modName and site columns. The
# modId, modName and site values are unchanged from the input Id, Name and Site
# columns respectively.
import argparse
from collections import Counter
import csv
from enum import Enum
import gzip
import logging
import os
from pathlib import Path
import re
import subprocess
import tempfile
from typing import NamedTuple
import acefile
MMH_REGEX = re.compile(r'\d{1,3}-\d{1,5}')
PLUGIN_EXTENSION_REGEX = re.compile(r'\.es[p|m]', re.IGNORECASE)
class Mod(NamedTuple):
id: int
name: str
site: str
link: str
mod_url: str
class ModPlugin(NamedTuple):
mod_id: str
mod_name: str
site: str
mod_url: str
plugin_name: str
class MagicBytes(NamedTuple):
offset: int
magic: bytes
class FileType(Enum):
SEVENZIP = MagicBytes(0, b'\x37\x7A\xBC\xAF\x27\x1C')
ZIP = MagicBytes(0, b'\x50\x4B\x03\x04')
PREFIXED_ZIP = MagicBytes(0, b'\x50\x4B\x30\x30\x50\x4B\x03\x04')
EMPTY_ZIP = MagicBytes(0, b'\x50\x4B\x05\x06')
RAR = MagicBytes(0, b'\x52\x61\x72\x21\x1A\x07\x00')
GZIP = MagicBytes(0, b'\x1F\x8B')
ACE = MagicBytes(7, b'**ACE**')
CAB = MagicBytes(0, b'MSCF')
PDF = MagicBytes(0, b'%PDF')
ESP = MagicBytes(0, b'TES3')
RTF = MagicBytes(0, b'{\\rtf1')
DDS = MagicBytes(0, b'DDS')
NIF = MagicBytes(0, b'NetImmerse File Format')
MZ = MagicBytes(0, b'MZ')
WMV = MagicBytes(0, b'\x30\x26\xB2\x75\x8E\x66\xCF\x11\xA6\xD9\x00\xAA\x00\x62\xCE\x6C')
JIF = MagicBytes(0, b'\xFF\xD8\xFF\xDB')
JFIF = MagicBytes(0, b'\xFF\xD8\xFF\xE0\x00\x10\x4A\x46\x49\x46\x00\x01')
COMPOUND = MagicBytes(0, b'\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1')
CHM = MagicBytes(0, b'\x49\x54\x53\x46\x03\x00\x00\x00\x60\x00\x00\x00')
CPIO = MagicBytes(0, b'070707')
HTML = 1
TXT = 2
def is_supported_by_7z(self):
match self:
case FileType.SEVENZIP | FileType.ZIP | FileType.PREFIXED_ZIP | FileType.EMPTY_ZIP | FileType.RAR | FileType.CAB | FileType.MZ | FileType.COMPOUND | FileType.CHM | FileType.CPIO:
return True
case _:
# Treat GZIP as unsupported because 7z can only see the inner
# archive, not anything inside that.
return False
def are_magic_bytes_present(self, buffer):
if not isinstance(self.value, MagicBytes):
return False
start = self.value.offset
end = start + len(self.value.magic)
return buffer[start:end] == self.value.magic
FILE_TYPE_OVERRIDES = {
# These archives are prefixed with PHP error messages.
'13375': FileType.SEVENZIP,
'13138': FileType.SEVENZIP,
'12972': FileType.SEVENZIP,
# These files are plain text files.
'12076': FileType.TXT,
'10811': FileType.TXT,
'8202': FileType.TXT,
'4247': FileType.HTML,
'4246': FileType.TXT,
'2273': FileType.TXT,
}
def get_altervista_url(mod_id: str) -> str:
return f'https://modlist.altervista.org/mmh/index.php?operation=view&pk0={mod_id}'
def get_url(mod_id: str, site: str, link: str):
match site:
case 'GHF' | 'Fliggerty':
return get_altervista_url(mod_id)
case 'MMH':
if link.startswith('https://drive.google.com/'):
return get_altervista_url(mod_id)
# Handle a handful of special cases. The tinyurl.com links are
# replaced with the links that tinyurl redirects to.
match link:
case ('https://tinyurl.com/mwscpted'
| 'https://tinyurl.com/issilarMN'
| 'https://tinyurl.com/mwchimlight'
| 'https://tinyurl.com/issilar11'
| 'Qr4W2QrD#PgUHt_MB2iae7MosUzB_T5QkIK_5cASelSQnTddL67U'
| 'U6wzWTBL#7oxESyEYeCdfk4hEkjPO9BHKNKlWC673OovL-pIbgBc'
| 'Z2ZigRKA#JFPfvdUJG9J_iT_GMQ228qe9ksJmZgD_k0l3pAHu_Ec'):
return get_altervista_url(mod_id)
case 'https://www.nexusmods.com/morrowind/mods/53730':
return link
if not MMH_REGEX.fullmatch(link):
raise RuntimeError(f'Unexpected MMH link style: {link}')
return f'https://web.archive.org/web/20161103152243/https://mw.modhistory.com/download-{link}'
case _:
return link
def convert_row(row) -> Mod:
id = row['Id']
site = row['Site']
link = row['Link']
return Mod(
id,
row['Name'],
site,
link,
get_url(id, site, link)
)
def read_mods_csv(input, input_delimiter) -> dict[str, Mod]:
reader = csv.DictReader(input,delimiter=input_delimiter)
return {mod.id: mod for mod in [convert_row(row) for row in reader]}
def get_file_type(file_path: Path) -> FileType:
if file_path.name in FILE_TYPE_OVERRIDES:
return FILE_TYPE_OVERRIDES[file_path.name]
# Long enough to hold the longest magic byte sequence, which is NIF's.
BUFFER_SIZE = 22
with open(file_path, 'rb') as input:
buffer = input.read(BUFFER_SIZE)
for file_type in FileType:
if isinstance(file_type.value, MagicBytes):
if file_type.are_magic_bytes_present(buffer):
return file_type
raise RuntimeError(f'Unknown file type for: {file_path}')
def list_archive_plugins(archive_path) -> list[str]:
# Don't check the return code because it can be non-zero due to archive read
# errors that don't prevent at least some useful information being printed.
result = subprocess.run(
['7z', 'l', '-ba', '-sccUTF-8', archive_path],
capture_output=True,
text=True,
encoding='utf8'
)
paths = [Path(l[53:]) for l in result.stdout[:-1].split('\n')]
# Error if there are no paths, as that suggests something went wrong.
if not paths:
raise RuntimeError(f'Failed to read any paths in the archive at {archive_path}')
return [p.name for p in paths if PLUGIN_EXTENSION_REGEX.fullmatch(p.suffix)]
def list_ace_archive_plugins(archive_path: Path) -> list[str]:
with acefile.open(str(archive_path)) as archive:
return [f.name for f in (Path(m.filename) for m in archive if m.is_reg()) if PLUGIN_EXTENSION_REGEX.fullmatch(f.suffix)]
def list_gzip_archive_plugins(archive_path: Path) -> list[str]:
with gzip.open(archive_path, 'rb') as archive:
decompressed = archive.read()
for file_type in FileType:
if file_type.is_supported_by_7z() and isinstance(file_type.value, MagicBytes):
if file_type.are_magic_bytes_present(decompressed):
# Write out a temporary file to call 7-zip on.
with tempfile.NamedTemporaryFile(delete_on_close=False) as file:
file.write(decompressed)
file.close()
return list_archive_plugins(file.name)
raise RuntimeError(f'Unsupported content in gzip file at {archive_path}: {decompressed}')
def list_plugins(file_path: Path, file_type: FileType) -> list[str]:
if file_type == FileType.ACE:
return list_ace_archive_plugins(file_path)
elif file_type == FileType.GZIP:
return list_gzip_archive_plugins(file_path)
elif file_type == FileType.ESP:
raise RuntimeError(f'Unsupported ESP file for {file_path}: put this in a zip file to preserve its filename')
elif file_type.is_supported_by_7z():
return list_archive_plugins(file_path)
else:
return []
def write_to_tsv(output_path: str, mod_plugins: list[ModPlugin]):
with open(output_path, 'w', newline='', encoding='utf8') as tsv_file:
field_names = ['fileName', 'modId', 'url', 'modName', 'site']
writer = csv.DictWriter(tsv_file, delimiter='\t', fieldnames=field_names)
writer.writeheader()
for m in mod_plugins:
writer.writerow({
'fileName': m.plugin_name,
'modId': m.mod_id,
'url': m.mod_url,
'modName': m.mod_name,
'site': m.site
})
def get_sort_key(mod_plugin: ModPlugin):
return (mod_plugin.plugin_name, int(mod_plugin.mod_id) if mod_plugin.mod_id.isdigit() else mod_plugin.mod_id)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument('-i', '--index-path', default=Path.cwd() / 'data' / 'MMH_&_Fliggerty_Mods.csv')
parser.add_argument('--index-delimiter', default=',')
parser.add_argument('-d', '--downloads-path', default=Path.home() / "Downloads" / 'mmh_fliggerty_mods')
parser.add_argument('-o', '--output-path', default=Path.cwd() / 'data' / 'mmh_fliggerty_plugins.tsv')
parser.add_argument('-l', '--log-path', default=Path(__file__).with_suffix('.log'))
parser.add_argument('-s', '--log-severity', default='info', choices=['debug', 'info', 'warning', 'error'])
args = parser.parse_args()
logging.basicConfig(filename=args.log_path, filemode='w', level=args.log_severity.upper())
logging.getLogger().addHandler(logging.StreamHandler())
mods = []
with open(args.index_path, encoding='utf8') as input:
mods = read_mods_csv(input, args.index_delimiter)
file_types = Counter()
mod_plugins = []
for entry in os.scandir(args.downloads_path):
try:
if not entry.is_file():
continue
logging.debug(f'Processing mod archive at {entry.path}')
file_path = Path(entry.path)
mod_id = entry.name
# Hack for the multi-part archive for Wizards Islands downloaded
# from the Wayback Machine's archive of mw.modhistory.com.
if mod_id == '2770.7z.001':
mod_id = '2770'
elif mod_id in ['2770.7z.002', '2770.7z.003']:
continue
if mod_id not in mods:
raise RuntimeError(f'Could not find mod ID {mod_id} in mod index')
file_type = get_file_type(file_path)
file_types[file_type.name.lower()] += 1
plugins = list_plugins(file_path, file_type)
mod = mods[mod_id]
mod_plugins.extend(ModPlugin(mod.id, mod.name, mod.site, mod.mod_url, p) for p in plugins)
except Exception as e:
logging.error(f'Caught exception when processing mod archive at {entry.path}: {e}')
logging.info(f'File type counts: {file_types}')
mod_plugins.sort(key=get_sort_key)
write_to_tsv(args.output_path, mod_plugins)