diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml
new file mode 100644
index 0000000..f131a82
--- /dev/null
+++ b/.gitea/workflows/release.yml
@@ -0,0 +1,66 @@
+name: Build and Upload Release (Windows EXE)
+
+on:
+ release:
+ types: [published]
+ workflow_dispatch: {}
+
+jobs:
+ build-windows-exe:
+ name: Build Windows EXE
+ runs-on: windows-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+
+ - name: Install dependencies
+ shell: pwsh
+ run: |
+ python -m pip install --upgrade pip
+ if (Test-Path requirements.txt) { pip install -r requirements.txt }
+ pip install pyinstaller
+
+ - name: Build EXE with PyInstaller
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+ # Include SeaLoader.png so the packaged app icon in-app works
+ $addData = "SeaLoader.png;." # Windows uses ';' for --add-data
+ pyinstaller --noconfirm --onefile --windowed sealoader_gui.py --name SeaLoader --add-data "$addData"
+
+ - name: Prepare artifact
+ shell: pwsh
+ run: |
+ New-Item -ItemType Directory -Force -Path dist_upload | Out-Null
+ Copy-Item dist\SeaLoader.exe dist_upload\SeaLoader.exe
+ if (Test-Path README.md) { Copy-Item README.md dist_upload\ }
+ if (Test-Path LICENSE) { Copy-Item LICENSE dist_upload\ }
+ Compress-Archive -Path dist_upload\* -DestinationPath SeaLoader_Windows_x64.zip -Force
+
+ - name: Upload asset to Release
+ if: ${{ github.event_name == 'release' && github.event.action == 'published' }}
+ shell: pwsh
+ env:
+ TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ REPO: ${{ github.repository }}
+ RELEASE_ID: ${{ github.event.release.id }}
+ SERVER_URL: ${{ github.server_url }}
+ run: |
+ $ErrorActionPreference = 'Stop'
+ $uploadUrl = "$env:SERVER_URL/api/v1/repos/$env:REPO/releases/$env:RELEASE_ID/assets?name=SeaLoader_Windows_x64.zip"
+ Write-Host "Uploading asset to $uploadUrl"
+ Invoke-RestMethod -Method Post -Uri $uploadUrl -Headers @{ Authorization = "token $env:TOKEN" } -ContentType "application/zip" -InFile "SeaLoader_Windows_x64.zip"
+
+ - name: Upload artifact (CI logs)
+ uses: actions/upload-artifact@v4
+ with:
+ name: SeaLoader_Windows_x64
+ path: |
+ SeaLoader_Windows_x64.zip
+ dist/SeaLoader/SeaLoader.exe
+
diff --git a/SeaLoader.png b/SeaLoader.png
new file mode 100644
index 0000000..d16b393
Binary files /dev/null and b/SeaLoader.png differ
diff --git a/SeaLoader.psd b/SeaLoader.psd
new file mode 100644
index 0000000..919b05a
Binary files /dev/null and b/SeaLoader.psd differ
diff --git a/__pycache__/steam_required_ids.cpython-310.pyc b/__pycache__/steam_required_ids.cpython-310.pyc
new file mode 100644
index 0000000..dd05ab8
Binary files /dev/null and b/__pycache__/steam_required_ids.cpython-310.pyc differ
diff --git a/hrsys_logo.svg b/hrsys_logo.svg
new file mode 100644
index 0000000..60d90e6
--- /dev/null
+++ b/hrsys_logo.svg
@@ -0,0 +1,37 @@
+
+
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..55f34b6
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,2 @@
+requests>=2.31.0
+beautifulsoup4>=4.12.2
diff --git a/sealoader_gui.py b/sealoader_gui.py
new file mode 100644
index 0000000..ea3d64d
--- /dev/null
+++ b/sealoader_gui.py
@@ -0,0 +1,385 @@
+import configparser
+import os
+import shutil
+import sys
+import threading
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Dict, List, Tuple
+
+import tkinter as tk
+from tkinter import messagebox, ttk
+
+from steam_required_ids import extract_required_item_ids, resolve_workshop_names
+
+
+# Minimal dark theme colors
+BG = "#16181c"
+PANEL_BG = "#1f232a"
+FG = "#e6e6e6"
+MUTED = "#a0a6ad"
+ACCENT = "#4f8cff"
+
+
+DEFAULT_INI_PATH = str(Path.home() / "AppData" / "LocalLow" / "Triassic Games" / "Sea Power" / "usersettings.ini")
+
+
+@dataclass
+class InstalledMod:
+ mod_id: str
+ enabled: bool
+
+
+def read_installed_mods(ini_path: str) -> Dict[str, InstalledMod]:
+ parser = configparser.ConfigParser()
+ parser.optionxform = str
+ if not os.path.exists(ini_path):
+ raise FileNotFoundError(f"usersettings.ini not found at: {ini_path}")
+ parser.read(ini_path, encoding="utf-8")
+
+ if "[LoadOrder]" in "\n".join(open(ini_path, encoding="utf-8").readlines()):
+ section = "LoadOrder"
+ else:
+ section = "LoadOrder"
+
+ if section not in parser:
+ return {}
+
+ mods: Dict[str, InstalledMod] = {}
+ for key, value in parser[section].items():
+ if not key.lower().startswith("mod"):
+ continue
+ # Format: ModXDirectory=,
+ parts = value.split(",")
+ if not parts:
+ continue
+ left_token = parts[0].strip()
+ # Prefer numeric Workshop ID if present at start, else keep full token
+ num_prefix = ""
+ for ch in left_token:
+ if ch.isdigit():
+ num_prefix += ch
+ else:
+ break
+ mod_id = num_prefix if num_prefix else left_token
+ enabled = False
+ if len(parts) > 1:
+ enabled = parts[1].strip().lower() == "true"
+ mods[mod_id] = InstalledMod(mod_id=mod_id, enabled=enabled)
+ return mods
+
+
+def enable_mods_in_ini(ini_path: str, ids_to_enable: List[str]) -> Tuple[int, int]:
+ parser = configparser.ConfigParser()
+ parser.optionxform = str
+ parser.read(ini_path, encoding="utf-8")
+
+ section = "LoadOrder"
+ if section not in parser:
+ raise ValueError("[LoadOrder] section not found in usersettings.ini")
+
+ # Backup before changes
+ backup_path = ini_path + ".bak"
+ shutil.copyfile(ini_path, backup_path)
+
+ enabled_count = 0
+ missing_count = 0
+
+ # Maintain order and preserve original left token (may include name after ID)
+ items = list(parser[section].items())
+ # Maintain order
+ for target_id in ids_to_enable:
+ found_key = None
+ for key, value in items:
+ if not key.lower().startswith("mod"):
+ continue
+ parts = value.split(",")
+ if not parts:
+ continue
+ left_token = parts[0].strip()
+ # Extract numeric prefix for matching
+ num_prefix = ""
+ for ch in left_token:
+ if ch.isdigit():
+ num_prefix += ch
+ else:
+ break
+ mod_id = num_prefix if num_prefix else left_token
+ if mod_id == target_id:
+ found_key = key
+ current_enabled = False
+ if len(parts) > 1:
+ current_enabled = parts[1].strip().lower() == "true"
+ if not current_enabled:
+ # Preserve original left token, only toggle enabled flag
+ new_value = f"{left_token},True"
+ parser[section][key] = new_value
+ enabled_count += 1
+ break
+ if not found_key:
+ missing_count += 1
+
+ with open(ini_path, "w", encoding="utf-8") as f:
+ parser.write(f)
+
+ return enabled_count, missing_count
+
+
+class SeaLoaderApp(tk.Tk):
+ def __init__(self, ini_path: str):
+ super().__init__()
+ self.title("SeaLoader")
+ self.configure(bg=BG)
+ self.geometry("960x560")
+ self.minsize(800, 480)
+
+ self.ini_path = ini_path
+ self.installed_mods: Dict[str, InstalledMod] = {}
+ self.required_ids: List[str] = []
+ self.installed_names_map: Dict[str, str] = {}
+ self._icon_img = None
+
+ self._style()
+ self._layout()
+ self._load_installed_mods()
+
+ # App icon from PNG (optional)
+ try:
+ icon_path = self._resource_path("SeaLoader.png")
+ if icon_path.exists():
+ self._icon_img = tk.PhotoImage(file=str(icon_path))
+ self.iconphoto(True, self._icon_img)
+ except Exception:
+ pass
+
+ # Help shortcut
+ try:
+ self.bind("", lambda e: self._on_help())
+ except Exception:
+ pass
+
+ def _style(self) -> None:
+ style = ttk.Style(self)
+ try:
+ style.theme_use("clam")
+ except Exception:
+ pass
+ style.configure("TFrame", background=BG)
+ style.configure("Panel.TFrame", background=PANEL_BG)
+ style.configure("TLabel", background=PANEL_BG, foreground=FG)
+ style.configure("Header.TLabel", background=BG, foreground=FG, font=("Segoe UI", 12, "bold"))
+ style.configure("TButton", background=PANEL_BG, foreground=FG, borderwidth=0)
+ style.map("TButton", background=[("active", ACCENT)])
+ style.configure("TEntry", fieldbackground=PANEL_BG, foreground=FG, insertcolor=FG)
+ style.configure("TScrollbar", background=PANEL_BG, troughcolor=BG)
+ style.configure("Treeview", background=PANEL_BG, fieldbackground=PANEL_BG, foreground=FG, borderwidth=0)
+ style.configure("Treeview.Heading", background=PANEL_BG, foreground=MUTED)
+
+ def _layout(self) -> None:
+ top = ttk.Frame(self, style="TFrame")
+ top.pack(fill=tk.X, padx=12, pady=(12, 6))
+
+ url_label = ttk.Label(top, text="Workshop URL:", style="TLabel")
+ url_label.pack(side=tk.LEFT)
+ self.url_var = tk.StringVar()
+ self.url_entry = ttk.Entry(top, textvariable=self.url_var, width=70)
+ self.url_entry.pack(side=tk.LEFT, padx=8, fill=tk.X, expand=True)
+ self.fetch_btn = ttk.Button(top, text="Fetch Required Mods", command=self._on_fetch)
+ self.fetch_btn.pack(side=tk.LEFT)
+
+ main = ttk.Frame(self, style="TFrame")
+ main.pack(fill=tk.BOTH, expand=True, padx=12, pady=6)
+
+ left_panel = ttk.Frame(main, style="Panel.TFrame")
+ left_panel.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
+ right_panel = ttk.Frame(main, style="Panel.TFrame")
+ right_panel.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(8, 0))
+
+ left_header = ttk.Label(left_panel, text="Installed Mods", style="Header.TLabel")
+ left_header.pack(anchor="w", padx=8, pady=8)
+ self.installed_tree = self._create_tree(left_panel, ["Mod ID", "Name", "Enabled"], [0.30, 0.55, 0.15])
+ self.installed_tree.pack(fill=tk.BOTH, expand=True, padx=8, pady=(0, 8))
+
+ right_header = ttk.Label(right_panel, text="Scenario Required Mods", style="Header.TLabel")
+ right_header.pack(anchor="w", padx=8, pady=8)
+ self.required_tree = self._create_tree(right_panel, ["Mod ID", "Name", "Status"], [0.25, 0.60, 0.15])
+ self.required_tree.pack(fill=tk.BOTH, expand=True, padx=8, pady=(0, 8))
+
+ bottom = ttk.Frame(self, style="TFrame")
+ bottom.pack(fill=tk.X, padx=12, pady=(0, 12))
+ self.enable_btn = ttk.Button(bottom, text="Enable Matching Mods", command=self._on_enable)
+ self.enable_btn.pack(side=tk.LEFT)
+ self.help_btn = ttk.Button(bottom, text="Help", command=self._on_help)
+ self.help_btn.pack(side=tk.RIGHT)
+
+ def _create_tree(self, parent, columns: List[str], ratios: List[float]):
+ tree = ttk.Treeview(parent, columns=columns, show="headings", height=12)
+ for col in columns:
+ tree.heading(col, text=col)
+ tree.column(col, width=50, anchor=tk.W, stretch=True)
+
+ vsb = ttk.Scrollbar(parent, orient="vertical", command=tree.yview)
+ hsb = ttk.Scrollbar(parent, orient="horizontal", command=tree.xview)
+ tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
+
+ tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
+ vsb.pack(side=tk.LEFT, fill=tk.Y)
+ hsb.pack(side=tk.BOTTOM, fill=tk.X)
+
+ self._bind_auto_columns(parent, tree, ratios)
+ return tree
+
+ def _bind_auto_columns(self, container, tree: ttk.Treeview, ratios: List[float]) -> None:
+ ratios = [r / sum(ratios) for r in ratios]
+ def on_resize(event):
+ # Reserve some space for vertical scrollbar and paddings
+ total = max(event.width - 18, 100)
+ for col, ratio in zip(tree["columns"], ratios):
+ tree.column(col, width=int(total * ratio))
+ container.bind("", on_resize, add="+")
+
+ def _load_installed_mods(self) -> None:
+ try:
+ self.installed_mods = read_installed_mods(self.ini_path)
+ except FileNotFoundError as e:
+ messagebox.showerror("SeaLoader", str(e))
+ self.installed_mods = {}
+ self._refresh_installed_list()
+ self._resolve_installed_names_async()
+
+ def _refresh_installed_list(self) -> None:
+ for row in self.installed_tree.get_children():
+ self.installed_tree.delete(row)
+ for mod in self.installed_mods.values():
+ status = "Yes" if mod.enabled else "No"
+ display_name = self.installed_names_map.get(mod.mod_id, "")
+ self.installed_tree.insert("", tk.END, values=(mod.mod_id, display_name, status))
+
+ def _resolve_installed_names_async(self) -> None:
+ ids = [mid for mid in self.installed_mods.keys() if mid.isdigit()]
+ ids_to_lookup = [mid for mid in ids if mid not in self.installed_names_map]
+ if not ids_to_lookup:
+ return
+
+ def task():
+ try:
+ names = resolve_workshop_names(ids_to_lookup)
+ except Exception:
+ names = {}
+
+ def update():
+ if names:
+ self.installed_names_map.update(names)
+ self._refresh_installed_list()
+
+ self.after(0, update)
+
+ threading.Thread(target=task, daemon=True).start()
+
+ def _on_fetch(self) -> None:
+ url = self.url_var.get().strip()
+ if not url:
+ messagebox.showwarning("SeaLoader", "Please paste a Workshop URL.")
+ return
+
+ self.fetch_btn.configure(state=tk.DISABLED)
+ self.enable_btn.configure(state=tk.DISABLED)
+
+ def task():
+ try:
+ ids = extract_required_item_ids(url)
+ # Reuse installed cache to reduce API calls
+ names_map = {i: self.installed_names_map[i] for i in ids if i in self.installed_names_map}
+ ids_to_lookup = [i for i in ids if i not in names_map]
+ if ids_to_lookup:
+ fetched = resolve_workshop_names(ids_to_lookup)
+ names_map.update(fetched)
+ except Exception as exc:
+ self.after(0, lambda: messagebox.showerror("SeaLoader", f"Failed to fetch IDs: {exc}"))
+ self.after(0, lambda: self.fetch_btn.configure(state=tk.NORMAL))
+ self.after(0, lambda: self.enable_btn.configure(state=tk.NORMAL))
+ return
+
+ def update():
+ self.required_ids = ids
+ self._refresh_required_list(names_map)
+ self.fetch_btn.configure(state=tk.NORMAL)
+ self.enable_btn.configure(state=tk.NORMAL)
+
+ self.after(0, update)
+
+ threading.Thread(target=task, daemon=True).start()
+
+ def _refresh_required_list(self, names_map: Dict[str, str] | None = None) -> None:
+ for row in self.required_tree.get_children():
+ self.required_tree.delete(row)
+ installed_ids = set(self.installed_mods.keys())
+ names_map = names_map or {}
+ for mod_id in self.required_ids:
+ status = "Installed" if mod_id in installed_ids else "Missing"
+ display_name = names_map.get(mod_id, "")
+ self.required_tree.insert("", tk.END, values=(mod_id, display_name, status))
+
+ def _on_enable(self) -> None:
+ if not self.required_ids:
+ messagebox.showinfo("SeaLoader", "No required mods loaded. Fetch from URL first.")
+ return
+
+ try:
+ enabled_count, missing_count = enable_mods_in_ini(self.ini_path, self.required_ids)
+ except Exception as exc:
+ messagebox.showerror("SeaLoader", f"Failed to update INI: {exc}")
+ return
+
+ # Reload to reflect changes
+ self._load_installed_mods()
+ self._refresh_required_list()
+
+ msg_lines = [f"Enabled {enabled_count} mod(s)."]
+ if missing_count:
+ msg_lines.append(f"{missing_count} required mod(s) not found in usersettings.ini.")
+ else:
+ msg_lines.append("All required mods were found.")
+ messagebox.showinfo("SeaLoader", "\n".join(msg_lines))
+
+ def _on_help(self) -> None:
+ ini_hint = (
+ f"Default INI: {self.ini_path}\n\n"
+ )
+ text = (
+ "Usage:\n\n"
+ "1) Paste a Steam Workshop URL at the top and click 'Fetch Required Mods'.\n"
+ " - Right list shows required Mod IDs, names and whether they are installed.\n"
+ "2) Installed mods load automatically from usersettings.ini (left list).\n"
+ "3) Click 'Enable Matching Mods' to turn on any installed required mods.\n"
+ " - A .bak backup of usersettings.ini is created before changes.\n"
+ "4) Mods marked Missing must be installed separately in Sea Power.\n\n"
+ "Tips:\n"
+ "- Run with a custom INI path: python sealoader_gui.py \"\"\n"
+ "- Press F1 to open this help.\n\n"
+ + ini_hint
+ )
+ messagebox.showinfo("SeaLoader Help", text)
+
+ def _resource_path(self, name: str) -> Path:
+ try:
+ base = getattr(sys, "_MEIPASS", None)
+ if base:
+ return Path(base) / name
+ except Exception:
+ pass
+ return Path(__file__).resolve().parent / name
+
+
+def main() -> None:
+ ini_path = DEFAULT_INI_PATH
+ if len(sys.argv) > 1:
+ ini_path = sys.argv[1]
+ app = SeaLoaderApp(ini_path)
+ app.mainloop()
+
+
+if __name__ == "__main__":
+ main()
+
+
diff --git a/steam_required_ids.py b/steam_required_ids.py
new file mode 100644
index 0000000..f558b06
--- /dev/null
+++ b/steam_required_ids.py
@@ -0,0 +1,234 @@
+import argparse
+import json
+import re
+import sys
+from typing import Iterable, List, Set, Dict
+from urllib.parse import urlparse, parse_qs
+
+import requests
+from bs4 import BeautifulSoup
+
+
+WORKSHOP_ITEM_ID_REGEX = re.compile(r"id=(\d+)")
+
+
+def extract_id_from_href(href: str) -> str | None:
+ if not href:
+ return None
+
+ # Accept absolute or relative Steam workshop/sharedfiles links
+ if "filedetails" not in href or "id=" not in href:
+ return None
+
+ try:
+ parsed = urlparse(href)
+ # Handle relative URLs like "/sharedfiles/filedetails/?id=123"
+ query = parsed.query or href.split("?", 1)[1] if "?" in href else ""
+ qs = parse_qs(query)
+ if "id" in qs and qs["id"]:
+ candidate = qs["id"][0]
+ return candidate if candidate.isdigit() else None
+ except Exception:
+ match = WORKSHOP_ITEM_ID_REGEX.search(href)
+ if match:
+ return match.group(1)
+ return None
+
+
+def parse_main_item_id(url: str) -> str | None:
+ try:
+ parsed = urlparse(url)
+ qs = parse_qs(parsed.query)
+ if "id" in qs and qs["id"]:
+ candidate = qs["id"][0]
+ return candidate if candidate.isdigit() else None
+ except Exception:
+ pass
+ return None
+
+
+def collect_ids_from_elements(elements: Iterable) -> Set[str]:
+ ids: Set[str] = set()
+ for el in elements:
+ href = getattr(el, "get", None)
+ if callable(href):
+ link = el.get("href", "")
+ else:
+ link = ""
+ item_id = extract_id_from_href(link)
+ if item_id:
+ ids.add(item_id)
+ return ids
+
+
+def extract_required_item_ids_from_html(html: str) -> Set[str]:
+ soup = BeautifulSoup(html, "html.parser")
+
+ # Strategy 1: Look for a section headed "Required items" and parse links within
+ section_ids: Set[str] = set()
+ heading_candidates = soup.find_all(string=re.compile(r"^\s*Required\s+items\s*$", re.IGNORECASE))
+ for heading in heading_candidates:
+ parent = heading.parent
+ if parent is None:
+ continue
+
+ # Search within nearby container siblings/descendants for links
+ container = parent
+ for _ in range(3): # climb up a few levels to catch the full block
+ if container is None:
+ break
+ links = container.find_all("a", href=True)
+ section_ids |= collect_ids_from_elements(links)
+ container = container.parent
+
+ if section_ids:
+ return section_ids
+
+ # Strategy 2: Look for any block that contains the sentence used by Steam
+ hint_blocks = soup.find_all(string=re.compile(r"requires\s+all\s+of\s+the\s+following\s+other\s+items", re.IGNORECASE))
+ for hint in hint_blocks:
+ container = hint.parent
+ for _ in range(3):
+ if container is None:
+ break
+ links = container.find_all("a", href=True)
+ section_ids |= collect_ids_from_elements(links)
+ container = container.parent
+
+ if section_ids:
+ return section_ids
+
+ # Strategy 3 (fallback): scan all anchors on the page
+ all_links = soup.find_all("a", href=True)
+ return collect_ids_from_elements(all_links)
+
+
+def fetch_page(url: str, timeout: int = 20) -> str:
+ headers = {
+ "User-Agent": (
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/126.0.0.0 Safari/537.36"
+ ),
+ "Accept-Language": "en-US,en;q=0.9",
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
+ }
+ # Steam can occasionally require a cookie for age gates. Provide innocuous defaults.
+ cookies = {
+ "birthtime": "568022401", # 1987-12-20
+ "lastagecheckage": "1-January-1990",
+ "mature_content": "1",
+ }
+ resp = requests.get(url, headers=headers, cookies=cookies, timeout=timeout)
+ resp.raise_for_status()
+ return resp.text
+
+
+def extract_required_item_ids(url: str) -> List[str]:
+ html = fetch_page(url)
+ found_ids = extract_required_item_ids_from_html(html)
+
+ # Remove the current page's ID if present
+ current_id = parse_main_item_id(url)
+ if current_id and current_id in found_ids:
+ found_ids.remove(current_id)
+
+ return sorted(found_ids, key=int)
+
+
+def resolve_workshop_names(ids: List[str], timeout: int = 20) -> Dict[str, str]:
+ """Resolve Workshop IDs to human-readable titles using Steam API, with HTML fallback.
+
+ Uses ISteamRemoteStorage.GetPublishedFileDetails, batching up to 100 IDs per call.
+ Falls back to scraping each item's page if the API fails.
+ """
+ id_list = [i for i in dict.fromkeys([i for i in ids if i and i.isdigit()])]
+ if not id_list:
+ return {}
+
+ headers = {
+ "User-Agent": (
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/126.0.0.0 Safari/537.36"
+ ),
+ }
+
+ api_url = "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/"
+ resolved: Dict[str, str] = {}
+
+ try:
+ session = requests.Session()
+ session.headers.update(headers)
+ batch_size = 100
+ for start in range(0, len(id_list), batch_size):
+ batch = id_list[start:start + batch_size]
+ data = {"itemcount": len(batch)}
+ for idx, pub_id in enumerate(batch):
+ data[f"publishedfileids[{idx}]"] = pub_id
+ resp = session.post(api_url, data=data, timeout=timeout)
+ resp.raise_for_status()
+ payload = resp.json()
+ details = payload.get("response", {}).get("publishedfiledetails", [])
+ for entry in details:
+ if entry.get("result") == 1:
+ title = entry.get("title")
+ pub_id = str(entry.get("publishedfileid"))
+ if pub_id and title:
+ resolved[pub_id] = title
+ except Exception:
+ # API failure; fall back to HTML scraping below
+ pass
+
+ # Fallback for unresolved IDs: scrape the item page
+ unresolved = [i for i in id_list if i not in resolved]
+ for pub_id in unresolved:
+ try:
+ page_url = f"https://steamcommunity.com/sharedfiles/filedetails/?id={pub_id}"
+ html = fetch_page(page_url, timeout=timeout)
+ soup = BeautifulSoup(html, "html.parser")
+ name = None
+ og = soup.find("meta", attrs={"property": "og:title"})
+ if og and og.get("content"):
+ name = og.get("content").strip()
+ if not name:
+ title_div = soup.find("div", class_="workshopItemTitle")
+ if title_div and title_div.text:
+ name = title_div.text.strip()
+ if name:
+ resolved[pub_id] = name
+ except Exception:
+ # Leave unresolved if both methods fail
+ pass
+
+ return resolved
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Extract Steam Workshop 'Required items' IDs from a Workshop item page")
+ parser.add_argument("url", help="Steam Workshop item URL (e.g., https://steamcommunity.com/sharedfiles/filedetails/?id=XXXXXXXX)")
+ parser.add_argument("--json", action="store_true", help="Print JSON array instead of plain text")
+ args = parser.parse_args()
+
+ try:
+ ids = extract_required_item_ids(args.url)
+ except requests.HTTPError as http_err:
+ print(f"HTTP error: {http_err}", file=sys.stderr)
+ sys.exit(2)
+ except Exception as exc:
+ print(f"Failed to extract IDs: {exc}", file=sys.stderr)
+ sys.exit(1)
+
+ if args.json:
+ print(json.dumps(ids))
+ else:
+ if not ids:
+ print("No required item IDs found.")
+ else:
+ print("\n".join(ids))
+
+
+if __name__ == "__main__":
+ main()
+
+