Add collection scraper and local mod detection

This commit is contained in:
2026-01-23 21:13:37 -05:00
parent e67b461167
commit 7f293558b4
4 changed files with 579 additions and 1 deletions

29
About.xml Normal file
View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<ModMetaData>
<name>RT Fuse</name>
<author>Ratys</author>
<packageId>ratys.rtfuse</packageId>
<url>https://ludeon.com/forums/index.php?topic=11272</url>
<supportedVersions>
<li>1.0</li>
<li>1.1</li>
<li>1.2</li>
<li>1.3</li>
<li>1.4</li>
<li>1.5</li>
<li>1.6</li>
</supportedVersions>
<modDependencies>
<li>
<packageId>brrainz.harmony</packageId>
<displayName>Harmony</displayName>
<steamWorkshopUrl>steam://url/CommunityFilePage/2009463077</steamWorkshopUrl>
<downloadUrl>https://github.com/pardeike/HarmonyRimWorld/releases/latest</downloadUrl>
</li>
</modDependencies>
<description>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.</description>
</ModMetaData>

View File

@@ -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

1
requirements.txt Normal file
View File

@@ -0,0 +1 @@
requests>=2.25.1

502
steam_workshop_gui.py Normal file
View File

@@ -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('<KeyRelease>', 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('<div class="collectionChildren">')
if collection_start != -1:
# Find the proper end of the collectionChildren section
# Look for the closing </div> that matches the opening collectionChildren div
# We need to count div tags to find the matching closing tag
search_pos = collection_start + len('<div class="collectionChildren">')
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('<div', search_pos)
next_close = html_content.find('</div>', 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()