diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea9ed7c --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.zip +/feedtheforge/__pycache__ +/__main__.build +/__main__.dist +/__main__.onefile-build +FeedTheForge.exe \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8d3bbe0 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# Feed The Forge +## Introduce +This is a simple tool to download modpacks from FTB without the need of the FTB Launcher. + +You can then import or drag this zip file into any curseforge compatible launcher. + +For example: HMCL, PCL2, Prism Launcher etc. + +## Usage +WIP + +## Develop and Build +WIP + +## LICENSE +[GNU General Public License v3.0](.LICENSE) \ No newline at end of file diff --git a/__main__.py b/__main__.py new file mode 100644 index 0000000..57369ef --- /dev/null +++ b/__main__.py @@ -0,0 +1,253 @@ +import aiohttp +import asyncio +from urllib import request +from feedtheforge.const import * +import json +import os +import shutil +from zipfile import ZIP_DEFLATED, ZipFile +from pick import pick, Option + +async def download_file(session, url, output_path): + async with session.get(url) as response: + with open(output_path, "wb") as f: + while chunk := await response.content.read(1024): + f.write(chunk) + +async def _create_directory(path): + os.makedirs(path, exist_ok=True) + +async def download_mod_files(session, non_curse_files): + tasks = [] + for file_info in non_curse_files: + mod_file_path = file_info["path"][2:] + mod_file_name = file_info["name"] + full_path = os.path.join(modpack_path, "overrides", mod_file_path) + output_path = os.path.join(full_path, mod_file_name) + + if not os.path.exists(output_path): + await _create_directory(full_path) + tasks.append(download_file(session, file_info["url"], output_path)) + + await asyncio.gather(*tasks) + +async def _featured_and_search(load_json): + # 读取json并制作对应的选择菜单 + options = [] + with open(load_json, "r", encoding="utf-8") as f: + data = json.load(f) + for modpack_id in data["packs"]: + get_modpack_info(modpack_id) + with open(modpack_id_path, "r", encoding="utf-8") as pack_file: + pack_data = json.load(pack_file) + print(f"{pack_data['name']} (id {modpack_id})") + options.append(Option(f"{pack_data['name']}( id:{modpack_id})", modpack_id)) + title = locale.t("feedtheforge.start.title") + modpack_id = pick(options, title, indicator="=>") + modpack_id = modpack_id[0].value + # 下载选择的整合包 + await download_modpack(modpack_id) + +async def get_featured_modpack(): + featured_json = os.path.join(cache_dir, "featured_modpacks.json") + async with aiohttp.ClientSession() as session: + await download_file(session, api_featured, featured_json) + + await _featured_and_search(featured_json) + +async def search_modpack(): + print("未完成,敬请期待") + # search_json = os.path.join(cache_dir, "search_modpacks.json") + # keyword = input(locale.t("feedtheforge.main.search_modpack")) + # async with aiohttp.ClientSession() as session: + # await download_file(session, api_search + keyword, search_json) + + # await _featured_and_search(search_json) + # os.remove(search_json) + +def get_modpack_info(modpack_id): + global modpack_id_path + modpack_id_path = os.path.join(cache_dir, f"pack-{modpack_id}.json") + with request.urlopen(f"https://api.modpacks.ch/public/modpack/{modpack_id}") as response: + data = json.loads(response.read().decode("utf-8")) + + with open(modpack_id_path, "w", encoding="utf-8") as f: + f.write(json.dumps(data, indent=4)) + +async def chinese_patch(lanzou_url): + # 蓝奏云api直链解析下载 + async with aiohttp.ClientSession() as session: + await download_file(session, f"https://tool.bitefu.net/lz?url={lanzou_url}", patch) + with ZipFile(patch, 'r') as zip_ref: + zip_ref.extractall(patch_folder) + os.remove(patch) + # 把汉化补丁移动剪切到整合包 + for root, _, files in os.walk(patch_folder): + for file in files: + patch_file = os.path.join(root, file) + shutil.move(patch_file, modpack_path) + shutil.rmtree(patch_folder) + +async def download_modpack(modpack_id): + get_modpack_info(modpack_id) + with open(modpack_id_path, "r", encoding="utf-8") as f: + modpack_data = json.load(f) + + modpack_name = modpack_data["name"] + modpack_author = modpack_data["authors"][0]["name"] + modpack_version = modpack_data["versions"][0]["name"] + print(locale.t("feedtheforge.main.modpack_name", modpack_name = modpack_name)) + versions = modpack_data["versions"] + version_list = [version["id"] for version in versions] + print(locale.t("feedtheforge.main.version_list", version_list = version_list)) + selected_version = input(locale.t("feedtheforge.main.enter_version")) + + # 输入为空且有版本可下载(更保险),取最新版本 + if not selected_version and version_list: + selected_version = max(version_list) + print(locale.t("feedtheforge.main.default_version", selected_version = selected_version)) + # id无效,无对应整合包 + elif int(selected_version) not in version_list: + input(locale.t("feedtheforge.main.invalid_modpack_version")) + return + # 输入的不是数字,大错特错 + else: + input(locale.t("feedtheforge.main.invalid_modpack_version")) + return + + async with aiohttp.ClientSession() as session: + await download_file(session, f"https://api.modpacks.ch/public/modpack/{modpack_id}/{selected_version}", + os.path.join(cache_dir, "download.json")) + await get_modpack_files(modpack_name, modpack_author, modpack_version, session) + # 切片[-27:]恰为模组文件名 + request.urlretrieve(i18nupdate_link, os.path.join(mod_path, i18nupdate_link[-27:])) + # 检查有无对应汉化 + if str(selected_version) in all_patch: + install = input(locale.t("feedtheforge.main.has_chinese_patch")) + if install == "Y" or install == "y": + chinese_patch(selected_version, all_patch[selected_version]) + else: pass + zip_modpack(modpack_name) + +async def get_modpack_files(modpack_name, modpack_author, modpack_version, session): + os.makedirs(modpack_path, exist_ok=True) + with open(os.path.join(cache_dir, "download.json"), "r", encoding="utf-8") as f: + data = json.load(f) + + # 下面均为CurseForge整合包识别的固定格式 + mc_version = data["targets"][1]["version"] + modloader_name = data["targets"][0]["name"] + modloader_version = data["targets"][0]["version"] + + curse_files, non_curse_files = [], [] + for file_info in data["files"]: + if "curseforge" in file_info: + curse_files.append({ + "fileID": file_info["curseforge"]["file"], + "projectID": file_info["curseforge"]["project"], + "required": True + }) + else: + non_curse_files.append(file_info) + + modloader_id = f"{modloader_name}-{modloader_version}" + if modloader_name == "neoforge" and mc_version == "1.20.1": + modloader_id = f"{modloader_name}-{mc_version}-{modloader_version}" + + manifest_data = { + "author": modpack_author, + "files": curse_files, + "manifestType": "minecraftModpack", + "manifestVersion": 1, + "minecraft": { + "version": mc_version, + "modLoaders": [{"id": modloader_id, "primary": True}] + }, + "name": modpack_name, + "overrides": "overrides", + "version": modpack_version + } + + with open(os.path.join(modpack_path, "manifest.json"), "w", encoding="utf-8") as f: + json.dump(manifest_data, f, indent=4) + os.makedirs(os.path.join(modpack_path, "overrides"), exist_ok=True) + + await download_mod_files(session, non_curse_files) + +def zip_modpack(modpack_name): + print(locale.t("feedtheforge.main.zipping_modpack")) + + with ZipFile(f"{modpack_name}.zip", "w", ZIP_DEFLATED) as zf: + for dirname, _, files in os.walk(modpack_path): + for filename in files: + file_path = os.path.join(dirname, filename) + zf.write(file_path, os.path.relpath(file_path, modpack_path)) + print(locale.t("feedtheforge.main.modpack_created", modpack_name=f"{modpack_name}.zip")) + shutil.rmtree(modpack_path, ignore_errors=True) + +def cleat_temp(): + size = 0 + for root, _, files in os.walk(cache_dir): + size += sum([os.path.getsize(os.path.join(root, name)) for name in files]) + shutil.rmtree(cache_dir, ignore_errors=True) + print(locale.t("feedtheforge.main.clean_temp", size=int(size/1024))) + +async def get_modpack_list(): + print(locale.t("feedtheforge.main.getting_list")) + try: + with request.urlopen(api_list) as response: + if response.status == 200: + modpacks_data = json.loads(response.read().decode("utf-8")) + with open(packlist_path, "w", encoding="utf-8") as f: + json.dump(modpacks_data, f, indent=4) + # 网络错误无法连接为OSError + except OSError: + input(locale.t("feedtheforge.main.getting_error")) + exit(1) + + with open(packlist_path, "r", encoding="utf-8") as f: + modpacks_data = json.load(f) + global all_pack_ids + all_pack_ids = [str(all_pack_ids) for all_pack_ids in modpacks_data["packs"]] + +async def main(): + if not os.path.exists(cache_dir): + os.makedirs(cache_dir) + + # 本地化中这里的字中间要有空格,不加空格VSCode终端正常,在cmd中字会重叠 + title = locale.t("feedtheforge.start.title") + options = [ + Option(locale.t("feedtheforge.start.featured_modpack"), + description=locale.t("feedtheforge.start.featured_modpack_desc")), + Option(locale.t("feedtheforge.start.search_modpack"), + description=locale.t("feedtheforge.start.search_modpack_desc")), + Option(locale.t("feedtheforge.start.enter_id"), + description=locale.t("feedtheforge.start.enter_id_desc")), + Option(locale.t("feedtheforge.start.clean_temp"), + description=locale.t("feedtheforge.start.clean_temp_desc")) + ] + options, index = pick(options, title, indicator="=>") + + if index == 0: + await get_featured_modpack() + elif index == 1: + await search_modpack() + elif index == 2: + await get_modpack_list() + modpack_id = input(locale.t("feedtheforge.main.enter_id")) + if modpack_id not in all_pack_ids: + print(locale.t("feedtheforge.main.invalid_pack_id")) + return + await download_modpack(modpack_id) + elif index == 3: + cleat_temp() + +if __name__ == "__main__": + import sys + py_version = sys.version_info + # main.py L14; feedtheforge/i18n.py 类型标注为Python 3.8新功能 + if py_version < (3, 8): + input(locale.t("feedtheforge.main.unsupported_version", + cur=f"{py_version.major}.{py_version.minor}.{py_version.micro}")) + exit(0) + asyncio.run(main()) diff --git a/feedtheforge/const.py b/feedtheforge/const.py new file mode 100644 index 0000000..5d7b2bd --- /dev/null +++ b/feedtheforge/const.py @@ -0,0 +1,33 @@ +import os +import tempfile +from feedtheforge.i18n import Locale + +# 默认且仅支持中文 +locale = Locale("zh_cn") + +cache_dir = os.path.join(tempfile.gettempdir(), "FeedTheForge") +packlist_path = os.path.join(cache_dir, "packlist.json") +modpack_path = os.path.join(cache_dir, "pack_files") + +patch = os.path.join(cache_dir, "patch.zip") +patch_folder = os.path.join(cache_dir, "patch") +i18nupdate_link = "https://mediafilez.forgecdn.net/files/5335/196/I18nUpdateMod-3.5.5-all.jar" +mod_path = os.path.join(modpack_path, "overrides", "mods") + +api_list = "https://api.modpacks.ch/public/modpack/all" +api_featured = "https://api.modpacks.ch/public/modpack/featured/20" +api_search = "https://api.modpacks.ch/public/modpack/search/20/detailed?platform=modpacksch&term=" + +# 全部汉化 key FTB唯一包版本 vaule 蓝奏云汉化下载链接 +all_patch = { + # 100 StoneBlock 3 + "6498": "https://wulian233.lanzouj.com/iwAZ61xg3yib", + "6647": "https://wulian233.lanzouj.com/iwAZ61xg3yib", + "6967": "https://wulian233.lanzouj.com/iwAZ61xg3yib", + "11655": "https://wulian233.lanzouj.com/iwAZ61xg3yib", + # 115 Arcanum Institute + "11512": "https://vmhanhuazu.lanzouo.com/i8W7Y1nr83le", + # 122 Builders Paradise 2 + "11840": "https://wulian233.lanzouj.com/ib5G81wnrpwb", + "11937": "https://wulian233.lanzouj.com/ib5G81wnrpwb" +} diff --git a/feedtheforge/i18n.py b/feedtheforge/i18n.py new file mode 100644 index 0000000..c8e3d21 --- /dev/null +++ b/feedtheforge/i18n.py @@ -0,0 +1,36 @@ +from pathlib import Path +import json +from string import Template + +class Locale: + def __init__(self, lang: str): + self.path = Path(f"./feedtheforge/lang/{lang}.json") + self.data = {} + self.load() + + def __getitem__(self, key: str): + return self.data[key] + + def __contains__(self, key: str): + return key in self.data + + def load(self): + with open(self.path, "r", encoding="utf-8") as f: + d = f.read() + self.data = json.loads(d) + f.close() + + def get_string(self, key: str, failed_prompt): + n = self.data.get(key, None) + if n != None: + return n + if failed_prompt: + return str(key) + self.t("feedtheforge.i18n.failed") + return key + + def t(self, key: str, failed_prompt=True, *args, **kwargs): + localized = self.get_string(key, failed_prompt) + return Template(localized).safe_substitute(*args, **kwargs) + + +locale: Locale = Locale("zh_cn") \ No newline at end of file diff --git a/feedtheforge/lang/zh_cn.json b/feedtheforge/lang/zh_cn.json new file mode 100644 index 0000000..3bba0a7 --- /dev/null +++ b/feedtheforge/lang/zh_cn.json @@ -0,0 +1,28 @@ +{ "feedtheforge.start.title": "按 上 下 键 选 择 , 回 车 确 认 :", + "feedtheforge.start.featured_modpack": "查 看 热 门 整 合 包", + "feedtheforge.start.featured_modpack_desc": "查 看 当 前 近 20天 最 热 门 的 5个 整 合 包 并 选 择 下 载", + "feedtheforge.start.search_modpack": "搜 索 整 合 包", + "feedtheforge.start.search_modpack_desc": "输 入 英 文 关 键 词 搜 索 整 合 包 , 选 择 后 下 载", + "feedtheforge.start.enter_id": "输 入 整 合 包 id", + "feedtheforge.start.enter_id_desc":"直 接 输 入 整 合 包 对 应 的 数 字 id下 载", + "feedtheforge.start.clean_temp": "清 除 工 具 缓 存", + "feedtheforge.start.clean_temp_desc": "清 除 缓 存 的 整 合 包 信 息 , 下 次 启 动 会 变 慢", + + "feedtheforge.main.clean_temp": "清理缓存成功,共清理了 $size kb", + "feedtheforge.main.default_version": "已自动选择最新的 $selected_version 版本", + "feedtheforge.main.enter_id": "请输入要下载的整合包id:", + "feedtheforge.main.enter_version": "请输入整合包版本(留空默认最新):", + "feedtheforge.main.getting_error": "网络错误,获取整合包列表失败。回车自动退出", + "feedtheforge.main.getting_list": "正在获取整合包列表", + "feedtheforge.main.has_chinese_patch": "本整合包有人工汉化补丁可用,是否自动下载并安装?输入Y安装。", + "feedtheforge.main.search_modpack": "请输入整合包英文关键词:", + "feedtheforge.main.invalid_pack_id": "没有对应的整合包。请输入正确的整合包id", + "feedtheforge.main.invalid_modpack_version": "没有对应的整合包版本。请输入一个正确的版本。回车自动退出", + "feedtheforge.main.unsupported_version": "该程序需要Python 3.8+运行,当前版本为Python $cur 。回车自动退出", + "feedtheforge.main.version_list": "当前整合包可下载版本:$version_list", + "feedtheforge.main.modpack_created": "压缩成功。已创建名为 $modpack_name 的整合包,请拖入启动器安装", + "feedtheforge.main.modpack_name": "成功选择了 $modpack_name", + "feedtheforge.main.zipping_modpack": "下载完成,正在压缩制作整合包安装包。", + + "feedtheforge.i18n.failed": "错误:没有对应的本地化字符串" +} diff --git a/icon.ico b/icon.ico new file mode 100644 index 0000000..9cbfbaa Binary files /dev/null and b/icon.ico differ diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..59b0e24 Binary files /dev/null and b/icon.png differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..92a116b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +aiohttp +pick \ No newline at end of file diff --git a/win_build.bat b/win_build.bat new file mode 100644 index 0000000..6e7cea6 --- /dev/null +++ b/win_build.bat @@ -0,0 +1,2 @@ +pip install nuitka +nuitka --onefile --enable-console --enable-plugin=upx --show-progress --windows-icon-from-ico=.\icon.ico --output-file=FeedTheForge --include-data-dir=.\feedtheforge\lang=feedtheforge\lang __main__.py