Intial Release
Some checks failed
Build and Upload Release (Windows EXE) / Build Windows EXE (release) Has been cancelled

This commit is contained in:
2025-09-16 12:47:31 -04:00
parent 7e0462bc23
commit af8b13295a
8 changed files with 724 additions and 0 deletions

View File

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

BIN
SeaLoader.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

BIN
SeaLoader.psd Normal file

Binary file not shown.

Binary file not shown.

37
hrsys_logo.svg Normal file
View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 2048 2048">
<!-- Generator: Adobe Illustrator 29.6.0, SVG Export Plug-In . SVG Version: 2.1.1 Build 207) -->
<defs>
<style>
.st0, .st1, .st2 {
fill: #f16465;
}
.st0, .st1, .st3 {
display: none;
}
.st1 {
font-family: FMBolyarSansPro-400, 'FM Bolyar Sans Pro';
font-size: 176.84px;
}
.st4 {
letter-spacing: .6em;
}
</style>
</defs>
<g id="Systems" class="st3">
<text/>
</g>
<g id="H">
<g>
<text class="st1" transform="translate(151.86 1585.59)"><tspan class="st4" x="0" y="0">SYSTEM</tspan><tspan x="1589.37" y="0">S</tspan></text>
<rect class="st0" x="43.86" y="1699.23" width="1960.29" height="16.64"/>
<g>
<path class="st2" d="M516.81,384.73c354.34.79,708.68,1.58,1063.01,2.37,25.33-.26,191.03.81,306.99,134.63,27.84,32.13,97.9,123.95,92.85,253.41-5.85,150-109.21,265.44-206.05,311.69-40.43,19.31-82.89,28.07-95.95,30.69-75.28,15.13-139.12,8.06-179.02.41,180.82,180.82,361.64,361.64,542.47,542.47h-281.15c-240.47-240.47-480.95-480.95-721.42-721.42h600.36c98.23-15.25,167-101.58,161.66-191.46-6.01-101.15-104.58-184.37-216.28-168.88h-897.5c-56.65-64.64-113.31-129.28-169.96-193.92Z"/>
<polygon class="st2" points="6.89 1663.27 6.89 385.2 190.13 385.88 189.56 945.29 940.52 945.29 1173.93 1166.01 1173.93 1663.27 991.26 1663.27 991.26 1125.42 192.09 1125.42 192.09 1663.27 6.89 1663.27"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
requests>=2.31.0
beautifulsoup4>=4.12.2

385
sealoader_gui.py Normal file
View File

@@ -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=<id or name>,<True|False>
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("<F1>", 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("<Configure>", 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 \"<path-to-usersettings.ini>\"\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()

234
steam_required_ids.py Normal file
View File

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