Add automatic instruction fetcher for official sets
Downloads official instruction PDFs from LEGO, located via the Brickset API, one set per interval (10 minutes by default) so neither API gets hammered. Official sets only; MOCs are excluded throughout. - New admin page at /admin/instruction-fetch: queue builder, pacing controls, live countdown and per-set job history - Database-backed queue (instruction_fetch_jobs) and last-run timestamp, so pacing survives restarts - Background worker thread, plus 'flask instructions queue/run/status' for Task Scheduler or cron - SHA-256 dedupe (instructions.sha256), PDF header check, size cap and streaming downloads via temp files - brickset_api: getInstructions2 by set number and getInstructions by setID (the old code sent a set number to the setID method); getSets params now proper JSON; getKeyUsageStats added - Schema top-ups applied automatically at boot: new table plus instructions.sha256/source - Docker: fetcher env vars passed through compose, no worker thread in the entrypoint bootstrap
This commit is contained in:
1 parent
aac2b60517
commit
89f65e2d74
23 files changed
+2073
-76
No files matched your search
+134
-72
@@ -1,19 +1,39 @@
|
||||
"""
|
||||
Brickset API v3 client.
|
||||
|
||||
https://brickset.com/article/52664/api-version-3-documentation
|
||||
|
||||
Two things worth knowing about the API, both of which shape this file:
|
||||
|
||||
* getSets takes its search terms as a JSON string. Python's str() on a dict
|
||||
produces single quotes, which is not JSON — hence json.dumps below.
|
||||
* getInstructions takes a Brickset setID; getInstructions2 takes a set
|
||||
number like "10497-1". They are different methods and passing a set
|
||||
number to the first one quietly returns nothing.
|
||||
|
||||
Only getSets counts against the daily key quota.
|
||||
|
||||
Created by: Jess Rogerson (yelling commands at Claude.AI)
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
from flask import current_app
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
|
||||
class BricksetAPI:
|
||||
"""Service for interacting with the Brickset API v3."""
|
||||
|
||||
|
||||
BASE_URL = 'https://brickset.com/api/v3.asmx'
|
||||
|
||||
|
||||
def __init__(self):
|
||||
self.api_key = current_app.config.get('BRICKSET_API_KEY')
|
||||
self.username = current_app.config.get('BRICKSET_USERNAME')
|
||||
self.password = current_app.config.get('BRICKSET_PASSWORD')
|
||||
self._user_hash = None
|
||||
|
||||
|
||||
@staticmethod
|
||||
def is_configured() -> bool:
|
||||
"""Check if Brickset API is properly configured."""
|
||||
@@ -22,16 +42,16 @@ class BricksetAPI:
|
||||
current_app.config.get('BRICKSET_USERNAME') and
|
||||
current_app.config.get('BRICKSET_PASSWORD')
|
||||
)
|
||||
|
||||
|
||||
def _get_user_hash(self) -> Optional[str]:
|
||||
"""Authenticate and get user hash token."""
|
||||
if self._user_hash:
|
||||
return self._user_hash
|
||||
|
||||
|
||||
if not all([self.api_key, self.username, self.password]):
|
||||
current_app.logger.warning('Brickset API credentials not configured')
|
||||
return None
|
||||
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
f'{self.BASE_URL}/login',
|
||||
@@ -44,47 +64,43 @@ class BricksetAPI:
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
|
||||
if data.get('status') == 'success':
|
||||
self._user_hash = data.get('hash')
|
||||
return self._user_hash
|
||||
else:
|
||||
current_app.logger.error(f"Brickset login failed: {data.get('message')}")
|
||||
return None
|
||||
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Brickset API authentication error: {str(e)}')
|
||||
return None
|
||||
|
||||
def search_sets(self,
|
||||
set_number: Optional[str] = None,
|
||||
theme: Optional[str] = None,
|
||||
year: Optional[int] = None,
|
||||
query: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
|
||||
def search_sets(self,
|
||||
set_number: Optional[str] = None,
|
||||
theme: Optional[str] = None,
|
||||
year: Optional[int] = None,
|
||||
query: Optional[str] = None,
|
||||
page_size: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search for LEGO sets using various parameters.
|
||||
|
||||
|
||||
Args:
|
||||
set_number: Specific set number to search for
|
||||
theme: Theme name to filter by
|
||||
year: Year released
|
||||
query: General search query
|
||||
|
||||
page_size: Number of records to return (max 500)
|
||||
|
||||
Returns:
|
||||
List of set dictionaries
|
||||
"""
|
||||
user_hash = self._get_user_hash()
|
||||
if not user_hash:
|
||||
return []
|
||||
|
||||
params = {
|
||||
'apiKey': self.api_key,
|
||||
'userHash': user_hash,
|
||||
'params': '{}' # JSON params object
|
||||
}
|
||||
|
||||
|
||||
# Build search parameters
|
||||
search_params = {}
|
||||
search_params: Dict[str, Any] = {}
|
||||
if set_number:
|
||||
search_params['setNumber'] = set_number
|
||||
if theme:
|
||||
@@ -93,9 +109,20 @@ class BricksetAPI:
|
||||
search_params['year'] = year
|
||||
if query:
|
||||
search_params['query'] = query
|
||||
|
||||
params['params'] = str(search_params)
|
||||
|
||||
if page_size:
|
||||
search_params['pageSize'] = page_size
|
||||
|
||||
if not search_params:
|
||||
return []
|
||||
|
||||
params = {
|
||||
'apiKey': self.api_key,
|
||||
'userHash': user_hash,
|
||||
# Must be a JSON string — str(dict) gives single quotes and is
|
||||
# rejected with "Parameter error".
|
||||
'params': json.dumps(search_params),
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
f'{self.BASE_URL}/getSets',
|
||||
@@ -104,41 +131,49 @@ class BricksetAPI:
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
|
||||
if data.get('status') == 'success':
|
||||
return data.get('sets', [])
|
||||
else:
|
||||
current_app.logger.error(f"Brickset search failed: {data.get('message')}")
|
||||
return []
|
||||
|
||||
|
||||
message = data.get('message', 'unknown error')
|
||||
current_app.logger.error(f'Brickset search failed: {message}')
|
||||
if 'limit exceeded' in message.lower():
|
||||
raise RuntimeError('Brickset API limit exceeded')
|
||||
return []
|
||||
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Brickset API search error: {str(e)}')
|
||||
return []
|
||||
|
||||
|
||||
def get_set_by_number(self, set_number: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get detailed information for a specific set by its number.
|
||||
|
||||
|
||||
Args:
|
||||
set_number: The LEGO set number (e.g., "10497")
|
||||
|
||||
set_number: The LEGO set number (e.g., "10497" or "10497-1")
|
||||
|
||||
Returns:
|
||||
Dictionary with set information or None
|
||||
"""
|
||||
results = self.search_sets(set_number=set_number)
|
||||
if not results and '-' not in set_number:
|
||||
# Brickset stores numbers with a variant suffix.
|
||||
results = self.search_sets(set_number=f'{set_number}-1')
|
||||
return results[0] if results else None
|
||||
|
||||
|
||||
def get_themes(self) -> List[str]:
|
||||
"""
|
||||
Get list of all available LEGO themes.
|
||||
|
||||
|
||||
Returns:
|
||||
List of theme names
|
||||
"""
|
||||
user_hash = self._get_user_hash()
|
||||
if not user_hash:
|
||||
return []
|
||||
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
f'{self.BASE_URL}/getThemes',
|
||||
@@ -150,49 +185,76 @@ class BricksetAPI:
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
|
||||
if data.get('status') == 'success':
|
||||
themes = data.get('themes', [])
|
||||
return [theme.get('theme') for theme in themes if theme.get('theme')]
|
||||
else:
|
||||
return []
|
||||
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Brickset API themes error: {str(e)}')
|
||||
return []
|
||||
|
||||
def get_instructions(self, set_number: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get instruction information for a specific set.
|
||||
|
||||
Args:
|
||||
set_number: The LEGO set number
|
||||
|
||||
Returns:
|
||||
List of instruction dictionaries
|
||||
"""
|
||||
user_hash = self._get_user_hash()
|
||||
if not user_hash:
|
||||
return []
|
||||
|
||||
|
||||
# -- instructions -------------------------------------------------------
|
||||
|
||||
def _instructions_request(self, method: str,
|
||||
params: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
params = dict(params, apiKey=self.api_key)
|
||||
try:
|
||||
response = requests.get(
|
||||
f'{self.BASE_URL}/getInstructions',
|
||||
params={
|
||||
'apiKey': self.api_key,
|
||||
'userHash': user_hash,
|
||||
'setNumber': set_number
|
||||
},
|
||||
timeout=10
|
||||
)
|
||||
response = requests.get(f'{self.BASE_URL}/{method}',
|
||||
params=params, timeout=20)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
|
||||
if data.get('status') == 'success':
|
||||
return data.get('instructions', [])
|
||||
else:
|
||||
return []
|
||||
|
||||
return data.get('instructions', []) or []
|
||||
|
||||
current_app.logger.error(
|
||||
f"Brickset {method} failed: {data.get('message')}")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Brickset API instructions error: {str(e)}')
|
||||
current_app.logger.error(f'Brickset {method} error: {str(e)}')
|
||||
return []
|
||||
|
||||
def get_instructions(self, set_number: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Instructions for a set number, e.g. "10497-1" (getInstructions2).
|
||||
|
||||
Returns a list of {'URL': ..., 'description': ...} dictionaries.
|
||||
Does not need a user hash and does not count against the key quota.
|
||||
"""
|
||||
if not self.api_key or not set_number:
|
||||
return []
|
||||
return self._instructions_request('getInstructions2',
|
||||
{'setNumber': set_number})
|
||||
|
||||
def get_instructions_by_id(self, set_id: int) -> List[Dict[str, Any]]:
|
||||
"""Instructions for an internal Brickset set ID (getInstructions)."""
|
||||
if not self.api_key or not set_id:
|
||||
return []
|
||||
return self._instructions_request('getInstructions', {'setID': set_id})
|
||||
|
||||
# -- quota --------------------------------------------------------------
|
||||
|
||||
def get_key_usage(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Daily getSets usage for the last 30 days.
|
||||
|
||||
Returns a list of {'dateStamp': ..., 'count': ...} dictionaries.
|
||||
"""
|
||||
if not self.api_key:
|
||||
return []
|
||||
try:
|
||||
response = requests.get(f'{self.BASE_URL}/getKeyUsageStats',
|
||||
params={'apiKey': self.api_key},
|
||||
timeout=10)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get('status') == 'success':
|
||||
return data.get('apiKeyUsage', []) or []
|
||||
return []
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Brickset key usage error: {str(e)}')
|
||||
return []
|
||||
Reference in new issue
Block a user