No changes
This commit is contained in:
parent
8680a02b13
commit
b6b398f5bf
17374 changed files with 2475441 additions and 0 deletions
BIN
scripts/__pycache__/build_pdfs.cpython-312.pyc
Normal file
BIN
scripts/__pycache__/build_pdfs.cpython-312.pyc
Normal file
Binary file not shown.
691
scripts/build_pdfs.py
Normal file
691
scripts/build_pdfs.py
Normal file
|
|
@ -0,0 +1,691 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Game Guide PDF Generator
|
||||
|
||||
Reads all_hostable_games_union.csv and generates comprehensive self-hosting guides
|
||||
for each game in both Markdown and PDF formats.
|
||||
|
||||
Features:
|
||||
- No provider mentions (hosting-agnostic)
|
||||
- Exhaustive startup parameters and troubleshooting
|
||||
- Complete port mappings and configuration details
|
||||
- Steam Workshop integration where applicable
|
||||
- Professional PDF output with TOC and styling
|
||||
|
||||
Output Structure:
|
||||
- out/md/<sanitized-game>.md - Markdown source
|
||||
- out/pdfs/<sanitized-game>.pdf - PDF output
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import csv
|
||||
import re
|
||||
import subprocess
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
import pandas as pd
|
||||
|
||||
class GameGuideGenerator:
|
||||
def __init__(self, csv_path="all_hostable_games_union.csv",
|
||||
output_md="out/md", output_pdf="out/pdfs", template_path="templates/game_guide.md"):
|
||||
self.csv_path = Path(csv_path)
|
||||
self.output_md = Path(output_md)
|
||||
self.output_pdf = Path(output_pdf)
|
||||
self.template_path = Path(template_path)
|
||||
|
||||
# Ensure output directories exist
|
||||
self.output_md.mkdir(parents=True, exist_ok=True)
|
||||
self.output_pdf.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Forbidden provider mentions (must be filtered out)
|
||||
self.forbidden_terms = [
|
||||
'OGP', 'LinuxGSM', 'Nitrado', 'GameServers.com', 'G-Portal',
|
||||
'OpenGamePanel'
|
||||
]
|
||||
|
||||
# Common game engines for enhanced data
|
||||
self.engine_mapping = {
|
||||
'Counter-Strike': 'Source Engine', 'Counter-Strike 2': 'Source 2',
|
||||
'Counter-Strike: Source': 'Source Engine', 'Counter-Strike: Global Offensive': 'Source Engine',
|
||||
'Team Fortress 2': 'Source Engine', 'Left 4 Dead 2': 'Source Engine',
|
||||
'Garry\'s Mod': 'Source Engine', 'Half-Life 2: Deathmatch': 'Source Engine',
|
||||
'ARK: Survival Evolved': 'Unreal Engine 4', 'ARK: Survival Ascended': 'Unreal Engine 5',
|
||||
'Squad': 'Unreal Engine 4', 'Insurgency: Sandstorm': 'Unreal Engine 4',
|
||||
'Mordhau': 'Unreal Engine 4', 'Killing Floor 2': 'Unreal Engine 3',
|
||||
'Rust': 'Unity', 'Valheim': 'Unity', '7 Days to Die': 'Unity',
|
||||
'Unturned': 'Unity', 'Escape from Tarkov': 'Unity',
|
||||
'ARMA 3': 'Real Virtuality 4', 'ARMA 2': 'Real Virtuality 3',
|
||||
'DayZ': 'Enfusion', 'DayZ Standalone': 'Enfusion',
|
||||
'Minecraft': 'Java', 'Minecraft: Java Edition': 'Java',
|
||||
'Terraria': 'XNA/MonoGame', 'Starbound': 'Custom C++',
|
||||
'Factorio': 'Custom C++', 'Space Engineers': 'VRAGE 2.0',
|
||||
'Project Zomboid': 'Java', 'Don\'t Starve Together': 'Custom',
|
||||
'Palworld': 'Unreal Engine 5', 'Satisfactory': 'Unreal Engine 4',
|
||||
'Conan Exiles': 'Unreal Engine 4', 'The Forest': 'Unity',
|
||||
'Sons of the Forest': 'Unity', 'Green Hell': 'Unity',
|
||||
'V Rising': 'Unity', 'Core Keeper': 'Unity',
|
||||
'Enshrouded': 'Custom', 'Palworld': 'Unreal Engine 5'
|
||||
}
|
||||
|
||||
# Common Steam App IDs
|
||||
self.appid_mapping = {
|
||||
'Counter-Strike 2': '730', 'Counter-Strike: Global Offensive': '730',
|
||||
'Team Fortress 2': '440', 'Left 4 Dead 2': '550',
|
||||
'Garry\'s Mod': '4000', 'Rust': '258550',
|
||||
'ARK: Survival Evolved': '376030', 'Squad': '393380',
|
||||
'Valheim': '892970', '7 Days to Die': '294420',
|
||||
'Unturned': '304930', 'ARMA 3': '107410',
|
||||
'DayZ': '221100', 'Terraria': '105600',
|
||||
'Project Zomboid': '108600', 'Don\'t Starve Together': '322330',
|
||||
'Space Engineers': '244850', 'Conan Exiles': '440900',
|
||||
'The Forest': '242760', 'Killing Floor 2': '232090',
|
||||
'Insurgency: Sandstorm': '581320', 'Mordhau': '629760'
|
||||
}
|
||||
|
||||
def load_template(self):
|
||||
"""Load the Markdown template"""
|
||||
try:
|
||||
with open(self.template_path, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
except FileNotFoundError:
|
||||
print(f"Error: Template file not found: {self.template_path}")
|
||||
sys.exit(1)
|
||||
|
||||
def read_csv_games(self):
|
||||
"""Read games from CSV file"""
|
||||
if not self.csv_path.exists():
|
||||
print(f"Error: CSV file not found: {self.csv_path}")
|
||||
sys.exit(1)
|
||||
|
||||
games = []
|
||||
try:
|
||||
df = pd.read_csv(self.csv_path)
|
||||
|
||||
# Find the game name column (could be 'Game', 'Title', etc.)
|
||||
game_column = None
|
||||
for col in df.columns:
|
||||
if col.lower() in ['game', 'title', 'name']:
|
||||
game_column = col
|
||||
break
|
||||
|
||||
if not game_column:
|
||||
# Use first column as fallback
|
||||
game_column = df.columns[0]
|
||||
print(f"Warning: Using first column '{game_column}' as game name column")
|
||||
|
||||
# Extract unique games (remove duplicates and empty entries)
|
||||
for _, row in df.iterrows():
|
||||
game_name = row[game_column]
|
||||
if pd.notna(game_name) and game_name.strip():
|
||||
# Remove numbering prefix if present (e.g., "1.Game" -> "Game")
|
||||
game_name = re.sub(r'^\d+\.', '', str(game_name)).strip()
|
||||
if game_name and game_name not in [g['name'] for g in games]:
|
||||
games.append({'name': game_name})
|
||||
|
||||
print(f"Loaded {len(games)} unique games from {self.csv_path}")
|
||||
return sorted(games, key=lambda x: x['name'])
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error reading CSV file: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def sanitize_filename(self, name):
|
||||
"""Create a safe filename from game name"""
|
||||
# Remove special characters, replace spaces with hyphens
|
||||
sanitized = re.sub(r'[^\w\s-]', '', name)
|
||||
sanitized = re.sub(r'[-\s]+', '-', sanitized).strip('-').lower()
|
||||
return sanitized
|
||||
|
||||
def detect_engine(self, game_name):
|
||||
"""Detect game engine based on name patterns"""
|
||||
# Direct mapping first
|
||||
if game_name in self.engine_mapping:
|
||||
return self.engine_mapping[game_name]
|
||||
|
||||
# Pattern-based detection
|
||||
name_lower = game_name.lower()
|
||||
|
||||
if any(x in name_lower for x in ['source', 'half-life', 'counter-strike']):
|
||||
return 'Source Engine'
|
||||
elif any(x in name_lower for x in ['unreal', 'squad', 'insurgency']):
|
||||
return 'Unreal Engine 4'
|
||||
elif any(x in name_lower for x in ['unity', 'rust', 'cities']):
|
||||
return 'Unity'
|
||||
elif 'arma' in name_lower:
|
||||
return 'Real Virtuality 4'
|
||||
elif any(x in name_lower for x in ['minecraft', 'java']):
|
||||
return 'Java'
|
||||
elif any(x in name_lower for x in ['quake', 'doom', 'id tech']):
|
||||
return 'id Tech'
|
||||
else:
|
||||
return 'Custom/Unknown'
|
||||
|
||||
def get_app_id(self, game_name):
|
||||
"""Get Steam App ID for the game"""
|
||||
return self.appid_mapping.get(game_name, 'N/A')
|
||||
|
||||
def supports_workshop(self, game_name):
|
||||
"""Determine if game supports Steam Workshop"""
|
||||
# Games known to support Workshop
|
||||
workshop_games = [
|
||||
'Counter-Strike 2', 'Counter-Strike: Global Offensive', 'Team Fortress 2',
|
||||
'Garry\'s Mod', 'ARK: Survival Evolved', 'Rust', 'Squad',
|
||||
'DayZ', 'ARMA 3', 'Killing Floor 2', 'Insurgency: Sandstorm',
|
||||
'Cities: Skylines', 'Civilization VI', 'Europa Universalis IV',
|
||||
'Stellaris', 'Hearts of Iron IV', 'Prison Architect'
|
||||
]
|
||||
|
||||
return game_name in workshop_games
|
||||
|
||||
def generate_port_table(self, game_name, engine):
|
||||
"""Generate port mapping table based on game type"""
|
||||
ports = []
|
||||
|
||||
# Common patterns based on engine/game type
|
||||
if 'Source' in engine:
|
||||
ports = [
|
||||
{'feature': 'Game Port', 'port': '27015', 'protocol': 'UDP', 'relation': 'Primary', 'notes': 'Main game traffic'},
|
||||
{'feature': 'RCON', 'port': '27015', 'protocol': 'TCP', 'relation': 'Same as game', 'notes': 'Remote administration'},
|
||||
{'feature': 'SourceTV', 'port': '27020', 'protocol': 'UDP', 'relation': 'Game + 5', 'notes': 'Spectator broadcasting'},
|
||||
{'feature': 'Steam Query', 'port': '27016', 'protocol': 'UDP', 'relation': 'Game + 1', 'notes': 'Server browser queries'},
|
||||
{'feature': 'Steam Master', 'port': '27011', 'protocol': 'UDP', 'relation': 'Fixed', 'notes': 'Steam master server communication'}
|
||||
]
|
||||
elif 'Unity' in engine:
|
||||
ports = [
|
||||
{'feature': 'Game Port', 'port': '7777', 'protocol': 'UDP', 'relation': 'Primary', 'notes': 'Main game traffic'},
|
||||
{'feature': 'Query Port', 'port': '7778', 'protocol': 'UDP', 'relation': 'Game + 1', 'notes': 'Server status queries'},
|
||||
{'feature': 'Steam Port', 'port': '8766', 'protocol': 'UDP', 'relation': 'Fixed', 'notes': 'Steam integration'},
|
||||
{'feature': 'RCON', 'port': '8080', 'protocol': 'TCP', 'relation': 'Configurable', 'notes': 'Web-based admin interface'}
|
||||
]
|
||||
elif 'Unreal' in engine:
|
||||
ports = [
|
||||
{'feature': 'Game Port', 'port': '7777', 'protocol': 'UDP', 'relation': 'Primary', 'notes': 'Main game traffic'},
|
||||
{'feature': 'Query Port', 'port': '27015', 'protocol': 'UDP', 'relation': 'Configurable', 'notes': 'Server browser queries'},
|
||||
{'feature': 'RCON', 'port': '27020', 'protocol': 'TCP', 'relation': 'Configurable', 'notes': 'Remote console access'},
|
||||
{'feature': 'Beacon Port', 'port': '15000', 'protocol': 'UDP', 'relation': 'Fixed', 'notes': 'LAN discovery'}
|
||||
]
|
||||
else:
|
||||
# Generic ports for unknown engines
|
||||
ports = [
|
||||
{'feature': 'Game Port', 'port': '25565', 'protocol': 'UDP', 'relation': 'Primary', 'notes': 'Main game traffic'},
|
||||
{'feature': 'Query Port', 'port': '25566', 'protocol': 'UDP', 'relation': 'Game + 1', 'notes': 'Server status queries'},
|
||||
{'feature': 'RCON', 'port': '25575', 'protocol': 'TCP', 'relation': 'Game + 10', 'notes': 'Remote administration'}
|
||||
]
|
||||
|
||||
# Format as table
|
||||
table_rows = []
|
||||
for port in ports:
|
||||
table_rows.append(f"| {port['feature']} | {port['port']} | {port['protocol']} | {port['relation']} | {port['notes']} |")
|
||||
|
||||
return '\n'.join(table_rows)
|
||||
|
||||
def generate_startup_flags(self, game_name, engine):
|
||||
"""Generate exhaustive startup parameters based on game engine"""
|
||||
flags = []
|
||||
|
||||
if 'Source' in engine:
|
||||
flags = [
|
||||
('+map', '', 'string', 'Starting map name', '+map de_dust2'),
|
||||
('+maxplayers', '16', 'integer 1-64', 'Maximum player count', '+maxplayers 32'),
|
||||
('+sv_password', '', 'string', 'Server password for private games', '+sv_password mypass123'),
|
||||
('+hostname', '', 'string', 'Server name in browser', '+hostname "My Server"'),
|
||||
('+rcon_password', '', 'string', 'RCON administration password', '+rcon_password adminpass'),
|
||||
('+sv_setsteamaccount', '', 'string', 'Game Server Login Token', '+sv_setsteamaccount TOKEN'),
|
||||
('-port', '27015', 'integer 1024-65535', 'Primary server port', '-port 27015'),
|
||||
('-tickrate', '64', 'integer 33-128', 'Server update frequency', '-tickrate 128'),
|
||||
('-threads', '0', 'integer 0-8', 'Thread count (0=auto)', '-threads 4'),
|
||||
('+fps_max', '300', 'integer 33-1000', 'Maximum server FPS', '+fps_max 500'),
|
||||
('-nohltv', '', 'flag', 'Disable SourceTV', '-nohltv'),
|
||||
('-insecure', '', 'flag', 'Disable VAC (NOT recommended)', '-insecure'),
|
||||
('-console', '', 'flag', 'Enable console output', '-console'),
|
||||
('-condebug', '', 'flag', 'Log console to file', '-condebug'),
|
||||
('+log', 'on', 'on/off', 'Enable server logging', '+log on'),
|
||||
('+sv_lan', '0', 'integer 0-1', 'LAN only mode', '+sv_lan 0'),
|
||||
('+sv_cheats', '0', 'integer 0-1', 'Enable cheat commands', '+sv_cheats 0'),
|
||||
('+mp_autoteambalance', '1', 'integer 0-1', 'Auto-balance teams', '+mp_autoteambalance 1'),
|
||||
('+mp_limitteams', '2', 'integer 0-30', 'Team size difference limit', '+mp_limitteams 1'),
|
||||
('+sv_pure', '1', 'integer 0-2', 'File consistency checking', '+sv_pure 1')
|
||||
]
|
||||
elif 'Unity' in engine or 'Rust' in game_name:
|
||||
flags = [
|
||||
('+server.port', '28015', 'integer 1024-65535', 'Server port', '+server.port 28015'),
|
||||
('+server.maxplayers', '100', 'integer 1-300', 'Maximum players', '+server.maxplayers 200'),
|
||||
('+server.hostname', '', 'string', 'Server name', '+server.hostname "My Server"'),
|
||||
('+server.description', '', 'string', 'Server description', '+server.description "Welcome!"'),
|
||||
('+server.password', '', 'string', 'Server password', '+server.password secretpass'),
|
||||
('+server.worldsize', '4000', 'integer 1000-8000', 'World map size', '+server.worldsize 3000'),
|
||||
('+server.seed', '0', 'integer', 'World generation seed', '+server.seed 12345'),
|
||||
('+server.saveinterval', '300', 'integer 60-3600', 'Auto-save interval (seconds)', '+server.saveinterval 600'),
|
||||
('+rcon.port', '28016', 'integer 1024-65535', 'RCON port', '+rcon.port 28016'),
|
||||
('+rcon.password', '', 'string', 'RCON password', '+rcon.password rconpass'),
|
||||
('+rcon.web', '1', 'integer 0-1', 'Enable web RCON', '+rcon.web 1'),
|
||||
('+server.encryption', '2', 'integer 0-2', 'Anti-cheat level', '+server.encryption 2'),
|
||||
('+fps.limit', '256', 'integer 60-1000', 'Server FPS limit', '+fps.limit 120'),
|
||||
('+gc.buffer', '256', 'integer 64-2048', 'Garbage collection buffer (MB)', '+gc.buffer 512'),
|
||||
('+server.pve', 'false', 'true/false', 'Player vs Environment mode', '+server.pve false'),
|
||||
('+decay.scale', '1.0', 'float 0.0-10.0', 'Building decay multiplier', '+decay.scale 0.5'),
|
||||
('+craft.instant', 'false', 'true/false', 'Instant crafting', '+craft.instant false'),
|
||||
('+gather.rate', '1.0', 'float 0.1-50.0', 'Resource gather multiplier', '+gather.rate 2.0'),
|
||||
('+server.tickrate', '30', 'integer 10-60', 'Server tick rate', '+server.tickrate 30'),
|
||||
('+server.stability', 'true', 'true/false', 'Building stability checks', '+server.stability true')
|
||||
]
|
||||
elif 'Unreal' in engine:
|
||||
flags = [
|
||||
('Port', '7777', 'integer 1024-65535', 'Server port', 'Port=7777'),
|
||||
('QueryPort', '27015', 'integer 1024-65535', 'Query port', 'QueryPort=27015'),
|
||||
('MaxPlayers', '80', 'integer 1-100', 'Maximum players', 'MaxPlayers=50'),
|
||||
('ServerName', '', 'string', 'Server name in browser', 'ServerName="My Server"'),
|
||||
('ServerPassword', '', 'string', 'Password for private server', 'ServerPassword=pass123'),
|
||||
('AdminPassword', '', 'string', 'Admin console password', 'AdminPassword=adminpass'),
|
||||
('Random', 'NONE', 'NONE/FULL/LIMITED', 'Random map selection', 'Random=LIMITED'),
|
||||
('NumReservedSlots', '0', 'integer 0-20', 'Reserved admin slots', 'NumReservedSlots=2'),
|
||||
('ShouldAdvertise', 'true', 'true/false', 'Advertise on server browser', 'ShouldAdvertise=true'),
|
||||
('IsLANMatch', 'false', 'true/false', 'LAN only mode', 'IsLANMatch=false'),
|
||||
('AllowCheats', 'false', 'true/false', 'Enable cheat commands', 'AllowCheats=false'),
|
||||
('RecordDemos', 'false', 'true/false', 'Record demo files', 'RecordDemos=true'),
|
||||
('PublicQueue', 'true', 'true/false', 'Use public matchmaking', 'PublicQueue=true'),
|
||||
('AllowTeamChange', 'true', 'true/false', 'Allow players to switch teams', 'AllowTeamChange=true'),
|
||||
('TKAutoKick', 'true', 'true/false', 'Auto-kick team killers', 'TKAutoKick=true'),
|
||||
('PreventTeamKill', 'true', 'true/false', 'Prevent team killing', 'PreventTeamKill=false'),
|
||||
('UnlimitedAmmo', 'false', 'true/false', 'Infinite ammunition', 'UnlimitedAmmo=false'),
|
||||
('MapRotation', '', 'string', 'Map rotation list', 'MapRotation=Map1,Map2,Map3'),
|
||||
('RoundTime', '300', 'integer 60-3600', 'Round duration (seconds)', 'RoundTime=600'),
|
||||
('LogToFile', 'true', 'true/false', 'Enable file logging', 'LogToFile=true')
|
||||
]
|
||||
else:
|
||||
# Generic flags for other engines
|
||||
flags = [
|
||||
('--port', '25565', 'integer 1024-65535', 'Server port', '--port 25565'),
|
||||
('--max-players', '20', 'integer 1-1000', 'Maximum players', '--max-players 50'),
|
||||
('--server-name', '', 'string', 'Server name', '--server-name "My Server"'),
|
||||
('--password', '', 'string', 'Server password', '--password mypass'),
|
||||
('--admin-password', '', 'string', 'Admin password', '--admin-password adminpass'),
|
||||
('--world-size', 'medium', 'small/medium/large', 'World generation size', '--world-size large'),
|
||||
('--difficulty', 'normal', 'easy/normal/hard', 'Game difficulty', '--difficulty hard'),
|
||||
('--pvp', 'true', 'true/false', 'Player vs Player enabled', '--pvp false'),
|
||||
('--auto-save', '300', 'integer 60-3600', 'Auto-save interval', '--auto-save 600'),
|
||||
('--log-level', 'info', 'debug/info/warn/error', 'Logging verbosity', '--log-level debug'),
|
||||
('--console', '', 'flag', 'Enable console interface', '--console'),
|
||||
('--dedicated', '', 'flag', 'Run as dedicated server', '--dedicated'),
|
||||
('--no-pause', '', 'flag', 'Disable pause when unfocused', '--no-pause'),
|
||||
('--headless', '', 'flag', 'Run without graphics', '--headless'),
|
||||
('--threads', '0', 'integer 0-16', 'Worker thread count', '--threads 4')
|
||||
]
|
||||
|
||||
# Format as table
|
||||
table_rows = []
|
||||
for flag_data in flags:
|
||||
flag, default, type_info, desc, example = flag_data
|
||||
table_rows.append(f"| {flag} | {default} | {type_info} | {desc} | {example} |")
|
||||
|
||||
return '\n'.join(table_rows)
|
||||
|
||||
def generate_config_files_section(self, game_name, engine):
|
||||
"""Generate configuration files section"""
|
||||
configs = []
|
||||
|
||||
if 'Source' in engine:
|
||||
configs = [
|
||||
"### Core Configuration Files",
|
||||
"",
|
||||
"**Windows Paths:**",
|
||||
"- `server.cfg` - Main server configuration",
|
||||
"- `autoexec.cfg` - Auto-executed commands on startup",
|
||||
"- `banned_user.cfg` - Banned Steam IDs",
|
||||
"- `banned_ip.cfg` - Banned IP addresses",
|
||||
"- `listip.cfg` - Reserved slot IP addresses",
|
||||
"- `mapcycle.txt` - Map rotation list",
|
||||
"- `motd.txt` - Message of the day",
|
||||
"- `admins_simple.ini` - Simple admin system (SourceMod)",
|
||||
"",
|
||||
"**Linux Paths:**",
|
||||
"- `/opt/steamcmd/servers/{}/cfg/server.cfg`",
|
||||
"- `/opt/steamcmd/servers/{}/cfg/autoexec.cfg`",
|
||||
"- `/opt/steamcmd/servers/{}/addons/sourcemod/configs/`",
|
||||
""
|
||||
]
|
||||
elif 'Unity' in engine or 'Rust' in game_name:
|
||||
configs = [
|
||||
"### Core Configuration Files",
|
||||
"",
|
||||
"**Windows Paths:**",
|
||||
"- `server.cfg` - Main server settings",
|
||||
"- `users.cfg` - User permissions and data",
|
||||
"- `bans.cfg` - Banned players list",
|
||||
"- `oxide/config/` - Plugin configurations",
|
||||
"- `oxide/data/` - Plugin data storage",
|
||||
"- `oxide/logs/` - Server and plugin logs",
|
||||
"",
|
||||
"**Linux Paths:**",
|
||||
"- `~/.steam/steamapps/common/rust_dedicated/server.cfg`",
|
||||
"- `~/.steam/steamapps/common/rust_dedicated/oxide/`",
|
||||
""
|
||||
]
|
||||
else:
|
||||
configs = [
|
||||
"### Configuration Files",
|
||||
"",
|
||||
"**Windows Paths:**",
|
||||
"- `server.properties` - Main server configuration",
|
||||
"- `whitelist.json` - Allowed players list",
|
||||
"- `ops.json` - Server operators list",
|
||||
"- `banned-players.json` - Banned players",
|
||||
"- `banned-ips.json` - Banned IP addresses",
|
||||
"- `server-icon.png` - Server icon (64x64)",
|
||||
"",
|
||||
"**Linux Paths:**",
|
||||
"- `/opt/gameserver/server.properties`",
|
||||
"- `/opt/gameserver/world/` - World save files",
|
||||
""
|
||||
]
|
||||
|
||||
return '\n'.join(configs)
|
||||
|
||||
def generate_workshop_section(self, game_name, supports_workshop):
|
||||
"""Generate Steam Workshop integration section"""
|
||||
if not supports_workshop:
|
||||
return "This game does not support Steam Workshop integration."
|
||||
|
||||
return """### Steam Workshop Setup
|
||||
|
||||
**Authentication Requirements:**
|
||||
- Valid Steam account with game ownership
|
||||
- Game Server Login Token (GSLT) from Steam
|
||||
- Proper Steam API authentication
|
||||
|
||||
**Workshop Collection Management:**
|
||||
1. Create Workshop collection in Steam client
|
||||
2. Note the collection ID from the URL
|
||||
3. Configure server to subscribe to collection
|
||||
4. Set up automatic updates for Workshop content
|
||||
|
||||
**File Locations:**
|
||||
|
||||
**Windows:**
|
||||
- Workshop cache: `%USERPROFILE%/.steam/steamapps/workshop/content/<appid>/`
|
||||
- Server workshop: `steamapps/workshop/content/<appid>/`
|
||||
|
||||
**Linux:**
|
||||
- Workshop cache: `~/.steam/steamapps/workshop/content/<appid>/`
|
||||
- Server workshop: `steamapps/workshop/content/<appid>/`
|
||||
|
||||
**Common Workshop Commands:**
|
||||
```bash
|
||||
# Subscribe to workshop item
|
||||
workshop.download <item_id>
|
||||
|
||||
# Update all subscribed items
|
||||
workshop.update
|
||||
|
||||
# List subscribed items
|
||||
workshop.list
|
||||
|
||||
# Unsubscribe from item
|
||||
workshop.unsubscribe <item_id>
|
||||
```
|
||||
|
||||
**Troubleshooting Workshop Issues:**
|
||||
- Verify GSLT is valid and not expired
|
||||
- Check Steam API connectivity
|
||||
- Clear workshop cache if downloads fail
|
||||
- Ensure sufficient disk space for content
|
||||
- Monitor workshop update logs for errors"""
|
||||
|
||||
def generate_sample_configs(self, game_name, engine):
|
||||
"""Generate sample configuration files"""
|
||||
if 'Source' in engine:
|
||||
return """### server.cfg Example
|
||||
```
|
||||
// Server identification
|
||||
hostname "My Self-Hosted Server"
|
||||
sv_password ""
|
||||
rcon_password "secure_rcon_password_here"
|
||||
|
||||
// Game settings
|
||||
mp_maxrounds 30
|
||||
mp_timelimit 0
|
||||
mp_roundtime 115
|
||||
mp_buytime 15
|
||||
mp_startmoney 800
|
||||
|
||||
// Server behavior
|
||||
sv_cheats 0
|
||||
sv_pure 1
|
||||
sv_lan 0
|
||||
sv_region 255
|
||||
|
||||
// Network optimization
|
||||
sv_maxcmdrate 128
|
||||
sv_maxupdaterate 128
|
||||
sv_minrate 20000
|
||||
sv_maxrate 100000
|
||||
|
||||
// Logging
|
||||
log on
|
||||
sv_logbans 1
|
||||
sv_logecho 1
|
||||
sv_logfile 1
|
||||
sv_log_onefile 0
|
||||
```"""
|
||||
else:
|
||||
return """### server.properties Example
|
||||
```
|
||||
# Basic server settings
|
||||
server-name=My Self-Hosted Server
|
||||
server-port=25565
|
||||
max-players=20
|
||||
gamemode=survival
|
||||
difficulty=normal
|
||||
|
||||
# World settings
|
||||
level-name=world
|
||||
level-seed=
|
||||
level-type=default
|
||||
spawn-protection=16
|
||||
|
||||
# Network settings
|
||||
enable-query=true
|
||||
query.port=25565
|
||||
enable-rcon=true
|
||||
rcon.port=25575
|
||||
rcon.password=secure_password_here
|
||||
|
||||
# Performance
|
||||
view-distance=10
|
||||
simulation-distance=10
|
||||
max-tick-time=60000
|
||||
```"""
|
||||
|
||||
def generate_game_guide(self, game_name):
|
||||
"""Generate complete guide for a single game"""
|
||||
template = self.load_template()
|
||||
|
||||
# Gather game information
|
||||
engine = self.detect_engine(game_name)
|
||||
app_id = self.get_app_id(game_name)
|
||||
workshop_support = self.supports_workshop(game_name)
|
||||
|
||||
# Generate content sections
|
||||
port_table = self.generate_port_table(game_name, engine)
|
||||
startup_flags_table = self.generate_startup_flags(game_name, engine)
|
||||
config_files_section = self.generate_config_files_section(game_name, engine)
|
||||
workshop_section = self.generate_workshop_section(game_name, workshop_support)
|
||||
sample_configs = self.generate_sample_configs(game_name, engine)
|
||||
|
||||
# Template replacements
|
||||
replacements = {
|
||||
'{game_name}': game_name,
|
||||
'{engine_type}': engine,
|
||||
'{workshop_support_text}': 'supports' if workshop_support else 'does not support',
|
||||
'{port_table}': port_table,
|
||||
'{startup_flags_table}': startup_flags_table,
|
||||
'{config_files_section}': config_files_section,
|
||||
'{workshop_section}': workshop_section,
|
||||
'{sample_configs}': sample_configs,
|
||||
'{windows_command}': f'server.exe +map de_dust2 +maxplayers 16',
|
||||
'{linux_command}': f'./server +map de_dust2 +maxplayers 16',
|
||||
'{basic_command}': f'./server --dedicated --console',
|
||||
'{production_command}': f'./server --dedicated --console --max-players 50 --auto-save 300',
|
||||
'{performance_command}': f'./server --dedicated --headless --threads 4 --max-players 100',
|
||||
'{windows_batch_example}': f'server.exe --dedicated --console\npause',
|
||||
'{linux_service_example}': f'/opt/gameserver/server --dedicated --console'
|
||||
}
|
||||
|
||||
# Apply replacements
|
||||
content = template
|
||||
for placeholder, replacement in replacements.items():
|
||||
content = content.replace(placeholder, replacement)
|
||||
|
||||
return content
|
||||
|
||||
def convert_to_pdf(self, markdown_file, pdf_file):
|
||||
"""Convert Markdown to PDF using Pandoc"""
|
||||
try:
|
||||
cmd = [
|
||||
'pandoc',
|
||||
str(markdown_file),
|
||||
'-o', str(pdf_file),
|
||||
'--from=gfm',
|
||||
'--toc',
|
||||
'--toc-depth=3',
|
||||
'--number-sections',
|
||||
'--pdf-engine=wkhtmltopdf',
|
||||
f'--metadata=title:{markdown_file.stem.replace("-", " ").title()} Server Guide',
|
||||
'--metadata=author:Self-Hosting Guide',
|
||||
f'--metadata=date:{datetime.now().strftime("%Y-%m-%d")}'
|
||||
]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
|
||||
if result.returncode == 0:
|
||||
print(f"✓ PDF generated: {pdf_file.name}")
|
||||
return True
|
||||
else:
|
||||
print(f"✗ PDF generation failed for {markdown_file.name}")
|
||||
print(f" Error: {result.stderr}")
|
||||
return False
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f"✗ PDF generation timed out for {markdown_file.name}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"✗ PDF generation error for {markdown_file.name}: {e}")
|
||||
return False
|
||||
|
||||
def validate_content(self, content, game_name):
|
||||
"""Validate content for forbidden terms and requirements"""
|
||||
issues = []
|
||||
|
||||
# Check for forbidden provider mentions
|
||||
content_lower = content.lower()
|
||||
for term in self.forbidden_terms:
|
||||
if term.lower() in content_lower:
|
||||
issues.append(f"Contains forbidden term: {term}")
|
||||
|
||||
# Check for HTML entity escapes
|
||||
if '<' in content or '>' in content:
|
||||
issues.append("Contains HTML entity escapes (<, >)")
|
||||
|
||||
# Check for required sections
|
||||
required_sections = [
|
||||
'## Overview', '## System Requirements', '## Ports & Networking',
|
||||
'## Startup Parameters', '## Configuration Files', '## Troubleshooting'
|
||||
]
|
||||
|
||||
for section in required_sections:
|
||||
if section not in content:
|
||||
issues.append(f"Missing required section: {section}")
|
||||
|
||||
if issues:
|
||||
print(f"⚠️ Validation issues for {game_name}:")
|
||||
for issue in issues:
|
||||
print(f" - {issue}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def generate_all_guides(self):
|
||||
"""Generate guides for all games in CSV"""
|
||||
games = self.read_csv_games()
|
||||
|
||||
if not games:
|
||||
print("No games found in CSV file")
|
||||
return False
|
||||
|
||||
print(f"\nGenerating guides for {len(games)} games...")
|
||||
print("=" * 50)
|
||||
|
||||
generated_md = 0
|
||||
generated_pdf = 0
|
||||
failed_games = []
|
||||
|
||||
for game in games:
|
||||
game_name = game['name']
|
||||
sanitized_name = self.sanitize_filename(game_name)
|
||||
|
||||
print(f"\nProcessing: {game_name}")
|
||||
|
||||
try:
|
||||
# Generate Markdown content
|
||||
content = self.generate_game_guide(game_name)
|
||||
|
||||
# Validate content
|
||||
if not self.validate_content(content, game_name):
|
||||
failed_games.append(f"{game_name} (validation failed)")
|
||||
continue
|
||||
|
||||
# Write Markdown file
|
||||
md_file = self.output_md / f"{sanitized_name}.md"
|
||||
with open(md_file, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
print(f" ✓ Markdown: {md_file.name}")
|
||||
generated_md += 1
|
||||
|
||||
# Generate PDF
|
||||
pdf_file = self.output_pdf / f"{sanitized_name}.pdf"
|
||||
if self.convert_to_pdf(md_file, pdf_file):
|
||||
generated_pdf += 1
|
||||
else:
|
||||
failed_games.append(f"{game_name} (PDF generation failed)")
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ Error processing {game_name}: {e}")
|
||||
failed_games.append(f"{game_name} (processing error)")
|
||||
|
||||
# Generate summary
|
||||
print("\n" + "=" * 50)
|
||||
print("GENERATION SUMMARY")
|
||||
print("=" * 50)
|
||||
print(f"Games processed: {len(games)}")
|
||||
print(f"Markdown files generated: {generated_md}")
|
||||
print(f"PDF files generated: {generated_pdf}")
|
||||
print(f"Failed games: {len(failed_games)}")
|
||||
|
||||
if failed_games:
|
||||
print(f"\nFailed games:")
|
||||
for failed in failed_games:
|
||||
print(f" - {failed}")
|
||||
|
||||
# Save generation manifest
|
||||
manifest = {
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"total_games": len(games),
|
||||
"markdown_generated": generated_md,
|
||||
"pdfs_generated": generated_pdf,
|
||||
"failed_games": failed_games,
|
||||
"games": [{"name": g["name"], "sanitized": self.sanitize_filename(g["name"])} for g in games]
|
||||
}
|
||||
|
||||
manifest_file = self.output_pdf / "manifest.json"
|
||||
with open(manifest_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
print(f"\nManifest saved: {manifest_file}")
|
||||
|
||||
return len(failed_games) == 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
generator = GameGuideGenerator()
|
||||
success = generator.generate_all_guides()
|
||||
sys.exit(0 if success else 1)
|
||||
39
scripts/clean.py
Normal file
39
scripts/clean.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Clean Output Directories
|
||||
|
||||
Removes all generated files from out/md and out/pdfs directories before a full rebuild.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
def clean_output_directories():
|
||||
"""Remove all files from output directories"""
|
||||
base_dir = Path(__file__).parent.parent
|
||||
|
||||
# Define directories to clean
|
||||
directories_to_clean = [
|
||||
base_dir / "out" / "md",
|
||||
base_dir / "out" / "pdfs"
|
||||
]
|
||||
|
||||
for directory in directories_to_clean:
|
||||
if directory.exists():
|
||||
print(f"Cleaning {directory}...")
|
||||
# Remove all files and subdirectories
|
||||
for item in directory.iterdir():
|
||||
if item.is_file():
|
||||
item.unlink()
|
||||
print(f" Removed file: {item.name}")
|
||||
elif item.is_dir():
|
||||
shutil.rmtree(item)
|
||||
print(f" Removed directory: {item.name}")
|
||||
else:
|
||||
print(f"Directory does not exist: {directory}")
|
||||
|
||||
print("Clean completed.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
clean_output_directories()
|
||||
277
scripts/quality_control.py
Normal file
277
scripts/quality_control.py
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Quality Control and Linting for Generated Game Guides
|
||||
|
||||
Validates that generated guides meet all requirements:
|
||||
- No forbidden provider mentions
|
||||
- No HTML entity escaping
|
||||
- All required sections present
|
||||
- Sufficient startup parameters
|
||||
- Proper <PLACEHOLDER> handling
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
class GuideQualityControl:
|
||||
def __init__(self, md_dir="out/md", pdf_dir="out/pdfs"):
|
||||
self.md_dir = Path(md_dir)
|
||||
self.pdf_dir = Path(pdf_dir)
|
||||
|
||||
# Forbidden terms that should not appear in output
|
||||
self.forbidden_terms = [
|
||||
'OGP', 'OpenGamePanel', 'LinuxGSM', 'Nitrado',
|
||||
'GameServers.com', 'G-Portal', 'gameservers.com',
|
||||
'control panel', 'panel setup', 'OGP module'
|
||||
]
|
||||
|
||||
# Required sections that must be present
|
||||
self.required_sections = [
|
||||
'## Overview',
|
||||
'## System Requirements',
|
||||
'## Ports & Networking',
|
||||
'## Startup Parameters',
|
||||
'## Configuration Files',
|
||||
'## Steam Workshop',
|
||||
'## Admin & RCON',
|
||||
'## Saves, Backups & Wipes',
|
||||
'## Performance Tuning',
|
||||
'## Troubleshooting',
|
||||
'## Appendices'
|
||||
]
|
||||
|
||||
self.errors = []
|
||||
self.warnings = []
|
||||
|
||||
def check_forbidden_terms(self, content, file_name):
|
||||
"""Check for forbidden provider mentions"""
|
||||
content_lower = content.lower()
|
||||
found_terms = []
|
||||
|
||||
for term in self.forbidden_terms:
|
||||
if term.lower() in content_lower:
|
||||
# Count occurrences for severity assessment
|
||||
count = content_lower.count(term.lower())
|
||||
found_terms.append(f"{term} ({count}x)")
|
||||
|
||||
if found_terms:
|
||||
self.errors.append(f"{file_name}: Contains forbidden terms: {', '.join(found_terms)}")
|
||||
|
||||
def check_html_entities(self, content, file_name):
|
||||
"""Check for HTML entity escapes"""
|
||||
html_entities = ['<', '>', '&', '"', '&#']
|
||||
found_entities = []
|
||||
|
||||
for entity in html_entities:
|
||||
if entity in content:
|
||||
count = content.count(entity)
|
||||
found_entities.append(f"{entity} ({count}x)")
|
||||
|
||||
if found_entities:
|
||||
self.errors.append(f"{file_name}: Contains HTML entities: {', '.join(found_entities)}")
|
||||
|
||||
def check_placeholder_handling(self, content, file_name):
|
||||
"""Verify <PLACEHOLDER> text is literal, not escaped"""
|
||||
# Check for properly formatted placeholders
|
||||
placeholder_pattern = r'<[A-Z_]+>'
|
||||
placeholders = re.findall(placeholder_pattern, content)
|
||||
|
||||
# Check for escaped placeholders that shouldn't be
|
||||
escaped_pattern = r'<[A-Z_]+>'
|
||||
escaped_placeholders = re.findall(escaped_pattern, content)
|
||||
|
||||
if escaped_placeholders:
|
||||
self.errors.append(f"{file_name}: Contains escaped placeholders: {', '.join(escaped_placeholders)}")
|
||||
|
||||
# Log found placeholders for information
|
||||
if placeholders:
|
||||
self.warnings.append(f"{file_name}: Contains {len(placeholders)} placeholders: {', '.join(placeholders[:3])}{'...' if len(placeholders) > 3 else ''}")
|
||||
|
||||
def check_required_sections(self, content, file_name):
|
||||
"""Verify all required sections are present"""
|
||||
missing_sections = []
|
||||
|
||||
for section in self.required_sections:
|
||||
if section not in content:
|
||||
missing_sections.append(section)
|
||||
|
||||
if missing_sections:
|
||||
self.errors.append(f"{file_name}: Missing required sections: {', '.join(missing_sections)}")
|
||||
|
||||
def check_startup_parameters(self, content, file_name):
|
||||
"""Verify sufficient startup parameters are documented"""
|
||||
# Look for startup parameters table
|
||||
param_pattern = r'\| [+\-][a-zA-Z0-9_.]+'
|
||||
parameters = re.findall(param_pattern, content)
|
||||
|
||||
param_count = len(parameters)
|
||||
if param_count < 10:
|
||||
self.warnings.append(f"{file_name}: Only {param_count} startup parameters found (minimum 10 recommended)")
|
||||
elif param_count < 15:
|
||||
self.warnings.append(f"{file_name}: {param_count} startup parameters found (15+ recommended for exhaustive coverage)")
|
||||
|
||||
def check_port_mappings(self, content, file_name):
|
||||
"""Verify port mapping table is comprehensive"""
|
||||
# Look for port table entries
|
||||
port_pattern = r'\| [A-Za-z ]+ \| \d+ \| [A-Z]+ \|'
|
||||
port_entries = re.findall(port_pattern, content)
|
||||
|
||||
port_count = len(port_entries)
|
||||
if port_count < 3:
|
||||
self.warnings.append(f"{file_name}: Only {port_count} port mappings found (3+ recommended)")
|
||||
|
||||
def check_troubleshooting_depth(self, content, file_name):
|
||||
"""Verify troubleshooting section is comprehensive"""
|
||||
# Look for troubleshooting subsections
|
||||
troubleshooting_match = re.search(r'## Troubleshooting.*?(?=## |\Z)', content, re.DOTALL)
|
||||
|
||||
if troubleshooting_match:
|
||||
troubleshooting_content = troubleshooting_match.group(0)
|
||||
|
||||
# Count subsections (### headers)
|
||||
subsection_pattern = r'###'
|
||||
subsections = re.findall(subsection_pattern, troubleshooting_content)
|
||||
subsection_count = len(subsections)
|
||||
|
||||
if subsection_count < 5:
|
||||
self.warnings.append(f"{file_name}: Troubleshooting has only {subsection_count} subsections (5+ recommended for deep coverage)")
|
||||
|
||||
# Check for common troubleshooting topics
|
||||
required_topics = ['startup', 'connection', 'performance', 'save', 'network']
|
||||
content_lower = troubleshooting_content.lower()
|
||||
missing_topics = [topic for topic in required_topics if topic not in content_lower]
|
||||
|
||||
if missing_topics:
|
||||
self.warnings.append(f"{file_name}: Troubleshooting missing topics: {', '.join(missing_topics)}")
|
||||
else:
|
||||
self.errors.append(f"{file_name}: No troubleshooting section found")
|
||||
|
||||
def validate_markdown_file(self, md_file):
|
||||
"""Validate a single Markdown file"""
|
||||
try:
|
||||
with open(md_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
file_name = md_file.name
|
||||
|
||||
# Run all validation checks
|
||||
self.check_forbidden_terms(content, file_name)
|
||||
self.check_html_entities(content, file_name)
|
||||
self.check_placeholder_handling(content, file_name)
|
||||
self.check_required_sections(content, file_name)
|
||||
self.check_startup_parameters(content, file_name)
|
||||
self.check_port_mappings(content, file_name)
|
||||
self.check_troubleshooting_depth(content, file_name)
|
||||
|
||||
except Exception as e:
|
||||
self.errors.append(f"{md_file.name}: Error reading file: {e}")
|
||||
|
||||
def validate_pdf_file(self, pdf_file):
|
||||
"""Basic validation of PDF file"""
|
||||
try:
|
||||
size = pdf_file.stat().st_size
|
||||
|
||||
# Check file size is reasonable (not empty, not too large)
|
||||
if size < 1000: # Less than 1KB
|
||||
self.errors.append(f"{pdf_file.name}: PDF file too small ({size} bytes)")
|
||||
elif size > 50 * 1024 * 1024: # More than 50MB
|
||||
self.warnings.append(f"{pdf_file.name}: PDF file very large ({size // 1024 // 1024} MB)")
|
||||
else:
|
||||
# File size is reasonable
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
self.errors.append(f"{pdf_file.name}: Error checking PDF: {e}")
|
||||
|
||||
def validate_all_guides(self):
|
||||
"""Validate all generated guides"""
|
||||
print("=== Quality Control Validation ===\n")
|
||||
|
||||
if not self.md_dir.exists():
|
||||
self.errors.append(f"Markdown directory does not exist: {self.md_dir}")
|
||||
return False
|
||||
|
||||
if not self.pdf_dir.exists():
|
||||
self.errors.append(f"PDF directory does not exist: {self.pdf_dir}")
|
||||
return False
|
||||
|
||||
# Get all markdown files
|
||||
md_files = list(self.md_dir.glob("*.md"))
|
||||
pdf_files = list(self.pdf_dir.glob("*.pdf"))
|
||||
|
||||
if not md_files:
|
||||
self.errors.append("No Markdown files found for validation")
|
||||
return False
|
||||
|
||||
print(f"Validating {len(md_files)} Markdown files...")
|
||||
|
||||
# Validate each markdown file
|
||||
for md_file in md_files:
|
||||
print(f" Checking: {md_file.name}")
|
||||
self.validate_markdown_file(md_file)
|
||||
|
||||
# Check if corresponding PDF exists
|
||||
pdf_name = md_file.stem + ".pdf"
|
||||
pdf_file = self.pdf_dir / pdf_name
|
||||
|
||||
if pdf_file.exists():
|
||||
self.validate_pdf_file(pdf_file)
|
||||
else:
|
||||
self.errors.append(f"Missing PDF for {md_file.name}: {pdf_name}")
|
||||
|
||||
print(f"\nValidation completed.")
|
||||
return self.print_results()
|
||||
|
||||
def print_results(self):
|
||||
"""Print validation results"""
|
||||
print("\n" + "=" * 50)
|
||||
print("VALIDATION RESULTS")
|
||||
print("=" * 50)
|
||||
|
||||
total_issues = len(self.errors) + len(self.warnings)
|
||||
|
||||
if self.errors:
|
||||
print(f"\n❌ ERRORS ({len(self.errors)}):")
|
||||
for error in self.errors:
|
||||
print(f" • {error}")
|
||||
|
||||
if self.warnings:
|
||||
print(f"\n⚠️ WARNINGS ({len(self.warnings)}):")
|
||||
for warning in self.warnings:
|
||||
print(f" • {warning}")
|
||||
|
||||
if not self.errors and not self.warnings:
|
||||
print("✅ All validations passed! No issues found.")
|
||||
|
||||
print(f"\nSummary: {len(self.errors)} errors, {len(self.warnings)} warnings")
|
||||
|
||||
# Return True if no errors (warnings are acceptable)
|
||||
return len(self.errors) == 0
|
||||
|
||||
def generate_report(self):
|
||||
"""Generate a JSON report of validation results"""
|
||||
report = {
|
||||
"timestamp": Path().cwd().name,
|
||||
"total_errors": len(self.errors),
|
||||
"total_warnings": len(self.warnings),
|
||||
"errors": self.errors,
|
||||
"warnings": self.warnings,
|
||||
"validation_passed": len(self.errors) == 0
|
||||
}
|
||||
|
||||
report_file = self.pdf_dir / "quality_report.json"
|
||||
with open(report_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(report, f, indent=2)
|
||||
|
||||
print(f"Quality report saved: {report_file}")
|
||||
return report
|
||||
|
||||
if __name__ == "__main__":
|
||||
qc = GuideQualityControl()
|
||||
success = qc.validate_all_guides()
|
||||
qc.generate_report()
|
||||
|
||||
sys.exit(0 if success else 1)
|
||||
Loading…
Add table
Add a link
Reference in a new issue