root_localhost

steam_achievements_rss.py

Aug 3rd, 2025
98
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 21.69 KB | None | 0 0
  1. #!/usr/bin/env python3
  2. """
  3. Steam Achievements RSS Feed Generator
  4.  
  5. A production-ready script that generates RSS feeds from Steam user achievements
  6. using the official Steam Web API.
  7.  
  8. Author: Generated by Claude
  9. License: MIT
  10. """
  11.  
  12. import argparse
  13. import json
  14. import logging
  15. import os
  16. import sys
  17. import time
  18. from datetime import datetime, timedelta, timezone
  19. from typing import Dict, List, Optional, Tuple
  20. from urllib.parse import quote
  21. import xml.etree.ElementTree as ET
  22.  
  23. import requests
  24. from dotenv import load_dotenv
  25.  
  26.  
  27. # Configure logging
  28. logging.basicConfig(
  29. level=logging.INFO,
  30. format='%(asctime)s - %(levelname)s - %(message)s',
  31. handlers=[
  32. logging.StreamHandler(sys.stdout),
  33. logging.FileHandler('steam_rss.log', mode='a')
  34. ]
  35. )
  36. logger = logging.getLogger(__name__)
  37.  
  38.  
  39. class SteamAPIError(Exception):
  40. """Custom exception for Steam API related errors."""
  41. pass
  42.  
  43.  
  44. class SteamAPIClient:
  45. """Steam Web API client with rate limiting and error handling."""
  46.  
  47. def __init__(self, api_key: str):
  48. self.api_key = api_key
  49. self.base_url = "https://api.steampowered.com"
  50. self.session = requests.Session()
  51. self.session.headers.update({
  52. 'User-Agent': 'Steam-Achievements-RSS/1.0'
  53. })
  54. self._last_request_time = 0
  55. self._min_request_interval = 0.1 # 10 requests per second max
  56.  
  57. def _rate_limit(self):
  58. """Enforce rate limiting between API requests."""
  59. elapsed = time.time() - self._last_request_time
  60. if elapsed < self._min_request_interval:
  61. time.sleep(self._min_request_interval - elapsed)
  62. self._last_request_time = time.time()
  63.  
  64. def _make_request(self, endpoint: str, params: Dict, max_retries: int = 3) -> Dict:
  65. """Make an API request with exponential backoff retry logic."""
  66. self._rate_limit()
  67.  
  68. url = f"{self.base_url}/{endpoint}"
  69. params['key'] = self.api_key
  70.  
  71. for attempt in range(max_retries):
  72. try:
  73. response = self.session.get(url, params=params, timeout=30)
  74.  
  75. if response.status_code == 429:
  76. # Rate limited - exponential backoff
  77. wait_time = 2 ** attempt
  78. logger.warning(f"Rate limited. Waiting {wait_time}s before retry {attempt + 1}/{max_retries}")
  79. time.sleep(wait_time)
  80. continue
  81. elif response.status_code == 400:
  82. # Bad request - don't retry, this is usually a game with no achievements
  83. raise SteamAPIError(f"Bad request (400) - likely no achievements available")
  84. elif response.status_code == 403:
  85. # Forbidden - don't retry, this is a permissions issue
  86. raise SteamAPIError(f"Access forbidden (403) - profile may be private")
  87.  
  88. response.raise_for_status()
  89. return response.json()
  90.  
  91. except requests.exceptions.Timeout:
  92. if attempt < max_retries - 1:
  93. logger.debug(f"Request timeout on attempt {attempt + 1}/{max_retries}, retrying...")
  94. time.sleep(2 ** attempt)
  95. continue
  96. else:
  97. raise SteamAPIError("Request timed out after multiple attempts")
  98.  
  99. except requests.exceptions.RequestException as e:
  100. # For 400/403 errors, don't retry
  101. if hasattr(e, 'response') and e.response is not None:
  102. if e.response.status_code in [400, 403]:
  103. raise SteamAPIError(f"HTTP {e.response.status_code}")
  104.  
  105. if attempt < max_retries - 1:
  106. logger.debug(f"Request failed on attempt {attempt + 1}/{max_retries}: {e}")
  107. time.sleep(2 ** attempt)
  108. continue
  109. else:
  110. raise SteamAPIError(f"Request failed: {e}")
  111.  
  112. raise SteamAPIError("Max retries exceeded")
  113.  
  114. def resolve_vanity_url(self, vanity_url: str) -> str:
  115. """Resolve a vanity URL to SteamID64."""
  116. logger.info(f"Resolving vanity URL: {vanity_url}")
  117.  
  118. try:
  119. data = self._make_request("ISteamUser/ResolveVanityURL/v0001/", {
  120. 'vanityurl': vanity_url
  121. })
  122.  
  123. if data.get('response', {}).get('success') == 1:
  124. steam_id = data['response']['steamid']
  125. logger.info(f"Resolved {vanity_url} to {steam_id}")
  126. return steam_id
  127. else:
  128. raise SteamAPIError(f"Could not resolve vanity URL '{vanity_url}'. Please check the username.")
  129.  
  130. except KeyError:
  131. raise SteamAPIError("Invalid response format from Steam API")
  132.  
  133. def get_player_achievements(self, steam_id: str, app_id: str) -> Dict:
  134. """Get player achievements for a specific game."""
  135. return self._make_request("ISteamUserStats/GetPlayerAchievements/v0001/", {
  136. 'steamid': steam_id,
  137. 'appid': app_id
  138. })
  139.  
  140. def get_owned_games(self, steam_id: str) -> Dict:
  141. """Get list of games owned by the player."""
  142. return self._make_request("IPlayerService/GetOwnedGames/v0001/", {
  143. 'steamid': steam_id,
  144. 'include_appinfo': 1,
  145. 'include_played_free_games': 1
  146. })
  147.  
  148. def get_game_schema(self, app_id: str) -> Dict:
  149. """Get game schema including achievement definitions."""
  150. return self._make_request("ISteamUserStats/GetSchemaForGame/v2/", {
  151. 'appid': app_id
  152. })
  153.  
  154. def get_global_achievement_percentages(self, app_id: str) -> Dict:
  155. """Get global achievement percentages for a game."""
  156. try:
  157. return self._make_request("ISteamUserStats/GetGlobalAchievementPercentagesForApp/v0002/", {
  158. 'gameid': app_id
  159. })
  160. except SteamAPIError:
  161. # Some games don't have global stats available
  162. logger.debug(f"No global achievement stats available for app {app_id}")
  163. return {}
  164.  
  165.  
  166. class AchievementProcessor:
  167. """Process and filter Steam achievements."""
  168.  
  169. def __init__(self, api_client: SteamAPIClient):
  170. self.api_client = api_client
  171. self.game_cache = {}
  172. self.schema_cache = {}
  173. self.global_stats_cache = {}
  174.  
  175. def get_recent_achievements(self, steam_id: str, days: int = 30) -> List[Dict]:
  176. """Get recent achievements for a Steam user."""
  177. logger.info(f"Fetching recent achievements for user {steam_id} (last {days} days)")
  178.  
  179. # Get owned games
  180. try:
  181. games_data = self.api_client.get_owned_games(steam_id)
  182. games = games_data.get('response', {}).get('games', [])
  183. except SteamAPIError as e:
  184. if "403" in str(e) or "private" in str(e).lower():
  185. raise SteamAPIError("User profile is private. Cannot access game data.")
  186. raise
  187.  
  188. if not games:
  189. raise SteamAPIError("No games found or profile is private")
  190.  
  191. logger.info(f"Found {len(games)} owned games")
  192.  
  193. # Cache game info
  194. for game in games:
  195. self.game_cache[str(game['appid'])] = {
  196. 'name': game['name'],
  197. 'appid': game['appid']
  198. }
  199.  
  200. # Calculate cutoff date
  201. cutoff_date = datetime.now(timezone.utc) - timedelta(days=days)
  202. cutoff_timestamp = int(cutoff_date.timestamp())
  203.  
  204. all_achievements = []
  205. processed_games = 0
  206. games_with_achievements = 0
  207.  
  208. for game in games:
  209. app_id = str(game['appid'])
  210.  
  211. try:
  212. # Get player achievements for this game
  213. achievements_data = self.api_client.get_player_achievements(steam_id, app_id)
  214.  
  215. if 'playerstats' not in achievements_data:
  216. continue
  217.  
  218. playerstats = achievements_data['playerstats']
  219.  
  220. if not playerstats.get('success', True):
  221. continue
  222.  
  223. achievements = playerstats.get('achievements', [])
  224. if not achievements:
  225. continue
  226.  
  227. games_with_achievements += 1
  228.  
  229. # Filter for recent achievements
  230. recent_achievements = [
  231. ach for ach in achievements
  232. if ach.get('achieved', 0) == 1 and
  233. ach.get('unlocktime', 0) >= cutoff_timestamp
  234. ]
  235.  
  236. if recent_achievements:
  237. # Get game schema for achievement details
  238. schema = self._get_game_schema(app_id)
  239. global_stats = self._get_global_stats(app_id)
  240.  
  241. # Enrich achievement data
  242. for achievement in recent_achievements:
  243. enriched = self._enrich_achievement(
  244. achievement, app_id, schema, global_stats
  245. )
  246. if enriched:
  247. all_achievements.append(enriched)
  248.  
  249. processed_games += 1
  250.  
  251. # Progress logging
  252. if processed_games % 20 == 0:
  253. logger.info(f"Processed {processed_games}/{len(games)} games ({games_with_achievements} with achievements), found {len(all_achievements)} recent achievements")
  254.  
  255. except SteamAPIError as e:
  256. # Log only unexpected errors, not common 400s for games without achievements
  257. if "400" not in str(e):
  258. logger.debug(f"Could not get achievements for game {app_id} ({game.get('name', 'Unknown')}): {e}")
  259. continue
  260.  
  261. # Sort by unlock time (newest first)
  262. all_achievements.sort(key=lambda x: x['unlocktime'], reverse=True)
  263.  
  264. logger.info(f"Scan complete: {processed_games} games processed ({games_with_achievements} had achievements)")
  265. logger.info(f"Found {len(all_achievements)} recent achievements in the last {days} days")
  266. return all_achievements
  267.  
  268. def _get_game_schema(self, app_id: str) -> Dict:
  269. """Get and cache game schema."""
  270. if app_id not in self.schema_cache:
  271. try:
  272. schema_data = self.api_client.get_game_schema(app_id)
  273. self.schema_cache[app_id] = schema_data.get('game', {})
  274. except SteamAPIError:
  275. self.schema_cache[app_id] = {}
  276.  
  277. return self.schema_cache[app_id]
  278.  
  279. def _get_global_stats(self, app_id: str) -> Dict:
  280. """Get and cache global achievement statistics."""
  281. if app_id not in self.global_stats_cache:
  282. global_data = self.api_client.get_global_achievement_percentages(app_id)
  283. stats_dict = {}
  284.  
  285. for achievement in global_data.get('achievementpercentages', {}).get('achievements', []):
  286. stats_dict[achievement['name']] = achievement['percent']
  287.  
  288. self.global_stats_cache[app_id] = stats_dict
  289.  
  290. return self.global_stats_cache[app_id]
  291.  
  292. def _enrich_achievement(self, achievement: Dict, app_id: str, schema: Dict, global_stats: Dict) -> Optional[Dict]:
  293. """Enrich achievement data with additional information."""
  294. api_name = achievement['apiname']
  295.  
  296. # Find achievement details in schema
  297. achievement_details = None
  298. for ach in schema.get('availableGameStats', {}).get('achievements', []):
  299. if ach['name'] == api_name:
  300. achievement_details = ach
  301. break
  302.  
  303. if not achievement_details:
  304. return None
  305.  
  306. # Get global percentage
  307. global_percent = global_stats.get(api_name, 0)
  308.  
  309. # Determine rarity category
  310. if global_percent == 0:
  311. rarity = "Unknown"
  312. elif global_percent < 1:
  313. rarity = "Ultra Rare"
  314. elif global_percent < 5:
  315. rarity = "Very Rare"
  316. elif global_percent < 15:
  317. rarity = "Rare"
  318. elif global_percent < 50:
  319. rarity = "Uncommon"
  320. else:
  321. rarity = "Common"
  322.  
  323. return {
  324. 'name': achievement_details.get('displayName', api_name),
  325. 'description': achievement_details.get('description', ''),
  326. 'icon': achievement_details.get('icon', ''),
  327. 'unlocktime': achievement['unlocktime'],
  328. 'app_id': app_id,
  329. 'game_name': self.game_cache[app_id]['name'],
  330. 'global_percent': global_percent,
  331. 'rarity': rarity
  332. }
  333.  
  334.  
  335. class RSSGenerator:
  336. """Generate RSS 2.0 feeds from achievement data."""
  337.  
  338. def __init__(self, title: str = "Steam Achievements", description: str = "Recent Steam achievements",
  339. link: str = "https://steamcommunity.com"):
  340. self.title = title
  341. self.description = description
  342. self.link = link
  343.  
  344. def generate_feed(self, achievements: List[Dict], steam_id: str) -> str:
  345. """Generate RSS 2.0 XML feed from achievements."""
  346. # Create root RSS element
  347. rss = ET.Element("rss", version="2.0")
  348. rss.set("xmlns:atom", "http://www.w3.org/2005/Atom")
  349.  
  350. # Create channel
  351. channel = ET.SubElement(rss, "channel")
  352.  
  353. # Add channel metadata
  354. ET.SubElement(channel, "title").text = self.title
  355. ET.SubElement(channel, "description").text = self.description
  356. ET.SubElement(channel, "link").text = self.link
  357. ET.SubElement(channel, "language").text = "en-us"
  358. ET.SubElement(channel, "lastBuildDate").text = self._format_rfc2822(datetime.now(timezone.utc))
  359. ET.SubElement(channel, "generator").text = "Steam Achievements RSS Generator"
  360.  
  361. # Add atom:link for self-reference
  362. atom_link = ET.SubElement(channel, "atom:link")
  363. atom_link.set("href", self.link)
  364. atom_link.set("rel", "self")
  365. atom_link.set("type", "application/rss+xml")
  366.  
  367. # Add achievement items
  368. for achievement in achievements:
  369. item = ET.SubElement(channel, "item")
  370.  
  371. # Title
  372. title = f"🏆 {achievement['name']} - {achievement['game_name']}"
  373. ET.SubElement(item, "title").text = title
  374.  
  375. # Description with rich HTML
  376. description = self._generate_achievement_html(achievement)
  377. ET.SubElement(item, "description").text = description
  378.  
  379. # Link to game store page
  380. link = f"https://store.steampowered.com/app/{achievement['app_id']}"
  381. ET.SubElement(item, "link").text = link
  382.  
  383. # GUID (unique identifier)
  384. guid = f"steam-achievement-{steam_id}-{achievement['app_id']}-{achievement['unlocktime']}"
  385. guid_elem = ET.SubElement(item, "guid")
  386. guid_elem.text = guid
  387. guid_elem.set("isPermaLink", "false")
  388.  
  389. # Publication date
  390. pub_date = datetime.fromtimestamp(achievement['unlocktime'], timezone.utc)
  391. ET.SubElement(item, "pubDate").text = self._format_rfc2822(pub_date)
  392.  
  393. # Category
  394. ET.SubElement(item, "category").text = "Gaming"
  395.  
  396. # Convert to string with XML declaration
  397. rough_string = ET.tostring(rss, encoding='unicode')
  398. return f'<?xml version="1.0" encoding="UTF-8"?>\n{rough_string}'
  399.  
  400. def _generate_achievement_html(self, achievement: Dict) -> str:
  401. """Generate rich HTML description for an achievement."""
  402. unlock_date = datetime.fromtimestamp(achievement['unlocktime'], timezone.utc)
  403. formatted_date = unlock_date.strftime("%B %d, %Y at %I:%M %p UTC")
  404.  
  405. # Format percentage
  406. if achievement['global_percent'] > 0:
  407. percent_str = f"{achievement['global_percent']:.2f}%"
  408. else:
  409. percent_str = "Unknown"
  410.  
  411. html = f"""
  412. <img src="{achievement['icon']}" alt="Achievement Icon"
  413. style="float: left; margin-right: 10px; margin-bottom: 5px; width: 64px; height: 64px;" />
  414.  
  415. <p>{achievement['description']}</p>
  416.  
  417. <hr>
  418.  
  419. <div style="margin-top: 10px; padding: 18px; background-color: #f5f5f5;
  420. border-radius: 4px; font-size: 0.9em;">
  421. <strong>🎮 Game:</strong>
  422. <a href="https://store.steampowered.com/app/{achievement['app_id']}">{achievement['game_name']}</a><br/>
  423. <strong>🏆 Rarity:</strong> {achievement['rarity']} ({percent_str} of players)<br/>
  424. <strong>📅 Unlocked:</strong> {formatted_date}
  425. </div>
  426.  
  427. <div style="clear: both;"></div>
  428. """.strip()
  429.  
  430. return html
  431.  
  432. def _format_rfc2822(self, dt: datetime) -> str:
  433. """Format datetime as RFC 2822 string for RSS."""
  434. return dt.strftime("%a, %d %b %Y %H:%M:%S %z")
  435.  
  436.  
  437. def setup_argument_parser() -> argparse.ArgumentParser:
  438. """Set up command line argument parser."""
  439. parser = argparse.ArgumentParser(
  440. description="Generate RSS feed from Steam achievements",
  441. formatter_class=argparse.RawDescriptionHelpFormatter,
  442. epilog="""
  443. Examples:
  444. %(prog)s --steam-id 76561198024399018 --days 7
  445. %(prog)s --vanity-url myusername --days 30 --output feed.xml
  446. %(prog)s --vanity-url myusername --title "My Gaming Achievements"
  447. """
  448. )
  449.  
  450. # Steam ID options (mutually exclusive)
  451. id_group = parser.add_mutually_exclusive_group(required=True)
  452. id_group.add_argument('--steam-id', help='Steam ID64')
  453. id_group.add_argument('--vanity-url', help='Steam vanity URL (custom username)')
  454.  
  455. # Configuration options
  456. parser.add_argument('--days', type=int, default=30,
  457. help='Number of days to look back for achievements (default: 30)')
  458. parser.add_argument('--output', default='steam_achievements.xml',
  459. help='Output RSS file path (default: steam_achievements.xml)')
  460.  
  461. # RSS metadata
  462. parser.add_argument('--title', default='Steam Achievements',
  463. help='RSS feed title (default: Steam Achievements)')
  464. parser.add_argument('--description', default='Recent Steam achievements',
  465. help='RSS feed description (default: Recent Steam achievements)')
  466. parser.add_argument('--link', default='https://steamcommunity.com',
  467. help='RSS feed link (default: https://steamcommunity.com)')
  468.  
  469. # API key option
  470. parser.add_argument('--api-key', help='Steam Web API key (can also use .env file)')
  471.  
  472. # Logging
  473. parser.add_argument('--verbose', '-v', action='store_true',
  474. help='Enable verbose logging')
  475.  
  476. return parser
  477.  
  478.  
  479. def load_configuration(args: argparse.Namespace) -> Tuple[str, str]:
  480. """Load configuration from arguments and environment."""
  481. # Load .env file
  482. load_dotenv()
  483.  
  484. # Get API key
  485. api_key = args.api_key or os.getenv('STEAM_API_KEY')
  486. if not api_key:
  487. raise ValueError(
  488. "Steam API key is required. Provide it via --api-key argument or "
  489. "STEAM_API_KEY environment variable in .env file.\n"
  490. "Get your API key at: https://steamcommunity.com/dev/apikey"
  491. )
  492.  
  493. # Get Steam ID
  494. steam_id = args.steam_id or os.getenv('STEAM_ID')
  495. if not steam_id and not args.vanity_url:
  496. vanity_url = os.getenv('STEAM_VANITY_URL')
  497. if vanity_url:
  498. args.vanity_url = vanity_url
  499.  
  500. return api_key, steam_id
  501.  
  502.  
  503. def main():
  504. """Main application entry point."""
  505. parser = setup_argument_parser()
  506. args = parser.parse_args()
  507.  
  508. # Configure logging level
  509. if args.verbose:
  510. logging.getLogger().setLevel(logging.DEBUG)
  511.  
  512. try:
  513. # Load configuration
  514. api_key, steam_id = load_configuration(args)
  515.  
  516. # Initialize API client
  517. api_client = SteamAPIClient(api_key)
  518.  
  519. # Resolve vanity URL if needed
  520. if args.vanity_url:
  521. steam_id = api_client.resolve_vanity_url(args.vanity_url)
  522.  
  523. if not steam_id:
  524. raise ValueError("Steam ID is required via --steam-id argument or environment variable")
  525.  
  526. # Process achievements
  527. processor = AchievementProcessor(api_client)
  528. achievements = processor.get_recent_achievements(steam_id, args.days)
  529.  
  530. if not achievements:
  531. logger.info(f"No achievements found in the last {args.days} days")
  532. logger.info("The RSS feed will be created but will be empty")
  533. # Create empty feed
  534. achievements = []
  535. else:
  536. logger.info(f"Creating RSS feed with {len(achievements)} achievements")
  537.  
  538. # Generate RSS feed
  539. rss_generator = RSSGenerator(
  540. title=args.title,
  541. description=args.description,
  542. link=args.link
  543. )
  544.  
  545. rss_content = rss_generator.generate_feed(achievements, steam_id)
  546.  
  547. # Write to file
  548. with open(args.output, 'w', encoding='utf-8') as f:
  549. f.write(rss_content)
  550.  
  551. logger.info(f"RSS feed generated successfully: {args.output}")
  552. logger.info(f"Feed contains {len(achievements)} achievement(s)")
  553.  
  554. except ValueError as e:
  555. logger.error(f"Configuration error: {e}")
  556. sys.exit(1)
  557. except SteamAPIError as e:
  558. logger.error(f"Steam API error: {e}")
  559. sys.exit(1)
  560. except Exception as e:
  561. logger.error(f"Unexpected error: {e}")
  562. if args.verbose:
  563. logger.exception("Full traceback:")
  564. sys.exit(1)
  565.  
  566.  
  567. if __name__ == "__main__":
  568. main()
  569.  
Advertisement
Add Comment
Please, Sign In to add comment