Initial commit

This commit is contained in:
Daniel Snider 2022-05-25 01:44:45 -07:00
commit 5b665f55eb
9 changed files with 770 additions and 0 deletions

162
.gitignore vendored Normal file
View File

@ -0,0 +1,162 @@
cache
data.json
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

70
components.js Normal file
View File

@ -0,0 +1,70 @@
import { h, render } from 'https://unpkg.com/preact?module';
function players(range) {
if (range[0] === range[1]) {
if (range[0] === 1) {
return "1 player";
}
return `${range[0]} players`;
}
return `${range[0]} to ${range[1]} players`;
}
function time(minutes) {
const hours = Math.floor(minutes / 60);
minutes -= hours * 60;
const out = [];
if (hours === 1) {
out.push(`1 hour`);
} else if (hours > 1){
out.push(`${hours} hours`);
}
if (minutes === 1) {
out.push(`1 minute`);
} else if (minutes > 1){
out.push(`${minutes} minutes`);
}
return out.join(" ");
}
function weight_to_percent(weight) {
return Math.round(((weight - 1) / 4) * 100);
}
export function boardgame(game) {
return h('div', { class: 'boardgame' }, [
h('h2', { class: 'title' }, game.title),
h('img', {src: `./cache/${game.id}.jpg`}),
h('div', { class: 'weight stat' }, `${weight_to_percent(game.weight)}%`),
h('div', { class: 'time stat' }, time(game.time)),
h('div', { class: 'players stat' }, players(game.player_range)),
h('div', { class: 'description' }, game.description.slice(0, 400) + "..."),
h('div', { class: 'mechanics' }, game.mechanics.slice(0,3).join(" • "))
]);
}
function boardgames(games) {
return h('div', { class: "boardgames" }, games.map(boardgame));
}
function nav_list() {
return h('ul', null, [
h('li', null, h('a', { href: "?all" }, "All")),
h('li', null, h('a', { href: "?two-player" }, "Two Player")),
h('li', null, h('a', { href: "?multi" }, "Multiplayer"))
]);
}
export function app(games, nav) {
if (nav === null) {
return nav_list();
} else if (nav === "all") {
return boardgames(games);
} else if (nav === "two-player") {
return boardgames(games.filter(g => g.player_range[0] === 2 && g.player_range[1] === 2));
} else if (nav === "multi") {
return boardgames(games.filter(g => g.player_range[0] !== 2 || g.player_range[1] !== 2));
}
}

12
index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Boardgame Menu</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div id="content"></div>
</body>
<script src="main.js" type="module"></script>
</html>

246
input.json Normal file
View File

@ -0,0 +1,246 @@
[
{
"title": "7 Wonders Duel",
"id": 173346
},
{
"title": "Advanced Squad Leader",
"id": 243
},
{
"title": "Agricola",
"id": 31260
},
{
"title": "Arkham Horror The Card Game",
"id": 205637
},
{
"title": "Ascension",
"id": 69789
},
{
"title": "Azul",
"id": 230802
},
{
"title": "Bärenpark",
"id": 219513
},
{
"title": "Battleline",
"id": 760
},
{
"title": "Battletech",
"id": 296694,
"families": ["Strategy"]
},
{
"title": "Betrayal at the House on the Hill",
"id": 10547
},
{
"title": "Castle Panic",
"id": 43443
},
{
"title": "Caylus",
"id": 18602
},
{
"title": "Codenames",
"id": 178900
},
{
"title": "Commands & Colors Ancients",
"id": 14105
},
{
"title": "Coup",
"id": 131357
},
{
"title": "Dominion",
"id": 36218
},
{
"title": "Flash Point",
"id": 100901
},
{
"title": "Gloomhaven Jaws of the Lion",
"id": 291457
},
{
"title": "Haunted Mansion",
"id": 309129,
"families": ["Family"]
},
{
"title": "Haunted Mansion Clue",
"id": 5467,
"families": ["Family"]
},
{
"title": "Haunted Mansion Life",
"id": 80028,
"families": ["Family"]
},
{
"title": "Hive",
"id": 2655
},
{
"title": "Jaipur",
"id": 54043
},
{
"title": "Kanagawa",
"id": 200147
},
{
"title": "Keyforge",
"id": 257501
},
{
"title": "King of Tokyo",
"id": 70323
},
{
"title": "Koi-Koi",
"id": 11865
},
{
"title": "Lanterns",
"id": 160851
},
{
"title": "Love Letter",
"id": 129622
},
{
"title": "Machi Koro",
"id": 143884
},
{
"title": "Mage Knight Board Game",
"id": 96848
},
{
"title": "Magic Maze",
"id": 209778
},
{
"title": "Mr Jack",
"id": 21763
},
{
"title": "Mysterium",
"id": 181304
},
{
"title": "Mysterium Park",
"id": 301767
},
{
"title": "Netrunner",
"id": 124742
},
{
"title": "One Night Ultimate Werewolf",
"id": 147949
},
{
"title": "Pandemic",
"id": 30549
},
{
"title": "Patchwork",
"id": 163412
},
{
"title": "Pathfinder Adventure Card Game",
"id": 133038
},
{
"title": "Power Grid",
"id": 2651
},
{
"title": "Puerto Rico",
"id": 3076
},
{
"title": "Ramen Fury",
"id": 265524
},
{
"title": "Red Cathedral",
"id": 227224
},
{
"title": "Ricochet Robots",
"id": 51
},
{
"title": "Rummikub",
"id": 811
},
{
"title": "Settlers of Catan",
"id": 13
},
{
"title": "Shadows over Camelot",
"id": 15062
},
{
"title": "Star Realms",
"id": 147020
},
{
"title": "Tak",
"id": 197405
},
{
"title": "Through the Ages",
"id": 25613
},
{
"title": "Ticket to Ride Europe",
"id": 14996
},
{
"title": "Timeline",
"id": 85256
},
{
"title": "Tokaido",
"id": 123540
},
{
"title": "Twilight Struggle",
"id": 12333
},
{
"title": "Unmatched",
"id": 274637
},
{
"title": "Uno",
"id": 2223
},
{
"title": "Upwords",
"id": 1515
},
{
"title": "Villainous",
"id": 256382
},
{
"title": "Wingspan",
"id": 266192
}
]

16
main.js Normal file
View File

@ -0,0 +1,16 @@
import { h, render } from 'https://unpkg.com/preact?module';
import * as components from './components.js';
(async function() {
const boardgames = (await (await fetch("./data.json")).json());
boardgames.sort((a, b) => a.rank - b.rank);
let nav = window.location.search.substr(1);
if (nav === "") {
nav = null;
}
render(components.app(boardgames, nav), document.body);
})().then();

104
scripts/fetch.py Normal file
View File

@ -0,0 +1,104 @@
#!/usr/bin/env python3
from pathlib import Path
from util import DiskCache
import html
import json
import re
import urllib.request
import xml.etree.ElementTree as ET
INPUT_PATH = Path(__file__).parent.parent.joinpath("input.json").resolve()
OUTPUT_PATH = Path(__file__).parent.parent.joinpath("data.json").resolve()
CACHE_PATH = Path(__file__).parent.parent.joinpath("cache").resolve()
class BoardGame:
def __init__(self, id, name, year, player_range, time, rank, families, weight, description, mechanics, thumbnail_url, image_url):
self.id = id
self.name = name
self.year = year
self.player_range = player_range
self.time = time
self.rank = rank
self.families = families
self.weight = weight
self.description = description
self.mechanics = mechanics
self.thumbnail_url = thumbnail_url
self.image_url = image_url
@classmethod
def from_bgg_xml(cls, xml_doc):
families = [e.attrib['friendlyname'].split()[0] for e in xml_doc.findall('.//rank[@type="family"]')]
mechanics = [e.attrib['value'] for e in xml_doc.findall('.//link[@type="boardgamemechanic"]')]
return cls(
name=re.split(r'[-]', xml_doc.find(".//name").attrib['value'])[0].strip(),
id=int(xml_doc.find(".//item").attrib['id']),
year=int(xml_doc.find('.//yearpublished').attrib['value']),
player_range=(
int(xml_doc.find('.//minplayers').attrib['value']),
int(xml_doc.find('.//maxplayers').attrib['value']),
),
time=int(xml_doc.find('.//playingtime').attrib['value']),
rank=int(xml_doc.find('.//rank[@type="subtype"]').attrib['value']),
families=families,
weight=float(xml_doc.find('.//averageweight').attrib['value']),
description=html.unescape(xml_doc.find('.//description').text.strip()),
mechanics=mechanics,
thumbnail_url=xml_doc.find('.//thumbnail').text.strip(),
image_url=xml_doc.find('.//image').text.strip()
)
def serialize(self):
return {
'id': self.id,
'title': self.name,
'year': self.year,
'player_range': self.player_range,
'time': self.time,
'rank': self.rank,
'families': self.families,
'description': self.description,
'mechanics': self.mechanics,
'weight': self.weight
}
def fetch_boardgames(data, cache):
boardgames = []
for item in data:
key = f"{item['title']}.xml"
if key not in cache:
url = f"https://api.geekdo.com/xmlapi2/thing?id={item['id']}&stats=1"
with urllib.request.urlopen(url) as response:
cache[key] = response.read()
boardgame = BoardGame.from_bgg_xml(ET.fromstring(cache[key]))
if 'families' in item:
boardgame.families = item['families']
boardgames.append(boardgame)
return boardgames
def cache_images(boardgames, cache):
for boardgame in boardgames:
key = f"{boardgame.id}_thumbnail.jpg"
if key not in cache:
with urllib.request.urlopen(boardgame.thumbnail_url) as response:
cache[key] = response.read()
key = f"{boardgame.id}.jpg"
if key not in cache:
with urllib.request.urlopen(boardgame.image_url) as response:
cache[key] = response.read()
if __name__ == "__main__":
CACHE_PATH.mkdir(parents=True, exist_ok=True)
cache = DiskCache(CACHE_PATH)
with open(INPUT_PATH) as infile:
data = json.load(infile)
boardgames = fetch_boardgames(data, cache)
cache_images(boardgames, cache)
with open(OUTPUT_PATH, 'w') as outfile:
json.dump([game.serialize() for game in boardgames], outfile)

15
scripts/mechanics.py Normal file
View File

@ -0,0 +1,15 @@
import json
from collections import Counter
from pathlib import Path
DATA_PATH = Path(__file__).parent.parent.joinpath("data.json").resolve()
if __name__ == "__main__":
with open(DATA_PATH, 'r') as infile:
data = json.load(infile)
c = Counter()
for game in data:
c.update(game['mechanics'])
print(c)

45
scripts/util.py Normal file
View File

@ -0,0 +1,45 @@
import pathlib
import time
STALE_SECONDS = 60 * 60 * 24 * 1 # one day
class DiskCache:
def __init__(self, root_path):
self.root_path = pathlib.Path(root_path)
self.root_path.mkdir(exist_ok=True)
assert(self.root_path.is_dir())
def __contains__(self, key):
self.clear_stale_files()
return self.root_path.joinpath(key).exists()
def __getitem__(self, key):
try:
with self.root_path.joinpath(key).open('rb') as infile:
return infile.read()
except FileNotFoundError:
raise KeyError(key)
def __setitem__(self, key, value):
try:
with self.root_path.joinpath(key).open('wb') as outfile:
outfile.write(value)
except FileNotFoundError:
raise KeyError(key)
def __delitem__(self, key):
try:
self.root_path.joinpath(key).unlink()
except FileNotFoundError:
raise KeyError(key)
def clear_stale_files(self):
files_to_delete = []
for path in self.root_path.iterdir():
if not path.is_file():
continue
age = time.time() - path.stat().st_mtime
if age >= STALE_SECONDS:
files_to_delete.append(path)
for path in files_to_delete:
path.unlink()

100
style.css Normal file
View File

@ -0,0 +1,100 @@
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;700&display=swap');
html {
font-family: 'Roboto', sans-serif;
color: #707070;
}
.boardgames {
width: 1100px;
margin: 0 auto;
}
.boardgame {
margin-bottom: 12em;
}
@media print {
.boardgames:nth-child(3) {
page-break-after: always;
}
}
.boardgame .stat {
font-size: 24px;
font-weight: 900;
}
.boardgame .weight::before {
content: "🧠 ";
font-size: 42px;
font-weight: bold;
}
.boardgame .time::before {
content: "⏲️ ";
font-size: 42px;
font-weight: bold;
}
.boardgame .players::before {
content: "👥 ";
font-size: 42px;
font-weight: bold;
}
.boardgame img {
width: 250px;
height: 300px;
object-fit: cover;
}
.boardgame .title {
font-size: 42px;
font-weight: bold;
margin: 0;
}
.boardgame .description {
font-size: 18px;
line-height: 24px;
}
.boardgame .mechanics {
font-size: 20px;
font-weight: 300;
}
.boardgame {
display: grid;
grid-template-columns: auto auto auto;
grid-template-rows: auto repeat(3, 1fr) auto;
grid-column-gap: 32px;
grid-row-gap: 8px;
height: 300px;
}
.boardgame img {
grid-area: 1 / 1 / 6 / 2;
}
.boardgame .title {
grid-area: 1 / 2 / 2 / 4;
}
.boardgame .players {
grid-area: 2 / 2 / 3 / 3;
}
.boardgame .time {
grid-area: 3 / 2 / 4 / 3;
}
.boardgame .weight {
grid-area: 4 / 2 / 5 / 3;
}
.boardgame .description {
grid-area: 2 / 3 / 5 / 4;
margin-top: 1em;
}
.boardgame .mechanics {
grid-area: 5 / 2 / 6 / 4;
}