#!/bin/python3 import requests import os import pathlib from enum import Enum from argparse import ArgumentParser from dotenv import load_dotenv from tabulate import tabulate from datetime import datetime, timedelta, timezone class Mod: def from_curse(self, data): self.id = str(data["id"]) self.platform = "CURSE" self.name = str(data["name"]) self.downloads = int(data["downloadCount"]) self.date_created = str(data["dateCreated"]) year_created = "" try: year_created = datetime.strptime( data["dateCreated"], "%Y-%m-%dT%H:%M:%S.%f%z" ).strftime("%Y") except ValueError: year_created = datetime.strptime( data["dateCreated"], "%Y-%m-%dT%H:%M:%S%z" ).strftime("%Y") self.year_created = int(year_created) self.date_modified = str(data["dateModified"]) try: self.date_released = datetime.strptime( data["dateReleased"], "%Y-%m-%dT%H:%M:%S.%f%z" ) except ValueError: self.date_released = datetime.strptime( data["dateReleased"], "%Y-%m-%dT%H:%M:%S%z" ) return self def from_modrinth(self, data): self.id = str(data["project_id"]) self.platform = "MODRINTH" self.name = str(data["title"]) self.downloads = int(data["downloads"]) self.date_created = str(data["date_created"]) self.date_released = datetime.strptime( self.date_created, "%Y-%m-%dT%H:%M:%S.%f%z" ) year_created = "" try: year_created = datetime.strptime( data["date_created"], "%Y-%m-%dT%H:%M:%S.%f%z" ).strftime("%Y") except ValueError: year_created = datetime.strptime( data["date_created"], "%Y-%m-%dT%H:%M:%S%z" ).strftime("%Y") self.year_created = int(year_created) self.date_modified = str(data["date_modified"]) return self # def __init__(self, data): class FilterType(Enum): LastYear = "last-year" Downloads = "downloads" class SortType(Enum): Downloads = "downloads" InvDownloads = "-downloads" ReleaseYear = "release-year" InvReleaseYear = "-release-year" class ModLoaderType(Enum): Any = 0 Forge = 1 Liteloader = 3 Fabric = 4 Quilt = 5 NeoForge = 6 def filter_mod(mod: Mod) -> bool: # filter mods with under 1M downloads if FilterType.Downloads in filters: if mod.downloads < 1_000_000: return False # filter mods without a release in the past year if FilterType.LastYear in filters: past = datetime.now(timezone.utc) - timedelta(days=365) if mod.date_released < past: return False return True def fetch_curse_mods() -> tuple[list[Mod], int]: URL = "https://api.curseforge.com/v1/mods/search" PARAMS = { "gameId": "432", # minecraft "classId": 6, # mods "gameVersion": GAME_VERSION, "modLoaderType": MOD_LOADER.value, "sortField": 6, # downloads "sortOrder": "desc", "pageSize": PAGE_SIZE, "index": 0, } CURSE_API_TOKEN = os.environ["CURSE_API_TOKEN"] HEADERS = {"x-api-key": CURSE_API_TOKEN} count = 0 mods: list[Mod] = [] for i in range(0, MAX_INDEX, PAGE_SIZE): PARAMS["index"] = i r = requests.get(url=URL, params=PARAMS, headers=HEADERS) try: data = r.json() except ValueError as err: print(r) print(err) break if ONLY_COUNT: count = int(data["pagination"]["totalCount"]) break data = data["data"] for entry in data: mod = Mod().from_curse(entry) if filter_mod(mod): mods.append(mod) print(f"{i} / {MAX_INDEX}") return (mods, count) def fetch_modrinth_mods() -> tuple[list[Mod], int]: URL = "https://api.modrinth.com/v2/search" PARAMS = { "facets": format( f'[["project_type:mod"],["categories:{MOD_LOADER.name}"],["versions:{GAME_VERSION}"]]' ), "limit": PAGE_SIZE, "index": "downloads", } HEADERS = {} count = 0 mods: list[Mod] = [] for i in range(0, MAX_INDEX, PAGE_SIZE): r = requests.get(url=URL, params=PARAMS, headers=HEADERS) try: data = r.json() except ValueError as err: print(r) print(err) break if ONLY_COUNT: count = data["total_hits"] break data = data["hits"] for entry in data: mod = Mod().from_modrinth(entry) # filter mods with under 1M downloads if FilterType.Downloads in filters: if mod.downloads < 1_000_000: continue # filter mods without a release in the past year if FilterType.LastYear in filters: past = datetime.now(timezone.utc) - timedelta(days=365) if mod.date_released < past: continue mods.append(mod) print(f"{i} / {MAX_INDEX}") return (mods, count) if __name__ == "__main__": load_dotenv() def clamp(val: int, smallest: int, largest: int) -> int: return max(smallest, min(val, largest)) pathlib.Path("./out").mkdir(exist_ok=True) parser = ArgumentParser() parser.add_argument( "-f", "--filter", help="Filters what mods are included", type=FilterType, required=False, action="append", nargs="+", dest="filter", ) parser.add_argument( "-s", "--sort", help="Sorts the mods before putting them in a table", type=SortType, required=False, action="append", nargs="+", dest="sort", ) parser.add_argument( "--page-size", help="Entries per page", type=int, default=50, required=False, dest="page_size", ) parser.add_argument( "--max-index", help="Max amount of entries fetched", type=int, default=10_000, required=False, dest="max_index", ) parser.add_argument( "--count", help="Fetched only the max number of entries of the search", default=False, required=False, action="store_true", dest="count", ) parser.add_argument( "--game-version", help="Narrows down the search for only a specific game version", type=str, default="", required=False, dest="game_version", ) parser.add_argument( "--mod-loader", help="Narrows down the search for only a specific mod loader", type=str, default="any", required=False, dest="mod_loader", ) args = parser.parse_args() page_size = clamp(args.page_size, 10, 50) # Merge all filters into 1 array regardless of the arg format filters = [] if args.filter is not None: for filter_list in args.filter: for filter in filter_list: filters.append(filter) sorts = [] if args.sort is not None: for sort_list in args.sort: for sort_logic in sort_list: sorts.append(sort_logic) def sort_mod(mod: Mod, sorts: list[SortType]) -> list: sorting = [] for sort in sorts: if sort is SortType.Downloads: sorting.append(mod.downloads) elif sort is SortType.InvDownloads: sorting.append(-mod.downloads) elif sort is SortType.ReleaseYear: sorting.append(mod.year_created) elif sort is SortType.InvReleaseYear: sorting.append(-mod.year_created) return sorting MOD_LOADER = ModLoaderType[args.mod_loader] GAME_VERSION = args.game_version ONLY_COUNT = args.count PAGE_SIZE = page_size MAX_INDEX = args.max_index all_mods: list[Mod] = [] (curse_mods, curse_count) = fetch_curse_mods() all_mods.extend(curse_mods) (modrinth_mods, modrinth_count) = fetch_modrinth_mods() all_mods.extend(modrinth_mods) if ONLY_COUNT: print(f"{MOD_LOADER.name}-{GAME_VERSION} {curse_count + modrinth_count}") # print( # f"{MOD_LOADER.name}-{GAME_VERSION}\n Curse {curse_count}\n Modrinth {modrinth_count}" # ) exit(0) all_mods.sort(key=lambda m: sort_mod(m, sorts)) mods_map = map( lambda m: [ m.name, m.platform, "{0:,d}".format(m.downloads), m.date_created, m.date_released, ], all_mods, ) table = tabulate( mods_map, headers=["Name", "Platform", "Downloads", "Created Date", "Last Release Date"], tablefmt="double_grid", showindex=True, ) # Write the above table in a file for logging with today's date as the file's name with open("./out/mods", "w") as f: f.write(table)