Last active
December 4, 2025 14:25
-
-
Save Caellian/aade59087699f4fdc48fd510e95e7790 to your computer and use it in GitHub Desktop.
A python script to download Foundry VTT through the terminal.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| selenium>=4.0.0 | |
| requests>=2.31.0 | |
| python-dotenv>=1.0.0 | |
| simple-term-menu>=1.6.1 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| import argparse | |
| import getpass | |
| import os | |
| import re | |
| import sys | |
| from html.parser import HTMLParser | |
| from pathlib import Path | |
| import requests | |
| from dotenv import load_dotenv | |
| from selenium import webdriver | |
| from selenium.webdriver.chrome.options import Options | |
| from selenium.webdriver.common.by import By | |
| from selenium.webdriver.support import expected_conditions as EC | |
| from selenium.webdriver.support.ui import WebDriverWait | |
| from simple_term_menu import TerminalMenu | |
| # Load .env file from script directory | |
| script_dir = Path(__file__).parent | |
| env_file = script_dir / ".env" | |
| if env_file.exists(): | |
| load_dotenv(env_file) | |
| print(f"Loaded environment variables from {env_file}") | |
| class VersionOptionParser(HTMLParser): | |
| """Parse version and platform options from the download form.""" | |
| def __init__(self): | |
| super().__init__() | |
| self.version_options = [] # List of tuples: (label, version) | |
| self.platform_options = [] # List of tuples: (label, value) | |
| self.in_version_select = False | |
| self.in_platform_select = False | |
| self.current_option_value = None | |
| self.current_optgroup = None | |
| def handle_starttag(self, tag, attrs): | |
| attrs_dict = dict(attrs) | |
| if tag == "select": | |
| if attrs_dict.get("name") == "build": # Note: it's "build", not "version" | |
| self.in_version_select = True | |
| elif attrs_dict.get("name") == "platform": | |
| self.in_platform_select = True | |
| elif tag == "optgroup": | |
| # Store the optgroup label for categorization | |
| self.current_optgroup = attrs_dict.get("label", "") | |
| elif tag == "option": | |
| if self.in_version_select or self.in_platform_select: | |
| value = attrs_dict.get("value", "") | |
| if value and value not in ("", "null"): | |
| self.current_option_value = value | |
| def handle_data(self, data): | |
| data = data.strip() | |
| if not data or data.startswith("—"): # Skip placeholder options | |
| return | |
| # Extract version from option text like "Release 13.351 (Build 351)" | |
| if self.in_version_select and self.current_option_value: | |
| match = re.search(r'Release\s+([\d.]+)', data) | |
| if match: | |
| version = match.group(1) | |
| # Create display label with category prefix | |
| category = self._format_category(self.current_optgroup) | |
| display_label = f"[{category}] {data}" | |
| # Store tuple of (display_label, version) | |
| self.version_options.append((display_label, version)) | |
| self.current_option_value = None | |
| elif self.in_platform_select and self.current_option_value: | |
| # Store tuple of (display_label, value) | |
| self.platform_options.append((data, self.current_option_value)) | |
| self.current_option_value = None | |
| def handle_endtag(self, tag): | |
| if tag == "select": | |
| self.in_version_select = False | |
| self.in_platform_select = False | |
| self.current_option_value = None | |
| self.current_optgroup = None | |
| elif tag == "optgroup": | |
| self.current_optgroup = None | |
| def _format_category(self, label): | |
| """Shorten category labels for display.""" | |
| if not label: | |
| return "Other" | |
| # Shorten common labels | |
| shortcuts = { | |
| "Recommended Releases": "Recommended", | |
| "Stable Releases": "Stable", | |
| "Feature Testing Releases": "Testing", | |
| "API Development Releases": "Dev", | |
| "Prototype Releases": "Prototype" | |
| } | |
| return shortcuts.get(label, label) | |
| def parse_download_options(html_content): | |
| """Extract version and platform options from HTML. | |
| Returns: | |
| tuple: (version_options, platform_options) where each is a list of (label, value) tuples | |
| """ | |
| parser = VersionOptionParser() | |
| parser.feed(html_content) | |
| return parser.version_options, parser.platform_options | |
| def select_interactively_tui(options, title="Select an option"): | |
| """Allow user to interactively select from a list of options using TUI. | |
| Args: | |
| options: List of tuples (label, value) | |
| title: Title to display | |
| Returns: | |
| The selected value (second element of tuple) | |
| """ | |
| if not options: | |
| print(f"No options available.") | |
| sys.exit(1) | |
| # Extract labels for display | |
| labels = [label for label, _ in options] | |
| # Create terminal menu | |
| terminal_menu = TerminalMenu( | |
| labels, | |
| title=title, | |
| menu_cursor="► ", | |
| menu_cursor_style=("fg_cyan", "bold"), | |
| menu_highlight_style=("bg_cyan", "fg_black"), | |
| cycle_cursor=True, | |
| clear_screen=False, | |
| ) | |
| # Show menu and get selection | |
| menu_entry_index = terminal_menu.show() | |
| if menu_entry_index is None: | |
| print("\nSelection cancelled.") | |
| sys.exit(1) | |
| # Return the value (second element of tuple) | |
| return options[menu_entry_index][1] | |
| def download_file(url, output_path, cookies_dict): | |
| """Download a file from URL using cookies.""" | |
| print(f"Downloading to: {output_path}") | |
| try: | |
| response = requests.get(url, cookies=cookies_dict, stream=True, allow_redirects=True) | |
| response.raise_for_status() | |
| total_size = int(response.headers.get('content-length', 0)) | |
| block_size = 8192 | |
| downloaded = 0 | |
| with open(output_path, 'wb') as f: | |
| for chunk in response.iter_content(block_size): | |
| if chunk: | |
| f.write(chunk) | |
| downloaded += len(chunk) | |
| if total_size > 0: | |
| percent = (downloaded / total_size) * 100 | |
| print(f"\rProgress: {percent:.1f}% ({downloaded}/{total_size} bytes)", end='', flush=True) | |
| print(f"\n\nDownload completed: {output_path}") | |
| return True | |
| except requests.exceptions.RequestException as e: | |
| print(f"\nDownload failed: {e}") | |
| return False | |
| # Parse command-line arguments | |
| parser = argparse.ArgumentParser(description="Download FoundryVTT releases") | |
| parser.add_argument("-u", "--username", help="FoundryVTT username") | |
| parser.add_argument("-p", "--password", help="FoundryVTT password") | |
| parser.add_argument("-v", "--version", help="Version to download (e.g., 13.351)") | |
| parser.add_argument("-P", "--platform", help="Platform to download (e.g., windows, linux)") | |
| parser.add_argument("-o", "--output", help="Output filename for downloaded zip") | |
| parser.add_argument("-c", "--cookie-jar", default="cookies.txt", help="Cookie jar file path (default: cookies.txt)") | |
| parser.add_argument("--debug", action="store_true", help="Enable debug output") | |
| args = parser.parse_args() | |
| # Get username and password | |
| username = args.username or os.environ.get("FVTT_USERNAME") | |
| password = args.password or os.environ.get("FVTT_PASSWORD") | |
| if not username: | |
| username = input("FoundryVTT Username: ").strip() | |
| if not username: | |
| print("Username is required.") | |
| sys.exit(1) | |
| if not password: | |
| password = getpass.getpass("FoundryVTT Password: ") | |
| if not password: | |
| print("Password is required.") | |
| sys.exit(1) | |
| # Get version and platform preferences from args or environment | |
| version = args.version or os.environ.get("FVTT_VERSION") | |
| platform = args.platform or os.environ.get("FVTT_PLATFORM") | |
| output_file = args.output or os.environ.get("FVTT_OUTPUT") | |
| cookie_output = args.cookie_jar or os.environ.get("COOKIE_JAR", "cookies.txt") | |
| print("Logging in to FoundryVTT...") | |
| opts = Options() | |
| opts.add_argument("--headless=new") | |
| opts.add_argument("--disable-gpu") | |
| opts.add_argument("--no-sandbox") | |
| driver = webdriver.Chrome(options=opts) | |
| driver.get("https://foundryvtt.com/auth/login/") | |
| wait = WebDriverWait(driver, 10) | |
| try: | |
| btn = wait.until( | |
| EC.element_to_be_clickable( | |
| (By.XPATH, "//main//form[@id='analytics-prompt']//button[@name='yes']") | |
| ) | |
| ) | |
| btn.click() | |
| except Exception: | |
| print("Login failed! AUR package requires maintenance, notify the AUR package maintainer.") | |
| driver.quit() | |
| sys.exit(1) | |
| # Fill fields | |
| username_field = wait.until( | |
| EC.visibility_of_element_located( | |
| (By.CSS_SELECTOR, "form[method='post'] div.username input[name='username']") | |
| ) | |
| ) | |
| username_field.send_keys(username) | |
| driver.find_element( | |
| By.CSS_SELECTOR, "form[method='post'] div.password input[name='password']" | |
| ).send_keys(password) | |
| driver.find_element( | |
| By.CSS_SELECTOR, "main form[method='post'] button[type=submit][name=login]" | |
| ).click() | |
| try: | |
| wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, "header form#login-form button[name='logout']"))) | |
| print("Login successful!") | |
| except Exception: | |
| print("Login failed! Check your username and password.") | |
| driver.quit() | |
| sys.exit(1) | |
| # Navigate to licenses page to get version and platform options | |
| print("Fetching available versions and platforms...") | |
| driver.get("https://foundryvtt.com/community/caellian/licenses") | |
| wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, "form#software-download-form"))) | |
| # Get page source and parse options | |
| page_source = driver.page_source | |
| # Debug: Save the page source to a file for inspection | |
| if args.debug: | |
| debug_file = script_dir / "debug_page.html" | |
| with open(debug_file, "w", encoding="utf-8") as f: | |
| f.write(page_source) | |
| print(f"Debug: Saved page source to {debug_file}") | |
| version_options, platform_options = parse_download_options(page_source) | |
| # Extract just the values for validation | |
| available_versions = [v for _, v in version_options] | |
| available_platforms = [p for _, p in platform_options] | |
| # Debug: Show what we found | |
| if args.debug: | |
| print(f"Debug: Found {len(version_options)} versions: {available_versions[:5] if len(available_versions) > 5 else available_versions}") | |
| print(f"Debug: Found {len(platform_options)} platforms: {available_platforms}") | |
| # Select version | |
| if version: | |
| if version not in available_versions: | |
| print(f"Warning: Specified version '{version}' not found in available versions.") | |
| print(f"Available versions: {', '.join(available_versions[:10])} ...") | |
| version = None | |
| if not version: | |
| version = select_interactively_tui(version_options, "Select FoundryVTT Version") | |
| # Select platform | |
| if platform: | |
| if platform not in available_platforms: | |
| print(f"Warning: Specified platform '{platform}' not found in available platforms.") | |
| print(f"Available platforms: {', '.join(available_platforms)}") | |
| platform = None | |
| if not platform: | |
| platform = select_interactively_tui(platform_options, "Select Operating System") | |
| print(f"\nSelected version: {version}") | |
| print(f"Selected platform: {platform}") | |
| # Get cookies before downloading | |
| cookies = {c["name"]: c["value"] for c in driver.get_cookies()} | |
| # Close browser now that we have cookies | |
| driver.quit() | |
| # Construct download URL | |
| download_url = f"https://foundryvtt.com/releases/download?version={version}&platform={platform}" | |
| # Determine output filename | |
| if not output_file: | |
| output_file = f"foundryvtt-{version}-{platform}.zip" | |
| print(f"\nDownloading from: {download_url}") | |
| # Download the file | |
| success = download_file(download_url, output_file, cookies) | |
| if success: | |
| print("\nDone!") | |
| sys.exit(0) | |
| else: | |
| print("\nDownload failed. You can try manually with:") | |
| print(f" curl -b {cookie_output} '{download_url}' -o {output_file}") | |
| sys.exit(1) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Copyright (c) 2025 Tin Švagelj <[email protected]> | |
| Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: | |
| The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. | |
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment