From 7f293558b46cfdacf5f70a0ba478a5044b6eb86b Mon Sep 17 00:00:00 2001 From: HRiggs Date: Fri, 23 Jan 2026 21:13:37 -0500 Subject: [PATCH] Add collection scraper and local mod detection --- About.xml | 29 +++ README.md | 48 +++- requirements.txt | 1 + steam_workshop_gui.py | 502 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 579 insertions(+), 1 deletion(-) create mode 100644 About.xml create mode 100644 requirements.txt create mode 100644 steam_workshop_gui.py diff --git a/About.xml b/About.xml new file mode 100644 index 0000000..292bf0c --- /dev/null +++ b/About.xml @@ -0,0 +1,29 @@ + + + RT Fuse + Ratys + ratys.rtfuse + https://ludeon.com/forums/index.php?topic=11272 + +
  • 1.0
  • +
  • 1.1
  • +
  • 1.2
  • +
  • 1.3
  • +
  • 1.4
  • +
  • 1.5
  • +
  • 1.6
  • +
    + +
  • + brrainz.harmony + Harmony + steam://url/CommunityFilePage/2009463077 + https://github.com/pardeike/HarmonyRimWorld/releases/latest +
  • +
    + Researchable (RT Mods research tab) electric fuses to mitigate short circuits. When placed anywhere on a power network, each fuse will safely discharge up to three of network's batteries, mitigating or preventing the explosion. + +Does not require a new colony to add or remove (might throw a one-time error). + +Refer to forum thread for additional information. +
    \ No newline at end of file diff --git a/README.md b/README.md index 577d77f..a6a1dc4 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,48 @@ -# ProgressionMods +# Steam Workshop Collection Manager +A Python GUI application for extracting Steam Workshop mod IDs from RimWorld mod collections. + +## Features + +- Dark themed Windows-style GUI +- Three input fields for different mod collection types (Core, Content, Cosmetics) +- Automatic extraction of Workshop IDs from Steam collection URLs +- Real-time output display with scrollable text area +- Pre-populated with example RimWorld mod collections + +## Installation + +1. Make sure you have Python 3.6+ installed +2. Install required dependencies: + ``` + pip install -r requirements.txt + ``` + +## Usage + +1. Run the application: + ``` + python steam_workshop_gui.py + ``` + +2. The application comes pre-populated with example collection URLs: + - **Core**: https://steamcommunity.com/workshop/filedetails/?id=3521297585 + - **Content**: steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3521319712 + - **Cosmetics**: steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3637541646 + +3. You can modify these URLs or enter your own Steam Workshop collection URLs + +4. Click "Extract Workshop IDs" to process the collections + +5. The right panel will display all the Workshop IDs found in each collection + +## URL Formats Supported + +- Standard Steam Workshop URLs: `https://steamcommunity.com/workshop/filedetails/?id=XXXXXXXXX` +- Steam protocol URLs: `steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=XXXXXXXXX` + +## Notes + +- The application fetches collection data directly from Steam Workshop +- Processing may take a few seconds depending on collection size +- Workshop IDs are extracted from the HTML content of collection pages \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9e4b84c --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +requests>=2.25.1 \ No newline at end of file diff --git a/steam_workshop_gui.py b/steam_workshop_gui.py new file mode 100644 index 0000000..0e52b87 --- /dev/null +++ b/steam_workshop_gui.py @@ -0,0 +1,502 @@ +import tkinter as tk +from tkinter import ttk, scrolledtext, filedialog, messagebox +import requests +import re +from urllib.parse import urlparse, parse_qs +import threading +import os +import winreg +from pathlib import Path + +class SteamWorkshopGUI: + def __init__(self, root): + self.root = root + self.root.title("Steam Workshop Collection Manager") + self.root.geometry("1200x800") + self.root.configure(bg='#2b2b2b') + + # Configure dark theme + self.setup_dark_theme() + + # Initialize workshop folder + self.workshop_folder = None + + # Create main frame + main_frame = tk.Frame(root, bg='#2b2b2b') + main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + + # Left panel for inputs + left_frame = tk.Frame(main_frame, bg='#2b2b2b', width=400) + left_frame.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 10)) + left_frame.pack_propagate(False) + + # Right panel for output + right_frame = tk.Frame(main_frame, bg='#2b2b2b') + right_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True) + + self.create_input_section(left_frame) + self.create_output_section(right_frame) + + def setup_dark_theme(self): + """Configure dark theme colors""" + style = ttk.Style() + style.theme_use('clam') + + # Configure colors for dark theme + style.configure('TLabel', background='#2b2b2b', foreground='#ffffff') + style.configure('TButton', background='#404040', foreground='#ffffff') + style.map('TButton', background=[('active', '#505050')]) + style.configure('TEntry', background='#404040', foreground='#ffffff', fieldbackground='#404040') + + def create_input_section(self, parent): + """Create the left input section""" + title_label = tk.Label(parent, text="Steam Workshop Collections", + font=('Arial', 14, 'bold'), bg='#2b2b2b', fg='#ffffff') + title_label.pack(pady=(0, 10)) + + # RimWorld game folder input + rimworld_frame = tk.Frame(parent, bg='#2b2b2b') + rimworld_frame.pack(fill='x', pady=(0, 10)) + + rimworld_label = tk.Label(rimworld_frame, text="RimWorld Game Folder:", + font=('Arial', 9, 'bold'), bg='#2b2b2b', fg='#ffffff') + rimworld_label.pack(anchor='w') + + rimworld_help = tk.Label(rimworld_frame, + text="Right-click RimWorld in Steam > Manage > Browse local files, copy that path", + font=('Arial', 8), bg='#2b2b2b', fg='#888888', wraplength=380) + rimworld_help.pack(anchor='w', pady=(2, 5)) + + rimworld_input_frame = tk.Frame(rimworld_frame, bg='#2b2b2b') + rimworld_input_frame.pack(fill='x') + + self.rimworld_var = tk.StringVar() + self.rimworld_entry = tk.Entry(rimworld_input_frame, textvariable=self.rimworld_var, + font=('Arial', 9), bg='#404040', fg='#ffffff', + insertbackground='#ffffff', relief='flat', bd=5) + self.rimworld_entry.pack(side='left', fill='x', expand=True) + self.rimworld_entry.bind('', self.on_rimworld_path_change) + + browse_game_btn = tk.Button(rimworld_input_frame, text="Browse", + command=self.browse_rimworld_folder, + bg='#505050', fg='white', font=('Arial', 8), + relief='flat', padx=10) + browse_game_btn.pack(side='right', padx=(5, 0)) + + # Workshop folder display (derived from RimWorld path) + workshop_frame = tk.Frame(parent, bg='#2b2b2b') + workshop_frame.pack(fill='x', pady=(0, 15)) + + workshop_label = tk.Label(workshop_frame, text="Workshop Folder (Auto-derived):", + font=('Arial', 9, 'bold'), bg='#2b2b2b', fg='#ffffff') + workshop_label.pack(anchor='w') + + self.workshop_var = tk.StringVar(value="Enter RimWorld path above") + self.workshop_display = tk.Entry(workshop_frame, textvariable=self.workshop_var, + font=('Arial', 8), bg='#404040', fg='#ffffff', + insertbackground='#ffffff', relief='flat', bd=5, state='readonly') + self.workshop_display.pack(fill='x', pady=(5, 0)) + + # Core collection + self.create_url_input(parent, "Core Collection:", + "https://steamcommunity.com/workshop/filedetails/?id=3521297585") + + # Content collection + self.create_url_input(parent, "Content Collection:", + "steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3521319712") + + # Cosmetics collection + self.create_url_input(parent, "Cosmetics Collection:", + "steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3637541646") + + # Process button + process_btn = tk.Button(parent, text="Extract Workshop IDs", + command=self.process_collections, + bg='#0078d4', fg='white', font=('Arial', 10, 'bold'), + relief='flat', padx=20, pady=8) + process_btn.pack(pady=10) + + # Check local mods button + local_btn = tk.Button(parent, text="List Local Mod Folders", + command=self.list_local_mod_folders, + bg='#28a745', fg='white', font=('Arial', 10, 'bold'), + relief='flat', padx=20, pady=8) + local_btn.pack(pady=5) + + def create_url_input(self, parent, label_text, default_url): + """Create a labeled URL input field""" + label = tk.Label(parent, text=label_text, font=('Arial', 10, 'bold'), + bg='#2b2b2b', fg='#ffffff') + label.pack(anchor='w', pady=(10, 5)) + + entry = tk.Entry(parent, font=('Arial', 9), bg='#404040', fg='#ffffff', + insertbackground='#ffffff', relief='flat', bd=5) + entry.pack(fill='x', pady=(0, 10)) + entry.insert(0, default_url) + + # Store reference to entry widgets + if not hasattr(self, 'url_entries'): + self.url_entries = {} + + collection_name = label_text.replace(" Collection:", "").lower() + self.url_entries[collection_name] = entry + + def create_output_section(self, parent): + """Create the right output section""" + # Main output area + main_output_frame = tk.Frame(parent, bg='#2b2b2b') + main_output_frame.pack(fill=tk.BOTH, expand=True) + + title_label = tk.Label(main_output_frame, text="Workshop IDs Output", + font=('Arial', 14, 'bold'), bg='#2b2b2b', fg='#ffffff') + title_label.pack(pady=(0, 10)) + + # Output text area with scrollbar + self.output_text = scrolledtext.ScrolledText(main_output_frame, + font=('Consolas', 10), + bg='#1e1e1e', fg='#ffffff', + insertbackground='#ffffff', + selectbackground='#404040', + relief='flat', bd=5, height=20) + self.output_text.pack(fill=tk.BOTH, expand=True) + + # Local mod folders section + local_mods_frame = tk.Frame(parent, bg='#2b2b2b') + local_mods_frame.pack(fill=tk.X, pady=(10, 0)) + + local_title_label = tk.Label(local_mods_frame, text="Local Mod Folders", + font=('Arial', 12, 'bold'), bg='#2b2b2b', fg='#ffffff') + local_title_label.pack(pady=(0, 5)) + + # Local mods text area + self.local_mods_text = scrolledtext.ScrolledText(local_mods_frame, + font=('Consolas', 9), + bg='#1e1e1e', fg='#ffffff', + insertbackground='#ffffff', + selectbackground='#404040', + relief='flat', bd=5, height=8) + self.local_mods_text.pack(fill=tk.X) + + def extract_workshop_id(self, url): + """Extract workshop ID from Steam URL""" + # Clean up steam:// protocol URLs + if url.startswith('steam://openurl/'): + url = url.replace('steam://openurl/', '') + + # Extract ID from URL parameters + try: + parsed_url = urlparse(url) + if 'id=' in parsed_url.query: + query_params = parse_qs(parsed_url.query) + return query_params.get('id', [None])[0] + elif '/filedetails/?id=' in url: + # Direct extraction for simple cases + match = re.search(r'id=(\d+)', url) + if match: + return match.group(1) + except Exception as e: + print(f"Error extracting ID from {url}: {e}") + + return None + + def process_collections(self): + """Process all collection URLs and extract workshop IDs""" + self.output_text.delete(1.0, tk.END) + self.output_text.insert(tk.END, "Processing collections...\n\n") + self.root.update() + + # Process each collection in a separate thread to avoid blocking UI + threading.Thread(target=self._process_collections_thread, daemon=True).start() + + def _process_collections_thread(self): + """Thread function to process collections""" + try: + for collection_name, entry in self.url_entries.items(): + url = entry.get().strip() + if url: + self._safe_update_output(f"=== {collection_name.upper()} COLLECTION ===\n") + + workshop_id = self.extract_workshop_id(url) + if workshop_id: + self._safe_update_output(f"Collection ID: {workshop_id}\n") + + # Fetch collection contents + workshop_ids = self.fetch_collection_items(workshop_id) + if workshop_ids: + self._safe_update_output(f"Found {len(workshop_ids)} items:\n") + + # Check which mods are installed locally if workshop folder is available + local_mods = set() + if self.workshop_folder and os.path.exists(self.workshop_folder): + try: + local_mods = {item for item in os.listdir(self.workshop_folder) + if os.path.isdir(os.path.join(self.workshop_folder, item)) and item.isdigit()} + except: + pass + + missing_count = 0 + for item_id in workshop_ids: + status = " [INSTALLED]" if item_id in local_mods else " [MISSING]" + if item_id not in local_mods: + missing_count += 1 + self._safe_update_output(f"{item_id}{status}\n") + + if local_mods: + self._safe_update_output(f"\nSummary: {len(workshop_ids) - missing_count} installed, {missing_count} missing\n") + else: + self._safe_update_output("Could not fetch collection items\n") + else: + self._safe_update_output("Could not extract workshop ID from URL\n") + + self._safe_update_output("\n") + + self._safe_update_output("Processing complete!") + except Exception as e: + self._safe_update_output(f"Error in processing: {str(e)}\n") + + def find_steam_workshop_folder(self): + """Automatically find the Steam Workshop folder for RimWorld""" + try: + # Method 1: Try to find Steam installation path from registry + steam_paths = [] + + try: + # Check HKEY_LOCAL_MACHINE first + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\WOW6432Node\Valve\Steam") as key: + install_path = winreg.QueryValueEx(key, "InstallPath")[0] + steam_paths.append(install_path) + except (FileNotFoundError, OSError): + pass + + try: + # Check HKEY_CURRENT_USER + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Valve\Steam") as key: + install_path = winreg.QueryValueEx(key, "SteamPath")[0] + steam_paths.append(install_path) + except (FileNotFoundError, OSError): + pass + + # Method 2: Check common Steam installation locations + common_paths = [ + r"C:\Program Files (x86)\Steam", + r"C:\Program Files\Steam", + r"D:\Steam", + r"E:\Steam", + os.path.expanduser(r"~\Steam") + ] + steam_paths.extend(common_paths) + + # Method 3: Check if Steam is in PATH + try: + import shutil + steam_exe = shutil.which("steam.exe") + if steam_exe: + steam_paths.append(os.path.dirname(steam_exe)) + except: + pass + + # Look for the workshop folder in each potential Steam path + for steam_path in steam_paths: + if not steam_path: + continue + + workshop_path = os.path.join(steam_path, "steamapps", "workshop", "content", "294100") + if os.path.exists(workshop_path): + return workshop_path + + return None + + except Exception as e: + print(f"Error finding Steam workshop folder: {e}") + return None + + def browse_workshop_folder(self): + """Allow user to manually select the workshop folder""" + folder = filedialog.askdirectory( + title="Select RimWorld Workshop Folder (steamapps/workshop/content/294100)", + initialdir=self.workshop_folder or "C:\\" + ) + if folder: + # Validate that this looks like a workshop folder + if "294100" in folder or messagebox.askyesno( + "Confirm Folder", + f"Are you sure this is the RimWorld workshop folder?\n\n{folder}" + ): + self._safe_update_output(f"Workshop folder updated: {folder}\n") + + def on_rimworld_path_change(self, event=None): + """Called when RimWorld path is changed""" + rimworld_path = self.rimworld_var.get().strip() + if rimworld_path: + # Derive workshop path from RimWorld path + # From: D:\SteamLibrary\steamapps\common\RimWorld + # To: D:\SteamLibrary\steamapps\workshop\content\294100 + + if "steamapps" in rimworld_path.lower(): + # Find the steamapps part and replace common\RimWorld with workshop\content\294100 + parts = rimworld_path.split(os.sep) + try: + steamapps_index = next(i for i, part in enumerate(parts) if part.lower() == 'steamapps') + # Build new path: everything up to steamapps + steamapps + workshop + content + 294100 + workshop_parts = parts[:steamapps_index + 1] + ['workshop', 'content', '294100'] + workshop_path = os.sep.join(workshop_parts) + self.workshop_folder = workshop_path + self.workshop_var.set(workshop_path) + except (StopIteration, IndexError): + self.workshop_var.set("Invalid RimWorld path - should contain 'steamapps'") + else: + self.workshop_var.set("Invalid RimWorld path - should contain 'steamapps'") + else: + self.workshop_var.set("Enter RimWorld path above") + + def browse_rimworld_folder(self): + """Allow user to browse for RimWorld game folder""" + folder = filedialog.askdirectory( + title="Select RimWorld Game Folder (Right-click RimWorld in Steam > Manage > Browse local files)", + initialdir="C:\\" + ) + if folder: + self.rimworld_var.set(folder) + self.on_rimworld_path_change() # Update workshop path + + def list_local_mod_folders(self): + """List all local mod folder names in the separate text box""" + if not self.workshop_folder or not os.path.exists(self.workshop_folder): + self.local_mods_text.delete(1.0, tk.END) + self.local_mods_text.insert(tk.END, "Workshop folder not found or invalid.\nPlease enter correct RimWorld path above.") + return + + threading.Thread(target=self._list_local_mod_folders_thread, daemon=True).start() + + def _list_local_mod_folders_thread(self): + """Thread function to list local mod folders""" + try: + # Clear the local mods text box + self.local_mods_text.delete(1.0, tk.END) + + if not os.path.exists(self.workshop_folder): + self.local_mods_text.insert(tk.END, f"Workshop folder does not exist:\n{self.workshop_folder}") + return + + # Get all subdirectories (each represents a workshop mod) + local_mods = [] + for item in os.listdir(self.workshop_folder): + item_path = os.path.join(self.workshop_folder, item) + if os.path.isdir(item_path) and item.isdigit(): + local_mods.append(item) + + local_mods.sort() + + # Display in the local mods text box + self.local_mods_text.insert(tk.END, f"Found {len(local_mods)} local workshop mod folders:\n\n") + for mod_id in local_mods: + self.local_mods_text.insert(tk.END, f"{mod_id}\n") + + except Exception as e: + self.local_mods_text.delete(1.0, tk.END) + self.local_mods_text.insert(tk.END, f"Error listing local mods: {str(e)}") + + def check_local_mods(self): + """Check which mods are installed locally in the workshop folder (for backward compatibility)""" + self.list_local_mod_folders() + + def _safe_update_output(self, text): + """Safely update output text without recursion""" + try: + self.output_text.insert(tk.END, text) + self.output_text.see(tk.END) + except Exception: + pass # Ignore update errors to prevent recursion + + def fetch_collection_items(self, collection_id): + """Fetch workshop IDs from a Steam Workshop collection""" + try: + # Steam Workshop collection URL + url = f"https://steamcommunity.com/sharedfiles/filedetails/?id={collection_id}" + + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' + } + + self._safe_update_output(f"Fetching collection page...\n") + + response = requests.get(url, headers=headers, timeout=15) + response.raise_for_status() + + # Extract workshop IDs from collection items only + html_content = response.text + workshop_ids = [] + + # Find the collectionChildren section + collection_start = html_content.find('
    ') + if collection_start != -1: + # Find the proper end of the collectionChildren section + # Look for the closing
    that matches the opening collectionChildren div + # We need to count div tags to find the matching closing tag + + search_pos = collection_start + len('
    ') + div_count = 1 # We've opened one div + collection_end = -1 + + while search_pos < len(html_content) and div_count > 0: + next_open = html_content.find('', search_pos) + + if next_close == -1: + break + + if next_open != -1 and next_open < next_close: + div_count += 1 + search_pos = next_open + 4 + else: + div_count -= 1 + if div_count == 0: + collection_end = next_close + 6 + break + search_pos = next_close + 6 + + if collection_end == -1: + collection_end = len(html_content) + + collection_section = html_content[collection_start:collection_end] + + # Extract IDs from sharedfile_ elements (these are the actual collection items) + sharedfile_ids = re.findall(r'id="sharedfile_(\d+)"', collection_section) + workshop_ids.extend(sharedfile_ids) + + self._safe_update_output(f"Found {len(sharedfile_ids)} collection items\n") + else: + # Fallback: search the entire page but be more selective + self._safe_update_output("Collection section not found, using fallback method\n") + sharedfile_ids = re.findall(r'id="sharedfile_(\d+)"', html_content) + workshop_ids.extend(sharedfile_ids) + + # Remove duplicates and the collection ID itself + unique_ids = list(set(workshop_ids)) + if collection_id in unique_ids: + unique_ids.remove(collection_id) + + return sorted(unique_ids) # Sort for consistent output + + except requests.RequestException as e: + self._safe_update_output(f"Network error: {str(e)}\n") + return [] + except Exception as e: + self._safe_update_output(f"Error fetching collection: {str(e)}\n") + return [] + + def _update_output(self, text): + """Update output text (called from main thread)""" + try: + self.output_text.insert(tk.END, text) + self.output_text.see(tk.END) + except Exception: + pass # Prevent recursion errors + +def main(): + root = tk.Tk() + app = SteamWorkshopGUI(root) + root.mainloop() + +if __name__ == "__main__": + main() \ No newline at end of file