mirror of
https://github.com/loot/morrowind.git
synced 2026-07-27 14:16:19 -07:00
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.
317 lines
12 KiB
Python
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)
|