216 lines
8.3 KiB
Python
216 lines
8.3 KiB
Python
import argparse
|
|
import datetime
|
|
import json
|
|
import re
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
from envfile import env_value
|
|
from jsonfile import PROJECT_ROOT
|
|
|
|
TOKEN_URL = "https://id.twitch.tv/oauth2/token"
|
|
GAMES_URL = "https://api.igdb.com/v4/games"
|
|
EXTERNAL_GAMES_URL = "https://api.igdb.com/v4/external_games"
|
|
IMAGE_URL = "https://images.igdb.com/igdb/image/upload/{size}/{image_id}.jpg"
|
|
TOKEN_CACHE_PATH = PROJECT_ROOT / "tools" / "content" / ".cache" / "igdb_token.json"
|
|
TOKEN_EXPIRY_MARGIN_SECONDS = 300
|
|
SUMMARY_MAX_LENGTH = 600
|
|
GAME_FIELDS = ", ".join([
|
|
"name",
|
|
"slug",
|
|
"url",
|
|
"summary",
|
|
"first_release_date",
|
|
"total_rating",
|
|
"genres.name",
|
|
"platforms.name",
|
|
"involved_companies.developer",
|
|
"involved_companies.publisher",
|
|
"involved_companies.company.name",
|
|
"cover.image_id",
|
|
"artworks.image_id",
|
|
"screenshots.image_id",
|
|
])
|
|
IGDB_SLUG_PATTERN = re.compile(r"igdb\.com/games/([\w-]+)")
|
|
STORE_ID_PATTERNS = {
|
|
"store.steampowered.com": re.compile(r"store\.steampowered\.com/app/(\d+)"),
|
|
"xbox.com": re.compile(r"xbox\.com/.*?/([0-9A-Za-z]{12})(?:[/?#]|$)"),
|
|
}
|
|
LINK_NOISE_SEGMENTS = {"store", "products", "product", "games", "game", "app", "p", "us", "en-us"}
|
|
SEGMENT_ID_PATTERN = re.compile(r"\d+|[0-9A-Z]{12}")
|
|
EPIC_SUFFIX_PATTERN = re.compile(r"-(?=[0-9a-f]*\d)[0-9a-f]{6}$")
|
|
NAME_NOISE_PATTERN = re.compile(r"\b(switch|ps4|ps5|pc|xbox)\b", re.I)
|
|
|
|
|
|
class IgdbError(ValueError):
|
|
pass
|
|
|
|
|
|
def credentials() -> tuple[str, str]:
|
|
client_id = env_value("IGDB_CLIENT_ID")
|
|
client_secret = env_value("IGDB_CLIENT_SECRET")
|
|
if not client_id or not client_secret:
|
|
raise IgdbError("Set IGDB_CLIENT_ID and IGDB_CLIENT_SECRET in .env to look up games")
|
|
return client_id, client_secret
|
|
|
|
|
|
def cached_token() -> str | None:
|
|
try:
|
|
cached = json.loads(TOKEN_CACHE_PATH.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
return None
|
|
return cached["token"] if cached.get("expiresAt", 0) > time.time() else None
|
|
|
|
|
|
def fetch_token(client_id: str, client_secret: str) -> str:
|
|
parameters = urllib.parse.urlencode({"client_id": client_id, "client_secret": client_secret, "grant_type": "client_credentials"})
|
|
request = urllib.request.Request(f"{TOKEN_URL}?{parameters}", method="POST")
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=15) as response:
|
|
result = json.load(response)
|
|
except urllib.error.HTTPError as error:
|
|
raise IgdbError(f"Twitch refused the IGDB keys ({error.code}). Check IGDB_CLIENT_ID and IGDB_CLIENT_SECRET") from error
|
|
TOKEN_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
expires_at = time.time() + result["expires_in"] - TOKEN_EXPIRY_MARGIN_SECONDS
|
|
TOKEN_CACHE_PATH.write_text(json.dumps({"token": result["access_token"], "expiresAt": expires_at}), encoding="utf-8")
|
|
return result["access_token"]
|
|
|
|
|
|
def query(url: str, body: str) -> list[dict]:
|
|
client_id, client_secret = credentials()
|
|
token = cached_token() or fetch_token(client_id, client_secret)
|
|
headers = {"Client-ID": client_id, "Authorization": f"Bearer {token}", "Accept": "application/json"}
|
|
request = urllib.request.Request(url, data=body.encode("utf-8"), headers=headers, method="POST")
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=15) as response:
|
|
return json.load(response)
|
|
except urllib.error.HTTPError as error:
|
|
if error.code == 401:
|
|
TOKEN_CACHE_PATH.unlink(missing_ok=True)
|
|
raise IgdbError(f"IGDB returned {error.code}: {error.read().decode('utf-8', errors='replace')[:200]}") from error
|
|
|
|
|
|
def quoted(text: str) -> str:
|
|
return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
|
|
|
|
def game_by_id(identifier: str) -> dict | None:
|
|
results = query(GAMES_URL, f"fields {GAME_FIELDS}; where id = {int(identifier)};")
|
|
return results[0] if results else None
|
|
|
|
|
|
def game_by_slug(slug: str) -> dict | None:
|
|
results = query(GAMES_URL, f"fields {GAME_FIELDS}; where slug = {quoted(slug)};")
|
|
return results[0] if results else None
|
|
|
|
|
|
def game_by_store_link(link: str) -> dict | None:
|
|
for host, pattern in STORE_ID_PATTERNS.items():
|
|
match = pattern.search(link)
|
|
if not match:
|
|
continue
|
|
uid = match.group(1).upper() if host == "xbox.com" else match.group(1)
|
|
matches = query(EXTERNAL_GAMES_URL, f"fields game, url; where uid = {quoted(uid)}; limit 50;")
|
|
same_store = [item for item in matches if host in (item.get("url") or "")]
|
|
chosen = same_store or (matches if len(matches) == 1 else [])
|
|
if chosen and chosen[0].get("game"):
|
|
return game_by_id(str(chosen[0]["game"]))
|
|
return None
|
|
|
|
|
|
def search_games(name: str) -> list[dict]:
|
|
return query(GAMES_URL, f"search {quoted(name)}; fields {GAME_FIELDS}; limit 10;")
|
|
|
|
|
|
def search_game(name: str) -> dict | None:
|
|
results = search_games(name)
|
|
exact = [game for game in results if game.get("name", "").lower() == name.lower()]
|
|
return (exact or results or [None])[0]
|
|
|
|
|
|
def name_from_link(link: str) -> str:
|
|
segments = [segment for segment in urllib.parse.urlparse(link).path.split("/") if segment]
|
|
words = [segment for segment in segments if segment.lower() not in LINK_NOISE_SEGMENTS and not SEGMENT_ID_PATTERN.fullmatch(segment)]
|
|
guess = EPIC_SUFFIX_PATTERN.sub("", words[-1]) if words else ""
|
|
guess = NAME_NOISE_PATTERN.sub("", re.sub(r"[-_]+", " ", guess))
|
|
return " ".join(guess.split())
|
|
|
|
|
|
def find_game(link: str | None, name: str | None) -> dict:
|
|
game = None
|
|
if link and IGDB_SLUG_PATTERN.search(link):
|
|
game = game_by_slug(IGDB_SLUG_PATTERN.search(link).group(1))
|
|
if not game and link:
|
|
game = game_by_store_link(link)
|
|
search_name = name or (name_from_link(link) if link else "")
|
|
if not game and search_name:
|
|
game = search_game(search_name)
|
|
if not game:
|
|
raise IgdbError(f"Could not find {name or link} on IGDB. Try an IGDB link like https://www.igdb.com/games/hades-ii")
|
|
return game
|
|
|
|
|
|
def image_url(image: dict | None, size: str) -> str | None:
|
|
return IMAGE_URL.format(size=size, image_id=image["image_id"]) if image and image.get("image_id") else None
|
|
|
|
|
|
def first_image(game: dict, key: str) -> dict | None:
|
|
images = game.get(key) or []
|
|
return images[0] if images else None
|
|
|
|
|
|
def company_names(game: dict, role: str) -> list[str]:
|
|
return [item["company"]["name"] for item in game.get("involved_companies") or [] if item.get(role) and item.get("company")]
|
|
|
|
|
|
def short_summary(text: str | None) -> str | None:
|
|
if not text or len(text) <= SUMMARY_MAX_LENGTH:
|
|
return text
|
|
return text[:SUMMARY_MAX_LENGTH].rsplit(" ", 1)[0] + "..."
|
|
|
|
|
|
def release_date(game: dict) -> str | None:
|
|
seconds = game.get("first_release_date")
|
|
if not isinstance(seconds, (int, float)):
|
|
return None
|
|
return datetime.datetime.fromtimestamp(seconds, tz=datetime.timezone.utc).date().isoformat()
|
|
|
|
|
|
def details(game: dict) -> dict:
|
|
wide_image = first_image(game, "artworks") or first_image(game, "screenshots")
|
|
return {
|
|
"igdbId": str(game["id"]),
|
|
"name": game.get("name"),
|
|
"igdbUrl": game.get("url"),
|
|
"released": release_date(game),
|
|
"platforms": [platform["name"] for platform in game.get("platforms") or [] if platform.get("name")],
|
|
"developers": company_names(game, "developer"),
|
|
"publishers": company_names(game, "publisher"),
|
|
"genres": [genre["name"] for genre in game.get("genres") or [] if genre.get("name")],
|
|
"summary": short_summary(game.get("summary")),
|
|
"score": round(game["total_rating"]) if game.get("total_rating") is not None else None,
|
|
"imageSource": image_url(wide_image, "t_1080p"),
|
|
"coverSource": image_url(game.get("cover"), "t_cover_big"),
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Look up a video game on IGDB")
|
|
parser.add_argument("text", help="a store link, an IGDB link or a game name")
|
|
arguments = parser.parse_args()
|
|
is_link = arguments.text.startswith("http")
|
|
try:
|
|
result = details(find_game(arguments.text if is_link else None, None if is_link else arguments.text))
|
|
except (OSError, ValueError) as error:
|
|
print(f"Error: {error}", file=sys.stderr)
|
|
return 1
|
|
print(json.dumps(result, indent="\t", ensure_ascii=False))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|