Skip to content

Instantly share code, notes, and snippets.

@Caellian
Last active December 4, 2025 14:25
Show Gist options
  • Select an option

  • Save Caellian/aade59087699f4fdc48fd510e95e7790 to your computer and use it in GitHub Desktop.

Select an option

Save Caellian/aade59087699f4fdc48fd510e95e7790 to your computer and use it in GitHub Desktop.
A python script to download Foundry VTT through the terminal.
selenium>=4.0.0
requests>=2.31.0
python-dotenv>=1.0.0
simple-term-menu>=1.6.1
#!/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)
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