292 lines
No EOL
12 KiB
Python
292 lines
No EOL
12 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
FAQ RSS Generator
|
|
Generates FAQ.RSS from YAML game documentation files.
|
|
Produces clean HTML-escaped content without CDATA, using <br> for line breaks.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import yaml
|
|
import html
|
|
import xml.etree.ElementTree as ET
|
|
from datetime import datetime
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
class FAQRSSGenerator:
|
|
def __init__(self, data_dir="data/games", output_file="FAQ.RSS"):
|
|
self.data_dir = Path(data_dir)
|
|
self.output_file = Path(output_file)
|
|
self.games = []
|
|
|
|
def load_games(self):
|
|
"""Load all YAML game files from data directory"""
|
|
self.games = []
|
|
if not self.data_dir.exists():
|
|
print(f"Error: Data directory {self.data_dir} does not exist")
|
|
return False
|
|
|
|
yaml_files = list(self.data_dir.glob("*.yml")) + list(self.data_dir.glob("*.yaml"))
|
|
if not yaml_files:
|
|
print(f"Warning: No YAML files found in {self.data_dir}")
|
|
return True
|
|
|
|
for yaml_file in yaml_files:
|
|
try:
|
|
with open(yaml_file, 'r', encoding='utf-8') as f:
|
|
game_data = yaml.safe_load(f)
|
|
if self.validate_game_data(game_data, yaml_file):
|
|
self.games.append(game_data)
|
|
print(f"Loaded: {game_data['name']}")
|
|
except Exception as e:
|
|
print(f"Error loading {yaml_file}: {e}")
|
|
|
|
return True
|
|
|
|
def validate_game_data(self, data, filename):
|
|
"""Validate game YAML data structure"""
|
|
required_fields = ['name', 'supports_workshop', 'startup', 'configs', 'troubleshooting']
|
|
|
|
for field in required_fields:
|
|
if field not in data:
|
|
print(f"Error in {filename}: Missing required field '{field}'")
|
|
return False
|
|
|
|
# Validate startup section
|
|
startup = data['startup']
|
|
if 'default_command' not in startup:
|
|
print(f"Error in {filename}: Missing 'default_command' in startup section")
|
|
return False
|
|
|
|
# Validate workshop section if supported
|
|
if data['supports_workshop'] and 'workshop' not in data:
|
|
print(f"Error in {filename}: supports_workshop is true but 'workshop' section missing")
|
|
return False
|
|
|
|
return True
|
|
|
|
def escape_html_content(self, text):
|
|
"""Escape HTML content and convert newlines to <br> tags"""
|
|
if not text:
|
|
return ""
|
|
# Escape HTML entities
|
|
escaped = html.escape(str(text))
|
|
# Convert newlines to <br> tags
|
|
escaped = escaped.replace('\n', '<br>')
|
|
return escaped
|
|
|
|
def generate_config_files_content(self, game):
|
|
"""Generate Config Files section content"""
|
|
content = "<strong>Configuration Files</strong><br>"
|
|
|
|
for config in game['configs']:
|
|
file_name = config['file']
|
|
paths = config.get('paths', [])
|
|
desc = config.get('desc', '')
|
|
|
|
content += "<br>- " + self.escape_html_content(file_name)
|
|
if paths:
|
|
path_str = ", ".join(paths)
|
|
content += " — " + self.escape_html_content(desc) + ". Paths: " + self.escape_html_content(path_str)
|
|
elif desc:
|
|
content += " — " + self.escape_html_content(desc)
|
|
content += "<br>"
|
|
|
|
return content
|
|
|
|
def generate_startup_parameters_content(self, game):
|
|
"""Generate Startup Parameters section content"""
|
|
startup = game['startup']
|
|
content = "<strong>Default Command Line</strong><br>"
|
|
content += "<br>" + self.escape_html_content(startup['default_command']) + "<br>"
|
|
|
|
# Port scheme
|
|
if 'ports' in startup and startup['ports']:
|
|
content += "<br><strong>Port Scheme</strong><br>"
|
|
for port in startup['ports']:
|
|
label = port.get('label', '')
|
|
port_num = port.get('port', '')
|
|
proto = port.get('proto', '')
|
|
relative = port.get('relative', '')
|
|
content += "<br>- " + self.escape_html_content(f"{label} ({proto}) — {relative} (default {port_num})") + "<br>"
|
|
|
|
# Command line flags
|
|
if 'flags' in startup and startup['flags']:
|
|
content += "<br><strong>Command Line Flags</strong><br>"
|
|
for flag in startup['flags']:
|
|
flag_name = flag.get('flag', '')
|
|
default = flag.get('default', '')
|
|
flag_type = flag.get('type', '')
|
|
desc = flag.get('desc', '')
|
|
content += "<br>" + self.escape_html_content(f"{flag_name} — {desc}. Type: {flag_type}, Default: {default}") + "<br>"
|
|
|
|
return content
|
|
|
|
def generate_troubleshooting_content(self, game):
|
|
"""Generate Troubleshooting section content"""
|
|
content = "<strong>Common Issues and Solutions</strong><br>"
|
|
|
|
for issue in game['troubleshooting']:
|
|
content += "<br>- " + self.escape_html_content(issue) + "<br>"
|
|
|
|
return content
|
|
|
|
def generate_workshop_content(self, game):
|
|
"""Generate Steam Workshop section content"""
|
|
if not game.get('supports_workshop', False):
|
|
return None
|
|
|
|
workshop = game.get('workshop', {})
|
|
content = "<strong>Steam Workshop Configuration</strong><br>"
|
|
|
|
notes = workshop.get('notes', [])
|
|
for note in notes:
|
|
content += "<br>- " + self.escape_html_content(note) + "<br>"
|
|
|
|
return content
|
|
|
|
def create_rss_item(self, title, category, content):
|
|
"""Create RSS item element"""
|
|
item = ET.Element('item')
|
|
|
|
title_elem = ET.SubElement(item, 'title')
|
|
title_elem.text = title
|
|
|
|
category_elem = ET.SubElement(item, 'category')
|
|
category_elem.text = category
|
|
|
|
# Handle namespaced element properly
|
|
content_elem = ET.SubElement(item, '{http://purl.org/rss/1.0/modules/content/}encoded')
|
|
content_elem.text = content
|
|
|
|
return item
|
|
|
|
def generate_rss(self):
|
|
"""Generate the complete RSS file"""
|
|
# Create backup if file exists
|
|
if self.output_file.exists():
|
|
backup_file = self.output_file.with_suffix('.bak')
|
|
shutil.copy2(self.output_file, backup_file)
|
|
print(f"Created backup: {backup_file}")
|
|
|
|
# Create RSS root
|
|
rss = ET.Element('rss', version='2.0')
|
|
rss.set('xmlns:content', 'http://purl.org/rss/1.0/modules/content/')
|
|
rss.set('xmlns:dc', 'http://purl.org/dc/elements/1.1/')
|
|
|
|
channel = ET.SubElement(rss, 'channel')
|
|
|
|
# Channel metadata
|
|
title = ET.SubElement(channel, 'title')
|
|
title.text = 'Game Server FAQ'
|
|
|
|
link = ET.SubElement(channel, 'link')
|
|
link.text = 'https://gameservers.world/faq'
|
|
|
|
description = ET.SubElement(channel, 'description')
|
|
description.text = 'Comprehensive game server configuration and troubleshooting guide'
|
|
|
|
language = ET.SubElement(channel, 'dc:language')
|
|
language.text = 'en'
|
|
|
|
pubdate = ET.SubElement(channel, 'pubDate')
|
|
# Fix datetime deprecation warning
|
|
pubdate.text = datetime.now().strftime('%a, %d %b %Y %H:%M:%S GMT')
|
|
|
|
# Sort games alphabetically by name, then items by section order
|
|
sorted_games = sorted(self.games, key=lambda x: x['name'])
|
|
|
|
# Define section order
|
|
section_order = ['Config Files', 'Startup Parameters', 'Troubleshooting', 'Steam Workshop']
|
|
|
|
# Generate items for each game
|
|
all_items = []
|
|
for game in sorted_games:
|
|
game_name = game['name']
|
|
|
|
# Required sections in order
|
|
sections = [
|
|
('Config Files', self.generate_config_files_content(game)),
|
|
('Startup Parameters', self.generate_startup_parameters_content(game)),
|
|
('Troubleshooting', self.generate_troubleshooting_content(game))
|
|
]
|
|
|
|
# Optional workshop section
|
|
workshop_content = self.generate_workshop_content(game)
|
|
if workshop_content:
|
|
sections.append(('Steam Workshop', workshop_content))
|
|
|
|
# Create RSS items
|
|
for title, content in sections:
|
|
item = self.create_rss_item(title, game_name, content)
|
|
all_items.append((game_name, title, item))
|
|
|
|
# Sort all items by category (game name) then by section order
|
|
all_items.sort(key=lambda x: (x[0], section_order.index(x[1]) if x[1] in section_order else 999))
|
|
|
|
# Add all items to channel (no longer needed since we're writing manually)
|
|
# for game_name, title, item in all_items:
|
|
# channel.append(item)
|
|
|
|
# Write RSS file manually to avoid namespace issues
|
|
with open(self.output_file, 'w', encoding='utf-8') as f:
|
|
f.write('<?xml version="1.0" encoding="utf-8" ?>\n')
|
|
f.write('<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/">\n')
|
|
f.write(' <channel>\n')
|
|
f.write(' <title>Game Server FAQ</title>\n')
|
|
f.write(' <link>https://gameservers.world/faq</link>\n')
|
|
f.write(' <description>Comprehensive game server configuration and troubleshooting guide</description>\n')
|
|
f.write(' <dc:language>en</dc:language>\n')
|
|
f.write(f' <pubDate>{datetime.now().strftime("%a, %d %b %Y %H:%M:%S GMT")}</pubDate>\n')
|
|
|
|
# Write all items
|
|
for game_name, title, item in all_items:
|
|
title_text = item.find('title').text
|
|
category_text = item.find('category').text
|
|
content_elem = item.find('{http://purl.org/rss/1.0/modules/content/}encoded')
|
|
content = content_elem.text if content_elem is not None else ""
|
|
|
|
f.write(' <item>\n')
|
|
f.write(f' <title>{html.escape(title_text)}</title>\n')
|
|
f.write(f' <category>{html.escape(category_text)}</category>\n')
|
|
f.write(f' <content:encoded>{content}</content:encoded>\n')
|
|
f.write(' </item>\n')
|
|
|
|
f.write(' </channel>\n')
|
|
f.write('</rss>\n')
|
|
|
|
print(f"Generated RSS with {len(all_items)} items for {len(sorted_games)} games")
|
|
print(f"Output: {self.output_file}")
|
|
|
|
def run(self):
|
|
"""Main execution method"""
|
|
print("FAQ RSS Generator")
|
|
print("================")
|
|
|
|
if not self.load_games():
|
|
return False
|
|
|
|
if not self.games:
|
|
print("No valid games found. RSS file will be empty.")
|
|
|
|
self.generate_rss()
|
|
return True
|
|
|
|
def main():
|
|
if len(sys.argv) > 1:
|
|
data_dir = sys.argv[1]
|
|
else:
|
|
data_dir = "data/games"
|
|
|
|
if len(sys.argv) > 2:
|
|
output_file = sys.argv[2]
|
|
else:
|
|
output_file = "FAQ.RSS"
|
|
|
|
generator = FAQRSSGenerator(data_dir, output_file)
|
|
success = generator.run()
|
|
sys.exit(0 if success else 1)
|
|
|
|
if __name__ == "__main__":
|
|
main() |