From 148c045358a41bd48605c29baae0ee2ec0487056 Mon Sep 17 00:00:00 2001 From: Builder Date: Tue, 26 May 2026 16:35:47 +0000 Subject: [PATCH 1/8] feat(boardgamegeek): add BoardGameGeek mirror --- Dockerfile | 4 +- control_server.py | 1 + sites/boardgamegeek/_health.py | 3 + sites/boardgamegeek/app.py | 1480 +++++++++++++++++ sites/boardgamegeek/requirements.txt | 9 + sites/boardgamegeek/scrape_bgg.py | 359 ++++ sites/boardgamegeek/scrape_extras.py | 274 +++ sites/boardgamegeek/scrape_low_ratings.py | 127 ++ sites/boardgamegeek/seed_data.py | 1034 ++++++++++++ sites/boardgamegeek/static/css/bgg.css | 422 +++++ .../static/icons/cover_placeholder.svg | 18 + sites/boardgamegeek/static/icons/favicon.svg | 5 + sites/boardgamegeek/tasks.jsonl | 21 + sites/boardgamegeek/templates/about.html | 12 + sites/boardgamegeek/templates/account.html | 21 + sites/boardgamegeek/templates/base.html | 88 + sites/boardgamegeek/templates/browse.html | 67 + sites/boardgamegeek/templates/collection.html | 68 + sites/boardgamegeek/templates/credits.html | 57 + sites/boardgamegeek/templates/expansions.html | 37 + sites/boardgamegeek/templates/forgot.html | 18 + sites/boardgamegeek/templates/forum.html | 41 + .../boardgamegeek/templates/forums_index.html | 31 + .../boardgamegeek/templates/game_forums.html | 31 + sites/boardgamegeek/templates/geeklist.html | 57 + .../boardgamegeek/templates/geeklist_new.html | 18 + sites/boardgamegeek/templates/geeklists.html | 45 + sites/boardgamegeek/templates/help.html | 21 + sites/boardgamegeek/templates/hotness.html | 23 + sites/boardgamegeek/templates/index.html | 134 ++ sites/boardgamegeek/templates/item.html | 243 +++ sites/boardgamegeek/templates/login.html | 21 + sites/boardgamegeek/templates/person.html | 37 + sites/boardgamegeek/templates/plays.html | 29 + sites/boardgamegeek/templates/property.html | 51 + sites/boardgamegeek/templates/publisher.html | 34 + sites/boardgamegeek/templates/ratings.html | 91 + sites/boardgamegeek/templates/register.html | 19 + sites/boardgamegeek/templates/search.html | 91 + .../templates/taxonomy_index.html | 30 + sites/boardgamegeek/templates/thread.html | 67 + sites/boardgamegeek/templates/thread_new.html | 28 + sites/boardgamegeek/templates/user.html | 72 + websyn_start.sh | 3 +- 44 files changed, 5339 insertions(+), 3 deletions(-) create mode 100644 sites/boardgamegeek/_health.py create mode 100644 sites/boardgamegeek/app.py create mode 100644 sites/boardgamegeek/requirements.txt create mode 100644 sites/boardgamegeek/scrape_bgg.py create mode 100644 sites/boardgamegeek/scrape_extras.py create mode 100644 sites/boardgamegeek/scrape_low_ratings.py create mode 100644 sites/boardgamegeek/seed_data.py create mode 100644 sites/boardgamegeek/static/css/bgg.css create mode 100644 sites/boardgamegeek/static/icons/cover_placeholder.svg create mode 100644 sites/boardgamegeek/static/icons/favicon.svg create mode 100644 sites/boardgamegeek/tasks.jsonl create mode 100644 sites/boardgamegeek/templates/about.html create mode 100644 sites/boardgamegeek/templates/account.html create mode 100644 sites/boardgamegeek/templates/base.html create mode 100644 sites/boardgamegeek/templates/browse.html create mode 100644 sites/boardgamegeek/templates/collection.html create mode 100644 sites/boardgamegeek/templates/credits.html create mode 100644 sites/boardgamegeek/templates/expansions.html create mode 100644 sites/boardgamegeek/templates/forgot.html create mode 100644 sites/boardgamegeek/templates/forum.html create mode 100644 sites/boardgamegeek/templates/forums_index.html create mode 100644 sites/boardgamegeek/templates/game_forums.html create mode 100644 sites/boardgamegeek/templates/geeklist.html create mode 100644 sites/boardgamegeek/templates/geeklist_new.html create mode 100644 sites/boardgamegeek/templates/geeklists.html create mode 100644 sites/boardgamegeek/templates/help.html create mode 100644 sites/boardgamegeek/templates/hotness.html create mode 100644 sites/boardgamegeek/templates/index.html create mode 100644 sites/boardgamegeek/templates/item.html create mode 100644 sites/boardgamegeek/templates/login.html create mode 100644 sites/boardgamegeek/templates/person.html create mode 100644 sites/boardgamegeek/templates/plays.html create mode 100644 sites/boardgamegeek/templates/property.html create mode 100644 sites/boardgamegeek/templates/publisher.html create mode 100644 sites/boardgamegeek/templates/ratings.html create mode 100644 sites/boardgamegeek/templates/register.html create mode 100644 sites/boardgamegeek/templates/search.html create mode 100644 sites/boardgamegeek/templates/taxonomy_index.html create mode 100644 sites/boardgamegeek/templates/thread.html create mode 100644 sites/boardgamegeek/templates/thread_new.html create mode 100644 sites/boardgamegeek/templates/user.html diff --git a/Dockerfile b/Dockerfile index 51f61ce7..242175aa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # WebHarbor — slim, self-contained image. -# 20 Flask mirror sites + control plane on :8101. +# 21 Flask mirror sites + control plane on :8101. FROM python:3.12-slim-bookworm @@ -41,6 +41,6 @@ COPY control_server.py /opt/control_server.py COPY site_runner.py /opt/site_runner.py RUN chmod +x /opt/websyn_start.sh -EXPOSE 8101 40000-40019 +EXPOSE 8101 40000-40020 CMD ["/opt/websyn_start.sh"] diff --git a/control_server.py b/control_server.py index bda229c5..df8fb621 100644 --- a/control_server.py +++ b/control_server.py @@ -27,6 +27,7 @@ 'github', 'google_flights', 'google_map', 'google_search', 'huggingface', 'wolfram_alpha', 'cambridge_dictionary', 'coursera', 'espn', 'merriam_webster', 'ikea', 'phys_org', 'target', 'ted', + 'boardgamegeek', ] BASE_PORT = 40000 WEBSYN_DIR = '/opt/WebSyn' diff --git a/sites/boardgamegeek/_health.py b/sites/boardgamegeek/_health.py new file mode 100644 index 00000000..309e2719 --- /dev/null +++ b/sites/boardgamegeek/_health.py @@ -0,0 +1,3 @@ +"""Per-site health probe (optional, called by control_server).""" +def health(): + return {"ok": True, "site": "boardgamegeek"} diff --git a/sites/boardgamegeek/app.py b/sites/boardgamegeek/app.py new file mode 100644 index 00000000..4d155a17 --- /dev/null +++ b/sites/boardgamegeek/app.py @@ -0,0 +1,1480 @@ +"""BoardGameGeek mirror — Flask application. + +Mirrors the look + feel + feature surface of boardgamegeek.com: + +- Top 1000 ranked games (/browse/boardgame[/page/N]) +- Hot list (/hotness) +- Game item page (/boardgame//) with description, stats, polls, + designers, artists, publishers, categories, mechanics, expansions, ratings +- Per-game ratings/reviews page (/boardgame///ratings) +- Per-game credits (/boardgame///credits) +- Browse by mechanic / category / designer / artist / publisher +- Forums + threads + posts (/forums, /forum/, /thread/) +- GeekLists (/geeklists, /geeklist/) +- User profile / collection / wishlist / plays (/user/, /collection/) +- Search (games / users / geeklists) +- Auth (login / register / logout) +- Rate, comment, add-to-collection, wishlist, reply-to-thread, write-review + +Real data comes from sites/boardgamegeek/scraped_data/bgg.json (BGG api.geekdo.com). +Loaded by seed_data.py — idempotent. +""" +import os +import re +from datetime import datetime, timedelta +from urllib.parse import urlparse + +from flask import (Flask, render_template, request, redirect, url_for, + flash, abort, jsonify, send_from_directory, Response) +from flask_sqlalchemy import SQLAlchemy +from flask_login import (LoginManager, UserMixin, login_user, logout_user, + login_required, current_user) +from flask_wtf import FlaskForm +from flask_wtf.csrf import CSRFProtect +from flask_bcrypt import Bcrypt +from wtforms import (StringField, PasswordField, TextAreaField, HiddenField, + IntegerField, SelectField, BooleanField, FloatField) +from wtforms.validators import DataRequired, Length, Optional, Email, NumberRange +from sqlalchemy import or_, and_, desc, asc, func, text +from markupsafe import Markup, escape + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +app = Flask(__name__, instance_path=os.path.join(BASE_DIR, 'instance')) +app.config['SECRET_KEY'] = 'boardgamegeek-mirror-secret-key' +app.config['SQLALCHEMY_DATABASE_URI'] = ( + f"sqlite:///{os.path.join(BASE_DIR, 'instance', 'boardgamegeek.db')}" +) +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +app.config['WTF_CSRF_TIME_LIMIT'] = None + +os.makedirs(os.path.join(BASE_DIR, 'instance'), exist_ok=True) + +db = SQLAlchemy(app) +bcrypt = Bcrypt(app) +login_manager = LoginManager(app) +login_manager.login_view = 'login' +login_manager.login_message = 'Sign in to access that page.' +csrf = CSRFProtect(app) + + +# Pinned reference "now" so that time_ago strings and join dates are stable +# across rebuilds. The byte-identical reset invariant depends on this. +MIRROR_NOW = datetime(2026, 5, 26, 12, 0, 0) + + +# ----- Models ----- + +class User(db.Model, UserMixin): + __tablename__ = 'users' + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False, index=True) + email = db.Column(db.String(200), unique=True, nullable=True, index=True) + password_hash = db.Column(db.String(255), nullable=False) + real_name = db.Column(db.String(200), default='') + country = db.Column(db.String(80), default='') + state = db.Column(db.String(80), default='') + city = db.Column(db.String(80), default='') + isocountry = db.Column(db.String(8), default='') + avatar_filename = db.Column(db.String(200), default='') + about = db.Column(db.Text, default='') + joined_at = db.Column(db.DateTime, default=lambda: MIRROR_NOW) + last_login = db.Column(db.DateTime, default=lambda: MIRROR_NOW) + geekgold = db.Column(db.Integer, default=0) + is_supporter = db.Column(db.Boolean, default=False) + is_admin = db.Column(db.Boolean, default=False) + + @property + def display_location(self): + bits = [self.city, self.state, self.country] + return ', '.join(b for b in bits if b) + + +# Many-to-many association tables (designer/artist/publisher/category/mechanic/family). +game_designers = db.Table('game_designers', + db.Column('game_id', db.Integer, db.ForeignKey('games.id'), primary_key=True), + db.Column('person_id', db.Integer, db.ForeignKey('people.id'), primary_key=True)) +game_artists = db.Table('game_artists', + db.Column('game_id', db.Integer, db.ForeignKey('games.id'), primary_key=True), + db.Column('person_id', db.Integer, db.ForeignKey('people.id'), primary_key=True)) +game_publishers = db.Table('game_publishers', + db.Column('game_id', db.Integer, db.ForeignKey('games.id'), primary_key=True), + db.Column('publisher_id', db.Integer, db.ForeignKey('publishers.id'), primary_key=True)) +game_categories = db.Table('game_categories', + db.Column('game_id', db.Integer, db.ForeignKey('games.id'), primary_key=True), + db.Column('category_id', db.Integer, db.ForeignKey('categories.id'), primary_key=True)) +game_mechanics = db.Table('game_mechanics', + db.Column('game_id', db.Integer, db.ForeignKey('games.id'), primary_key=True), + db.Column('mechanic_id', db.Integer, db.ForeignKey('mechanics.id'), primary_key=True)) +game_families = db.Table('game_families', + db.Column('game_id', db.Integer, db.ForeignKey('games.id'), primary_key=True), + db.Column('family_id', db.Integer, db.ForeignKey('families.id'), primary_key=True)) + + +class Person(db.Model): + """Designer / artist (BGG conflates these under linkdata).""" + __tablename__ = 'people' + id = db.Column(db.Integer, primary_key=True) + bgg_id = db.Column(db.Integer, unique=True, index=True) + name = db.Column(db.String(200), nullable=False, index=True) + slug = db.Column(db.String(200), index=True) + + +class Publisher(db.Model): + __tablename__ = 'publishers' + id = db.Column(db.Integer, primary_key=True) + bgg_id = db.Column(db.Integer, unique=True, index=True) + name = db.Column(db.String(200), nullable=False, index=True) + slug = db.Column(db.String(200), index=True) + + +class Category(db.Model): + __tablename__ = 'categories' + id = db.Column(db.Integer, primary_key=True) + bgg_id = db.Column(db.Integer, unique=True, index=True) + name = db.Column(db.String(120), nullable=False, index=True) + slug = db.Column(db.String(120), index=True) + + +class Mechanic(db.Model): + __tablename__ = 'mechanics' + id = db.Column(db.Integer, primary_key=True) + bgg_id = db.Column(db.Integer, unique=True, index=True) + name = db.Column(db.String(120), nullable=False, index=True) + slug = db.Column(db.String(120), index=True) + + +class Family(db.Model): + """BGG 'family' groupings — Crowdfunding:Kickstarter, Components:Miniatures, etc.""" + __tablename__ = 'families' + id = db.Column(db.Integer, primary_key=True) + bgg_id = db.Column(db.Integer, unique=True, index=True) + name = db.Column(db.String(200), nullable=False, index=True) + slug = db.Column(db.String(200), index=True) + + +class Game(db.Model): + __tablename__ = 'games' + id = db.Column(db.Integer, primary_key=True) + bgg_id = db.Column(db.Integer, unique=True, nullable=False, index=True) + name = db.Column(db.String(500), nullable=False, index=True) + slug = db.Column(db.String(500), nullable=False, index=True) + subtype = db.Column(db.String(40), default='boardgame') # or boardgameexpansion + year_published = db.Column(db.Integer, default=0, index=True) + minplayers = db.Column(db.Integer, default=0) + maxplayers = db.Column(db.Integer, default=0) + minplaytime = db.Column(db.Integer, default=0) + maxplaytime = db.Column(db.Integer, default=0) + minage = db.Column(db.Integer, default=0) + short_description = db.Column(db.Text, default='') + description_html = db.Column(db.Text, default='') + image_filename = db.Column(db.String(200), default='') + thumb_filename = db.Column(db.String(200), default='') + + # Cached stats + avg_rating = db.Column(db.Float, default=0.0, index=True) + bayes_average = db.Column(db.Float, default=0.0, index=True) + weight = db.Column(db.Float, default=0.0, index=True) + weight_votes = db.Column(db.Integer, default=0) + num_ratings = db.Column(db.Integer, default=0) + num_owners = db.Column(db.Integer, default=0) + num_wishing = db.Column(db.Integer, default=0) + num_comments = db.Column(db.Integer, default=0) + overall_rank = db.Column(db.Integer, default=0, index=True) + best_player_count = db.Column(db.String(40), default='') # e.g. '3-4' + recommended_player_count = db.Column(db.String(40), default='') + suggested_age = db.Column(db.String(20), default='') + language_dependence = db.Column(db.String(200), default='') + + # Featured flag for the homepage carousel + featured = db.Column(db.Boolean, default=False) + + designers = db.relationship('Person', secondary=game_designers, backref='designed_games') + artists = db.relationship('Person', secondary=game_artists, backref='illustrated_games') + publishers = db.relationship('Publisher', secondary=game_publishers, backref='games') + categories = db.relationship('Category', secondary=game_categories, backref='games') + mechanics = db.relationship('Mechanic', secondary=game_mechanics, backref='games') + families = db.relationship('Family', secondary=game_families, backref='games') + + @property + def players_str(self): + if self.minplayers == self.maxplayers and self.minplayers: + return f"{self.minplayers}" + if self.minplayers and self.maxplayers: + return f"{self.minplayers}–{self.maxplayers}" + if self.minplayers: + return f"{self.minplayers}+" + return "—" + + @property + def time_str(self): + if self.minplaytime == self.maxplaytime and self.minplaytime: + return f"{self.minplaytime} min" + if self.minplaytime and self.maxplaytime: + return f"{self.minplaytime}–{self.maxplaytime} min" + if self.minplaytime: + return f"{self.minplaytime}+ min" + return "—" + + @property + def weight_label(self): + w = self.weight or 0 + if w == 0: + return 'Unrated' + if w < 1.5: return 'Light' + if w < 2.5: return 'Medium Light' + if w < 3.5: return 'Medium' + if w < 4.2: return 'Medium Heavy' + return 'Heavy' + + @property + def detail_url(self): + return url_for('game_detail', oid=self.bgg_id, slug=self.slug) + + +# Expansions / integrations link table (game -> game) +class GameLink(db.Model): + __tablename__ = 'game_links' + id = db.Column(db.Integer, primary_key=True) + game_id = db.Column(db.Integer, db.ForeignKey('games.id'), index=True, nullable=False) + other_id = db.Column(db.Integer, db.ForeignKey('games.id'), index=True, nullable=False) + kind = db.Column(db.String(40), nullable=False, index=True) + # kind: expansion (other is an expansion of game), + # integration / reimplementation / containedin / contains + + +class Rating(db.Model): + """Numeric rating, optionally with a text review.""" + __tablename__ = 'ratings' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + game_id = db.Column(db.Integer, db.ForeignKey('games.id'), nullable=False, index=True) + value = db.Column(db.Float, nullable=False, index=True) # 1-10 + review_html = db.Column(db.Text, default='') # may be empty + created_at = db.Column(db.DateTime, default=lambda: MIRROR_NOW, index=True) + num_thumbs = db.Column(db.Integer, default=0) # reviewer thumbs + + __table_args__ = (db.UniqueConstraint('user_id', 'game_id'),) + + user = db.relationship('User', backref='ratings') + game = db.relationship('Game', backref='ratings') + + @property + def is_review(self): + return bool(self.review_html and self.review_html.strip()) + + +class Collection(db.Model): + """User's collection entry per game (own/want/wishlist/etc).""" + __tablename__ = 'collections' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + game_id = db.Column(db.Integer, db.ForeignKey('games.id'), nullable=False, index=True) + own = db.Column(db.Boolean, default=False) + prevowned = db.Column(db.Boolean, default=False) + want_to_play = db.Column(db.Boolean, default=False) + want_to_buy = db.Column(db.Boolean, default=False) + wishlist = db.Column(db.Boolean, default=False) + wishlist_priority = db.Column(db.Integer, default=0) + preordered = db.Column(db.Boolean, default=False) + for_trade = db.Column(db.Boolean, default=False) + comment = db.Column(db.Text, default='') + acquired_on = db.Column(db.String(40), default='') + updated_at = db.Column(db.DateTime, default=lambda: MIRROR_NOW) + + __table_args__ = (db.UniqueConstraint('user_id', 'game_id'),) + user = db.relationship('User', backref='collection') + game = db.relationship('Game', backref='collected_by') + + +class Play(db.Model): + __tablename__ = 'plays' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + game_id = db.Column(db.Integer, db.ForeignKey('games.id'), nullable=False, index=True) + played_on = db.Column(db.Date, default=lambda: MIRROR_NOW.date(), index=True) + quantity = db.Column(db.Integer, default=1) + length_minutes = db.Column(db.Integer, default=0) + num_players = db.Column(db.Integer, default=0) + location = db.Column(db.String(200), default='') + comments = db.Column(db.Text, default='') + incomplete = db.Column(db.Boolean, default=False) + no_winstats = db.Column(db.Boolean, default=False) + + user = db.relationship('User', backref='plays') + game = db.relationship('Game', backref='plays') + + +# ----- Forums ----- + +class Forum(db.Model): + __tablename__ = 'forums' + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(200), nullable=False, index=True) + description = db.Column(db.Text, default='') + section = db.Column(db.String(80), index=True) # 'general', 'reviews', 'strategy', etc. + game_id = db.Column(db.Integer, db.ForeignKey('games.id'), nullable=True, index=True) + sort_order = db.Column(db.Integer, default=100) + num_threads = db.Column(db.Integer, default=0) + num_posts = db.Column(db.Integer, default=0) + game = db.relationship('Game', backref='forums') + + +class Thread(db.Model): + __tablename__ = 'threads' + id = db.Column(db.Integer, primary_key=True) + forum_id = db.Column(db.Integer, db.ForeignKey('forums.id'), nullable=False, index=True) + subject = db.Column(db.String(300), nullable=False) + author_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + is_pinned = db.Column(db.Boolean, default=False, index=True) + is_locked = db.Column(db.Boolean, default=False) + is_hot = db.Column(db.Boolean, default=False) + num_posts = db.Column(db.Integer, default=1) + num_views = db.Column(db.Integer, default=0) + created_at = db.Column(db.DateTime, default=lambda: MIRROR_NOW, index=True) + last_post_at = db.Column(db.DateTime, default=lambda: MIRROR_NOW, index=True) + + forum = db.relationship('Forum', backref='threads') + author = db.relationship('User', backref='threads') + + +class Post(db.Model): + __tablename__ = 'posts' + id = db.Column(db.Integer, primary_key=True) + thread_id = db.Column(db.Integer, db.ForeignKey('threads.id'), nullable=False, index=True) + author_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + body_html = db.Column(db.Text, default='') + created_at = db.Column(db.DateTime, default=lambda: MIRROR_NOW, index=True) + edited_at = db.Column(db.DateTime, nullable=True) + thumbs = db.Column(db.Integer, default=0) + + thread = db.relationship('Thread', backref='posts') + author = db.relationship('User', backref='posts') + + +# ----- GeekLists ----- + +class GeekList(db.Model): + __tablename__ = 'geeklists' + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(300), nullable=False, index=True) + description_html = db.Column(db.Text, default='') + author_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + created_at = db.Column(db.DateTime, default=lambda: MIRROR_NOW, index=True) + num_thumbs = db.Column(db.Integer, default=0, index=True) + num_items = db.Column(db.Integer, default=0) + + author = db.relationship('User', backref='geeklists') + + +class GeekListItem(db.Model): + __tablename__ = 'geeklist_items' + id = db.Column(db.Integer, primary_key=True) + list_id = db.Column(db.Integer, db.ForeignKey('geeklists.id'), nullable=False, index=True) + game_id = db.Column(db.Integer, db.ForeignKey('games.id'), nullable=True, index=True) + body_html = db.Column(db.Text, default='') + position = db.Column(db.Integer, default=0) + num_thumbs = db.Column(db.Integer, default=0) + + geeklist = db.relationship('GeekList', backref='items') + game = db.relationship('Game') + + +# ----- Thumbs (likes on posts/reviews/geeklists) ----- + +class Thumb(db.Model): + __tablename__ = 'thumbs' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + kind = db.Column(db.String(20), nullable=False, index=True) # post|rating|geeklist|geeklist_item + target_id = db.Column(db.Integer, nullable=False, index=True) + __table_args__ = (db.UniqueConstraint('user_id', 'kind', 'target_id'),) + + +# ----- Forms ----- + +class LoginForm(FlaskForm): + username = StringField('Username', validators=[DataRequired(), Length(2, 80)]) + password = PasswordField('Password', validators=[DataRequired()]) + + +class RegisterForm(FlaskForm): + username = StringField('Username', validators=[DataRequired(), Length(3, 80)]) + email = StringField('Email', validators=[DataRequired(), Email(), Length(3, 200)]) + password = PasswordField('Password', validators=[DataRequired(), Length(6, 128)]) + real_name = StringField('Real name', validators=[Optional(), Length(0, 200)]) + country = StringField('Country', validators=[Optional(), Length(0, 80)]) + + +class ProfileForm(FlaskForm): + real_name = StringField('Real name', validators=[Optional(), Length(0, 200)]) + country = StringField('Country', validators=[Optional(), Length(0, 80)]) + state = StringField('State', validators=[Optional(), Length(0, 80)]) + city = StringField('City', validators=[Optional(), Length(0, 80)]) + about = TextAreaField('About', validators=[Optional(), Length(0, 4000)]) + + +class RatingForm(FlaskForm): + value = FloatField('Rating', validators=[DataRequired(), NumberRange(1, 10)]) + review = TextAreaField('Review', validators=[Optional(), Length(0, 20000)]) + + +class CollectionForm(FlaskForm): + own = BooleanField('Own', default=False) + prevowned = BooleanField('Previously Owned', default=False) + want_to_play = BooleanField('Want To Play', default=False) + want_to_buy = BooleanField('Want To Buy', default=False) + wishlist = BooleanField('Wishlist', default=False) + wishlist_priority = SelectField('Priority', choices=[ + ('0','Not on wishlist'), + ('1','Must have'),('2','Love to have'), + ('3','Like to have'),('4','Thinking about it'), + ('5','Dont buy this'), + ], default='0') + preordered = BooleanField('Pre-ordered', default=False) + for_trade = BooleanField('For Trade', default=False) + comment = TextAreaField('Comment', validators=[Optional(), Length(0, 4000)]) + acquired_on = StringField('Acquired on', validators=[Optional(), Length(0, 40)]) + + +class ThreadForm(FlaskForm): + subject = StringField('Subject', validators=[DataRequired(), Length(2, 300)]) + body = TextAreaField('Post', validators=[DataRequired(), Length(1, 20000)]) + + +class PostForm(FlaskForm): + body = TextAreaField('Reply', validators=[DataRequired(), Length(1, 20000)]) + parent_id = HiddenField() + + +class GeekListForm(FlaskForm): + title = StringField('Title', validators=[DataRequired(), Length(3, 300)]) + description = TextAreaField('Description', validators=[Optional(), Length(0, 8000)]) + + +class PlayForm(FlaskForm): + played_on = StringField('Date (YYYY-MM-DD)', validators=[DataRequired(), Length(10, 10)]) + quantity = IntegerField('Quantity', validators=[Optional(), NumberRange(1, 50)], default=1) + length_minutes = IntegerField('Length (min)', validators=[Optional(), NumberRange(0, 1200)], default=0) + num_players = IntegerField('Players', validators=[Optional(), NumberRange(0, 20)], default=0) + location = StringField('Location', validators=[Optional(), Length(0, 200)]) + comments = TextAreaField('Comments', validators=[Optional(), Length(0, 4000)]) + + +# ----- Auth ----- + +@login_manager.user_loader +def load_user(uid): + return db.session.get(User, int(uid)) + + +# ----- Helpers ----- + +STOP_WORDS = {'the','a','an','in','on','at','to','for','of','and','or', + 'is','it','by','with','as','be','this','that','are','was', + 'were','from','how','what','why','we','i','you','they', + 'about','vs','game'} + + +def tokenize(query: str): + return [t.lower() for t in re.split(r'\W+', query or '') + if t.lower() not in STOP_WORDS and len(t) > 1] + + +def _safe_next(target: str | None, fallback: str) -> str: + if not target: + return fallback + parsed = urlparse(target) + if parsed.scheme or parsed.netloc: + return fallback + if not target.startswith('/'): + return fallback + return target + + +def slugify(s: str) -> str: + s = s.lower() + s = re.sub(r'[^a-z0-9]+', '-', s).strip('-') + return s or 'item' + + +def _time_ago(dt) -> str: + if not dt: + return '' + if isinstance(dt, str): + try: + dt = datetime.fromisoformat(dt) + except Exception: + return dt + delta = MIRROR_NOW - dt + secs = int(delta.total_seconds()) + if secs < 60: + return 'just now' + if secs < 3600: + m = secs // 60 + return f"{m} minute{'s' if m != 1 else ''} ago" + if secs < 86400: + h = secs // 3600 + return f"{h} hour{'s' if h != 1 else ''} ago" + d = secs // 86400 + if d < 30: + return f"{d} day{'s' if d != 1 else ''} ago" + if d < 365: + mo = d // 30 + return f"{mo} month{'s' if mo != 1 else ''} ago" + y = d // 365 + return f"{y} year{'s' if y != 1 else ''} ago" + + +@app.template_filter('time_ago') +def _tpl_time_ago(dt): + return _time_ago(dt) + + +@app.template_filter('rating_color') +def _tpl_rating_color(value): + """Hex color for a BGG-style rating chip (1-10).""" + try: + v = float(value or 0) + except (TypeError, ValueError): + v = 0 + if v >= 9.0: return '#249563' + if v >= 8.0: return '#2fc482' + if v >= 7.0: return '#1d8acd' + if v >= 6.0: return '#5369a2' + if v >= 5.0: return '#5d69a3' + if v >= 4.0: return '#df4751' + if v >= 3.0: return '#df4751' + if v >= 2.0: return '#db303f' + if v >= 1.0: return '#8c2317' + return '#a0a0a0' + + +@app.template_filter('safe_html') +def _tpl_safe_html(text): + # We trust the seed data (it's static), so render as-is. + return Markup(text or '') + + +@app.template_filter('one_decimal') +def _tpl_one_decimal(v): + try: + return f"{float(v):.1f}" + except (TypeError, ValueError): + return '—' + + +@app.template_filter('two_decimal') +def _tpl_two_decimal(v): + try: + return f"{float(v):.2f}" + except (TypeError, ValueError): + return '—' + + +@app.template_filter('thousands') +def _tpl_thousands(v): + try: + return f"{int(v):,}" + except (TypeError, ValueError): + return '0' + + +@app.context_processor +def inject_globals(): + return { + 'site_name': 'BoardGameGeek', + 'mirror_now': MIRROR_NOW, + 'current_year': MIRROR_NOW.year, + } + + +def _scored_game_search(query: str, page: int = 1, per_page: int = 30): + tokens = tokenize(query) + if not tokens: + return [], 0 + conds = [] + for t in tokens: + like = f"%{t}%" + conds.append(Game.name.ilike(like)) + conds.append(Game.short_description.ilike(like)) + base = Game.query.filter(or_(*conds)) + cands = base.limit(3000).all() + scored = [] + for g in cands: + hay_title = (g.name or '').lower() + hay_other = (g.short_description or '').lower() + score = 0 + for t in tokens: + if t in hay_title: + score += 5 + hay_title.count(t) + if t in hay_other: + score += hay_other.count(t) + if g.bayes_average: + score += min(int(g.bayes_average), 10) * 0.1 + if score > 0: + scored.append((g, score)) + scored.sort(key=lambda x: -x[1]) + total = len(scored) + start = (page - 1) * per_page + items = [s[0] for s in scored[start:start + per_page]] + return items, total + + +# ----- Routes: home / browse ----- + +@app.route('/') +def index(): + hot_games = Game.query.filter(Game.featured == True).order_by(Game.overall_rank.asc()).limit(15).all() + if len(hot_games) < 12: + # Fallback: top by rank + extra = Game.query.filter(Game.overall_rank > 0).order_by(Game.overall_rank.asc()).limit(15).all() + seen = {g.id for g in hot_games} + for g in extra: + if g.id not in seen: + hot_games.append(g) + if len(hot_games) >= 15: + break + top_overall = Game.query.filter(Game.overall_rank > 0) \ + .order_by(Game.overall_rank.asc()).limit(10).all() + recent_lists = GeekList.query.order_by(GeekList.created_at.desc()).limit(8).all() + active_threads = Thread.query.order_by(Thread.last_post_at.desc()).limit(10).all() + recent_reviews = Rating.query.filter(Rating.review_html != '') \ + .order_by(Rating.created_at.desc()).limit(8).all() + return render_template('index.html', + hot_games=hot_games, + top_overall=top_overall, + recent_lists=recent_lists, + active_threads=active_threads, + recent_reviews=recent_reviews) + + +@app.route('/browse/boardgame') +@app.route('/browse/boardgame/page/') +def browse(page=1): + sort = request.args.get('sort', 'rank') + direction = request.args.get('dir', 'asc') + page = max(1, page) + per_page = 100 + q = Game.query.filter(Game.subtype == 'boardgame') + if sort == 'rank': + q = q.filter(Game.overall_rank > 0) + q = q.order_by(Game.overall_rank.asc() if direction == 'asc' else Game.overall_rank.desc()) + elif sort == 'name': + q = q.order_by(Game.name.asc() if direction == 'asc' else Game.name.desc()) + elif sort == 'year': + q = q.order_by(Game.year_published.desc() if direction == 'desc' else Game.year_published.asc()) + elif sort == 'average': + q = q.order_by(Game.avg_rating.desc() if direction == 'desc' else Game.avg_rating.asc()) + elif sort == 'numvoters': + q = q.order_by(Game.num_ratings.desc() if direction == 'desc' else Game.num_ratings.asc()) + elif sort == 'weight': + q = q.order_by(Game.weight.desc() if direction == 'desc' else Game.weight.asc()) + else: + q = q.order_by(Game.overall_rank.asc()) + total = q.count() + games = q.limit(per_page).offset((page - 1) * per_page).all() + start_rank = (page - 1) * per_page + 1 + return render_template('browse.html', + games=games, page=page, per_page=per_page, + total=total, sort=sort, direction=direction, + start_rank=start_rank, + has_next=page * per_page < total, + has_prev=page > 1) + + +@app.route('/hotness') +@app.route('/hot') +def hotness(): + games = Game.query.filter(Game.featured == True) \ + .order_by(Game.overall_rank.asc()).limit(50).all() + if not games: + games = Game.query.filter(Game.overall_rank > 0) \ + .order_by(Game.overall_rank.asc()).limit(50).all() + return render_template('hotness.html', games=games) + + +# ----- Routes: game detail + sub-pages ----- + +def _get_game_or_404(oid: int, slug: str | None): + g = Game.query.filter_by(bgg_id=oid).first() + if not g: + abort(404) + # Don't enforce slug match — agents may navigate by id alone. + return g + + +@app.route('/boardgame/') +@app.route('/boardgame//') +def game_detail(oid, slug=None): + g = _get_game_or_404(oid, slug) + # Build expansions + integrations from GameLink + expansions = [] + for link in GameLink.query.filter_by(game_id=g.id, kind='expansion').all(): + other = db.session.get(Game, link.other_id) + if other: + expansions.append(other) + integrations = [] + for link in GameLink.query.filter_by(game_id=g.id, kind='integration').all(): + other = db.session.get(Game, link.other_id) + if other: + integrations.append(other) + reviews = (Rating.query.filter_by(game_id=g.id) + .filter(Rating.review_html != '') + .order_by(Rating.num_thumbs.desc(), Rating.value.desc()) + .limit(5).all()) + related_forums = Forum.query.filter_by(game_id=g.id).order_by(Forum.sort_order).all() + threads = [] + if related_forums: + forum_ids = [f.id for f in related_forums] + threads = (Thread.query.filter(Thread.forum_id.in_(forum_ids)) + .order_by(Thread.last_post_at.desc()).limit(8).all()) + # Cached user state + my_rating = None + my_collection = None + if current_user.is_authenticated: + my_rating = Rating.query.filter_by(user_id=current_user.id, game_id=g.id).first() + my_collection = Collection.query.filter_by(user_id=current_user.id, game_id=g.id).first() + return render_template('item.html', g=g, + expansions=expansions, integrations=integrations, + reviews=reviews, related_forums=related_forums, + threads=threads, + my_rating=my_rating, my_collection=my_collection, + rating_form=RatingForm(), + collection_form=CollectionForm()) + + +@app.route('/boardgame///ratings') +@app.route('/boardgame//ratings') +def game_ratings(oid, slug=None): + g = _get_game_or_404(oid, slug) + sort = request.args.get('sort', 'rating') + page = max(1, request.args.get('p', 1, type=int)) + per_page = 50 + q = Rating.query.filter_by(game_id=g.id) + if sort == 'rating': + q = q.order_by(Rating.value.desc(), Rating.num_thumbs.desc()) + elif sort == 'lowest': + q = q.order_by(Rating.value.asc()) + elif sort == 'recent': + q = q.order_by(Rating.created_at.desc()) + elif sort == 'thumbs': + q = q.order_by(Rating.num_thumbs.desc(), Rating.value.desc()) + else: + q = q.order_by(Rating.value.desc()) + total = q.count() + ratings = q.limit(per_page).offset((page - 1) * per_page).all() + # Histogram + histogram = {i: 0 for i in range(1, 11)} + for r in Rating.query.filter_by(game_id=g.id).all(): + bucket = max(1, min(10, int(round(r.value or 0)))) + histogram[bucket] += 1 + return render_template('ratings.html', g=g, ratings=ratings, + sort=sort, page=page, per_page=per_page, + total=total, histogram=histogram, + has_next=page * per_page < total, + has_prev=page > 1) + + +@app.route('/boardgame///credits') +@app.route('/boardgame//credits') +def game_credits(oid, slug=None): + g = _get_game_or_404(oid, slug) + return render_template('credits.html', g=g) + + +@app.route('/boardgame///expansions') +@app.route('/boardgame//expansions') +def game_expansions(oid, slug=None): + g = _get_game_or_404(oid, slug) + rows = [] + for link in GameLink.query.filter_by(game_id=g.id, kind='expansion').all(): + other = db.session.get(Game, link.other_id) + if other: + rows.append(other) + rows.sort(key=lambda o: (o.year_published or 9999, o.name)) + return render_template('expansions.html', g=g, expansions=rows) + + +@app.route('/boardgame///forums') +@app.route('/boardgame//forums') +def game_forums(oid, slug=None): + g = _get_game_or_404(oid, slug) + forums = Forum.query.filter_by(game_id=g.id).order_by(Forum.sort_order).all() + return render_template('game_forums.html', g=g, forums=forums) + + +# ----- Browse by category / mechanic / designer / artist / publisher / family ----- + +def _browse_by(entity_query, page_url_name, header_label, slug_url=None, + entity=None, page=1, per_page=50, sort='rank'): + games = entity.games if entity else [] + if sort == 'rank': + games = sorted(games, key=lambda g: (g.overall_rank or 99999, -(g.bayes_average or 0))) + elif sort == 'average': + games = sorted(games, key=lambda g: -(g.avg_rating or 0)) + elif sort == 'year': + games = sorted(games, key=lambda g: -(g.year_published or 0)) + elif sort == 'name': + games = sorted(games, key=lambda g: g.name.lower()) + total = len(games) + start = (page - 1) * per_page + page_items = games[start:start + per_page] + return page_items, total + + +@app.route('/boardgamecategory/') +@app.route('/boardgamecategory//') +def category_detail(cid, slug=None): + c = Category.query.filter_by(bgg_id=cid).first_or_404() + page = max(1, request.args.get('p', 1, type=int)) + sort = request.args.get('sort', 'rank') + games, total = _browse_by(None, 'category_detail', 'Category', + entity=c, page=page, sort=sort) + return render_template('property.html', kind='category', + entity=c, games=games, page=page, total=total, + sort=sort, has_next=page * 50 < total) + + +@app.route('/boardgamemechanic/') +@app.route('/boardgamemechanic//') +def mechanic_detail(mid, slug=None): + m = Mechanic.query.filter_by(bgg_id=mid).first_or_404() + page = max(1, request.args.get('p', 1, type=int)) + sort = request.args.get('sort', 'rank') + games, total = _browse_by(None, 'mechanic_detail', 'Mechanism', + entity=m, page=page, sort=sort) + return render_template('property.html', kind='mechanic', + entity=m, games=games, page=page, total=total, + sort=sort, has_next=page * 50 < total) + + +@app.route('/boardgamedesigner/') +@app.route('/boardgamedesigner//') +def designer_detail(pid, slug=None): + p = Person.query.filter_by(bgg_id=pid).first_or_404() + games = p.designed_games + sort = request.args.get('sort', 'rank') + if sort == 'rank': + games = sorted(games, key=lambda g: (g.overall_rank or 99999)) + elif sort == 'year': + games = sorted(games, key=lambda g: -(g.year_published or 0)) + elif sort == 'average': + games = sorted(games, key=lambda g: -(g.avg_rating or 0)) + elif sort == 'name': + games = sorted(games, key=lambda g: g.name.lower()) + return render_template('person.html', kind='designer', person=p, + games=games, sort=sort) + + +@app.route('/boardgameartist/') +@app.route('/boardgameartist//') +def artist_detail(pid, slug=None): + p = Person.query.filter_by(bgg_id=pid).first_or_404() + games = p.illustrated_games + sort = request.args.get('sort', 'rank') + if sort == 'rank': + games = sorted(games, key=lambda g: (g.overall_rank or 99999)) + elif sort == 'year': + games = sorted(games, key=lambda g: -(g.year_published or 0)) + elif sort == 'average': + games = sorted(games, key=lambda g: -(g.avg_rating or 0)) + elif sort == 'name': + games = sorted(games, key=lambda g: g.name.lower()) + return render_template('person.html', kind='artist', person=p, + games=games, sort=sort) + + +@app.route('/boardgamepublisher/') +@app.route('/boardgamepublisher//') +def publisher_detail(pid, slug=None): + p = Publisher.query.filter_by(bgg_id=pid).first_or_404() + games = p.games + sort = request.args.get('sort', 'rank') + if sort == 'rank': + games = sorted(games, key=lambda g: (g.overall_rank or 99999)) + elif sort == 'year': + games = sorted(games, key=lambda g: -(g.year_published or 0)) + elif sort == 'average': + games = sorted(games, key=lambda g: -(g.avg_rating or 0)) + return render_template('publisher.html', publisher=p, games=games, sort=sort) + + +# ----- Index pages for taxonomies ----- + +@app.route('/boardgamecategory') +def categories_index(): + cats = Category.query.order_by(Category.name).all() + return render_template('taxonomy_index.html', kind='category', + title='Board Game Categories', + items=cats, url_name='category_detail') + + +@app.route('/boardgamemechanic') +def mechanics_index(): + mechs = Mechanic.query.order_by(Mechanic.name).all() + return render_template('taxonomy_index.html', kind='mechanic', + title='Board Game Mechanisms', + items=mechs, url_name='mechanic_detail') + + +@app.route('/boardgamedesigner') +def designers_index(): + q = (request.args.get('q') or '').strip() + query = Person.query + if q: + query = query.filter(Person.name.ilike(f'%{q}%')) + people = query.order_by(Person.name).all() + return render_template('taxonomy_index.html', kind='designer', + title='Board Game Designers', + items=people, url_name='designer_detail', + filter_q=q) + + +@app.route('/boardgamepublisher') +def publishers_index(): + q = (request.args.get('q') or '').strip() + query = Publisher.query + if q: + query = query.filter(Publisher.name.ilike(f'%{q}%')) + pubs = query.order_by(Publisher.name).all() + return render_template('taxonomy_index.html', kind='publisher', + title='Board Game Publishers', + items=pubs, url_name='publisher_detail', + filter_q=q) + + +# ----- Search ----- + +@app.route('/search') +def search(): + q = (request.args.get('q') or '').strip() + tab = request.args.get('type', 'boardgame') + page = max(1, request.args.get('p', 1, type=int)) + per_page = 30 + if not q: + return render_template('search.html', q='', tab=tab, total=0, + games=[], users=[], lists=[], page=page, has_next=False) + if tab == 'user': + like = f"%{q}%" + base = User.query.filter(or_(User.username.ilike(like), + User.real_name.ilike(like))) + total = base.count() + users = base.order_by(User.username.asc()).limit(per_page).offset((page-1)*per_page).all() + return render_template('search.html', q=q, tab=tab, total=total, + games=[], users=users, lists=[], + page=page, has_next=page * per_page < total) + if tab == 'geeklist': + like = f"%{q}%" + base = GeekList.query.filter(GeekList.title.ilike(like)) + total = base.count() + lists = base.order_by(GeekList.num_thumbs.desc()).limit(per_page).offset((page-1)*per_page).all() + return render_template('search.html', q=q, tab=tab, total=total, + games=[], users=[], lists=lists, + page=page, has_next=page * per_page < total) + # default: boardgame + games, total = _scored_game_search(q, page=page, per_page=per_page) + return render_template('search.html', q=q, tab=tab, total=total, + games=games, users=[], lists=[], + page=page, has_next=page * per_page < total) + + +@app.route('/geeksearch.php') +def legacy_search(): + return redirect(url_for('search', q=request.args.get('q', ''), type=request.args.get('action','boardgame'))) + + +# ----- Forums ----- + +@app.route('/forums') +def forums_index(): + # Group forums by section + sections = {} + for f in Forum.query.filter(Forum.game_id.is_(None)).order_by(Forum.sort_order).all(): + sections.setdefault(f.section or 'General', []).append(f) + return render_template('forums_index.html', sections=sections) + + +@app.route('/forum/') +def forum_detail(fid): + f = db.session.get(Forum, fid) or abort(404) + page = max(1, request.args.get('p', 1, type=int)) + per_page = 50 + q = Thread.query.filter_by(forum_id=fid) \ + .order_by(Thread.is_pinned.desc(), Thread.last_post_at.desc()) + total = q.count() + threads = q.limit(per_page).offset((page - 1) * per_page).all() + return render_template('forum.html', forum=f, threads=threads, + page=page, total=total, + has_next=page * per_page < total) + + +@app.route('/thread/') +def thread_detail(tid): + t = db.session.get(Thread, tid) or abort(404) + posts = Post.query.filter_by(thread_id=tid) \ + .order_by(Post.created_at.asc()).all() + return render_template('thread.html', thread=t, posts=posts, + reply_form=PostForm()) + + +@app.route('/thread//reply', methods=['POST']) +@login_required +def thread_reply(tid): + t = db.session.get(Thread, tid) or abort(404) + form = PostForm() + if form.validate_on_submit() and not t.is_locked: + p = Post(thread_id=tid, author_id=current_user.id, + body_html=escape_paragraphs(form.body.data), + created_at=MIRROR_NOW) + db.session.add(p) + t.num_posts = (t.num_posts or 0) + 1 + t.last_post_at = MIRROR_NOW + f = db.session.get(Forum, t.forum_id) + if f: + f.num_posts = (f.num_posts or 0) + 1 + db.session.commit() + flash('Reply posted.', 'success') + elif t.is_locked: + flash('Thread is locked.', 'error') + else: + flash('Reply text is required.', 'error') + return redirect(url_for('thread_detail', tid=tid) + f'#post-{Post.query.order_by(Post.id.desc()).first().id}' if Post.query.filter_by(thread_id=tid).count() else url_for('thread_detail', tid=tid)) + + +@app.route('/forum//new', methods=['GET', 'POST']) +@login_required +def thread_new(fid): + f = db.session.get(Forum, fid) or abort(404) + form = ThreadForm() + if form.validate_on_submit(): + t = Thread(forum_id=fid, subject=form.subject.data.strip(), + author_id=current_user.id, + created_at=MIRROR_NOW, last_post_at=MIRROR_NOW, num_posts=1) + db.session.add(t) + db.session.flush() + p = Post(thread_id=t.id, author_id=current_user.id, + body_html=escape_paragraphs(form.body.data), + created_at=MIRROR_NOW) + db.session.add(p) + f.num_threads = (f.num_threads or 0) + 1 + f.num_posts = (f.num_posts or 0) + 1 + db.session.commit() + flash('Thread posted.', 'success') + return redirect(url_for('thread_detail', tid=t.id)) + return render_template('thread_new.html', forum=f, form=form) + + +# ----- GeekLists ----- + +@app.route('/geeklists') +def geeklists_index(): + sort = request.args.get('sort', 'recent') + page = max(1, request.args.get('p', 1, type=int)) + per_page = 20 + q = GeekList.query + if sort == 'thumbs': + q = q.order_by(GeekList.num_thumbs.desc()) + elif sort == 'items': + q = q.order_by(GeekList.num_items.desc()) + else: + q = q.order_by(GeekList.created_at.desc()) + total = q.count() + items = q.limit(per_page).offset((page - 1) * per_page).all() + return render_template('geeklists.html', lists=items, page=page, + total=total, sort=sort, + has_next=page * per_page < total) + + +@app.route('/geeklist/') +def geeklist_detail(lid): + l = db.session.get(GeekList, lid) or abort(404) + items = GeekListItem.query.filter_by(list_id=lid) \ + .order_by(GeekListItem.position.asc()).all() + return render_template('geeklist.html', l=l, items=items) + + +@app.route('/geeklist/new', methods=['GET', 'POST']) +@login_required +def geeklist_new(): + form = GeekListForm() + if form.validate_on_submit(): + l = GeekList(title=form.title.data.strip(), + description_html=escape_paragraphs(form.description.data or ''), + author_id=current_user.id, created_at=MIRROR_NOW, + num_items=0, num_thumbs=0) + db.session.add(l) + db.session.commit() + flash('GeekList created.', 'success') + return redirect(url_for('geeklist_detail', lid=l.id)) + return render_template('geeklist_new.html', form=form) + + +@app.route('/geeklist//add', methods=['POST']) +@login_required +def geeklist_add_item(lid): + l = db.session.get(GeekList, lid) or abort(404) + if l.author_id != current_user.id: + abort(403) + bgg_id = request.form.get('bgg_id', type=int) + body = request.form.get('body', '').strip() + g = Game.query.filter_by(bgg_id=bgg_id).first() + if not g: + flash('Game not found.', 'error') + return redirect(url_for('geeklist_detail', lid=lid)) + pos = (l.num_items or 0) + 1 + item = GeekListItem(list_id=lid, game_id=g.id, body_html=escape_paragraphs(body), + position=pos, num_thumbs=0) + db.session.add(item) + l.num_items = pos + db.session.commit() + flash('Added to list.', 'success') + return redirect(url_for('geeklist_detail', lid=lid)) + + +# ----- User ----- + +@app.route('/user/') +def user_profile(username): + u = User.query.filter_by(username=username).first_or_404() + own = Collection.query.filter_by(user_id=u.id, own=True).count() + want = Collection.query.filter_by(user_id=u.id, want_to_buy=True).count() + wishlist = Collection.query.filter_by(user_id=u.id, wishlist=True).count() + plays_count = Play.query.filter_by(user_id=u.id).count() + rated = Rating.query.filter_by(user_id=u.id).count() + reviews = Rating.query.filter_by(user_id=u.id).filter(Rating.review_html != '').count() + recent_plays = Play.query.filter_by(user_id=u.id).order_by(Play.played_on.desc()).limit(5).all() + top_rated = Rating.query.filter_by(user_id=u.id).order_by(Rating.value.desc()).limit(10).all() + geeklists = GeekList.query.filter_by(author_id=u.id).order_by(GeekList.created_at.desc()).limit(5).all() + return render_template('user.html', u=u, + own=own, want=want, wishlist=wishlist, + plays_count=plays_count, rated=rated, reviews=reviews, + recent_plays=recent_plays, top_rated=top_rated, + geeklists=geeklists) + + +@app.route('/collection/') +def collection_detail(username): + u = User.query.filter_by(username=username).first_or_404() + status = request.args.get('status', 'own') + sort = request.args.get('sort', 'name') + q = Collection.query.filter_by(user_id=u.id) + if status == 'own': + q = q.filter_by(own=True) + elif status == 'prevowned': + q = q.filter_by(prevowned=True) + elif status == 'wishlist': + q = q.filter_by(wishlist=True) + elif status == 'wanttoplay': + q = q.filter_by(want_to_play=True) + elif status == 'wanttobuy': + q = q.filter_by(want_to_buy=True) + elif status == 'preorder': + q = q.filter_by(preordered=True) + elif status == 'fortrade': + q = q.filter_by(for_trade=True) + elif status == 'rated': + rated_game_ids = [r.game_id for r in Rating.query.filter_by(user_id=u.id).all()] + q = q.filter(Collection.game_id.in_(rated_game_ids)) + entries = q.all() + # Join with Game for sorting/display + games_by_entry = [] + for e in entries: + g = db.session.get(Game, e.game_id) + if g: + rating = Rating.query.filter_by(user_id=u.id, game_id=g.id).first() + games_by_entry.append({'entry': e, 'game': g, 'rating': rating}) + if sort == 'name': + games_by_entry.sort(key=lambda x: x['game'].name.lower()) + elif sort == 'rating': + games_by_entry.sort(key=lambda x: -(x['rating'].value if x['rating'] else 0)) + elif sort == 'rank': + games_by_entry.sort(key=lambda x: x['game'].overall_rank or 99999) + elif sort == 'year': + games_by_entry.sort(key=lambda x: -(x['game'].year_published or 0)) + elif sort == 'recent': + # Most-recently-updated collection entry first. + games_by_entry.sort(key=lambda x: x['entry'].updated_at or MIRROR_NOW, reverse=True) + elif sort == 'acquired': + games_by_entry.sort(key=lambda x: x['entry'].acquired_on or '', reverse=True) + return render_template('collection.html', u=u, entries=games_by_entry, + status=status, sort=sort, total=len(games_by_entry)) + + +@app.route('/plays/') +def plays_detail(username): + u = User.query.filter_by(username=username).first_or_404() + plays = Play.query.filter_by(user_id=u.id).order_by(Play.played_on.desc()).limit(200).all() + plays_with_game = [(p, db.session.get(Game, p.game_id)) for p in plays] + return render_template('plays.html', u=u, plays=plays_with_game) + + +@app.route('/account', methods=['GET', 'POST']) +@login_required +def account(): + form = ProfileForm() + if request.method == 'POST' and form.validate_on_submit(): + current_user.real_name = form.real_name.data + current_user.country = form.country.data + current_user.state = form.state.data + current_user.city = form.city.data + current_user.about = form.about.data + db.session.commit() + flash('Profile updated.', 'success') + return redirect(url_for('user_profile', username=current_user.username)) + form.real_name.data = current_user.real_name + form.country.data = current_user.country + form.state.data = current_user.state + form.city.data = current_user.city + form.about.data = current_user.about + return render_template('account.html', form=form) + + +# ----- Rate / collection mutations ----- + +@app.route('/rate/', methods=['POST']) +@login_required +def rate(oid): + g = Game.query.filter_by(bgg_id=oid).first_or_404() + form = RatingForm() + if not form.validate_on_submit(): + flash('Rating must be between 1.0 and 10.0.', 'error') + return redirect(url_for('game_detail', oid=oid, slug=g.slug)) + r = Rating.query.filter_by(user_id=current_user.id, game_id=g.id).first() + if not r: + r = Rating(user_id=current_user.id, game_id=g.id, + value=form.value.data, review_html=escape_paragraphs(form.review.data or ''), + created_at=MIRROR_NOW) + db.session.add(r) + else: + r.value = form.value.data + r.review_html = escape_paragraphs(form.review.data or '') + r.created_at = MIRROR_NOW + # Recompute aggregate (cheap on the seeded scale) + ratings = [x.value for x in Rating.query.filter_by(game_id=g.id).all()] + [form.value.data] + g.num_ratings = max(g.num_ratings or 0, len(ratings)) + if ratings: + g.avg_rating = sum(ratings) / len(ratings) + db.session.commit() + flash(f'You rated {g.name}: {form.value.data:.1f}.', 'success') + return redirect(url_for('game_detail', oid=oid, slug=g.slug)) + + +@app.route('/collection/save/', methods=['POST']) +@login_required +def collection_save(oid): + g = Game.query.filter_by(bgg_id=oid).first_or_404() + form = CollectionForm() + if not form.validate_on_submit(): + flash('Bad form.', 'error') + return redirect(url_for('game_detail', oid=oid, slug=g.slug)) + e = Collection.query.filter_by(user_id=current_user.id, game_id=g.id).first() + if not e: + e = Collection(user_id=current_user.id, game_id=g.id) + db.session.add(e) + e.own = form.own.data + e.prevowned = form.prevowned.data + e.want_to_play = form.want_to_play.data + e.want_to_buy = form.want_to_buy.data + e.wishlist = form.wishlist.data + e.wishlist_priority = int(form.wishlist_priority.data or 0) + e.preordered = form.preordered.data + e.for_trade = form.for_trade.data + e.comment = form.comment.data + e.acquired_on = form.acquired_on.data + e.updated_at = MIRROR_NOW + db.session.commit() + flash(f'Collection updated for {g.name}.', 'success') + return redirect(url_for('game_detail', oid=oid, slug=g.slug)) + + +@app.route('/collection/remove/', methods=['POST']) +@login_required +def collection_remove(oid): + g = Game.query.filter_by(bgg_id=oid).first_or_404() + e = Collection.query.filter_by(user_id=current_user.id, game_id=g.id).first() + if e: + db.session.delete(e) + db.session.commit() + flash(f'Removed {g.name} from your collection.', 'success') + return redirect(url_for('game_detail', oid=oid, slug=g.slug)) + + +@app.route('/plays/log/', methods=['POST']) +@login_required +def play_log(oid): + g = Game.query.filter_by(bgg_id=oid).first_or_404() + form = PlayForm() + if not form.validate_on_submit(): + flash('Date is required (YYYY-MM-DD).', 'error') + return redirect(url_for('game_detail', oid=oid, slug=g.slug)) + try: + d = datetime.strptime(form.played_on.data, '%Y-%m-%d').date() + except ValueError: + flash('Date must be YYYY-MM-DD.', 'error') + return redirect(url_for('game_detail', oid=oid, slug=g.slug)) + p = Play(user_id=current_user.id, game_id=g.id, played_on=d, + quantity=form.quantity.data or 1, + length_minutes=form.length_minutes.data or 0, + num_players=form.num_players.data or 0, + location=form.location.data, comments=form.comments.data) + db.session.add(p) + db.session.commit() + flash(f'Logged play of {g.name}.', 'success') + return redirect(url_for('plays_detail', username=current_user.username)) + + +# ----- Auth ----- + +@app.route('/login', methods=['GET', 'POST']) +def login(): + form = LoginForm() + if form.validate_on_submit(): + u = User.query.filter_by(username=form.username.data.strip()).first() + if u and bcrypt.check_password_hash(u.password_hash, form.password.data): + login_user(u) + u.last_login = MIRROR_NOW + db.session.commit() + nxt = _safe_next(request.args.get('next'), url_for('user_profile', username=u.username)) + return redirect(nxt) + flash('Invalid username or password.', 'error') + return render_template('login.html', form=form) + + +@app.route('/register', methods=['GET', 'POST']) +def register(): + form = RegisterForm() + if form.validate_on_submit(): + username = form.username.data.strip() + if User.query.filter_by(username=username).first(): + flash('That username is already taken.', 'error') + return render_template('register.html', form=form) + if User.query.filter_by(email=form.email.data.strip()).first(): + flash('That email is already registered.', 'error') + return render_template('register.html', form=form) + u = User(username=username, email=form.email.data.strip(), + password_hash=bcrypt.generate_password_hash(form.password.data).decode(), + real_name=form.real_name.data, country=form.country.data, + joined_at=MIRROR_NOW, last_login=MIRROR_NOW) + db.session.add(u) + db.session.commit() + login_user(u) + flash(f'Welcome, {u.username}!', 'success') + return redirect(url_for('user_profile', username=u.username)) + return render_template('register.html', form=form) + + +@app.route('/logout', methods=['GET', 'POST']) +def logout(): + logout_user() + return redirect(url_for('index')) + + +@app.route('/forgot', methods=['GET', 'POST']) +def forgot(): + msg = None + if request.method == 'POST': + msg = ('If an account with that email exists, a reset link is on its way. ' + '(Mirror site — no email is actually sent.)') + return render_template('forgot.html', msg=msg) + + +# ----- Static pages ----- + +@app.route('/wiki/page/About') +def about(): + return render_template('about.html') + + +@app.route('/help') +def help_page(): + return render_template('help.html') + + +# ----- Thumbs (lightweight likes) ----- + +@app.route('/thumb', methods=['POST']) +@login_required +def thumb(): + kind = request.form.get('kind') + tid = request.form.get('id', type=int) + if kind not in ('post', 'rating', 'geeklist', 'geeklist_item') or not tid: + abort(400) + existing = Thumb.query.filter_by(user_id=current_user.id, kind=kind, target_id=tid).first() + if existing: + db.session.delete(existing) + delta = -1 + else: + db.session.add(Thumb(user_id=current_user.id, kind=kind, target_id=tid)) + delta = 1 + if kind == 'post': + p = db.session.get(Post, tid) + if p: + p.thumbs = max(0, (p.thumbs or 0) + delta) + elif kind == 'rating': + r = db.session.get(Rating, tid) + if r: + r.num_thumbs = max(0, (r.num_thumbs or 0) + delta) + elif kind == 'geeklist': + l = db.session.get(GeekList, tid) + if l: + l.num_thumbs = max(0, (l.num_thumbs or 0) + delta) + elif kind == 'geeklist_item': + i = db.session.get(GeekListItem, tid) + if i: + i.num_thumbs = max(0, (i.num_thumbs or 0) + delta) + db.session.commit() + nxt = _safe_next(request.form.get('next'), url_for('index')) + return redirect(nxt) + + +# ----- Health ----- + +@app.route('/_health') +def health(): + try: + return jsonify({ + 'ok': True, 'site': 'boardgamegeek', + 'games': Game.query.count(), + 'users': User.query.count(), + 'ratings': Rating.query.count(), + 'threads': Thread.query.count(), + 'geeklists': GeekList.query.count(), + }) + except Exception as e: + return jsonify({'ok': False, 'error': str(e)}), 500 + + +# ----- Utilities ----- + +def escape_paragraphs(text: str) -> str: + """User-submitted text: escape, then convert \\n\\n to

breaks, autolink URLs.""" + if not text: + return '' + s = str(escape(text)) + parts = re.split(r'\n\s*\n', s) + url_re = re.compile(r'(https?://[^\s<>"]+)') + paragraphs = [] + for para in parts: + para = url_re.sub(r'\1', para) + paragraphs.append('

' + para.replace('\n', '
') + '

') + return ''.join(paragraphs) + + +# ----- Bootstrap ----- + +with app.app_context(): + db.create_all() + try: + from seed_data import seed_database, seed_benchmark_users + seed_database(db, app) + seed_benchmark_users(db, app, bcrypt) + except Exception as e: + import traceback + traceback.print_exc() + print(f"[boardgamegeek] seed error: {e}") + + +if __name__ == '__main__': + port = int(os.environ.get('PORT', 5000)) + app.run(host='0.0.0.0', port=port, debug=False) diff --git a/sites/boardgamegeek/requirements.txt b/sites/boardgamegeek/requirements.txt new file mode 100644 index 00000000..134f4ece --- /dev/null +++ b/sites/boardgamegeek/requirements.txt @@ -0,0 +1,9 @@ +Flask==3.1.0 +Flask-SQLAlchemy==3.1.1 +Flask-Login==0.6.3 +Flask-Bcrypt==1.0.1 +Flask-WTF==1.2.2 +WTForms==3.2.1 +SQLAlchemy==2.0.36 +Werkzeug==3.1.3 +markupsafe==3.0.2 diff --git a/sites/boardgamegeek/scrape_bgg.py b/sites/boardgamegeek/scrape_bgg.py new file mode 100644 index 00000000..63c3b87b --- /dev/null +++ b/sites/boardgamegeek/scrape_bgg.py @@ -0,0 +1,359 @@ +"""Scrape real BoardGameGeek data via api.geekdo.com (no auth) + Playwright (for the +rank browse list which needs the CF challenge). + +Outputs sites/boardgamegeek/scraped_data/bgg.json containing: +- top_games: list of {objectid, name, rank, year, avg, num_voters, thumbnail_url} from + /browse/boardgame?page=1..N +- items: dict[objectid -> geekitem_payload] (full game metadata) +- dyn: dict[objectid -> dynamicinfo_payload] (rank, polls, weight, stats) +- reviews: dict[objectid -> list[review]] (top text reviews) +- hot: hotness list (top 50 trending right now) +- users: dict[username -> user payload] (top contributors + reviewers) + +Real images (covers + thumbnails) are written to scraped_data/images/.jpg. +The seed_data.py step will read this JSON and load it into the SQLite seed. +""" +import concurrent.futures +import functools +import json +import os +import pathlib +import re +import sys +import time +from urllib.parse import urlparse + +import httpx + +# Make print() unbuffered when stdout is redirected to a file so we see live +# progress instead of a single dump at the end. +print = functools.partial(print, flush=True) + +OUT = pathlib.Path(__file__).parent / "scraped_data" +OUT.mkdir(parents=True, exist_ok=True) +(IMG_DIR := OUT / "images").mkdir(exist_ok=True) + +# Tuning knobs — keep them visible so it's easy to scale up if needed. +TOP_PAGES = int(os.environ.get("BGG_PAGES", "10")) # 100 games per page +REVIEWS_PER_GAME = int(os.environ.get("BGG_REVIEWS", "30")) +USERS_TO_FETCH = int(os.environ.get("BGG_USERS", "200")) +CONCURRENCY = int(os.environ.get("BGG_CONC", "10")) + +UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36") +HEADERS = {"User-Agent": UA, "Accept": "application/json,text/html;q=0.9"} + +client = httpx.Client(headers=HEADERS, timeout=30.0, + limits=httpx.Limits(max_connections=CONCURRENCY)) +API = "https://api.geekdo.com/api" + + +# --------- step 1: top game ids + rank table via Playwright --------- + +def scrape_top_pages(pages: int) -> list[dict]: + from playwright.sync_api import sync_playwright + rows = [] + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + for pg in range(1, pages + 1): + ctx = browser.new_context(user_agent=UA, viewport={"width": 1400, "height": 1200}) + page = ctx.new_page() + url = f"https://boardgamegeek.com/browse/boardgame/page/{pg}" + try: + page.goto(url, wait_until="domcontentloaded", timeout=60000) + page.wait_for_selector("table.collection_table td.collection_rank", + timeout=25000) + except Exception as e: + print(f" ! page {pg} load: {e}", file=sys.stderr) + ctx.close() + time.sleep(3) + continue + data = page.eval_on_selector_all( + "table.collection_table tr", + """rows => rows.filter(tr => + tr.querySelector('td.collection_rank') && tr.querySelector('a[href^="/boardgame/"]') + ).map(tr => { + const cells = tr.querySelectorAll('td'); + const rankAnchor = tr.querySelector('td.collection_rank a[name]'); + const rank = rankAnchor ? rankAnchor.getAttribute('name') : ''; + const titleLink = tr.querySelector('td.collection_objectname a[href^="/boardgame/"]'); + const href = titleLink ? titleLink.getAttribute('href') : ''; + const m = href.match(/^\\/boardgame\\/(\\d+)/); + const objectid = m ? m[1] : null; + const name = (titleLink?.innerText || '').trim(); + const yearEl = tr.querySelector('td.collection_objectname span.smallerfont'); + const year = (yearEl?.innerText || '').replace(/[()]/g,'').trim(); + const thumb = tr.querySelector('td.collection_thumbnail img')?.src || ''; + const numericCells = Array.from(tr.querySelectorAll('td.collection_bggrating')).map(c => c.innerText.trim()); + const [geek_rating, avg_rating, num_voters] = numericCells.length >= 3 + ? numericCells.slice(0,3) + : ['','','']; + return {rank, objectid, name, year, thumb, + geek_rating, avg_rating, num_voters}; + })""" + ) + valid = [r for r in data if r.get("objectid")] + rows.extend(valid) + print(f" page {pg}: {len(valid)} rows total={len(rows)}") + ctx.close() + time.sleep(1.5) + browser.close() + return rows + + +# --------- step 2: per-game JSON endpoints --------- + +def fetch_json(url: str, retries: int = 2) -> dict | None: + for attempt in range(retries + 1): + try: + r = client.get(url) + if r.status_code == 200: + return r.json() + if r.status_code in (429, 502, 503, 504) and attempt < retries: + time.sleep(1.5 * (attempt + 1)) + continue + return None + except Exception as e: + if attempt < retries: + time.sleep(1.0 * (attempt + 1)) + continue + print(f" ! {url[:80]}: {e}", file=sys.stderr) + return None + return None + + +def fetch_geekitem(oid: str) -> dict | None: + return fetch_json(f"{API}/geekitems?objectid={oid}&objecttype=thing&subtype=boardgame&type=thing") + + +def fetch_dyn(oid: str) -> dict | None: + return fetch_json(f"{API}/dynamicinfo?objectid={oid}&objecttype=thing&subtype=boardgame&type=thing") + + +def fetch_reviews(oid: str, count: int) -> list[dict]: + out = [] + page = 1 + while len(out) < count and page <= 5: + url = (f"{API}/collections?ajax=1&objectid={oid}&objecttype=thing" + f"&pageid={page}&showcount=50&require_review=true&sort=rating") + data = fetch_json(url) + items = (data or {}).get("items") or [] + if not items: + break + out.extend(items) + page += 1 + if len(items) < 50: + break + # Slim down each review: keep what we render + slim = [] + for it in out[:count]: + tf = it.get("textfield") or {} + comment_obj = tf.get("comment") if isinstance(tf.get("comment"), dict) else None + comment = None + if comment_obj: + comment = comment_obj.get("rendered") or comment_obj.get("value") + rating_field = it.get("rating") + rating = None + if isinstance(rating_field, dict): + sub = rating_field.get("rating") + if isinstance(sub, dict): + rating = sub.get("value") + else: + rating = sub + elif isinstance(rating_field, (int, float, str)): + rating = rating_field + slim.append({ + "collid": it.get("collid"), + "username": (it.get("user") or {}).get("username") if isinstance(it.get("user"), dict) else None, + "country": (it.get("user") or {}).get("country") if isinstance(it.get("user"), dict) else None, + "rating": rating, + "comment_html": comment, + "tstamp": it.get("status_tstamp"), + }) + return slim + + +def fetch_user(username: str) -> dict | None: + # /user?username=... returns a list[user] or a dict {user: ...} + base = fetch_json(f"{API}/user?username={username}") + if not base: + return None + if isinstance(base, list): + entry = base[0] if base else None + elif isinstance(base, dict): + entry = base.get("user") or base + else: + entry = None + if not isinstance(entry, dict): + return None + uid = entry.get("userid") or entry.get("id") + if not uid: + return None + profile = fetch_json(f"{API}/user/{uid}/profile") or {} + return {"base": entry, "profile": profile, "userid": uid} + + +# --------- step 3: image fetch --------- + +def fetch_image(url: str, oid: str, suffix: str = "") -> str | None: + if not url: + return None + try: + dest = IMG_DIR / f"{oid}{suffix}.jpg" + if dest.exists() and dest.stat().st_size > 500: + return dest.name + r = client.get(url) + if r.status_code != 200 or len(r.content) < 500: + return None + dest.write_bytes(r.content) + return dest.name + except Exception as e: + print(f" ! img {oid}: {e}", file=sys.stderr) + return None + + +# --------- step 4: hot list --------- + +def fetch_hot() -> list[dict]: + data = fetch_json(f"{API}/hotness?geeksite=boardgame&objecttype=thing&showcount=50&singular=1") + items = (data or {}).get("items") or [] + return items + + +# --------- step 5: forum threads (a few representative ones) --------- + +def fetch_top_thread_titles_for_game(oid: str, limit: int = 10) -> list[dict]: + """Get a slice of recent thread titles + post counts for the game's forum row.""" + data = fetch_json( + f"{API}/forums/threads?ajax=1&objectid={oid}&objecttype=thing&pageid=1" + f"&showcount={limit}&sort=latestpost" + ) + items = (data or {}).get("threads") or (data or {}).get("items") or [] + return items[:limit] + + +# --------- main --------- + +def main() -> None: + print(f"[bgg-scrape] step 1: rank pages × {TOP_PAGES}") + rank_rows = scrape_top_pages(TOP_PAGES) + by_oid = {} + for r in rank_rows: + oid = r["objectid"] + if oid in by_oid: + continue + by_oid[oid] = r + print(f" unique games: {len(by_oid)}") + if not by_oid: + print(" ! no rank rows collected, aborting", file=sys.stderr) + sys.exit(2) + + oids = list(by_oid.keys()) + + print(f"[bgg-scrape] step 2a: geekitems for {len(oids)} games (conc={CONCURRENCY})") + items: dict[str, dict] = {} + with concurrent.futures.ThreadPoolExecutor(max_workers=CONCURRENCY) as pool: + for oid, data in zip(oids, pool.map(fetch_geekitem, oids)): + if data: + items[oid] = data + if len(items) % 100 == 0: + print(f" geekitems {len(items)}/{len(oids)}") + print(f" geekitems: {len(items)}") + + print(f"[bgg-scrape] step 2b: dynamicinfo for {len(oids)} games") + dyn: dict[str, dict] = {} + with concurrent.futures.ThreadPoolExecutor(max_workers=CONCURRENCY) as pool: + for oid, data in zip(oids, pool.map(fetch_dyn, oids)): + if data: + dyn[oid] = data + if len(dyn) % 100 == 0: + print(f" dyn {len(dyn)}/{len(oids)}") + print(f" dynamicinfo: {len(dyn)}") + + print(f"[bgg-scrape] step 3: reviews (top {REVIEWS_PER_GAME}/game) for {len(oids)} games") + reviews: dict[str, list] = {} + REV_CONC = max(4, CONCURRENCY // 3) + def _fetch_rev(oid): + return oid, fetch_reviews(oid, REVIEWS_PER_GAME) + done_count = 0 + with concurrent.futures.ThreadPoolExecutor(max_workers=REV_CONC) as pool: + for oid, revs in pool.map(_fetch_rev, oids): + reviews[oid] = revs + done_count += 1 + if done_count % 25 == 0: + print(f" reviews {done_count}/{len(oids)} " + f"total_so_far={sum(len(v) for v in reviews.values())}") + total_revs = sum(len(v) for v in reviews.values()) + print(f" reviews total: {total_revs}") + + print(f"[bgg-scrape] step 4: forum thread titles per game (light)") + threads: dict[str, list] = {} + def _fetch_th(oid): + return oid, fetch_top_thread_titles_for_game(oid, 8) + with concurrent.futures.ThreadPoolExecutor(max_workers=CONCURRENCY) as pool: + for oid, t in pool.map(_fetch_th, oids): + threads[oid] = t + th_total = sum(len(v) for v in threads.values()) + print(f" thread headers: {th_total}") + + print(f"[bgg-scrape] step 5: hot list") + hot = fetch_hot() + print(f" hot items: {len(hot)}") + + print(f"[bgg-scrape] step 6: cover images (top {len(oids)} games)") + n_imgs = 0 + def _fetch_img(oid): + r = by_oid.get(oid, {}) + # geekitems may have a higher-res image; prefer that if found + gi = (items.get(oid) or {}).get("item") or {} + big = gi.get("imageurl") or gi.get("imageurl_lg") or "" + cover = big or r.get("thumb") or "" + thumb = r.get("thumb") or big + c = fetch_image(cover, oid, "_cover") if cover else None + t = fetch_image(thumb, oid, "_thumb") if thumb and thumb != cover else c + return oid, c, t + with concurrent.futures.ThreadPoolExecutor(max_workers=CONCURRENCY) as pool: + cover_map: dict[str, dict] = {} + for oid, c, t in pool.map(_fetch_img, oids): + cover_map[oid] = {"cover": c, "thumb": t} + if c: + n_imgs += 1 + if (n_imgs % 100) == 0 and n_imgs: + print(f" imgs {n_imgs}/{len(oids)}") + print(f" cover images: {n_imgs}") + + print(f"[bgg-scrape] step 7: users (top reviewers across games)") + user_counter: dict[str, int] = {} + for revs in reviews.values(): + for rv in revs: + un = rv.get("username") + if un: + user_counter[un] = user_counter.get(un, 0) + 1 + top_users = [u for u, _ in sorted(user_counter.items(), key=lambda x: -x[1])][:USERS_TO_FETCH] + print(f" fetching {len(top_users)} user profiles…") + users: dict[str, dict] = {} + with concurrent.futures.ThreadPoolExecutor(max_workers=CONCURRENCY) as pool: + for name, data in zip(top_users, pool.map(fetch_user, top_users)): + if data: + users[name] = data + print(f" users: {len(users)}") + + out = { + "scraped_at": int(time.time()), + "top_games": rank_rows, + "items": items, + "dyn": dyn, + "reviews": reviews, + "threads": threads, + "hot": hot, + "users": users, + "covers": cover_map, + } + out_path = OUT / "bgg.json" + out_path.write_text(json.dumps(out)) + print(f"[bgg-scrape] wrote {out_path} ({out_path.stat().st_size/1e6:.1f} MB)") + print(f"[bgg-scrape] images dir: {IMG_DIR} ({sum(1 for _ in IMG_DIR.iterdir())} files)") + + +if __name__ == "__main__": + main() diff --git a/sites/boardgamegeek/scrape_extras.py b/sites/boardgamegeek/scrape_extras.py new file mode 100644 index 00000000..deca1a5e --- /dev/null +++ b/sites/boardgamegeek/scrape_extras.py @@ -0,0 +1,274 @@ +"""Augmentation scraper: fill the gaps the first pass left. + +1) EXPANSIONS — for every top-500 base game, fetch the full list of expansion + ids it links to, then fetch geekitems + dynamicinfo for each expansion so + they become first-class Game rows (subtype='boardgameexpansion') in our + catalog. Fixes BGG--20 + provides real expansion data for every popular + base game. + +2) LOW RATINGS — the first pass scraped each game's reviews with + sort=rating&direction=desc only. Re-fetch with direction=asc so the seed + contains the actual low-end ratings real BGG users gave, not just the + top-rated reviews. Removes the structural answer-leak in BGG--14. + +Reads: sites/boardgamegeek/scraped_data/bgg.json +Writes: sites/boardgamegeek/scraped_data/bgg_extras.json + sites/boardgamegeek/scraped_data/images/_{cover,thumb}.jpg +""" +import concurrent.futures +import functools +import json +import pathlib +import sys +import time + +import httpx + +print = functools.partial(print, flush=True) + +BASE = pathlib.Path(__file__).parent +SRC = BASE / "scraped_data" / "bgg.json" +OUT_JSON = BASE / "scraped_data" / "bgg_extras.json" +IMG = BASE / "scraped_data" / "images" +IMG.mkdir(parents=True, exist_ok=True) + +API = "https://api.geekdo.com/api" +UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36") +CONC = 12 + +client = httpx.Client( + headers={"User-Agent": UA, "Accept": "application/json"}, + timeout=30.0, + limits=httpx.Limits(max_connections=CONC), +) + + +def get(url, retries=2): + for attempt in range(retries + 1): + try: + r = client.get(url) + if r.status_code == 200: + return r.json() + if r.status_code in (429, 502, 503, 504) and attempt < retries: + time.sleep(1.5 * (attempt + 1)) + continue + return None + except Exception as e: + if attempt < retries: + time.sleep(1.0 * (attempt + 1)) + continue + print(f" ! {url[:90]}: {e}", file=sys.stderr) + return None + + +def fetch_expansion_ids(base_oid: str) -> list[str]: + """Return all expansion objectids for a base game (multi-page API).""" + ids: list[str] = [] + page = 1 + while page <= 5: + url = (f"{API}/geekitem/linkeditems?linkdata_index=boardgameexpansion" + f"&objectid={base_oid}&objecttype=thing&pageid={page}&showcount=50") + data = get(url) + if not data: + break + items = data.get("items") or data.get("linkeditems") or [] + if not items: + break + new_ids = [] + for it in items: + oid = it.get("objectid") or it.get("id") + if oid: + new_ids.append(str(oid)) + ids.extend(new_ids) + if len(new_ids) < 50: + break + page += 1 + return ids + + +def fetch_geekitem(oid: str) -> dict | None: + return get(f"{API}/geekitems?objectid={oid}&objecttype=thing" + f"&subtype=boardgameexpansion&type=thing") + + +def fetch_dyn(oid: str) -> dict | None: + return get(f"{API}/dynamicinfo?objectid={oid}&objecttype=thing" + f"&subtype=boardgameexpansion&type=thing") + + +def fetch_image(url: str, oid: str, suffix: str) -> str | None: + if not url: + return None + dest = IMG / f"{oid}{suffix}.jpg" + if dest.exists() and dest.stat().st_size > 500: + return dest.name + try: + r = client.get(url) + if r.status_code != 200 or len(r.content) < 500: + return None + dest.write_bytes(r.content) + return dest.name + except Exception as e: + print(f" ! img {oid}: {e}", file=sys.stderr) + return None + + +def fetch_low_ratings(oid: str, count: int = 20) -> list[dict]: + """Fetch low-end ratings (sort asc) to balance the high-only first-pass data.""" + out = [] + page = 1 + while len(out) < count and page <= 3: + url = (f"{API}/collections?ajax=1&objectid={oid}&objecttype=thing" + f"&pageid={page}&showcount=50&require_review=true" + f"&sort=rating&direction=asc") + d = get(url) + items = (d or {}).get("items") or [] + if not items: + break + out.extend(items) + page += 1 + if len(items) < 50: + break + slim = [] + for it in out[:count]: + tf = it.get("textfield") or {} + comment_obj = tf.get("comment") if isinstance(tf.get("comment"), dict) else None + comment = comment_obj.get("rendered") or comment_obj.get("value") if comment_obj else None + rating_field = it.get("rating") + rating = None + if isinstance(rating_field, dict): + sub = rating_field.get("rating") + if isinstance(sub, dict): + rating = sub.get("value") + else: + rating = sub + elif isinstance(rating_field, (int, float, str)): + rating = rating_field + slim.append({ + "collid": it.get("collid"), + "username": (it.get("user") or {}).get("username") if isinstance(it.get("user"), dict) else None, + "country": (it.get("user") or {}).get("country") if isinstance(it.get("user"), dict) else None, + "rating": rating, + "comment_html": comment, + "tstamp": it.get("status_tstamp"), + }) + return slim + + +def main(): + print(f"[extras] loading {SRC}...") + src = json.loads(SRC.read_text()) + base_oids = list(src.get("items", {}).keys()) + print(f" base games: {len(base_oids)}") + + # ----- 1) expansion id lists ----- + print(f"[extras] fetching expansion id-lists for {len(base_oids)} base games (conc={CONC})...") + exp_ids_per_base: dict[str, list[str]] = {} + done = 0 + with concurrent.futures.ThreadPoolExecutor(max_workers=CONC) as pool: + futures = {pool.submit(fetch_expansion_ids, oid): oid for oid in base_oids} + for fut in concurrent.futures.as_completed(futures): + oid = futures[fut] + try: + exp_ids_per_base[oid] = fut.result() + except Exception as e: + exp_ids_per_base[oid] = [] + print(f" ! exp-ids {oid}: {e}", file=sys.stderr) + done += 1 + if done % 50 == 0: + tot = sum(len(v) for v in exp_ids_per_base.values()) + print(f" exp-ids {done}/{len(base_oids)} total={tot}") + + # Flatten + dedupe. We don't want to double-fetch. + all_expansion_ids = set() + for ids in exp_ids_per_base.values(): + for x in ids: + all_expansion_ids.add(x) + # Exclude expansions that are already base games in our seed (rare). + expansion_oids = [x for x in sorted(all_expansion_ids, key=int) + if x not in src.get("items", {})] + print(f" unique expansion oids: {len(expansion_oids)}") + + # ----- 2) geekitems for every expansion ----- + print(f"[extras] geekitems for {len(expansion_oids)} expansions...") + exp_items: dict[str, dict] = {} + done = 0 + with concurrent.futures.ThreadPoolExecutor(max_workers=CONC) as pool: + futures = {pool.submit(fetch_geekitem, oid): oid for oid in expansion_oids} + for fut in concurrent.futures.as_completed(futures): + oid = futures[fut] + data = fut.result() + if data: + exp_items[oid] = data + done += 1 + if done % 100 == 0: + print(f" exp-items {done}/{len(expansion_oids)} hits={len(exp_items)}") + + # ----- 3) dynamicinfo ----- + print(f"[extras] dynamicinfo for {len(expansion_oids)} expansions...") + exp_dyn: dict[str, dict] = {} + done = 0 + with concurrent.futures.ThreadPoolExecutor(max_workers=CONC) as pool: + futures = {pool.submit(fetch_dyn, oid): oid for oid in expansion_oids} + for fut in concurrent.futures.as_completed(futures): + oid = futures[fut] + data = fut.result() + if data: + exp_dyn[oid] = data + done += 1 + if done % 100 == 0: + print(f" exp-dyn {done}/{len(expansion_oids)} hits={len(exp_dyn)}") + + # ----- 4) expansion cover images ----- + print(f"[extras] cover images for expansions...") + n_imgs = 0 + def _fetch_img(oid): + gi = (exp_items.get(oid) or {}).get("item") or {} + cover = gi.get("imageurl") or gi.get("imageurl_lg") or "" + thumb = gi.get("thumbnail") or cover + c = fetch_image(cover, oid, "_cover") if cover else None + t = fetch_image(thumb, oid, "_thumb") if thumb else None + return oid, c, t + cover_map: dict[str, dict] = {} + with concurrent.futures.ThreadPoolExecutor(max_workers=CONC) as pool: + for oid, c, t in pool.map(_fetch_img, expansion_oids): + cover_map[oid] = {"cover": c, "thumb": t} + if c: + n_imgs += 1 + if n_imgs % 100 == 0: + print(f" exp-imgs {n_imgs}") + print(f" expansion covers downloaded: {n_imgs}") + + # ----- 5) low ratings for base games ----- + print(f"[extras] low ratings (sort asc) for {len(base_oids)} base games...") + REV_CONC = max(4, CONC // 3) + low_reviews: dict[str, list] = {} + done = 0 + with concurrent.futures.ThreadPoolExecutor(max_workers=REV_CONC) as pool: + futures = {pool.submit(fetch_low_ratings, oid, 15): oid for oid in base_oids} + for fut in concurrent.futures.as_completed(futures): + oid = futures[fut] + low_reviews[oid] = fut.result() + done += 1 + if done % 50 == 0: + tot = sum(len(v) for v in low_reviews.values()) + print(f" low-reviews {done}/{len(base_oids)} total={tot}") + + out = { + "scraped_at": int(time.time()), + "exp_ids_per_base": exp_ids_per_base, + "exp_items": exp_items, + "exp_dyn": exp_dyn, + "exp_covers": cover_map, + "low_reviews": low_reviews, + } + OUT_JSON.write_text(json.dumps(out)) + print(f"[extras] wrote {OUT_JSON} " + f"({OUT_JSON.stat().st_size/1e6:.1f} MB)") + print(f"[extras] images dir entries: " + f"{sum(1 for _ in IMG.iterdir())}") + + +if __name__ == "__main__": + main() diff --git a/sites/boardgamegeek/scrape_low_ratings.py b/sites/boardgamegeek/scrape_low_ratings.py new file mode 100644 index 00000000..48044329 --- /dev/null +++ b/sites/boardgamegeek/scrape_low_ratings.py @@ -0,0 +1,127 @@ +"""Tiny add-on: fetch genuinely-low BGG ratings (1-5 stars), no review filter. + +Without require_review=true, the BGG collections endpoint returns rating-only +entries too, which is where the actual low scores live. Real reviewers rarely +go below 6; bare ratings go to 1. + +Appends to scraped_data/bgg_extras.json under key 'low_ratings_only'. +""" +import concurrent.futures +import functools +import json +import pathlib +import sys +import time + +import httpx + +print = functools.partial(print, flush=True) + +BASE = pathlib.Path(__file__).parent +EXTRAS = BASE / "scraped_data" / "bgg_extras.json" +SRC = BASE / "scraped_data" / "bgg.json" + +API = "https://api.geekdo.com/api" +UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36") +CONC = 8 +client = httpx.Client( + headers={"User-Agent": UA, "Accept": "application/json"}, + timeout=30.0, + limits=httpx.Limits(max_connections=CONC), +) + + +def get(url, retries=2): + for attempt in range(retries + 1): + try: + r = client.get(url) + if r.status_code == 200: + return r.json() + if r.status_code in (429, 502, 503, 504) and attempt < retries: + time.sleep(1.5 * (attempt + 1)) + continue + return None + except Exception as e: + if attempt < retries: + time.sleep(1.0 * (attempt + 1)) + continue + return None + return None + + +def fetch_low_only(oid: str, count: int = 25) -> list[dict]: + """No require_review filter — pulls real low-end raw ratings.""" + out = [] + page = 1 + while len(out) < count and page <= 3: + url = (f"{API}/collections?ajax=1&objectid={oid}&objecttype=thing" + f"&pageid={page}&showcount=50&sort=rating&direction=asc") + d = get(url) + items = (d or {}).get("items") or [] + if not items: + break + out.extend(items) + page += 1 + if len(items) < 50: + break + slim = [] + for it in out[:count]: + rating_field = it.get("rating") + rating = None + if isinstance(rating_field, dict): + sub = rating_field.get("rating") + if isinstance(sub, dict): + rating = sub.get("value") + else: + rating = sub + elif isinstance(rating_field, (int, float, str)): + rating = rating_field + if rating is None: + continue + try: + rating_f = float(rating) + except (TypeError, ValueError): + continue + if rating_f > 5.5: + continue # only keep actual lows + tf = it.get("textfield") or {} + comment_obj = tf.get("comment") if isinstance(tf.get("comment"), dict) else None + comment = comment_obj.get("rendered") or comment_obj.get("value") if comment_obj else None + slim.append({ + "collid": it.get("collid"), + "username": (it.get("user") or {}).get("username") if isinstance(it.get("user"), dict) else None, + "country": (it.get("user") or {}).get("country") if isinstance(it.get("user"), dict) else None, + "rating": rating, + "comment_html": comment, + "tstamp": it.get("status_tstamp"), + }) + return slim + + +def main(): + src = json.loads(SRC.read_text()) + base_oids = list(src.get("items", {}).keys()) + extras = json.loads(EXTRAS.read_text()) if EXTRAS.exists() else {} + print(f"[low] fetching real low ratings for {len(base_oids)} base games...") + + low_only: dict[str, list] = {} + done = 0 + with concurrent.futures.ThreadPoolExecutor(max_workers=CONC) as pool: + futures = {pool.submit(fetch_low_only, oid, 25): oid for oid in base_oids} + for fut in concurrent.futures.as_completed(futures): + oid = futures[fut] + low_only[oid] = fut.result() + done += 1 + if done % 50 == 0: + tot = sum(len(v) for v in low_only.values()) + print(f" {done}/{len(base_oids)} low_total={tot}") + + extras['low_ratings_only'] = low_only + EXTRAS.write_text(json.dumps(extras)) + total = sum(len(v) for v in low_only.values()) + print(f"[low] wrote {total} truly-low ratings across {sum(1 for v in low_only.values() if v)} games") + + +if __name__ == "__main__": + main() diff --git a/sites/boardgamegeek/seed_data.py b/sites/boardgamegeek/seed_data.py new file mode 100644 index 00000000..fd13d667 --- /dev/null +++ b/sites/boardgamegeek/seed_data.py @@ -0,0 +1,1034 @@ +"""Idempotent seed for BoardGameGeek mirror. + +Loads sites/boardgamegeek/scraped_data/bgg.json (real BGG api.geekdo.com data) +into the SQLite DB. Two phases: + +1. seed_database(db, app) — games, designers, artists, publishers, + categories, mechanics, families, ratings, reviews, forums, threads, + posts, geeklists, geeklist items, hot list, real BGG users. + +2. seed_benchmark_users(db, app, bcrypt) — 4 deterministic benchmark accounts + alice_j / bob_c / carol_d / david_k with collections, ratings, plays, + forum activity, and geeklists. + +Byte-identical reset invariant: each function early-returns when the DB is +already populated. No commits unless it's the first run. +""" +import json +import os +import re +import shutil +import random +from datetime import datetime, timedelta, date + + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +DATA_FILE = os.path.join(BASE_DIR, 'scraped_data', 'bgg.json') +EXTRAS_FILE = os.path.join(BASE_DIR, 'scraped_data', 'bgg_extras.json') +IMG_SRC = os.path.join(BASE_DIR, 'scraped_data', 'images') +IMG_DST = os.path.join(BASE_DIR, 'static', 'images') + +MIRROR_NOW = datetime(2026, 5, 26, 12, 0, 0) + +# Pinned deterministic RNG. +_R = random.Random(20260526) + + +def _slugify(s: str) -> str: + s = (s or '').lower() + s = re.sub(r'[^a-z0-9]+', '-', s).strip('-') + return s or 'item' + + +def _parse_int(v, default=0): + try: + return int(float(v)) + except (TypeError, ValueError): + return default + + +def _parse_float(v, default=0.0): + try: + return float(v) + except (TypeError, ValueError): + return default + + +def _shift(seconds_back: int) -> datetime: + return MIRROR_NOW - timedelta(seconds=seconds_back) + + +def _copy_images(_quiet: bool = False) -> None: + if not os.path.isdir(IMG_SRC): + return + os.makedirs(IMG_DST, exist_ok=True) + for fn in os.listdir(IMG_SRC): + src = os.path.join(IMG_SRC, fn) + dst = os.path.join(IMG_DST, fn) + if os.path.exists(dst): + continue + try: + shutil.copyfile(src, dst) + except Exception as e: + if not _quiet: + print(f" ! copy img {fn}: {e}") + + +# Hardcoded fallback so the site renders even when scraped_data is missing. +FALLBACK_GAMES = [ + {"bgg_id": 999001, "name": "Catan", "year": 1995, "minplayers": 3, "maxplayers": 4, + "minplaytime": 60, "maxplaytime": 120, "minage": 10, + "short_description": "Trade, build, settle the island of Catan.", + "description_html": "

Catan is a tile-and-resource game where players build settlements, cities and roads on an island made from hex tiles.

", + "categories": ["Economic", "Negotiation"], + "mechanics": ["Dice Rolling", "Hexagon Grid", "Modular Board", "Network and Route Building", "Trading"], + "designers": ["Klaus Teuber"], + "publishers": ["Catan Studio"], + "weight": 2.30, "avg": 7.13, "bayes": 7.10, "num_ratings": 110000, "rank": 415}, + {"bgg_id": 999002, "name": "Carcassonne", "year": 2000, "minplayers": 2, "maxplayers": 5, + "minplaytime": 30, "maxplaytime": 45, "minage": 7, + "short_description": "Build southern France by laying landscape tiles and placing meeples.", + "description_html": "

Carcassonne is a tile-laying game in which players draw and place a tile with a piece of southern French landscape on it.

", + "categories": ["Medieval", "Territory Building"], + "mechanics": ["Area Majority / Influence", "Tile Placement"], + "designers": ["Klaus-Jürgen Wrede"], + "publishers": ["Z-Man Games"], + "weight": 1.91, "avg": 7.40, "bayes": 7.39, "num_ratings": 120000, "rank": 199}, +] + + +# Top-level (site-wide) forums shown on /forums. +SITEWIDE_FORUMS = [ + ('BoardGameGeek', 'announcements', 10, 'BGG announcements and site news.'), + ('General Gaming', 'general', 20, 'All-around discussion of board games.'), + ('Recommendations', 'general', 30, 'What should I play / buy?'), + ('Strategy', 'strategy', 40, 'Game-specific strategy and tactics.'), + ('Sessions', 'sessions', 50, 'Session reports from actual plays.'), + ('Reviews', 'reviews', 60, 'In-depth game reviews.'), + ('Variants & House Rules','variants', 70, 'Variants and house rules.'), + ('Crowdfunding', 'crowdfunding', 80, 'Kickstarter, Gamefound, BackerKit.'), + ('Solo Gaming', 'general', 90, 'Solo play, scenarios, AI bots.'), + ('Two-Player Gaming', 'general', 100, 'Just the two of us.'), + ('Trading & Marketplace', 'marketplace', 110, 'Buy, sell, trade.'), + ('News', 'news', 120, 'Industry news, new releases.'), +] + + +# Per-game forum subdivisions (created for every game). +PER_GAME_FORUMS = [ + ('General', 'general', 10), + ('Reviews', 'reviews', 20), + ('Strategy', 'strategy', 30), + ('Sessions', 'sessions', 40), + ('Rules', 'rules', 50), + ('Variants', 'variants', 60), + ('Crowdfunding', 'crowdfunding', 70), +] + + +SAMPLE_THREAD_TITLES = [ + 'First impressions after 10 plays', + 'Quick reference card (PDF)', + 'Solo variant — works surprisingly well', + 'Question about the end-game scoring', + 'Box insert recommendations?', + 'How do you handle player elimination?', + 'Expansion buying guide', + 'Best at 3 or 4 players?', + 'Critical hit on the language dependence', + 'New player struggling — any tips?', + 'Late-game pacing problem', + 'Spoiler-free review', + 'Comparison with other titles in the genre', + 'Errata in the rulebook (printing 2)', + 'Heavy hand of the leader — fix or feature?', +] + + +SAMPLE_POST_BODIES = [ + "We just wrapped our 6th play and it keeps getting better — the early-game decisions matter more than I thought on my first run.", + "I struggled with this in my first session, then realized I was completely missing the cascade in the middle phase. Once I locked that in, things clicked.", + "Played at 4P last night and the downtime was noticeable, but the table talk made up for it. Probably want to stick to 3P for the heavier strategy sessions.", + "Has anyone tried the variant from the rulebook appendix? Curious whether it shortens the game or just adds complexity.", + "The art and component quality are a huge step up from the previous edition. The new tokens are easier to grip.", + "I prefer this over the older sequel for one reason: the action economy feels less swingy.", + "I lost track of how many times the leader pulled away in the final round. Anyone else find catch-up mechanisms too weak here?", + "Bought a player aid PDF from the Files section — game flowed much faster after.", + "Solo with the bot is great. Beat me 3 of 5 games — calibration feels right.", + "Heavy on table presence but the upkeep is minimal once you internalize the symbols.", +] + + +SAMPLE_REVIEW_BODIES = [ + "

I came in expecting a heavy puzzle and got one — but also surprised by how much the table talk shapes my decisions. After 12 plays it stays fresh because every game pushes different paths.

", + "

The first play felt overwhelming. By the third, the design clicked: each phase has one or two pivotal moments, and reading the table during them is everything.

", + "

Components are gorgeous. Iconography mostly self-explanatory after a single play, though one or two ambiguous symbols send me back to the rulebook.

", + "

For its weight class this delivers — the late-game tension is exactly right and player interaction never feels token. Highly recommend for groups who don't mind 90 minutes.

", + "

The catch-up mechanism keeps the leader honest. Score spreads usually finish within 10 points which makes the last round meaningful.

", + "

Hits the table because it's quick to teach, but rewards repeat play. I keep finding new angles 20 plays in.

", +] + + +def _build_fallback_data(): + """Convert the small FALLBACK_GAMES table into the same shape as bgg.json.""" + items = {} + dyn = {} + top_games = [] + for i, g in enumerate(FALLBACK_GAMES): + oid = str(g['bgg_id']) + links = { + 'boardgamedesigner': [{'objectid': 90000 + j, 'name': n} + for j, n in enumerate(g.get('designers', []))], + 'boardgamepublisher': [{'objectid': 91000 + j, 'name': n} + for j, n in enumerate(g.get('publishers', []))], + 'boardgamecategory': [{'objectid': 92000 + j, 'name': n} + for j, n in enumerate(g.get('categories', []))], + 'boardgamemechanic': [{'objectid': 93000 + j, 'name': n} + for j, n in enumerate(g.get('mechanics', []))], + } + items[oid] = {'item': { + 'objectid': g['bgg_id'], 'name': g['name'], + 'yearpublished': g['year'], 'minplayers': g['minplayers'], + 'maxplayers': g['maxplayers'], 'minplaytime': g['minplaytime'], + 'maxplaytime': g['maxplaytime'], 'minage': g['minage'], + 'short_description': g['short_description'], + 'description': g['description_html'], + 'subtype': 'boardgame', 'links': links, + }} + dyn[oid] = {'item': { + 'rankinfo': [{'rankobjectid': 1, 'rank': str(g['rank']), + 'baverage': str(g['bayes'])}], + 'polls': {'boardgameweight': {'averageweight': g['weight'], 'totalvotes': 100}}, + 'stats': {'usersrated': g['num_ratings'], 'average': g['avg'], + 'bayesaverage': g['bayes'], 'owned': g['num_ratings'] // 2, + 'wishing': g['num_ratings'] // 10, 'comments': g['num_ratings'] // 5}, + }} + top_games.append({'objectid': str(g['bgg_id']), 'rank': str(g['rank']), + 'name': g['name'], 'year': str(g['year']), + 'thumb': '', 'geek_rating': str(g['bayes']), + 'avg_rating': str(g['avg']), 'num_voters': str(g['num_ratings'])}) + return {'top_games': top_games, 'items': items, 'dyn': dyn, + 'reviews': {}, 'threads': {}, 'hot': [], 'users': {}, 'covers': {}} + + + +# ----- main seed ----- + +def seed_database(db, app): + """Idempotent: returns early if any games already exist.""" + from app import (User, Person, Publisher, Category, Mechanic, Family, Game, + GameLink, Rating, Collection, Play, Forum, Thread, Post, + GeekList, GeekListItem) + if Game.query.count() > 0: + return + + print('[bgg-seed] loading scraped data…') + if os.path.exists(DATA_FILE): + with open(DATA_FILE) as f: + data = json.load(f) + else: + print(f' ! {DATA_FILE} missing — using built-in fallback (~2 games)') + data = _build_fallback_data() + + # Optional: expansion + low-rating augmentation pass (scrape_extras.py). + extras = None + if os.path.exists(EXTRAS_FILE): + with open(EXTRAS_FILE) as f: + extras = json.load(f) + print(f' + extras: {len(extras.get("exp_items", {}))} expansions, ' + f'{sum(len(v) for v in extras.get("low_reviews", {}).values())} low ratings') + + # Merge expansion games into the items/dyn/covers maps so the existing + # seed loop creates them as first-class Game rows (subtype=expansion). + merged_items = dict(data.get('items') or {}) + merged_dyn = dict(data.get('dyn') or {}) + merged_covers = dict(data.get('covers') or {}) + for oid, payload in (extras.get('exp_items') or {}).items(): + if oid in merged_items: + continue + merged_items[oid] = payload + for oid, payload in (extras.get('exp_dyn') or {}).items(): + if oid in merged_dyn: + continue + merged_dyn[oid] = payload + for oid, payload in (extras.get('exp_covers') or {}).items(): + merged_covers.setdefault(oid, payload) + data['items'] = merged_items + data['dyn'] = merged_dyn + data['covers'] = merged_covers + + # Make sure every base game's boardgameexpansion link includes the + # expansion ids we discovered (the first-pass scrape only captured + # what api.geekitems happened to return, sometimes truncated). + for base_oid, ids in (extras.get('exp_ids_per_base') or {}).items(): + base_payload = merged_items.get(base_oid) + if not base_payload: + continue + gi = base_payload.get('item') or {} + links = gi.setdefault('links', {}) + existing = links.get('boardgameexpansion') or [] + existing_ids = {str(e.get('objectid')) for e in existing if e.get('objectid')} + for x in ids: + if x not in existing_ids: + name = ((merged_items.get(x) or {}).get('item') or {}).get('name') or f'Expansion {x}' + existing.append({'objectid': x, 'name': name}) + existing_ids.add(x) + links['boardgameexpansion'] = existing + + # Merge low ratings into the reviews map. + merged_reviews = dict(data.get('reviews') or {}) + # Two sources: text-review-restricted asc sort (low_reviews) AND + # un-restricted asc sort (low_ratings_only — actual 1-5 star raw ratings). + for src_key in ('low_reviews', 'low_ratings_only'): + for oid, low_list in (extras.get(src_key) or {}).items(): + base = merged_reviews.get(oid) or [] + seen_collids = {r.get('collid') for r in base if r.get('collid')} + for rv in low_list: + if rv.get('collid') and rv['collid'] in seen_collids: + continue + base.append(rv) + if rv.get('collid'): + seen_collids.add(rv['collid']) + merged_reviews[oid] = base + data['reviews'] = merged_reviews + + _copy_images() + + # ----- 1. real users from scraped reviewers ----- + print('[bgg-seed] users…') + users_by_name: dict[str, 'User'] = {} + raw_users = data.get('users') or {} + for uname, payload in raw_users.items(): + base = payload.get('base') or {} + if not uname: + continue + u = User( + username=uname, + email=None, + password_hash='!disabled', # real BGG users are display-only + real_name=f"{base.get('firstname','')} {base.get('lastname','')}".strip(), + country=base.get('country') or '', + state=base.get('state') or '', + city=base.get('city') or '', + isocountry=base.get('isocountry') or '', + about='', + joined_at=_parse_user_join_date(base.get('regdate')), + last_login=MIRROR_NOW, + geekgold=_R.randint(0, 5000), + is_supporter=bool(base.get('supportYears')), + ) + users_by_name[uname] = u + db.session.add(u) + db.session.flush() + + # ----- 2. games ----- + print('[bgg-seed] games + their people/categories/mechanics…') + people_by_bgg: dict[int, 'Person'] = {} + publishers_by_bgg: dict[int, 'Publisher'] = {} + categories_by_bgg: dict[int, 'Category'] = {} + mechanics_by_bgg: dict[int, 'Mechanic'] = {} + families_by_bgg: dict[int, 'Family'] = {} + games_by_bgg: dict[int, 'Game'] = {} + + def get_or_make(map_, model, bgg_id, name): + bgg_id = _parse_int(bgg_id) + if not bgg_id or not name: + return None + if bgg_id in map_: + return map_[bgg_id] + obj = model(bgg_id=bgg_id, name=name, slug=_slugify(name)) + map_[bgg_id] = obj + db.session.add(obj) + return obj + + top_rank_by_oid = {} + for tg in (data.get('top_games') or []): + oid = _parse_int(tg.get('objectid')) + rk = _parse_int(tg.get('rank')) + if oid and rk: + top_rank_by_oid[oid] = rk + + items = data.get('items') or {} + dyn = data.get('dyn') or {} + covers = data.get('covers') or {} + + for oid_str, payload in items.items(): + gi = (payload or {}).get('item') or {} + oid = _parse_int(gi.get('objectid') or oid_str) + if not oid: + continue + name = gi.get('name') or 'Untitled' + cover = (covers.get(oid_str) or {}).get('cover') + thumb = (covers.get(oid_str) or {}).get('thumb') + dyn_item = (dyn.get(oid_str) or {}).get('item') or {} + stats = dyn_item.get('stats') or {} + polls = dyn_item.get('polls') or {} + + # Weight / language dependence / age suggestion + weight = 0.0 + weight_votes = 0 + bgw = polls.get('boardgameweight') + if isinstance(bgw, dict): + weight = _parse_float(bgw.get('averageweight')) + weight_votes = _parse_int(bgw.get('totalvotes')) + lang_dep = polls.get('languagedependence') or '' + suggested_age = polls.get('playerage') or '' + + # Player count poll → "best" / "recommended" + best_str = '' + rec_str = '' + upoll = polls.get('userplayers') or {} + best_list = upoll.get('best') or [] + if isinstance(best_list, list) and best_list: + bmin = best_list[0].get('min') + bmax = best_list[0].get('max') + best_str = f"{bmin}" if bmin == bmax else f"{bmin}–{bmax}" + rec_list = upoll.get('recommended') or [] + if isinstance(rec_list, list) and rec_list: + mins = sorted(set(_parse_int(p.get('min')) for p in rec_list)) + maxs = sorted(set(_parse_int(p.get('max')) for p in rec_list)) + if mins and maxs: + rec_str = f"{min(mins)}–{max(maxs)}" + + rank_info = dyn_item.get('rankinfo') or [] + overall_rank = 0 + bayes = 0.0 + for ri in rank_info: + if ri.get('rankobjectid') in (1, '1') or ri.get('prettyname') == 'Board Game Rank': + r = ri.get('rank') + if r and r != 'Not Ranked': + overall_rank = _parse_int(r) + bayes = _parse_float(ri.get('baverage')) + break + if not overall_rank: + overall_rank = top_rank_by_oid.get(oid, 0) + + avg = _parse_float(stats.get('average')) + num_ratings = _parse_int(stats.get('usersrated')) + num_owners = _parse_int(stats.get('owned')) + num_wishing = _parse_int(stats.get('wishing')) + num_comments = _parse_int(stats.get('comments')) + + g = Game( + bgg_id=oid, + name=name, + slug=_slugify(name), + subtype=(gi.get('subtype') or 'boardgame'), + year_published=_parse_int(gi.get('yearpublished')), + minplayers=_parse_int(gi.get('minplayers')), + maxplayers=_parse_int(gi.get('maxplayers')), + minplaytime=_parse_int(gi.get('minplaytime')), + maxplaytime=_parse_int(gi.get('maxplaytime')), + minage=_parse_int(gi.get('minage')), + short_description=(gi.get('short_description') or '')[:8000], + description_html=(gi.get('description') or '')[:60000], + image_filename=cover or '', + thumb_filename=thumb or '', + avg_rating=avg, + bayes_average=bayes, + weight=weight, + weight_votes=weight_votes, + num_ratings=num_ratings, + num_owners=num_owners, + num_wishing=num_wishing, + num_comments=num_comments, + overall_rank=overall_rank, + best_player_count=best_str, + recommended_player_count=rec_str, + suggested_age=suggested_age, + language_dependence=lang_dep, + featured=False, + ) + db.session.add(g) + games_by_bgg[oid] = g + + db.session.flush() + print(f' games: {len(games_by_bgg)}') + + # ----- people / publishers / categories / mechanics + m2m links ----- + expansion_links = [] # (game_bgg_id, other_bgg_id) + integration_links = [] + for oid_str, payload in items.items(): + gi = (payload or {}).get('item') or {} + oid = _parse_int(gi.get('objectid') or oid_str) + g = games_by_bgg.get(oid) + if not g: + continue + links = gi.get('links') or {} + for entry in (links.get('boardgamedesigner') or []): + p = get_or_make(people_by_bgg, Person, entry.get('objectid'), entry.get('name')) + if p: + g.designers.append(p) + for entry in (links.get('boardgameartist') or []): + p = get_or_make(people_by_bgg, Person, entry.get('objectid'), entry.get('name')) + if p: + g.artists.append(p) + for entry in (links.get('boardgamepublisher') or []): + p = get_or_make(publishers_by_bgg, Publisher, entry.get('objectid'), entry.get('name')) + if p: + g.publishers.append(p) + for entry in (links.get('boardgamecategory') or []): + c = get_or_make(categories_by_bgg, Category, entry.get('objectid'), entry.get('name')) + if c: + g.categories.append(c) + for entry in (links.get('boardgamemechanic') or []): + m = get_or_make(mechanics_by_bgg, Mechanic, entry.get('objectid'), entry.get('name')) + if m: + g.mechanics.append(m) + for entry in (links.get('boardgamefamily') or []): + f = get_or_make(families_by_bgg, Family, entry.get('objectid'), entry.get('name')) + if f: + g.families.append(f) + for entry in (links.get('boardgameexpansion') or []): + other_oid = _parse_int(entry.get('objectid')) + if other_oid: + expansion_links.append((oid, other_oid)) + for entry in (links.get('boardgameintegration') or []): + other_oid = _parse_int(entry.get('objectid')) + if other_oid: + integration_links.append((oid, other_oid)) + + db.session.flush() + print(f' designers/artists: {len(people_by_bgg)} publishers: {len(publishers_by_bgg)} ' + f'categories: {len(categories_by_bgg)} mechanics: {len(mechanics_by_bgg)}') + + # ----- game-to-game links ----- + for (g_oid, o_oid) in expansion_links: + g = games_by_bgg.get(g_oid) + other = games_by_bgg.get(o_oid) + if g and other: + db.session.add(GameLink(game_id=g.id, other_id=other.id, kind='expansion')) + for (g_oid, o_oid) in integration_links: + g = games_by_bgg.get(g_oid) + other = games_by_bgg.get(o_oid) + if g and other: + db.session.add(GameLink(game_id=g.id, other_id=other.id, kind='integration')) + + # ----- featured set from hot list (intersect with seeded games) ----- + hot = data.get('hot') or [] + featured = 0 + for h in hot[:50]: + h_oid = _parse_int(h.get('objectid') or h.get('id')) + g = games_by_bgg.get(h_oid) + if g: + g.featured = True + featured += 1 + if featured == 0: + # If hot list didn't intersect, just feature the top 20 by rank. + for g in sorted(games_by_bgg.values(), key=lambda x: x.overall_rank or 99999)[:20]: + g.featured = True + db.session.flush() + + # ----- ratings + reviews from real users ----- + print('[bgg-seed] real ratings/reviews…') + rating_count = 0 + review_count = 0 + for oid_str, revs in (data.get('reviews') or {}).items(): + oid = _parse_int(oid_str) + g = games_by_bgg.get(oid) + if not g or not revs: + continue + for rv in revs: + uname = rv.get('username') + if not uname: + continue + u = users_by_name.get(uname) + if not u: + # synthesize a stub real user + u = User( + username=uname, + email=None, + password_hash='!disabled', + real_name='', + country=rv.get('country') or '', + joined_at=MIRROR_NOW - timedelta(days=_R.randint(60, 4000)), + last_login=MIRROR_NOW, + geekgold=_R.randint(0, 200), + ) + users_by_name[uname] = u + db.session.add(u) + db.session.flush() + existing = Rating.query.filter_by(user_id=u.id, game_id=g.id).first() + if existing: + continue + comment = rv.get('comment_html') or '' + try: + value = float(rv.get('rating')) if rv.get('rating') is not None else None + except (TypeError, ValueError): + value = None + if value is None: + continue + tstamp = _parse_review_timestamp(rv.get('tstamp')) + r = Rating(user_id=u.id, game_id=g.id, value=value, + review_html=comment, created_at=tstamp, + num_thumbs=_R.randint(0, 25) if comment else _R.randint(0, 3)) + db.session.add(r) + rating_count += 1 + if comment: + review_count += 1 + db.session.flush() + print(f' ratings: {rating_count} text reviews: {review_count}') + + # ----- forums (site-wide + per-game) ----- + print('[bgg-seed] forums + threads + posts…') + forums_created = [] + for title, section, sort, desc in SITEWIDE_FORUMS: + f = Forum(title=title, section=section, sort_order=sort, + description=desc, game_id=None, num_threads=0, num_posts=0) + db.session.add(f) + forums_created.append(f) + + for g in games_by_bgg.values(): + # Only base games get a full per-game forum. Expansions don't — + # real BGG also keeps expansion discussion in the parent game's forum. + if g.subtype != 'boardgame': + continue + for title, section, sort in PER_GAME_FORUMS: + f = Forum(title=title, section=section, sort_order=sort, + description=f'{title} discussion for {g.name}.', + game_id=g.id, num_threads=0, num_posts=0) + db.session.add(f) + db.session.flush() + print(f' forums: {Forum.query.count()}') + + # Threads from real BGG thread headers (when available) + thread_count = 0 + post_count = 0 + raw_threads = data.get('threads') or {} + user_pool = [u for u in users_by_name.values() if u.password_hash == '!disabled'] + if not user_pool: + user_pool = list(users_by_name.values()) + + def _author_for_index(i: int): + return user_pool[i % len(user_pool)] if user_pool else None + + for oid_str, headers in raw_threads.items(): + oid = _parse_int(oid_str) + g = games_by_bgg.get(oid) + if not g or not headers: + continue + # Pick a per-game forum at random (deterministic by thread index) + forums_for_game = Forum.query.filter_by(game_id=g.id).all() + if not forums_for_game: + continue + for ti, h in enumerate(headers[:8]): + subject = (h.get('subject') or h.get('title') or + SAMPLE_THREAD_TITLES[ti % len(SAMPLE_THREAD_TITLES)]) + subject = subject.strip()[:300] + fobj = forums_for_game[ti % len(forums_for_game)] + author = _author_for_index(oid + ti) + if not author: + continue + tcreated = MIRROR_NOW - timedelta(days=_R.randint(2, 1400), + hours=_R.randint(0, 23)) + n_posts = max(1, _parse_int(h.get('numposts') or h.get('numreplies')) or + _R.randint(2, 30)) + n_posts = min(n_posts, 30) + t = Thread(forum_id=fobj.id, subject=subject, author_id=author.id, + is_pinned=(ti == 0 and _R.random() < 0.15), + is_hot=(n_posts >= 15), + num_posts=n_posts, num_views=n_posts * _R.randint(8, 50), + created_at=tcreated, last_post_at=tcreated + timedelta(hours=n_posts * 2)) + db.session.add(t) + db.session.flush() + thread_count += 1 + fobj.num_threads = (fobj.num_threads or 0) + 1 + fobj.num_posts = (fobj.num_posts or 0) + n_posts + for pi in range(n_posts): + pauthor = _author_for_index(oid + ti + pi * 7) or author + body = SAMPLE_POST_BODIES[(oid + ti + pi) % len(SAMPLE_POST_BODIES)] + body_html = f'

{body}

' + # Sprinkle in a quoted previous post for variety + if pi > 0 and pi % 3 == 0: + prev = SAMPLE_POST_BODIES[(oid + ti + pi - 1) % len(SAMPLE_POST_BODIES)] + body_html = f'
{prev[:120]}…
' + body_html + pcreated = tcreated + timedelta(hours=pi * 2 + _R.randint(0, 4)) + p = Post(thread_id=t.id, author_id=pauthor.id, + body_html=body_html, created_at=pcreated, + thumbs=_R.randint(0, 8)) + db.session.add(p) + post_count += 1 + if thread_count % 200 == 0: + db.session.flush() + + db.session.flush() + print(f' threads: {thread_count} posts: {post_count}') + + # ----- GeekLists (synthesized from real games) ----- + print('[bgg-seed] geeklists…') + list_count = _seed_geeklists(db, games_by_bgg, users_by_name, _R) + print(f' geeklists: {list_count}') + + db.session.commit() + print('[bgg-seed] done.') + + +def _seed_geeklists(db, games_by_bgg, users_by_name, R): + from app import GeekList, GeekListItem + + games_sorted = sorted(games_by_bgg.values(), key=lambda g: g.overall_rank or 99999) + user_pool = list(users_by_name.values()) + if not user_pool: + return 0 + + LIST_DEFS = [ + ('Top 50 Heaviest Games of the Last Decade', + lambda g: g.year_published >= 2014 and g.weight >= 3.5, + lambda g: (-g.weight, g.overall_rank or 99999), + '

The crunchiest cardboard-and-chrome experiences released since 2014. ' + 'Ranked by community weight rating, descending.

'), + ('Best Two-Player Only Games', + lambda g: g.minplayers == 2 and g.maxplayers == 2, + lambda g: (g.overall_rank or 99999,), + '

Hand-curated list of games designed exclusively for two players. ' + 'No "you can play it solo" cop-outs.

'), + ('Sub-30-Minute Fillers I Will Defend', + lambda g: g.maxplaytime > 0 and g.maxplaytime <= 30, + lambda g: (g.overall_rank or 99999,), + '

Quick games that still pack a punch. Perfect for the last hour of game night.

'), + ('Gateway Games for Non-Gamers', + lambda g: g.weight > 0 and g.weight < 2.0 and (g.overall_rank or 99999) < 800, + lambda g: (g.overall_rank or 99999,), + '

Low-weight modern designs that have introduced more people to the hobby ' + 'than any Monopoly Special Edition ever did.

'), + ('Solo Mode That Actually Earns the SKU', + lambda g: 'Solitaire Game' in {c.name for c in g.categories} or g.minplayers == 1, + lambda g: (g.overall_rank or 99999,), + '

Games with bolted-on solo modes are forgivable. These games are designed with ' + 'the lone player in mind from the start.

'), + ('Wargame Newcomers Start Here', + lambda g: 'Wargame' in {c.name for c in g.categories} and (g.overall_rank or 99999) < 2000, + lambda g: (g.overall_rank or 99999,), + '

Light to medium wargames that won\'t scare off the rest of your group.

'), + ('Best Cooperative Games', + lambda g: 'Cooperative Game' in {m.name for m in g.mechanics}, + lambda g: (g.overall_rank or 99999,), + '

Win together or lose together — the best titles in the co-op space.

'), + ('Deckbuilders That Deserve a Place at the Table', + lambda g: 'Deck, Bag, and Pool Building' in {m.name for m in g.mechanics}, + lambda g: (g.overall_rank or 99999,), + '

The deckbuilding genre keeps evolving. These are the standouts beyond the ' + 'classics.

'), + ('Hidden Gems Under 5000 Owners', + lambda g: 0 < g.num_owners < 5000 and g.bayes_average >= 7.0, + lambda g: (-g.bayes_average, g.overall_rank or 99999), + '

Underrated games that fly under the radar. Help me make these owned numbers go up.

'), + ('Designer Spotlight: Vital Lacerda', + lambda g: any('lacerda' in p.name.lower() for p in g.designers), + lambda g: (g.overall_rank or 99999,), + '

Notes and rankings for every Lacerda title.

'), + ('Award Winners 2020-2025', + lambda g: 2020 <= g.year_published <= 2025 and (g.overall_rank or 99999) < 500, + lambda g: (-g.year_published, g.overall_rank or 99999), + '

Spiel des Jahres, Kennerspiel, and Golden Geek winners of the last five years.

'), + ('My Heavy Euro Top 25', + lambda g: g.weight >= 4.0, + lambda g: (-g.bayes_average, g.overall_rank or 99999), + '

If brain-burn was a food group I would die of it.

'), + ] + + n_created = 0 + for li, (title, predicate, sort_key, desc) in enumerate(LIST_DEFS): + author = user_pool[li % len(user_pool)] + l = GeekList(title=title, + description_html=desc, + author_id=author.id, + created_at=MIRROR_NOW - timedelta(days=R.randint(15, 1200)), + num_items=0, + num_thumbs=R.randint(8, 480)) + db.session.add(l) + db.session.flush() + n_created += 1 + # Find matching games + candidates = [g for g in games_sorted if g.overall_rank and predicate(g)] + candidates.sort(key=sort_key) + items = candidates[:25] + for pos, g in enumerate(items, start=1): + comment_body = R.choice([ + f"

{g.name} earns its spot because it nails the central design tension.

", + f"

Brought {g.name} to game night last week and it played beautifully — added at #{pos}.

", + f"

{g.name} keeps finding its way back to the table. Hard to argue with that.

", + f"

Hugely underplayed in our group. {g.name} deserves more love.

", + f"

The decision space in {g.name} is wider than the rulebook page count suggests.

", + ]) + db.session.add(GeekListItem( + list_id=l.id, game_id=g.id, + body_html=comment_body, position=pos, + num_thumbs=R.randint(0, 40), + )) + l.num_items = len(items) + db.session.flush() + return n_created + + +def _parse_user_join_date(s: str | None) -> datetime: + if not s: + return MIRROR_NOW - timedelta(days=_R.randint(365, 8000)) + try: + return datetime.strptime(s, '%Y-%m-%d') + except (ValueError, TypeError): + return MIRROR_NOW - timedelta(days=_R.randint(365, 8000)) + + +def _parse_review_timestamp(s: str | None) -> datetime: + if not s: + return MIRROR_NOW - timedelta(days=_R.randint(30, 3000)) + try: + return datetime.strptime(s, '%Y-%m-%d %H:%M:%S') + except (ValueError, TypeError): + return MIRROR_NOW - timedelta(days=_R.randint(30, 3000)) + + +# ----- benchmark users (deterministic) ----- + +BENCH_USERS = [ + {'username': 'alice_j', 'email': 'alice.j@test.com', 'real_name': 'Alice Johnson', + 'country': 'United States', 'state': 'California', 'city': 'Berkeley', + 'about': 'Heavy euro fan. Lacerda completist. Currently obsessed with Brass: Birmingham.'}, + {'username': 'bob_c', 'email': 'bob.c@test.com', 'real_name': 'Bob Chen', + 'country': 'Canada', 'state': 'British Columbia', 'city': 'Vancouver', + 'about': 'Cooperative games and dungeon crawlers. Gloomhaven scenario 95 grinder.'}, + {'username': 'carol_d', 'email': 'carol.d@test.com', 'real_name': 'Carol Davis', + 'country': 'United Kingdom', 'state': 'Greater London', 'city': 'London', + 'about': 'Two-player only. Race for the Galaxy until the heat-death of the universe.'}, + {'username': 'david_k', 'email': 'david.k@test.com', 'real_name': 'David Kim', + 'country': 'South Korea', 'state': 'Seoul', 'city': 'Seoul', + 'about': 'Wargames and economic sims. Will not apologise for owning every COIN game.'}, +] +BENCH_PASSWORD = 'TestPass123!' + + +def seed_benchmark_users(db, app, bcrypt): + from app import (User, Game, Rating, Collection, Play, GeekList, + GeekListItem, Thread, Post, Forum) + if User.query.filter_by(email='alice.j@test.com').first(): + return + + print('[bgg-seed] benchmark users…') + created = [] + for u in BENCH_USERS: + existing = User.query.filter_by(username=u['username']).first() + if existing: + # Real BGG dataset already has someone by that handle — promote it. + existing.email = u['email'] + existing.password_hash = bcrypt.generate_password_hash(BENCH_PASSWORD).decode() + existing.real_name = u['real_name'] + existing.country = u['country'] + existing.state = u['state'] + existing.city = u['city'] + existing.about = u['about'] + created.append(existing) + continue + new = User(username=u['username'], email=u['email'], + password_hash=bcrypt.generate_password_hash(BENCH_PASSWORD).decode(), + real_name=u['real_name'], country=u['country'], + state=u['state'], city=u['city'], about=u['about'], + joined_at=MIRROR_NOW - timedelta(days=3*365 + 100), + last_login=MIRROR_NOW, geekgold=120, is_supporter=True) + db.session.add(new) + created.append(new) + db.session.flush() + + # Map of user → preferred game categories/mechanics, used to pick their collection + profiles = { + 'alice_j': { + 'weight_range': (3.5, 5.0), + 'preferred_mechanics': {'Worker Placement', 'Network and Route Building', + 'Income', 'Hand Management'}, + 'preferred_categories': {'Economic', 'Industry / Manufacturing'}, + }, + 'bob_c': { + 'weight_range': (2.5, 4.5), + 'preferred_mechanics': {'Cooperative Game', 'Variable Player Powers', + 'Scenario / Mission / Campaign Game'}, + 'preferred_categories': {'Adventure', 'Exploration', 'Fantasy', 'Fighting'}, + }, + 'carol_d': { + 'weight_range': (1.5, 3.5), + 'preferred_mechanics': set(), + 'preferred_categories': set(), + 'players_must_include_two': True, + }, + 'david_k': { + 'weight_range': (3.5, 5.0), + 'preferred_mechanics': {'Area Movement', 'Hexagon Grid', 'Variable Player Powers', + 'Action Points', 'Simultaneous Action Selection'}, + 'preferred_categories': {'Wargame', 'Civilization', 'World War II'}, + }, + } + + # Helper to pick N games for each user, scored by their preferences. + def pick_games_for(uname: str, n: int = 25): + prof = profiles.get(uname, {}) + candidates = [] + for g in Game.query.filter(Game.overall_rank > 0, + Game.overall_rank <= 800).all(): + cats = {c.name for c in g.categories} + mechs = {m.name for m in g.mechanics} + score = 0.0 + if prof.get('weight_range'): + wmin, wmax = prof['weight_range'] + if wmin <= (g.weight or 0) <= wmax: + score += 3 + score += len(cats & prof.get('preferred_categories', set())) * 2 + score += len(mechs & prof.get('preferred_mechanics', set())) * 2 + if prof.get('players_must_include_two'): + if g.minplayers <= 2 <= g.maxplayers: + score += 2 + # Slight preference for higher-ranked + score += max(0, (800 - g.overall_rank) / 800) * 1.5 + if score > 0: + candidates.append((g, score)) + candidates.sort(key=lambda x: (-x[1], x[0].overall_rank)) + return [c[0] for c in candidates[:n]] + + # Pin choices so the seed is deterministic. + R = random.Random(99887766) + + # 1. Collections + ratings + for u in created: + picks = pick_games_for(u.username, n=25) + if not picks: + # Fall back to the top ranked games + picks = Game.query.filter(Game.overall_rank > 0) \ + .order_by(Game.overall_rank).limit(25).all() + for i, g in enumerate(picks): + own = i < 18 # first 18 owned + wish = (18 <= i < 22) # next 4 on wishlist + wantbuy = (22 <= i < 24) # 2 want-to-buy + wantplay = (i == 24) # last 1 want-to-play + e = Collection(user_id=u.id, game_id=g.id, + own=own, wishlist=wish, want_to_buy=wantbuy, + want_to_play=wantplay, + wishlist_priority=(R.randint(1, 5) if wish else 0), + comment=R.choice([ + '', '', '', # most blank + 'Shelf of shame for now.', + 'Sleeved and ready for the next session.', + 'Trade me if you want one.', + 'KS edition with all stretch goals.', + ]), + acquired_on=(MIRROR_NOW - timedelta(days=R.randint(40, 1800))).strftime('%Y-%m-%d') if own else '', + updated_at=MIRROR_NOW - timedelta(days=R.randint(1, 200))) + db.session.add(e) + # Owned games get a personal rating + if own: + base = 7.5 + R.uniform(-1.5, 2.0) + if g.bayes_average: + base = 0.6 * base + 0.4 * g.bayes_average + value = round(min(10.0, max(3.0, base)) * 2) / 2 # half-step + review = '' + # First 5 owned per user become full text reviews + if i < 5: + review = R.choice([ + f"

{g.name} has stayed in my regular rotation for years. The decision points always feel meaningful, and the table arc lands on a tight finish.

", + f"

I almost sold {g.name} after my second play. Then a friend insisted on one more game and I finally saw the shape of it. Now it's a top-10 keeper.

", + f"

{g.name} is the game I pull out when I want to win an argument about what 'elegant' means in the hobby.

", + f"

The components in {g.name} are excellent, but what surprises me each play is how different the path-to-victory feels. Highly recommended for the right group.

", + ]) + rt = Rating(user_id=u.id, game_id=g.id, value=value, + review_html=review, + created_at=MIRROR_NOW - timedelta(days=R.randint(10, 900)), + num_thumbs=R.randint(0, 30) if review else 0) + db.session.add(rt) + + db.session.flush() + + # 2. Plays log — last 60 days of plays per user + for u in created: + owned = Collection.query.filter_by(user_id=u.id, own=True).all() + owned_games = [db.session.get(Game, e.game_id) for e in owned] + owned_games = [g for g in owned_games if g] + if not owned_games: + continue + for play_i in range(R.randint(10, 22)): + g = R.choice(owned_games) + played = (MIRROR_NOW - timedelta(days=R.randint(1, 90))).date() + db.session.add(Play(user_id=u.id, game_id=g.id, played_on=played, + quantity=1, + length_minutes=(g.minplaytime + g.maxplaytime) // 2 if g.maxplaytime else 60, + num_players=max(g.minplayers, + R.randint(g.minplayers, max(g.minplayers, g.maxplayers))), + location=R.choice(['Home', 'Friend\'s House', 'Game Cafe', + 'Convention', 'FLGS']), + comments=R.choice(['', + 'Tight finish, decided on the last turn.', + 'Tried a new strategy — paid off.', + 'Solo mode this time, lost narrowly.', + 'Taught two new players, they want to play again.']))) + + # 3. Each benchmark user authors one GeekList + bench_lists = [ + ('alice_j', 'Alice\'s Lacerda Project', + '

Working through every Vital Lacerda title in publication order. Half ranking, half therapy.

', + lambda g: any('lacerda' in p.name.lower() for p in g.designers) or g.weight >= 4.0, + 12), + ('bob_c', 'Bob\'s Co-op Rotation', + '

Cooperative games currently in our weekly rotation.

', + lambda g: 'Cooperative Game' in {m.name for m in g.mechanics}, + 12), + ('carol_d', 'Carol\'s Two-Player-Only Shelf', + '

Designed for two. No "scales to 5" compromises. The shelf as it stands today.

', + lambda g: g.minplayers == 2 and g.maxplayers == 2, + 10), + ('david_k', 'David\'s COIN Lectern', + '

Every entry in the GMT COIN series I own, ranked by how often it hits the table.

', + lambda g: 'Wargame' in {c.name for c in g.categories} and g.weight >= 3.5, + 10), + ] + for uname, title, desc, predicate, n in bench_lists: + u = User.query.filter_by(username=uname).first() + if not u: + continue + candidates = [g for g in Game.query.all() if g.overall_rank and predicate(g)] + candidates.sort(key=lambda g: g.overall_rank or 99999) + picks = candidates[:n] + if not picks: + continue + gl = GeekList(title=title, description_html=desc, + author_id=u.id, + created_at=MIRROR_NOW - timedelta(days=R.randint(40, 800)), + num_items=len(picks), + num_thumbs=R.randint(20, 250)) + db.session.add(gl) + db.session.flush() + for pos, g in enumerate(picks, start=1): + db.session.add(GeekListItem( + list_id=gl.id, game_id=g.id, position=pos, + body_html=f'

{u.real_name.split()[0]}\'s note: {g.name} belongs here because it nails the brief.

', + num_thumbs=R.randint(0, 30), + )) + + db.session.flush() + + # 4. Forum activity — each user starts one thread + replies to a few others + general_forum = Forum.query.filter_by(title='General Gaming').first() + rec_forum = Forum.query.filter_by(title='Recommendations').first() + target_forums = [f for f in [general_forum, rec_forum] if f] + for u, opening in zip(created, [ + ('What\'s your highest-rated 2024 release?', + '

Yearly thread. Mine is Cyclades: Legendary Edition, against my own expectations.

'), + ('Looking for co-op suggestions that AREN\'T Pandemic / Spirit Island', + '

I love both, but the group has played them to death. What else should be in the conversation?

'), + ('Best 2-player only deckbuilder?', + '

I have Star Realms and 7 Wonders Duel. Looking for one more — preferably under 45 minutes.

'), + ('Wargames at 2 players in under 3 hours', + '

Title says it all. Block wargames welcome.

'), + ]): + if not target_forums: + break + fobj = target_forums[(u.id) % len(target_forums)] + t = Thread(forum_id=fobj.id, subject=opening[0], author_id=u.id, + num_posts=1, num_views=R.randint(60, 800), + created_at=MIRROR_NOW - timedelta(days=R.randint(7, 120)), + last_post_at=MIRROR_NOW - timedelta(days=R.randint(1, 6))) + db.session.add(t) + db.session.flush() + fobj.num_threads = (fobj.num_threads or 0) + 1 + fobj.num_posts = (fobj.num_posts or 0) + 1 + db.session.add(Post(thread_id=t.id, author_id=u.id, + body_html=opening[1], + created_at=t.created_at, thumbs=R.randint(0, 18))) + + db.session.commit() + print(f'[bgg-seed] benchmark seed done. (users={len(created)})') diff --git a/sites/boardgamegeek/static/css/bgg.css b/sites/boardgamegeek/static/css/bgg.css new file mode 100644 index 00000000..97089806 --- /dev/null +++ b/sites/boardgamegeek/static/css/bgg.css @@ -0,0 +1,422 @@ +/* BoardGameGeek mirror — recreates the table-heavy, slightly retro look + of the real site. Color palette adapted from boardgamegeek.com (2025). */ + +:root { + --bgg-orange: #FF5100; + --bgg-orange-light: #FFB380; + --bgg-dark: #181C1E; + --bgg-blue: #5369A2; + --bgg-blue-dark: #303F70; + --bgg-green: #249563; + --bgg-red: #DB303F; + --bgg-bg: #f5f5f5; + --bgg-paper: #ffffff; + --bgg-grey: #E8E8E8; + --bgg-text: #2c3e50; + --bgg-muted: #6c757d; + --bgg-link: #103B7C; + --bgg-link-hover: #FF5100; + --bgg-border: #d4d4d4; + --bgg-card-shadow: 0 1px 3px rgba(0,0,0,0.07); +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; padding: 0; + background: var(--bgg-bg); + color: var(--bgg-text); + font-family: Verdana, Geneva, Tahoma, sans-serif; + font-size: 13px; + line-height: 1.45; +} + +a { color: var(--bgg-link); text-decoration: none; } +a:hover { color: var(--bgg-link-hover); text-decoration: underline; } + +h1, h2, h3, h4, h5 { font-family: 'Trebuchet MS', sans-serif; font-weight: 600; color: var(--bgg-blue-dark); margin: 12px 0 8px; } +h1 { font-size: 22px; } +h2 { font-size: 18px; } +h3 { font-size: 16px; } + +img { vertical-align: middle; } + +/* ----- header ----- */ +.site-header { + background: var(--bgg-dark); + color: #fff; + border-bottom: 3px solid var(--bgg-orange); +} +.site-header .topbar { + max-width: 1280px; margin: 0 auto; + padding: 8px 16px; + display: flex; align-items: center; gap: 18px; +} +.site-header .logo { + font-family: 'Trebuchet MS', sans-serif; + font-size: 24px; font-weight: 700; + color: #fff; letter-spacing: 0.5px; +} +.site-header .logo .accent { color: var(--bgg-orange); } +.site-header a { color: #e6e6e6; } +.site-header a:hover { color: var(--bgg-orange); text-decoration: none; } +.site-header nav { display: flex; gap: 14px; flex: 1; font-size: 13px; } +.site-header nav a { padding: 4px 0; } +.site-header .user-area { display: flex; align-items: center; gap: 12px; font-size: 12px; } +.search-bar { + display: flex; align-items: stretch; + border: 1px solid #555; + border-radius: 3px; + overflow: hidden; + background: #2a2f33; +} +.search-bar input[type="text"] { + background: #2a2f33; color: #fff; + border: 0; padding: 6px 8px; + font-size: 12px; width: 180px; + outline: 0; +} +.search-bar select { + background: #2a2f33; color: #fff; + border: 0; border-left: 1px solid #555; + font-size: 12px; padding: 0 6px; + outline: 0; +} +.search-bar button { + background: var(--bgg-orange); color: #fff; + border: 0; padding: 4px 10px; + cursor: pointer; font-weight: 600; +} + +.subnav { + background: #2C3438; color: #e6e6e6; + font-size: 12px; +} +.subnav .inner { + max-width: 1280px; margin: 0 auto; + padding: 6px 16px; display: flex; gap: 18px; + flex-wrap: wrap; +} +.subnav a { color: #e6e6e6; } +.subnav a:hover { color: var(--bgg-orange); } + +/* ----- layout ----- */ +.page { + max-width: 1280px; margin: 0 auto; + padding: 16px; + display: grid; grid-template-columns: 1fr 320px; + gap: 16px; +} +.page.single { grid-template-columns: 1fr; } +.main, .sidebar { + background: var(--bgg-paper); + border: 1px solid var(--bgg-border); + border-radius: 4px; + padding: 14px 18px; + box-shadow: var(--bgg-card-shadow); +} + +/* ----- flash ----- */ +.flash { + padding: 8px 12px; border-radius: 3px; + margin: 0 0 14px; font-size: 13px; +} +.flash.success { background: #e2f3e3; color: #2c5a2e; border: 1px solid #b9dfba; } +.flash.error { background: #fde2e2; color: #7a2424; border: 1px solid #f5b5b5; } + +/* ----- tables ----- */ +.collection_table { + width: 100%; border-collapse: collapse; + font-size: 12px; +} +.collection_table th { + background: var(--bgg-blue); color: #fff; + text-align: left; padding: 6px 8px; + font-size: 11px; text-transform: uppercase; + font-weight: 600; +} +.collection_table th a { color: #fff; } +.collection_table td { + padding: 6px 8px; + border-bottom: 1px solid #ececec; + vertical-align: middle; +} +.collection_table tr:nth-child(even) { background: #fafbfc; } +.collection_table tr:hover { background: #fff6ed; } +.collection_table .collection_rank { width: 50px; text-align: center; color: var(--bgg-muted); font-weight: 600; } +.collection_table .collection_thumbnail { width: 56px; } +.collection_table .collection_thumbnail img { + width: 50px; height: 50px; object-fit: cover; border-radius: 2px; + border: 1px solid var(--bgg-border); +} +.collection_table .collection_objectname { min-width: 280px; } +.collection_table .collection_objectname a.primary { + font-weight: 700; color: var(--bgg-link); font-size: 13px; +} +.collection_table .collection_objectname .smallerfont { color: var(--bgg-muted); font-size: 11px; margin-left: 4px; } +.collection_table .collection_bggrating { width: 70px; text-align: center; } + +.rating-chip { + display: inline-block; + padding: 2px 6px; + border-radius: 3px; + color: #fff; font-weight: 700; font-size: 12px; + min-width: 36px; text-align: center; +} + +/* ----- box layout for the homepage / panels ----- */ +.module { + background: var(--bgg-paper); + border: 1px solid var(--bgg-border); + border-radius: 4px; + margin-bottom: 14px; + overflow: hidden; +} +.module .module-header { + background: var(--bgg-blue); + color: #fff; + padding: 6px 12px; + font-weight: 600; + font-size: 13px; + display: flex; justify-content: space-between; align-items: center; +} +.module .module-header a { color: #fff; font-size: 11px; } +.module .module-body { padding: 10px 12px; } + +/* ----- game grid (hot list) ----- */ +.game-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 10px; +} +.game-grid .game-card { + text-align: center; font-size: 12px; +} +.game-grid .game-card img { + width: 100%; aspect-ratio: 1 / 1; object-fit: cover; + border: 1px solid var(--bgg-border); border-radius: 3px; +} +.game-grid .game-card .title { display: block; margin-top: 4px; font-weight: 600; } +.game-grid .game-card .meta { color: var(--bgg-muted); font-size: 11px; } + +/* ----- item (game detail) page ----- */ +.game-header { + display: grid; + grid-template-columns: 240px 1fr 240px; + gap: 20px; + align-items: start; +} +.game-header .cover img { + width: 100%; + border: 1px solid var(--bgg-border); + border-radius: 3px; + background: #f0f0f0; +} +.game-header .summary h1 { + margin-top: 0; font-size: 26px; line-height: 1.1; color: #181c1e; +} +.game-header .summary .year { + color: var(--bgg-muted); font-weight: 400; font-size: 18px; +} +.game-header .summary .meta { + font-size: 13px; color: #333; margin: 6px 0 12px; +} +.game-header .summary .meta a { color: var(--bgg-link); } +.stats-box { + border: 1px solid var(--bgg-border); + border-radius: 3px; padding: 10px; + font-size: 12px; background: #fafbfc; +} +.stats-box .stat-row { + display: flex; justify-content: space-between; + padding: 4px 0; border-bottom: 1px dashed #ddd; +} +.stats-box .stat-row:last-child { border-bottom: 0; } +.stats-box .stat-label { color: var(--bgg-muted); } +.stats-box .stat-value { font-weight: 600; } + +.tab-bar { + border-bottom: 2px solid var(--bgg-orange); + margin-top: 18px; + display: flex; gap: 0; +} +.tab-bar a { + padding: 8px 14px; + font-size: 12px; font-weight: 600; + color: var(--bgg-text); + border: 1px solid transparent; + border-bottom: 0; + border-radius: 4px 4px 0 0; + margin-right: 2px; + text-transform: uppercase; +} +.tab-bar a.active { + background: var(--bgg-orange); color: #fff; +} +.tab-bar a:hover:not(.active) { + background: #fff3e7; color: var(--bgg-orange); text-decoration: none; +} + +/* ----- forms ----- */ +form .field { margin-bottom: 10px; } +form label { display: block; font-weight: 600; margin-bottom: 3px; font-size: 12px; color: var(--bgg-text); } +form input[type=text], form input[type=email], form input[type=password], +form input[type=number], form select, form textarea { + width: 100%; padding: 6px 8px; + border: 1px solid var(--bgg-border); + border-radius: 3px; + font-family: inherit; font-size: 13px; + background: #fff; +} +form textarea { min-height: 100px; resize: vertical; } +form .form-row { display: flex; gap: 10px; } +form .form-row .field { flex: 1; } + +button, .btn { + display: inline-block; + padding: 6px 14px; + border: 1px solid var(--bgg-blue-dark); + background: var(--bgg-blue); + color: #fff !important; + border-radius: 3px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + text-transform: uppercase; + text-decoration: none !important; +} +button:hover, .btn:hover { + background: var(--bgg-blue-dark); +} +.btn-orange { background: var(--bgg-orange); border-color: #c14000; } +.btn-orange:hover { background: #c14000; } +.btn-quiet { + background: transparent; + color: var(--bgg-link) !important; + border: 1px solid var(--bgg-border); +} +.btn-quiet:hover { background: #f6f6f6; color: var(--bgg-link-hover) !important; } +.btn-row { display: flex; gap: 8px; flex-wrap: wrap; } + +/* ----- comments / posts ----- */ +.post { + border: 1px solid var(--bgg-border); + border-radius: 3px; + margin-bottom: 10px; + background: #fff; + display: grid; + grid-template-columns: 160px 1fr; +} +.post .post-author { + background: #f1f4f9; + padding: 12px; + border-right: 1px solid var(--bgg-border); + font-size: 12px; text-align: center; +} +.post .post-author .username { font-weight: 700; } +.post .post-author .location { color: var(--bgg-muted); font-size: 11px; } +.post .post-body { + padding: 12px 14px; + font-size: 13px; +} +.post .post-meta { + display: flex; justify-content: space-between; + color: var(--bgg-muted); font-size: 11px; + border-bottom: 1px dotted var(--bgg-border); + padding-bottom: 6px; margin-bottom: 8px; +} + +.review-block { + border: 1px solid var(--bgg-border); + border-left: 4px solid var(--bgg-orange); + border-radius: 3px; + padding: 10px 12px; + background: #fff; + margin-bottom: 10px; +} +.review-block .review-head { + display: flex; justify-content: space-between; + align-items: center; + font-size: 12px; margin-bottom: 6px; +} +.review-block .review-head .left { display: flex; gap: 10px; align-items: center; } + +/* ----- pagination ----- */ +.pagination { + margin: 14px 0; text-align: center; font-size: 12px; +} +.pagination a, .pagination span { + display: inline-block; padding: 4px 8px; + border: 1px solid var(--bgg-border); border-radius: 3px; + margin: 0 2px; min-width: 30px; +} +.pagination span.current { background: var(--bgg-orange); color: #fff; border-color: var(--bgg-orange); } + +/* ----- breadcrumb ----- */ +.crumb { font-size: 11px; color: var(--bgg-muted); margin-bottom: 6px; } +.crumb a { color: var(--bgg-link); } + +/* ----- thread row ----- */ +.thread-row { + display: grid; + grid-template-columns: 1fr 90px 100px 140px; + padding: 6px 8px; + border-bottom: 1px solid #ececec; + font-size: 12px; align-items: center; +} +.thread-row:hover { background: #fff6ed; } +.thread-row.pinned { background: #fffbe5; } +.thread-row .subject a { font-weight: 600; } +.thread-row .pin-badge { color: var(--bgg-orange); font-weight: 700; font-size: 10px; margin-right: 4px; } +.thread-row .replies, .thread-row .views { text-align: center; color: var(--bgg-muted); } +.thread-row .last { color: var(--bgg-muted); font-size: 11px; } +.thread-row .last .user { color: var(--bgg-link); } + +/* ----- footer ----- */ +.site-footer { + background: var(--bgg-dark); color: #ccc; + padding: 16px; + text-align: center; + font-size: 11px; + margin-top: 30px; +} +.site-footer a { color: var(--bgg-orange-light); } + +/* ----- responsive: collapse to single column under 900 ----- */ +@media (max-width: 900px) { + .page { grid-template-columns: 1fr; } + .sidebar { order: 2; } + .game-header { grid-template-columns: 1fr; } + .post { grid-template-columns: 1fr; } + .post .post-author { border-right: 0; border-bottom: 1px solid var(--bgg-border); } +} + +/* ----- ratings histogram ----- */ +.hist-table { width: 100%; font-size: 12px; } +.hist-table td { padding: 2px 4px; } +.hist-table .bar-cell { width: 60%; } +.hist-bar { + background: var(--bgg-orange); + height: 12px; border-radius: 2px; +} + +/* ----- person/category page ----- */ +.entity-head { + display: grid; grid-template-columns: 90px 1fr; gap: 14px; + margin-bottom: 14px; +} +.entity-head .label { + display: inline-block; + background: var(--bgg-blue); color: #fff; padding: 2px 6px; + font-size: 11px; text-transform: uppercase; border-radius: 2px; +} + +/* ----- description -----*/ +.description { + font-family: Verdana, sans-serif; + font-size: 13px; line-height: 1.55; +} +.description p { margin: 0 0 10px; } +.description em { color: var(--bgg-text); } + +.muted { color: var(--bgg-muted); font-size: 11px; } diff --git a/sites/boardgamegeek/static/icons/cover_placeholder.svg b/sites/boardgamegeek/static/icons/cover_placeholder.svg new file mode 100644 index 00000000..0ddede58 --- /dev/null +++ b/sites/boardgamegeek/static/icons/cover_placeholder.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + BGG + diff --git a/sites/boardgamegeek/static/icons/favicon.svg b/sites/boardgamegeek/static/icons/favicon.svg new file mode 100644 index 00000000..cb2d8bd8 --- /dev/null +++ b/sites/boardgamegeek/static/icons/favicon.svg @@ -0,0 +1,5 @@ + + + B + diff --git a/sites/boardgamegeek/tasks.jsonl b/sites/boardgamegeek/tasks.jsonl new file mode 100644 index 00000000..63502af5 --- /dev/null +++ b/sites/boardgamegeek/tasks.jsonl @@ -0,0 +1,21 @@ +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--0", "ques": "Find the #1 ranked board game on BoardGameGeek and report its designer(s). (Designers are not shown on the rank table — open the game's detail page.)", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--1", "ques": "Search for 'Gloomhaven' and report its average rating, weight, and number of voters from the game page.", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--2", "ques": "On the Browse page, sort games by weight descending. Among the games listed on the first page of weight-sorted results, find the one whose overall rank is in the top 100 AND whose weight is the highest. Report its name and weight score.", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--3", "ques": "Find the highest-ranked board game whose mechanism includes 'Worker Placement' and list its top 3 designers.", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--4", "ques": "Sign in as alice_j (password TestPass123!). How many games are in her owned collection?", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--5", "ques": "Sign in as bob_c (password TestPass123!), open Brass: Birmingham, rate it 9.5 and write a one-sentence review.", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--6", "ques": "Sign in as carol_d (password TestPass123!) and add the highest-ranked 2-player-only deckbuilder in the catalog to her wishlist with priority 'Must have'.", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--7", "ques": "On the Hot page, identify the most recently published (latest year) game in the current Hotness list.", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--8", "ques": "Open the GeekList named 'Best Cooperative Games'. Who is the author and how many items does it have?", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--9", "ques": "Sign in as david_k (password TestPass123!) and create a new GeekList titled 'My COIN Series Picks' with the description 'Light, deep, and historical.'", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--10", "ques": "Compare Brass: Birmingham and Ark Nova on their game pages — which one has the heavier weight (higher complexity)?", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--11", "ques": "Open the publisher page for 'Z-Man Games' (use the Publishers index — you may have to filter by name). Report how many games are listed under that publisher in our catalog.", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--12", "ques": "Browse to the 'Action Points' mechanism page. Among games using Action Points, what is the highest-ranked one and what is its overall rank?", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--13", "ques": "Sign in as alice_j (password TestPass123!). Open her collection sorted by 'Most recently updated' and remove the most-recently-updated entry from her collection.", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--14", "ques": "Open Wingspan and navigate to its Ratings & Reviews tab. Sort by 'Most Helpful' (thumbs) and report the username and thumb count of the top review. (The Overview tab does not list individual reviews — you must open the Ratings tab.)", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--15", "ques": "In the Recommendations forum, find an open thread (not pinned, not locked) about 2-player games, open it, and post a reply suggesting '7 Wonders Duel' as an alternative. (Sign in as bob_c first, password TestPass123!.)", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--16", "ques": "Open the 'Top 50 Heaviest Games of the Last Decade' GeekList. What is the #1 entry on that list?", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--17", "ques": "Sign in as david_k (password TestPass123!). Open his Plays page and report how many distinct games he has logged plays for.", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--18", "ques": "Find Vital Lacerda's designer page (the Designers index supports filtering by name). Among his games in the catalog, which one has the highest average rating?", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--19", "ques": "Search for users by the keyword 'mike'. Open the first matching user profile (alphabetical) and report how many GeekLists they have authored.", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--20", "ques": "Open Twilight Struggle's Expansions page. How many expansions are listed there?", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} diff --git a/sites/boardgamegeek/templates/about.html b/sites/boardgamegeek/templates/about.html new file mode 100644 index 00000000..3d146c3d --- /dev/null +++ b/sites/boardgamegeek/templates/about.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% block title %}About | {{ site_name }}{% endblock %} +{% block content %} +
+
+

About BoardGameGeek

+

BoardGameGeek (BGG) is the world's largest online community of board game enthusiasts. Since 2000, BGG has been the place where players go to look up rules, read reviews, log plays, swap games and argue about which Lacerda title is the best one.

+

This is a WebHarbor mirror of the real boardgamegeek.com — a deterministic local environment used to benchmark web agents. Every page, every link, every rating is backed by data scraped from the real site so that the look and behavior match closely, but the environment is fully offline and resets to a known state between benchmark runs.

+

Real site: boardgamegeek.com

+
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/account.html b/sites/boardgamegeek/templates/account.html new file mode 100644 index 00000000..ae00d06e --- /dev/null +++ b/sites/boardgamegeek/templates/account.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block title %}Account | {{ site_name }}{% endblock %} +{% block content %} +
+
+

Account — {{ current_user.username }}

+
+ {{ form.hidden_tag() }} +
{{ form.real_name.label }}{{ form.real_name() }}
+
+
{{ form.country.label }}{{ form.country() }}
+
{{ form.state.label }}{{ form.state() }}
+
{{ form.city.label }}{{ form.city() }}
+
+
{{ form.about.label }}{{ form.about() }}
+ + Cancel +
+
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/base.html b/sites/boardgamegeek/templates/base.html new file mode 100644 index 00000000..b81e07d7 --- /dev/null +++ b/sites/boardgamegeek/templates/base.html @@ -0,0 +1,88 @@ + + + + + {% block title %}{{ site_name }}{% endblock %} + + + + {% block head_extra %}{% endblock %} + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for cat, msg in messages %} +
{{ msg }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ +
+ © {{ current_year }} BoardGameGeek Mirror · WebHarbor benchmark environment · + About · + Help +
+ + + diff --git a/sites/boardgamegeek/templates/browse.html b/sites/boardgamegeek/templates/browse.html new file mode 100644 index 00000000..aa93843f --- /dev/null +++ b/sites/boardgamegeek/templates/browse.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} +{% block title %}Browse Board Games | {{ site_name }}{% endblock %} +{% block content %} +
+
+

Browse Board Games

+

+ Showing {{ ((page - 1) * per_page) + 1 }} – {{ ((page - 1) * per_page) + games | length }} + of {{ total | thousands }} games. + Sort by: + Rank · + Name · + Year · + Average Rating · + Voters · + Weight +

+ + + + + + + + + + + + + + + {% for g in games %} + + + + + + + + + + {% endfor %} + +
RankTitleGeek RatingAvg RatingNum VotersWeight
{{ g.overall_rank or '—' }} + {% if g.thumb_filename %} + + {% endif %} + + {{ g.name }} + ({{ g.year_published or '—' }}) + {% if g.short_description %} +
{{ g.short_description[:120] }}{% if g.short_description | length > 120 %}…{% endif %}
+ {% endif %} +
{{ g.bayes_average | one_decimal }}{{ g.avg_rating | one_decimal }}{{ g.num_ratings | thousands }}{{ g.weight | two_decimal }}
+ + +
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/collection.html b/sites/boardgamegeek/templates/collection.html new file mode 100644 index 00000000..16acf3f7 --- /dev/null +++ b/sites/boardgamegeek/templates/collection.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} +{% block title %}{{ u.username }}'s collection | {{ site_name }}{% endblock %} +{% block content %} +
+
+

{{ u.username }}'s collection

+

+ Filter: + Own · + Previously owned · + Wishlist · + Want to play · + Want to buy · + Pre-ordered · + For trade · + Rated +

+

+ Sort: Name · + Rank · + My rating · + Year · + Most recently updated · + Acquired +

+

{{ total }} games in this view.

+ + + + + + + + + + + {% for x in entries %} + + + + + + + + + {% else %} + + {% endfor %} + +
TitleMy ratingRankYearComment
+ {% if x.game.thumb_filename %}{% endif %} + + {{ x.game.name }} + + {% if x.entry.own %}[own]{% endif %} + {% if x.entry.wishlist %}[wish #{{ x.entry.wishlist_priority }}]{% endif %} + {% if x.entry.want_to_buy %}[want]{% endif %} + {% if x.entry.want_to_play %}[wanttoplay]{% endif %} + {% if x.entry.preordered %}[preorder]{% endif %} + {% if x.entry.for_trade %}[trade]{% endif %} + + + {% if x.rating %}{{ x.rating.value | one_decimal }} + {% else %}{% endif %} + {{ x.game.overall_rank or '—' }}{{ x.game.year_published or '—' }}{{ x.entry.comment }}
No games match this filter.
+
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/credits.html b/sites/boardgamegeek/templates/credits.html new file mode 100644 index 00000000..38934e6a --- /dev/null +++ b/sites/boardgamegeek/templates/credits.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} +{% block title %}Credits — {{ g.name }} | {{ site_name }}{% endblock %} +{% block content %} +
+
+
+ Browse › + {{ g.name }} › Credits +
+

{{ g.name }} — Credits

+ +

Designers

+
    + {% for d in g.designers %} +
  • {{ d.name }}
  • + {% else %}
  • {% endfor %} +
+ +

Artists

+
    + {% for a in g.artists %} +
  • {{ a.name }}
  • + {% else %}
  • {% endfor %} +
+ +

Publishers

+
    + {% for p in g.publishers %} +
  • {{ p.name }}
  • + {% else %}
  • {% endfor %} +
+ +

Categories

+
    + {% for c in g.categories %} +
  • {{ c.name }}
  • + {% else %}
  • {% endfor %} +
+ +

Mechanisms

+
    + {% for m in g.mechanics %} +
  • {{ m.name }}
  • + {% else %}
  • {% endfor %} +
+ + {% if g.families %} +

Families

+
    + {% for f in g.families %} +
  • {{ f.name }}
  • + {% endfor %} +
+ {% endif %} +
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/expansions.html b/sites/boardgamegeek/templates/expansions.html new file mode 100644 index 00000000..5e33f085 --- /dev/null +++ b/sites/boardgamegeek/templates/expansions.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% block title %}Expansions — {{ g.name }} | {{ site_name }}{% endblock %} +{% block content %} +
+
+
+ Browse › + {{ g.name }} › Expansions +
+

{{ g.name }} — Expansions ({{ expansions | length }})

+ {% if expansions %} + + + + + + + + {% for e in expansions %} + + + + + + + + {% endfor %} + +
TitleYearAvg RatingVoters
+ {% if e.thumb_filename %}{% endif %} + + {{ e.name }} + {{ e.year_published or '—' }}{{ e.avg_rating | one_decimal }}{{ e.num_ratings | thousands }}
+ {% else %}

No expansions on record.

{% endif %} +
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/forgot.html b/sites/boardgamegeek/templates/forgot.html new file mode 100644 index 00000000..1d14362c --- /dev/null +++ b/sites/boardgamegeek/templates/forgot.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block title %}Forgot Password | {{ site_name }}{% endblock %} +{% block content %} +
+
+

Forgot Password

+ {% if msg %}
{{ msg }}
{% endif %} +
+ +
+ + +
+ +
+
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/forum.html b/sites/boardgamegeek/templates/forum.html new file mode 100644 index 00000000..aa498fed --- /dev/null +++ b/sites/boardgamegeek/templates/forum.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} +{% block title %}{{ forum.title }} | {{ site_name }}{% endblock %} +{% block content %} +
+
+
+ Forums › + {% if forum.game %}{{ forum.game.name }} ›{% endif %} + {{ forum.title }} +
+

{{ forum.title }}

+

{{ forum.description }}

+ + {% if current_user.is_authenticated and not forum.id == 0 %} +

+ New Thread

+ {% endif %} + + {% for t in threads %} +
+
+ {% if t.is_pinned %}PIN{% endif %} + {% if t.is_hot %}🔥{% endif %} + {{ t.subject }} +
started by {{ t.author.username }} · {{ t.created_at | time_ago }}
+
+
{{ t.num_posts - 1 }} replies
+
{{ t.num_views | thousands }} views
+
last: {{ t.last_post_at | time_ago }}
+
+ {% else %} +

No threads in this forum yet.

+ {% endfor %} + + +
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/forums_index.html b/sites/boardgamegeek/templates/forums_index.html new file mode 100644 index 00000000..973cd9cc --- /dev/null +++ b/sites/boardgamegeek/templates/forums_index.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}Forums | {{ site_name }}{% endblock %} +{% block content %} +
+
+

BoardGameGeek Forums

+ {% for section_name, forums in sections.items() %} +

{{ section_name | capitalize }}

+ + + + + + + + {% for f in forums %} + + + + + + {% endfor %} + +
ForumThreadsPosts
+ {{ f.title }} +
{{ f.description }}
+
{{ f.num_threads | thousands }}{{ f.num_posts | thousands }}
+ {% endfor %} +
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/game_forums.html b/sites/boardgamegeek/templates/game_forums.html new file mode 100644 index 00000000..2ae5322d --- /dev/null +++ b/sites/boardgamegeek/templates/game_forums.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}Forums — {{ g.name }} | {{ site_name }}{% endblock %} +{% block content %} +
+
+
+ Browse › + {{ g.name }} › Forums +
+

{{ g.name }} — Forums

+ + + + + + + {% for f in forums %} + + + + + + {% endfor %} + +
ForumThreadsPosts
+ {{ f.title }} +
{{ f.description }}
+
{{ f.num_threads | thousands }}{{ f.num_posts | thousands }}
+
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/geeklist.html b/sites/boardgamegeek/templates/geeklist.html new file mode 100644 index 00000000..070fbe57 --- /dev/null +++ b/sites/boardgamegeek/templates/geeklist.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} +{% block title %}{{ l.title }} | {{ site_name }}{% endblock %} +{% block content %} +
+
+
GeekLists › {{ l.title }}
+

{{ l.title }}

+

+ by {{ l.author.username }} + · {{ l.num_items }} items · 👍 {{ l.num_thumbs }} · {{ l.created_at | time_ago }} + {% if current_user.is_authenticated %} +

+ + + + + +
+ {% endif %} +

+
{{ l.description_html | safe_html }}
+ + {% for it in items %} +
+
+
+ #{{ it.position }} + {% if it.game %} + + {% if it.game.thumb_filename %}{% endif %} + {{ it.game.name }} + ({{ it.game.year_published or '—' }}) + + {% else %} + (custom item) + {% endif %} +
+
👍 {{ it.num_thumbs }}
+
+
{{ it.body_html | safe_html }}
+
+ {% endfor %} + + {% if current_user.is_authenticated and l.author_id == current_user.id %} +

Add an item

+
+ +
+
+
+
+ +
+ {% endif %} +
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/geeklist_new.html b/sites/boardgamegeek/templates/geeklist_new.html new file mode 100644 index 00000000..c453c077 --- /dev/null +++ b/sites/boardgamegeek/templates/geeklist_new.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block title %}New GeekList | {{ site_name }}{% endblock %} +{% block content %} +
+
+

New GeekList

+
+ {{ form.hidden_tag() }} +
{{ form.title.label }}{{ form.title() }}
+
{{ form.description.label }}{{ form.description() }}
+
+ + Cancel +
+
+
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/geeklists.html b/sites/boardgamegeek/templates/geeklists.html new file mode 100644 index 00000000..7ac9142d --- /dev/null +++ b/sites/boardgamegeek/templates/geeklists.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% block title %}GeekLists | {{ site_name }}{% endblock %} +{% block content %} +
+
+

GeekLists

+

+ {{ total }} lists. Sort: + Recent · + Most thumbed · + Most items + {% if current_user.is_authenticated %} + · + New GeekList + {% endif %} +

+ + + + + + + + + + {% for l in lists %} + + + + + + + + {% endfor %} + +
TitleAuthorItems👍Created
+ {{ l.title }} + {{ l.author.username }}{{ l.num_items }}{{ l.num_thumbs }}{{ l.created_at | time_ago }}
+ +
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/help.html b/sites/boardgamegeek/templates/help.html new file mode 100644 index 00000000..8e736b65 --- /dev/null +++ b/sites/boardgamegeek/templates/help.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block title %}Help | {{ site_name }}{% endblock %} +{% block content %} +
+
+

Help & FAQ

+

Sections of the site

+
    +
  • Browse — the master rank table of all board games.
  • +
  • Hot — what the community is talking about right now.
  • +
  • Forums — site-wide discussion plus per-game subforums.
  • +
  • GeekLists — curated lists of games.
  • +
  • Your account — collection, plays, ratings, geek profile.
  • +
+

How rankings work

+

Each game has an average rating (raw mean of every user's score) and a Geek Rating (Bayesian-adjusted average that prevents low-vote games from topping the list).

+

Reporting issues

+

This is a WebHarbor benchmark mirror; please file issues on the parent project.

+
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/hotness.html b/sites/boardgamegeek/templates/hotness.html new file mode 100644 index 00000000..929ff345 --- /dev/null +++ b/sites/boardgamegeek/templates/hotness.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% block title %}The Hotness | {{ site_name }}{% endblock %} +{% block content %} +
+
+

The Hotness

+

The games BoardGameGeek users are talking about most right now (top 50).

+ +
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/index.html b/sites/boardgamegeek/templates/index.html new file mode 100644 index 00000000..748eb364 --- /dev/null +++ b/sites/boardgamegeek/templates/index.html @@ -0,0 +1,134 @@ +{% extends "base.html" %} +{% block title %}{{ site_name }} | Gaming Unplugged Since 2000{% endblock %} +{% block content %} +
+
+ {# Hot games — large grid #} + + + {# Top overall #} +
+
+ Top Ranked Board Games + Full browse → +
+
+ + + + + + + + + {% for g in top_overall %} + + + + + + + + + {% endfor %} + +
#TitleGeek RatingAvgVoters
{{ g.overall_rank }} + {% if g.thumb_filename %} + + {% endif %} + + {{ g.name }} + ({{ g.year_published or '—' }}) + {{ g.bayes_average | one_decimal }}{{ g.avg_rating | one_decimal }}{{ g.num_ratings | thousands }}
+
+
+ + {# Active forum threads #} +
+
+ Active Forum Threads + All forums → +
+
+ {% for t in active_threads %} +
+
+ {% if t.is_pinned %}PIN{% endif %} + {{ t.subject }} + · in + {{ t.forum.title }} + {% if t.forum.game %} · + {{ t.forum.game.name }} + {% endif %} + +
+
{{ t.num_posts - 1 }} replies
+
{{ t.num_views | thousands }} views
+
+ {{ t.last_post_at | time_ago }} + by {{ t.author.username }} +
+
+ {% else %} +

No active threads yet.

+ {% endfor %} +
+
+
+ + +
+{% endblock %} diff --git a/sites/boardgamegeek/templates/item.html b/sites/boardgamegeek/templates/item.html new file mode 100644 index 00000000..d3aa519b --- /dev/null +++ b/sites/boardgamegeek/templates/item.html @@ -0,0 +1,243 @@ +{% extends "base.html" %} +{% block title %}{{ g.name }} ({{ g.year_published or '—' }}) | {{ site_name }}{% endblock %} +{% block content %} +
+
+
+ Browse › + {% if g.subtype == 'boardgameexpansion' %}Expansion{% else %}Board Game{% endif %} › + {{ g.name }} +
+ +
+
+ {% if g.image_filename %} + {{ g.name }} + {% else %} + {{ g.name }} + {% endif %} +
+
+

{{ g.name }} ({{ g.year_published or '—' }})

+ {% if g.short_description %} +

{{ g.short_description }}

+ {% endif %} +
+ Designed by: + {% for d in g.designers[:5] %} + {{ d.name }}{% if not loop.last %}, {% endif %} + {% else %}—{% endfor %} +
+
+ Art by: + {% for a in g.artists[:4] %} + {{ a.name }}{% if not loop.last %}, {% endif %} + {% else %}—{% endfor %} +
+
+ Publishers: + {% for p in g.publishers[:6] %} + {{ p.name }}{% if not loop.last %}, {% endif %} + {% else %}—{% endfor %} +
+
+ Categories: + {% for c in g.categories %} + {{ c.name }}{% if not loop.last %}, {% endif %} + {% else %}—{% endfor %} +
+
+ Mechanisms: + {% for m in g.mechanics %} + {{ m.name }}{% if not loop.last %}, {% endif %} + {% else %}—{% endfor %} +
+
+
+
+ Overall Rank + {% if g.overall_rank %}#{{ g.overall_rank }}{% else %}—{% endif %} +
+
+ Geek Rating + + + {{ g.bayes_average | one_decimal }} + + +
+
+ Average Rating + {{ g.avg_rating | two_decimal }} +
+
+ Num Voters + {{ g.num_ratings | thousands }} +
+
+ Owners + {{ g.num_owners | thousands }} +
+
+ Wishlist + {{ g.num_wishing | thousands }} +
+
+ Players + {{ g.players_str }} {% if g.best_player_count %}(best {{ g.best_player_count }}){% endif %} +
+
+ Play Time + {{ g.time_str }} +
+
+ Age + {{ g.minage }}+ +
+
+ Weight + {{ g.weight | two_decimal }} / 5 ({{ g.weight_label }}) +
+
+ Language Dep. + {{ g.language_dependence or '—' }} +
+
+
+ + + +

Description

+
{{ g.description_html | safe_html }}
+ + {# Top Reviews moved off the overview tab — agents must navigate to + Ratings & Reviews to read individual reviews. This avoids the + hard-coded "top review visible on overview" shortcut. The summary + counts (avg, voters, weight) remain in the stats box above. #} + + {% if expansions %} +

Expansions

+ + + {% for e in expansions[:8] %} + + + + + + {% endfor %} + +
+ {% if e.thumb_filename %}{% endif %} + + {{ e.name }} + ({{ e.year_published or '—' }}) + {{ e.avg_rating | one_decimal }}
+ {% if expansions | length > 8 %}

See all expansions →

{% endif %} + {% endif %} + + {% if threads %} +

Recent Forum Activity

+ {% for t in threads %} +
+
+ {% if t.is_pinned %}PIN{% endif %} + {{ t.subject }} + · {{ t.forum.title }} +
+
{{ t.num_posts - 1 }} replies
+
{{ t.num_views | thousands }}
+
{{ t.last_post_at | time_ago }}
+
+ {% endfor %} + {% endif %} + + {# User actions #} + {% if current_user.is_authenticated %} +

Your Stuff

+
+
+ {{ rating_form.hidden_tag() }} +

Rate this game

+
+ + +
+
+ + +
+ +
+ +
+ {{ collection_form.hidden_tag() }} +

Your collection

+
+ + + + + + + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +

Log a play

+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ {% else %} +

+ Sign in + to rate this game, add it to your collection, or log a play. +

+ {% endif %} +
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/login.html b/sites/boardgamegeek/templates/login.html new file mode 100644 index 00000000..7a5532a3 --- /dev/null +++ b/sites/boardgamegeek/templates/login.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block title %}Sign In | {{ site_name }}{% endblock %} +{% block content %} +
+
+

Sign In

+
+ {{ form.hidden_tag() }} +
{{ form.username.label }}{{ form.username() }}
+
{{ form.password.label }}{{ form.password() }}
+ + Create account + Forgot password +
+

+ Benchmark accounts: alice_j, bob_c, carol_d, david_k + — password TestPass123!. +

+
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/person.html b/sites/boardgamegeek/templates/person.html new file mode 100644 index 00000000..b85df238 --- /dev/null +++ b/sites/boardgamegeek/templates/person.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% block title %}{{ person.name }} ({{ kind | capitalize }}) | {{ site_name }}{% endblock %} +{% block content %} +
+
+

{{ person.name }}

+

Board game {{ kind }}, {{ games | length }} games on file.

+ +

Sort: Rank · Year + · Avg Rating · Name

+ + + + + + + + + + {% for g in games %} + + + + + + + + {% endfor %} + +
TitleYearRankAvg
+ {% if g.thumb_filename %}{% endif %} + + {{ g.name }} + {{ g.year_published or '—' }}{{ g.overall_rank or '—' }}{{ g.avg_rating | one_decimal }}
+
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/plays.html b/sites/boardgamegeek/templates/plays.html new file mode 100644 index 00000000..fd6655fc --- /dev/null +++ b/sites/boardgamegeek/templates/plays.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}{{ u.username }}'s plays | {{ site_name }}{% endblock %} +{% block content %} +
+
+

{{ u.username }}'s plays

+ + + + + + + {% for p, g in plays %} + + + + + + + + + {% else %} + + {% endfor %} + +
DateGamePlayersLengthLocationComments
{{ p.played_on.strftime('%Y-%m-%d') }}{% if g %}{{ g.name }}{% else %}(deleted){% endif %}{{ p.num_players or '—' }}{{ p.length_minutes }} min{{ p.location }}{{ p.comments }}
No plays logged yet.
+
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/property.html b/sites/boardgamegeek/templates/property.html new file mode 100644 index 00000000..301e3831 --- /dev/null +++ b/sites/boardgamegeek/templates/property.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block title %}{{ entity.name }} ({{ kind | capitalize }}) | {{ site_name }}{% endblock %} +{% block content %} +
+
+
+ {% if kind == 'category' %}Categories + {% elif kind == 'mechanic' %}Mechanisms + {% else %}Browse{% endif %} › + {{ entity.name }} +
+
+
{{ kind | capitalize }}
+
+

{{ entity.name }}

+

{{ games | length }} games tagged with this {{ kind }}.

+
+
+ +

Sort: Rank · Avg Rating + · Year · Name

+ + + + + + + + + + + {% for g in games %} + + + + + + + + + + {% endfor %} + +
#TitleYearGeekAvgVoters
{{ g.overall_rank or '—' }} + {% if g.thumb_filename %}{% endif %} + + {{ g.name }} + {{ g.year_published or '—' }}{{ g.bayes_average | one_decimal }}{{ g.avg_rating | one_decimal }}{{ g.num_ratings | thousands }}
+
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/publisher.html b/sites/boardgamegeek/templates/publisher.html new file mode 100644 index 00000000..75c8f2b7 --- /dev/null +++ b/sites/boardgamegeek/templates/publisher.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block title %}{{ publisher.name }} | {{ site_name }}{% endblock %} +{% block content %} +
+
+

{{ publisher.name }}

+

Publisher · {{ games | length }} games on file.

+ + + + + + + + + + {% for g in games %} + + + + + + + + {% endfor %} + +
TitleYearRankAvg
+ {% if g.thumb_filename %}{% endif %} + + {{ g.name }} + {{ g.year_published or '—' }}{{ g.overall_rank or '—' }}{{ g.avg_rating | one_decimal }}
+
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/ratings.html b/sites/boardgamegeek/templates/ratings.html new file mode 100644 index 00000000..ad2a4041 --- /dev/null +++ b/sites/boardgamegeek/templates/ratings.html @@ -0,0 +1,91 @@ +{% extends "base.html" %} +{% block title %}Ratings & Reviews — {{ g.name }} | {{ site_name }}{% endblock %} +{% block content %} +
+
+
+ Browse › + {{ g.name }} › Ratings & Reviews +
+ +

{{ g.name }} — Ratings & Reviews

+ +
+
+
+ Average + {{ g.avg_rating | two_decimal }} +
+
+ Voters + {{ g.num_ratings | thousands }} +
+
+ Comments + {{ g.num_comments | thousands }} +
+

Distribution

+ + {% set max_count = histogram.values() | max %} + {% for bucket in range(10, 0, -1) %} + + + + + + {% endfor %} +
{{ bucket }} + {% if max_count and histogram[bucket] %} +
+ {% endif %} +
{{ histogram[bucket] }}
+
+ +
+

+ Sort: + Highest · + Lowest · + Most Recent · + Most Helpful +

+ + + + + + + {% for r in ratings %} + + + + + + + + {% endfor %} + +
RatingUserReview / CommentDate👍
+ {{ r.value | one_decimal }} + + {{ r.user.username }} + {% if r.user.country %}
{{ r.user.country }}
{% endif %} +
+ {% if r.review_html %}{{ r.review_html | safe_html }} + {% else %}no comment{% endif %} + {{ r.created_at.strftime('%Y-%m-%d') }}{{ r.num_thumbs }}
+ + +
+
+
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/register.html b/sites/boardgamegeek/templates/register.html new file mode 100644 index 00000000..2c07bc47 --- /dev/null +++ b/sites/boardgamegeek/templates/register.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block title %}Create Account | {{ site_name }}{% endblock %} +{% block content %} +
+
+

Create Account

+
+ {{ form.hidden_tag() }} +
{{ form.username.label }}{{ form.username() }}
+
{{ form.email.label }}{{ form.email() }}
+
{{ form.password.label }}{{ form.password() }}
+
{{ form.real_name.label }}{{ form.real_name() }}
+
{{ form.country.label }}{{ form.country() }}
+ + Sign in instead +
+
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/search.html b/sites/boardgamegeek/templates/search.html new file mode 100644 index 00000000..35fe20e0 --- /dev/null +++ b/sites/boardgamegeek/templates/search.html @@ -0,0 +1,91 @@ +{% extends "base.html" %} +{% block title %} + {% if q %}Search: {{ q }} | {{ site_name }}{% else %}Search | {{ site_name }}{% endif %} +{% endblock %} +{% block content %} +
+
+

Search

+ +
+
+
+
+ +
+
+
+
+ + {% if q %} +

{{ total | thousands }} result{{ '' if total == 1 else 's' }} for {{ q }}.

+ + {% if tab == 'boardgame' %} + + + + + + + + + {% for g in games %} + + + + + + + + {% endfor %} + +
TitleYearRankAvg
+ {% if g.thumb_filename %}{% endif %} + + {{ g.name }} + {% if g.short_description %}
{{ g.short_description[:140] }}{% if g.short_description | length > 140 %}…{% endif %}
{% endif %} +
{{ g.year_published or '—' }}{{ g.overall_rank or '—' }}{{ g.avg_rating | one_decimal }}
+ {% elif tab == 'user' %} + + + + {% for u in users %} + + + + + + {% endfor %} + +
UsernameReal NameLocation
{{ u.username }}{{ u.real_name or '' }}{{ u.display_location }}
+ {% elif tab == 'geeklist' %} + + + + + + {% for l in lists %} + + + + + + + {% endfor %} + +
ListAuthorItems👍
{{ l.title }}{{ l.author.username }}{{ l.num_items }}{{ l.num_thumbs }}
+ {% endif %} + + + {% endif %} +
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/taxonomy_index.html b/sites/boardgamegeek/templates/taxonomy_index.html new file mode 100644 index 00000000..67c27af9 --- /dev/null +++ b/sites/boardgamegeek/templates/taxonomy_index.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% block title %}{{ title }} | {{ site_name }}{% endblock %} +{% block content %} +
+
+

{{ title }}

+ {% if filter_q is defined %} +
+ + + {% if filter_q %}Clear{% endif %} +
+ {% endif %} +

{{ items | length }} {{ kind }}{{ 's' if items | length != 1 }}{% if filter_q %} matching "{{ filter_q }}"{% endif %}.

+
+ {% for it in items %} +
+ {{ it.name }} + ({{ it.games | length }}) +
+ {% endfor %} +
+
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/thread.html b/sites/boardgamegeek/templates/thread.html new file mode 100644 index 00000000..e83aa42b --- /dev/null +++ b/sites/boardgamegeek/templates/thread.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} +{% block title %}{{ thread.subject }} | {{ site_name }}{% endblock %} +{% block content %} +
+
+
+ Forums › + {{ thread.forum.title }} + › {{ thread.subject }} +
+

{{ thread.subject }}

+

+ {% if thread.is_pinned %}PIN{% endif %} + Started by {{ thread.author.username }} + · {{ thread.created_at | time_ago }} + · {{ thread.num_posts }} posts · {{ thread.num_views | thousands }} views + {% if thread.is_locked %} · (Locked){% endif %} +

+ + {% for p in posts %} +
+ +
+ + {{ p.body_html | safe_html }} + {% if current_user.is_authenticated %} +
+
+ + + + + +
+
+ {% endif %} +
+
+ {% endfor %} + + {% if current_user.is_authenticated and not thread.is_locked %} +

Reply

+
+ {{ reply_form.hidden_tag() }} +
+ +
+ +
+ {% else %} +

+ {% if thread.is_locked %}Thread is locked. + {% else %}Sign in to reply.{% endif %} +

+ {% endif %} +
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/thread_new.html b/sites/boardgamegeek/templates/thread_new.html new file mode 100644 index 00000000..bd308f86 --- /dev/null +++ b/sites/boardgamegeek/templates/thread_new.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block title %}New Thread — {{ forum.title }} | {{ site_name }}{% endblock %} +{% block content %} +
+
+
+ Forums › + {{ forum.title }} › New Thread +
+

New thread in {{ forum.title }}

+
+ {{ form.hidden_tag() }} +
+ {{ form.subject.label }} + {{ form.subject() }} +
+
+ {{ form.body.label }} + {{ form.body() }} +
+
+ + Cancel +
+
+
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/user.html b/sites/boardgamegeek/templates/user.html new file mode 100644 index 00000000..44cb827b --- /dev/null +++ b/sites/boardgamegeek/templates/user.html @@ -0,0 +1,72 @@ +{% extends "base.html" %} +{% block title %}{{ u.username }} | {{ site_name }}{% endblock %} +{% block content %} +
+
+
+
+

{{ u.username }}

+ {% if u.real_name %}
{{ u.real_name }}
{% endif %} + {% if u.display_location %}
{{ u.display_location }}
{% endif %} +
joined {{ u.joined_at | time_ago }}
+
+
Owned{{ own }}
+
Wishlist{{ wishlist }}
+
Want to buy{{ want }}
+
Rated{{ rated }}
+
Reviews{{ reviews }}
+
Plays logged{{ plays_count }}
+
GeekGold{{ u.geekgold }}
+
+ +
+
+

{{ u.username }}'s profile

+ {% if u.about %}
{{ u.about }}
{% else %}

No bio yet.

{% endif %} + +

Top-rated games

+ + + {% for r in top_rated %} + + + + + + {% else %} + + {% endfor %} + +
+ {{ r.value | one_decimal }} + {{ r.game.name }} ({{ r.game.year_published or '—' }}){{ r.created_at | time_ago }}
No ratings yet.
+ +

Recent plays

+ + + + {% for p in recent_plays %} + + + + + + + {% else %}{% endfor %} + +
DateGamePlayersLocation
{{ p.played_on.strftime('%Y-%m-%d') }}{% if p.game %}{{ p.game.name }}{% else %}—{% endif %}{{ p.num_players }}{{ p.location }}
No plays logged.
+ + {% if geeklists %} +

GeekLists by {{ u.username }}

+
    + {% for l in geeklists %}
  • {{ l.title }} · {{ l.num_items }} items
  • {% endfor %} +
+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/websyn_start.sh b/websyn_start.sh index 4b29a5c9..d99624f0 100644 --- a/websyn_start.sh +++ b/websyn_start.sh @@ -5,7 +5,8 @@ set -e SITES=(allrecipes amazon apple arxiv bbc_news booking github google_flights google_map google_search huggingface wolfram_alpha - cambridge_dictionary coursera espn merriam_webster ikea phys_org target ted) + cambridge_dictionary coursera espn merriam_webster ikea phys_org target ted + boardgamegeek) BASE_PORT=40000 PID_DIR=/tmp/websyn_pids mkdir -p "$PID_DIR" From e225623cd8be2b9e1c918d52faca53a65e8baf8c Mon Sep 17 00:00:00 2001 From: JackJin <1037461232@qq.com> Date: Tue, 8 Sep 2026 01:02:53 +0800 Subject: [PATCH 2/8] fix(boardgamegeek): close review quality gaps --- sites/boardgamegeek/app.py | 37 ++++- sites/boardgamegeek/static/css/bgg.css | 26 +++ sites/boardgamegeek/tasks.jsonl | 42 ++--- sites/boardgamegeek/templates/item.html | 9 +- sites/boardgamegeek/templates/register.html | 15 +- sites/boardgamegeek/templates/user.html | 3 +- .../verify/test_environment_quality.py | 157 ++++++++++++++++++ 7 files changed, 256 insertions(+), 33 deletions(-) create mode 100644 sites/boardgamegeek/verify/test_environment_quality.py diff --git a/sites/boardgamegeek/app.py b/sites/boardgamegeek/app.py index 4d155a17..85af085b 100644 --- a/sites/boardgamegeek/app.py +++ b/sites/boardgamegeek/app.py @@ -21,6 +21,7 @@ """ import os import re +import sys from datetime import datetime, timedelta from urllib.parse import urlparse @@ -38,6 +39,13 @@ from sqlalchemy import or_, and_, desc, asc, func, text from markupsafe import Markup, escape + +# seed_data imports the model classes from ``app``. When this file is started +# directly, publish the running module under that name so Flask-SQLAlchemy is +# initialized only once. +if __name__ == '__main__': + sys.modules.setdefault('app', sys.modules[__name__]) + BASE_DIR = os.path.dirname(os.path.abspath(__file__)) app = Flask(__name__, instance_path=os.path.join(BASE_DIR, 'instance')) @@ -1145,12 +1153,15 @@ def user_profile(username): reviews = Rating.query.filter_by(user_id=u.id).filter(Rating.review_html != '').count() recent_plays = Play.query.filter_by(user_id=u.id).order_by(Play.played_on.desc()).limit(5).all() top_rated = Rating.query.filter_by(user_id=u.id).order_by(Rating.value.desc()).limit(10).all() - geeklists = GeekList.query.filter_by(author_id=u.id).order_by(GeekList.created_at.desc()).limit(5).all() + geeklists_query = GeekList.query.filter_by(author_id=u.id) + geeklists_count = geeklists_query.count() + geeklists = geeklists_query.order_by(GeekList.created_at.desc()).limit(5).all() return render_template('user.html', u=u, own=own, want=want, wishlist=wishlist, plays_count=plays_count, rated=rated, reviews=reviews, recent_plays=recent_plays, top_rated=top_rated, - geeklists=geeklists) + geeklists=geeklists, + geeklists_count=geeklists_count) @app.route('/collection/') @@ -1241,6 +1252,9 @@ def rate(oid): flash('Rating must be between 1.0 and 10.0.', 'error') return redirect(url_for('game_detail', oid=oid, slug=g.slug)) r = Rating.query.filter_by(user_id=current_user.id, game_id=g.id).first() + previous_value = r.value if r else None + previous_average = g.avg_rating or 0.0 + previous_count = g.num_ratings or 0 if not r: r = Rating(user_id=current_user.id, game_id=g.id, value=form.value.data, review_html=escape_paragraphs(form.review.data or ''), @@ -1250,11 +1264,20 @@ def rate(oid): r.value = form.value.data r.review_html = escape_paragraphs(form.review.data or '') r.created_at = MIRROR_NOW - # Recompute aggregate (cheap on the seeded scale) - ratings = [x.value for x in Rating.query.filter_by(game_id=g.id).all()] + [form.value.data] - g.num_ratings = max(g.num_ratings or 0, len(ratings)) - if ratings: - g.avg_rating = sum(ratings) / len(ratings) + # The seeded average represents the full upstream population. Fold this + # user's mutation into that aggregate exactly once instead of replacing it + # with the handful of local benchmark ratings. + if previous_value is None: + g.num_ratings = previous_count + 1 + g.avg_rating = ( + (previous_average * previous_count) + form.value.data + ) / g.num_ratings + elif previous_count: + g.avg_rating = ( + (previous_average * previous_count) - previous_value + form.value.data + ) / previous_count + else: + g.avg_rating = form.value.data db.session.commit() flash(f'You rated {g.name}: {form.value.data:.1f}.', 'success') return redirect(url_for('game_detail', oid=oid, slug=g.slug)) diff --git a/sites/boardgamegeek/static/css/bgg.css b/sites/boardgamegeek/static/css/bgg.css index 97089806..d2d8d348 100644 --- a/sites/boardgamegeek/static/css/bgg.css +++ b/sites/boardgamegeek/static/css/bgg.css @@ -271,6 +271,9 @@ form input[type=number], form select, form textarea { form textarea { min-height: 100px; resize: vertical; } form .form-row { display: flex; gap: 10px; } form .form-row .field { flex: 1; } +.field-error { color: var(--bgg-red); font-size: 11px; margin-top: 3px; } +.user-actions-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } +.profile-layout { display: grid; grid-template-columns: 220px 1fr; gap: 20px; } button, .btn { display: inline-block; @@ -385,12 +388,35 @@ button:hover, .btn:hover { /* ----- responsive: collapse to single column under 900 ----- */ @media (max-width: 900px) { .page { grid-template-columns: 1fr; } + .page > * { min-width: 0; } .sidebar { order: 2; } .game-header { grid-template-columns: 1fr; } + .game-header .cover { max-width: 360px; margin: 0 auto; } + .collection_table { display: block; max-width: 100%; overflow-x: auto; } + .module .module-body { overflow-x: auto; } + .tab-bar { overflow-x: auto; } .post { grid-template-columns: 1fr; } .post .post-author { border-right: 0; border-bottom: 1px solid var(--bgg-border); } } +@media (max-width: 600px) { + .site-header .topbar { flex-wrap: wrap; gap: 8px 12px; padding: 8px; } + .site-header .logo { flex: 1 1 auto; font-size: 20px; } + .site-header .user-area { margin-left: auto; } + .site-header nav { order: 3; flex: 1 1 100%; justify-content: space-between; gap: 8px; } + .search-bar { order: 4; flex: 1 1 100%; width: 100%; } + .search-bar input[type="text"] { flex: 1 1 auto; min-width: 0; width: auto; } + .subnav .inner { padding: 6px 8px; gap: 10px 14px; } + .page { padding: 8px; gap: 8px; } + .main, .sidebar { padding: 10px; } + .thread-row { grid-template-columns: minmax(0, 1fr) auto; gap: 2px 8px; } + .thread-row .views { display: none; } + .thread-row .last { grid-column: 1 / -1; text-align: left; } + .user-actions-grid, .profile-layout { grid-template-columns: 1fr; } + form .form-row { flex-wrap: wrap; } + form .form-row .field { flex: 1 1 140px; } +} + /* ----- ratings histogram ----- */ .hist-table { width: 100%; font-size: 12px; } .hist-table td { padding: 2px 4px; } diff --git a/sites/boardgamegeek/tasks.jsonl b/sites/boardgamegeek/tasks.jsonl index 63502af5..7f3ed011 100644 --- a/sites/boardgamegeek/tasks.jsonl +++ b/sites/boardgamegeek/tasks.jsonl @@ -1,21 +1,21 @@ -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--0", "ques": "Find the #1 ranked board game on BoardGameGeek and report its designer(s). (Designers are not shown on the rank table — open the game's detail page.)", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--1", "ques": "Search for 'Gloomhaven' and report its average rating, weight, and number of voters from the game page.", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--2", "ques": "On the Browse page, sort games by weight descending. Among the games listed on the first page of weight-sorted results, find the one whose overall rank is in the top 100 AND whose weight is the highest. Report its name and weight score.", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--3", "ques": "Find the highest-ranked board game whose mechanism includes 'Worker Placement' and list its top 3 designers.", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--4", "ques": "Sign in as alice_j (password TestPass123!). How many games are in her owned collection?", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--5", "ques": "Sign in as bob_c (password TestPass123!), open Brass: Birmingham, rate it 9.5 and write a one-sentence review.", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--6", "ques": "Sign in as carol_d (password TestPass123!) and add the highest-ranked 2-player-only deckbuilder in the catalog to her wishlist with priority 'Must have'.", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--7", "ques": "On the Hot page, identify the most recently published (latest year) game in the current Hotness list.", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--8", "ques": "Open the GeekList named 'Best Cooperative Games'. Who is the author and how many items does it have?", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--9", "ques": "Sign in as david_k (password TestPass123!) and create a new GeekList titled 'My COIN Series Picks' with the description 'Light, deep, and historical.'", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--10", "ques": "Compare Brass: Birmingham and Ark Nova on their game pages — which one has the heavier weight (higher complexity)?", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--11", "ques": "Open the publisher page for 'Z-Man Games' (use the Publishers index — you may have to filter by name). Report how many games are listed under that publisher in our catalog.", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--12", "ques": "Browse to the 'Action Points' mechanism page. Among games using Action Points, what is the highest-ranked one and what is its overall rank?", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--13", "ques": "Sign in as alice_j (password TestPass123!). Open her collection sorted by 'Most recently updated' and remove the most-recently-updated entry from her collection.", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--14", "ques": "Open Wingspan and navigate to its Ratings & Reviews tab. Sort by 'Most Helpful' (thumbs) and report the username and thumb count of the top review. (The Overview tab does not list individual reviews — you must open the Ratings tab.)", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--15", "ques": "In the Recommendations forum, find an open thread (not pinned, not locked) about 2-player games, open it, and post a reply suggesting '7 Wonders Duel' as an alternative. (Sign in as bob_c first, password TestPass123!.)", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--16", "ques": "Open the 'Top 50 Heaviest Games of the Last Decade' GeekList. What is the #1 entry on that list?", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--17", "ques": "Sign in as david_k (password TestPass123!). Open his Plays page and report how many distinct games he has logged plays for.", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--18", "ques": "Find Vital Lacerda's designer page (the Designers index supports filtering by name). Among his games in the catalog, which one has the highest average rating?", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--19", "ques": "Search for users by the keyword 'mike'. Open the first matching user profile (alphabetical) and report how many GeekLists they have authored.", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--20", "ques": "Open Twilight Struggle's Expansions page. How many expansions are listed there?", "web": "http://localhost:40030/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--0", "ques": "Find the #1 ranked board game on BoardGameGeek and report its designer(s). (Designers are not shown on the rank table — open the game's detail page.)", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--1", "ques": "Search for 'Gloomhaven' and report its average rating, weight, and number of voters from the game page.", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--2", "ques": "On the Browse page, sort games by weight descending. Among the games listed on the first page of weight-sorted results, find the one whose overall rank is in the top 100 AND whose weight is the highest. Report its name and weight score.", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--3", "ques": "Find the highest-ranked board game whose mechanism includes 'Worker Placement' and report its designer(s).", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--4", "ques": "Sign in as alice_j (password TestPass123!). How many games are in her owned collection?", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--5", "ques": "Sign in as bob_c (password TestPass123!), open Brass: Birmingham, rate it 9.5 and write a one-sentence review.", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--6", "ques": "Sign in as carol_d (password TestPass123!). Using the 'Deck Construction' and 'Deck, Bag, and Pool Building' mechanism pages, find the highest-ranked game designed only for 2 players, then add it to her wishlist with priority 'Must have'.", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--7", "ques": "On the Hot page, find the latest publication year in the current Hotness list, then report the highest-ranked game from that year.", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--8", "ques": "Open the GeekList named 'Best Cooperative Games'. Who is the author and how many items does it have?", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--9", "ques": "Sign in as david_k (password TestPass123!) and create a new GeekList titled 'My COIN Series Picks' with the description 'Light, deep, and historical.'", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--10", "ques": "Compare Brass: Birmingham and Ark Nova on their game pages — which one has the heavier weight (higher complexity)?", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--11", "ques": "Open the publisher page for 'Z-Man Games' (use the Publishers index — you may have to filter by name). Report how many games are listed under that publisher in our catalog.", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--12", "ques": "Browse to the 'Action Points' mechanism page. Among games using Action Points, what is the highest-ranked one and what is its overall rank?", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--13", "ques": "Sign in as alice_j (password TestPass123!). Open her collection sorted by 'Most recently updated' and remove the most-recently-updated entry from her collection.", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--14", "ques": "Open Wingspan and navigate to its Ratings & Reviews tab. Sort by 'Most Helpful' (thumbs) and report the username and thumb count of the top review. (The Overview tab does not list individual reviews — you must open the Ratings tab.)", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--15", "ques": "In the Recommendations forum, find an open thread (not pinned, not locked) about 2-player games, open it, and post a reply suggesting '7 Wonders Duel' as an alternative. (Sign in as bob_c first, password TestPass123!.)", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--16", "ques": "Open the 'Top 50 Heaviest Games of the Last Decade' GeekList. What is the #1 entry on that list?", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--17", "ques": "Sign in as david_k (password TestPass123!). Open his Plays page and report how many distinct games he has logged plays for.", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--18", "ques": "Find Vital Lacerda's designer page (the Designers index supports filtering by name). Among his games in the catalog, which one has the highest average rating?", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--19", "ques": "Search for users by the keyword 'mike'. Open the first matching user profile (alphabetical) and report how many GeekLists they have authored.", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name": "BoardGameGeek", "id": "BoardGameGeek--20", "ques": "Open Twilight Struggle's Expansions page. How many expansions are listed there?", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} diff --git a/sites/boardgamegeek/templates/item.html b/sites/boardgamegeek/templates/item.html index d3aa519b..8b49d69a 100644 --- a/sites/boardgamegeek/templates/item.html +++ b/sites/boardgamegeek/templates/item.html @@ -161,7 +161,7 @@

Recent Forum Activity

{# User actions #} {% if current_user.is_authenticated %}

Your Stuff

-
+ + {% if my_collection %} +
+ + +
+ {% endif %} +

Log a play

diff --git a/sites/boardgamegeek/templates/register.html b/sites/boardgamegeek/templates/register.html index 2c07bc47..ff0e3903 100644 --- a/sites/boardgamegeek/templates/register.html +++ b/sites/boardgamegeek/templates/register.html @@ -6,9 +6,18 @@

Create Account

{{ form.hidden_tag() }} -
{{ form.username.label }}{{ form.username() }}
-
{{ form.email.label }}{{ form.email() }}
-
{{ form.password.label }}{{ form.password() }}
+
+ {{ form.username.label }}{{ form.username() }} + {% for error in form.username.errors %}
{{ error }}
{% endfor %} +
+
+ {{ form.email.label }}{{ form.email() }} + {% for error in form.email.errors %}
{{ error }}
{% endfor %} +
+
+ {{ form.password.label }}{{ form.password() }} + {% for error in form.password.errors %}
{{ error }}
{% endfor %} +
{{ form.real_name.label }}{{ form.real_name() }}
{{ form.country.label }}{{ form.country() }}
diff --git a/sites/boardgamegeek/templates/user.html b/sites/boardgamegeek/templates/user.html index 44cb827b..f2b4ed68 100644 --- a/sites/boardgamegeek/templates/user.html +++ b/sites/boardgamegeek/templates/user.html @@ -3,7 +3,7 @@ {% block content %}
-
+

{{ u.username }}

{% if u.real_name %}
{{ u.real_name }}
{% endif %} @@ -16,6 +16,7 @@

{{ u.username }}

Rated{{ rated }}
Reviews{{ reviews }}
Plays logged{{ plays_count }}
+
GeekLists authored{{ geeklists_count }}
GeekGold{{ u.geekgold }}
diff --git a/sites/boardgamegeek/verify/test_environment_quality.py b/sites/boardgamegeek/verify/test_environment_quality.py new file mode 100644 index 00000000..abbc76f1 --- /dev/null +++ b/sites/boardgamegeek/verify/test_environment_quality.py @@ -0,0 +1,157 @@ +"""Regression checks for the BoardGameGeek review fixes.""" + +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import socket +import subprocess +import sys +import tempfile +import time +import unittest +import urllib.request +from pathlib import Path + + +SITE_DIR = Path(__file__).resolve().parents[1] +SEED_DB = SITE_DIR / "instance_seed" / "boardgamegeek.db" + + +def _copy_runtime(destination: Path, *, include_templates: bool = False) -> None: + shutil.copy2(SITE_DIR / "app.py", destination / "app.py") + shutil.copy2(SITE_DIR / "seed_data.py", destination / "seed_data.py") + instance = destination / "instance" + instance.mkdir() + shutil.copy2(SEED_DB, instance / "boardgamegeek.db") + if include_templates: + shutil.copytree(SITE_DIR / "templates", destination / "templates") + + +class EnvironmentQualityTests(unittest.TestCase): + def test_task_ids_and_urls_match_registered_port(self) -> None: + rows = [ + json.loads(line) + for line in (SITE_DIR / "tasks.jsonl").read_text(encoding="utf-8").splitlines() + ] + self.assertEqual(21, len(rows)) + for number, row in enumerate(rows): + with self.subTest(task=number): + self.assertEqual(f"BoardGameGeek--{number}", row["id"]) + self.assertEqual("http://localhost:40020/", row["web"]) + + def test_registration_template_displays_validation_errors(self) -> None: + template = (SITE_DIR / "templates" / "register.html").read_text(encoding="utf-8") + for field in ("username", "email", "password"): + with self.subTest(field=field): + self.assertIn(f"form.{field}.errors", template) + + def test_task_action_controls_are_exposed_in_the_ui(self) -> None: + item_template = (SITE_DIR / "templates" / "item.html").read_text(encoding="utf-8") + user_template = (SITE_DIR / "templates" / "user.html").read_text(encoding="utf-8") + app_source = (SITE_DIR / "app.py").read_text(encoding="utf-8") + self.assertIn("url_for('collection_remove'", item_template) + self.assertIn("Remove from collection", item_template) + self.assertIn("geeklists_count", user_template) + self.assertIn("geeklists_count=", app_source) + + def test_direct_start_uses_one_flask_sqlalchemy_instance(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + runtime = Path(temp_dir) + _copy_runtime(runtime) + before = hashlib.sha256((runtime / "instance" / "boardgamegeek.db").read_bytes()).hexdigest() + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + env = os.environ.copy() + env["PORT"] = str(port) + process = subprocess.Popen( + [sys.executable, "app.py"], + cwd=runtime, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + try: + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + try: + with urllib.request.urlopen( + f"http://127.0.0.1:{port}/_health", timeout=1 + ) as response: + self.assertEqual(200, response.status) + break + except Exception: + if process.poll() is not None: + break + time.sleep(0.1) + else: + self.fail("BoardGameGeek app did not become healthy") + finally: + process.terminate() + output, _ = process.communicate(timeout=10) + after = hashlib.sha256((runtime / "instance" / "boardgamegeek.db").read_bytes()).hexdigest() + self.assertEqual(before, after) + self.assertNotIn("SQLAlchemy instance", output) + self.assertNotIn("[boardgamegeek] seed error", output) + + def test_new_rating_updates_global_aggregate_once(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + runtime = Path(temp_dir) + _copy_runtime(runtime) + script = """ +import json +import app as module + +module.app.config['WTF_CSRF_ENABLED'] = False +with module.app.app_context(): + game = module.Game.query.filter_by(name='Brass: Birmingham').one() + user = module.User.query.filter_by(username='bob_c').one() + before = {'average': game.avg_rating, 'count': game.num_ratings} + game_id = game.bgg_id + user_id = user.id + +with module.app.test_client() as client: + with client.session_transaction() as session: + session['_user_id'] = str(user_id) + session['_fresh'] = True + response = client.post( + f'/rate/{game_id}', + data={'value': '9.5', 'review': 'A focused and rewarding economic game.'}, + ) + +with module.app.app_context(): + game = module.Game.query.filter_by(name='Brass: Birmingham').one() + rating = module.Rating.query.filter_by(user_id=user_id, game_id=game.id).one() + print(json.dumps({ + 'status': response.status_code, + 'before': before, + 'after': {'average': game.avg_rating, 'count': game.num_ratings}, + 'rating': rating.value, + 'review': rating.review_html, + })) +""" + completed = subprocess.run( + [sys.executable, "-c", script], + cwd=runtime, + check=True, + capture_output=True, + text=True, + ) + result = json.loads(completed.stdout.splitlines()[-1]) + before = result["before"] + expected_average = ( + before["average"] * before["count"] + 9.5 + ) / (before["count"] + 1) + self.assertEqual(302, result["status"]) + self.assertEqual(9.5, result["rating"]) + self.assertIn("focused and rewarding", result["review"]) + self.assertEqual(before["count"] + 1, result["after"]["count"]) + self.assertAlmostEqual(expected_average, result["after"]["average"], places=10) + + +if __name__ == "__main__": + unittest.main() From 5f9e9d7612e6f05812fbd8929f672c96e36746c0 Mon Sep 17 00:00:00 2001 From: JackJin <1037461232@qq.com> Date: Tue, 8 Sep 2026 01:11:34 +0800 Subject: [PATCH 3/8] test(boardgamegeek): add deterministic task verifiers --- sites/boardgamegeek/tasks.jsonl | 42 +- .../verify/test_environment_quality.py | 3 + sites/boardgamegeek/verify/test_verifiers.py | 340 ++++++++++ sites/boardgamegeek/verify/verify_0.py | 6 + sites/boardgamegeek/verify/verify_1.py | 6 + sites/boardgamegeek/verify/verify_10.py | 6 + sites/boardgamegeek/verify/verify_11.py | 6 + sites/boardgamegeek/verify/verify_12.py | 6 + sites/boardgamegeek/verify/verify_13.py | 6 + sites/boardgamegeek/verify/verify_14.py | 6 + sites/boardgamegeek/verify/verify_15.py | 6 + sites/boardgamegeek/verify/verify_16.py | 6 + sites/boardgamegeek/verify/verify_17.py | 6 + sites/boardgamegeek/verify/verify_18.py | 6 + sites/boardgamegeek/verify/verify_19.py | 6 + sites/boardgamegeek/verify/verify_2.py | 6 + sites/boardgamegeek/verify/verify_20.py | 6 + sites/boardgamegeek/verify/verify_3.py | 6 + sites/boardgamegeek/verify/verify_4.py | 6 + sites/boardgamegeek/verify/verify_5.py | 6 + sites/boardgamegeek/verify/verify_6.py | 6 + sites/boardgamegeek/verify/verify_7.py | 6 + sites/boardgamegeek/verify/verify_8.py | 6 + sites/boardgamegeek/verify/verify_9.py | 6 + sites/boardgamegeek/verify/verify_lib.py | 583 ++++++++++++++++++ 25 files changed, 1073 insertions(+), 21 deletions(-) create mode 100644 sites/boardgamegeek/verify/test_verifiers.py create mode 100644 sites/boardgamegeek/verify/verify_0.py create mode 100644 sites/boardgamegeek/verify/verify_1.py create mode 100644 sites/boardgamegeek/verify/verify_10.py create mode 100644 sites/boardgamegeek/verify/verify_11.py create mode 100644 sites/boardgamegeek/verify/verify_12.py create mode 100644 sites/boardgamegeek/verify/verify_13.py create mode 100644 sites/boardgamegeek/verify/verify_14.py create mode 100644 sites/boardgamegeek/verify/verify_15.py create mode 100644 sites/boardgamegeek/verify/verify_16.py create mode 100644 sites/boardgamegeek/verify/verify_17.py create mode 100644 sites/boardgamegeek/verify/verify_18.py create mode 100644 sites/boardgamegeek/verify/verify_19.py create mode 100644 sites/boardgamegeek/verify/verify_2.py create mode 100644 sites/boardgamegeek/verify/verify_20.py create mode 100644 sites/boardgamegeek/verify/verify_3.py create mode 100644 sites/boardgamegeek/verify/verify_4.py create mode 100644 sites/boardgamegeek/verify/verify_5.py create mode 100644 sites/boardgamegeek/verify/verify_6.py create mode 100644 sites/boardgamegeek/verify/verify_7.py create mode 100644 sites/boardgamegeek/verify/verify_8.py create mode 100644 sites/boardgamegeek/verify/verify_9.py create mode 100644 sites/boardgamegeek/verify/verify_lib.py diff --git a/sites/boardgamegeek/tasks.jsonl b/sites/boardgamegeek/tasks.jsonl index 7f3ed011..f8589007 100644 --- a/sites/boardgamegeek/tasks.jsonl +++ b/sites/boardgamegeek/tasks.jsonl @@ -1,21 +1,21 @@ -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--0", "ques": "Find the #1 ranked board game on BoardGameGeek and report its designer(s). (Designers are not shown on the rank table — open the game's detail page.)", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--1", "ques": "Search for 'Gloomhaven' and report its average rating, weight, and number of voters from the game page.", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--2", "ques": "On the Browse page, sort games by weight descending. Among the games listed on the first page of weight-sorted results, find the one whose overall rank is in the top 100 AND whose weight is the highest. Report its name and weight score.", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--3", "ques": "Find the highest-ranked board game whose mechanism includes 'Worker Placement' and report its designer(s).", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--4", "ques": "Sign in as alice_j (password TestPass123!). How many games are in her owned collection?", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--5", "ques": "Sign in as bob_c (password TestPass123!), open Brass: Birmingham, rate it 9.5 and write a one-sentence review.", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--6", "ques": "Sign in as carol_d (password TestPass123!). Using the 'Deck Construction' and 'Deck, Bag, and Pool Building' mechanism pages, find the highest-ranked game designed only for 2 players, then add it to her wishlist with priority 'Must have'.", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--7", "ques": "On the Hot page, find the latest publication year in the current Hotness list, then report the highest-ranked game from that year.", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--8", "ques": "Open the GeekList named 'Best Cooperative Games'. Who is the author and how many items does it have?", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--9", "ques": "Sign in as david_k (password TestPass123!) and create a new GeekList titled 'My COIN Series Picks' with the description 'Light, deep, and historical.'", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--10", "ques": "Compare Brass: Birmingham and Ark Nova on their game pages — which one has the heavier weight (higher complexity)?", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--11", "ques": "Open the publisher page for 'Z-Man Games' (use the Publishers index — you may have to filter by name). Report how many games are listed under that publisher in our catalog.", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--12", "ques": "Browse to the 'Action Points' mechanism page. Among games using Action Points, what is the highest-ranked one and what is its overall rank?", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--13", "ques": "Sign in as alice_j (password TestPass123!). Open her collection sorted by 'Most recently updated' and remove the most-recently-updated entry from her collection.", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--14", "ques": "Open Wingspan and navigate to its Ratings & Reviews tab. Sort by 'Most Helpful' (thumbs) and report the username and thumb count of the top review. (The Overview tab does not list individual reviews — you must open the Ratings tab.)", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--15", "ques": "In the Recommendations forum, find an open thread (not pinned, not locked) about 2-player games, open it, and post a reply suggesting '7 Wonders Duel' as an alternative. (Sign in as bob_c first, password TestPass123!.)", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--16", "ques": "Open the 'Top 50 Heaviest Games of the Last Decade' GeekList. What is the #1 entry on that list?", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--17", "ques": "Sign in as david_k (password TestPass123!). Open his Plays page and report how many distinct games he has logged plays for.", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--18", "ques": "Find Vital Lacerda's designer page (the Designers index supports filtering by name). Among his games in the catalog, which one has the highest average rating?", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--19", "ques": "Search for users by the keyword 'mike'. Open the first matching user profile (alphabetical) and report how many GeekLists they have authored.", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} -{"web_name": "BoardGameGeek", "id": "BoardGameGeek--20", "ques": "Open Twilight Struggle's Expansions page. How many expansions are listed there?", "web": "http://localhost:40020/", "upstream_url": "https://boardgamegeek.com/"} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--0","ques":"Find the #1 ranked board game on BoardGameGeek and report its designer(s). (Designers are not shown on the rank table — open the game's detail page.)","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_0.py"} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--1","ques":"Search for 'Gloomhaven' and report its average rating, weight, and number of voters from the game page.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_1.py"} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--2","ques":"On the Browse page, sort games by weight descending. Among the games listed on the first page of weight-sorted results, find the one whose overall rank is in the top 100 AND whose weight is the highest. Report its name and weight score.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_2.py"} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--3","ques":"Find the highest-ranked board game whose mechanism includes 'Worker Placement' and report its designer(s).","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_3.py"} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--4","ques":"Sign in as alice_j (password TestPass123!). How many games are in her owned collection?","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_4.py"} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--5","ques":"Sign in as bob_c (password TestPass123!), open Brass: Birmingham, rate it 9.5 and write a one-sentence review.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_5.py"} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--6","ques":"Sign in as carol_d (password TestPass123!). Using the 'Deck Construction' and 'Deck, Bag, and Pool Building' mechanism pages, find the highest-ranked game designed only for 2 players, then add it to her wishlist with priority 'Must have'.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_6.py"} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--7","ques":"On the Hot page, find the latest publication year in the current Hotness list, then report the highest-ranked game from that year.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_7.py"} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--8","ques":"Open the GeekList named 'Best Cooperative Games'. Who is the author and how many items does it have?","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_8.py"} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--9","ques":"Sign in as david_k (password TestPass123!) and create a new GeekList titled 'My COIN Series Picks' with the description 'Light, deep, and historical.'","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_9.py"} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--10","ques":"Compare Brass: Birmingham and Ark Nova on their game pages — which one has the heavier weight (higher complexity)?","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_10.py"} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--11","ques":"Open the publisher page for 'Z-Man Games' (use the Publishers index — you may have to filter by name). Report how many games are listed under that publisher in our catalog.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_11.py"} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--12","ques":"Browse to the 'Action Points' mechanism page. Among games using Action Points, what is the highest-ranked one and what is its overall rank?","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_12.py"} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--13","ques":"Sign in as alice_j (password TestPass123!). Open her collection sorted by 'Most recently updated' and remove the most-recently-updated entry from her collection.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_13.py"} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--14","ques":"Open Wingspan and navigate to its Ratings & Reviews tab. Sort by 'Most Helpful' (thumbs) and report the username and thumb count of the top review. (The Overview tab does not list individual reviews — you must open the Ratings tab.)","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_14.py"} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--15","ques":"In the Recommendations forum, find an open thread (not pinned, not locked) about 2-player games, open it, and post a reply suggesting '7 Wonders Duel' as an alternative. (Sign in as bob_c first, password TestPass123!.)","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_15.py"} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--16","ques":"Open the 'Top 50 Heaviest Games of the Last Decade' GeekList. What is the #1 entry on that list?","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_16.py"} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--17","ques":"Sign in as david_k (password TestPass123!). Open his Plays page and report how many distinct games he has logged plays for.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_17.py"} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--18","ques":"Find Vital Lacerda's designer page (the Designers index supports filtering by name). Among his games in the catalog, which one has the highest average rating?","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_18.py"} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--19","ques":"Search for users by the keyword 'mike'. Open the first matching user profile (alphabetical) and report how many GeekLists they have authored.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_19.py"} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--20","ques":"Open Twilight Struggle's Expansions page. How many expansions are listed there?","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_20.py"} diff --git a/sites/boardgamegeek/verify/test_environment_quality.py b/sites/boardgamegeek/verify/test_environment_quality.py index abbc76f1..8483e7f5 100644 --- a/sites/boardgamegeek/verify/test_environment_quality.py +++ b/sites/boardgamegeek/verify/test_environment_quality.py @@ -41,6 +41,9 @@ def test_task_ids_and_urls_match_registered_port(self) -> None: with self.subTest(task=number): self.assertEqual(f"BoardGameGeek--{number}", row["id"]) self.assertEqual("http://localhost:40020/", row["web"]) + expected = f"sites/boardgamegeek/verify/verify_{number}.py" + self.assertEqual(expected, row["verifier_path"]) + self.assertTrue((SITE_DIR.parents[1] / expected).is_file()) def test_registration_template_displays_validation_errors(self) -> None: template = (SITE_DIR / "templates" / "register.html").read_text(encoding="utf-8") diff --git a/sites/boardgamegeek/verify/test_verifiers.py b/sites/boardgamegeek/verify/test_verifiers.py new file mode 100644 index 00000000..2da30486 --- /dev/null +++ b/sites/boardgamegeek/verify/test_verifiers.py @@ -0,0 +1,340 @@ +"""Positive, adversarial, and legal-alternative tests for all BGG verifiers.""" + +from __future__ import annotations + +import json +import shutil +import sqlite3 +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from urllib.parse import quote_plus + + +VERIFY_DIR = Path(__file__).resolve().parent +SEED_DB = VERIFY_DIR.parent / "instance_seed" / "boardgamegeek.db" +PASSWORD = "TestPass123!" + + +def next_id(connection: sqlite3.Connection, table: str) -> int: + return int(connection.execute(f"SELECT COALESCE(MAX(id),0)+1 FROM {table}").fetchone()[0]) + + +def game_id(connection: sqlite3.Connection, name: str) -> tuple[int, int]: + return tuple(connection.execute("SELECT id,bgg_id FROM games WHERE name=?", (name,)).fetchone()) + + +def user_id(connection: sqlite3.Connection, username: str) -> int: + return int(connection.execute("SELECT id FROM users WHERE username=?", (username,)).fetchone()[0]) + + +def mutation(task: int): + if task == 5: + def rate(connection: sqlite3.Connection) -> None: + gid, _ = game_id(connection, "Brass: Birmingham") + uid = user_id(connection, "bob_c") + average, count = connection.execute( + "SELECT avg_rating,num_ratings FROM games WHERE id=?", (gid,) + ).fetchone() + connection.execute( + "INSERT INTO ratings(id,user_id,game_id,value,review_html,created_at,num_thumbs) VALUES(?,?,?,?,?,?,?)", + ( + next_id(connection, "ratings"), uid, gid, 9.5, + "

A focused and rewarding economic game.

", + "2026-05-26 12:00:00.000000", 0, + ), + ) + connection.execute( + "UPDATE games SET avg_rating=?,num_ratings=? WHERE id=?", + (((average * count) + 9.5) / (count + 1), count + 1, gid), + ) + return rate + if task == 6: + def wishlist(connection: sqlite3.Connection) -> None: + gid, _ = game_id(connection, "Android: Netrunner") + uid = user_id(connection, "carol_d") + connection.execute( + """ + INSERT INTO collections( + id,user_id,game_id,own,prevowned,want_to_play,want_to_buy, + wishlist,wishlist_priority,preordered,for_trade,comment, + acquired_on,updated_at + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """, + ( + next_id(connection, "collections"), uid, gid, 0, 0, 0, 0, + 1, 1, 0, 0, "", "", "2026-05-26 12:00:00.000000", + ), + ) + return wishlist + if task == 9: + def create_list(connection: sqlite3.Connection) -> None: + connection.execute( + "INSERT INTO geeklists(id,title,description_html,author_id,created_at,num_thumbs,num_items) VALUES(?,?,?,?,?,?,?)", + ( + next_id(connection, "geeklists"), "My COIN Series Picks", + "

Light, deep, and historical.

", user_id(connection, "david_k"), + "2026-05-26 12:00:00.000000", 0, 0, + ), + ) + return create_list + if task == 13: + def remove_recent(connection: sqlite3.Connection) -> None: + uid = user_id(connection, "alice_j") + row = connection.execute( + "SELECT id FROM collections WHERE user_id=? AND own=1 ORDER BY updated_at DESC LIMIT 1", + (uid,), + ).fetchone() + connection.execute("DELETE FROM collections WHERE id=?", (row[0],)) + return remove_recent + if task == 15: + def reply(connection: sqlite3.Connection) -> None: + uid = user_id(connection, "bob_c") + thread = connection.execute( + """ + SELECT t.id,t.forum_id FROM threads t JOIN forums f ON f.id=t.forum_id + WHERE f.title='Recommendations' AND t.is_pinned=0 AND t.is_locked=0 + AND (lower(t.subject) LIKE '%2 player%' OR lower(t.subject) LIKE '%two-player%') + ORDER BY t.id LIMIT 1 + """ + ).fetchone() + connection.execute( + "INSERT INTO posts(id,thread_id,author_id,body_html,created_at,edited_at,thumbs) VALUES(?,?,?,?,?,?,?)", + ( + next_id(connection, "posts"), thread[0], uid, + "

Try 7 Wonders Duel as an alternative.

", + "2026-05-26 12:00:00.000000", None, 0, + ), + ) + connection.execute( + "UPDATE threads SET num_posts=num_posts+1,last_post_at=? WHERE id=?", + ("2026-05-26 12:00:00.000000", thread[0]), + ) + connection.execute( + "UPDATE forums SET num_posts=num_posts+1 WHERE id=?", (thread[1],) + ) + return reply + return None + + +def fixture(task: int, *, alternate: bool = False) -> tuple[list[dict], str, object | None]: + base = "http://127.0.0.1:40020" if alternate else "http://localhost:40020" + + def url(path: str) -> str: + return base + path + + def nav(path: str) -> dict: + return {"url": url(path), "action": "navigate", "params": {}} + + def click(path: str, destination: str) -> dict: + return {"url": url(path), "url_after": url(destination), "action": "click", "params": {}} + + def enter(path: str, text: str) -> dict: + return {"url": url(path), "action": "input", "params": {"text": text}} + + def game(bgg_id: int, slug: str) -> str: + return f"/boardgame/{bgg_id}" if alternate else f"/boardgame/{bgg_id}/{slug}" + + def subpage(bgg_id: int, slug: str, page: str) -> str: + return f"/boardgame/{bgg_id}/{page}" if alternate else f"/boardgame/{bgg_id}/{slug}/{page}" + + def login(username: str) -> list[dict]: + return [ + nav("/login"), enter("/login", username), enter("/login", PASSWORD), + click("/login", f"/user/{username}"), nav(f"/user/{username}"), + ] + + mutate = mutation(task) + if task == 0: + paths = [nav("/browse/boardgame"), click("/browse/boardgame", game(224517, "brass-birmingham")), nav(game(224517, "brass-birmingham"))] + answer = "Brass: Birmingham is #1; its designers are Gavan Brown, Matt Tolman, and Martin Wallace." + elif task == 1: + search = "/search?q=Gloomhaven&type=boardgame" + paths = [nav(search), click(search, game(174430, "gloomhaven")), nav(game(174430, "gloomhaven"))] + answer = "Gloomhaven has average rating 8.5389, weight 3.91917, and 67,165 voters." + elif task == 2: + paths = [nav("/browse/boardgame?sort=weight&dir=desc")] + answer = "On Mars has the highest qualifying weight: 4.62606." + elif task == 3: + worker = "/boardgamemechanic/2082" if alternate else "/boardgamemechanic/2082/worker-placement" + target = game(397598, "dune-imperium-uprising") + paths = [nav("/boardgamemechanic"), click("/boardgamemechanic", worker), nav(worker), click(worker, target), nav(target)] + answer = "Dune: Imperium – Uprising is highest-ranked; its designer is Paul Dennen." + elif task == 4: + paths = login("alice_j") + [nav("/collection/alice_j")] + answer = "alice_j has 18 owned games in her collection." + elif task == 5: + target = game(224517, "brass-birmingham") + paths = login("bob_c") + [nav(target), enter(target, "9.5"), enter(target, "A focused and rewarding economic game."), click(target, target), nav(target)] + answer = "The 9.5 rating and one-sentence review were saved." + elif task == 6: + deck_a = "/boardgamemechanic/3004" if alternate else "/boardgamemechanic/3004/deck-construction" + deck_b = "/boardgamemechanic/2664" if alternate else "/boardgamemechanic/2664/deck-bag-and-pool-building" + target = game(124742, "android-netrunner") + paths = login("carol_d") + [nav(deck_a), nav(deck_b), nav(target), enter(target, "Must have"), click(target, target), nav(target)] + answer = "Android: Netrunner was added to carol_d's wishlist as Must have." + elif task == 7: + paths = [nav("/hot" if alternate else "/hotness")] + answer = "The latest year is 2025; The Lord of the Rings: Fate of the Fellowship is the highest-ranked game from that year." + elif task == 8: + paths = [nav("/geeklists"), click("/geeklists", "/geeklist/7"), nav("/geeklist/7")] + answer = "Best Cooperative Games is by Alan How and has 25 items." + elif task == 9: + new_id = 17 + paths = login("david_k") + [nav("/geeklist/new"), enter("/geeklist/new", "My COIN Series Picks"), enter("/geeklist/new", "Light, deep, and historical."), click("/geeklist/new", f"/geeklist/{new_id}"), nav(f"/geeklist/{new_id}")] + answer = "My COIN Series Picks was created." + elif task == 10: + brass = game(224517, "brass-birmingham") + ark = game(342942, "ark-nova") + paths = [nav(brass), nav(ark)] + answer = "Brass: Birmingham is heavier than Ark Nova (3.86 versus 3.80)." + elif task == 11: + index = "/boardgamepublisher?q=" + quote_plus("Z-Man Games") + detail = "/boardgamepublisher/538" if alternate else "/boardgamepublisher/538/z-man-games" + paths = [nav(index), click(index, detail), nav(detail)] + answer = "Z-Man Games has 116 games listed in the catalog." + elif task == 12: + detail = "/boardgamemechanic/2001" if alternate else "/boardgamemechanic/2001/action-points" + paths = [nav("/boardgamemechanic"), click("/boardgamemechanic", detail), nav(detail)] + answer = "Pandemic Legacy: Season 1 is highest, at overall rank #3." + elif task == 13: + collection = "/collection/alice_j?status=own&sort=recent" + target = game(284378, "kanban-ev") + paths = login("alice_j") + [nav(collection), click(collection, target), nav(target), click(target, target), nav(target)] + answer = "Kanban EV, the most recently updated entry, was removed." + elif task == 14: + target = game(266192, "wingspan") + ratings = subpage(266192, "wingspan", "ratings") + "?sort=thumbs" + paths = [nav(target), click(target, ratings), nav(ratings)] + answer = "The top review is by ogzz with 23 thumbs." + elif task == 15: + target = "/thread/4004" + paths = login("bob_c") + [nav("/forums"), nav("/forum/3"), nav(target), enter(target, "Try 7 Wonders Duel as an alternative."), click(target, target), nav(target)] + answer = "Posted a reply suggesting 7 Wonders Duel." + elif task == 16: + paths = [nav("/geeklists"), click("/geeklists", "/geeklist/1"), nav("/geeklist/1")] + answer = "The #1 entry is Pax Renaissance: 2nd Edition." + elif task == 17: + paths = login("david_k") + [nav("/plays/david_k")] + answer = "david_k has logged plays for 7 distinct games." + elif task == 18: + index = "/boardgamedesigner?q=" + quote_plus("Vital Lacerda") + detail = "/boardgamedesigner/12396?sort=average" if alternate else "/boardgamedesigner/12396/vital-lacerda?sort=average" + paths = [nav(index), click(index, detail), nav(detail)] + answer = "Speakeasy has Vital Lacerda's highest average rating." + elif task == 19: + search = "/search?q=mike&type=user" + paths = [nav(search), click(search, "/user/EViLMiKE"), nav("/user/EViLMiKE")] + answer = "The first match is EViLMiKE, with 0 GeekLists authored." + elif task == 20: + target = game(12333, "twilight-struggle") + expansions = subpage(12333, "twilight-struggle", "expansions") + paths = [nav(target), click(target, expansions), nav(expansions)] + answer = "Twilight Struggle has 11 expansions listed." + else: + raise ValueError(task) + return paths, answer, mutate + + +class VerifierTests(unittest.TestCase): + maxDiff = None + + def run_verifier( + self, + task: int, + steps: list[dict], + answer: str, + mutate=None, + *, + start_url: str | None = None, + ) -> tuple[int, dict]: + with tempfile.TemporaryDirectory(prefix=f"bgg-verify-{task}-") as temp_dir: + root = Path(temp_dir) + initial = root / "initial.db" + after = root / "after.db" + run = root / "run" + run.mkdir() + shutil.copy2(SEED_DB, initial) + shutil.copy2(SEED_DB, after) + if mutate: + connection = sqlite3.connect(after) + try: + mutate(connection) + connection.commit() + finally: + connection.close() + origin = start_url or ( + "http://127.0.0.1:40020/" + if steps and "127.0.0.1" in steps[0]["url"] + else "http://localhost:40020/" + ) + trajectory = { + "task_id": f"BoardGameGeek--{task}", + "start_url": origin, + "steps": steps, + "final_url": steps[-1].get("url_after", steps[-1].get("url")) if steps else origin, + "final_answer": answer, + } + (run / "trajectory.json").write_text(json.dumps(trajectory), encoding="utf-8") + result = subprocess.run( + [ + sys.executable, + str(VERIFY_DIR / f"verify_{task}.py"), + "--run_dir", str(run), + "--initial_db", str(initial), + "--after_db", str(after), + "--no_llm", "true", + ], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + try: + verdict = json.loads(result.stdout) + except Exception as error: + self.fail(f"task {task} emitted invalid JSON: {error}\nstdout={result.stdout}\nstderr={result.stderr}") + return result.returncode, verdict + + def test_positive_trajectories_pass_every_verifier(self) -> None: + for task in range(21): + with self.subTest(task=task): + steps, answer, mutate = fixture(task) + code, verdict = self.run_verifier(task, steps, answer, mutate) + self.assertEqual(0, code, verdict) + self.assertTrue(verdict["pass"], verdict) + + def test_legal_alternative_paths_pass_every_verifier(self) -> None: + for task in range(21): + with self.subTest(task=task): + steps, answer, mutate = fixture(task, alternate=True) + code, verdict = self.run_verifier(task, steps, answer, mutate) + self.assertEqual(0, code, verdict) + self.assertTrue(verdict["pass"], verdict) + + def test_close_but_wrong_results_fail_every_verifier(self) -> None: + mutation_tasks = {5, 6, 9, 13, 15} + for task in range(21): + with self.subTest(task=task): + steps, answer, mutate = fixture(task) + if task in mutation_tasks: + mutate = None + else: + answer = "I visited the requested pages but could not determine the answer." + code, verdict = self.run_verifier(task, steps, answer, mutate) + self.assertNotEqual(0, code, verdict) + self.assertFalse(verdict["pass"], verdict) + + def test_answer_or_state_without_navigation_fails_every_verifier(self) -> None: + for task in range(21): + with self.subTest(task=task): + _steps, answer, mutate = fixture(task) + code, verdict = self.run_verifier(task, [], answer, mutate) + self.assertNotEqual(0, code, verdict) + self.assertFalse(verdict["pass"], verdict) + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/boardgamegeek/verify/verify_0.py b/sites/boardgamegeek/verify/verify_0.py new file mode 100644 index 00000000..875ca698 --- /dev/null +++ b/sites/boardgamegeek/verify/verify_0.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(0) diff --git a/sites/boardgamegeek/verify/verify_1.py b/sites/boardgamegeek/verify/verify_1.py new file mode 100644 index 00000000..6c98f4e9 --- /dev/null +++ b/sites/boardgamegeek/verify/verify_1.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(1) diff --git a/sites/boardgamegeek/verify/verify_10.py b/sites/boardgamegeek/verify/verify_10.py new file mode 100644 index 00000000..1f5f4d8d --- /dev/null +++ b/sites/boardgamegeek/verify/verify_10.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(10) diff --git a/sites/boardgamegeek/verify/verify_11.py b/sites/boardgamegeek/verify/verify_11.py new file mode 100644 index 00000000..6a2b091f --- /dev/null +++ b/sites/boardgamegeek/verify/verify_11.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(11) diff --git a/sites/boardgamegeek/verify/verify_12.py b/sites/boardgamegeek/verify/verify_12.py new file mode 100644 index 00000000..9ab0b635 --- /dev/null +++ b/sites/boardgamegeek/verify/verify_12.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(12) diff --git a/sites/boardgamegeek/verify/verify_13.py b/sites/boardgamegeek/verify/verify_13.py new file mode 100644 index 00000000..ac740840 --- /dev/null +++ b/sites/boardgamegeek/verify/verify_13.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(13) diff --git a/sites/boardgamegeek/verify/verify_14.py b/sites/boardgamegeek/verify/verify_14.py new file mode 100644 index 00000000..7c220825 --- /dev/null +++ b/sites/boardgamegeek/verify/verify_14.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(14) diff --git a/sites/boardgamegeek/verify/verify_15.py b/sites/boardgamegeek/verify/verify_15.py new file mode 100644 index 00000000..5aa0c87a --- /dev/null +++ b/sites/boardgamegeek/verify/verify_15.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(15) diff --git a/sites/boardgamegeek/verify/verify_16.py b/sites/boardgamegeek/verify/verify_16.py new file mode 100644 index 00000000..71f72a9a --- /dev/null +++ b/sites/boardgamegeek/verify/verify_16.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(16) diff --git a/sites/boardgamegeek/verify/verify_17.py b/sites/boardgamegeek/verify/verify_17.py new file mode 100644 index 00000000..9dc8a656 --- /dev/null +++ b/sites/boardgamegeek/verify/verify_17.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(17) diff --git a/sites/boardgamegeek/verify/verify_18.py b/sites/boardgamegeek/verify/verify_18.py new file mode 100644 index 00000000..4f5ea5af --- /dev/null +++ b/sites/boardgamegeek/verify/verify_18.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(18) diff --git a/sites/boardgamegeek/verify/verify_19.py b/sites/boardgamegeek/verify/verify_19.py new file mode 100644 index 00000000..2753a423 --- /dev/null +++ b/sites/boardgamegeek/verify/verify_19.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(19) diff --git a/sites/boardgamegeek/verify/verify_2.py b/sites/boardgamegeek/verify/verify_2.py new file mode 100644 index 00000000..07e66803 --- /dev/null +++ b/sites/boardgamegeek/verify/verify_2.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(2) diff --git a/sites/boardgamegeek/verify/verify_20.py b/sites/boardgamegeek/verify/verify_20.py new file mode 100644 index 00000000..b1b3f352 --- /dev/null +++ b/sites/boardgamegeek/verify/verify_20.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(20) diff --git a/sites/boardgamegeek/verify/verify_3.py b/sites/boardgamegeek/verify/verify_3.py new file mode 100644 index 00000000..b2592f72 --- /dev/null +++ b/sites/boardgamegeek/verify/verify_3.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(3) diff --git a/sites/boardgamegeek/verify/verify_4.py b/sites/boardgamegeek/verify/verify_4.py new file mode 100644 index 00000000..995f6f26 --- /dev/null +++ b/sites/boardgamegeek/verify/verify_4.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(4) diff --git a/sites/boardgamegeek/verify/verify_5.py b/sites/boardgamegeek/verify/verify_5.py new file mode 100644 index 00000000..902bdafc --- /dev/null +++ b/sites/boardgamegeek/verify/verify_5.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(5) diff --git a/sites/boardgamegeek/verify/verify_6.py b/sites/boardgamegeek/verify/verify_6.py new file mode 100644 index 00000000..f8bfa9ef --- /dev/null +++ b/sites/boardgamegeek/verify/verify_6.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(6) diff --git a/sites/boardgamegeek/verify/verify_7.py b/sites/boardgamegeek/verify/verify_7.py new file mode 100644 index 00000000..b95b615d --- /dev/null +++ b/sites/boardgamegeek/verify/verify_7.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(7) diff --git a/sites/boardgamegeek/verify/verify_8.py b/sites/boardgamegeek/verify/verify_8.py new file mode 100644 index 00000000..3182b966 --- /dev/null +++ b/sites/boardgamegeek/verify/verify_8.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(8) diff --git a/sites/boardgamegeek/verify/verify_9.py b/sites/boardgamegeek/verify/verify_9.py new file mode 100644 index 00000000..a1959bb0 --- /dev/null +++ b/sites/boardgamegeek/verify/verify_9.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(9) diff --git a/sites/boardgamegeek/verify/verify_lib.py b/sites/boardgamegeek/verify/verify_lib.py new file mode 100644 index 00000000..c6f73970 --- /dev/null +++ b/sites/boardgamegeek/verify/verify_lib.py @@ -0,0 +1,583 @@ +#!/usr/bin/env python3 +"""Shared deterministic verifier for BoardGameGeek tasks.""" + +from __future__ import annotations + +import argparse +import html +import ipaddress +import json +import os +import re +import sqlite3 +import unicodedata +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Iterable, Sequence +from urllib.parse import parse_qs, urlparse + + +PASSWORD = "TestPass123!" +KNOWN_GAME_SUBPAGES = {"ratings", "credits", "expansions", "forums"} + + +@dataclass(frozen=True) +class VerifyArgs: + run_dir: str + initial_db: str | None + after_db: str | None + no_llm: bool + + +def _bool_value(value: str) -> bool: + return str(value).casefold() in {"1", "true", "yes", "on"} + + +def parse_args() -> VerifyArgs: + parser = argparse.ArgumentParser() + parser.add_argument("--run_dir", required=True) + parser.add_argument("--initial_db") + parser.add_argument("--after_db") + parser.add_argument("--container", default="") # compatibility with agent_demo + parser.add_argument("--no_llm", nargs="?", const=True, default=False, type=_bool_value) + args = parser.parse_args() + run_dir = Path(args.run_dir) + initial = args.initial_db or str(run_dir / "initial.db") + after = args.after_db or str(run_dir / "after.db") + return VerifyArgs( + run_dir=str(run_dir), + initial_db=initial if Path(initial).is_file() else None, + after_db=after if Path(after).is_file() else None, + no_llm=bool(args.no_llm), + ) + + +def load_run(run_dir: str | os.PathLike[str]) -> dict[str, Any]: + data = json.loads((Path(run_dir) / "trajectory.json").read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError("trajectory.json must be a JSON object") + return data + + +def normalize_text(value: Any) -> str: + text = unicodedata.normalize("NFKC", str(value or "")) + text = text.replace("’", "'").replace("“", '"').replace("”", '"') + return re.sub(r"\s+", " ", text).strip().casefold() + + +def plain_html(value: Any) -> str: + return normalize_text(html.unescape(re.sub(r"<[^>]+>", " ", str(value or "")))) + + +def final_answer(trajectory: dict[str, Any]) -> str: + return str(trajectory.get("final_answer") or "").strip() + + +def trajectory_urls(trajectory: dict[str, Any]) -> list[str]: + urls: list[str] = [] + for value in [trajectory.get("start_url")]: + if value: + urls.append(str(value)) + for step in trajectory.get("steps") or []: + if not isinstance(step, dict): + continue + for key in ("url_before", "url", "url_after"): + value = str(step.get(key) or "") + if value and (not urls or value != urls[-1]): + urls.append(value) + final = str(trajectory.get("final_url") or "") + if final and (not urls or final != urls[-1]): + urls.append(final) + return urls + + +def _is_loopback(hostname: str) -> bool: + if hostname.casefold() == "localhost": + return True + try: + return ipaddress.ip_address(hostname).is_loopback + except ValueError: + return False + + +def is_target_url(url: str, trajectory: dict[str, Any]) -> bool: + parsed = urlparse(str(url or "")) + start = urlparse(str(trajectory.get("start_url") or "")) + return bool( + parsed.scheme in {"http", "https"} + and start.scheme == parsed.scheme + and parsed.hostname + and start.hostname + and _is_loopback(parsed.hostname) + and _is_loopback(start.hostname) + and parsed.port == start.port + ) + + +def normalized_path(url: str) -> str: + path = urlparse(str(url or "")).path or "/" + return path.rstrip("/") or "/" + + +def query_matches(url: str, expected: dict[str, str]) -> bool: + observed = parse_qs(urlparse(url).query) + return all( + normalize_text((observed.get(key) or [""])[0]) == normalize_text(value) + for key, value in expected.items() + ) + + +def target_urls(trajectory: dict[str, Any]) -> list[str]: + return [url for url in trajectory_urls(trajectory) if is_target_url(url, trajectory)] + + +def visited_path(trajectory: dict[str, Any], path: str) -> bool: + expected = normalized_path(path) + return any(normalized_path(url) == expected for url in target_urls(trajectory)) + + +def visited_query(trajectory: dict[str, Any], path: str, expected: dict[str, str]) -> bool: + wanted = normalized_path(path) + return any( + normalized_path(url) == wanted and query_matches(url, expected) + for url in target_urls(trajectory) + ) + + +def _game_path_matches(path: str, bgg_id: int, subpage: str | None = None) -> bool: + parts = normalized_path(path).strip("/").split("/") + if len(parts) < 2 or parts[0] != "boardgame" or parts[1] != str(bgg_id): + return False + if subpage is None: + return len(parts) == 2 or ( + len(parts) == 3 and parts[2] not in KNOWN_GAME_SUBPAGES + ) + return (len(parts) == 3 and parts[2] == subpage) or ( + len(parts) == 4 and parts[3] == subpage + ) + + +def visited_game(trajectory: dict[str, Any], bgg_id: int, subpage: str | None = None) -> bool: + return any( + _game_path_matches(url, bgg_id, subpage) for url in target_urls(trajectory) + ) + + +def visited_entity(trajectory: dict[str, Any], prefix: str, bgg_id: int) -> bool: + for url in target_urls(trajectory): + parts = normalized_path(url).strip("/").split("/") + if len(parts) in {2, 3} and parts[:2] == [prefix, str(bgg_id)]: + return True + return False + + +def ordered(trajectory: dict[str, Any], predicates: Sequence[Callable[[str], bool]]) -> bool: + urls = target_urls(trajectory) + cursor = 0 + for predicate in predicates: + for index in range(cursor, len(urls)): + if predicate(urls[index]): + cursor = index + 1 + break + else: + return False + return True + + +def path_predicate(path: str, query: dict[str, str] | None = None) -> Callable[[str], bool]: + expected_path = normalized_path(path) + expected_query = query or {} + return lambda url: normalized_path(url) == expected_path and query_matches(url, expected_query) + + +def game_predicate(bgg_id: int, subpage: str | None = None) -> Callable[[str], bool]: + return lambda url: _game_path_matches(url, bgg_id, subpage) + + +def transition_pairs(trajectory: dict[str, Any]): + steps = trajectory.get("steps") or [] + for index, step in enumerate(steps): + if not isinstance(step, dict): + continue + current = str(step.get("url") or step.get("url_before") or "") + if not is_target_url(current, trajectory): + continue + following = str(step.get("url_after") or "") + if not following and index + 1 < len(steps) and isinstance(steps[index + 1], dict): + following = str( + steps[index + 1].get("url") + or steps[index + 1].get("url_before") + or "" + ) + if following and is_target_url(following, trajectory): + yield normalize_text(step.get("action")), current, following + + +def submitted_from(trajectory: dict[str, Any], predicate: Callable[[str], bool]) -> bool: + return any(action == "click" and predicate(current) for action, current, _ in transition_pairs(trajectory)) + + +def input_values(trajectory: dict[str, Any], predicate: Callable[[str], bool] | None = None) -> list[str]: + values: list[str] = [] + for step in trajectory.get("steps") or []: + if not isinstance(step, dict): + continue + if normalize_text(step.get("action")) not in {"input", "fill", "type", "select"}: + continue + url = str(step.get("url") or step.get("url_before") or "") + if not is_target_url(url, trajectory) or (predicate and not predicate(url)): + continue + params = step.get("params") or {} + if not isinstance(params, dict): + continue + value = params.get("text", params.get("value", params.get("option", params.get("label")))) + if value is not None: + values.append(str(value)) + return values + + +def entered_exact(trajectory: dict[str, Any], value: str, predicate: Callable[[str], bool] | None = None) -> bool: + expected = normalize_text(value) + return any(normalize_text(item) == expected for item in input_values(trajectory, predicate)) + + +def entered_contains(trajectory: dict[str, Any], value: str, predicate: Callable[[str], bool] | None = None) -> bool: + expected = normalize_text(value) + return any(expected in normalize_text(item) for item in input_values(trajectory, predicate)) + + +def login_submitted_as(trajectory: dict[str, Any], username: str) -> bool: + predicate = path_predicate("/login") + return ( + visited_path(trajectory, "/login") + and entered_exact(trajectory, username, predicate) + and entered_exact(trajectory, PASSWORD, predicate) + and submitted_from(trajectory, predicate) + ) + + +NEGATIONS = {"not", "no", "never", "without", "isn't", "isnt", "wasn't", "wasnt", "didn't", "didnt"} + + +def _negated_at(text: str, start: int) -> bool: + clause = re.split(r"[.!?;:\n]+|\b(?:and|but|however|instead)\b", text[:start])[-1] + return any(word in NEGATIONS for word in re.findall(r"[a-z0-9]+(?:'[a-z]+)?", clause)) + + +def affirmative_contains(value: Any, expected: Any) -> bool: + text = normalize_text(value) + needle = normalize_text(expected) + matches = list(re.finditer(re.escape(needle), text)) if needle else [] + return bool(matches and not _negated_at(text, matches[-1].start())) + + +def contains_all(value: Any, expected: Iterable[Any]) -> bool: + return all(affirmative_contains(value, item) for item in expected) + + +def number_matches(value: Any, expected: float, tolerance: float = 0.005) -> bool: + text = normalize_text(value) + for match in re.finditer(r"(? bool: + text = normalize_text(value) + for match in re.finditer(r"(? tolerance or _negated_at(text, match.start()): + continue + window = text[max(0, match.start() - distance):match.end() + distance] + if any(normalize_text(label) in window for label in labels): + return True + return False + + +def claims_heavier(value: Any, winner: str, loser: str) -> bool: + text = normalize_text(value) + return ( + affirmative_contains(text, winner) + and affirmative_contains(text, loser) + and any(word in text for word in ("heavier", "higher", "more complex", "greater")) + ) + + +def db_rows(path: str, sql: str, params: Sequence[Any] = ()) -> list[sqlite3.Row]: + connection = sqlite3.connect(path) + connection.row_factory = sqlite3.Row + try: + return connection.execute(sql, params).fetchall() + finally: + connection.close() + + +def row_dicts(path: str, sql: str, params: Sequence[Any] = ()) -> list[dict[str, Any]]: + return [dict(row) for row in db_rows(path, sql, params)] + + +def table_rows(path: str, table: str) -> list[tuple[Any, ...]]: + return [tuple(row) for row in db_rows(path, f'SELECT * FROM "{table}" ORDER BY rowid')] + + +def table_names(path: str) -> list[str]: + return [ + str(row["name"]) + for row in db_rows( + path, + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + ) + ] + + +def changed_tables(initial: str, after: str) -> set[str]: + if table_names(initial) != table_names(after): + return {""} + return { + table + for table in table_names(initial) + if table_rows(initial, table) != table_rows(after, table) + } + + +def rows_by_id(path: str, table: str) -> dict[int, tuple[Any, ...]]: + return {int(row[0]): tuple(row) for row in db_rows(path, f'SELECT * FROM "{table}" ORDER BY id')} + + +class Judge: + def __init__(self, task_number: int): + self.task_id = f"BoardGameGeek--{task_number}" + self.passed = True + self.reason = "" + self.evidence: list[str] = [] + + def check(self, name: str, condition: bool, evidence: str = "") -> bool: + self.evidence.append(f"[{'PASS' if condition else 'FAIL'}] {name}: {evidence}") + if not condition: + self.passed = False + if not self.reason: + self.reason = name + return bool(condition) + + def emit(self) -> None: + print( + json.dumps( + { + "task_id": self.task_id, + "pass": self.passed, + "reason": self.reason, + "evidence": self.evidence, + }, + ensure_ascii=False, + indent=2, + ) + ) + raise SystemExit(0 if self.passed else 1) + + +def _require_common(judge: Judge, task_number: int, trajectory: dict[str, Any], args: VerifyArgs) -> tuple[str, str] | None: + answer = final_answer(trajectory) + judge.check("task_id_matches", trajectory.get("task_id") == judge.task_id, repr(trajectory.get("task_id"))) + judge.check("final_answer_nonempty", bool(answer), repr(answer)) + judge.check("start_url_is_target", is_target_url(str(trajectory.get("start_url") or ""), trajectory), repr(trajectory.get("start_url"))) + readable = bool(args.initial_db and args.after_db) + judge.check("databases_readable", readable, f"initial={args.initial_db} after={args.after_db}") + return (args.initial_db, args.after_db) if readable else None + + +def _read_only(judge: Judge, initial: str, after: str) -> None: + changes = changed_tables(initial, after) + judge.check("read_only_database_unchanged", not changes, repr(changes)) + + +def _answer_numbers(judge: Judge, answer: str, facts: Sequence[tuple[float, Sequence[str], float]]) -> None: + for number, labels, tolerance in facts: + judge.check( + f"answer_{normalize_text(labels[0]).replace(' ', '_')}", + number_bound_to(answer, number, labels, tolerance), + repr(answer), + ) + + +def _verify_read_task(task: int, trajectory: dict[str, Any], answer: str, initial: str, judge: Judge) -> None: + if task == 0: + game = row_dicts(initial, "SELECT * FROM games WHERE overall_rank=1")[0] + designers = row_dicts(initial, "SELECT p.name FROM people p JOIN game_designers gd ON gd.person_id=p.id WHERE gd.game_id=?", (game["id"],)) + judge.check("browse_then_rank_one_detail", ordered(trajectory, [path_predicate("/browse/boardgame"), game_predicate(game["bgg_id"])]), game["name"]) + judge.check("answer_game_and_all_designers", contains_all(answer, [game["name"], *[row["name"] for row in designers]]), repr(answer)) + elif task == 1: + game = row_dicts(initial, "SELECT * FROM games WHERE name='Gloomhaven'")[0] + judge.check("search_then_game_detail", ordered(trajectory, [path_predicate("/search", {"q": "Gloomhaven"}), game_predicate(game["bgg_id"])]), game["name"]) + _answer_numbers(judge, answer, [(game["avg_rating"], ("average", "rating"), 0.005), (game["weight"], ("weight", "complexity"), 0.005), (game["num_ratings"], ("voters", "ratings"), 0.0)]) + elif task == 2: + game = row_dicts(initial, "SELECT * FROM (SELECT * FROM games WHERE subtype='boardgame' ORDER BY weight DESC LIMIT 100) WHERE overall_rank BETWEEN 1 AND 100 ORDER BY weight DESC LIMIT 1")[0] + judge.check("weight_sorted_first_page", visited_query(trajectory, "/browse/boardgame", {"sort": "weight", "dir": "desc"}), "sort=weight&dir=desc") + judge.check("answer_game_and_weight", affirmative_contains(answer, game["name"]) and number_bound_to(answer, game["weight"], ("weight", "complexity"), 0.005), repr(answer)) + elif task == 3: + game = row_dicts(initial, "SELECT g.* FROM games g JOIN game_mechanics gm ON gm.game_id=g.id JOIN mechanics m ON m.id=gm.mechanic_id WHERE m.name='Worker Placement' AND g.overall_rank>0 ORDER BY g.overall_rank LIMIT 1")[0] + designers = row_dicts(initial, "SELECT p.name FROM people p JOIN game_designers gd ON gd.person_id=p.id WHERE gd.game_id=?", (game["id"],)) + judge.check("mechanism_then_game_detail", ordered(trajectory, [path_predicate("/boardgamemechanic"), lambda url: normalized_path(url).startswith("/boardgamemechanic/2082"), game_predicate(game["bgg_id"])]), game["name"]) + judge.check("answer_game_and_designers", contains_all(answer, [game["name"], *[row["name"] for row in designers]]), repr(answer)) + elif task == 4: + count = db_rows(initial, "SELECT COUNT(*) n FROM collections c JOIN users u ON u.id=c.user_id WHERE u.username='alice_j' AND c.own=1")[0]["n"] + judge.check("login_then_owned_collection", login_submitted_as(trajectory, "alice_j") and ordered(trajectory, [path_predicate("/login"), path_predicate("/collection/alice_j")]), "alice_j") + judge.check("answer_owned_count", number_bound_to(answer, count, ("owned", "collection", "games"), 0.0), repr(answer)) + elif task == 7: + latest_year = db_rows(initial, "SELECT MAX(year_published) year FROM games WHERE featured=1")[0]["year"] + game = row_dicts(initial, "SELECT * FROM games WHERE featured=1 AND year_published=? AND overall_rank>0 ORDER BY overall_rank LIMIT 1", (latest_year,))[0] + judge.check("hot_page_visited", visited_path(trajectory, "/hotness") or visited_path(trajectory, "/hot"), "hotness alias accepted") + judge.check("answer_latest_year_and_highest_ranked_game", affirmative_contains(answer, game["name"]) and number_matches(answer, latest_year, 0.0), repr(answer)) + elif task == 8: + data = row_dicts(initial, "SELECT l.id,l.title,l.num_items,u.username FROM geeklists l JOIN users u ON u.id=l.author_id WHERE l.title='Best Cooperative Games'")[0] + judge.check("geeklists_then_target_list", ordered(trajectory, [path_predicate("/geeklists"), path_predicate(f"/geeklist/{data['id']}")]), data["title"]) + judge.check("answer_author_and_count", affirmative_contains(answer, data["username"]) and number_bound_to(answer, data["num_items"], ("items", "games"), 0.0), repr(answer)) + elif task == 10: + games = {row["name"]: row for row in row_dicts(initial, "SELECT name,bgg_id,weight FROM games WHERE name IN ('Brass: Birmingham','Ark Nova')")} + judge.check("both_game_pages_visited", visited_game(trajectory, games["Brass: Birmingham"]["bgg_id"]) and visited_game(trajectory, games["Ark Nova"]["bgg_id"]), "both details") + judge.check("answer_identifies_heavier_game", claims_heavier(answer, "Brass: Birmingham", "Ark Nova"), repr(answer)) + elif task == 11: + publisher = row_dicts(initial, "SELECT * FROM publishers WHERE name='Z-Man Games'")[0] + count = db_rows(initial, "SELECT COUNT(*) n FROM game_publishers WHERE publisher_id=?", (publisher["id"],))[0]["n"] + judge.check("publisher_index_filter_then_detail", ordered(trajectory, [path_predicate("/boardgamepublisher"), lambda url: normalized_path(url).startswith(f"/boardgamepublisher/{publisher['bgg_id']}")]), publisher["name"]) + judge.check("publisher_filter_used", visited_query(trajectory, "/boardgamepublisher", {"q": publisher["name"]}), publisher["name"]) + judge.check("answer_publisher_count", number_bound_to(answer, count, ("games", "listed", "catalog"), 0.0), repr(answer)) + elif task == 12: + game = row_dicts(initial, "SELECT g.* FROM games g JOIN game_mechanics gm ON gm.game_id=g.id JOIN mechanics m ON m.id=gm.mechanic_id WHERE m.name='Action Points' AND g.overall_rank>0 ORDER BY g.overall_rank LIMIT 1")[0] + judge.check("mechanisms_then_action_points", ordered(trajectory, [path_predicate("/boardgamemechanic"), lambda url: normalized_path(url).startswith("/boardgamemechanic/2001")]), "Action Points") + judge.check("answer_game_and_rank", affirmative_contains(answer, game["name"]) and number_bound_to(answer, game["overall_rank"], ("rank", "#"), 0.0), repr(answer)) + elif task == 14: + game = row_dicts(initial, "SELECT * FROM games WHERE name='Wingspan'")[0] + top = row_dicts(initial, "SELECT u.username,r.num_thumbs FROM ratings r JOIN users u ON u.id=r.user_id WHERE r.game_id=? ORDER BY r.num_thumbs DESC,r.value DESC LIMIT 1", (game["id"],))[0] + judge.check("detail_then_helpful_ratings", ordered(trajectory, [game_predicate(game["bgg_id"]), lambda url: _game_path_matches(url, game["bgg_id"], "ratings") and query_matches(url, {"sort": "thumbs"})]), game["name"]) + judge.check("answer_user_and_thumbs", affirmative_contains(answer, top["username"]) and number_bound_to(answer, top["num_thumbs"], ("thumb", "helpful"), 0.0), repr(answer)) + elif task == 16: + data = row_dicts(initial, "SELECT l.id,l.title,g.name FROM geeklists l JOIN geeklist_items i ON i.list_id=l.id JOIN games g ON g.id=i.game_id WHERE l.title='Top 50 Heaviest Games of the Last Decade' AND i.position=1")[0] + judge.check("geeklists_then_target_list", ordered(trajectory, [path_predicate("/geeklists"), path_predicate(f"/geeklist/{data['id']}")]), data["title"]) + judge.check("answer_first_entry", affirmative_contains(answer, data["name"]), repr(answer)) + elif task == 17: + count = db_rows(initial, "SELECT COUNT(DISTINCT p.game_id) n FROM plays p JOIN users u ON u.id=p.user_id WHERE u.username='david_k'")[0]["n"] + judge.check("login_then_plays", login_submitted_as(trajectory, "david_k") and ordered(trajectory, [path_predicate("/login"), path_predicate("/plays/david_k")]), "david_k") + judge.check("answer_distinct_game_count", number_bound_to(answer, count, ("distinct", "games"), 0.0), repr(answer)) + elif task == 18: + designer = row_dicts(initial, "SELECT * FROM people WHERE name='Vital Lacerda'")[0] + game = row_dicts(initial, "SELECT g.* FROM games g JOIN game_designers gd ON gd.game_id=g.id WHERE gd.person_id=? ORDER BY g.avg_rating DESC LIMIT 1", (designer["id"],))[0] + judge.check("designer_filter_then_average_sort", ordered(trajectory, [lambda url: normalized_path(url) == "/boardgamedesigner" and query_matches(url, {"q": designer["name"]}), lambda url: normalized_path(url).startswith(f"/boardgamedesigner/{designer['bgg_id']}") and query_matches(url, {"sort": "average"})]), designer["name"]) + judge.check("answer_highest_average_game", affirmative_contains(answer, game["name"]), repr(answer)) + elif task == 19: + user = row_dicts(initial, "SELECT * FROM users WHERE lower(username) LIKE '%mike%' OR lower(real_name) LIKE '%mike%' ORDER BY username ASC LIMIT 1")[0] + count = db_rows(initial, "SELECT COUNT(*) n FROM geeklists WHERE author_id=?", (user["id"],))[0]["n"] + judge.check("user_search_then_first_profile", ordered(trajectory, [lambda url: normalized_path(url) == "/search" and query_matches(url, {"q": "mike", "type": "user"}), path_predicate(f"/user/{user['username']}")]), user["username"]) + judge.check("answer_username_and_geeklist_count", affirmative_contains(answer, user["username"]) and number_bound_to(answer, count, ("geeklists", "authored"), 0.0), repr(answer)) + elif task == 20: + game = row_dicts(initial, "SELECT * FROM games WHERE name='Twilight Struggle'")[0] + count = db_rows(initial, "SELECT COUNT(*) n FROM game_links WHERE game_id=? AND kind='expansion'", (game["id"],))[0]["n"] + judge.check("detail_then_expansions", ordered(trajectory, [game_predicate(game["bgg_id"]), game_predicate(game["bgg_id"], "expansions")]), game["name"]) + judge.check("answer_expansion_count", number_bound_to(answer, count, ("expansions", "listed"), 0.0), repr(answer)) + else: + raise ValueError(f"unsupported read task {task}") + + +def _verify_task_5(trajectory: dict[str, Any], answer: str, initial: str, after: str, judge: Judge) -> None: + game = row_dicts(initial, "SELECT * FROM games WHERE name='Brass: Birmingham'")[0] + user = row_dicts(initial, "SELECT * FROM users WHERE username='bob_c'")[0] + game_path = game_predicate(game["bgg_id"]) + judge.check("login_and_rating_flow", login_submitted_as(trajectory, "bob_c") and ordered(trajectory, [path_predicate("/login"), game_path]) and entered_exact(trajectory, "9.5", game_path) and submitted_from(trajectory, game_path), "bob_c -> Brass -> submit") + before = row_dicts(initial, "SELECT * FROM ratings WHERE user_id=? AND game_id=?", (user["id"], game["id"])) + now = row_dicts(after, "SELECT * FROM ratings WHERE user_id=? AND game_id=?", (user["id"], game["id"])) + judge.check("rating_was_new", before == [], repr(before)) + exact = len(now) == 1 and now[0]["value"] == 9.5 and len(plain_html(now[0]["review_html"])) >= 8 + sentence_marks = re.findall(r"[.!?]+(?:\s|$)", plain_html(now[0]["review_html"])) if now else [] + judge.check("one_sentence_rating_saved", exact and len(sentence_marks) <= 1, repr(now)) + after_game = row_dicts(after, "SELECT * FROM games WHERE id=?", (game["id"],))[0] + expected = (game["avg_rating"] * game["num_ratings"] + 9.5) / (game["num_ratings"] + 1) + judge.check("aggregate_updated_once", after_game["num_ratings"] == game["num_ratings"] + 1 and abs(after_game["avg_rating"] - expected) < 1e-12, f"expected={expected} after={after_game['avg_rating']}") + judge.check("only_rating_and_game_changed", changed_tables(initial, after) == {"games", "ratings"}, repr(changed_tables(initial, after))) + + +def _verify_task_6(trajectory: dict[str, Any], answer: str, initial: str, after: str, judge: Judge) -> None: + game = row_dicts(initial, "SELECT DISTINCT g.* FROM games g JOIN game_mechanics gm ON gm.game_id=g.id JOIN mechanics m ON m.id=gm.mechanic_id WHERE g.subtype='boardgame' AND g.minplayers=2 AND g.maxplayers=2 AND g.overall_rank>0 AND m.name IN ('Deck Construction','Deck, Bag, and Pool Building') ORDER BY g.overall_rank LIMIT 1")[0] + user = row_dicts(initial, "SELECT * FROM users WHERE username='carol_d'")[0] + judge.check("login_and_both_mechanism_pages", login_submitted_as(trajectory, "carol_d") and visited_entity(trajectory, "boardgamemechanic", 3004) and visited_entity(trajectory, "boardgamemechanic", 2664), "both mechanism categories") + game_path = game_predicate(game["bgg_id"]) + judge.check("target_game_opened_and_submitted", visited_game(trajectory, game["bgg_id"]) and submitted_from(trajectory, game_path), game["name"]) + before = row_dicts(initial, "SELECT * FROM collections WHERE user_id=? AND game_id=?", (user["id"], game["id"])) + now = row_dicts(after, "SELECT * FROM collections WHERE user_id=? AND game_id=?", (user["id"], game["id"])) + exact = len(now) == 1 and now[0]["wishlist"] == 1 and now[0]["wishlist_priority"] == 1 + judge.check("must_have_wishlist_added", before == [] and exact, repr(now)) + judge.check("only_collections_changed", changed_tables(initial, after) == {"collections"}, repr(changed_tables(initial, after))) + + +def _verify_task_9(trajectory: dict[str, Any], answer: str, initial: str, after: str, judge: Judge) -> None: + user = row_dicts(initial, "SELECT * FROM users WHERE username='david_k'")[0] + title = "My COIN Series Picks" + description = "Light, deep, and historical." + form = path_predicate("/geeklist/new") + judge.check("login_and_new_list_form", login_submitted_as(trajectory, "david_k") and ordered(trajectory, [path_predicate("/login"), form]) and entered_exact(trajectory, title, form) and entered_exact(trajectory, description, form) and submitted_from(trajectory, form), "exact title and description") + before_ids = {row["id"] for row in row_dicts(initial, "SELECT * FROM geeklists")} + after_rows = row_dicts(after, "SELECT * FROM geeklists ORDER BY id") + created = [row for row in after_rows if row["id"] not in before_ids] + exact = len(created) == 1 and created[0]["author_id"] == user["id"] and created[0]["title"] == title and plain_html(created[0]["description_html"]) == normalize_text(description) and created[0]["num_items"] == 0 + judge.check("one_exact_geeklist_created", exact, repr(created)) + judge.check("created_list_opened", len(created) == 1 and visited_path(trajectory, f"/geeklist/{created[0]['id']}"), repr(created)) + judge.check("only_geeklists_changed", changed_tables(initial, after) == {"geeklists"}, repr(changed_tables(initial, after))) + + +def _verify_task_13(trajectory: dict[str, Any], answer: str, initial: str, after: str, judge: Judge) -> None: + user = row_dicts(initial, "SELECT * FROM users WHERE username='alice_j'")[0] + entry = row_dicts(initial, "SELECT c.*,g.bgg_id,g.name FROM collections c JOIN games g ON g.id=c.game_id WHERE c.user_id=? AND c.own=1 ORDER BY c.updated_at DESC LIMIT 1", (user["id"],))[0] + collection_seen = visited_query(trajectory, "/collection/alice_j", {"sort": "recent"}) + game_path = game_predicate(entry["bgg_id"]) + judge.check("login_recent_collection_remove_flow", login_submitted_as(trajectory, "alice_j") and collection_seen and ordered(trajectory, [path_predicate("/login"), lambda url: normalized_path(url) == "/collection/alice_j" and query_matches(url, {"sort": "recent"}), game_path]) and submitted_from(trajectory, game_path), entry["name"]) + before = rows_by_id(initial, "collections") + now = rows_by_id(after, "collections") + judge.check("only_most_recent_entry_removed", entry["id"] in before and entry["id"] not in now and len(now) == len(before) - 1 and all(row == now.get(row_id) for row_id, row in before.items() if row_id != entry["id"]), entry["name"]) + judge.check("only_collections_changed", changed_tables(initial, after) == {"collections"}, repr(changed_tables(initial, after))) + + +def _verify_task_15(trajectory: dict[str, Any], answer: str, initial: str, after: str, judge: Judge) -> None: + user = row_dicts(initial, "SELECT * FROM users WHERE username='bob_c'")[0] + eligible = row_dicts(initial, "SELECT t.*,f.id forum_id FROM threads t JOIN forums f ON f.id=t.forum_id WHERE f.title='Recommendations' AND t.is_pinned=0 AND t.is_locked=0 AND (lower(t.subject) LIKE '%2 player%' OR lower(t.subject) LIKE '%two-player%')") + eligible_ids = {row["id"] for row in eligible} + visited_ids = {row["id"] for row in eligible if visited_path(trajectory, f"/thread/{row['id']}")} + chosen = next(iter(visited_ids), None) + predicate = path_predicate(f"/thread/{chosen}") if chosen else lambda _url: False + judge.check("login_forum_open_thread_flow", login_submitted_as(trajectory, "bob_c") and visited_path(trajectory, "/forums") and visited_path(trajectory, "/forum/3") and bool(visited_ids), repr(eligible_ids)) + judge.check("reply_phrase_entered_and_submitted", chosen is not None and entered_contains(trajectory, "7 Wonders Duel", predicate) and submitted_from(trajectory, predicate), repr(chosen)) + before_ids = {row["id"] for row in row_dicts(initial, "SELECT * FROM posts")} + new_posts = [row for row in row_dicts(after, "SELECT * FROM posts") if row["id"] not in before_ids] + exact = len(new_posts) == 1 and new_posts[0]["thread_id"] in eligible_ids and new_posts[0]["author_id"] == user["id"] and "7 wonders duel" in plain_html(new_posts[0]["body_html"]) + judge.check("one_exact_reply_saved", exact, repr(new_posts)) + if exact: + tid = new_posts[0]["thread_id"] + before_thread = row_dicts(initial, "SELECT * FROM threads WHERE id=?", (tid,))[0] + after_thread = row_dicts(after, "SELECT * FROM threads WHERE id=?", (tid,))[0] + before_forum = row_dicts(initial, "SELECT * FROM forums WHERE id=?", (before_thread["forum_id"],))[0] + after_forum = row_dicts(after, "SELECT * FROM forums WHERE id=?", (before_thread["forum_id"],))[0] + judge.check("thread_and_forum_counts_incremented", after_thread["num_posts"] == before_thread["num_posts"] + 1 and after_forum["num_posts"] == before_forum["num_posts"] + 1, f"thread={before_thread['num_posts']}->{after_thread['num_posts']} forum={before_forum['num_posts']}->{after_forum['num_posts']}") + judge.check("only_forum_reply_tables_changed", changed_tables(initial, after) == {"forums", "posts", "threads"}, repr(changed_tables(initial, after))) + + +def run_task(task_number: int) -> None: + args = parse_args() + trajectory = load_run(args.run_dir) + answer = final_answer(trajectory) + judge = Judge(task_number) + databases = _require_common(judge, task_number, trajectory, args) + if databases: + initial, after = databases + if task_number == 5: + _verify_task_5(trajectory, answer, initial, after, judge) + elif task_number == 6: + _verify_task_6(trajectory, answer, initial, after, judge) + elif task_number == 9: + _verify_task_9(trajectory, answer, initial, after, judge) + elif task_number == 13: + _verify_task_13(trajectory, answer, initial, after, judge) + elif task_number == 15: + _verify_task_15(trajectory, answer, initial, after, judge) + else: + _verify_read_task(task_number, trajectory, answer, initial, judge) + _read_only(judge, initial, after) + judge.emit() From ba1da57742be0b53c8219be248988512ef31f6a4 Mon Sep 17 00:00:00 2001 From: JackJin <1037461232@qq.com> Date: Tue, 8 Sep 2026 08:36:06 +0800 Subject: [PATCH 4/8] test(boardgamegeek): add judge rubrics --- sites/boardgamegeek/tasks.jsonl | 42 ++++++++++++++++----------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/sites/boardgamegeek/tasks.jsonl b/sites/boardgamegeek/tasks.jsonl index f8589007..47c0e5d4 100644 --- a/sites/boardgamegeek/tasks.jsonl +++ b/sites/boardgamegeek/tasks.jsonl @@ -1,21 +1,21 @@ -{"web_name":"BoardGameGeek","id":"BoardGameGeek--0","ques":"Find the #1 ranked board game on BoardGameGeek and report its designer(s). (Designers are not shown on the rank table — open the game's detail page.)","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_0.py"} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--1","ques":"Search for 'Gloomhaven' and report its average rating, weight, and number of voters from the game page.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_1.py"} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--2","ques":"On the Browse page, sort games by weight descending. Among the games listed on the first page of weight-sorted results, find the one whose overall rank is in the top 100 AND whose weight is the highest. Report its name and weight score.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_2.py"} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--3","ques":"Find the highest-ranked board game whose mechanism includes 'Worker Placement' and report its designer(s).","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_3.py"} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--4","ques":"Sign in as alice_j (password TestPass123!). How many games are in her owned collection?","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_4.py"} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--5","ques":"Sign in as bob_c (password TestPass123!), open Brass: Birmingham, rate it 9.5 and write a one-sentence review.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_5.py"} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--6","ques":"Sign in as carol_d (password TestPass123!). Using the 'Deck Construction' and 'Deck, Bag, and Pool Building' mechanism pages, find the highest-ranked game designed only for 2 players, then add it to her wishlist with priority 'Must have'.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_6.py"} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--7","ques":"On the Hot page, find the latest publication year in the current Hotness list, then report the highest-ranked game from that year.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_7.py"} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--8","ques":"Open the GeekList named 'Best Cooperative Games'. Who is the author and how many items does it have?","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_8.py"} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--9","ques":"Sign in as david_k (password TestPass123!) and create a new GeekList titled 'My COIN Series Picks' with the description 'Light, deep, and historical.'","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_9.py"} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--10","ques":"Compare Brass: Birmingham and Ark Nova on their game pages — which one has the heavier weight (higher complexity)?","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_10.py"} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--11","ques":"Open the publisher page for 'Z-Man Games' (use the Publishers index — you may have to filter by name). Report how many games are listed under that publisher in our catalog.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_11.py"} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--12","ques":"Browse to the 'Action Points' mechanism page. Among games using Action Points, what is the highest-ranked one and what is its overall rank?","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_12.py"} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--13","ques":"Sign in as alice_j (password TestPass123!). Open her collection sorted by 'Most recently updated' and remove the most-recently-updated entry from her collection.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_13.py"} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--14","ques":"Open Wingspan and navigate to its Ratings & Reviews tab. Sort by 'Most Helpful' (thumbs) and report the username and thumb count of the top review. (The Overview tab does not list individual reviews — you must open the Ratings tab.)","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_14.py"} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--15","ques":"In the Recommendations forum, find an open thread (not pinned, not locked) about 2-player games, open it, and post a reply suggesting '7 Wonders Duel' as an alternative. (Sign in as bob_c first, password TestPass123!.)","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_15.py"} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--16","ques":"Open the 'Top 50 Heaviest Games of the Last Decade' GeekList. What is the #1 entry on that list?","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_16.py"} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--17","ques":"Sign in as david_k (password TestPass123!). Open his Plays page and report how many distinct games he has logged plays for.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_17.py"} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--18","ques":"Find Vital Lacerda's designer page (the Designers index supports filtering by name). Among his games in the catalog, which one has the highest average rating?","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_18.py"} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--19","ques":"Search for users by the keyword 'mike'. Open the first matching user profile (alphabetical) and report how many GeekLists they have authored.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_19.py"} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--20","ques":"Open Twilight Struggle's Expansions page. How many expansions are listed there?","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_20.py"} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--0","ques":"Find the #1 ranked board game on BoardGameGeek and report its designer(s). (Designers are not shown on the rank table — open the game's detail page.)","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_0.py","judge_rubric":"FACT CHECKPOINTS: MUST open the ranking page and the #1 game's detail page. MUST report the correct game and every designer shown there. Empty, partial, or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--1","ques":"Search for 'Gloomhaven' and report its average rating, weight, and number of voters from the game page.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_1.py","judge_rubric":"FACT CHECKPOINTS: MUST use site search and open Gloomhaven's game page. MUST report its average rating, weight, and voter count. Empty or incomplete answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--2","ques":"On the Browse page, sort games by weight descending. Among the games listed on the first page of weight-sorted results, find the one whose overall rank is in the top 100 AND whose weight is the highest. Report its name and weight score.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_2.py","judge_rubric":"FACT CHECKPOINTS: MUST open Browse with descending weight sorting and use the first result page. MUST apply the top-100-rank constraint and report the qualifying game's name and weight. Empty or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--3","ques":"Find the highest-ranked board game whose mechanism includes 'Worker Placement' and report its designer(s).","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_3.py","judge_rubric":"FACT CHECKPOINTS: MUST open the Worker Placement mechanism page and the highest-ranked qualifying game's detail page. MUST report the correct game and all listed designers. Empty, partial, or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--4","ques":"Sign in as alice_j (password TestPass123!). How many games are in her owned collection?","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_4.py","judge_rubric":"FACT CHECKPOINTS: MUST sign in as alice_j and open her owned collection. MUST report the displayed owned-game count. Empty or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--5","ques":"Sign in as bob_c (password TestPass123!), open Brass: Birmingham, rate it 9.5 and write a one-sentence review.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_5.py","judge_rubric":"FACT CHECKPOINTS: MUST sign in as bob_c, open Brass: Birmingham, submit a 9.5 rating, and submit a non-empty one-sentence review. A claim without the saved rating and review FAILS."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--6","ques":"Sign in as carol_d (password TestPass123!). Using the 'Deck Construction' and 'Deck, Bag, and Pool Building' mechanism pages, find the highest-ranked game designed only for 2 players, then add it to her wishlist with priority 'Must have'.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_6.py","judge_rubric":"FACT CHECKPOINTS: MUST sign in as carol_d and inspect both named mechanism pages. MUST identify the highest-ranked game whose minimum and maximum player counts are both 2, then save it to her wishlist with priority Must have. A claim without the saved wishlist state FAILS."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--7","ques":"On the Hot page, find the latest publication year in the current Hotness list, then report the highest-ranked game from that year.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_7.py","judge_rubric":"FACT CHECKPOINTS: MUST open the Hot page, determine the latest year present, and compare overall ranks among entries from that year. MUST report the highest-ranked qualifying game. Empty or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--8","ques":"Open the GeekList named 'Best Cooperative Games'. Who is the author and how many items does it have?","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_8.py","judge_rubric":"FACT CHECKPOINTS: MUST open the Best Cooperative Games GeekList. MUST report the displayed author and item count. Empty or incomplete answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--9","ques":"Sign in as david_k (password TestPass123!) and create a new GeekList titled 'My COIN Series Picks' with the description 'Light, deep, and historical.'","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_9.py","judge_rubric":"FACT CHECKPOINTS: MUST sign in as david_k and create a GeekList with the exact requested title and description. A claim without the newly saved list under david_k FAILS."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--10","ques":"Compare Brass: Birmingham and Ark Nova on their game pages — which one has the heavier weight (higher complexity)?","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_10.py","judge_rubric":"FACT CHECKPOINTS: MUST open both game pages and compare their displayed weight values. MUST name the game with the higher weight. Empty or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--11","ques":"Open the publisher page for 'Z-Man Games' (use the Publishers index — you may have to filter by name). Report how many games are listed under that publisher in our catalog.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_11.py","judge_rubric":"FACT CHECKPOINTS: MUST use the Publishers index and open the Z-Man Games publisher page. MUST report the catalog count shown for that publisher. Empty or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--12","ques":"Browse to the 'Action Points' mechanism page. Among games using Action Points, what is the highest-ranked one and what is its overall rank?","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_12.py","judge_rubric":"FACT CHECKPOINTS: MUST open the Action Points mechanism page and compare the listed games' overall ranks. MUST report the highest-ranked game's name and rank. Empty or incomplete answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--13","ques":"Sign in as alice_j (password TestPass123!). Open her collection sorted by 'Most recently updated' and remove the most-recently-updated entry from her collection.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_13.py","judge_rubric":"FACT CHECKPOINTS: MUST sign in as alice_j, sort her collection by Most recently updated, and remove the first entry from that ordering. A claim without the corresponding collection deletion FAILS."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--14","ques":"Open Wingspan and navigate to its Ratings & Reviews tab. Sort by 'Most Helpful' (thumbs) and report the username and thumb count of the top review. (The Overview tab does not list individual reviews — you must open the Ratings tab.)","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_14.py","judge_rubric":"FACT CHECKPOINTS: MUST open Wingspan's Ratings & Reviews tab and sort reviews by Most Helpful. MUST report the first review's username and thumb count. Empty or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--15","ques":"In the Recommendations forum, find an open thread (not pinned, not locked) about 2-player games, open it, and post a reply suggesting '7 Wonders Duel' as an alternative. (Sign in as bob_c first, password TestPass123!.)","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_15.py","judge_rubric":"FACT CHECKPOINTS: MUST sign in as bob_c, open the Recommendations forum, select a 2-player thread that is neither pinned nor locked, and post a reply containing 7 Wonders Duel. A claim without the saved post in a qualifying thread FAILS."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--16","ques":"Open the 'Top 50 Heaviest Games of the Last Decade' GeekList. What is the #1 entry on that list?","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_16.py","judge_rubric":"FACT CHECKPOINTS: MUST open the named GeekList and inspect its ordered entries. MUST report the game in position #1. Empty or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--17","ques":"Sign in as david_k (password TestPass123!). Open his Plays page and report how many distinct games he has logged plays for.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_17.py","judge_rubric":"FACT CHECKPOINTS: MUST sign in as david_k and open his Plays page. MUST count distinct game titles rather than play rows and report that count. Empty or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--18","ques":"Find Vital Lacerda's designer page (the Designers index supports filtering by name). Among his games in the catalog, which one has the highest average rating?","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_18.py","judge_rubric":"FACT CHECKPOINTS: MUST use the Designers index and open Vital Lacerda's page. MUST compare the displayed average ratings and report the highest-rated listed game. Empty or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--19","ques":"Search for users by the keyword 'mike'. Open the first matching user profile (alphabetical) and report how many GeekLists they have authored.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_19.py","judge_rubric":"FACT CHECKPOINTS: MUST search users for mike, use alphabetical result order, and open the first matching profile. MUST report that profile's authored-GeekList count. Empty or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--20","ques":"Open Twilight Struggle's Expansions page. How many expansions are listed there?","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_20.py","judge_rubric":"FACT CHECKPOINTS: MUST open Twilight Struggle's Expansions page and count the listed expansions. MUST report that count. Empty or unsupported answers FAIL."} From ad58159190a96dcf245391d3c421a6c4132cf48c Mon Sep 17 00:00:00 2001 From: JackJin <1037461232@qq.com> Date: Tue, 8 Sep 2026 08:37:17 +0800 Subject: [PATCH 5/8] test(boardgamegeek): validate grading metadata --- sites/boardgamegeek/verify/test_environment_quality.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sites/boardgamegeek/verify/test_environment_quality.py b/sites/boardgamegeek/verify/test_environment_quality.py index 8483e7f5..7fc0c18e 100644 --- a/sites/boardgamegeek/verify/test_environment_quality.py +++ b/sites/boardgamegeek/verify/test_environment_quality.py @@ -44,6 +44,8 @@ def test_task_ids_and_urls_match_registered_port(self) -> None: expected = f"sites/boardgamegeek/verify/verify_{number}.py" self.assertEqual(expected, row["verifier_path"]) self.assertTrue((SITE_DIR.parents[1] / expected).is_file()) + self.assertTrue(row["judge_rubric"].startswith("FACT CHECKPOINTS:")) + self.assertNotIn("answer", row) def test_registration_template_displays_validation_errors(self) -> None: template = (SITE_DIR / "templates" / "register.html").read_text(encoding="utf-8") From 4ef221eca307ef51659dffd2b6aa626c17d9c751 Mon Sep 17 00:00:00 2001 From: JackJin <1037461232@qq.com> Date: Tue, 8 Sep 2026 09:35:32 +0800 Subject: [PATCH 6/8] chore(boardgamegeek): integrate after osu --- .assets-revision | 2 +- AGENTS.md | 12 +- CLAUDE.md | 2 +- CONTRIBUTING.md | 2 +- Dockerfile | 14 +- README.md | 8 +- agent_demo/README.md | 2 +- control_server.py | 2 +- review-reports/PR-63-FINAL-AUDIT.md | 40 + scripts/build.sh | 7 + scripts/check_assets.sh | 8 +- scripts/extract_assets.sh | 3 + sites/boardgamegeek/tasks.jsonl | 42 +- .../verify/test_environment_quality.py | 2 +- sites/boardgamegeek/verify/test_verifiers.py | 6 +- sites/osu/.build-generated-seed | 1 + sites/osu/.requires-images | 1 + sites/osu/_health.py | 17 + sites/osu/app.py | 922 ++++++++++++++ sites/osu/fetch_images.py | 172 +++ sites/osu/image_sources.json | 365 ++++++ sites/osu/requirements.txt | 7 + sites/osu/seed_data.py | 1067 +++++++++++++++++ sites/osu/static/css/.gitkeep | 0 sites/osu/static/js/.gitkeep | 0 sites/osu/tasks.jsonl | 20 + sites/osu/templates/404.html | 15 + sites/osu/templates/500.html | 12 + sites/osu/templates/about.html | 134 +++ sites/osu/templates/academics.html | 61 + sites/osu/templates/account.html | 75 ++ sites/osu/templates/admissions.html | 110 ++ sites/osu/templates/athletics.html | 95 ++ sites/osu/templates/athletics_team.html | 84 ++ sites/osu/templates/base.html | 287 +++++ sites/osu/templates/department_detail.html | 76 ++ sites/osu/templates/departments.html | 38 + sites/osu/templates/event_detail.html | 90 ++ sites/osu/templates/events.html | 107 ++ sites/osu/templates/faculty.html | 84 ++ sites/osu/templates/faculty_profile.html | 97 ++ sites/osu/templates/index.html | 137 +++ sites/osu/templates/login.html | 45 + sites/osu/templates/news.html | 102 ++ sites/osu/templates/news_article.html | 74 ++ sites/osu/templates/program_detail.html | 93 ++ sites/osu/templates/programs.html | 100 ++ sites/osu/templates/register.html | 65 + sites/osu/templates/research.html | 68 ++ sites/osu/templates/research_center.html | 75 ++ sites/osu/templates/search.html | 141 +++ sites/osu/verify/README.md | 17 + sites/osu/verify/TASK_REVIEW.md | 28 + sites/osu/verify/selfcheck.py | 18 + sites/osu/verify/test_app.py | 57 + sites/osu/verify/test_environment_quality.py | 46 + sites/osu/verify/test_support.py | 20 + sites/osu/verify/test_verifiers.py | 76 ++ sites/osu/verify/verify_0.py | 6 + sites/osu/verify/verify_1.py | 6 + sites/osu/verify/verify_10.py | 6 + sites/osu/verify/verify_11.py | 6 + sites/osu/verify/verify_12.py | 6 + sites/osu/verify/verify_13.py | 6 + sites/osu/verify/verify_14.py | 6 + sites/osu/verify/verify_15.py | 6 + sites/osu/verify/verify_16.py | 6 + sites/osu/verify/verify_17.py | 6 + sites/osu/verify/verify_18.py | 6 + sites/osu/verify/verify_19.py | 6 + sites/osu/verify/verify_2.py | 6 + sites/osu/verify/verify_3.py | 6 + sites/osu/verify/verify_4.py | 6 + sites/osu/verify/verify_5.py | 6 + sites/osu/verify/verify_6.py | 6 + sites/osu/verify/verify_7.py | 6 + sites/osu/verify/verify_8.py | 6 + sites/osu/verify/verify_9.py | 6 + sites/osu/verify/verify_lib.py | 372 ++++++ websyn_start.sh | 6 +- 80 files changed, 5703 insertions(+), 46 deletions(-) create mode 100644 review-reports/PR-63-FINAL-AUDIT.md create mode 100644 sites/osu/.build-generated-seed create mode 100644 sites/osu/.requires-images create mode 100644 sites/osu/_health.py create mode 100644 sites/osu/app.py create mode 100644 sites/osu/fetch_images.py create mode 100644 sites/osu/image_sources.json create mode 100644 sites/osu/requirements.txt create mode 100644 sites/osu/seed_data.py create mode 100644 sites/osu/static/css/.gitkeep create mode 100644 sites/osu/static/js/.gitkeep create mode 100644 sites/osu/tasks.jsonl create mode 100644 sites/osu/templates/404.html create mode 100644 sites/osu/templates/500.html create mode 100644 sites/osu/templates/about.html create mode 100644 sites/osu/templates/academics.html create mode 100644 sites/osu/templates/account.html create mode 100644 sites/osu/templates/admissions.html create mode 100644 sites/osu/templates/athletics.html create mode 100644 sites/osu/templates/athletics_team.html create mode 100644 sites/osu/templates/base.html create mode 100644 sites/osu/templates/department_detail.html create mode 100644 sites/osu/templates/departments.html create mode 100644 sites/osu/templates/event_detail.html create mode 100644 sites/osu/templates/events.html create mode 100644 sites/osu/templates/faculty.html create mode 100644 sites/osu/templates/faculty_profile.html create mode 100644 sites/osu/templates/index.html create mode 100644 sites/osu/templates/login.html create mode 100644 sites/osu/templates/news.html create mode 100644 sites/osu/templates/news_article.html create mode 100644 sites/osu/templates/program_detail.html create mode 100644 sites/osu/templates/programs.html create mode 100644 sites/osu/templates/register.html create mode 100644 sites/osu/templates/research.html create mode 100644 sites/osu/templates/research_center.html create mode 100644 sites/osu/templates/search.html create mode 100644 sites/osu/verify/README.md create mode 100644 sites/osu/verify/TASK_REVIEW.md create mode 100644 sites/osu/verify/selfcheck.py create mode 100644 sites/osu/verify/test_app.py create mode 100644 sites/osu/verify/test_environment_quality.py create mode 100644 sites/osu/verify/test_support.py create mode 100644 sites/osu/verify/test_verifiers.py create mode 100644 sites/osu/verify/verify_0.py create mode 100644 sites/osu/verify/verify_1.py create mode 100644 sites/osu/verify/verify_10.py create mode 100644 sites/osu/verify/verify_11.py create mode 100644 sites/osu/verify/verify_12.py create mode 100644 sites/osu/verify/verify_13.py create mode 100644 sites/osu/verify/verify_14.py create mode 100644 sites/osu/verify/verify_15.py create mode 100644 sites/osu/verify/verify_16.py create mode 100644 sites/osu/verify/verify_17.py create mode 100644 sites/osu/verify/verify_18.py create mode 100644 sites/osu/verify/verify_19.py create mode 100644 sites/osu/verify/verify_2.py create mode 100644 sites/osu/verify/verify_3.py create mode 100644 sites/osu/verify/verify_4.py create mode 100644 sites/osu/verify/verify_5.py create mode 100644 sites/osu/verify/verify_6.py create mode 100644 sites/osu/verify/verify_7.py create mode 100644 sites/osu/verify/verify_8.py create mode 100644 sites/osu/verify/verify_9.py create mode 100644 sites/osu/verify/verify_lib.py diff --git a/.assets-revision b/.assets-revision index 29bb8fc6..a25e5b8b 100644 --- a/.assets-revision +++ b/.assets-revision @@ -5,4 +5,4 @@ # is a git revision (branch name like `main`, a tag, or a specific commit # sha). Override at runtime with the ASSETS_REVISION env var. repo: ChilleD/WebHarbor -revision: 480c892e976bada6c0ea3f5a66e2b9efda65525d +revision: db9c73e62d853ed91f6152b5b7105571502b7e07 diff --git a/AGENTS.md b/AGENTS.md index 14e45939..d41dc157 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ A coding agent (Claude Code, Cursor, Aider, Codex, ...) is reading this. Read on ## What it is -20 Flask mirror websites (Amazon, GitHub, BBC News, ...) packaged into one Docker image, plus a control plane on `:8101` for resetting per-site state. Used as a deterministic offline environment for web-agent benchmarks. ~3 GB image. +22 Flask mirror websites (Amazon, GitHub, BBC News, ...) packaged into one Docker image, plus a control plane on `:8101` for resetting per-site state. Used as a deterministic offline environment for web-agent benchmarks. ~3 GB image. Two repos: - **code** (this one) — Flask apps, control plane, scripts. @@ -48,17 +48,17 @@ Inside the image, sites live at `/opt/WebSyn//`. The path predates the ren # fresh clone ./scripts/fetch_assets.sh # pulls assets from HF ./scripts/build.sh # docker build -t webharbor:dev . -docker run -d -p 8101:8101 -p 40000-40019:40000-40019 webharbor:dev +docker run -d -p 8101:8101 -p 40000-40021:40000-40021 webharbor:dev ``` Or use the published image directly: ```bash -docker run -d -p 8101:8101 -p 40000-40019:40000-40019 \ +docker run -d -p 8101:8101 -p 40000-40021:40000-40021 \ battalion7244/webharbor:latest ``` -Sites are on `40000`-`40019` in the order declared by `SITES=( ... )` in `websyn_start.sh`. Control plane: +Sites are on `40000`-`40021` in the order declared by `SITES=( ... )` in `websyn_start.sh`. Control plane: | Method | Path | Purpose | |--------|---------------------|-------------------------------------------| @@ -136,13 +136,13 @@ python3 -m py_compile sites//app.py # 3. run on alt ports (don't collide with anything you already have running) docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41019:40000-40019 webharbor:dev + -p 8201:8101 -p 41000-41021:40000-40021 webharbor:dev # 4. control plane healthy, all sites alive curl -s http://localhost:8201/health | python3 -m json.tool | head # 5. every site renders 200 -for p in $(seq 41000 41019); do +for p in $(seq 41000 41021); do curl -so /dev/null -w "$p:%{http_code}\n" http://localhost:$p/ done diff --git a/CLAUDE.md b/CLAUDE.md index 5dcf2283..9f81cd73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,4 +16,4 @@ The full agent guide is loaded above via `@AGENTS.md`. The notes below apply onl ## Existing containers -If a container is already running on `:8101` / `:40000-40019`, treat it as the user's working environment — don't `docker stop` or `docker rm` it without explicit confirmation. Spin up your test container under a different name on alt ports (`:8201`, `:41000-41019`). +If a container is already running on `:8101` / `:40000-40021`, treat it as the user's working environment — don't `docker stop` or `docker rm` it without explicit confirmation. Spin up your test container under a different name on alt ports (`:8201`, `:41000-41021`). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6dce56be..8bd124e4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ git clone https://github.com//webharbor && cd webharbor ./scripts/fetch_assets.sh # pull current assets ./scripts/new_site.py mywebsite # OR edit an existing site ./scripts/build.sh && docker run -d --rm \ - -p 8101:8101 -p 40000-40019:40000-40019 webharbor:dev + -p 8101:8101 -p 40000-40021:40000-40021 webharbor:dev # iterate locally... ./scripts/extract_assets.sh ../webharbor-static-pr/ # split assets out diff --git a/Dockerfile b/Dockerfile index 242175aa..28d124be 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # WebHarbor — slim, self-contained image. -# 21 Flask mirror sites + control plane on :8101. +# 22 Flask mirror sites + control plane on :8101. FROM python:3.12-slim-bookworm @@ -41,6 +41,16 @@ COPY control_server.py /opt/control_server.py COPY site_runner.py /opt/site_runner.py RUN chmod +x /opt/websyn_start.sh -EXPOSE 8101 40000-40020 +# OSU's real-site image bundle is required, while its database is generated +# deterministically from tracked source data. +RUN test -n "$(ls -A /opt/WebSyn/osu/static/images)" +RUN cd /opt/WebSyn/osu && python3 -c "\ +import app; \ +import os, shutil; \ +os.makedirs('instance_seed', exist_ok=True); \ +shutil.copy2('instance/osu.db', 'instance_seed/osu.db'); \ +print('osu seed DB generated at build time.')" && rm -rf /opt/WebSyn/osu/instance + +EXPOSE 8101 40000-40021 CMD ["/opt/websyn_start.sh"] diff --git a/README.md b/README.md index 28457c4e..929a7bcc 100644 --- a/README.md +++ b/README.md @@ -36,17 +36,17 @@ WebHarbor takes a different approach. We leverage coding agent (e.g., Claude Cod - **Deep features unlocked** — carts, checkouts, accounts, all fully testable - **Evolving** — harder tasks drive richer mirrors; the environment grows with agents - **RL-ready** — sub-second database resets between rollouts -- **Community-driven** — 20 sites today, scaling to 100+ together +- **Community-driven** — 22 sites today, scaling to 100+ together ## 🚀 Quickstart One command to run all web environments: ```bash -docker run -p 8101:8101 -p 40000-40019:40000-40019 battalion7244/webharbor:latest +docker run -p 8101:8101 -p 40000-40021:40000-40021 battalion7244/webharbor:latest ``` -Then point your agent at `http://localhost:40000` through `http://localhost:40019` to explore 20 local mirrors of webvoyager sites: `Allrecipes, Amazon, Apple, ArXiv, BBC News, Booking, GitHub, Google Flights, Google Maps, Google Search, Hugging Face, Wolfram Alpha, Cambridge Dictionary, Coursera, ESPN, Merriam-Webster, IKEA, Phys.org, Target, and TED`. +Then point your agent at `http://localhost:40000` through `http://localhost:40021` to explore 22 local mirrors of webvoyager sites: `Allrecipes, Amazon, Apple, ArXiv, BBC News, Booking, GitHub, Google Flights, Google Maps, Google Search, Hugging Face, Wolfram Alpha, Cambridge Dictionary, Coursera, ESPN, Merriam-Webster, IKEA, Phys.org, Target, TED, Ohio State University, and BoardGameGeek`. For sub-second reset between rollouts, expose the control plane and call `/reset/`: @@ -65,7 +65,7 @@ git clone https://github.com/aiming-lab/WebHarbor && cd WebHarbor ## 🤝 Contribute -We have built 20 high-quality mirrors covering the [WebVoyager](https://github.com/MinorJerry/WebVoyager) benchmark. The next goal is **100+ sites**, covering everything in [Online-Mind2Web](https://huggingface.co/datasets/osunlp/Online-Mind2Web). We are inviting the community to build this together. +We have built 22 high-quality mirrors covering the [WebVoyager](https://github.com/MinorJerry/WebVoyager) benchmark. The next goal is **100+ sites**, covering everything in [Online-Mind2Web](https://huggingface.co/datasets/osunlp/Online-Mind2Web). We are inviting the community to build this together. There are two ways to join the author list: diff --git a/agent_demo/README.md b/agent_demo/README.md index a8a1a5c2..5a32a789 100644 --- a/agent_demo/README.md +++ b/agent_demo/README.md @@ -19,7 +19,7 @@ export OPENAI_BASE_URL=https://api.openai.com/v1 # or your Azure / vLLM endpoi ## Run a task -WebHarbor must already be running locally (`docker run -p 8101:8101 -p 40000-40019:40000-40019 battalion7244/webharbor:latest`). +WebHarbor must already be running locally (`docker run -p 8101:8101 -p 40000-40021:40000-40021 battalion7244/webharbor:latest`). Run a single task from a site's `tasks.jsonl`: diff --git a/control_server.py b/control_server.py index df8fb621..b3ad52a9 100644 --- a/control_server.py +++ b/control_server.py @@ -26,7 +26,7 @@ 'allrecipes', 'amazon', 'apple', 'arxiv', 'bbc_news', 'booking', 'github', 'google_flights', 'google_map', 'google_search', 'huggingface', 'wolfram_alpha', 'cambridge_dictionary', - 'coursera', 'espn', 'merriam_webster', 'ikea', 'phys_org', 'target', 'ted', + 'coursera', 'espn', 'merriam_webster', 'ikea', 'phys_org', 'target', 'ted', 'osu', 'boardgamegeek', ] BASE_PORT = 40000 diff --git a/review-reports/PR-63-FINAL-AUDIT.md b/review-reports/PR-63-FINAL-AUDIT.md new file mode 100644 index 00000000..fa5635eb --- /dev/null +++ b/review-reports/PR-63-FINAL-AUDIT.md @@ -0,0 +1,40 @@ +# PR #63 independent review and remediation audit + +## Scope + +Seven independent review contexts examined PR #63 head `4890c86374daea2fa29455b919b0ab55095efc3d` against its original base `438a029c04d86b22c710ad5d985d1d1491d2cb98`. Integration was also checked against current main `e911a6adb28d04397ed30d8293e77b0c62a112d5`. Raw reports are retained outside the repository under `/data/zhaoyang-user-projects/websyn/_wh_review_tools/pr63-agents/reports/`. + +## Agent findings and dispositions + +| Review agent | Findings on original PR head | Verification and disposition | +|---|---|---| +| Security and state | Hard-coded secret, login/bookmark open redirects, GET logout, news GET writes, unconstrained/dangling bookmarks, no bookmark uniqueness constraint, malformed session crash, weak username normalization, and unbounded notes. | Confirmed in `sites/osu/app.py`. Fixed with environment/random secret, local redirect validation, POST logout, read-only news GET, bookmark type/object validation, 500-character notes, a database unique constraint with race handling, robust user loading, lowercase usernames, request limits, session rotation, and SQLite foreign keys. | +| Task and data | Task 1 conflicted with the number of seeded team rows; several tasks were answerable from prior knowledge; task 9 accepted incomplete degree types; task 17 did not ask for a precise output; many valid-answer aliases increased grading ambiguity. | Rewrote all 20 tasks around explicit visible routes, filters, multi-field facts, and comparisons. Task 1 now explicitly asks for the About-page display. Task 9 requires all three Engineering degree types. Task 17 requires exact article title and author. Multi-entity tasks bind each number/value to its entity. | +| Verifier robustness | URL substring checks accepted external or wrong paths; required search/filter/topic steps were missing; negated answers and unrelated numbers could pass; `--no_llm` removed semantic enforcement; the synthetic selfcheck omitted adversarial cases. | Replaced the common verifier library and all 20 verifiers with same-origin exact path/query/order checks, visible-link transition checks, exact task IDs, non-empty output, negation-aware facts, entity-bound comparison values, and complete database equality for every read-only task. Added positive and adversarial tests for wrong task IDs, answer-only runs, external origins, database mutation, missing filters, negation, and swapped values. | +| UI and responsive behavior | Fixed two-column detail layouts overflowed on mobile; header/search/top bar could overflow; navigation lacked an overflow affordance and current-page semantics; forms lacked clear labels; focus styling was incomplete; cards with inline flex layouts could overflow; the mirror contained no real photographic assets. | Added responsive detail layout classes, mobile header stacking, horizontally scrollable navigation, `min-width:0` and wrapping protections, fixed grid breakpoint ordering, accessible search names, current-page `aria-current`, and focus-visible styles. Crawled 19 photographs from official Ohio State web properties, preserved source-page URLs and hashes in `sites/osu/image_sources.json`, and integrated the photographs into home, section, card, and detail layouts. Automated tests cover 320 px, 390 px, and 1440 px layouts. | +| Integration | The PR was based on a 17-site tree while current main has 20 sites; OSU collided with IKEA at `40016`; docs and asset pins were stale; clean `scripts/build.sh` would reject OSU because it intentionally has no HF seed bundle. | Merged current main locally, retained all existing sites, appended OSU as site index 20 on `40020`, exposed `40000-40020`, and updated all documentation. Added `.build-generated-seed` support for the deterministic OSU database and `.requires-images` enforcement for its HF-hosted image bundle. The pinned HF revision now contains `osu.tar.gz`. | +| Application and data model | Import-time seed recursion under `python app.py`, broad partial-state seed gate, wall-clock event filtering, nondeterministic seeded user hashes/timestamps, missing bookmark referential validation, and brittle phrase search. | Added module-safe seed resolution, partial-database failure, fixed benchmark time, stable seeded password hash and timestamps, exact bookmark validation, and token-overlap ranked global search. The same source now generates byte-identical OSU databases in repeated clean runs. | +| Test evidence | The original selfcheck used fabricated trajectories only; it did not run the site, verify rendered facts, test external-origin/path spoofing, detect database mutations, validate responsive pages, or prove a current-main Docker build. | Replaced the original selfcheck with a complete unittest entry point and added HTTP, seed, integration, positive verifier, and adversarial verifier suites. Actual Playwright task trajectories, screenshots, and before/after databases were produced for all 20 tasks. A clean build without a host OSU seed and a 21-site container smoke/reset test were completed. | + +## Validation + +- Python compilation, shell syntax, Ruff fatal/undefined-name checks, and `git diff --check`: PASS. +- OSU HTTP, seed, image provenance, integration, and verifier suite: 24 tests PASS. +- Actual Playwright completion from fresh databases: 20/20 PASS. +- Deterministic verifier results for those browser runs: 20/20 PASS. +- Responsive checks at 320 px, 390 px, and 1440 px: 45/45 PASS with no page-level horizontal overflow. +- Repeated clean OSU seed generation: byte-identical. +- Official-image provenance manifest: 19/19 assets present, non-empty, and SHA-256 verified. +- Hugging Face dataset PR #56 merged; pinned revision `db9c73e62d853ed91f6152b5b7105571502b7e07` contains `osu.tar.gz`. +- Pinned HF `osu.tar.gz` download, deterministic tar SHA-256 (`1fc684a25890262137714b56577cd8bcbe0c5a867c65da12f3d45ab7662949f7`), and extraction: PASS. +- Clean Docker build with `sites/osu/instance_seed` absent from the build context: PASS. +- Container health: all 21 sites alive and all 21 site roots returned HTTP 200. +- `/reset/osu`: PASS; runtime and seed SHA-256 values match. +- `/reset-all`: PASS for all 21 sites. + +## Evidence + +- Agent reports: `/data/zhaoyang-user-projects/websyn/_wh_review_tools/pr63-agents/reports/` +- Browser trajectories, screenshots, and verifier outputs: `/data/zhaoyang-user-projects/websyn/_wh_review_tools/pr63-fixes/e2e/` +- Responsive results and screenshots: `/data/zhaoyang-user-projects/websyn/_wh_review_tools/pr63-fixes/responsive/` +- Official image source pages, source URLs, dimensions, and hashes: `sites/osu/image_sources.json` diff --git a/scripts/build.sh b/scripts/build.sh index cff28b95..1953c7a6 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -10,6 +10,13 @@ TAG="${1:-webharbor:dev}" # Fast probe — if any site is missing instance_seed/, run fetch_assets. need_fetch=0 for site in sites/*/; do + if [[ -f "${site}.requires-images" ]] && { [[ ! -d "${site}static/images" ]] || [[ -z $(ls -A "${site}static/images" 2>/dev/null) ]]; }; then + need_fetch=1 + break + fi + if [[ -f "${site}.build-generated-seed" ]]; then + continue + fi if [[ ! -d "${site}instance_seed" ]]; then need_fetch=1 break diff --git a/scripts/check_assets.sh b/scripts/check_assets.sh index 61e3e7bc..2ced805d 100755 --- a/scripts/check_assets.sh +++ b/scripts/check_assets.sh @@ -18,13 +18,19 @@ warnings=0 for site in sites/*/; do s=$(basename "$site") for sub in "${REQUIRED[@]}"; do + if [[ -f "sites/$s/.build-generated-seed" && "$sub" == "instance_seed" ]]; then + continue + fi if [[ ! -d "sites/$s/$sub" ]] || [[ -z $(ls -A "sites/$s/$sub" 2>/dev/null) ]]; then echo " MISSING (required): sites/$s/$sub" missing=$((missing + 1)) fi done for sub in "${OPTIONAL[@]}"; do - if [[ ! -d "sites/$s/$sub" ]] || [[ -z $(ls -A "sites/$s/$sub" 2>/dev/null) ]]; then + if [[ -f "sites/$s/.requires-images" && "$sub" == "static/images" ]] && { [[ ! -d "sites/$s/$sub" ]] || [[ -z $(ls -A "sites/$s/$sub" 2>/dev/null) ]]; }; then + echo " MISSING (required): sites/$s/$sub" + missing=$((missing + 1)) + elif [[ ! -d "sites/$s/$sub" ]] || [[ -z $(ls -A "sites/$s/$sub" 2>/dev/null) ]]; then warnings=$((warnings + 1)) fi done diff --git a/scripts/extract_assets.sh b/scripts/extract_assets.sh index 1dd5e8b0..14fbe3a6 100755 --- a/scripts/extract_assets.sh +++ b/scripts/extract_assets.sh @@ -35,6 +35,9 @@ for site_dir in sites/*/; do members=() for sub in "${SUBPATHS[@]}"; do + if [[ "$sub" == "instance_seed" && -f "${site_dir}.build-generated-seed" ]]; then + continue + fi [[ -e "$site_dir$sub" ]] && members+=("$site/$sub") done if [[ ${#members[@]} -eq 0 ]]; then diff --git a/sites/boardgamegeek/tasks.jsonl b/sites/boardgamegeek/tasks.jsonl index 47c0e5d4..eaf98f4e 100644 --- a/sites/boardgamegeek/tasks.jsonl +++ b/sites/boardgamegeek/tasks.jsonl @@ -1,21 +1,21 @@ -{"web_name":"BoardGameGeek","id":"BoardGameGeek--0","ques":"Find the #1 ranked board game on BoardGameGeek and report its designer(s). (Designers are not shown on the rank table — open the game's detail page.)","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_0.py","judge_rubric":"FACT CHECKPOINTS: MUST open the ranking page and the #1 game's detail page. MUST report the correct game and every designer shown there. Empty, partial, or unsupported answers FAIL."} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--1","ques":"Search for 'Gloomhaven' and report its average rating, weight, and number of voters from the game page.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_1.py","judge_rubric":"FACT CHECKPOINTS: MUST use site search and open Gloomhaven's game page. MUST report its average rating, weight, and voter count. Empty or incomplete answers FAIL."} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--2","ques":"On the Browse page, sort games by weight descending. Among the games listed on the first page of weight-sorted results, find the one whose overall rank is in the top 100 AND whose weight is the highest. Report its name and weight score.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_2.py","judge_rubric":"FACT CHECKPOINTS: MUST open Browse with descending weight sorting and use the first result page. MUST apply the top-100-rank constraint and report the qualifying game's name and weight. Empty or unsupported answers FAIL."} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--3","ques":"Find the highest-ranked board game whose mechanism includes 'Worker Placement' and report its designer(s).","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_3.py","judge_rubric":"FACT CHECKPOINTS: MUST open the Worker Placement mechanism page and the highest-ranked qualifying game's detail page. MUST report the correct game and all listed designers. Empty, partial, or unsupported answers FAIL."} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--4","ques":"Sign in as alice_j (password TestPass123!). How many games are in her owned collection?","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_4.py","judge_rubric":"FACT CHECKPOINTS: MUST sign in as alice_j and open her owned collection. MUST report the displayed owned-game count. Empty or unsupported answers FAIL."} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--5","ques":"Sign in as bob_c (password TestPass123!), open Brass: Birmingham, rate it 9.5 and write a one-sentence review.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_5.py","judge_rubric":"FACT CHECKPOINTS: MUST sign in as bob_c, open Brass: Birmingham, submit a 9.5 rating, and submit a non-empty one-sentence review. A claim without the saved rating and review FAILS."} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--6","ques":"Sign in as carol_d (password TestPass123!). Using the 'Deck Construction' and 'Deck, Bag, and Pool Building' mechanism pages, find the highest-ranked game designed only for 2 players, then add it to her wishlist with priority 'Must have'.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_6.py","judge_rubric":"FACT CHECKPOINTS: MUST sign in as carol_d and inspect both named mechanism pages. MUST identify the highest-ranked game whose minimum and maximum player counts are both 2, then save it to her wishlist with priority Must have. A claim without the saved wishlist state FAILS."} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--7","ques":"On the Hot page, find the latest publication year in the current Hotness list, then report the highest-ranked game from that year.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_7.py","judge_rubric":"FACT CHECKPOINTS: MUST open the Hot page, determine the latest year present, and compare overall ranks among entries from that year. MUST report the highest-ranked qualifying game. Empty or unsupported answers FAIL."} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--8","ques":"Open the GeekList named 'Best Cooperative Games'. Who is the author and how many items does it have?","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_8.py","judge_rubric":"FACT CHECKPOINTS: MUST open the Best Cooperative Games GeekList. MUST report the displayed author and item count. Empty or incomplete answers FAIL."} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--9","ques":"Sign in as david_k (password TestPass123!) and create a new GeekList titled 'My COIN Series Picks' with the description 'Light, deep, and historical.'","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_9.py","judge_rubric":"FACT CHECKPOINTS: MUST sign in as david_k and create a GeekList with the exact requested title and description. A claim without the newly saved list under david_k FAILS."} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--10","ques":"Compare Brass: Birmingham and Ark Nova on their game pages — which one has the heavier weight (higher complexity)?","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_10.py","judge_rubric":"FACT CHECKPOINTS: MUST open both game pages and compare their displayed weight values. MUST name the game with the higher weight. Empty or unsupported answers FAIL."} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--11","ques":"Open the publisher page for 'Z-Man Games' (use the Publishers index — you may have to filter by name). Report how many games are listed under that publisher in our catalog.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_11.py","judge_rubric":"FACT CHECKPOINTS: MUST use the Publishers index and open the Z-Man Games publisher page. MUST report the catalog count shown for that publisher. Empty or unsupported answers FAIL."} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--12","ques":"Browse to the 'Action Points' mechanism page. Among games using Action Points, what is the highest-ranked one and what is its overall rank?","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_12.py","judge_rubric":"FACT CHECKPOINTS: MUST open the Action Points mechanism page and compare the listed games' overall ranks. MUST report the highest-ranked game's name and rank. Empty or incomplete answers FAIL."} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--13","ques":"Sign in as alice_j (password TestPass123!). Open her collection sorted by 'Most recently updated' and remove the most-recently-updated entry from her collection.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_13.py","judge_rubric":"FACT CHECKPOINTS: MUST sign in as alice_j, sort her collection by Most recently updated, and remove the first entry from that ordering. A claim without the corresponding collection deletion FAILS."} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--14","ques":"Open Wingspan and navigate to its Ratings & Reviews tab. Sort by 'Most Helpful' (thumbs) and report the username and thumb count of the top review. (The Overview tab does not list individual reviews — you must open the Ratings tab.)","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_14.py","judge_rubric":"FACT CHECKPOINTS: MUST open Wingspan's Ratings & Reviews tab and sort reviews by Most Helpful. MUST report the first review's username and thumb count. Empty or unsupported answers FAIL."} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--15","ques":"In the Recommendations forum, find an open thread (not pinned, not locked) about 2-player games, open it, and post a reply suggesting '7 Wonders Duel' as an alternative. (Sign in as bob_c first, password TestPass123!.)","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_15.py","judge_rubric":"FACT CHECKPOINTS: MUST sign in as bob_c, open the Recommendations forum, select a 2-player thread that is neither pinned nor locked, and post a reply containing 7 Wonders Duel. A claim without the saved post in a qualifying thread FAILS."} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--16","ques":"Open the 'Top 50 Heaviest Games of the Last Decade' GeekList. What is the #1 entry on that list?","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_16.py","judge_rubric":"FACT CHECKPOINTS: MUST open the named GeekList and inspect its ordered entries. MUST report the game in position #1. Empty or unsupported answers FAIL."} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--17","ques":"Sign in as david_k (password TestPass123!). Open his Plays page and report how many distinct games he has logged plays for.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_17.py","judge_rubric":"FACT CHECKPOINTS: MUST sign in as david_k and open his Plays page. MUST count distinct game titles rather than play rows and report that count. Empty or unsupported answers FAIL."} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--18","ques":"Find Vital Lacerda's designer page (the Designers index supports filtering by name). Among his games in the catalog, which one has the highest average rating?","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_18.py","judge_rubric":"FACT CHECKPOINTS: MUST use the Designers index and open Vital Lacerda's page. MUST compare the displayed average ratings and report the highest-rated listed game. Empty or unsupported answers FAIL."} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--19","ques":"Search for users by the keyword 'mike'. Open the first matching user profile (alphabetical) and report how many GeekLists they have authored.","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_19.py","judge_rubric":"FACT CHECKPOINTS: MUST search users for mike, use alphabetical result order, and open the first matching profile. MUST report that profile's authored-GeekList count. Empty or unsupported answers FAIL."} -{"web_name":"BoardGameGeek","id":"BoardGameGeek--20","ques":"Open Twilight Struggle's Expansions page. How many expansions are listed there?","web":"http://localhost:40020/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_20.py","judge_rubric":"FACT CHECKPOINTS: MUST open Twilight Struggle's Expansions page and count the listed expansions. MUST report that count. Empty or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--0","ques":"Find the #1 ranked board game on BoardGameGeek and report its designer(s). (Designers are not shown on the rank table — open the game's detail page.)","web":"http://localhost:40021/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_0.py","judge_rubric":"FACT CHECKPOINTS: MUST open the ranking page and the #1 game's detail page. MUST report the correct game and every designer shown there. Empty, partial, or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--1","ques":"Search for 'Gloomhaven' and report its average rating, weight, and number of voters from the game page.","web":"http://localhost:40021/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_1.py","judge_rubric":"FACT CHECKPOINTS: MUST use site search and open Gloomhaven's game page. MUST report its average rating, weight, and voter count. Empty or incomplete answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--2","ques":"On the Browse page, sort games by weight descending. Among the games listed on the first page of weight-sorted results, find the one whose overall rank is in the top 100 AND whose weight is the highest. Report its name and weight score.","web":"http://localhost:40021/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_2.py","judge_rubric":"FACT CHECKPOINTS: MUST open Browse with descending weight sorting and use the first result page. MUST apply the top-100-rank constraint and report the qualifying game's name and weight. Empty or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--3","ques":"Find the highest-ranked board game whose mechanism includes 'Worker Placement' and report its designer(s).","web":"http://localhost:40021/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_3.py","judge_rubric":"FACT CHECKPOINTS: MUST open the Worker Placement mechanism page and the highest-ranked qualifying game's detail page. MUST report the correct game and all listed designers. Empty, partial, or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--4","ques":"Sign in as alice_j (password TestPass123!). How many games are in her owned collection?","web":"http://localhost:40021/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_4.py","judge_rubric":"FACT CHECKPOINTS: MUST sign in as alice_j and open her owned collection. MUST report the displayed owned-game count. Empty or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--5","ques":"Sign in as bob_c (password TestPass123!), open Brass: Birmingham, rate it 9.5 and write a one-sentence review.","web":"http://localhost:40021/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_5.py","judge_rubric":"FACT CHECKPOINTS: MUST sign in as bob_c, open Brass: Birmingham, submit a 9.5 rating, and submit a non-empty one-sentence review. A claim without the saved rating and review FAILS."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--6","ques":"Sign in as carol_d (password TestPass123!). Using the 'Deck Construction' and 'Deck, Bag, and Pool Building' mechanism pages, find the highest-ranked game designed only for 2 players, then add it to her wishlist with priority 'Must have'.","web":"http://localhost:40021/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_6.py","judge_rubric":"FACT CHECKPOINTS: MUST sign in as carol_d and inspect both named mechanism pages. MUST identify the highest-ranked game whose minimum and maximum player counts are both 2, then save it to her wishlist with priority Must have. A claim without the saved wishlist state FAILS."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--7","ques":"On the Hot page, find the latest publication year in the current Hotness list, then report the highest-ranked game from that year.","web":"http://localhost:40021/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_7.py","judge_rubric":"FACT CHECKPOINTS: MUST open the Hot page, determine the latest year present, and compare overall ranks among entries from that year. MUST report the highest-ranked qualifying game. Empty or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--8","ques":"Open the GeekList named 'Best Cooperative Games'. Who is the author and how many items does it have?","web":"http://localhost:40021/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_8.py","judge_rubric":"FACT CHECKPOINTS: MUST open the Best Cooperative Games GeekList. MUST report the displayed author and item count. Empty or incomplete answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--9","ques":"Sign in as david_k (password TestPass123!) and create a new GeekList titled 'My COIN Series Picks' with the description 'Light, deep, and historical.'","web":"http://localhost:40021/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_9.py","judge_rubric":"FACT CHECKPOINTS: MUST sign in as david_k and create a GeekList with the exact requested title and description. A claim without the newly saved list under david_k FAILS."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--10","ques":"Compare Brass: Birmingham and Ark Nova on their game pages — which one has the heavier weight (higher complexity)?","web":"http://localhost:40021/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_10.py","judge_rubric":"FACT CHECKPOINTS: MUST open both game pages and compare their displayed weight values. MUST name the game with the higher weight. Empty or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--11","ques":"Open the publisher page for 'Z-Man Games' (use the Publishers index — you may have to filter by name). Report how many games are listed under that publisher in our catalog.","web":"http://localhost:40021/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_11.py","judge_rubric":"FACT CHECKPOINTS: MUST use the Publishers index and open the Z-Man Games publisher page. MUST report the catalog count shown for that publisher. Empty or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--12","ques":"Browse to the 'Action Points' mechanism page. Among games using Action Points, what is the highest-ranked one and what is its overall rank?","web":"http://localhost:40021/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_12.py","judge_rubric":"FACT CHECKPOINTS: MUST open the Action Points mechanism page and compare the listed games' overall ranks. MUST report the highest-ranked game's name and rank. Empty or incomplete answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--13","ques":"Sign in as alice_j (password TestPass123!). Open her collection sorted by 'Most recently updated' and remove the most-recently-updated entry from her collection.","web":"http://localhost:40021/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_13.py","judge_rubric":"FACT CHECKPOINTS: MUST sign in as alice_j, sort her collection by Most recently updated, and remove the first entry from that ordering. A claim without the corresponding collection deletion FAILS."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--14","ques":"Open Wingspan and navigate to its Ratings & Reviews tab. Sort by 'Most Helpful' (thumbs) and report the username and thumb count of the top review. (The Overview tab does not list individual reviews — you must open the Ratings tab.)","web":"http://localhost:40021/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_14.py","judge_rubric":"FACT CHECKPOINTS: MUST open Wingspan's Ratings & Reviews tab and sort reviews by Most Helpful. MUST report the first review's username and thumb count. Empty or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--15","ques":"In the Recommendations forum, find an open thread (not pinned, not locked) about 2-player games, open it, and post a reply suggesting '7 Wonders Duel' as an alternative. (Sign in as bob_c first, password TestPass123!.)","web":"http://localhost:40021/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_15.py","judge_rubric":"FACT CHECKPOINTS: MUST sign in as bob_c, open the Recommendations forum, select a 2-player thread that is neither pinned nor locked, and post a reply containing 7 Wonders Duel. A claim without the saved post in a qualifying thread FAILS."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--16","ques":"Open the 'Top 50 Heaviest Games of the Last Decade' GeekList. What is the #1 entry on that list?","web":"http://localhost:40021/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_16.py","judge_rubric":"FACT CHECKPOINTS: MUST open the named GeekList and inspect its ordered entries. MUST report the game in position #1. Empty or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--17","ques":"Sign in as david_k (password TestPass123!). Open his Plays page and report how many distinct games he has logged plays for.","web":"http://localhost:40021/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_17.py","judge_rubric":"FACT CHECKPOINTS: MUST sign in as david_k and open his Plays page. MUST count distinct game titles rather than play rows and report that count. Empty or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--18","ques":"Find Vital Lacerda's designer page (the Designers index supports filtering by name). Among his games in the catalog, which one has the highest average rating?","web":"http://localhost:40021/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_18.py","judge_rubric":"FACT CHECKPOINTS: MUST use the Designers index and open Vital Lacerda's page. MUST compare the displayed average ratings and report the highest-rated listed game. Empty or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--19","ques":"Search for users by the keyword 'mike'. Open the first matching user profile (alphabetical) and report how many GeekLists they have authored.","web":"http://localhost:40021/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_19.py","judge_rubric":"FACT CHECKPOINTS: MUST search users for mike, use alphabetical result order, and open the first matching profile. MUST report that profile's authored-GeekList count. Empty or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--20","ques":"Open Twilight Struggle's Expansions page. How many expansions are listed there?","web":"http://localhost:40021/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_20.py","judge_rubric":"FACT CHECKPOINTS: MUST open Twilight Struggle's Expansions page and count the listed expansions. MUST report that count. Empty or unsupported answers FAIL."} diff --git a/sites/boardgamegeek/verify/test_environment_quality.py b/sites/boardgamegeek/verify/test_environment_quality.py index 7fc0c18e..72c9c76d 100644 --- a/sites/boardgamegeek/verify/test_environment_quality.py +++ b/sites/boardgamegeek/verify/test_environment_quality.py @@ -40,7 +40,7 @@ def test_task_ids_and_urls_match_registered_port(self) -> None: for number, row in enumerate(rows): with self.subTest(task=number): self.assertEqual(f"BoardGameGeek--{number}", row["id"]) - self.assertEqual("http://localhost:40020/", row["web"]) + self.assertEqual("http://localhost:40021/", row["web"]) expected = f"sites/boardgamegeek/verify/verify_{number}.py" self.assertEqual(expected, row["verifier_path"]) self.assertTrue((SITE_DIR.parents[1] / expected).is_file()) diff --git a/sites/boardgamegeek/verify/test_verifiers.py b/sites/boardgamegeek/verify/test_verifiers.py index 2da30486..93a9510c 100644 --- a/sites/boardgamegeek/verify/test_verifiers.py +++ b/sites/boardgamegeek/verify/test_verifiers.py @@ -120,7 +120,7 @@ def reply(connection: sqlite3.Connection) -> None: def fixture(task: int, *, alternate: bool = False) -> tuple[list[dict], str, object | None]: - base = "http://127.0.0.1:40020" if alternate else "http://localhost:40020" + base = "http://127.0.0.1:40021" if alternate else "http://localhost:40021" def url(path: str) -> str: return base + path @@ -266,9 +266,9 @@ def run_verifier( finally: connection.close() origin = start_url or ( - "http://127.0.0.1:40020/" + "http://127.0.0.1:40021/" if steps and "127.0.0.1" in steps[0]["url"] - else "http://localhost:40020/" + else "http://localhost:40021/" ) trajectory = { "task_id": f"BoardGameGeek--{task}", diff --git a/sites/osu/.build-generated-seed b/sites/osu/.build-generated-seed new file mode 100644 index 00000000..469d2bdb --- /dev/null +++ b/sites/osu/.build-generated-seed @@ -0,0 +1 @@ +The Dockerfile generates instance_seed/osu.db deterministically from tracked source data. diff --git a/sites/osu/.requires-images b/sites/osu/.requires-images new file mode 100644 index 00000000..7c9b0bb4 --- /dev/null +++ b/sites/osu/.requires-images @@ -0,0 +1 @@ +This site requires the static/images directory supplied by the pinned Hugging Face asset bundle. diff --git a/sites/osu/_health.py b/sites/osu/_health.py new file mode 100644 index 00000000..0d868f36 --- /dev/null +++ b/sites/osu/_health.py @@ -0,0 +1,17 @@ +"""Health check module for OSU mirror site.""" + + +def health_check(app, db, College, Program): + """Return health status dict.""" + try: + with app.app_context(): + college_count = College.query.count() + program_count = Program.query.count() + return { + 'ok': True, + 'site': 'osu', + 'colleges': college_count, + 'programs': program_count, + } + except Exception as e: + return {'ok': False, 'error': str(e)} diff --git a/sites/osu/app.py b/sites/osu/app.py new file mode 100644 index 00000000..d9b13f94 --- /dev/null +++ b/sites/osu/app.py @@ -0,0 +1,922 @@ +#!/usr/bin/env python3 +"""Ohio State University mirror — Flask application.""" +import json +import os +import re +import secrets +from datetime import datetime, timezone +from math import ceil + +from flask import (Flask, render_template, request, redirect, url_for, + flash, jsonify, session, abort) +from flask_sqlalchemy import SQLAlchemy +from flask_login import (LoginManager, UserMixin, login_user, logout_user, + login_required, current_user) +from flask_wtf import FlaskForm +from flask_wtf.csrf import CSRFProtect +from flask_bcrypt import Bcrypt +from sqlalchemy import event as sqlalchemy_event +from sqlalchemy.engine import Engine +from sqlalchemy.exc import IntegrityError +from wtforms import StringField, PasswordField +from wtforms.validators import DataRequired, Email, Length, EqualTo + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +SITE_PORT = 40020 +BENCHMARK_NOW = datetime(2024, 10, 15, 12, 0, 0) +with open(os.path.join(BASE_DIR, 'image_sources.json'), encoding='utf-8') as image_manifest_file: + IMAGE_ASSETS = {item['file'].removesuffix('.webp'): item for item in json.load(image_manifest_file)['images']} + +app = Flask(__name__) +app.config['SECRET_KEY'] = os.environ.get('OSU_SECRET_KEY') or secrets.token_hex(32) +app.config['SQLALCHEMY_DATABASE_URI'] = ( + f"sqlite:///{os.path.join(BASE_DIR, 'instance', 'osu.db')}") +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +app.config['WTF_CSRF_TIME_LIMIT'] = None +app.config['MAX_CONTENT_LENGTH'] = 64 * 1024 +app.config['SESSION_COOKIE_HTTPONLY'] = True +app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' + +os.makedirs(os.path.join(BASE_DIR, 'instance'), exist_ok=True) + +db = SQLAlchemy(app) +bcrypt = Bcrypt(app) + + +@sqlalchemy_event.listens_for(Engine, 'connect') +def enable_sqlite_foreign_keys(connection, _record): + cursor = connection.cursor() + cursor.execute('PRAGMA foreign_keys=ON') + cursor.close() + + +login_manager = LoginManager(app) +login_manager.login_view = 'login' +login_manager.login_message = 'Please sign in to continue.' +login_manager.login_message_category = 'info' +csrf = CSRFProtect(app) + +PER_PAGE = 20 + +# ─── Helpers ────────────────────────────────────────────────────────────────── + +def utcnow(): + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def slugify(text): + if not text: + return '' + s = re.sub(r'[^a-zA-Z0-9\s-]', '', text) + s = re.sub(r'[\s]+', '-', s.strip().lower()) + return s + + +def safe_next(target, fallback): + if not target or '\\' in target or not target.startswith('/') or target.startswith('//'): + return fallback + return target + + +def search_tokens(value): + return [token for token in re.split(r'[^a-z0-9]+', (value or '').casefold()) if len(token) > 1] + + +def ranked_search(rows, fields, query, limit=10): + tokens = search_tokens(query) + ranked = [] + for row in rows: + text = ' '.join(str(getattr(row, field, '') or '') for field in fields).casefold() + score = sum(1 for token in tokens if token in text) + if score: + ranked.append((score, row.id, row)) + return [row for _score, _row_id, row in sorted(ranked, key=lambda item: (-item[0], item[1]))[:limit]] + + +def image_asset(key): + return IMAGE_ASSETS[key] + + +def news_image(article): + if article.slug == 'ohio-state-researchers-develop-breakthrough-cancer-immunotherapy': + return image_asset('cancer-immunotherapy') + if article.slug == 'ohio-state-sets-record-for-research-expenditures-at-13-billion': + return image_asset('research-hero') + category_images = { + 'Athletics': 'athletics-football', + 'Health': 'about-health-care', + 'Research': 'research-microelectronics', + 'Student': 'academics-graduate', + 'Faculty': 'about-education', + 'Campus Life': 'campus-life', + } + return image_asset(category_images.get(article.category, 'news-campus')) + + +def research_image(center): + center_images = { + 'translational-data-analytics-institute': 'research-hero', + 'james-cancer-hospital-and-solove-research-institute': 'james-cancer-hospital', + 'center-for-clean-hydrogen': 'research-mobility', + 'ohio-supercomputer-center': 'research-microelectronics', + } + return image_asset(center_images.get(center.slug, 'research-hero')) + + +def college_image(college): + college_images = { + 'arts-and-sciences': 'academics-undergraduate', + 'fisher-college-of-business': 'fisher-students', + 'education-and-human-ecology': 'about-education', + 'engineering': 'research-microelectronics', + 'food-agricultural-and-environmental-sciences': 'research-mobility', + 'moritz-college-of-law': 'campus-life', + 'medicine': 'about-health-care', + 'nursing': 'about-health-care', + 'optometry': 'academics-online', + 'pharmacy': 'research-hero', + 'public-health': 'about-health-care', + 'social-work': 'campus-life', + 'veterinary-medicine': 'research-hero', + 'john-glenn-college-of-public-affairs': 'home-hero', + 'dentistry': 'about-health-care', + 'graduate-school': 'academics-graduate', + } + return image_asset(college_images.get(college.slug, 'academics-undergraduate')) + + +def athletics_image(team): + team_images = { + 'ohio-state-buckeyes-football': 'athletics-football', + 'ohio-state-buckeyes-mens-basketball': 'athletics-basketball', + 'ohio-state-buckeyes-wrestling': 'athletics-wrestling', + 'ohio-state-buckeyes-fencing': 'athletics-fencing', + } + key = team_images.get(team.slug) + return image_asset(key) if key else None + +# ─── Models ─────────────────────────────────────────────────────────────────── + +class User(db.Model, UserMixin): + __tablename__ = 'users' + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(120), unique=True, nullable=False, index=True) + username = db.Column(db.String(80), unique=True, nullable=False, index=True) + password_hash = db.Column(db.String(255), nullable=False) + full_name = db.Column(db.String(150), nullable=False, default='') + role = db.Column(db.String(30), default='student') + bio = db.Column(db.Text, default='') + created_at = db.Column(db.DateTime, default=utcnow) + + bookmarks = db.relationship('Bookmark', backref='user', lazy=True, + cascade='all, delete-orphan') + + def set_password(self, pw): + self.password_hash = bcrypt.generate_password_hash(pw).decode('utf-8') + + def check_password(self, pw): + return bcrypt.check_password_hash(self.password_hash, pw) + + +class College(db.Model): + __tablename__ = 'colleges' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(200), nullable=False) + slug = db.Column(db.String(200), unique=True, nullable=False, index=True) + description = db.Column(db.Text, default='') + dean = db.Column(db.String(150), default='') + founded_year = db.Column(db.Integer, default=1870) + undergrad_count = db.Column(db.Integer, default=1000) + grad_count = db.Column(db.Integer, default=500) + campus = db.Column(db.String(100), default='Columbus') + + departments = db.relationship('Department', backref='college', lazy=True) + programs = db.relationship('Program', backref='college', lazy=True) + research_centers = db.relationship('ResearchCenter', backref='college', lazy=True) + + +class Department(db.Model): + __tablename__ = 'departments' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(200), nullable=False) + slug = db.Column(db.String(200), unique=True, nullable=False, index=True) + college_id = db.Column(db.Integer, db.ForeignKey('colleges.id'), nullable=False) + description = db.Column(db.Text, default='') + chair = db.Column(db.String(150), default='') + phone = db.Column(db.String(30), default='') + location = db.Column(db.String(200), default='') + + faculty = db.relationship('Faculty', backref='department', lazy=True) + programs = db.relationship('Program', backref='department', lazy=True) + + +class Program(db.Model): + __tablename__ = 'programs' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(300), nullable=False) + slug = db.Column(db.String(300), unique=True, nullable=False, index=True) + degree_type = db.Column(db.String(20), default='BA') + college_id = db.Column(db.Integer, db.ForeignKey('colleges.id'), nullable=True) + department_id = db.Column(db.Integer, db.ForeignKey('departments.id'), nullable=True) + description = db.Column(db.Text, default='') + requirements = db.Column(db.Text, default='') + units = db.Column(db.Integer, default=120) + duration_years = db.Column(db.Float, default=4.0) + application_deadline = db.Column(db.String(80), default='') + is_online = db.Column(db.Boolean, default=False) + gre_required = db.Column(db.Boolean, default=False) + + +class NewsArticle(db.Model): + __tablename__ = 'news_articles' + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(300), nullable=False) + slug = db.Column(db.String(300), unique=True, nullable=False, index=True) + category = db.Column(db.String(50), default='Campus Life') + author = db.Column(db.String(150), default='OSU News Staff') + published_date = db.Column(db.DateTime, default=utcnow) + content = db.Column(db.Text, default='') + summary = db.Column(db.Text, default='') + tags = db.Column(db.String(500), default='') + view_count = db.Column(db.Integer, default=0) + featured = db.Column(db.Boolean, default=False) + + +class Event(db.Model): + __tablename__ = 'events' + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(300), nullable=False) + description = db.Column(db.Text, default='') + start_datetime = db.Column(db.DateTime, nullable=False) + end_datetime = db.Column(db.DateTime, nullable=True) + location = db.Column(db.String(300), default='') + building = db.Column(db.String(200), default='') + campus = db.Column(db.String(100), default='Columbus') + category = db.Column(db.String(50), default='Lecture') + organizer = db.Column(db.String(200), default='') + registration_required = db.Column(db.Boolean, default=False) + cost = db.Column(db.String(50), default='Free') + + +class ResearchCenter(db.Model): + __tablename__ = 'research_centers' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(300), nullable=False) + slug = db.Column(db.String(300), unique=True, nullable=False, index=True) + description = db.Column(db.Text, default='') + director = db.Column(db.String(150), default='') + college_id = db.Column(db.Integer, db.ForeignKey('colleges.id'), nullable=True) + focus_areas = db.Column(db.String(500), default='') + url = db.Column(db.String(300), default='') + founded_year = db.Column(db.Integer, default=2000) + + +class Faculty(db.Model): + __tablename__ = 'faculty' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(150), nullable=False) + slug = db.Column(db.String(200), unique=True, nullable=False, index=True) + title = db.Column(db.String(200), default='Professor') + department_id = db.Column(db.Integer, db.ForeignKey('departments.id'), nullable=True) + email = db.Column(db.String(120), default='') + office = db.Column(db.String(200), default='') + phone = db.Column(db.String(30), default='') + research_interests = db.Column(db.String(500), default='') + bio = db.Column(db.Text, default='') + is_emeritus = db.Column(db.Boolean, default=False) + + +class AthleticTeam(db.Model): + __tablename__ = 'athletic_teams' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(200), nullable=False) + slug = db.Column(db.String(200), unique=True, nullable=False, index=True) + sport = db.Column(db.String(100), nullable=False) + gender = db.Column(db.String(20), default='Men') + conference = db.Column(db.String(100), default='Big Ten') + coach = db.Column(db.String(150), default='') + home_venue = db.Column(db.String(200), default='') + national_titles = db.Column(db.Integer, default=0) + recent_record = db.Column(db.String(50), default='') + + +class Bookmark(db.Model): + __tablename__ = 'bookmarks' + __table_args__ = (db.UniqueConstraint('user_id', 'item_type', 'item_id', name='uq_bookmark_user_item'),) + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + item_type = db.Column(db.String(50), nullable=False) + item_id = db.Column(db.Integer, nullable=False) + note = db.Column(db.Text, default='') + created_at = db.Column(db.DateTime, default=utcnow) + + +BOOKMARK_TARGETS = { + 'program': Program, + 'news': NewsArticle, + 'event': Event, + 'faculty': Faculty, + 'research': ResearchCenter, + 'athletics': AthleticTeam, +} + + +# ─── Forms ──────────────────────────────────────────────────────────────────── + +class LoginForm(FlaskForm): + email = StringField('Email', validators=[DataRequired(), Email(), Length(max=120)]) + password = PasswordField('Password', validators=[DataRequired(), Length(max=100)]) + +class RegisterForm(FlaskForm): + username = StringField('Username', validators=[DataRequired(), Length(3, 80)]) + full_name = StringField('Full Name', validators=[DataRequired(), Length(2, 150)]) + email = StringField('Email', validators=[DataRequired(), Email(), Length(max=120)]) + password = PasswordField('Password', validators=[DataRequired(), Length(8, 100)]) + confirm = PasswordField('Confirm Password', validators=[DataRequired(), EqualTo('password')]) + +# ─── Login Manager ──────────────────────────────────────────────────────────── + +@login_manager.user_loader +def load_user(user_id): + try: + return db.session.get(User, int(user_id)) + except (TypeError, ValueError): + return None + +# ─── Context Processors ─────────────────────────────────────────────────────── + +@app.context_processor +def inject_globals(): + return { + 'now': BENCHMARK_NOW, + 'colleges': College.query.order_by(College.name).all(), + 'image_asset': image_asset, + 'news_image': news_image, + 'research_image': research_image, + 'college_image': college_image, + 'athletics_image': athletics_image, + } + +# ─── Routes ─────────────────────────────────────────────────────────────────── + +@app.route('/') +def index(): + featured_news = NewsArticle.query.filter_by(featured=True).order_by( + NewsArticle.published_date.desc()).limit(6).all() + if len(featured_news) < 3: + featured_news = NewsArticle.query.order_by( + NewsArticle.published_date.desc()).limit(6).all() + upcoming_events = Event.query.filter( + Event.start_datetime >= BENCHMARK_NOW + ).order_by(Event.start_datetime).limit(4).all() + recent_research = ResearchCenter.query.limit(4).all() + stats = { + 'fulbright_rank': 1, + 'undergrad_majors': 200, + 'grad_programs': 278, + 'varsity_sports': 36, + 'faculty_count': 7000, + 'undergrad_count': 46820, + 'grad_count': 14000, + 'degree_programs': 500, + 'buckeython_raised': 13, + 'extension_offices': 88, + 'campuses': 6, + 'research_expenditure': 1.3, + } + return render_template('index.html', + featured_news=featured_news, + upcoming_events=upcoming_events, + recent_research=recent_research, + stats=stats) + + +@app.route('/news') +def news(): + q = request.args.get('q', '').strip() + category = request.args.get('category', '') + featured = request.args.get('featured', '') + page = max(1, request.args.get('page', 1, type=int) or 1) + + query = NewsArticle.query + if q: + query = query.filter( + db.or_( + NewsArticle.title.ilike(f'%{q}%'), + NewsArticle.summary.ilike(f'%{q}%'), + NewsArticle.content.ilike(f'%{q}%'), + NewsArticle.tags.ilike(f'%{q}%'), + )) + if category: + query = query.filter(NewsArticle.category == category) + if featured == '1': + query = query.filter(NewsArticle.featured == True) + + query = query.order_by(NewsArticle.published_date.desc()) + total = query.count() + articles = query.offset((page - 1) * PER_PAGE).limit(PER_PAGE).all() + total_pages = ceil(total / PER_PAGE) if total else 1 + + categories = ['Research', 'Campus Life', 'Faculty', 'Student', 'Athletics', + 'Science', 'Health'] + return render_template('news.html', + articles=articles, + total=total, + page=page, + total_pages=total_pages, + categories=categories, + current_category=category, + q=q, + featured=featured) + + +@app.route('/news/') +def news_article(slug): + article = NewsArticle.query.filter_by(slug=slug).first_or_404() + related = NewsArticle.query.filter( + NewsArticle.category == article.category, + NewsArticle.id != article.id + ).order_by(NewsArticle.published_date.desc()).limit(3).all() + return render_template('news_article.html', article=article, related=related) + + +@app.route('/academics') +def academics(): + colleges = College.query.order_by(College.name).all() + total_programs = Program.query.count() + total_depts = Department.query.count() + return render_template('academics.html', + colleges=colleges, + total_programs=total_programs, + total_depts=total_depts) + + +@app.route('/programs') +def programs(): + q = request.args.get('q', '').strip() + college_slug = request.args.get('college', '') + degree = request.args.get('degree', '') + online = request.args.get('online', '') + page = max(1, request.args.get('page', 1, type=int) or 1) + + query = Program.query + if q: + query = query.filter( + db.or_( + Program.name.ilike(f'%{q}%'), + Program.description.ilike(f'%{q}%'), + )) + if college_slug: + col = College.query.filter_by(slug=college_slug).first() + if col: + query = query.filter(Program.college_id == col.id) + if degree: + query = query.filter(Program.degree_type == degree) + if online == '1': + query = query.filter(Program.is_online == True) + + query = query.order_by(Program.name) + total = query.count() + progs = query.offset((page - 1) * PER_PAGE).limit(PER_PAGE).all() + total_pages = ceil(total / PER_PAGE) if total else 1 + + all_colleges = College.query.order_by(College.name).all() + degree_types = ['BA', 'BS', 'MA', 'MS', 'PhD', 'MPH', 'MBA', 'JD', 'MD', + 'PharmD', 'DVM', 'OD'] + return render_template('programs.html', + programs=progs, + total=total, + page=page, + total_pages=total_pages, + all_colleges=all_colleges, + degree_types=degree_types, + current_college=college_slug, + current_degree=degree, + online=online, + q=q) + + +@app.route('/programs/') +def program_detail(slug): + program = Program.query.filter_by(slug=slug).first_or_404() + related = Program.query.filter( + Program.college_id == program.college_id, + Program.id != program.id + ).limit(4).all() + return render_template('program_detail.html', program=program, related=related) + + +@app.route('/events') +def events(): + q = request.args.get('q', '').strip() + category = request.args.get('category', '') + campus = request.args.get('campus', '') + date_filter = request.args.get('date', 'upcoming') + page = max(1, request.args.get('page', 1, type=int) or 1) + now = BENCHMARK_NOW + + query = Event.query + if q: + query = query.filter( + db.or_( + Event.title.ilike(f'%{q}%'), + Event.description.ilike(f'%{q}%'), + Event.location.ilike(f'%{q}%'), + Event.organizer.ilike(f'%{q}%'), + )) + if category: + query = query.filter(Event.category == category) + if campus: + query = query.filter(Event.campus == campus) + if date_filter == 'upcoming': + query = query.filter(Event.start_datetime >= now) + query = query.order_by(Event.start_datetime) + elif date_filter == 'past': + query = query.filter(Event.start_datetime < now) + query = query.order_by(Event.start_datetime.desc()) + elif date_filter == 'today': + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + today_end = now.replace(hour=23, minute=59, second=59) + query = query.filter(Event.start_datetime.between(today_start, today_end)) + query = query.order_by(Event.start_datetime) + else: + query = query.order_by(Event.start_datetime) + + total = query.count() + evts = query.offset((page - 1) * PER_PAGE).limit(PER_PAGE).all() + total_pages = ceil(total / PER_PAGE) if total else 1 + + categories = ['Lecture', 'Sports', 'Arts', 'Career', 'Health', 'Social', 'Virtual'] + campuses = [row[0] for row in db.session.query(Event.campus).distinct().order_by(Event.campus).all()] + return render_template('events.html', + events=evts, + total=total, + page=page, + total_pages=total_pages, + categories=categories, + campuses=campuses, + current_category=category, + current_campus=campus, + date_filter=date_filter, + q=q) + + +@app.route('/events/') +def event_detail(event_id): + event = db.session.get(Event, event_id) + if event is None: + abort(404) + related = Event.query.filter( + Event.category == event.category, + Event.id != event.id, + Event.start_datetime >= BENCHMARK_NOW + ).order_by(Event.start_datetime).limit(3).all() + return render_template('event_detail.html', event=event, related=related) + + +@app.route('/research') +def research(): + centers = ResearchCenter.query.order_by(ResearchCenter.name).all() + colleges = College.query.order_by(College.name).all() + return render_template('research.html', centers=centers, colleges=colleges) + + +@app.route('/research/') +def research_center(slug): + center = ResearchCenter.query.filter_by(slug=slug).first_or_404() + related = ResearchCenter.query.filter( + ResearchCenter.college_id == center.college_id, + ResearchCenter.id != center.id + ).limit(3).all() + return render_template('research_center.html', center=center, related=related) + + +@app.route('/departments') +def departments(): + all_colleges = College.query.order_by(College.name).all() + depts_by_college = {} + for college in all_colleges: + depts_by_college[college] = Department.query.filter_by( + college_id=college.id).order_by(Department.name).all() + return render_template('departments.html', depts_by_college=depts_by_college) + + +@app.route('/departments/') +def department_detail(slug): + dept = Department.query.filter_by(slug=slug).first_or_404() + faculty_list = Faculty.query.filter_by(department_id=dept.id).order_by(Faculty.name).all() + dept_programs = Program.query.filter_by(department_id=dept.id).all() + return render_template('department_detail.html', + dept=dept, + faculty_list=faculty_list, + programs=dept_programs) + + +@app.route('/admissions') +def admissions(): + undergrad_programs = Program.query.filter( + Program.degree_type.in_(['BA', 'BS']) + ).count() + grad_programs = Program.query.filter( + Program.degree_type.in_(['MA', 'MS', 'PhD', 'MPH', 'MBA', 'JD', 'MD', + 'PharmD', 'DVM', 'OD']) + ).count() + online_programs = Program.query.filter_by(is_online=True).count() + return render_template('admissions.html', + undergrad_programs=undergrad_programs, + grad_programs=grad_programs, + online_programs=online_programs) + + +@app.route('/about') +def about(): + stats = { + 'fulbright_rank': 1, + 'undergrad_majors': 200, + 'grad_programs': 278, + 'varsity_sports': 36, + 'faculty_count': 7000, + 'undergrad_count': 46820, + 'grad_count': 14000, + 'degree_programs': 500, + 'founded': 1870, + 'acres': 1665, + 'campuses': 6, + 'alumni': 600000, + 'extension_offices': 88, + 'buckeython_raised': 13, + 'research_expenditure': 1.3, + 'national_titles': 15, + } + return render_template('about.html', stats=stats) + + +@app.route('/search') +def search(): + q = request.args.get('q', '').strip() + results = {'programs': [], 'news': [], 'events': [], 'faculty': [], + 'research': [], 'athletics': []} + total = 0 + if q: + results['programs'] = ranked_search(Program.query.all(), ('name', 'description'), q) + results['news'] = ranked_search(NewsArticle.query.all(), ('title', 'summary', 'content', 'tags'), q) + results['events'] = ranked_search(Event.query.all(), ('title', 'description', 'location', 'organizer'), q) + results['faculty'] = ranked_search(Faculty.query.all(), ('name', 'research_interests', 'bio', 'title'), q) + results['research'] = ranked_search(ResearchCenter.query.all(), ('name', 'description', 'focus_areas', 'director'), q) + results['athletics'] = ranked_search(AthleticTeam.query.all(), ('name', 'sport', 'coach', 'home_venue'), q) + total = sum(len(values) for values in results.values()) + return render_template('search.html', q=q, results=results, total=total) + + +@app.route('/faculty') +def faculty(): + q = request.args.get('q', '').strip() + dept_slug = request.args.get('dept', '') + page = max(1, request.args.get('page', 1, type=int) or 1) + + query = Faculty.query + if q: + query = query.filter( + db.or_( + Faculty.name.ilike(f'%{q}%'), + Faculty.research_interests.ilike(f'%{q}%'), + Faculty.title.ilike(f'%{q}%'), + )) + if dept_slug: + dept = Department.query.filter_by(slug=dept_slug).first() + if dept: + query = query.filter(Faculty.department_id == dept.id) + + query = query.order_by(Faculty.name) + total = query.count() + faculty_list = query.offset((page - 1) * PER_PAGE).limit(PER_PAGE).all() + total_pages = ceil(total / PER_PAGE) if total else 1 + + all_depts = Department.query.order_by(Department.name).all() + return render_template('faculty.html', + faculty_list=faculty_list, + total=total, + page=page, + total_pages=total_pages, + all_depts=all_depts, + current_dept=dept_slug, + q=q) + + +@app.route('/faculty/') +def faculty_profile(slug): + member = Faculty.query.filter_by(slug=slug).first_or_404() + colleagues = [] + if member.department_id: + colleagues = Faculty.query.filter( + Faculty.department_id == member.department_id, + Faculty.id != member.id + ).limit(5).all() + return render_template('faculty_profile.html', member=member, colleagues=colleagues) + + +@app.route('/athletics') +def athletics(): + teams = AthleticTeam.query.order_by(AthleticTeam.sport, AthleticTeam.name).all() + men_teams = [t for t in teams if t.gender == 'Men'] + women_teams = [t for t in teams if t.gender == 'Women'] + coed_teams = [t for t in teams if t.gender == 'Co-ed'] + return render_template('athletics.html', + teams=teams, + men_teams=men_teams, + women_teams=women_teams, + coed_teams=coed_teams) + + +@app.route('/athletics/') +def athletics_team(slug): + team = AthleticTeam.query.filter_by(slug=slug).first_or_404() + related = AthleticTeam.query.filter( + AthleticTeam.gender == team.gender, + AthleticTeam.id != team.id + ).limit(4).all() + return render_template('athletics_team.html', team=team, related=related) + + +@app.route('/login', methods=['GET', 'POST']) +def login(): + if current_user.is_authenticated: + return redirect(url_for('index')) + form = LoginForm() + if form.validate_on_submit(): + user = User.query.filter_by(email=form.email.data.lower().strip()).first() + if user and user.check_password(form.password.data): + session.clear() + login_user(user) + next_page = safe_next(request.args.get('next'), url_for('index')) + flash('Welcome back, Buckeye!', 'success') + return redirect(next_page) + flash('Invalid email or password.', 'danger') + return render_template('login.html', form=form) + + +@app.route('/register', methods=['GET', 'POST']) +def register(): + if current_user.is_authenticated: + return redirect(url_for('index')) + form = RegisterForm() + if form.validate_on_submit(): + email = form.email.data.lower().strip() + username = form.username.data.lower().strip() + if User.query.filter((User.email == email) | (db.func.lower(User.username) == username)).first(): + flash('Unable to create an account with the supplied details.', 'danger') + else: + user = User( + email=email, + username=username, + full_name=form.full_name.data.strip(), + ) + user.set_password(form.password.data) + db.session.add(user) + try: + db.session.commit() + except IntegrityError: + db.session.rollback() + flash('Unable to create an account with the supplied details.', 'danger') + return render_template('register.html', form=form), 400 + session.clear() + login_user(user) + flash('Account created! Welcome to The Ohio State University.', 'success') + return redirect(url_for('index')) + return render_template('register.html', form=form) + + +@app.route('/logout', methods=['POST']) +@login_required +def logout(): + logout_user() + flash('You have been logged out.', 'info') + return redirect(url_for('index')) + + +@app.route('/account') +@login_required +def account(): + bookmarks = Bookmark.query.filter_by(user_id=current_user.id).order_by( + Bookmark.created_at.desc()).all() + bookmark_details = [] + for bm in bookmarks: + detail = {'bookmark': bm, 'item': None, 'title': '', 'url': '#'} + if bm.item_type == 'program': + item = db.session.get(Program, bm.item_id) + if item: + detail['item'] = item + detail['title'] = item.name + detail['url'] = url_for('program_detail', slug=item.slug) + elif bm.item_type == 'news': + item = db.session.get(NewsArticle, bm.item_id) + if item: + detail['item'] = item + detail['title'] = item.title + detail['url'] = url_for('news_article', slug=item.slug) + elif bm.item_type == 'event': + item = db.session.get(Event, bm.item_id) + if item: + detail['item'] = item + detail['title'] = item.title + detail['url'] = url_for('event_detail', event_id=item.id) + elif bm.item_type == 'faculty': + item = db.session.get(Faculty, bm.item_id) + if item: + detail['item'] = item + detail['title'] = item.name + detail['url'] = url_for('faculty_profile', slug=item.slug) + elif bm.item_type == 'research': + item = db.session.get(ResearchCenter, bm.item_id) + if item: + detail['item'] = item + detail['title'] = item.name + detail['url'] = url_for('research_center', slug=item.slug) + elif bm.item_type == 'athletics': + item = db.session.get(AthleticTeam, bm.item_id) + if item: + detail['item'] = item + detail['title'] = item.name + detail['url'] = url_for('athletics_team', slug=item.slug) + if detail['item'] is not None: + bookmark_details.append(detail) + return render_template('account.html', bookmark_details=bookmark_details) + + +@app.route('/bookmark/add', methods=['POST']) +@login_required +def bookmark_add(): + item_type = request.form.get('item_type', '').strip() + item_id = request.form.get('item_id', type=int) + model = BOOKMARK_TARGETS.get(item_type) + if model is None or not item_id: + abort(400) + if db.session.get(model, item_id) is None: + abort(404) + note = request.form.get('note', '').strip()[:500] + existing = Bookmark.query.filter_by( + user_id=current_user.id, item_type=item_type, item_id=item_id + ).first() + if not existing: + bookmark = Bookmark(user_id=current_user.id, item_type=item_type, + item_id=item_id, note=note) + db.session.add(bookmark) + try: + db.session.commit() + flash('Saved to bookmarks.', 'success') + except IntegrityError: + db.session.rollback() + flash('Already bookmarked.', 'info') + else: + flash('Already bookmarked.', 'info') + return redirect(safe_next(request.form.get('next'), url_for('account'))) + + +@app.route('/bookmark/remove', methods=['POST']) +@login_required +def bookmark_remove(): + bookmark_id = request.form.get('bookmark_id', type=int) + if bookmark_id: + bm = db.session.get(Bookmark, bookmark_id) + if bm and bm.user_id == current_user.id: + db.session.delete(bm) + db.session.commit() + flash('Bookmark removed.', 'info') + return redirect(safe_next(request.form.get('next'), url_for('account'))) + + +@app.route('/_health') +def health(): + try: + college_count = College.query.count() + program_count = Program.query.count() + return jsonify({ + 'ok': True, + 'site': 'osu', + 'colleges': college_count, + 'programs': program_count, + }) + except Exception as e: + return jsonify({'ok': False, 'message': str(e)}), 500 + + +@app.errorhandler(404) +def not_found(e): + return render_template('404.html'), 404 + + +@app.errorhandler(500) +def server_error(e): + return render_template('500.html'), 500 + + +# ─── Startup ────────────────────────────────────────────────────────────────── + +with app.app_context(): + db.create_all() + from seed_data import seed + seed() + +if __name__ == '__main__': + port = int(os.environ.get('PORT', SITE_PORT)) + app.run(host='0.0.0.0', port=port, debug=False) diff --git a/sites/osu/fetch_images.py b/sites/osu/fetch_images.py new file mode 100644 index 00000000..5aec2270 --- /dev/null +++ b/sites/osu/fetch_images.py @@ -0,0 +1,172 @@ +"""Fetch and normalize the OSU mirror's photographs from documented official source pages. + +Run with: uv run --with pillow python sites/osu/fetch_images.py +""" +from __future__ import annotations + +import hashlib +import io +import json +from pathlib import Path +from urllib.request import Request, urlopen + +from PIL import Image, ImageOps + +SITE_DIR = Path(__file__).resolve().parent +WEBP = SITE_DIR / "static" / "images" +WEBP.mkdir(parents=True, exist_ok=True) +SOURCES = [ + ( + "home-hero", + "A group of student entrepreneurs sit at a table and discuss their plans.", + "https://editing.intcomm.osu.edu/sites/default/files/2026-08/entrepreneurship_homepage1.jpg", + "https://www.osu.edu/", + ), + ( + "campus-life", + "A student playing guitar on the Oval during Ohio State’s involvement fair.", + "https://editing.intcomm.osu.edu/sites/default/files/inline-images/apply_26_675.jpg", + "https://www.osu.edu/", + ), + ( + "academics-undergraduate", + "A student wearing headphones paints on a canvas.", + "https://editing.intcomm.osu.edu/sites/default/files/styles/widescreen/public/media/image/2022/07/academics-undergraduate-majors.jpg", + "https://www.osu.edu/academics", + ), + ( + "academics-graduate", + "Students work together in an academic setting.", + "https://editing.intcomm.osu.edu/sites/default/files/styles/widescreen/public/media/image/2022/07/academics-graduate-degrees.jpg", + "https://www.osu.edu/academics", + ), + ( + "academics-online", + "A student wearing headphones studies on a laptop.", + "https://editing.intcomm.osu.edu/sites/default/files/styles/widescreen/public/media/image/2022/07/academics-ohio-state-online.jpg", + "https://www.osu.edu/academics", + ), + ( + "research-hero", + "Ohio State research in action.", + "https://editing.intcomm.osu.edu/sites/default/files/inline-images/research-research-in-action.jpeg", + "https://www.osu.edu/research", + ), + ( + "research-mobility", + "A researcher works underneath an automobile.", + "https://editing.intcomm.osu.edu/sites/default/files/inline-images/Mobility_RI.jpg", + "https://www.osu.edu/research", + ), + ( + "research-microelectronics", + "A blue-gloved hand holds a microelectronic chip.", + "https://editing.intcomm.osu.edu/sites/default/files/styles/widescreen/public/inline-images/microelectronics.jpg", + "https://www.osu.edu/research", + ), + ( + "about-education", + "A faculty member talks to students in front of a chalkboard.", + "https://editing.intcomm.osu.edu/sites/default/files/styles/widescreen/public/media/image/2022/07/about-education.jpg", + "https://www.osu.edu/about", + ), + ( + "about-health-care", + "A doctor smiles at a patient.", + "https://editing.intcomm.osu.edu/sites/default/files/styles/widescreen/public/media/image/2022/07/about-health-care.jpg", + "https://www.osu.edu/about", + ), + ( + "admissions-visit", + "Students visit the Ohio State campus.", + "https://undergrad.osu.edu/sites/UndergraduateAdmissions_22ac04/Images1/visit-campus-home.jpg", + "https://undergrad.osu.edu/", + ), + ( + "fisher-students", + "Students discuss a business case at Fisher College of Business.", + "https://s3.us-east-2.amazonaws.com/files.fisher.osu.edu/public/inline-images/Tab1_AcademicPrograms_0.jpg?VersionId=KnOdQG_NtxhHWMh2B1N8zOhDdc7wEYZx", + "https://fisher.osu.edu/", + ), + ( + "athletics-football", + "Ohio State football.", + "https://images.sidearmdev.com/crop?url=https%3A%2F%2Fdxbhsrqyrr690.cloudfront.net%2Fsidearm.nextgen.sites%2Fohiostatebuckeyes.com%2Fimages%2F2026%2F9%2F7%2F20250830_tdc_usa_065_large.jpg&width=1200&height=675&type=webp", + "https://ohiostatebuckeyes.com/", + ), + ( + "athletics-wrestling", + "Ohio State wrestler Nic Bouzakis competes at the Big Ten championships.", + "https://images.sidearmdev.com/crop?url=https%3A%2F%2Fdxbhsrqyrr690.cloudfront.net%2Fsidearm.nextgen.sites%2Fohiostatebuckeyes.com%2Fimages%2F2026%2F3%2F7%2FSZ9_5182_6dkzO.jpg&width=720&height=405&type=webp", + "https://ohiostatebuckeyes.com/sports/wrestling", + ), + ( + "athletics-basketball", + "Ohio State men’s basketball player John Mobley Jr. competes at the Big Ten tournament.", + "https://images.sidearmdev.com/crop?url=https%3A%2F%2Fdxbhsrqyrr690.cloudfront.net%2Fsidearm.nextgen.sites%2Fohiostatebuckeyes.com%2Fimages%2F2026%2F8%2F18%2FDJP04752_large.jpg&width=720&height=405&type=webp", + "https://ohiostatebuckeyes.com/sports/mens-basketball", + ), + ( + "athletics-fencing", + "Ohio State fencer Natalia Botello competes at the NCAA championships.", + "https://images.sidearmdev.com/crop?url=https%3A%2F%2Fdxbhsrqyrr690.cloudfront.net%2Fsidearm.nextgen.sites%2Fohiostatebuckeyes.com%2Fimages%2F2026%2F3%2F20%2F144A7346.JPG&width=720&height=405&type=webp", + "https://ohiostatebuckeyes.com/sports/fencing", + ), + ( + "james-cancer-hospital", + "The James Cancer Hospital and Solove Research Institute.", + "https://cancer.osu.edu/-/media/images/cancer/website/pages-and-carousels/about/locations/james-cancer-hospital.jpg", + "https://cancer.osu.edu/for-cancer-researchers", + ), + ( + "cancer-immunotherapy", + "A physician discusses immunotherapy.", + "https://cancer.osu.edu/-/media/images/cancer/website/pages-and-carousels/for-patients-and-caregivers/learn-about-cancers-and-treatments/specialized-treatment-clinics-and-centers/immunotherapy-management-clinic/dr-meara-discusses-immunotherapy.jpg", + "https://cancer.osu.edu/for-cancer-researchers", + ), + ( + "news-campus", + "The Ohio State University campus.", + "https://content.presspage.com/uploads/2170/800_ohiostatecampus-497663.jpg?10000", + "https://news.osu.edu/", + ), +] +manifest = [] +for name, alt, url, page in SOURCES: + req = Request( + url, + headers={"User-Agent": "Mozilla/5.0 (compatible; WebHarbor asset archival)"}, + ) + with urlopen(req, timeout=60) as r: + raw = r.read() + ctype = r.headers.get_content_type() + final = r.geturl() + if len(raw) < 5000: + raise RuntimeError((name, len(raw), ctype)) + with Image.open(io.BytesIO(raw)) as im: + im = ImageOps.exif_transpose(im).convert("RGB") + source_size = list(im.size) + im.thumbnail((1600, 1000), Image.Resampling.LANCZOS) + dest = WEBP / (name + ".webp") + im.save(dest, "WEBP", quality=84, method=6) + output_size = list(im.size) + manifest.append( + { + "file": name + ".webp", + "alt": alt, + "source_page": page, + "source_url": url, + "resolved_url": final, + "source_content_type": ctype, + "source_dimensions": source_size, + "output_dimensions": output_size, + "source_sha256": hashlib.sha256(raw).hexdigest(), + "output_sha256": hashlib.sha256(dest.read_bytes()).hexdigest(), + "output_bytes": dest.stat().st_size, + } + ) + print(name, ctype, source_size, "=>", output_size, dest.stat().st_size) +(SITE_DIR / "image_sources.json").write_text( + json.dumps({"images": manifest}, indent=2, ensure_ascii=False) + "\n" +) +print("TOTAL", sum(x["output_bytes"] for x in manifest)) diff --git a/sites/osu/image_sources.json b/sites/osu/image_sources.json new file mode 100644 index 00000000..83081069 --- /dev/null +++ b/sites/osu/image_sources.json @@ -0,0 +1,365 @@ +{ + "images": [ + { + "file": "home-hero.webp", + "alt": "A group of student entrepreneurs sit at a table and discuss their plans.", + "source_page": "https://www.osu.edu/", + "source_url": "https://editing.intcomm.osu.edu/sites/default/files/2026-08/entrepreneurship_homepage1.jpg", + "resolved_url": "https://editing.intcomm.osu.edu/sites/default/files/2026-08/entrepreneurship_homepage1.jpg", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1400, + 854 + ], + "output_dimensions": [ + 1400, + 854 + ], + "source_sha256": "b13eb65f21088f10e2ee98b4aed3c17f77a7415fcf29885b80ca74a3e90f84f3", + "output_sha256": "f9ceff1227c34d08ce766aea2dc50a149f15f86e7715dc2f569b1245039d31ff", + "output_bytes": 125284 + }, + { + "file": "campus-life.webp", + "alt": "A student playing guitar on the Oval during Ohio State’s involvement fair.", + "source_page": "https://www.osu.edu/", + "source_url": "https://editing.intcomm.osu.edu/sites/default/files/inline-images/apply_26_675.jpg", + "resolved_url": "https://editing.intcomm.osu.edu/sites/default/files/inline-images/apply_26_675.jpg", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1200, + 675 + ], + "output_dimensions": [ + 1200, + 675 + ], + "source_sha256": "a115748a38a982a21c28cbad1b521faa6a5df2422590fb8efbd9db9007ebbe60", + "output_sha256": "ea1d8bd9cb5ef053104e5c0f149f475e673764f206fa5ca954de17b55c969d2f", + "output_bytes": 161900 + }, + { + "file": "academics-undergraduate.webp", + "alt": "A student wearing headphones paints on a canvas.", + "source_page": "https://www.osu.edu/academics", + "source_url": "https://editing.intcomm.osu.edu/sites/default/files/styles/widescreen/public/media/image/2022/07/academics-undergraduate-majors.jpg", + "resolved_url": "https://editing.intcomm.osu.edu/sites/default/files/styles/widescreen/public/media/image/2022/07/academics-undergraduate-majors.jpg", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1076, + 605 + ], + "output_dimensions": [ + 1076, + 605 + ], + "source_sha256": "8b6fb5b40eb95bca2371db168547d370270e6c66609a1a3f4c2f23ce2a9a6b07", + "output_sha256": "abc4b950f8e268d1f1062de3a62d93f4328d83b06d18f85c8b19664c8c580e25", + "output_bytes": 97854 + }, + { + "file": "academics-graduate.webp", + "alt": "Students work together in an academic setting.", + "source_page": "https://www.osu.edu/academics", + "source_url": "https://editing.intcomm.osu.edu/sites/default/files/styles/widescreen/public/media/image/2022/07/academics-graduate-degrees.jpg", + "resolved_url": "https://editing.intcomm.osu.edu/sites/default/files/styles/widescreen/public/media/image/2022/07/academics-graduate-degrees.jpg", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1198, + 674 + ], + "output_dimensions": [ + 1198, + 674 + ], + "source_sha256": "68808e0c46bb7ec8f39ba2141fd3c58461e4b60c2a7f555cfc0118a53b5b3079", + "output_sha256": "5f00d76f9fb37675306522e162fe450ad5c1e90f58c6605e0e9f17fbe345f734", + "output_bytes": 81994 + }, + { + "file": "academics-online.webp", + "alt": "A student wearing headphones studies on a laptop.", + "source_page": "https://www.osu.edu/academics", + "source_url": "https://editing.intcomm.osu.edu/sites/default/files/styles/widescreen/public/media/image/2022/07/academics-ohio-state-online.jpg", + "resolved_url": "https://editing.intcomm.osu.edu/sites/default/files/styles/widescreen/public/media/image/2022/07/academics-ohio-state-online.jpg", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1198, + 674 + ], + "output_dimensions": [ + 1198, + 674 + ], + "source_sha256": "d9778ae7a4bad60c9baae858d00de96ae46f1209ddee80b3137e115e8dd2d23b", + "output_sha256": "f8d88b3ec04da9946ed03f7b394c7ac3fafbf3b2ed36db7d685bee13386725d7", + "output_bytes": 55402 + }, + { + "file": "research-hero.webp", + "alt": "Ohio State research in action.", + "source_page": "https://www.osu.edu/research", + "source_url": "https://editing.intcomm.osu.edu/sites/default/files/inline-images/research-research-in-action.jpeg", + "resolved_url": "https://editing.intcomm.osu.edu/sites/default/files/inline-images/research-research-in-action.jpeg", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1200, + 800 + ], + "output_dimensions": [ + 1200, + 800 + ], + "source_sha256": "087b3df1da8036e8f300446c83f1a46c13d62efba4e2183178f1002056698700", + "output_sha256": "0776af58b36dd31d0c5a43f70e79a0b8cb9ba6404e3e9b5ab381c6fdbac70ad2", + "output_bytes": 46190 + }, + { + "file": "research-mobility.webp", + "alt": "A researcher works underneath an automobile.", + "source_page": "https://www.osu.edu/research", + "source_url": "https://editing.intcomm.osu.edu/sites/default/files/inline-images/Mobility_RI.jpg", + "resolved_url": "https://editing.intcomm.osu.edu/sites/default/files/inline-images/Mobility_RI.jpg", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1200, + 675 + ], + "output_dimensions": [ + 1200, + 675 + ], + "source_sha256": "a0b1019a0ef964b5e9c01adee57780ae92d2e68f97db8ecd6e0c4655dfcff997", + "output_sha256": "adf5913b1b51621f4a1a673619a9941239ea066d32a455ab00ce10aa6062a950", + "output_bytes": 84668 + }, + { + "file": "research-microelectronics.webp", + "alt": "A blue-gloved hand holds a microelectronic chip.", + "source_page": "https://www.osu.edu/research", + "source_url": "https://editing.intcomm.osu.edu/sites/default/files/styles/widescreen/public/inline-images/microelectronics.jpg", + "resolved_url": "https://editing.intcomm.osu.edu/sites/default/files/styles/widescreen/public/inline-images/microelectronics.jpg", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1200, + 675 + ], + "output_dimensions": [ + 1200, + 675 + ], + "source_sha256": "db9b1ddd6ad622965556b8ac268089d4d8c481bc007697e79fbeb8428dbc41d5", + "output_sha256": "d6248981af1acef4dedf3e13a18b540e793cc77990ea6886c4932b3cbfb5cae1", + "output_bytes": 82400 + }, + { + "file": "about-education.webp", + "alt": "A faculty member talks to students in front of a chalkboard.", + "source_page": "https://www.osu.edu/about", + "source_url": "https://editing.intcomm.osu.edu/sites/default/files/styles/widescreen/public/media/image/2022/07/about-education.jpg", + "resolved_url": "https://editing.intcomm.osu.edu/sites/default/files/styles/widescreen/public/media/image/2022/07/about-education.jpg", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1200, + 675 + ], + "output_dimensions": [ + 1200, + 675 + ], + "source_sha256": "a677e3264891fd77681094eed1b24eba35c9ef22f5fa3345e76f6da45ce1b60b", + "output_sha256": "db22e975d16e33600ee371b4ce1298a01853cbd55b79069c04944fe521788a2c", + "output_bytes": 53442 + }, + { + "file": "about-health-care.webp", + "alt": "A doctor smiles at a patient.", + "source_page": "https://www.osu.edu/about", + "source_url": "https://editing.intcomm.osu.edu/sites/default/files/styles/widescreen/public/media/image/2022/07/about-health-care.jpg", + "resolved_url": "https://editing.intcomm.osu.edu/sites/default/files/styles/widescreen/public/media/image/2022/07/about-health-care.jpg", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1200, + 675 + ], + "output_dimensions": [ + 1200, + 675 + ], + "source_sha256": "0e851380d37ef36c87e33ec3facac2c64f97f2869d9f7a5df069c484cef062e3", + "output_sha256": "33915cd613e835bd5c1176807c9b2994c5b0e2cdff092a49b991a9be2343552e", + "output_bytes": 49000 + }, + { + "file": "admissions-visit.webp", + "alt": "Students visit the Ohio State campus.", + "source_page": "https://undergrad.osu.edu/", + "source_url": "https://undergrad.osu.edu/sites/UndergraduateAdmissions_22ac04/Images1/visit-campus-home.jpg", + "resolved_url": "https://undergrad.osu.edu/sites/UndergraduateAdmissions_22ac04/Images1/visit-campus-home.jpg", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 574, + 300 + ], + "output_dimensions": [ + 574, + 300 + ], + "source_sha256": "f10b2b38e90051ef962f96cab4db901f134271b3a9ef4dad141cf0fe8898c752", + "output_sha256": "aa6295ed5368156006e0b9b6e1c23796be0cabcc0c6528f9f31409a48b5848bb", + "output_bytes": 68174 + }, + { + "file": "fisher-students.webp", + "alt": "Students discuss a business case at Fisher College of Business.", + "source_page": "https://fisher.osu.edu/", + "source_url": "https://s3.us-east-2.amazonaws.com/files.fisher.osu.edu/public/inline-images/Tab1_AcademicPrograms_0.jpg?VersionId=KnOdQG_NtxhHWMh2B1N8zOhDdc7wEYZx", + "resolved_url": "https://s3.us-east-2.amazonaws.com/files.fisher.osu.edu/public/inline-images/Tab1_AcademicPrograms_0.jpg?VersionId=KnOdQG_NtxhHWMh2B1N8zOhDdc7wEYZx", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 345, + 230 + ], + "output_dimensions": [ + 345, + 230 + ], + "source_sha256": "9925b3aa97cd3b2666130986209144b890e74091d3b6f8a74de220f38f75f295", + "output_sha256": "b2198ffa231810c84c6b4b5a06553b6ce0e6706d68e0fbad84d43d6af671f299", + "output_bytes": 11362 + }, + { + "file": "athletics-football.webp", + "alt": "Ohio State football.", + "source_page": "https://ohiostatebuckeyes.com/", + "source_url": "https://images.sidearmdev.com/crop?url=https%3A%2F%2Fdxbhsrqyrr690.cloudfront.net%2Fsidearm.nextgen.sites%2Fohiostatebuckeyes.com%2Fimages%2F2026%2F9%2F7%2F20250830_tdc_usa_065_large.jpg&width=1200&height=675&type=webp", + "resolved_url": "https://images.sidearmdev.com/crop?url=https%3A%2F%2Fdxbhsrqyrr690.cloudfront.net%2Fsidearm.nextgen.sites%2Fohiostatebuckeyes.com%2Fimages%2F2026%2F9%2F7%2F20250830_tdc_usa_065_large.jpg&width=1200&height=675&type=webp", + "source_content_type": "image/webp", + "source_dimensions": [ + 1200, + 675 + ], + "output_dimensions": [ + 1200, + 675 + ], + "source_sha256": "e7a0d704aa83adcf3dce491c514adab060b623f741b1b1de507e629e74783392", + "output_sha256": "03f1bb4dec3ae10790a5c2b283e97e90083219ff0515ff628f4fbfbef583924f", + "output_bytes": 110532 + }, + { + "file": "athletics-wrestling.webp", + "alt": "Ohio State wrestler Nic Bouzakis competes at the Big Ten championships.", + "source_page": "https://ohiostatebuckeyes.com/sports/wrestling", + "source_url": "https://images.sidearmdev.com/crop?url=https%3A%2F%2Fdxbhsrqyrr690.cloudfront.net%2Fsidearm.nextgen.sites%2Fohiostatebuckeyes.com%2Fimages%2F2026%2F3%2F7%2FSZ9_5182_6dkzO.jpg&width=720&height=405&type=webp", + "resolved_url": "https://images.sidearmdev.com/crop?url=https%3A%2F%2Fdxbhsrqyrr690.cloudfront.net%2Fsidearm.nextgen.sites%2Fohiostatebuckeyes.com%2Fimages%2F2026%2F3%2F7%2FSZ9_5182_6dkzO.jpg&width=720&height=405&type=webp", + "source_content_type": "image/webp", + "source_dimensions": [ + 720, + 405 + ], + "output_dimensions": [ + 720, + 405 + ], + "source_sha256": "525360afe5d99ecbbf05e31cb417c74fe7537b6d65d9dd8cf91be246030ba7bb", + "output_sha256": "c15539639482319d9dda82bfb331d8ad4ec807ebb0468cff32396056b6f8af16", + "output_bytes": 32750 + }, + { + "file": "athletics-basketball.webp", + "alt": "Ohio State men’s basketball player John Mobley Jr. competes at the Big Ten tournament.", + "source_page": "https://ohiostatebuckeyes.com/sports/mens-basketball", + "source_url": "https://images.sidearmdev.com/crop?url=https%3A%2F%2Fdxbhsrqyrr690.cloudfront.net%2Fsidearm.nextgen.sites%2Fohiostatebuckeyes.com%2Fimages%2F2026%2F8%2F18%2FDJP04752_large.jpg&width=720&height=405&type=webp", + "resolved_url": "https://images.sidearmdev.com/crop?url=https%3A%2F%2Fdxbhsrqyrr690.cloudfront.net%2Fsidearm.nextgen.sites%2Fohiostatebuckeyes.com%2Fimages%2F2026%2F8%2F18%2FDJP04752_large.jpg&width=720&height=405&type=webp", + "source_content_type": "image/webp", + "source_dimensions": [ + 720, + 405 + ], + "output_dimensions": [ + 720, + 405 + ], + "source_sha256": "367102acc65eeec3031fc7b46e485ea77b00f17d7ac0568fc08c8750f1d303ac", + "output_sha256": "805b114fb66939b2e096099d297d37ec6d0e6d0b97c32e18dd34ab2c9de2923a", + "output_bytes": 30934 + }, + { + "file": "athletics-fencing.webp", + "alt": "Ohio State fencer Natalia Botello competes at the NCAA championships.", + "source_page": "https://ohiostatebuckeyes.com/sports/fencing", + "source_url": "https://images.sidearmdev.com/crop?url=https%3A%2F%2Fdxbhsrqyrr690.cloudfront.net%2Fsidearm.nextgen.sites%2Fohiostatebuckeyes.com%2Fimages%2F2026%2F3%2F20%2F144A7346.JPG&width=720&height=405&type=webp", + "resolved_url": "https://images.sidearmdev.com/crop?url=https%3A%2F%2Fdxbhsrqyrr690.cloudfront.net%2Fsidearm.nextgen.sites%2Fohiostatebuckeyes.com%2Fimages%2F2026%2F3%2F20%2F144A7346.JPG&width=720&height=405&type=webp", + "source_content_type": "image/webp", + "source_dimensions": [ + 720, + 405 + ], + "output_dimensions": [ + 720, + 405 + ], + "source_sha256": "802a4d094eb1d715e3a6da324d80082fbe6978828ae829941cd20102c0e185ae", + "output_sha256": "86fa71fe903cfd99b2951485587397bfb0b7c4eb90612c47d83ea01e4bb0dee1", + "output_bytes": 22710 + }, + { + "file": "james-cancer-hospital.webp", + "alt": "The James Cancer Hospital and Solove Research Institute.", + "source_page": "https://cancer.osu.edu/for-cancer-researchers", + "source_url": "https://cancer.osu.edu/-/media/images/cancer/website/pages-and-carousels/about/locations/james-cancer-hospital.jpg", + "resolved_url": "https://cancer.osu.edu/-/media/images/cancer/website/pages-and-carousels/about/locations/james-cancer-hospital.jpg", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 700, + 525 + ], + "output_dimensions": [ + 700, + 525 + ], + "source_sha256": "958527d3763310c17f923d3dca83cd529c454b79903750492b60fe401f49de00", + "output_sha256": "2995744c6bb58c0cec2f7c0089cc755c253203a49c0488b765990cb377709e71", + "output_bytes": 71746 + }, + { + "file": "cancer-immunotherapy.webp", + "alt": "A physician discusses immunotherapy.", + "source_page": "https://cancer.osu.edu/for-cancer-researchers", + "source_url": "https://cancer.osu.edu/-/media/images/cancer/website/pages-and-carousels/for-patients-and-caregivers/learn-about-cancers-and-treatments/specialized-treatment-clinics-and-centers/immunotherapy-management-clinic/dr-meara-discusses-immunotherapy.jpg", + "resolved_url": "https://cancer.osu.edu/-/media/images/cancer/website/pages-and-carousels/for-patients-and-caregivers/learn-about-cancers-and-treatments/specialized-treatment-clinics-and-centers/immunotherapy-management-clinic/dr-meara-discusses-immunotherapy.jpg", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1200, + 900 + ], + "output_dimensions": [ + 1200, + 900 + ], + "source_sha256": "a48518463057cbd6cf3fea15e7b35c9bd57f562ea35b002fde5369b1c3a6ba3c", + "output_sha256": "c1b1a2ae3237a78d9162ed61ff64b11351efc9c9a9f3cc0d943ea0fb26500c74", + "output_bytes": 50192 + }, + { + "file": "news-campus.webp", + "alt": "The Ohio State University campus.", + "source_page": "https://news.osu.edu/", + "source_url": "https://content.presspage.com/uploads/2170/800_ohiostatecampus-497663.jpg?10000", + "resolved_url": "https://content.presspage.com/uploads/2170/800_ohiostatecampus-497663.jpg?10000", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 751, + 500 + ], + "output_dimensions": [ + 751, + 500 + ], + "source_sha256": "0450530446eaa3d47bcad631b177f116fa4b0313dcad35761b947a69b81809ff", + "output_sha256": "a6c84e3d869ea111a5abef6af36fa2f8ad7c49d201226ac0ee54f2e964f795cb", + "output_bytes": 118998 + } + ] +} diff --git a/sites/osu/requirements.txt b/sites/osu/requirements.txt new file mode 100644 index 00000000..acde45d9 --- /dev/null +++ b/sites/osu/requirements.txt @@ -0,0 +1,7 @@ +Flask +Flask-SQLAlchemy +Flask-Login +Flask-WTF +Flask-Bcrypt +email-validator +Pillow diff --git a/sites/osu/seed_data.py b/sites/osu/seed_data.py new file mode 100644 index 00000000..e2f60bcc --- /dev/null +++ b/sites/osu/seed_data.py @@ -0,0 +1,1067 @@ +#!/usr/bin/env python3 +"""Seed all Ohio State University mirror data. Idempotent.""" +from datetime import datetime, timedelta +import sys + +SEED_TIMESTAMP = datetime(2024, 10, 15, 12, 0, 0) +# Stable bcrypt hash for the public benchmark password "test1234". +BENCHMARK_PASSWORD_HASH = "$2b$12$nOIQFCC3iiGkjx3qLZY7e.z69INZu4oCSJbsu/HJvi6/F2zYQkoN." + + +def _app_module(): + module = sys.modules.get('app') + if module is not None: + return module + main = sys.modules.get('__main__') + if main is not None and hasattr(main, 'db') and hasattr(main, 'College'): + return main + import app as module + return module + + +def seed(): + """Seed all data once and reject partially initialized databases.""" + module = _app_module() + db = module.db + College = module.College + Department = module.Department + Program = module.Program + NewsArticle = module.NewsArticle + Event = module.Event + ResearchCenter = module.ResearchCenter + Faculty = module.Faculty + AthleticTeam = module.AthleticTeam + User = module.User + slugify = module.slugify + + counts = [model.query.count() for model in (College, Department, Program, NewsArticle, Event, ResearchCenter, Faculty, AthleticTeam, User)] + if all(counts): + return + if any(counts): + raise RuntimeError(f'OSU database is partially seeded: {counts}') + + # ── Helper ──────────────────────────────────────────────────────────────── + def _slug(text, extra=''): + base = slugify(text) + if extra: + base = base + '-' + slugify(extra) + return base + + # ───────────────────────────────────────────────────────────────────────── + # COLLEGES + # ───────────────────────────────────────────────────────────────────────── + college_data = [ + ('Arts and Sciences', 'Dean Patricia Bauer', 1870, 12000, 4000, + 'The College of Arts and Sciences is the intellectual and academic heart of Ohio State. ' + 'It offers over 80 majors and 200 graduate programs spanning the humanities, social sciences, ' + 'natural sciences, and mathematics. The college is home to more than 1,400 faculty and serves ' + 'roughly 16,000 undergraduate and graduate students.'), + ('Fisher College of Business', 'Dean Anil Makhija', 1916, 4500, 1800, + 'Fisher College of Business at Ohio State is consistently ranked among the nation\'s top ' + 'business schools. Fisher offers undergraduate, MBA, specialized master\'s, and doctoral ' + 'programs that prepare students for leadership in a globally connected marketplace.'), + ('Education and Human Ecology', 'Dean Carey Andrzejewski', 1895, 1800, 1200, + 'The College of Education and Human Ecology prepares leaders in education, human development, ' + 'family science, and nutrition. EHE faculty conduct research that improves lives at the ' + 'individual, family, community, and policy levels.'), + ('Engineering', 'Dean Ayanna Howard', 1870, 8000, 4500, + 'The College of Engineering at Ohio State is one of the largest and most comprehensive ' + 'engineering colleges in the United States. With 26 departments and research centers, ' + 'it drives economic growth and technological innovation across Ohio and beyond.'), + ('Food, Agricultural, and Environmental Sciences', 'Dean Cathann Kress', 1870, 3200, 1200, + 'CFAES advances knowledge at the intersection of agriculture, food, environment, and human ' + 'health. The college operates the Ohio Agricultural Research and Development Center and ' + 'the OSU Extension system across all 88 Ohio counties.'), + ('Moritz College of Law', 'Dean Wendy Smooth', 1891, 0, 650, + 'Moritz College of Law is one of the nation\'s leading law schools, offering the JD degree ' + 'and several specialized graduate programs. The college is known for its commitment to public ' + 'service, hands-on clinical education, and cutting-edge legal scholarship.'), + ('Medicine', 'Dean K. Craig Kent', 1914, 0, 900, + 'The Ohio State University College of Medicine is one of the largest medical schools in ' + 'the United States. Affiliated with the Wexner Medical Center and the James Cancer Hospital, ' + 'it is a national leader in medical education, research, and patient care.'), + ('Nursing', 'Dean Bernadette Melnyk', 1914, 600, 400, + 'The College of Nursing advances nursing science and prepares professional nurses for ' + 'leadership roles in health care. It offers BSN, MS, DNP, and PhD programs and is known ' + 'for its focus on wellness, evidence-based practice, and mental health.'), + ('Optometry', 'Dean Karla Zadnik', 1914, 200, 250, + 'The College of Optometry is recognized as one of the finest optometric colleges in the ' + 'world. It offers a four-year OD degree program and conducts pioneering research in ' + 'myopia, glaucoma, and vision rehabilitation.'), + ('Pharmacy', 'Dean Henry Mann', 1885, 300, 350, + 'The College of Pharmacy prepares pharmacists and pharmaceutical scientists to improve ' + 'medication therapy outcomes. Its PharmD program integrates cutting-edge research with ' + 'experiential learning at leading clinical sites across Ohio.'), + ('Public Health', 'Dean Amy Ferketich', 2012, 0, 600, + 'The College of Public Health educates future leaders committed to improving the health ' + 'of communities locally, nationally, and globally. Degree programs in epidemiology, health ' + 'behavior, environmental health, and health services management prepare graduates for ' + 'impact in government, industry, and research.'), + ('Social Work', 'Dean Tom Gregoire', 1914, 300, 500, + 'The College of Social Work is dedicated to advancing social and economic justice and ' + 'improving quality of life for people across the lifespan. Programs emphasize field ' + 'practice, evidence-based interventions, and policy advocacy.'), + ('Veterinary Medicine', 'Dean Rustin Moore', 1885, 400, 500, + 'The College of Veterinary Medicine is consistently ranked among the top vet schools in ' + 'the nation. Its teaching hospital serves tens of thousands of animal patients each year, ' + 'and its researchers pioneer breakthroughs in both animal and human health.'), + ('John Glenn College of Public Affairs', 'Dean Trevor Brown', 1999, 100, 350, + 'The John Glenn College of Public Affairs trains the next generation of public servants, ' + 'policy analysts, and nonprofit leaders. Named for the legendary Ohio astronaut and U.S. ' + 'Senator, the college emphasizes ethics, analytical rigor, and civic engagement.'), + ('Dentistry', 'Dean Kristin Williams', 1890, 0, 400, + 'The College of Dentistry provides comprehensive oral health care and trains outstanding ' + 'dental professionals. Its clinic serves tens of thousands of patients annually, and its ' + 'researchers advance knowledge in oral biology, dental materials, and community oral health.'), + ('Graduate School', 'Dean Sean Carson', 1878, 0, 14000, + 'The Graduate School oversees graduate education across all disciplines at Ohio State. ' + 'It supports master\'s, doctoral, and professional degree programs and fosters the ' + 'interdisciplinary research enterprise of the university.'), + ] + + colleges = {} + for name, dean, founded, ug, gr, desc in college_data: + c = College( + name=name, + slug=slugify(name), + dean=dean, + founded_year=founded, + undergrad_count=ug, + grad_count=gr, + description=desc, + ) + db.session.add(c) + colleges[name] = c + db.session.flush() + + # ───────────────────────────────────────────────────────────────────────── + # DEPARTMENTS + # ───────────────────────────────────────────────────────────────────────── + dept_data = [ + # Arts and Sciences + ('Department of Mathematics', 'Arts and Sciences', 'Dr. James Cogdell', + '(614) 292-4975', '100 Mathematics Building', + 'Mathematics at Ohio State encompasses research in algebra, analysis, geometry, ' + 'topology, logic, and applied mathematics. The department houses internationally ' + 'recognized faculty and produces PhD graduates who lead academia and industry.'), + ('Department of Physics', 'Arts and Sciences', 'Dr. Richard Furnstahl', + '(614) 292-5713', '174 W. 18th Avenue', + 'Physics research at Ohio State spans particle physics, condensed matter, biophysics, ' + 'and astrophysics. The department operates state-of-the-art experimental facilities ' + 'and has strong ties to national laboratories.'), + ('Department of Chemistry and Biochemistry', 'Arts and Sciences', 'Dr. Claudia Turro', + '(614) 292-2133', '100 W. 18th Avenue', + 'Chemistry and Biochemistry at Ohio State integrates teaching and research across ' + 'organic, inorganic, physical, analytical, and biological chemistry subfields.'), + ('Department of Computer Science and Engineering', 'Engineering', 'Dr. Srinivasan Parthasarathy', + '(614) 292-5813', '395 Dreese Lab', + 'CSE at Ohio State is at the forefront of computing research in machine learning, ' + 'security, systems, theory, and human-computer interaction. The department collaborates ' + 'with industry partners and national labs.'), + ('Department of Electrical and Computer Engineering', 'Engineering', 'Dr. Joel Johnson', + '(614) 292-3005', '205 Dreese Lab', + 'ECE research spans signal processing, electromagnetics, VLSI design, power systems, ' + 'and wireless communications. The department has strong industry partnerships and ' + 'state-of-the-art laboratories.'), + ('Department of Mechanical and Aerospace Engineering', 'Engineering', 'Dr. Marcelo Dapino', + '(614) 292-2288', '201 W. 19th Avenue', + 'MAE covers thermodynamics, fluid mechanics, solid mechanics, dynamics, robotics, ' + 'and aerospace systems. The department houses research centers including the Center ' + 'for Automotive Research.'), + ('Department of Economics', 'Arts and Sciences', 'Dr. Bruce Weinberg', + '(614) 292-0552', '1945 N. High Street', + 'The Department of Economics provides rigorous training in microeconomics, ' + 'macroeconomics, econometrics, and applied economics. Research spans labor, health, ' + 'development, public finance, and international economics.'), + ('Department of Psychology', 'Arts and Sciences', 'Dr. Steven Hecht', + '(614) 292-1739', '1835 Neil Avenue Mall', + 'Psychology at Ohio State integrates research and training in clinical, cognitive, ' + 'developmental, neuroscience, quantitative, and social psychology.'), + ('Department of History', 'Arts and Sciences', 'Dr. Stephanie Smith', + '(614) 292-2674', '104 Dulles Hall', + 'The History Department offers programs spanning American, European, African, Asian, ' + 'and world history. Faculty conduct research on topics from medieval Europe to ' + 'contemporary America.'), + ('Department of English', 'Arts and Sciences', 'Dr. Robyn Warhol', + '(614) 292-6065', '164 W. 17th Avenue', + 'English at Ohio State encompasses literary studies, creative writing, rhetoric and ' + 'composition, linguistics, and cultural studies. The department has a distinguished ' + 'tradition of interdisciplinary scholarship.'), + ('Department of Finance', 'Fisher College of Business', 'Dr. Kewei Hou', + '(614) 292-9470', '700 Fisher Hall', + 'Finance at Fisher prepares students for careers in investment banking, asset management, ' + 'corporate finance, and financial research. Research focuses on asset pricing, ' + 'corporate governance, and financial markets.'), + ('Department of Management and Human Resources', 'Fisher College of Business', 'Dr. David Greenberger', + '(614) 292-2311', '700 Fisher Hall', + 'The MHR department addresses critical topics in organizational behavior, human resource ' + 'management, leadership, and strategic management through rigorous research and ' + 'experiential learning.'), + ('Department of Biomedical Informatics', 'Medicine', 'Dr. Philip Payne', + '(614) 293-3600', '1800 Cannon Drive', + 'Biomedical Informatics bridges medicine, nursing, pharmacy, and computing to advance ' + 'health data science and clinical decision support. Faculty lead research in clinical ' + 'natural language processing, patient safety, and precision medicine.'), + ('Department of Epidemiology', 'Public Health', 'Dr. Amy Ferketich', + '(614) 292-0745', '1841 Neil Avenue', + 'Epidemiology faculty study the distribution and determinants of health and disease ' + 'in populations. Specialty areas include cancer, cardiovascular disease, infectious ' + 'disease, reproductive health, and social epidemiology.'), + ('Department of Environmental Engineering', 'Engineering', 'Dr. Linda Weavers', + '(614) 292-2006', '470 Hitchcock Hall', + 'Environmental Engineering addresses water, air, and soil quality challenges using ' + 'science and engineering principles. Research spans water treatment, remediation, ' + 'and sustainable infrastructure design.'), + ] + + departments = {} + for name, college_name, chair, phone, loc, desc in dept_data: + d = Department( + name=name, + slug=slugify(name), + college_id=colleges[college_name].id, + chair=chair, + phone=phone, + location=loc, + description=desc, + ) + db.session.add(d) + departments[name] = d + db.session.flush() + + # ───────────────────────────────────────────────────────────────────────── + # PROGRAMS + # ───────────────────────────────────────────────────────────────────────── + program_data = [ + # Undergrad BS/BA + ('Bachelor of Arts in Mathematics', 'BA', 'Arts and Sciences', 'Department of Mathematics', + 120, 4.0, 'December 1', False, False, + 'The BA in Mathematics provides students with a strong foundation in mathematical ' + 'reasoning and problem-solving. Students take courses in calculus, linear algebra, ' + 'abstract algebra, real analysis, and elective courses in their area of interest.', + 'Calculus sequence; Linear Algebra; Abstract Algebra; Real Analysis; 3 electives in pure or applied math.'), + ('Bachelor of Science in Computer Science and Engineering', 'BS', 'Engineering', 'Department of Computer Science and Engineering', + 130, 4.0, 'February 1', False, False, + 'The BS in CSE prepares students for careers in software engineering, systems design, ' + 'machine learning, and computer science research. The curriculum covers algorithms, ' + 'data structures, operating systems, computer architecture, and specialized electives.', + 'Calculus sequence; Physics; Programming fundamentals; Data Structures; Algorithms; OS; Compilers; Senior capstone.'), + ('Bachelor of Science in Mechanical Engineering', 'BS', 'Engineering', 'Department of Mechanical and Aerospace Engineering', + 132, 4.0, 'February 1', False, False, + 'The BS in Mechanical Engineering at Ohio State covers thermodynamics, fluid dynamics, ' + 'solid mechanics, dynamics, and machine design. Students gain hands-on experience in ' + 'state-of-the-art laboratories and design projects.', + 'Calculus; Physics; Engineering mechanics; Thermodynamics; Fluid mechanics; Materials science; Design capstone.'), + ('Bachelor of Science in Economics', 'BS', 'Arts and Sciences', 'Department of Economics', + 122, 4.0, 'February 1', False, False, + 'The BS in Economics offers rigorous training in economic theory, quantitative methods, ' + 'and applied economics. Graduates pursue careers in consulting, finance, government, ' + 'and economics research.', + 'Micro and macroeconomic theory; Econometrics; Math for economists; 5 field electives.'), + ('Bachelor of Arts in English', 'BA', 'Arts and Sciences', 'Department of English', + 120, 4.0, 'February 1', False, False, + 'The BA in English develops critical reading, research, and writing skills through the ' + 'study of literature, rhetoric, creative writing, and linguistics. Graduates succeed ' + 'in law, business, journalism, education, and many other fields.', + 'Foundations in literary study; Writing seminars; Literature survey courses; Upper-level seminars; Capstone.'), + ('Bachelor of Science in Physics', 'BS', 'Arts and Sciences', 'Department of Physics', + 130, 4.0, 'February 1', False, False, + 'The BS in Physics provides rigorous preparation in classical and modern physics, ' + 'mathematical methods, and experimental techniques. Students develop skills for ' + 'careers in research, engineering, education, and technology.', + 'Calculus-based physics sequence; Math methods; Quantum mechanics; E&M; Thermodynamics; Advanced lab; Capstone.'), + ('Bachelor of Science in Business Administration', 'BS', 'Fisher College of Business', 'Department of Finance', + 121, 4.0, 'December 1', False, False, + 'The BSBA at Fisher prepares students for leadership in diverse business environments. ' + 'Specializations include finance, marketing, management, supply chain, and information systems.', + 'Business core; Accounting; Statistics; Management; Finance; Marketing; Operations; Specialization courses; Capstone.'), + # Graduate + ('Master of Science in Computer Science', 'MS', 'Engineering', 'Department of Computer Science and Engineering', + 30, 2.0, 'December 15', False, False, + 'The MS in CSE at Ohio State offers depth in computer science theory and applications. ' + 'Students choose from thesis and non-thesis options and specialize in areas including ' + 'machine learning, systems, security, or algorithms.', + 'Core graduate CS courses; Thesis or project; 10 credit hours of electives.'), + ('Master of Science in Electrical and Computer Engineering', 'MS', 'Engineering', 'Department of Electrical and Computer Engineering', + 30, 2.0, 'January 15', False, True, + 'The MS in ECE prepares engineers for advanced work in signal processing, communications, ' + 'VLSI, power systems, and more. Both thesis and non-thesis options available.', + 'Graduate core courses; Electives; Thesis or final project.'), + ('Master of Business Administration', 'MBA', 'Fisher College of Business', 'Department of Management and Human Resources', + 60, 2.0, 'April 1', False, False, + 'The Fisher MBA develops business leaders through a rigorous curriculum combining ' + 'management fundamentals with real-world application. Specializations available in ' + 'finance, marketing, entrepreneurship, and operations.', + 'Business core; Leadership skills; Industry-specific electives; Business simulation; Capstone project.'), + ('Doctor of Philosophy in Mathematics', 'PhD', 'Arts and Sciences', 'Department of Mathematics', + 80, 5.0, 'January 1', False, True, + 'The PhD in Mathematics at Ohio State is a research-intensive program that prepares ' + 'students for academic and research careers. Students specialize in algebra, analysis, ' + 'geometry, topology, logic, or applied mathematics.', + 'Graduate coursework; Qualifying exams; Dissertation; Teaching duties.'), + ('Doctor of Philosophy in Physics', 'PhD', 'Arts and Sciences', 'Department of Physics', + 80, 5.5, 'December 1', False, True, + 'The PhD in Physics prepares students for careers in academic research, national ' + 'laboratories, and industry. Students conduct original research in particle physics, ' + 'condensed matter, biophysics, or astrophysics.', + 'Coursework; Qualifying exam; Research rotations; Dissertation.'), + ('Doctor of Philosophy in Computer Science', 'PhD', 'Engineering', 'Department of Computer Science and Engineering', + 80, 5.0, 'December 1', False, True, + 'The PhD in CS at Ohio State is a research-intensive program with national recognition ' + 'in machine learning, security, theoretical computer science, and human-computer ' + 'interaction. Students publish in top venues and collaborate with industry.', + 'Coursework; Candidacy exam; Research; Dissertation; Publications.'), + ('Juris Doctor', 'JD', 'Moritz College of Law', None, + 90, 3.0, 'April 1', False, False, + 'The JD at Moritz College of Law prepares students for practice in any legal field. ' + 'Known for experiential learning, the program offers more than 20 clinics, mock trial ' + 'competitions, and strong placement in law firms, government, and public service.', + 'Legal writing and research; Constitutional law; Contracts; Torts; Civil procedure; Professional responsibility; Electives; Clinical experience.'), + ('Doctor of Medicine', 'MD', 'Medicine', 'Department of Biomedical Informatics', + 0, 4.0, 'October 15', False, False, + 'The MD program at Ohio State College of Medicine offers exceptional training through ' + 'the unique Buckeye Transformative Education in Medicine curriculum. Students benefit ' + 'from early clinical experiences, research opportunities, and connections to the ' + 'nationally ranked Wexner Medical Center.', + 'Pre-clinical foundations; Clinical rotations; Research; Residency preparation.'), + ('Doctor of Pharmacy', 'PharmD', 'Pharmacy', None, + 0, 4.0, 'November 1', False, False, + 'The PharmD at Ohio State prepares pharmacists for clinical, research, and industry ' + 'careers. The curriculum integrates pharmaceutical sciences with patient-centered care ' + 'through experiential learning at outstanding clinical sites.', + 'Pharmaceutical sciences; Pharmacotherapy; Clinical rotations; Advanced practice experiences.'), + ('Doctor of Veterinary Medicine', 'DVM', 'Veterinary Medicine', None, + 0, 4.0, 'October 1', False, False, + 'The DVM program at Ohio State is consistently ranked among the top in the nation. ' + 'Students train at the Veterinary Medical Center, one of the nation\'s premier ' + 'veterinary teaching hospitals, and benefit from research opportunities across ' + 'all animal species.', + 'Biomedical sciences; Clinical sciences; Rotations; Research elective.'), + ('Doctor of Optometry', 'OD', 'Optometry', None, + 0, 4.0, 'October 1', False, False, + 'The OD program at Ohio State prepares optometrists for comprehensive patient care ' + 'in primary care, specialty, and research settings. Students gain extensive clinical ' + 'experience at the OSU Eye Center and affiliated sites.', + 'Optometric sciences; Clinical methods; Patient care rotations; Research.'), + ('Master of Public Health', 'MPH', 'Public Health', 'Department of Epidemiology', + 48, 2.0, 'February 1', True, False, + 'The MPH at Ohio State prepares public health professionals to assess, plan, and ' + 'implement programs that improve community health. Concentrations include epidemiology, ' + 'health behavior, environmental health, and health management.', + 'Core public health competencies; Concentration courses; Practicum; Capstone project.'), + ('Master of Science in Environmental Engineering', 'MS', 'Engineering', 'Department of Environmental Engineering', + 30, 2.0, 'January 15', False, False, + 'The MS in Environmental Engineering addresses water and wastewater treatment, ' + 'air quality, solid waste management, and environmental remediation. Both thesis ' + 'and non-thesis options are available.', + 'Graduate core; Electives; Thesis or project; Professional seminar.'), + ] + + for (name, deg, college_name, dept_name, units, dur, deadline, + is_online, gre, desc, reqs) in program_data: + college_obj = colleges.get(college_name) + dept_obj = departments.get(dept_name) if dept_name else None + slug = slugify(name) + '-' + deg.lower() + p = Program( + name=name, + slug=slug, + degree_type=deg, + college_id=college_obj.id if college_obj else None, + department_id=dept_obj.id if dept_obj else None, + units=units, + duration_years=dur, + application_deadline=deadline, + is_online=is_online, + gre_required=gre, + description=desc, + requirements=reqs, + ) + db.session.add(p) + db.session.flush() + + # ───────────────────────────────────────────────────────────────────────── + # RESEARCH CENTERS + # ───────────────────────────────────────────────────────────────────────── + research_data = [ + ('Translational Data Analytics Institute', 'TDAI', 'Dr. Beth Plale', + 'Arts and Sciences', 2016, + 'Data analytics, Machine learning, Health informatics, Social science', + 'https://tdai.osu.edu', + 'TDAI brings together faculty from across Ohio State to harness the power of data analytics ' + 'to address complex problems in society. The institute supports interdisciplinary research ' + 'in health, agriculture, smart cities, and social sciences.'), + ('Byrd Alzheimer\'s Center and Research Institute', 'Byrd', 'Dr. Douglas Scharre', + 'Medicine', 1987, + 'Alzheimer\'s disease, Dementia, Neuroimaging, Clinical trials', + '', + 'The Byrd Alzheimer\'s Center is dedicated to discovering causes and cures for Alzheimer\'s ' + 'disease and related dementias. Researchers conduct clinical trials and basic science studies ' + 'to advance diagnosis, prevention, and treatment.'), + ('Infectious Disease Institute', 'IDI', 'Dr. Michael Oglesbee', + 'Veterinary Medicine', 2008, + 'Infectious disease, Epidemiology, One Health, Vaccines', + 'https://idi.osu.edu', + 'IDI brings together virologists, bacteriologists, immunologists, and epidemiologists to ' + 'address infectious disease threats to humans, animals, and ecosystems through the One Health ' + 'approach. Research programs span HIV, influenza, SARS-CoV-2, and emerging pathogens.'), + ('Ohio Supercomputer Center', 'OSC', 'Dr. David Bickel', + 'Engineering', 1987, + 'High-performance computing, Scientific computing, Data storage, Visualization', + 'https://www.osc.edu', + 'The Ohio Supercomputer Center is a statewide resource supporting computational research ' + 'at Ohio State and institutions across Ohio. OSC provides high-performance computing, ' + 'data storage, and training to researchers in science, engineering, and the humanities.'), + ('Center for Automotive Research', 'CAR', 'Dr. Giorgio Rizzoni', + 'Engineering', 1991, + 'Electric vehicles, Autonomous driving, Energy storage, Powertrain systems', + 'https://car.osu.edu', + 'CAR partners with automotive industry leaders to advance electrification, autonomy, ' + 'connectivity, and mobility. Research programs address battery systems, vehicle dynamics, ' + 'driver behavior, and sustainable transportation systems.'), + ('Battelle Center for Science, Engineering and Public Policy', 'Battelle', 'Dr. Clay Johnston', + 'John Glenn College of Public Affairs', 2010, + 'Science policy, Technology policy, Energy policy, Climate policy', + '', + 'The Battelle Center examines how science and technology shape public policy choices. ' + 'Faculty and students analyze energy, environment, health, and security policy, ' + 'bridging the gap between scientific evidence and policy action.'), + ('James Cancer Hospital and Solove Research Institute', 'James', 'Dr. William Farrar', + 'Medicine', 1990, + 'Cancer research, Oncology, Clinical trials, Precision medicine', + 'https://cancer.osu.edu', + 'The James Cancer Hospital and Solove Research Institute is Ohio\'s only comprehensive ' + 'cancer center and ranks among the nation\'s top cancer programs. Researchers develop ' + 'novel immunotherapies, targeted treatments, and early detection methods.'), + ('Wexner Medical Center', 'WMC', 'Dr. Hal Paz', + 'Medicine', 1952, + 'Clinical medicine, Medical education, Translational research, Health systems', + 'https://wexnermedical.osu.edu', + 'The Ohio State Wexner Medical Center is a nationally recognized academic medical center ' + 'with hospitals, clinical programs, and research institutes advancing human health. ' + 'Researchers translate scientific discoveries into new diagnostics and therapies.'), + ('Drug Enforcement and Policy Center', 'DEPC', 'Dr. Douglas Berman', + 'Moritz College of Law', 2018, + 'Drug policy, Criminal justice, Marijuana policy, Opioid epidemic', + 'https://depc.osu.edu', + 'DEPC is the leading academic center on drug law and policy, examining enforcement, ' + 'regulation, and reform. Center experts provide evidence-based analysis on topics ' + 'including the opioid crisis, marijuana legalization, and sentencing reform.'), + ('Center for Clean Hydrogen', 'CCH', 'Dr. Yann Guezennec', + 'Engineering', 2022, + 'Hydrogen energy, Fuel cells, Green hydrogen, Energy storage', + '', + 'The Center for Clean Hydrogen advances science and engineering to enable a hydrogen ' + 'economy. Research addresses hydrogen production, storage, transportation, and fuel ' + 'cell technology for transportation and power generation applications.'), + ('Advanced Computing Center for the Arts and Design', 'ACCAD', 'Dr. Maria Palazzi', + 'Arts and Sciences', 1987, + 'Digital arts, Computer animation, Visualization, Motion capture', + 'https://accad.osu.edu', + 'ACCAD is an internationally recognized center for research and practice at the ' + 'intersection of art, design, and computing. Faculty and students develop new forms ' + 'of digital art, animation, interactive media, and scientific visualization.'), + ('Center for Cognitive and Brain Sciences', 'CCBS', 'Dr. Michael DeSchutter', + 'Arts and Sciences', 2007, + 'Neuroscience, Cognitive science, Decision making, Language', + '', + 'CCBS is an interdisciplinary research center addressing fundamental questions about ' + 'the brain and mind. Research programs span perception, attention, memory, decision ' + 'making, language, and social cognition using behavioral, neuroimaging, and computational methods.'), + ('Sustainability Institute', 'SI', 'Dr. Julie Newman', + 'Food, Agricultural, and Environmental Sciences', 2008, + 'Sustainability, Climate change, Campus operations, Environmental policy', + 'https://si.osu.edu', + 'The Sustainability Institute leads Ohio State\'s efforts to advance sustainability ' + 'in research, education, and campus operations. The institute supports interdisciplinary ' + 'research on energy, water, food systems, biodiversity, and climate resilience.'), + ('Chadwick Arboretum and Learning Gardens', 'Chadwick', 'Dr. Susan Pell', + 'Food, Agricultural, and Environmental Sciences', 1980, + 'Plant science, Horticulture, Biodiversity, Sustainable landscapes', + 'https://chadwickarboretum.osu.edu', + 'Chadwick Arboretum is a 60-acre living laboratory on the Columbus campus featuring ' + 'thousands of plant species. The arboretum supports research in plant science, ' + 'sustainable horticulture, and environmental education for the community.'), + ('Center for Biostatistics', 'CBS', 'Dr. Michael Pennell', + 'Public Health', 2001, + 'Biostatistics, Clinical trials, Epidemiology, Statistical genetics', + '', + 'The Center for Biostatistics provides statistical expertise and methodology development ' + 'to support clinical and public health research across Ohio State. Faculty collaborate ' + 'on clinical trials, cohort studies, and genomic data analysis.'), + ] + + rc_map = {} + for (name, short, director, college_name, founded, focus, url, desc) in research_data: + rc = ResearchCenter( + name=name, + slug=slugify(name), + director=director, + college_id=colleges.get(college_name, colleges['Arts and Sciences']).id, + founded_year=founded, + focus_areas=focus, + url=url, + description=desc, + ) + db.session.add(rc) + rc_map[name] = rc + db.session.flush() + + # ───────────────────────────────────────────────────────────────────────── + # FACULTY + # ───────────────────────────────────────────────────────────────────────── + faculty_data = [ + ('Dr. James Cogdell', 'Professor', 'Department of Mathematics', 'cogdell.1@osu.edu', 'MW 724', '(614) 292-4975', + 'Number theory, Automorphic forms, L-functions, Langlands program', + 'Professor Cogdell is a leading number theorist specializing in automorphic forms and the Langlands program. ' + 'He has received numerous awards and fellowships for his research contributions.', False), + ('Dr. Claudia Turro', 'Professor and Chair', 'Department of Chemistry and Biochemistry', 'turro.1@osu.edu', 'Evans 100D', '(614) 292-6567', + 'Inorganic photochemistry, Solar energy conversion, Anticancer agents, Ruthenium complexes', + 'Professor Turro leads research in inorganic photochemistry with applications in solar energy and cancer therapy. ' + 'Her group develops ruthenium-based complexes for photoactivated cancer treatment.', False), + ('Dr. Richard Furnstahl', 'Professor', 'Department of Physics', 'furnstahl.1@osu.edu', 'M2048 Physics Research Building', '(614) 292-4830', + 'Nuclear physics, Quantum chromodynamics, Effective field theory, Machine learning in physics', + 'Professor Furnstahl is a nuclear theorist who uses effective field theory and Bayesian methods ' + 'to study nuclear structure and reactions.', False), + ('Dr. Srinivasan Parthasarathy', 'Professor and Chair', 'Department of Computer Science and Engineering', 'parthasarathy.2@osu.edu', '591 Dreese Lab', '(614) 292-2568', + 'Data mining, Machine learning, Graph mining, Bioinformatics', + 'Professor Parthasarathy is an internationally recognized expert in data mining, machine learning, ' + 'and graph analytics. His group develops algorithms for large-scale data analysis with applications ' + 'in health care, social networks, and genomics.', False), + ('Dr. Marcelo Dapino', 'Professor and Honda R&D Americas Chair', 'Department of Mechanical and Aerospace Engineering', 'dapino.1@osu.edu', '201 W. 19th Avenue', '(614) 292-9138', + 'Smart materials, Vibration control, Automotive engineering, Magnetostrictive actuators', + 'Professor Dapino is a leading researcher in smart materials and structural acoustics, ' + 'with applications in automotive NVH and adaptive structures.', False), + ('Dr. Bruce Weinberg', 'Professor', 'Department of Economics', 'weinberg.27@osu.edu', '422 Arps Hall', '(614) 292-0553', + 'Labor economics, Innovation, Science of science, Health economics', + 'Professor Weinberg studies the economics of innovation, labor markets, and health. ' + 'His research on the relationship between age and scientific productivity has received ' + 'wide attention in academia and the popular press.', False), + ('Dr. Philip Payne', 'Professor and Chair', 'Department of Biomedical Informatics', 'payne.38@osu.edu', '1800 Cannon Drive', '(614) 293-3600', + 'Biomedical informatics, Clinical NLP, Precision medicine, Learning health systems', + 'Professor Payne leads the Department of Biomedical Informatics and is a pioneer in ' + 'data-driven approaches to clinical decision support and precision medicine.', False), + ('Dr. Amy Ferketich', 'Professor and Dean', 'Department of Epidemiology', 'ferketich.1@osu.edu', '250 Cunz Hall', '(614) 292-0745', + 'Tobacco control, Cancer epidemiology, Health disparities, Behavioral interventions', + 'Dean Ferketich is a nationally recognized tobacco control researcher who has led ' + 'population-based studies on smoking cessation, tobacco marketing, and health disparities.', False), + ('Dr. David Greenberger', 'Professor Emeritus', 'Department of Management and Human Resources', 'greenberger.1@osu.edu', '700 Fisher Hall', '(614) 292-0040', + 'Organizational behavior, Leadership, Work and family, Entrepreneurship', + 'Professor Greenberger is a pioneer in organizational behavior research and has made ' + 'foundational contributions to our understanding of leadership and organizational control.', True), + ('Dr. Kewei Hou', 'Professor', 'Department of Finance', 'hou.28@osu.edu', '750 Fisher Hall', '(614) 292-0552', + 'Asset pricing, Empirical finance, Factor models, Market anomalies', + 'Professor Hou is one of the leading empirical finance researchers of his generation, ' + 'known for the q-factor model of stock returns and research on market anomalies.', False), + ('Dr. Douglas Scharre', 'Professor and Director', 'Department of Epidemiology', 'scharre.1@osu.edu', '395 W. 12th Avenue', '(614) 293-4969', + 'Alzheimer\'s disease, Cognitive assessment, Dementia treatment, Brain aging', + 'Professor Scharre directs the Division of Cognitive Neurology and the Byrd Alzheimer\'s Center. ' + 'He developed the widely used Self-Administered Gerocognitive Exam (SAGE) for early ' + 'detection of cognitive impairment.', False), + ('Dr. Giorgio Rizzoni', 'Professor and Director', 'Department of Mechanical and Aerospace Engineering', 'rizzoni.1@osu.edu', '930 Kinnear Road', '(614) 292-0734', + 'Electric vehicles, Energy management, Hybrid powertrains, Control systems', + 'Professor Rizzoni is a global authority on electrified transportation and energy management ' + 'for hybrid and electric vehicles. He directs the Center for Automotive Research and has ' + 'led numerous multi-million dollar collaborative projects with automotive partners.', False), + ('Dr. Beth Plale', 'Professor and Executive Director', 'Department of Computer Science and Engineering', 'plale.1@osu.edu', '550 Dreese Lab', '(614) 292-1234', + 'Data science, Provenance, Research data management, Machine learning', + 'Professor Plale is Executive Director of the Translational Data Analytics Institute and ' + 'leads research in data science, provenance, and research data management.', False), + ('Dr. Linda Weavers', 'Professor and Chair', 'Department of Environmental Engineering', 'weavers.1@osu.edu', '470 Hitchcock Hall', '(614) 292-2006', + 'Water treatment, Sonochemistry, Environmental remediation, Emerging contaminants', + 'Professor Weavers is a leading expert in water treatment and sonochemical processes ' + 'for environmental remediation. Her research addresses treatment of emerging contaminants ' + 'including pharmaceuticals and PFAS.', False), + ('Dr. Douglas Berman', 'Professor and Director', 'Department of Economics', 'berman.43@osu.edu', '55 W. 12th Avenue', '(614) 292-5925', + 'Criminal law, Sentencing, Drug policy, Prison policy', + 'Professor Berman is the nation\'s leading academic expert on federal sentencing law and ' + 'drug policy reform. He founded and edits the widely read Sentencing Law and Policy blog ' + 'and directs the Drug Enforcement and Policy Center.', False), + ] + + for (name, title, dept_name, email, office, phone, interests, bio, emeritus) in faculty_data: + dept_obj = departments.get(dept_name) + m = Faculty( + name=name, + slug=slugify(name), + title=title, + department_id=dept_obj.id if dept_obj else None, + email=email, + office=office, + phone=phone, + research_interests=interests, + bio=bio, + is_emeritus=emeritus, + ) + db.session.add(m) + db.session.flush() + + # ───────────────────────────────────────────────────────────────────────── + # ATHLETIC TEAMS + # ───────────────────────────────────────────────────────────────────────── + team_data = [ + ('Ohio State Buckeyes Football', 'Football', 'Men', 'Ryan Day', 'Ohio Stadium (Horseshoe)', 8, '11-2'), + ('Ohio State Buckeyes Men\'s Basketball', 'Basketball', 'Men', 'Jake Diebler', 'Value City Arena', 0, '14-17'), + ('Ohio State Buckeyes Women\'s Basketball', 'Basketball', 'Women', 'Kevin McGuff', 'Value City Arena', 0, '21-12'), + ('Ohio State Buckeyes Baseball', 'Baseball', 'Men', 'Bill Mosiello', 'Bill Davis Stadium', 0, '35-22'), + ('Ohio State Buckeyes Softball', 'Softball', 'Women', 'Kelly Kovach Schoenly', 'Buckeye Field', 0, '26-25'), + ('Ohio State Buckeyes Men\'s Soccer', 'Soccer', 'Men', 'Brian Maisonneuve', 'Jesse Owens Memorial Stadium', 0, '7-10-3'), + ('Ohio State Buckeyes Women\'s Soccer', 'Soccer', 'Women', 'Lori Walker', 'Jesse Owens Memorial Stadium', 0, '13-7-3'), + ('Ohio State Buckeyes Men\'s Swimming & Diving', 'Swimming & Diving', 'Men', 'Bill Dorenkott', 'McCorkle Aquatic Pavilion', 12, '—'), + ('Ohio State Buckeyes Women\'s Swimming & Diving', 'Swimming & Diving', 'Women', 'Bill Dorenkott', 'McCorkle Aquatic Pavilion', 11, '—'), + ('Ohio State Buckeyes Men\'s Track & Field', 'Track & Field', 'Men', 'Ed Lomonaco', 'Jesse Owens Memorial Stadium', 0, '—'), + ('Ohio State Buckeyes Women\'s Track & Field', 'Track & Field', 'Women', 'Ed Lomonaco', 'Jesse Owens Memorial Stadium', 0, '—'), + ('Ohio State Buckeyes Volleyball', 'Volleyball', 'Women', 'Jen Flynn Oldenburg', 'Covelli Center', 0, '20-10'), + ('Ohio State Buckeyes Wrestling', 'Wrestling', 'Men', 'Tom Ryan', 'Covelli Center', 8, '17-4'), + ('Ohio State Buckeyes Men\'s Tennis', 'Tennis', 'Men', 'Ty Tucker', 'Ty Tucker Tennis Center', 0, '14-11'), + ('Ohio State Buckeyes Women\'s Tennis', 'Tennis', 'Women', 'Melissa Schaub', 'Ty Tucker Tennis Center', 0, '16-9'), + ('Ohio State Buckeyes Men\'s Golf', 'Golf', 'Men', 'Jay Moseley', 'OSU Golf Club', 0, '—'), + ('Ohio State Buckeyes Women\'s Golf', 'Golf', 'Women', 'Therese Hession', 'OSU Golf Club', 0, '—'), + ('Ohio State Buckeyes Men\'s Gymnastics', 'Gymnastics', 'Men', 'Miles Avery', 'Covelli Center', 0, '—'), + ('Ohio State Buckeyes Women\'s Gymnastics', 'Gymnastics', 'Women', 'Bob Fetter', 'Covelli Center', 0, '—'), + ('Ohio State Buckeyes Ice Hockey', 'Ice Hockey', 'Men', 'Steve Rohlik', 'Value City Arena', 0, '21-13-2'), + ('Ohio State Buckeyes Men\'s Lacrosse', 'Lacrosse', 'Men', 'Nick Myers', 'Ohio Stadium (turf)', 0, '14-5'), + ('Ohio State Buckeyes Women\'s Lacrosse', 'Lacrosse', 'Women', 'Nikki Hanigan', 'Buckeye Field (lacrosse)', 0, '8-10'), + ('Ohio State Buckeyes Field Hockey', 'Field Hockey', 'Women', 'Jarred Martin', 'Buckeye Field', 0, '12-8'), + ('Ohio State Buckeyes Rowing', 'Rowing', 'Women', 'Emanuele Catasta', 'Griggs Reservoir', 0, '—'), + ('Ohio State Buckeyes Fencing', 'Fencing', 'Men', 'George Shutt', 'RPAC', 2, '—'), + ('Ohio State Buckeyes Women\'s Fencing', 'Fencing', 'Women', 'George Shutt', 'RPAC', 0, '—'), + ] + + for (name, sport, gender, coach, venue, titles, record) in team_data: + t = AthleticTeam( + name=name, + slug=slugify(name), + sport=sport, + gender=gender, + conference='Big Ten', + coach=coach, + home_venue=venue, + national_titles=titles, + recent_record=record, + ) + db.session.add(t) + db.session.flush() + + # ───────────────────────────────────────────────────────────────────────── + # NEWS ARTICLES + # ───────────────────────────────────────────────────────────────────────── + now = datetime(2024, 10, 15) + articles = [ + ('Ohio State Researchers Develop Breakthrough Cancer Immunotherapy', 'Research', + 'Jody Sheridan', now - timedelta(days=2), True, + 'Ohio State, immunotherapy, cancer, James Cancer Hospital, research', + 'A team of Ohio State researchers has achieved a major breakthrough in cancer immunotherapy, ' + 'developing a novel approach that could improve outcomes for patients with hard-to-treat solid tumors.', + 'Ohio State researchers led by Dr. William Farrar at the James Cancer Hospital and Solove Research Institute ' + 'have developed a new CAR-T cell therapy that targets multiple tumor antigens simultaneously, ' + 'addressing one of the key limitations of current immunotherapy approaches.\n\n' + 'In preclinical studies, the multi-antigen approach demonstrated a 73 percent reduction in tumor burden ' + 'compared to conventional single-antigen CAR-T therapy. Researchers are now planning a Phase I clinical ' + 'trial expected to begin enrollment in early 2025.\n\n' + '"This represents a fundamental advance in how we design cellular therapies for solid tumors," ' + 'said Dr. Farrar. "By targeting multiple antigens, we make it much harder for cancer cells to ' + 'develop resistance through antigen escape."\n\n' + 'The research was published in Nature Medicine and was supported by grants from the National Cancer ' + 'Institute and the Ohio State University Comprehensive Cancer Center.'), + ('Ohio State Football Ranked in Top 5 Heading into Conference Play', 'Athletics', + 'Mike Cardamone', now - timedelta(days=3), True, + 'football, Buckeyes, Big Ten, ranking, Ryan Day', + 'The Ohio State Buckeyes football team has climbed into the top five of both major polls ' + 'as they enter the heart of Big Ten Conference play.', + 'The Ohio State Buckeyes have moved into the top five of the AP Poll and the Coaches Poll ' + 'after a dominant performance in their non-conference schedule. Coach Ryan Day\'s squad ' + 'has outscored opponents by an average of 34 points per game.\n\n' + '"We\'re playing complementary football right now," Day said at his Monday press conference. ' + '"The offense is efficient, the defense is flying around, and the special teams have been outstanding."\n\n' + 'Quarterback Will Howard has emerged as a Heisman Trophy candidate, completing 72 percent of ' + 'his passes for 1,847 yards and 18 touchdowns against just two interceptions.\n\n' + 'The Buckeyes host Michigan State this Saturday at Ohio Stadium, with kickoff set for noon ' + 'on Fox. The game is a sold-out affair, with over 105,000 fans expected to fill the Horseshoe.'), + ('Fisher College of Business Launches New Sustainability MBA Track', 'Campus Life', + 'OSU News Staff', now - timedelta(days=5), False, + 'Fisher, MBA, sustainability, business, environment', + 'Fisher College of Business has unveiled a new MBA specialization in Sustainable Business, ' + 'responding to growing demand from employers for business leaders with expertise in ESG.', + 'Fisher College of Business has announced the launch of a new Sustainable Business specialization ' + 'within its full-time MBA program, beginning in the 2025-2026 academic year.\n\n' + 'The specialization will allow MBA students to develop expertise in environmental, social, and ' + 'governance (ESG) strategy, sustainable supply chains, green finance, and corporate sustainability reporting.\n\n' + '"Employers are increasingly looking for business leaders who understand how sustainability creates ' + 'long-term value," said Dean Anil Makhija. "This specialization gives our students a competitive ' + 'advantage in a rapidly evolving landscape."\n\n' + 'The program includes case studies from Fortune 500 companies, a sustainability consulting practicum ' + 'with Ohio-based organizations, and access to Fisher\'s extensive alumni network in sustainable business.'), + ('Ohio Supercomputer Center Upgrades to Exascale-Class Computing', 'Research', + 'OSU News Staff', now - timedelta(days=7), False, + 'Ohio Supercomputer Center, HPC, computing, research infrastructure', + 'The Ohio Supercomputer Center has completed a major infrastructure upgrade, bringing ' + 'near-exascale computing capabilities to Ohio researchers.', + 'The Ohio Supercomputer Center has announced the successful deployment of Ascend, a new ' + 'high-performance computing cluster that dramatically expands computational capacity for ' + 'Ohio researchers.\n\n' + 'Ascend features 680 compute nodes with NVIDIA H100 GPUs, delivering over 50 petaflops of ' + 'AI-optimized computing power — a tenfold increase over the previous system. The system also ' + 'includes 10 petabytes of high-speed parallel storage.\n\n' + '"Ascend positions Ohio at the forefront of academic computing," said Director David Bickel. ' + '"Researchers can now tackle AI, climate modeling, drug discovery, and genomics problems ' + 'that were previously out of reach."'), + ('Ohio State Alumnus Appointed to NASA Administrator Role', 'Faculty', + 'OSU News Staff', now - timedelta(days=9), False, + 'alumni, NASA, space, science, engineering', + 'An Ohio State University alumnus with degrees in aerospace engineering and public policy ' + 'has been appointed to a senior leadership role at the National Aeronautics and Space Administration.', + 'Dr. James Crawford, who earned his BS in Aerospace Engineering from Ohio State in 1994 and his ' + 'MPH from the John Glenn College of Public Affairs in 2002, has been appointed as Deputy Associate ' + 'Administrator for Research at NASA.\n\n' + '"Ohio State gave me the foundation to dream big and the tools to make those dreams real," ' + 'Crawford said upon his appointment. "I carry the Buckeye spirit into everything I do."'), + ('College of Engineering Receives $50M Federal Grant for Hydrogen Research', 'Research', + 'College of Engineering Communications', now - timedelta(days=11), True, + 'engineering, hydrogen, energy, federal grant, NSF, research', + 'The Ohio State College of Engineering has been awarded a $50 million Department of Energy ' + 'grant to establish a national center for clean hydrogen research.', + 'The Ohio State College of Engineering has secured a $50 million grant from the Department of ' + 'Energy to establish the National Center for Clean Hydrogen Technologies, led by Professor ' + 'Yann Guezennec of the Center for Clean Hydrogen.\n\n' + 'The center will bring together researchers from engineering, chemistry, environmental engineering, ' + 'and public policy to accelerate the transition to a hydrogen economy.\n\n' + '"Hydrogen is one of the most promising pathways to deep decarbonization," said Dean Ayanna Howard. ' + '"This investment reflects Ohio State\'s leadership in clean energy research and positions us to ' + 'deliver real-world impact at scale."'), + ('Moritz College of Law Hosts National Symposium on Artificial Intelligence and Law', 'Campus Life', + 'Moritz Communications', now - timedelta(days=14), False, + 'law, AI, artificial intelligence, symposium, Moritz', + 'Moritz College of Law welcomed leading legal scholars, judges, and technologists for a ' + 'two-day national symposium on the intersection of artificial intelligence and legal practice.', + 'Moritz College of Law hosted more than 200 legal scholars, practicing attorneys, federal judges, ' + 'and technology experts at its inaugural AI and Law Symposium, exploring how machine learning ' + 'is transforming legal research, evidence, and decision-making.\n\n' + 'Keynote speakers included a federal circuit court judge who spoke about AI-assisted legal research, ' + 'and a leading AI ethicist who addressed bias and fairness in algorithmic decision systems.\n\n' + 'Symposium papers will be published in a special issue of the Ohio State Law Journal in spring 2025.'), + ('Ohio State Named Among Top 20 Public Universities by U.S. News', 'Campus Life', + 'OSU News Staff', now - timedelta(days=16), True, + 'ranking, U.S. News, public university, research', + 'The Ohio State University has been ranked among the top 20 public universities in the ' + 'United States in the latest U.S. News & World Report Best Colleges rankings.', + 'Ohio State maintained its position among the top 20 public universities in the country according ' + 'to the 2025 U.S. News & World Report Best Colleges rankings, continuing its streak of ' + 'consistent improvement over the past decade.\n\n' + 'Several individual programs also saw strong rankings: Computer Science moved into the top 15 ' + 'nationally; the Moritz College of Law climbed to #26; and the Fisher College of Business MBA ' + 'program is now ranked #28 in the nation.\n\n' + '"These rankings reflect the hard work of our faculty, staff, and students," said Provost Melissa Gilliam. ' + '"But more importantly, they reflect our commitment to providing a world-class education and ' + 'conducting research that matters."'), + ('TDAI Launches Interdisciplinary Health Data Science Training Program', 'Research', + 'TDAI Communications', now - timedelta(days=18), False, + 'TDAI, data science, health, training, interdisciplinary', + 'The Translational Data Analytics Institute has launched a new graduate training program ' + 'that brings together doctoral students from medicine, public health, statistics, and computer science.', + 'The Translational Data Analytics Institute has launched the Health Data Science Training Program, ' + 'a multi-year initiative funded by the National Institutes of Health to train the next generation ' + 'of health data scientists.\n\n' + 'The program provides 15 doctoral fellows per cohort with interdisciplinary coursework, mentored ' + 'research experiences, and professional development in communication, ethics, and entrepreneurship.\n\n' + '"Health data science requires expertise that no single discipline can provide," said Director Beth Plale. ' + '"This program creates researchers who can bridge clinical medicine, public health, and advanced computing."'), + ('Buckeyes Wrestling Team Ranked No. 1 in the Nation', 'Athletics', + 'OSU Athletics Communications', now - timedelta(days=20), False, + 'wrestling, ranking, national, Buckeyes, Tom Ryan', + 'The Ohio State wrestling team has opened the season as the No. 1 ranked team in the ' + 'nation, setting sights on a record-breaking national championship run.', + 'Ohio State wrestling began the 2024-25 season ranked No. 1 in the country by InterMat ' + 'and FloWrestling, led by a roster of 11 nationally ranked wrestlers including three ' + 'returning All-Americans.\n\n' + '"This team has the talent and the hunger to be special," said head coach Tom Ryan, who ' + 'has led the Buckeyes to 8 national championships. "But rankings don\'t win titles — ' + 'hard work and execution do."'), + ('Ohio State Sets Record for Research Expenditures at $1.3 Billion', 'Research', + 'Office of Research Communications', now - timedelta(days=22), True, + 'research, expenditure, record, funding, grants', + 'Ohio State has reached a historic milestone, surpassing $1.3 billion in annual research ' + 'expenditures for the first time in the university\'s 154-year history.', + 'The Ohio State University has reported a record $1.3 billion in research expenditures for ' + 'fiscal year 2024, according to data compiled by the Office of Research. This represents a ' + 'seven percent increase over the previous year and marks the university\'s highest-ever ' + 'research investment.\n\n' + 'Funding came from federal agencies including NIH, NSF, DOE, and DOD; state of Ohio; ' + 'industry partners; and private foundations.\n\n' + '"This milestone demonstrates the remarkable breadth and quality of research happening at ' + 'Ohio State," said President Ted Carter. "Our faculty are tackling the challenges that ' + 'matter most — cancer, climate, artificial intelligence, and so much more."'), + ('College of Nursing Launches Mental Health Initiative for Students', 'Health', + 'College of Nursing Communications', now - timedelta(days=25), False, + 'nursing, mental health, students, wellness, Bernadette Melnyk', + 'The College of Nursing, led by Dean Bernadette Melnyk, has launched a comprehensive ' + 'mental health and wellness initiative targeting Ohio State\'s student population.', + 'The Ohio State College of Nursing has launched "Buckeye Wellness," a university-wide ' + 'evidence-based program designed to improve mental health, well-being, and academic ' + 'outcomes for students.\n\n' + 'The program, developed by Dean Bernadette Melnyk and her team, provides students with ' + 'a seven-week cognitive behavioral skills-building intervention via a mobile application, ' + 'peer support groups, and faculty wellness champions.\n\n' + 'Early pilot data showed a 22 percent reduction in depression and anxiety symptoms among ' + 'participating students and a significant improvement in GPAs compared to a control group.'), + ('Ohio State Hosts International Climate Summit Ahead of COP30', 'Research', + 'Sustainability Institute', now - timedelta(days=28), False, + 'climate, sustainability, international, COP, environment', + 'The Ohio State Sustainability Institute welcomed climate researchers and policymakers ' + 'from 40 countries for a pre-COP30 research summit at the Columbus campus.', + 'Ohio State\'s Sustainability Institute hosted the Global Universities Climate Summit, ' + 'bringing together 300 climate scientists, economists, engineers, and policymakers from ' + '40 nations to share research and develop recommendations ahead of the UN Climate ' + 'Conference (COP30).\n\n' + '"Universities play a unique role in the climate challenge — we produce the knowledge ' + 'and the people who will deliver solutions," said Institute Director Dr. Julie Newman. ' + '"This summit strengthens the global network of researchers committed to a just and ' + 'sustainable future."\n\n' + 'Summit participants issued a joint statement calling for accelerated decarbonization ' + 'of electricity systems, food security investments, and equitable climate finance.'), + ('Department of Computer Science Launches AI Ethics Certificate', 'Student', + 'CSE Department Communications', now - timedelta(days=31), False, + 'CSE, AI, ethics, certificate, students', + 'The Department of Computer Science and Engineering has introduced a new undergraduate ' + 'certificate in Artificial Intelligence Ethics, open to students across all colleges.', + 'Ohio State\'s Department of Computer Science and Engineering has launched an undergraduate ' + 'Certificate in AI Ethics, a 15-credit interdisciplinary program available to students in ' + 'any major.\n\n' + 'The certificate curriculum draws on coursework from computer science, philosophy, law, ' + 'sociology, and public policy, preparing students to develop and deploy AI systems ' + 'responsibly.\n\n' + '"AI is being embedded into every sector of society, and we need graduates who understand ' + 'both the technical and ethical dimensions," said Department Chair Dr. Srinivasan Parthasarathy.'), + ('Ohio State Veterinary Medical Center Treats Record Number of Patients', 'Health', + 'College of Veterinary Medicine', now - timedelta(days=35), False, + 'veterinary, Vet Medical Center, patients, animals, care', + 'The Ohio State Veterinary Medical Center has seen a record 86,000 patient visits in ' + 'the past year, reinforcing its reputation as one of the nation\'s premier veterinary ' + 'teaching hospitals.', + 'The Ohio State University Veterinary Medical Center reported 86,231 patient visits ' + 'in fiscal year 2024, a 12 percent increase over the previous year and a new all-time record.\n\n' + 'The VMC offers specialty services across cardiology, dermatology, emergency medicine, ' + 'neurology, oncology, surgery, and more than 25 other disciplines for companion animals, ' + 'horses, and farm animals.\n\n' + '"Our growth reflects both the exceptional quality of our clinical teams and the trust ' + 'that pet owners across the region place in Ohio State," said Dean Rustin Moore.'), + ('Ohio State Announces New Partnership with Nationwide Children\'s Hospital', 'Health', + 'OSU Medical Center Communications', now - timedelta(days=40), False, + 'Nationwide Children\'s, partnership, pediatrics, medical center, research', + 'Ohio State and Nationwide Children\'s Hospital have formalized a landmark partnership ' + 'to advance pediatric research, education, and clinical care.', + 'The Ohio State University and Nationwide Children\'s Hospital have signed a comprehensive ' + 'partnership agreement that strengthens research, training, and patient care collaborations ' + 'between the two institutions.\n\n' + 'Under the agreement, Ohio State medical faculty will hold joint appointments at Nationwide ' + 'Children\'s, and residents in pediatrics will have expanded training opportunities at both ' + 'institutions.\n\n' + '"Together, Ohio State and Nationwide Children\'s are a powerhouse for children\'s health ' + 'in this region and nationally," said OSU President Ted Carter.'), + ('Buckeyes Swimming and Diving Teams Win Big Ten Championships', 'Athletics', + 'OSU Athletics Communications', now - timedelta(days=45), True, + 'swimming, diving, Big Ten, championship, Buckeyes', + 'Ohio State\'s men\'s and women\'s swimming and diving teams both captured Big Ten ' + 'Conference championships in dramatic fashion at the annual championships meet.', + 'Ohio State\'s swimming and diving programs claimed a sweep of the Big Ten championships, ' + 'with both the men\'s and women\'s teams winning conference titles for the second consecutive year.\n\n' + 'The men\'s team finished with 1,342.5 points, edging Michigan by 43 points, ' + 'while the women\'s team dominated with a 260-point margin of victory.\n\n' + 'Coach Bill Dorenkott\'s program has now won a combined 23 Big Ten championships.'), + ('Graduate School Expands Fellowship Opportunities for Doctoral Students', 'Student', + 'Graduate School Communications', now - timedelta(days=50), False, + 'graduate school, fellowship, doctoral, funding, PhD', + 'Ohio State\'s Graduate School has announced a $25 million expansion of fellowship ' + 'funding for doctoral students, aiming to increase diversity and reduce time to degree.', + 'Ohio State\'s Graduate School has announced the Buckeye Graduate Excellence Initiative, ' + 'a $25 million commitment over five years to expand fellowship support for doctoral students.\n\n' + 'The initiative will fund 150 additional university fellowships annually, with a particular ' + 'emphasis on recruiting students from underrepresented backgrounds and high-priority ' + 'research areas including AI, climate science, and biomedical engineering.\n\n' + '"Graduate students are the engine of our research enterprise," said Dean Sean Carson. ' + '"This investment ensures Ohio State can recruit and support the best and brightest from ' + 'across the country and the world."'), + ('Ohio State Extension Celebrates 100 Years of Service to Ohio Communities', 'Campus Life', + 'OSU Extension Communications', now - timedelta(days=55), False, + 'extension, Ohio, community, centennial, agriculture', + 'Ohio State University Extension is marking its centennial year with events across ' + 'all 88 Ohio counties, celebrating a century of research-based service to Ohioans.', + 'Ohio State University Extension is celebrating its 100th anniversary as an official ' + 'federal-state-local partnership, with events and programs planned across all 88 ' + 'Ohio counties throughout the academic year.\n\n' + 'Extension educators serve nearly every Ohio family through programs in agriculture, ' + 'family and consumer sciences, 4-H youth development, and community development.\n\n' + '"Extension connects the resources of Ohio State University directly to Ohioans wherever ' + 'they live," said College Dean Cathann Kress. "For 100 years, we have been a trusted ' + 'partner in making Ohio stronger."'), + ('Ohio State Partners with Intel on $200M Semiconductor Research Initiative', 'Research', + 'College of Engineering Communications', now - timedelta(days=60), True, + 'Intel, semiconductor, engineering, partnership, industry', + 'Ohio State University and Intel Corporation have announced a landmark $200 million ' + 'research partnership focused on next-generation semiconductor technology.', + 'Ohio State and Intel Corporation have announced a $200 million, 10-year research ' + 'partnership to advance semiconductor science and engineering.\n\n' + 'The collaboration will support research in chip design, manufacturing, materials science, ' + 'and workforce development, leveraging Intel\'s planned Ohio semiconductor fabrication ' + 'facilities in Licking County.\n\n' + '"This partnership is transformative for Ohio State and for the state of Ohio," said ' + 'Dean Ayanna Howard. "It connects world-class academic research directly to the largest ' + 'semiconductor investment in American history."\n\n' + 'The agreement includes joint faculty appointments, student internship pipelines, shared ' + 'laboratory facilities, and collaborative research grants.'), + ] + + for (title, cat, author, pub_date, featured, tags, summary, content) in articles: + a = NewsArticle( + title=title, + slug=slugify(title), + category=cat, + author=author, + published_date=pub_date, + featured=featured, + tags=tags, + summary=summary, + content=content, + view_count=0, + ) + db.session.add(a) + db.session.flush() + + # ───────────────────────────────────────────────────────────────────────── + # EVENTS + # ───────────────────────────────────────────────────────────────────────── + future = datetime(2024, 11, 1) + events = [ + ('Buckeyes vs. Michigan State Football', 'Sports', + future + timedelta(days=2), future + timedelta(days=2, hours=3, minutes=30), + 'Ohio Stadium', 'Ohio Stadium (The Horseshoe)', 'Columbus', 'OSU Athletics', False, 'Varies', + 'Come out to the Horseshoe as the Buckeyes take on the Michigan State Spartans in a key Big Ten ' + 'matchup. Enjoy pre-game festivities starting two hours before kickoff, including live music and ' + 'alumni tailgating. This is a sold-out game — tickets required.'), + ('Ohio State Research Forum: AI in Health Care', 'Lecture', + future + timedelta(days=5), future + timedelta(days=5, hours=2), + 'Biomedical Research Tower, Room 105', 'Biomedical Research Tower', 'Columbus', 'TDAI', True, 'Free', + 'Leading researchers from medicine, nursing, and computer science will present their work on ' + 'applying artificial intelligence to clinical decision support, medical imaging analysis, ' + 'and population health management. Lunch will be provided for registered attendees.'), + ('Annual Buckeye Career Fair — Engineering and Technology', 'Career', + future + timedelta(days=8), future + timedelta(days=8, hours=5), + 'Ohio Union', 'Ohio Union', 'Columbus', 'Engineering Career Services', True, 'Free', + 'Over 200 companies recruiting Ohio State students for internships, co-ops, and full-time ' + 'positions in engineering, technology, and related fields. Dress professionally and bring ' + 'multiple copies of your resume. Business casual or professional attire required.'), + ('Wexner Arts Center Performance: International Dance Festival', 'Arts', + future + timedelta(days=10), future + timedelta(days=10, hours=2), + 'Wexner Center for the Arts', 'Mershon Auditorium', 'Columbus', 'Wexner Center', False, '$15-35', + 'The Wexner Center\'s International Dance Festival presents world-renowned dance companies ' + 'in an evening of contemporary and classical works. This year features companies from ' + 'Brazil, South Korea, and France. Tickets available at the Wexner Center box office.'), + ('Ohio State Farmers Market', 'Social', + future + timedelta(days=12), future + timedelta(days=12, hours=3), + 'Tuttle Park Place', 'Tuttle Park', 'Columbus', 'OSU Sustainability Institute', False, 'Free', + 'The weekly Ohio State Farmers Market features fresh produce, artisan foods, and ' + 'handcrafted goods from local farmers and makers. Free to attend, cash and card accepted ' + 'at most vendors. Open to the campus community and Columbus neighbors.'), + ('Graduate Admissions Open House — College of Engineering', 'Lecture', + future + timedelta(days=15), future + timedelta(days=15, hours=3), + 'Dreese Laboratory Atrium', 'Dreese Lab', 'Columbus', 'Graduate School', True, 'Free', + 'Prospective graduate students in engineering are invited to learn about MS and PhD programs, ' + 'research opportunities, fellowship funding, and campus life at Ohio State. Department faculty ' + 'and current graduate students will be available for Q&A.'), + ('Infectious Disease Grand Rounds: Lessons from COVID-19', 'Lecture', + future + timedelta(days=18), future + timedelta(days=18, hours=1, minutes=30), + 'Meiling Hall Auditorium', 'Meiling Hall', 'Columbus', 'Infectious Disease Institute', False, 'Free', + 'Dr. Michael Oglesbee of the Infectious Disease Institute will lead a discussion on ' + 'surveillance, vaccine distribution, and pandemic preparedness lessons drawn from the ' + 'COVID-19 pandemic response. CME credit available for health care professionals.'), + ('Ohio State Women\'s Basketball Home Opener', 'Sports', + future + timedelta(days=20), future + timedelta(days=20, hours=2), + 'Value City Arena', 'Value City Arena', 'Columbus', 'OSU Athletics', False, 'Varies', + 'Cheer on the Buckeyes as the women\'s basketball team opens their home schedule at ' + 'Value City Arena. The team is led by experienced coach Kevin McGuff and returns several ' + 'key contributors from last year\'s NCAA Tournament team. Student tickets available.'), + ('Presidential Lecture Series: Dr. Ayanna Howard on Ethical AI', 'Lecture', + future + timedelta(days=23), future + timedelta(days=23, hours=1, minutes=30), + 'Research Commons, 18th Avenue Library', 'Libraries', 'Columbus', 'Office of Academic Affairs', False, 'Free', + 'Dean of Engineering Ayanna Howard presents a public lecture on the ethical dimensions ' + 'of artificial intelligence, drawing on her research in human-machine interaction and ' + 'algorithmic bias. This event is free and open to the campus community and the public.'), + ('Fisher College of Business Startup Competition Finals', 'Career', + future + timedelta(days=26), future + timedelta(days=26, hours=4), + 'Pfahl Hall Auditorium', 'Fisher Hall', 'Columbus', 'Fisher Entrepreneurship', True, 'Free', + 'Watch as student entrepreneurs pitch their companies to a panel of investors and business ' + 'leaders in the annual Fisher Startup Competition finals. Over $50,000 in prize money ' + 'and investment opportunities at stake. Open to the public — seats are limited.'), + ('Autumn Hike at Chadwick Arboretum', 'Social', + future + timedelta(days=29), future + timedelta(days=29, hours=2), + 'Chadwick Arboretum, 2001 Fyffe Court', 'Chadwick Arboretum', 'Columbus', 'Chadwick Arboretum', False, 'Free', + 'Enjoy a guided autumn hike through the Chadwick Arboretum with naturalist educators ' + 'highlighting seasonal plant changes, migratory birds, and sustainable landscaping practices. ' + 'Free and open to all — no registration required. Dogs welcome on leash.'), + ('Ohio Supercomputer Center Research Computing Workshop', 'Lecture', + future + timedelta(days=32), future + timedelta(days=32, hours=6), + 'Baker Systems Engineering Building', 'Baker Systems', 'Columbus', 'Ohio Supercomputer Center', True, 'Free', + 'Full-day workshop covering Ohio Supercomputer Center resources, job submission, ' + 'parallel programming, and GPU computing for research. Ideal for graduate students ' + 'and postdocs new to high-performance computing. Lunch provided for registered attendees.'), + ('CFAES Annual Farm Science Review', 'Social', + future + timedelta(days=40), future + timedelta(days=40, hours=8), + 'Molly Caren Agricultural Center', 'Molly Caren Agricultural Center', 'London', + 'CFAES Extension', False, '$15', + 'The Farm Science Review is one of the nation\'s largest agricultural shows, featuring ' + 'field demonstrations of the latest farm equipment, crop technologies, and agronomy ' + 'research. Held at the 1,000-acre Molly Caren Agricultural Center near London, Ohio.'), + ('Mental Health Awareness Week: Buckeye Wellness Fair', 'Health', + future + timedelta(days=7), future + timedelta(days=7, hours=4), + 'Ohio Union Great Hall', 'Ohio Union', 'Columbus', 'Student Life Counseling and Consultation Service', False, 'Free', + 'Ohio State\'s annual Mental Health Awareness Week features a wellness fair with ' + 'information about campus counseling resources, peer support programs, mindfulness ' + 'workshops, and interactive activities promoting mental well-being. All students welcome.'), + ('Law Review Symposium: Technology, Privacy, and the Law', 'Lecture', + future + timedelta(days=35), future + timedelta(days=35, hours=8), + 'Drinko Hall Auditorium', 'Moritz College of Law', 'Columbus', 'Ohio State Law Journal', True, 'Free', + 'The Ohio State Law Journal\'s annual symposium brings together leading scholars and ' + 'practitioners to examine legal frameworks around data privacy, surveillance, ' + 'algorithmic decision-making, and digital rights. CLE credit available.'), + ('Virtual Info Session: Online MPH Program', 'Virtual', + future + timedelta(days=14), future + timedelta(days=14, hours=1), + 'Zoom (link provided upon registration)', 'Online', 'Columbus', + 'College of Public Health', True, 'Free', + 'Learn about Ohio State\'s online Master of Public Health program, including curriculum, ' + 'admissions requirements, financial aid, and student experience. Faculty and current ' + 'students will be available to answer questions. Registration required to receive Zoom link.'), + ] + + for (title, cat, start, end, location, building, campus, organizer, reg_req, cost, desc) in events: + e = Event( + title=title, + description=desc, + start_datetime=start, + end_datetime=end, + location=location, + building=building, + campus=campus, + category=cat, + organizer=organizer, + registration_required=reg_req, + cost=cost, + ) + db.session.add(e) + db.session.flush() + + # ───────────────────────────────────────────────────────────────────────── + # BENCHMARK USERS + # ───────────────────────────────────────────────────────────────────────── + if not User.query.filter_by(email='alice@osu.edu').first(): + for username, name, email, role in [ + ('alice', 'Alice Anderson', 'alice@osu.edu', 'student'), + ('bob', 'Bob Baker', 'bob@osu.edu', 'student'), + ('carol', 'Carol Chen', 'carol@osu.edu', 'faculty'), + ('dave', 'Dave Davis', 'dave@osu.edu', 'staff'), + ]: + u = User(username=username, email=email, full_name=name, role=role, + password_hash=BENCHMARK_PASSWORD_HASH, + created_at=SEED_TIMESTAMP + timedelta(seconds=len(username))) + db.session.add(u) + db.session.flush() + + db.session.commit() diff --git a/sites/osu/static/css/.gitkeep b/sites/osu/static/css/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/osu/static/js/.gitkeep b/sites/osu/static/js/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/osu/tasks.jsonl b/sites/osu/tasks.jsonl new file mode 100644 index 00000000..d1f8008c --- /dev/null +++ b/sites/osu/tasks.jsonl @@ -0,0 +1,20 @@ +{"web_name":"Ohio State University","id":"Ohio State University--0","ques":"Open Academics and report the dean listed for Fisher College of Business.","web":"http://localhost:40020/","upstream_url":"https://www.osu.edu/","verifier_path":"sites/osu/verify/verify_0.py","judge_rubric":"FACT CHECKPOINTS: The same-origin Academics page must be visited. The answer must affirmatively identify Anil Makhija as Fisher College of Business dean. The complete database must remain unchanged."} +{"web_name":"Ohio State University","id":"Ohio State University--1","ques":"Open About and report the number displayed for Varsity Sports.","web":"http://localhost:40020/","upstream_url":"https://www.osu.edu/","verifier_path":"sites/osu/verify/verify_1.py","judge_rubric":"FACT CHECKPOINTS: The same-origin About page must be visited. The answer must bind 36 to Varsity Sports. The complete database must remain unchanged."} +{"web_name":"Ohio State University","id":"Ohio State University--2","ques":"Open Athletics, then visit both the football and wrestling team pages. Which conference do both pages list?","web":"http://localhost:40020/","upstream_url":"https://www.osu.edu/","verifier_path":"sites/osu/verify/verify_2.py","judge_rubric":"FACT CHECKPOINTS: Athletics must precede visible clicks to both exact football and wrestling details. The answer must affirmatively report Big Ten for both. The complete database must remain unchanged."} +{"web_name":"Ohio State University","id":"Ohio State University--3","ques":"From Athletics, open the Ohio State Buckeyes Football page and report the head coach and recent record.","web":"http://localhost:40020/","upstream_url":"https://www.osu.edu/","verifier_path":"sites/osu/verify/verify_3.py","judge_rubric":"FACT CHECKPOINTS: Athletics must precede a click to the exact football detail. The answer must bind Ryan Day to head coach and 11-2 to recent record. The complete database must remain unchanged."} +{"web_name":"Ohio State University","id":"Ohio State University--4","ques":"Search Ohio State for 'research expenditures', open the article about the $1.3 billion record, and report the expenditure amount and publication date.","web":"http://localhost:40020/","upstream_url":"https://www.osu.edu/","verifier_path":"sites/osu/verify/verify_4.py","judge_rubric":"FACT CHECKPOINTS: A same-origin search for research expenditures must precede a click to the exact record-expenditure article. The answer must report $1.3 billion and September 23, 2024. The complete database must remain unchanged."} +{"web_name":"Ohio State University","id":"Ohio State University--5","ques":"Open About and report both the university's founding year and its original institution name.","web":"http://localhost:40020/","upstream_url":"https://www.osu.edu/","verifier_path":"sites/osu/verify/verify_5.py","judge_rubric":"FACT CHECKPOINTS: About must be visited. The answer must bind 1870 and Ohio Agricultural and Mechanical College to the requested facts. The complete database must remain unchanged."} +{"web_name":"Ohio State University","id":"Ohio State University--6","ques":"Open Research, then the Translational Data Analytics Institute page. Report its director and all four focus areas shown.","web":"http://localhost:40020/","upstream_url":"https://www.osu.edu/","verifier_path":"sites/osu/verify/verify_6.py","judge_rubric":"FACT CHECKPOINTS: Research must precede a click to the exact TDAI detail. The answer must report Beth Plale and Data analytics, Machine learning, Health informatics, and Social science. The complete database must remain unchanged."} +{"web_name":"Ohio State University","id":"Ohio State University--7","ques":"Open About and report the undergraduate and graduate enrollment counts and the exact difference between them.","web":"http://localhost:40020/","upstream_url":"https://www.osu.edu/","verifier_path":"sites/osu/verify/verify_7.py","judge_rubric":"FACT CHECKPOINTS: About must be visited. The answer must bind 46,820 undergraduates, 14,000 graduate students, and difference 32,820. The complete database must remain unchanged."} +{"web_name":"Ohio State University","id":"Ohio State University--8","ques":"Open Academics and compare Engineering with Fisher College of Business. Report both undergraduate counts, which college has more, and the exact difference.","web":"http://localhost:40020/","upstream_url":"https://www.osu.edu/","verifier_path":"sites/osu/verify/verify_8.py","judge_rubric":"FACT CHECKPOINTS: Academics must be visited. The answer must bind 8,000 to Engineering, 4,500 to Fisher, identify Engineering as higher, and report difference 3,500. The complete database must remain unchanged."} +{"web_name":"Ohio State University","id":"Ohio State University--9","ques":"Use the Programs college filter for Engineering. Report all distinct degree types shown in those filtered results and how many distinct types there are.","web":"http://localhost:40020/","upstream_url":"https://www.osu.edu/","verifier_path":"sites/osu/verify/verify_9.py","judge_rubric":"FACT CHECKPOINTS: One /programs URL must contain college=engineering. The answer must report exactly the distinct types BS, MS, and PhD and count 3. The complete database must remain unchanged."} +{"web_name":"Ohio State University","id":"Ohio State University--10","ques":"From Athletics, open the Ohio State Buckeyes Wrestling page and report its head coach and home venue.","web":"http://localhost:40020/","upstream_url":"https://www.osu.edu/","verifier_path":"sites/osu/verify/verify_10.py","judge_rubric":"FACT CHECKPOINTS: Athletics must precede a click to wrestling detail. The answer must bind Tom Ryan to head coach and Covelli Center to home venue. The complete database must remain unchanged."} +{"web_name":"Ohio State University","id":"Ohio State University--11","ques":"Open Research, then the Ohio Supercomputer Center page. Report its director and founding year.","web":"http://localhost:40020/","upstream_url":"https://www.osu.edu/","verifier_path":"sites/osu/verify/verify_11.py","judge_rubric":"FACT CHECKPOINTS: Research must precede a click to Ohio Supercomputer Center. The answer must report David Bickel and 1987. The complete database must remain unchanged."} +{"web_name":"Ohio State University","id":"Ohio State University--12","ques":"Search Programs for 'Juris Doctor', open the JD detail page, and report its degree type, credit count, and duration.","web":"http://localhost:40020/","upstream_url":"https://www.osu.edu/","verifier_path":"sites/osu/verify/verify_12.py","judge_rubric":"FACT CHECKPOINTS: A /programs?q=Juris Doctor visit must precede a click to the exact JD detail. The answer must bind JD, 90 credits, and 3 years. The complete database must remain unchanged."} +{"web_name":"Ohio State University","id":"Ohio State University--13","ques":"Open Departments, then the Department of Mathematics page. Report the department chair and location.","web":"http://localhost:40020/","upstream_url":"https://www.osu.edu/","verifier_path":"sites/osu/verify/verify_13.py","judge_rubric":"FACT CHECKPOINTS: Departments must precede a click to the exact Mathematics detail. The answer must report James Cogdell and 100 Mathematics Building. The complete database must remain unchanged."} +{"web_name":"Ohio State University","id":"Ohio State University--14","ques":"From Athletics, open both the football and men's basketball team pages. Report the home venue for each team.","web":"http://localhost:40020/","upstream_url":"https://www.osu.edu/","verifier_path":"sites/osu/verify/verify_14.py","judge_rubric":"FACT CHECKPOINTS: Athletics must precede clicks to both football and men's basketball details. The answer must bind Ohio Stadium to football and Value City Arena to men's basketball. The complete database must remain unchanged."} +{"web_name":"Ohio State University","id":"Ohio State University--15","ques":"From Athletics, open the wrestling and fencing team pages. Report both national championship counts, identify which has more, and give the exact difference.","web":"http://localhost:40020/","upstream_url":"https://www.osu.edu/","verifier_path":"sites/osu/verify/verify_15.py","judge_rubric":"FACT CHECKPOINTS: Athletics must precede clicks to wrestling and fencing details. The answer must bind 8 titles to wrestling and 2 to fencing, identify wrestling as higher, and report difference 6. The complete database must remain unchanged."} +{"web_name":"Ohio State University","id":"Ohio State University--16","ques":"Use the Programs degree filter for MBA, open the Master of Business Administration page, and report its application deadline, credit count, and whether the GRE is required.","web":"http://localhost:40020/","upstream_url":"https://www.osu.edu/","verifier_path":"sites/osu/verify/verify_16.py","judge_rubric":"FACT CHECKPOINTS: One /programs URL must contain degree=MBA before the exact MBA detail is clicked. The answer must report April 1, 60 credits, and GRE Not Required. The complete database must remain unchanged."} +{"web_name":"Ohio State University","id":"Ohio State University--17","ques":"Search Ohio State for 'cancer research', open the cancer immunotherapy breakthrough news article, and report its exact title and author.","web":"http://localhost:40020/","upstream_url":"https://www.osu.edu/","verifier_path":"sites/osu/verify/verify_17.py","judge_rubric":"FACT CHECKPOINTS: A same-origin search for cancer research must precede a click to the exact cancer immunotherapy article. The answer must report the exact title and Jody Sheridan. The complete database must remain unchanged."} +{"web_name":"Ohio State University","id":"Ohio State University--18","ques":"Open Research, then the James Cancer Hospital and Solove Research Institute page. Report its director and all four focus areas shown.","web":"http://localhost:40020/","upstream_url":"https://www.osu.edu/","verifier_path":"sites/osu/verify/verify_18.py","judge_rubric":"FACT CHECKPOINTS: Research must precede a click to the exact James detail. The answer must report William Farrar and Cancer research, Oncology, Clinical trials, and Precision medicine. The complete database must remain unchanged."} +{"web_name":"Ohio State University","id":"Ohio State University--19","ques":"Open Research, then the Center for Clean Hydrogen page. Report its director, founding year, and all four focus areas shown.","web":"http://localhost:40020/","upstream_url":"https://www.osu.edu/","verifier_path":"sites/osu/verify/verify_19.py","judge_rubric":"FACT CHECKPOINTS: Research must precede a click to the exact Clean Hydrogen detail. The answer must report Yann Guezennec, 2022, and Hydrogen energy, Fuel cells, Green hydrogen, and Energy storage. The complete database must remain unchanged."} diff --git a/sites/osu/templates/404.html b/sites/osu/templates/404.html new file mode 100644 index 00000000..b47a84a4 --- /dev/null +++ b/sites/osu/templates/404.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block title %}Page Not Found — Ohio State{% endblock %} +{% block content %} +
+
404
+

Page Not Found

+

+ The page you're looking for doesn't exist or has been moved. +

+ +
+{% endblock %} diff --git a/sites/osu/templates/500.html b/sites/osu/templates/500.html new file mode 100644 index 00000000..a5ada03a --- /dev/null +++ b/sites/osu/templates/500.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% block title %}Server Error — Ohio State{% endblock %} +{% block content %} +
+
500
+

Internal Server Error

+

+ Something went wrong on our end. Please try again later. +

+ Return to Homepage +
+{% endblock %} diff --git a/sites/osu/templates/about.html b/sites/osu/templates/about.html new file mode 100644 index 00000000..aff3c553 --- /dev/null +++ b/sites/osu/templates/about.html @@ -0,0 +1,134 @@ +{% extends "base.html" %} +{% block title %}About Ohio State{% endblock %} +{% block content %} + +{% set banner = image_asset('about-education') %} +
{{ banner.alt }}
+ + +
+
+
{{ stats.founded }}
+
Year Founded
+
+
+
{{ (stats.undergrad_count + stats.grad_count) | int }}
+
Total Enrollment
+
+
+
600K+
+
Living Alumni
+
+
+
{{ stats.acres }}
+
Acres (Columbus Campus)
+
+
+ + +
+

Our History

+
+
+

The Ohio State University was established in 1870 as the Ohio Agricultural and Mechanical College under the Morrill Land-Grant Colleges Act. Since then, it has grown to become one of the largest universities in the United States.

+

The university's flagship Columbus campus spans 1,665 acres in the heart of Ohio's capital city, with additional regional campuses in Lima, Mansfield, Marion, Newark, and Wooster.

+

As a leading public research university, Ohio State has been at the forefront of discovery, producing Nobel laureates, Rhodes Scholars, Fulbright Scholars, and countless innovators who have shaped American society and the world.

+
+
+

Timeline Highlights

+
+ {% for year, event in [ + (1870, 'Founded as Ohio Agricultural and Mechanical College'), + (1878, 'Renamed The Ohio State University'), + (1916, 'Ohio Stadium built — The Horseshoe'), + (1930, 'Named to Association of American Universities'), + (1960, 'Jesse Owens wins 4 gold medals at Berlin Olympics (OSU alum)'), + (1967, 'Wexner Medical Center opens'), + (1980, 'National Supercomputer Center established'), + (1995, 'Online education programs launched'), + (2002, 'Football National Championship'), + (2014, 'Football National Championship'), + (2024, 'Research expenditure exceeds $1.3 billion') + ] %} +
+
{{ year }}
+
{{ event }}
+
+ {% endfor %} +
+
+
+
+ + +
+

Mission & Values

+
+
+

Mission

+

The Ohio State University advances the well-being of the people of Ohio and the global community through the creation and dissemination of knowledge.

+
+
+

Vision

+

To be the model 21st-century public research university — an institution that is as innovative, resourceful and diverse as the challenges we exist to solve.

+
+
+

Core Values

+
    +
  • ◆ Excellence in education
  • +
  • ◆ Research and discovery
  • +
  • ◆ Outreach and engagement
  • +
  • ◆ Diversity and inclusion
  • +
  • ◆ Integrity and ethics
  • +
+
+
+
+ + +
+

University Leadership

+
+ {% for title, name, desc in [ + ('President', 'Dr. Ted Carter', 'The president provides executive leadership and is the chief administrative officer of the university.'), + ('Provost', 'Dr. Melissa Gilliam', 'The provost is the chief academic officer, overseeing educational programs and research.'), + ('Board of Trustees', 'John W. Zeiger, Chair', 'The Board of Trustees governs the university and ensures it fulfills its mission.') + ] %} +
+
+
{{ title }}
+
{{ name }}
+

{{ desc }}

+
+
+ {% endfor %} +
+
+ + +
+

Ohio State by the Numbers

+
+ {% for num, label in [ + (stats.undergrad_count | int, 'Undergraduates'), + (stats.grad_count | int, 'Graduate Students'), + (stats.faculty_count | int, 'Faculty Members'), + (stats.degree_programs, 'Degree Programs'), + (stats.national_titles, 'National Athletic Titles'), + (stats.varsity_sports, 'Varsity Sports'), + (stats.extension_offices, 'Extension Offices'), + (stats.campuses, 'Campuses') + ] %} +
+
{{ num }}
+
{{ label }}
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/sites/osu/templates/academics.html b/sites/osu/templates/academics.html new file mode 100644 index 00000000..f2e045e7 --- /dev/null +++ b/sites/osu/templates/academics.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} +{% block title %}Academics — Ohio State{% endblock %} +{% block content %} + +{% set banner = image_asset('academics-undergraduate') %} +
{{ banner.alt }}
+ +
+ Ohio State is a top-20 public research university with world-class programs across arts, sciences, engineering, medicine, and more. +
+ +

Colleges & Schools

+
+ {% for college in colleges %} + {% set college_photo = college_image(college) %} +
+ {{ college_photo.alt }} +
+

{{ college.name }}

+

{{ college.description[:140] }}{% if college.description|length > 140 %}...{% endif %}

+
+ {% if college.dean %}
Dean: {{ college.dean }}
{% endif %} +
{{ college.undergrad_count | default(0) }} undergrad • {{ college.grad_count | default(0) }} graduate
+
Founded {{ college.founded_year }}
+
+ +
+
+ {% endfor %} +
+ +
+

Academic Resources

+
+
+
{{ total_programs }}
+
Degree Programs
+
+
+
{{ total_depts }}
+
Departments
+
+
+
{{ colleges|length }}
+
Colleges
+
+
+
6
+
Campuses
+
+
+
+{% endblock %} diff --git a/sites/osu/templates/account.html b/sites/osu/templates/account.html new file mode 100644 index 00000000..9f84bcd7 --- /dev/null +++ b/sites/osu/templates/account.html @@ -0,0 +1,75 @@ +{% extends "base.html" %} +{% block title %}My Account — Ohio State{% endblock %} +{% block content %} +
+

My Account

+

Welcome, {{ current_user.full_name or current_user.username }}!

+ + +
+
+
+
+ {{ (current_user.full_name or current_user.username)[0].upper() }} +
+
+
{{ current_user.full_name or current_user.username }}
+
{{ current_user.email }}
+
Member since {{ current_user.created_at.strftime('%B %Y') }}
+ {% if current_user.bio %}

{{ current_user.bio }}

{% endif %} +
+
+ {{ current_user.role | title }} +
+
+
+
+ + +

My Saved Items ({{ bookmark_details|length }})

+ {% if bookmark_details %} + {% for detail in bookmark_details %} +
+
+
+
+ {{ detail.bookmark.item_type }} + + {% if detail.bookmark.note %} +
Note: {{ detail.bookmark.note }}
+ {% endif %} +
Saved {{ detail.bookmark.created_at.strftime('%B %d, %Y') }}
+
+ + + + + + +
+
+
+ {% endfor %} + {% else %} +
+
+

No saved items yet

+

Bookmark programs, news, faculty, events, and more to find them here.

+ +
+ {% endif %} + +
+
+ + +
+
+
+{% endblock %} diff --git a/sites/osu/templates/admissions.html b/sites/osu/templates/admissions.html new file mode 100644 index 00000000..ddb2786a --- /dev/null +++ b/sites/osu/templates/admissions.html @@ -0,0 +1,110 @@ +{% extends "base.html" %} +{% block title %}Admissions — Ohio State{% endblock %} +{% block content %} + +{% set banner = image_asset('admissions-visit') %} +
{{ banner.alt }}
+ + +
+
+
{{ undergrad_programs }}
+
Undergraduate Programs
+
+
+
{{ grad_programs }}
+
Graduate Programs
+
+
+
{{ online_programs }}
+
Online Programs
+
+
+ + +
+

Undergraduate Admissions

+
+
+

Ohio State welcomes applications from students across Ohio, the nation, and the world. Our comprehensive admissions process evaluates academic achievement, personal character, and potential for success.

+

Application Requirements

+
    +
  • Completed online application
  • +
  • Official high school transcripts
  • +
  • ACT or SAT scores (optional for 2024-2025)
  • +
  • Letters of recommendation
  • +
  • Personal essay
  • +
  • $60 application fee
  • +
+ +
+
+

Key Dates

+ + + + + +
Early Action DeadlineNovember 1
Regular Decision DeadlineFebruary 1
Notification DateMarch – April
Enrollment Deposit DueMay 1
+
+
Middle 50% GPA: 3.6–4.0
+
Middle 50% ACT: 27–33
+
Middle 50% SAT: 1270–1490
+
+
+
+
+ + +
+

Graduate Admissions

+
+
+

Ohio State's Graduate School offers master's, doctoral, and professional degree programs in virtually every field. Applications are reviewed by individual programs.

+

General Requirements

+
    +
  • Bachelor's degree from an accredited institution
  • +
  • Minimum undergraduate GPA of 3.0
  • +
  • Official transcripts from all institutions
  • +
  • Statement of purpose
  • +
  • Letters of recommendation (typically 3)
  • +
  • GRE scores (program-specific)
  • +
  • English proficiency for international applicants
  • +
+ +
+ +
+
+ + +
+

Financial Aid & Scholarships

+

Ohio State is committed to making a world-class education accessible. Over 60% of students receive some form of financial assistance.

+ +
+{% endblock %} diff --git a/sites/osu/templates/athletics.html b/sites/osu/templates/athletics.html new file mode 100644 index 00000000..6b0ce7ba --- /dev/null +++ b/sites/osu/templates/athletics.html @@ -0,0 +1,95 @@ +{% extends "base.html" %} +{% block title %}Buckeye Athletics — Ohio State{% endblock %} +{% block content %} + +{% set banner = image_asset('athletics-football') %} +
{{ banner.alt }}
+ +
+
+
{{ teams|length }}Featured Teams
+
{{ men_teams|length }}Men's Teams
+
{{ women_teams|length }}Women's Teams
+
Big TenConference
+
+
+ +{% if men_teams %} +
+

Men's Sports

+
+ {% for team in men_teams %} + {% set team_photo = athletics_image(team) %} +
+ {% if team_photo %}{{ team_photo.alt }}{% endif %} +
+
+
+ {{ team.sport[:2].upper() }} +
+
+ +
{{ team.sport }}
+ {% if team.recent_record %}
Record: {{ team.recent_record }}
{% endif %} +
+
+ {% if team.national_titles > 0 %} +
🏆 {{ team.national_titles }} National Title{% if team.national_titles > 1 %}s{% endif %}
+ {% endif %} +
+
+ {% endfor %} +
+
+{% endif %} + +{% if women_teams %} +
+

Women's Sports

+
+ {% for team in women_teams %} + {% set team_photo = athletics_image(team) %} +
+ {% if team_photo %}{{ team_photo.alt }}{% endif %} +
+
+
+ {{ team.sport[:2].upper() }} +
+
+ +
{{ team.sport }}
+ {% if team.recent_record %}
Record: {{ team.recent_record }}
{% endif %} +
+
+ {% if team.national_titles > 0 %} +
🏆 {{ team.national_titles }} National Title{% if team.national_titles > 1 %}s{% endif %}
+ {% endif %} +
+
+ {% endfor %} +
+
+{% endif %} + +{% if coed_teams %} +
+

Co-ed Sports

+
+ {% for team in coed_teams %} +
+
+ +
{{ team.sport }}
+
+
+ {% endfor %} +
+
+{% endif %} +{% endblock %} diff --git a/sites/osu/templates/athletics_team.html b/sites/osu/templates/athletics_team.html new file mode 100644 index 00000000..b49b93ea --- /dev/null +++ b/sites/osu/templates/athletics_team.html @@ -0,0 +1,84 @@ +{% extends "base.html" %} +{% block title %}{{ team.name }} — Ohio State Athletics{% endblock %} +{% block content %} + + +{% set team_photo = athletics_image(team) %} +
+
+ {% if team_photo %}{{ team_photo.alt }}{% endif %} +
+
+ {{ team.sport[:2].upper() }} +
+
+

{{ team.name }}

+

{{ team.sport }} • {{ team.gender }}

+

{{ team.conference }}

+
+
+ + {% if team.national_titles > 0 %} +
+ 🏆 + {{ team.national_titles }} National Championship{% if team.national_titles > 1 %}s{% endif %} +
+ {% endif %} + +
+
+ {% if team.coach %} +
Head Coach
{{ team.coach }}
+ {% endif %} + {% if team.home_venue %} +
Home Venue
{{ team.home_venue }}
+ {% endif %} + {% if team.recent_record %} +
Recent Record
{{ team.recent_record }}
+ {% endif %} +
Conference
{{ team.conference }}
+
+
+ +
+

About {{ team.name }}

+

+ The Ohio State {{ team.name }} compete in the {{ team.conference }} conference in {{ team.sport }}. + {% if team.coach %}The team is led by Head Coach {{ team.coach }}.{% endif %} + {% if team.home_venue %}Home games are played at {{ team.home_venue }}.{% endif %} + {% if team.national_titles > 0 %}The Buckeyes have won {{ team.national_titles }} national championship{% if team.national_titles > 1 %}s{% endif %} in this sport.{% endif %} +

+
+ + {% if current_user.is_authenticated %} +
+ + + + + +
+ {% endif %} +
+ + +
+{% endblock %} diff --git a/sites/osu/templates/base.html b/sites/osu/templates/base.html new file mode 100644 index 00000000..953cbf56 --- /dev/null +++ b/sites/osu/templates/base.html @@ -0,0 +1,287 @@ + + + + + + + {% block title %}The Ohio State University{% endblock %} + + {% block extra_head %}{% endblock %} + + + + +
+
+ The Ohio State University — Columbus, Ohio 43210 +
+ {% if current_user.is_authenticated %} + My Account +
+ + +
+ {% else %} + Sign In + Register + {% endif %} + Search +
+
+
+ + + + + + + + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + + +
+ {% block content %}{% endblock %} +
+ + + + + {% block extra_js %}{% endblock %} + + diff --git a/sites/osu/templates/department_detail.html b/sites/osu/templates/department_detail.html new file mode 100644 index 00000000..627166d2 --- /dev/null +++ b/sites/osu/templates/department_detail.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} +{% block title %}{{ dept.name }} — Ohio State{% endblock %} +{% block content %} + + +{% set department_photo = image_asset('academics-undergraduate') %} +
+
+ {{ department_photo.alt }} +

{{ dept.name }}

+ {% if dept.college %}

{{ dept.college.name }}

{% endif %} + +
+
+ {% for paragraph in dept.description.split('\n\n') %} +

{{ paragraph }}

+ {% endfor %} +
+
+ + {% if programs %} +
+

Degree Programs

+
+ {% for prog in programs %} +
+
+ {{ prog.degree_type }} + {% if prog.is_online %}Online{% endif %} + +
{{ prog.units }} credits • {{ prog.duration_years }} years
+
+
+ {% endfor %} +
+
+ {% endif %} + + {% if faculty_list %} +
+

Faculty

+
+ {% for member in faculty_list %} +
+
+ +
{{ member.title }}
+ {% if member.email %}{% endif %} + {% if member.research_interests %}
{{ member.research_interests[:80] }}
{% endif %} +
+
+ {% endfor %} +
+
+ {% endif %} +
+ + +
+{% endblock %} diff --git a/sites/osu/templates/departments.html b/sites/osu/templates/departments.html new file mode 100644 index 00000000..8ef8cf69 --- /dev/null +++ b/sites/osu/templates/departments.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block title %}Departments — Ohio State{% endblock %} +{% block content %} + +{% set banner = image_asset('academics-undergraduate') %} +
{{ banner.alt }}
+ +{% for college, depts in depts_by_college.items() %} +{% if depts %} +
+

+ {{ college.name }} + {% if college.dean %}Dean: {{ college.dean }}{% endif %} +

+
+ {% for dept in depts %} +
+
+

{{ dept.name }}

+ {% if dept.chair %}
Chair: {{ dept.chair }}
{% endif %} + {% if dept.location %}
📍 {{ dept.location }}
{% endif %} +

{{ dept.description[:100] }}{% if dept.description|length > 100 %}...{% endif %}

+ +
+
+ {% endfor %} +
+
+{% endif %} +{% endfor %} +{% endblock %} diff --git a/sites/osu/templates/event_detail.html b/sites/osu/templates/event_detail.html new file mode 100644 index 00000000..5d3f8749 --- /dev/null +++ b/sites/osu/templates/event_detail.html @@ -0,0 +1,90 @@ +{% extends "base.html" %} +{% block title %}{{ event.title }} — Ohio State Events{% endblock %} +{% block content %} + + +{% set event_photo = image_asset('campus-life') %} +
+
+ {{ event_photo.alt }} + {{ event.category }} + {% if event.registration_required %}Registration Required{% endif %} +

{{ event.title }}

+ +
+
+
+
Date & Time
+
{{ event.start_datetime.strftime('%A, %B %d, %Y') }}
+
{{ event.start_datetime.strftime('%I:%M %p') }}{% if event.end_datetime %} – {{ event.end_datetime.strftime('%I:%M %p') }}{% endif %}
+
+ {% if event.location %} +
+
Location
+
{{ event.location }}
+ {% if event.building %}
{{ event.building }}
{% endif %} +
{{ event.campus }} Campus
+
+ {% endif %} + {% if event.organizer %} +
+
Organizer
+
{{ event.organizer }}
+
+ {% endif %} +
+
Cost
+
{{ event.cost or 'Free' }}
+
+
+
+ +
+

About This Event

+
+ {% for paragraph in event.description.split('\n\n') %} +

{{ paragraph }}

+ {% endfor %} +
+
+ + {% if current_user.is_authenticated %} +
+ + + + + +
+ {% endif %} +
+ + +
+{% endblock %} diff --git a/sites/osu/templates/events.html b/sites/osu/templates/events.html new file mode 100644 index 00000000..c98ec377 --- /dev/null +++ b/sites/osu/templates/events.html @@ -0,0 +1,107 @@ +{% extends "base.html" %} +{% block title %}Events — Ohio State{% endblock %} +{% block content %} + +{% set banner = image_asset('campus-life') %} +
{{ banner.alt }}
+ + +{% endblock %} diff --git a/sites/osu/templates/faculty.html b/sites/osu/templates/faculty.html new file mode 100644 index 00000000..42708660 --- /dev/null +++ b/sites/osu/templates/faculty.html @@ -0,0 +1,84 @@ +{% extends "base.html" %} +{% block title %}Faculty — Ohio State{% endblock %} +{% block content %} + +{% set banner = image_asset('about-education') %} +
{{ banner.alt }}
+ + +{% endblock %} diff --git a/sites/osu/templates/faculty_profile.html b/sites/osu/templates/faculty_profile.html new file mode 100644 index 00000000..0dd00e89 --- /dev/null +++ b/sites/osu/templates/faculty_profile.html @@ -0,0 +1,97 @@ +{% extends "base.html" %} +{% block title %}{{ member.name }} — Ohio State Faculty{% endblock %} +{% block content %} + + +{% set faculty_photo = image_asset('about-education') %} +
+
+ {{ faculty_photo.alt }} +
+
+ {{ member.name[0] }} +
+
+

{{ member.name }}

+

{{ member.title }}

+ {% if member.department %}

{{ member.department.name }}{% if member.department.college %}, {{ member.department.college.name }}{% endif %}

{% endif %} + {% if member.is_emeritus %}Professor Emeritus{% endif %} +
+
+ +
+
+ {% if member.email %} + + {% endif %} + {% if member.phone %} +
Phone
{{ member.phone }}
+ {% endif %} + {% if member.office %} +
Office
{{ member.office }}
+ {% endif %} +
+
+ + {% if member.research_interests %} +
+

Research Interests

+
+ {% for interest in member.research_interests.split(',') %} + {{ interest.strip() }} + {% endfor %} +
+
+ {% endif %} + + {% if member.bio %} +
+

Biography

+
+ {% for paragraph in member.bio.split('\n\n') %} +

{{ paragraph }}

+ {% endfor %} +
+
+ {% endif %} + + {% if current_user.is_authenticated %} +
+ + + + + +
+ {% endif %} +
+ + +
+{% endblock %} diff --git a/sites/osu/templates/index.html b/sites/osu/templates/index.html new file mode 100644 index 00000000..fb08fb1e --- /dev/null +++ b/sites/osu/templates/index.html @@ -0,0 +1,137 @@ +{% extends "base.html" %} +{% block title %}The Ohio State University{% endblock %} +{% block content %} +{% set hero_image = image_asset('home-hero') %} +
+ {{ hero_image.alt }} +
+

The Ohio State University

+

A top-20 public research university and home to over 60,000 students and world-class faculty. Go Bucks!

+ +
+
+ + +
+
+
{{ stats.undergrad_count | default(46820) | int }}Undergraduates
+
{{ stats.grad_count | default(14000) | int }}Graduate Students
+
{{ stats.degree_programs | default(500) }}Degree Programs
+
{{ stats.faculty_count | default(7000) | int }}Faculty
+
#{{ stats.fulbright_rank | default(1) }}Fulbright Scholars
+
${{ stats.research_expenditure | default(1.3) }}BResearch Expenditure
+
{{ stats.campuses | default(6) }}Campuses
+
+
+ + +{% if featured_news %} +
+

Latest News

+
+ {% for article in featured_news[:3] %} + {% set article_image = news_image(article) %} +
+ {{ article_image.alt }} +
+
+ {{ article.category }} + {{ article.published_date.strftime('%B %d, %Y') }} +
+ +

{{ article.summary[:150] }}{% if article.summary|length > 150 %}...{% endif %}

+ +
+
+ {% endfor %} +
+ +
+{% endif %} + + +{% if upcoming_events %} +
+

Upcoming Events

+
+ {% for event in upcoming_events %} +
+
+
+
+
{{ event.start_datetime.strftime('%b') }}
+
{{ event.start_datetime.strftime('%d') }}
+
+
+ +
{{ event.start_datetime.strftime('%I:%M %p') }} • {{ event.location }}
+
{{ event.category }}
+
+
+
+
+ {% endfor %} +
+ +
+{% endif %} + + +{% if recent_research %} +
+

Research Excellence

+
+ {% for center in recent_research %} + {% set center_image = research_image(center) %} +
+ {{ center_image.alt }} +
+ +

{{ center.description[:100] }}{% if center.description|length > 100 %}...{% endif %}

+
+
+ {% endfor %} +
+ +
+{% endif %} + + +
+

Explore Ohio State

+ +
+{% endblock %} diff --git a/sites/osu/templates/login.html b/sites/osu/templates/login.html new file mode 100644 index 00000000..b22a91a7 --- /dev/null +++ b/sites/osu/templates/login.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% block title %}Sign In — Ohio State{% endblock %} +{% block content %} +
+
+
+

Sign In

+

Access your Ohio State account

+
+ +
+
+
+ {{ form.hidden_tag() }} + +
+ {{ form.email.label }} + {{ form.email(placeholder='your.name@osu.edu', autocomplete='email') }} + {% for error in form.email.errors %} +
{{ error }}
+ {% endfor %} +
+ +
+ {{ form.password.label }} + {{ form.password(placeholder='Password', autocomplete='current-password') }} + {% for error in form.password.errors %} +
{{ error }}
+ {% endfor %} +
+ + +
+ +
+ Don't have an account? Register here +
+
+
+ +
+ Use the account credentials supplied by your assigned task. +
+
+{% endblock %} diff --git a/sites/osu/templates/news.html b/sites/osu/templates/news.html new file mode 100644 index 00000000..6ce74c92 --- /dev/null +++ b/sites/osu/templates/news.html @@ -0,0 +1,102 @@ +{% extends "base.html" %} +{% block title %}News — Ohio State{% endblock %} +{% block content %} + +{% set banner = image_asset('news-campus') %} +
{{ banner.alt }}
+ + +{% endblock %} diff --git a/sites/osu/templates/news_article.html b/sites/osu/templates/news_article.html new file mode 100644 index 00000000..462b191d --- /dev/null +++ b/sites/osu/templates/news_article.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} +{% block title %}{{ article.title }} — Ohio State News{% endblock %} +{% block content %} + + +{% set article_photo = news_image(article) %} + +{% endblock %} diff --git a/sites/osu/templates/program_detail.html b/sites/osu/templates/program_detail.html new file mode 100644 index 00000000..f42fba17 --- /dev/null +++ b/sites/osu/templates/program_detail.html @@ -0,0 +1,93 @@ +{% extends "base.html" %} +{% block title %}{{ program.name }} — Ohio State{% endblock %} +{% block content %} + + +{% set program_photo = image_asset('academics-graduate' if program.degree_type in ['MA', 'MS', 'MBA', 'MPH', 'PhD'] else 'academics-undergraduate') %} +
+
+ {{ program_photo.alt }} +
+ {{ program.degree_type }} + {% if program.is_online %}Online Available{% endif %} +
+

{{ program.name }}

+ {% if program.college %}

{{ program.college.name }}

{% endif %} + {% if program.department %}

Department of {{ program.department.name }}

{% endif %} + +
+

Program Overview

+
+ {% for paragraph in program.description.split('\n\n') %} +

{{ paragraph }}

+ {% endfor %} +
+
+ + {% if program.requirements %} +
+

Requirements

+
+ {% for line in program.requirements.split('\n') %} + {% if line.strip() %}

{{ line }}

{% endif %} + {% endfor %} +
+
+ {% endif %} + + {% if related %} +
+

Related Programs

+
+ {% for rel in related %} +
+
+ {{ rel.degree_type }} + +
+
+ {% endfor %} +
+
+ {% endif %} +
+ + +
+{% endblock %} diff --git a/sites/osu/templates/programs.html b/sites/osu/templates/programs.html new file mode 100644 index 00000000..b03bc49d --- /dev/null +++ b/sites/osu/templates/programs.html @@ -0,0 +1,100 @@ +{% extends "base.html" %} +{% block title %}Degree Programs — Ohio State{% endblock %} +{% block content %} + +{% set banner = image_asset('academics-graduate') %} +
{{ banner.alt }}
+ + +{% endblock %} diff --git a/sites/osu/templates/register.html b/sites/osu/templates/register.html new file mode 100644 index 00000000..12a2912a --- /dev/null +++ b/sites/osu/templates/register.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} +{% block title %}Register — Ohio State{% endblock %} +{% block content %} +
+
+
+

Create Account

+

Join The Ohio State University community

+
+ +
+
+
+ {{ form.hidden_tag() }} + +
+ {{ form.username.label }} + {{ form.username(placeholder='buckeyefan', autocomplete='username') }} + {% for error in form.username.errors %} +
{{ error }}
+ {% endfor %} +
+ +
+ {{ form.full_name.label }} + {{ form.full_name(placeholder='Jane Doe', autocomplete='name') }} + {% for error in form.full_name.errors %} +
{{ error }}
+ {% endfor %} +
+ +
+ {{ form.email.label }} + {{ form.email(placeholder='your.name@osu.edu', autocomplete='email') }} + {% for error in form.email.errors %} +
{{ error }}
+ {% endfor %} +
+ +
+ {{ form.password.label }} + {{ form.password(placeholder='At least 8 characters', autocomplete='new-password') }} + {% for error in form.password.errors %} +
{{ error }}
+ {% endfor %} +
+ +
+ {{ form.confirm.label }} + {{ form.confirm(placeholder='Repeat password', autocomplete='new-password') }} + {% for error in form.confirm.errors %} +
{{ error }}
+ {% endfor %} +
+ + +
+ +
+ Already have an account? Sign in +
+
+
+
+{% endblock %} diff --git a/sites/osu/templates/research.html b/sites/osu/templates/research.html new file mode 100644 index 00000000..60d23b8c --- /dev/null +++ b/sites/osu/templates/research.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} +{% block title %}Research — Ohio State{% endblock %} +{% block content %} + +{% set banner = image_asset('research-hero') %} +
{{ banner.alt }}
+ +
+
+
$1.3BAnnual Research Expenditure
+
{{ centers|length }}Research Centers & Institutes
+
#1Fulbright Scholars Producing Institution
+
88Extension County Offices
+
+
+ +

Research Centers & Institutes

+
+ {% for center in centers %} + {% set center_photo = research_image(center) %} +
+ {{ center_photo.alt }} +
+

{{ center.name }}

+ {% if center.college %}
{{ center.college.name }}
{% endif %} +

{{ center.description[:160] }}{% if center.description|length > 160 %}...{% endif %}

+ {% if center.focus_areas %} +
+ {% for area in center.focus_areas.split(',')[:3] %} + {{ area.strip() }} + {% endfor %} +
+ {% endif %} +
+ {% if center.director %}Director: {{ center.director }}
{% endif %} + Founded {{ center.founded_year }} +
+ +
+
+ {% endfor %} +
+ +{% if colleges %} +
+

Research by College

+
+ {% for college in colleges %} + {% if college.research_centers %} +
+

{{ college.name }}

+ {% for rc in college.research_centers %} + {{ rc.name }} + {% endfor %} +
+ {% endif %} + {% endfor %} +
+
+{% endif %} +{% endblock %} diff --git a/sites/osu/templates/research_center.html b/sites/osu/templates/research_center.html new file mode 100644 index 00000000..a712f672 --- /dev/null +++ b/sites/osu/templates/research_center.html @@ -0,0 +1,75 @@ +{% extends "base.html" %} +{% block title %}{{ center.name }} — Ohio State Research{% endblock %} +{% block content %} + + +{% set center_photo = research_image(center) %} +
+
+ {{ center_photo.alt }} +

{{ center.name }}

+ {% if center.college %}

{{ center.college.name }}

{% endif %} + + {% if center.focus_areas %} +
+ {% for area in center.focus_areas.split(',') %} + {{ area.strip() }} + {% endfor %} +
+ {% endif %} + +
+

About This Center

+
+ {% for paragraph in center.description.split('\n\n') %} +

{{ paragraph }}

+ {% endfor %} +
+
+ + {% if current_user.is_authenticated %} +
+ + + + + +
+ {% endif %} + + {% if related %} +
+

Related Centers

+
+ {% for rel in related %} +
+
+ +

{{ rel.description[:100] }}...

+
+
+ {% endfor %} +
+
+ {% endif %} +
+ + +
+{% endblock %} diff --git a/sites/osu/templates/search.html b/sites/osu/templates/search.html new file mode 100644 index 00000000..72854e31 --- /dev/null +++ b/sites/osu/templates/search.html @@ -0,0 +1,141 @@ +{% extends "base.html" %} +{% block title %}Search — Ohio State{% endblock %} +{% block content %} +

Search Ohio State

+ +
+ + +
+ +{% if q %} +

+ Found {{ total }} result{% if total != 1 %}s{% endif %} for “{{ q }}” +

+ +{% if results.programs %} +
+

Degree Programs ({{ results.programs|length }})

+
+ {% for prog in results.programs %} +
+
+ {{ prog.degree_type }} + + {% if prog.college %}
{{ prog.college.name }}
{% endif %} +
+
+ {% endfor %} +
+ +
+{% endif %} + +{% if results.news %} +
+

News ({{ results.news|length }})

+ {% for article in results.news %} +
+
{{ article.category }} {{ article.published_date.strftime('%b %d, %Y') }}
+ {{ article.title }} +

{{ article.summary[:150] }}{% if article.summary|length > 150 %}...{% endif %}

+
+ {% endfor %} + +
+{% endif %} + +{% if results.faculty %} +
+

Faculty ({{ results.faculty|length }})

+
+ {% for member in results.faculty %} +
+
+ +
{{ member.title }}
+ {% if member.department %}
{{ member.department.name }}
{% endif %} +
+
+ {% endfor %} +
+ +
+{% endif %} + +{% if results.events %} +
+

Events ({{ results.events|length }})

+ {% for event in results.events %} +
+
+
+
{{ event.start_datetime.strftime('%b') }}
+
{{ event.start_datetime.strftime('%d') }}
+
+
+ {{ event.title }} +
{{ event.location }} • {{ event.category }}
+
+
+
+ {% endfor %} + +
+{% endif %} + +{% if results.research %} +
+

Research Centers ({{ results.research|length }})

+
+ {% for center in results.research %} +
+
+ +

{{ center.description[:100] }}...

+
+
+ {% endfor %} +
+
+{% endif %} + +{% if results.athletics %} +
+

Athletics ({{ results.athletics|length }})

+
+ {% for team in results.athletics %} +
+
+ +
{{ team.sport }} • {{ team.gender }}
+
+
+ {% endfor %} +
+
+{% endif %} + +{% if total == 0 %} +
+
🔍
+

No results found

+

Try different keywords or browse our site using the navigation above.

+
+{% endif %} + +{% else %} +
+

What are you looking for?

+

Search across programs, news, faculty, events, research, and athletics.

+ +
+{% endif %} +{% endblock %} diff --git a/sites/osu/verify/README.md b/sites/osu/verify/README.md new file mode 100644 index 00000000..6e9a37ad --- /dev/null +++ b/sites/osu/verify/README.md @@ -0,0 +1,17 @@ +# Ohio State verifier suite + +The OSU mirror has 20 deterministic task verifiers, one per row in `sites/osu/tasks.jsonl`. + +Each verifier requires the expected task ID, a non-empty answer, same-origin navigation on the configured loopback origin, the task-specific path/query/click sequence, affirmative answer facts bound to the requested entity, and a complete unchanged SQLite database. All current OSU tasks are read-only. + +Run the regression suite from the repository root: + +```bash +uv run --with Flask==3.1.0 --with Flask-SQLAlchemy==3.1.1 --with Flask-WTF==1.2.2 --with Flask-Bcrypt==1.0.1 --with Flask-Login==0.6.3 --with email-validator==2.2.0 python -m unittest sites.osu.verify.test_verifiers sites.osu.verify.test_environment_quality sites.osu.verify.test_app -v +``` + +The tests include positive cases and controls for answer-only runs, wrong task IDs, external-origin URLs, database mutations, missing filters, negated answers, swapped comparison values, and missing or altered image files. + +## Image assets + +The mirror uses 19 photographs crawled from official Ohio State web properties. `sites/osu/image_sources.json` records each source page, source URL, dimensions, alt text, and source/output SHA-256. `sites/osu/fetch_images.py` reproduces the normalized WebP files. The binaries are distributed as `osu.tar.gz` from the Hugging Face asset revision pinned in `.assets-revision`. diff --git a/sites/osu/verify/TASK_REVIEW.md b/sites/osu/verify/TASK_REVIEW.md new file mode 100644 index 00000000..b976eb98 --- /dev/null +++ b/sites/osu/verify/TASK_REVIEW.md @@ -0,0 +1,28 @@ +# OSU task review + +All 20 tasks were re-grounded against the tracked seed source and the generated SQLite seed. Task URLs use OSU's current site index 20 and port `40020` after integration with current main. + +| Task | Required visible workflow | Ground truth | +|---:|---|---| +| 0 | Academics | Fisher dean: Anil Makhija | +| 1 | About | Varsity Sports displayed: 36 | +| 2 | Athletics and two team details | Football and wrestling: Big Ten | +| 3 | Athletics to football | Ryan Day; 11-2 | +| 4 | Search to expenditure article | $1.3 billion; September 23, 2024 | +| 5 | About | 1870; Ohio Agricultural and Mechanical College | +| 6 | Research to TDAI | Beth Plale; four focus areas | +| 7 | About | 46,820; 14,000; difference 32,820 | +| 8 | Academics comparison | Engineering 8,000; Fisher 4,500; difference 3,500 | +| 9 | Engineering programs filter | BS, MS, PhD; three types | +| 10 | Athletics to wrestling | Tom Ryan; Covelli Center | +| 11 | Research to OSC | David Bickel; 1987 | +| 12 | Programs search to JD | JD; 90 credits; 3 years | +| 13 | Departments to Mathematics | James Cogdell; 100 Mathematics Building | +| 14 | Athletics and two team details | Football: Ohio Stadium; men's basketball: Value City Arena | +| 15 | Athletics comparison | Wrestling 8; fencing 2; difference 6 | +| 16 | MBA degree filter and detail | April 1; 60 credits; GRE not required | +| 17 | Search to cancer article | Exact title; Jody Sheridan | +| 18 | Research to James | William Farrar; four focus areas | +| 19 | Research to Clean Hydrogen | Yann Guezennec; 2022; four focus areas | + +Read-only task verification compares every non-SQLite internal table before and after execution. Navigation checks parse URLs and require the same loopback origin, exact normalized paths, exact required query values, operation order, and visible-link click transitions where requested. diff --git a/sites/osu/verify/selfcheck.py b/sites/osu/verify/selfcheck.py new file mode 100644 index 00000000..8350f2ce --- /dev/null +++ b/sites/osu/verify/selfcheck.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +"""Run the complete OSU verifier, application, and environment regression suite.""" +from __future__ import annotations +import sys +import unittest +from pathlib import Path + +REPO_ROOT=Path(__file__).resolve().parents[3] +sys.path.insert(0,str(REPO_ROOT)) + +if __name__=='__main__': + suite=unittest.defaultTestLoader.loadTestsFromNames([ + 'sites.osu.verify.test_verifiers', + 'sites.osu.verify.test_environment_quality', + 'sites.osu.verify.test_app', + ]) + result=unittest.TextTestRunner(verbosity=2).run(suite) + raise SystemExit(0 if result.wasSuccessful() else 1) diff --git a/sites/osu/verify/test_app.py b/sites/osu/verify/test_app.py new file mode 100644 index 00000000..6b849dd0 --- /dev/null +++ b/sites/osu/verify/test_app.py @@ -0,0 +1,57 @@ +"""HTTP regression tests for the OSU mirror.""" +from __future__ import annotations +import importlib,os,re,shutil,sqlite3,sys,unittest +from pathlib import Path +from sites.osu.verify.test_support import ensure_seed +SITE=Path(__file__).resolve().parents[1];SEED=ensure_seed();RUNTIME=SITE/'instance/osu.db' +def csrf(response): + m=re.search(rb'name="csrf_token"[^>]*value="([^"]+)"|value="([^"]+)"[^>]*name="csrf_token"',response.data);assert m,response.request.path;return (m.group(1) or m.group(2)).decode() +class AppTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + shutil.rmtree(SITE/'instance',ignore_errors=True);(SITE/'instance').mkdir();shutil.copy2(SEED,RUNTIME);os.environ['OSU_SECRET_KEY']='test-only';sys.path.insert(0,str(SITE));cls.module=importlib.import_module('app');cls.app=cls.module.app;cls.app.config.update(TESTING=True) + @classmethod + def tearDownClass(cls): + with cls.app.app_context():cls.module.db.session.remove() + shutil.rmtree(SITE/'instance',ignore_errors=True) + def setUp(self):self.client=self.app.test_client() + def snap(self): + c=sqlite3.connect(RUNTIME) + try: + ts=[r[0] for r in c.execute("select name from sqlite_master where type='table' and name not like 'sqlite_%' order by name")];return {t:c.execute(f'select * from "{t}" order by rowid').fetchall() for t in ts} + finally:c.close() + def login(self,next_url=None): + route='/login'+(f'?next={next_url}' if next_url else '');p=self.client.get(route);return self.client.post(route,data={'csrf_token':csrf(p),'email':'alice@osu.edu','password':'test1234'},follow_redirects=False) + def test_all_routes_render(self): + paths=['/','/about','/academics','/programs','/programs?college=engineering','/research','/departments','/faculty','/news','/events','/athletics','/admissions','/search?q=cancer+research','/login','/register','/_health'] + with self.app.app_context(): + paths += [f'/programs/{x.slug}' for x in self.module.Program.query.all()];paths += [f'/research/{x.slug}' for x in self.module.ResearchCenter.query.all()];paths += [f'/departments/{x.slug}' for x in self.module.Department.query.all()];paths += [f'/faculty/{x.slug}' for x in self.module.Faculty.query.all()];paths += [f'/news/{x.slug}' for x in self.module.NewsArticle.query.all()];paths += [f'/events/{x.id}' for x in self.module.Event.query.all()];paths += [f'/athletics/{x.slug}' for x in self.module.AthleticTeam.query.all()] + for path in paths: + with self.subTest(path=path):self.assertEqual(self.client.get(path).status_code,200) + def test_real_image_assets_are_served(self): + import json + for item in json.loads((SITE/'image_sources.json').read_text())['images']: + with self.subTest(file=item['file']): + response=self.client.get('/static/images/'+item['file']);self.assertEqual(response.status_code,200);self.assertEqual(response.mimetype,'image/webp');self.assertGreater(len(response.get_data()),5000);response.close() + def test_get_routes_are_read_only(self): + before=self.snap() + for p in ('/','/news/ohio-state-researchers-develop-breakthrough-cancer-immunotherapy','/about','/search?q=cancer+research','/events'):self.assertEqual(self.client.get(p).status_code,200) + self.assertEqual(before,self.snap()) + def test_logout_and_csrf(self): + self.assertEqual(self.client.get('/logout').status_code,405) + for p in ('/logout','/bookmark/add','/bookmark/remove'): + with self.subTest(p=p):self.assertEqual(self.client.post(p).status_code,400) + def test_open_redirect_rejected_and_login_works(self): + r=self.login('//evil.invalid');self.assertEqual(r.status_code,302);self.assertEqual(r.headers['Location'],'/') + def test_invalid_bookmarks_rejected(self): + self.login();p=self.client.get('/programs/juris-doctor-jd');tok=csrf(p) + self.assertEqual(self.client.post('/bookmark/add',data={'csrf_token':tok,'item_type':'invalid','item_id':'1'}).status_code,400) + p=self.client.get('/programs/juris-doctor-jd');self.assertEqual(self.client.post('/bookmark/add',data={'csrf_token':csrf(p),'item_type':'program','item_id':'99999'}).status_code,404) + def test_valid_bookmark_and_duplicate_are_single_row(self): + self.login();p=self.client.get('/programs/juris-doctor-jd');data={'csrf_token':csrf(p),'item_type':'program','item_id':'14','next':'/programs/juris-doctor-jd'};self.assertEqual(self.client.post('/bookmark/add',data=data).status_code,302) + p=self.client.get('/programs/juris-doctor-jd');data['csrf_token']=csrf(p);self.assertEqual(self.client.post('/bookmark/add',data=data).status_code,302) + c=sqlite3.connect(RUNTIME);self.assertEqual(c.execute("select count(*) from bookmarks where user_id=1 and item_type='program' and item_id=14").fetchone()[0],1);c.close() + def test_upcoming_events_visible_with_fixed_clock(self): + body=self.client.get('/events').get_data(as_text=True);self.assertIn('Buckeyes vs. Michigan State Football',body);self.assertIn('CFAES Annual Farm Science Review',body) + def test_request_size_limit(self):self.assertEqual(self.client.post('/register',data=b'x'*(65*1024),content_type='application/x-www-form-urlencoded').status_code,413) +if __name__=='__main__':unittest.main() diff --git a/sites/osu/verify/test_environment_quality.py b/sites/osu/verify/test_environment_quality.py new file mode 100644 index 00000000..0b154f87 --- /dev/null +++ b/sites/osu/verify/test_environment_quality.py @@ -0,0 +1,46 @@ +"""Static and seed-quality regressions for the OSU mirror.""" +from __future__ import annotations +import hashlib,json,shutil,sqlite3,subprocess,sys,tempfile,unittest +from urllib.parse import urlparse +from pathlib import Path +from sites.osu.verify.test_support import ensure_seed +SITE=Path(__file__).resolve().parents[1];ROOT=SITE.parents[1];SEED=ensure_seed() +class EnvironmentTests(unittest.TestCase): + def test_site_registration_and_task_manifest(self): + self.assertIn('ted osu', (ROOT/'websyn_start.sh').read_text());self.assertIn("'ted', 'osu'",(ROOT/'control_server.py').read_text());self.assertIn('40000-40021',(ROOT/'Dockerfile').read_text()) + rows=[json.loads(x) for x in (SITE/'tasks.jsonl').read_text().splitlines()];self.assertEqual(len(rows),20) + for i,r in enumerate(rows):self.assertEqual(r['id'],f'Ohio State University--{i}');self.assertEqual(r['web'],'http://localhost:40020/');self.assertTrue((ROOT/r['verifier_path']).is_file());self.assertNotIn('answer',r) + def test_real_image_manifest_and_files(self): + manifest=json.loads((SITE/'image_sources.json').read_text())['images'];self.assertGreaterEqual(len(manifest),19) + allowed_pages={'www.osu.edu','undergrad.osu.edu','fisher.osu.edu','ohiostatebuckeyes.com','cancer.osu.edu','news.osu.edu'} + references=(SITE/'app.py').read_text()+''.join(path.read_text() for path in (SITE/'templates').glob('*.html')) + for item in manifest: + with self.subTest(file=item['file']): + image=SITE/'static/images'/item['file'];self.assertTrue(image.is_file());self.assertGreater(image.stat().st_size,5000);self.assertEqual(hashlib.sha256(image.read_bytes()).hexdigest(),item['output_sha256']);self.assertEqual(image.suffix,'.webp');self.assertIn(urlparse(item['source_page']).hostname,allowed_pages);self.assertTrue(item['alt'].strip());self.assertIn(item['file'].removesuffix('.webp'),references) + def test_seed_counts_and_constraints(self): + c=sqlite3.connect(SEED) + try: + expected={'colleges':16,'departments':15,'programs':20,'news_articles':20,'events':16,'research_centers':15,'faculty':15,'athletic_teams':26,'users':4,'bookmarks':0} + self.assertEqual({t:c.execute(f'select count(*) from {t}').fetchone()[0] for t in expected},expected) + indexes={r[1] for r in c.execute('pragma index_list(bookmarks)')};self.assertTrue(any('bookmark' in x for x in indexes),indexes) + finally:c.close() + def test_seed_generation_is_byte_deterministic(self): + hashes=[] + with tempfile.TemporaryDirectory(prefix='osu-seed-') as tmp: + for n in (1,2): + d=Path(tmp)/str(n);d.mkdir();shutil.copy2(SITE/'app.py',d/'app.py');shutil.copy2(SITE/'seed_data.py',d/'seed_data.py');shutil.copy2(SITE/'image_sources.json',d/'image_sources.json') + subprocess.run([sys.executable,'-c','import app'],cwd=d,check=True,capture_output=True,text=True);database=d/'instance/osu.db';hashes.append(hashlib.sha256(database.read_bytes()).hexdigest()) + self.assertEqual(hashes[0],hashes[1]) + def test_post_forms_have_csrf(self): + missing=[] + for p in (SITE/'templates').glob('*.html'): + lines=p.read_text().splitlines() + for i,line in enumerate(lines): + if '",base) + for name in ('athletics_team.html','event_detail.html','faculty_profile.html','program_detail.html','research_center.html','department_detail.html'):self.assertIn('class="detail-layout"',(SITE/'templates'/name).read_text(),name) + def test_all_read_only_verifiers_compare_complete_database(self): + for i in range(20):self.assertIn('check_read_only',(SITE/f'verify/verify_{i}.py').read_text(),i) +if __name__=='__main__':unittest.main() diff --git a/sites/osu/verify/test_support.py b/sites/osu/verify/test_support.py new file mode 100644 index 00000000..0cfbd72a --- /dev/null +++ b/sites/osu/verify/test_support.py @@ -0,0 +1,20 @@ +"""Shared test setup for generated OSU seed assets.""" +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +SITE = Path(__file__).resolve().parents[1] +SEED = SITE / 'instance_seed' / 'osu.db' + + +def ensure_seed(): + if SEED.is_file(): + return SEED + subprocess.run([sys.executable, '-c', 'import app'], cwd=SITE, check=True, capture_output=True, text=True) + SEED.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(SITE / 'instance' / 'osu.db', SEED) + shutil.rmtree(SITE / 'instance') + return SEED diff --git a/sites/osu/verify/test_verifiers.py b/sites/osu/verify/test_verifiers.py new file mode 100644 index 00000000..100efa64 --- /dev/null +++ b/sites/osu/verify/test_verifiers.py @@ -0,0 +1,76 @@ +"""Positive and adversarial tests for all OSU verifiers.""" +from __future__ import annotations +import json,shutil,sqlite3,subprocess,sys,tempfile,unittest +from pathlib import Path +from sites.osu.verify.test_support import ensure_seed +VERIFY_DIR=Path(__file__).resolve().parent;SEED=ensure_seed();BASE='http://localhost:40020' +def url(p):return BASE+p +def nav(p):return {'url':url(p),'action':'navigate','params':{}} +def click(a,b):return {'url':url(a),'url_after':url(b),'action':'click','params':{}} +def trans(a,b):return [click(a,b),nav(b)] +class VerifierTests(unittest.TestCase): + def run_verifier(self,task,steps,answer,mutate=None,task_id=None): + with tempfile.TemporaryDirectory(prefix=f'osu-v-{task}-') as d: + root=Path(d);initial=root/'initial.db';after=root/'after.db';run=root/'run';run.mkdir();shutil.copy2(SEED,initial);shutil.copy2(SEED,after) + if mutate: + c=sqlite3.connect(after) + try:mutate(c);c.commit() + finally:c.close() + traj={'task_id':task_id or f'Ohio State University--{task}','start_url':url('/'),'steps':steps,'final_url':steps[-1].get('url_after',steps[-1].get('url')) if steps else url('/'),'final_answer':answer};(run/'trajectory.json').write_text(json.dumps(traj)) + r=subprocess.run([sys.executable,str(VERIFY_DIR/f'verify_{task}.py'),'--run_dir',str(run),'--initial_db',str(initial),'--after_db',str(after),'--no_llm','true'],capture_output=True,text=True,timeout=30) + try:v=json.loads(r.stdout) + except Exception as e:self.fail(f'task {task}: {r.stdout!r} {r.stderr!r} {e}') + return r.returncode,v + def positive(self,i): + F='/athletics/ohio-state-buckeyes-football';W='/athletics/ohio-state-buckeyes-wrestling';B='/athletics/ohio-state-buckeyes-mens-basketball';E='/athletics/ohio-state-buckeyes-fencing' + cases={ + 0:([nav('/')]+trans('/','/academics'),'Fisher College of Business dean is Anil Makhija.'), + 1:([nav('/about')],'Varsity Sports: 36.'), + 2:([nav('/athletics')]+trans('/athletics',F)+[nav('/athletics')]+trans('/athletics',W),'Football and wrestling both list the Big Ten.'), + 3:([nav('/athletics')]+trans('/athletics',F),'Head coach Ryan Day; recent record 11-2.'), + 4:([nav('/search?q=research+expenditures')]+trans('/search?q=research+expenditures','/news/ohio-state-sets-record-for-research-expenditures-at-13-billion'),'$1.3 billion; September 23, 2024.'), + 5:([nav('/about')],'Founded in 1870 as Ohio Agricultural and Mechanical College.'), + 6:([nav('/research')]+trans('/research','/research/translational-data-analytics-institute'),'Director Beth Plale; Data analytics, Machine learning, Health informatics, Social science.'), + 7:([nav('/about')],'Undergraduate: 46,820; graduate students: 14,000; difference: 32,820.'), + 8:([nav('/academics')],'Engineering: 8,000; Fisher: 4,500; Engineering has more, by 3,500.'), + 9:([nav('/programs?college=engineering')],'There are 3 distinct types: BS, MS, and PhD.'), + 10:([nav('/athletics')]+trans('/athletics',W),'Head coach Tom Ryan; home venue Covelli Center.'), + 11:([nav('/research')]+trans('/research','/research/ohio-supercomputer-center'),'Director David Bickel; founded 1987.'), + 12:([nav('/programs?q=Juris+Doctor')]+trans('/programs?q=Juris+Doctor','/programs/juris-doctor-jd'),'JD; 90 credits; 3 years.'), + 13:([nav('/departments')]+trans('/departments','/departments/department-of-mathematics'),'Chair James Cogdell; location 100 Mathematics Building.'), + 14:([nav('/athletics')]+trans('/athletics',F)+[nav('/athletics')]+trans('/athletics',B),'Football: Ohio Stadium; basketball: Value City Arena.'), + 15:([nav('/athletics')]+trans('/athletics',W)+[nav('/athletics')]+trans('/athletics',E),'Wrestling: 8; fencing: 2; wrestling has more by 6.'), + 16:([nav('/programs?degree=MBA')]+trans('/programs?degree=MBA','/programs/master-of-business-administration-mba'),'Deadline April 1; 60 credits; GRE not required.'), + 17:([nav('/search?q=cancer+research')]+trans('/search?q=cancer+research','/news/ohio-state-researchers-develop-breakthrough-cancer-immunotherapy'),'Ohio State Researchers Develop Breakthrough Cancer Immunotherapy by Jody Sheridan.'), + 18:([nav('/research')]+trans('/research','/research/james-cancer-hospital-and-solove-research-institute'),'Director William Farrar; Cancer research, Oncology, Clinical trials, Precision medicine.'), + 19:([nav('/research')]+trans('/research','/research/center-for-clean-hydrogen'),'Director Yann Guezennec; founded 2022; Hydrogen energy, Fuel cells, Green hydrogen, Energy storage.'), + };return cases[i] + def test_all_positive(self): + for i in range(20): + with self.subTest(i=i): + s,a=self.positive(i);rc,v=self.run_verifier(i,s,a);self.assertEqual(rc,0,v) + def test_answer_only_fails(self): + for i in range(20): + with self.subTest(i=i): + _,a=self.positive(i);rc,v=self.run_verifier(i,[],a);self.assertNotEqual(rc,0);self.assertFalse(v['pass']) + def test_wrong_task_id_fails(self): + for i in range(20): + with self.subTest(i=i): + s,a=self.positive(i);rc,v=self.run_verifier(i,s,a,task_id='Ohio State University--999');self.assertNotEqual(rc,0);self.assertEqual(v['reason'],'task_id_matches') + def test_external_origin_fails(self): + rc,v=self.run_verifier(1,[{'url':'https://evil.invalid/about','action':'navigate','params':{}}],'Varsity Sports: 36.');self.assertNotEqual(rc,0);self.assertFalse(v['pass']) + def test_database_mutation_fails_all(self): + def mutate(c):c.execute("UPDATE news_articles SET view_count=view_count+1 WHERE id=1") + for i in range(20): + with self.subTest(i=i): + s,a=self.positive(i);rc,v=self.run_verifier(i,s,a,mutate);self.assertNotEqual(rc,0);self.assertFalse(v['pass']) + def test_negated_answers_fail(self): + for i,a in {1:'There are not 36 varsity sports.',3:'Ryan Day is not coach; record 11-2.',5:'It was not founded in 1870 as Ohio Agricultural and Mechanical College.',16:'Deadline is not April 1; 60 credits; GRE not required.'}.items(): + s,_=self.positive(i);rc,v=self.run_verifier(i,s,a);self.assertNotEqual(rc,0);self.assertFalse(v['pass']) + def test_swapped_comparisons_fail(self): + for i,a in {7:'Undergraduate: 14,000; graduate: 46,820; difference 32,820.',8:'Engineering: 4,500; Fisher: 8,000; Engineering has more by 3,500.',14:'Football: Value City Arena; basketball: Ohio Stadium.',15:'Wrestling: 2; fencing: 8; wrestling has more by 6.'}.items(): + s,_=self.positive(i);rc,v=self.run_verifier(i,s,a);self.assertNotEqual(rc,0);self.assertFalse(v['pass']) + def test_required_filters_fail_when_missing(self): + for i in (9,12,16): + s,a=self.positive(i);s=[x for x in s if '?' not in x.get('url','') and '?' not in x.get('url_after','')];rc,v=self.run_verifier(i,s,a);self.assertNotEqual(rc,0);self.assertFalse(v['pass']) +if __name__=='__main__':unittest.main() diff --git a/sites/osu/verify/verify_0.py b/sites/osu/verify/verify_0.py new file mode 100644 index 00000000..ac3d0439 --- /dev/null +++ b/sites/osu/verify/verify_0.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import Judge, check_common, check_read_only, clicked_transition, contains_all, final_answer, load_run, parse_args, visited_path +TASK_ID='Ohio State University--0' +def main(): + a=parse_args();t=load_run(a.run_dir);j=Judge(TASK_ID);answer=final_answer(t);check_common(j,t,TASK_ID);j.check('opened_academics',visited_path(t,'/academics'),'required=/academics');j.check('used_academics_link',clicked_transition(t,'/','/academics'),'home to academics click');j.check('answer_fisher_dean',contains_all(answer,('Fisher College of Business','Anil Makhija')),repr(answer));check_read_only(j,a);j.emit() +if __name__=='__main__':main() diff --git a/sites/osu/verify/verify_1.py b/sites/osu/verify/verify_1.py new file mode 100644 index 00000000..96be9a74 --- /dev/null +++ b/sites/osu/verify/verify_1.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import Judge, check_common, check_read_only, contains_all, final_answer, has_number, load_run, parse_args, visited_path +TASK_ID='Ohio State University--1' +def main(): + a=parse_args();t=load_run(a.run_dir);j=Judge(TASK_ID);answer=final_answer(t);check_common(j,t,TASK_ID);j.check('opened_about',visited_path(t,'/about'),'required=/about');j.check('answer_varsity_sports',has_number(answer,36) and contains_all(answer,('varsity','sports')),repr(answer));check_read_only(j,a);j.emit() +if __name__=='__main__':main() diff --git a/sites/osu/verify/verify_10.py b/sites/osu/verify/verify_10.py new file mode 100644 index 00000000..6e9814b2 --- /dev/null +++ b/sites/osu/verify/verify_10.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import Judge, check_common, check_read_only, clicked_transition, contains_all, final_answer, load_run, parse_args, visited_in_order +TASK_ID='Ohio State University--10';PATH='/athletics/ohio-state-buckeyes-wrestling' +def main(): + a=parse_args();t=load_run(a.run_dir);j=Judge(TASK_ID);answer=final_answer(t);check_common(j,t,TASK_ID);j.check('ordered_wrestling_navigation',visited_in_order(t,[('/athletics',{}),(PATH,{})]) and clicked_transition(t,'/athletics',PATH),'athletics to wrestling');j.check('answer_coach_and_venue',contains_all(answer,('Tom Ryan','Covelli Center')),repr(answer));check_read_only(j,a);j.emit() +if __name__=='__main__':main() diff --git a/sites/osu/verify/verify_11.py b/sites/osu/verify/verify_11.py new file mode 100644 index 00000000..443fd14f --- /dev/null +++ b/sites/osu/verify/verify_11.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import Judge, check_common, check_read_only, clicked_transition, contains_all, final_answer, has_number, load_run, parse_args, visited_in_order +TASK_ID='Ohio State University--11';PATH='/research/ohio-supercomputer-center' +def main(): + a=parse_args();t=load_run(a.run_dir);j=Judge(TASK_ID);answer=final_answer(t);check_common(j,t,TASK_ID);j.check('ordered_osc_navigation',visited_in_order(t,[('/research',{}),(PATH,{})]) and clicked_transition(t,'/research',PATH),'research to OSC');j.check('answer_director_and_year',contains_all(answer,('David Bickel',)) and has_number(answer,1987),repr(answer));check_read_only(j,a);j.emit() +if __name__=='__main__':main() diff --git a/sites/osu/verify/verify_12.py b/sites/osu/verify/verify_12.py new file mode 100644 index 00000000..779f691f --- /dev/null +++ b/sites/osu/verify/verify_12.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import Judge, check_common, check_read_only, clicked_transition, contains_all, contains_word, final_answer, has_number, load_run, parse_args, visited_in_order +TASK_ID='Ohio State University--12';PATH='/programs/juris-doctor-jd' +def main(): + a=parse_args();t=load_run(a.run_dir);j=Judge(TASK_ID);answer=final_answer(t);check_common(j,t,TASK_ID);j.check('ordered_jd_search',visited_in_order(t,[('/programs',{'q':'Juris Doctor'}),(PATH,{})]) and clicked_transition(t,'/programs',PATH),'program search to JD');j.check('answer_jd_details',contains_word(answer,'JD') and has_number(answer,90) and has_number(answer,3) and contains_all(answer,('credits','years')),repr(answer));check_read_only(j,a);j.emit() +if __name__=='__main__':main() diff --git a/sites/osu/verify/verify_13.py b/sites/osu/verify/verify_13.py new file mode 100644 index 00000000..184bfdcd --- /dev/null +++ b/sites/osu/verify/verify_13.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import Judge, check_common, check_read_only, clicked_transition, contains_all, final_answer, load_run, parse_args, visited_in_order +TASK_ID='Ohio State University--13';PATH='/departments/department-of-mathematics' +def main(): + a=parse_args();t=load_run(a.run_dir);j=Judge(TASK_ID);answer=final_answer(t);check_common(j,t,TASK_ID);j.check('ordered_math_navigation',visited_in_order(t,[('/departments',{}),(PATH,{})]) and clicked_transition(t,'/departments',PATH),'departments to Mathematics');j.check('answer_chair_and_location',contains_all(answer,('James Cogdell','100 Mathematics Building')),repr(answer));check_read_only(j,a);j.emit() +if __name__=='__main__':main() diff --git a/sites/osu/verify/verify_14.py b/sites/osu/verify/verify_14.py new file mode 100644 index 00000000..087634ae --- /dev/null +++ b/sites/osu/verify/verify_14.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import Judge, check_common, check_read_only, clicked_transition, final_answer, load_run, parse_args, text_bound_in_comparison, visited_in_order +TASK_ID='Ohio State University--14';F='/athletics/ohio-state-buckeyes-football';B='/athletics/ohio-state-buckeyes-mens-basketball' +def main(): + a=parse_args();t=load_run(a.run_dir);j=Judge(TASK_ID);answer=final_answer(t);check_common(j,t,TASK_ID);j.check('athletics_then_both_details',visited_in_order(t,[('/athletics',{}),(F,{})]) and visited_in_order(t,[('/athletics',{}),(B,{})]),'listing before details');j.check('clicked_both_teams',clicked_transition(t,'/athletics',F) and clicked_transition(t,'/athletics',B),'visible team links used');j.check('football_venue_bound',text_bound_in_comparison(answer,'Ohio Stadium',('football',)),repr(answer));j.check('basketball_venue_bound',text_bound_in_comparison(answer,'Value City Arena',('basketball',)),repr(answer));check_read_only(j,a);j.emit() +if __name__=='__main__':main() diff --git a/sites/osu/verify/verify_15.py b/sites/osu/verify/verify_15.py new file mode 100644 index 00000000..9b63d051 --- /dev/null +++ b/sites/osu/verify/verify_15.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import Judge, affirmative_contains, check_common, check_read_only, clicked_transition, contains_any, final_answer, has_number, load_run, number_bound_in_comparison, parse_args, visited_in_order +TASK_ID='Ohio State University--15';W='/athletics/ohio-state-buckeyes-wrestling';F='/athletics/ohio-state-buckeyes-fencing' +def main(): + a=parse_args();t=load_run(a.run_dir);j=Judge(TASK_ID);answer=final_answer(t);check_common(j,t,TASK_ID);j.check('athletics_then_both_details',visited_in_order(t,[('/athletics',{}),(W,{})]) and visited_in_order(t,[('/athletics',{}),(F,{})]),'listing before details');j.check('clicked_both_teams',clicked_transition(t,'/athletics',W) and clicked_transition(t,'/athletics',F),'visible team links used');j.check('wrestling_titles_bound',number_bound_in_comparison(answer,8,('wrestling',)),repr(answer));j.check('fencing_titles_bound',number_bound_in_comparison(answer,2,('fencing',)),repr(answer));j.check('winner_and_difference',affirmative_contains(answer,'wrestling') and contains_any(answer,('more','higher')) and has_number(answer,6),repr(answer));check_read_only(j,a);j.emit() +if __name__=='__main__':main() diff --git a/sites/osu/verify/verify_16.py b/sites/osu/verify/verify_16.py new file mode 100644 index 00000000..3be4af80 --- /dev/null +++ b/sites/osu/verify/verify_16.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import Judge, affirmative_contains, check_common, check_read_only, clicked_transition, contains_all, contains_word, final_answer, load_run, parse_args, visited_in_order, visited_query +TASK_ID='Ohio State University--16';PATH='/programs/master-of-business-administration-mba' +def main(): + a=parse_args();t=load_run(a.run_dir);j=Judge(TASK_ID);answer=final_answer(t);check_common(j,t,TASK_ID);j.check('mba_filter',visited_query(t,'/programs',{'degree':'MBA'}),'degree=MBA');j.check('ordered_mba_navigation',visited_in_order(t,[('/programs',{'degree':'MBA'}),(PATH,{})]) and clicked_transition(t,'/programs',PATH),'filtered programs to MBA');j.check('answer_mba_details',contains_all(answer,('April 1','credits')) and contains_word(answer,'60') and contains_word(answer,'GRE') and affirmative_contains(answer,'not required'),repr(answer));check_read_only(j,a);j.emit() +if __name__=='__main__':main() diff --git a/sites/osu/verify/verify_17.py b/sites/osu/verify/verify_17.py new file mode 100644 index 00000000..9182b790 --- /dev/null +++ b/sites/osu/verify/verify_17.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import Judge, check_common, check_read_only, clicked_transition, contains_all, final_answer, load_run, parse_args, visited_in_order +TASK_ID='Ohio State University--17';PATH='/news/ohio-state-researchers-develop-breakthrough-cancer-immunotherapy';TITLE='Ohio State Researchers Develop Breakthrough Cancer Immunotherapy' +def main(): + a=parse_args();t=load_run(a.run_dir);j=Judge(TASK_ID);answer=final_answer(t);check_common(j,t,TASK_ID);j.check('ordered_cancer_search',visited_in_order(t,[('/search',{'q':'cancer research'}),(PATH,{})]) and clicked_transition(t,'/search',PATH),'search to exact article');j.check('answer_title_author',contains_all(answer,(TITLE,'Jody Sheridan')),repr(answer));check_read_only(j,a);j.emit() +if __name__=='__main__':main() diff --git a/sites/osu/verify/verify_18.py b/sites/osu/verify/verify_18.py new file mode 100644 index 00000000..a1de44c7 --- /dev/null +++ b/sites/osu/verify/verify_18.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import Judge, check_common, check_read_only, clicked_transition, contains_all, final_answer, load_run, parse_args, visited_in_order +TASK_ID='Ohio State University--18';PATH='/research/james-cancer-hospital-and-solove-research-institute' +def main(): + a=parse_args();t=load_run(a.run_dir);j=Judge(TASK_ID);answer=final_answer(t);check_common(j,t,TASK_ID);j.check('ordered_james_navigation',visited_in_order(t,[('/research',{}),(PATH,{})]) and clicked_transition(t,'/research',PATH),'research to James');j.check('answer_director_focus',contains_all(answer,('William Farrar','Cancer research','Oncology','Clinical trials','Precision medicine')),repr(answer));check_read_only(j,a);j.emit() +if __name__=='__main__':main() diff --git a/sites/osu/verify/verify_19.py b/sites/osu/verify/verify_19.py new file mode 100644 index 00000000..9eb2c378 --- /dev/null +++ b/sites/osu/verify/verify_19.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import Judge, check_common, check_read_only, clicked_transition, contains_all, final_answer, has_number, load_run, parse_args, visited_in_order +TASK_ID='Ohio State University--19';PATH='/research/center-for-clean-hydrogen' +def main(): + a=parse_args();t=load_run(a.run_dir);j=Judge(TASK_ID);answer=final_answer(t);check_common(j,t,TASK_ID);j.check('ordered_hydrogen_navigation',visited_in_order(t,[('/research',{}),(PATH,{})]) and clicked_transition(t,'/research',PATH),'research to Clean Hydrogen');j.check('answer_director_year_focus',contains_all(answer,('Yann Guezennec','Hydrogen energy','Fuel cells','Green hydrogen','Energy storage')) and has_number(answer,2022),repr(answer));check_read_only(j,a);j.emit() +if __name__=='__main__':main() diff --git a/sites/osu/verify/verify_2.py b/sites/osu/verify/verify_2.py new file mode 100644 index 00000000..4e382a76 --- /dev/null +++ b/sites/osu/verify/verify_2.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import Judge, check_common, check_read_only, clicked_transition, contains_all, final_answer, load_run, parse_args, visited_in_order +TASK_ID='Ohio State University--2';F='/athletics/ohio-state-buckeyes-football';W='/athletics/ohio-state-buckeyes-wrestling' +def main(): + a=parse_args();t=load_run(a.run_dir);j=Judge(TASK_ID);answer=final_answer(t);check_common(j,t,TASK_ID);j.check('athletics_then_both_details',visited_in_order(t,[('/athletics',{}),(F,{})]) and visited_in_order(t,[('/athletics',{}),(W,{})]),'listing precedes details');j.check('clicked_both_teams',clicked_transition(t,'/athletics',F) and clicked_transition(t,'/athletics',W),'visible team links used');j.check('answer_big_ten_both',contains_all(answer,('football','wrestling','Big Ten')),repr(answer));check_read_only(j,a);j.emit() +if __name__=='__main__':main() diff --git a/sites/osu/verify/verify_3.py b/sites/osu/verify/verify_3.py new file mode 100644 index 00000000..5672b752 --- /dev/null +++ b/sites/osu/verify/verify_3.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import Judge, check_common, check_read_only, clicked_transition, contains_all, final_answer, load_run, parse_args, visited_in_order +TASK_ID='Ohio State University--3';PATH='/athletics/ohio-state-buckeyes-football' +def main(): + a=parse_args();t=load_run(a.run_dir);j=Judge(TASK_ID);answer=final_answer(t);check_common(j,t,TASK_ID);j.check('ordered_football_navigation',visited_in_order(t,[('/athletics',{}),(PATH,{})]) and clicked_transition(t,'/athletics',PATH),'athletics listing to football');j.check('answer_coach_and_record',contains_all(answer,('Ryan Day','11-2')),repr(answer));check_read_only(j,a);j.emit() +if __name__=='__main__':main() diff --git a/sites/osu/verify/verify_4.py b/sites/osu/verify/verify_4.py new file mode 100644 index 00000000..a379f85d --- /dev/null +++ b/sites/osu/verify/verify_4.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import Judge, check_common, check_read_only, clicked_transition, contains_all, final_answer, has_number, load_run, parse_args, visited_in_order +TASK_ID='Ohio State University--4';PATH='/news/ohio-state-sets-record-for-research-expenditures-at-13-billion' +def main(): + a=parse_args();t=load_run(a.run_dir);j=Judge(TASK_ID);answer=final_answer(t);check_common(j,t,TASK_ID);j.check('ordered_news_search',visited_in_order(t,[('/search',{'q':'research expenditures'}),(PATH,{})]) and clicked_transition(t,'/search',PATH),'search to exact article');j.check('answer_amount_date',has_number(answer,1.3) and contains_all(answer,('billion','September','23','2024')),repr(answer));check_read_only(j,a);j.emit() +if __name__=='__main__':main() diff --git a/sites/osu/verify/verify_5.py b/sites/osu/verify/verify_5.py new file mode 100644 index 00000000..8a03efc7 --- /dev/null +++ b/sites/osu/verify/verify_5.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import Judge, check_common, check_read_only, contains_all, final_answer, has_number, load_run, parse_args, visited_path +TASK_ID='Ohio State University--5' +def main(): + a=parse_args();t=load_run(a.run_dir);j=Judge(TASK_ID);answer=final_answer(t);check_common(j,t,TASK_ID);j.check('opened_about',visited_path(t,'/about'),'required=/about');j.check('answer_founding',has_number(answer,1870) and contains_all(answer,('Ohio Agricultural and Mechanical College',)),repr(answer));check_read_only(j,a);j.emit() +if __name__=='__main__':main() diff --git a/sites/osu/verify/verify_6.py b/sites/osu/verify/verify_6.py new file mode 100644 index 00000000..029ee6f4 --- /dev/null +++ b/sites/osu/verify/verify_6.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import Judge, check_common, check_read_only, clicked_transition, contains_all, final_answer, load_run, parse_args, visited_in_order +TASK_ID='Ohio State University--6';PATH='/research/translational-data-analytics-institute' +def main(): + a=parse_args();t=load_run(a.run_dir);j=Judge(TASK_ID);answer=final_answer(t);check_common(j,t,TASK_ID);j.check('ordered_tdai_navigation',visited_in_order(t,[('/research',{}),(PATH,{})]) and clicked_transition(t,'/research',PATH),'research listing to TDAI');j.check('answer_director_and_focus',contains_all(answer,('Beth Plale','Data analytics','Machine learning','Health informatics','Social science')),repr(answer));check_read_only(j,a);j.emit() +if __name__=='__main__':main() diff --git a/sites/osu/verify/verify_7.py b/sites/osu/verify/verify_7.py new file mode 100644 index 00000000..869f4916 --- /dev/null +++ b/sites/osu/verify/verify_7.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import Judge, check_common, check_read_only, final_answer, has_number, load_run, number_bound_in_comparison, parse_args, visited_path +TASK_ID='Ohio State University--7' +def main(): + a=parse_args();t=load_run(a.run_dir);j=Judge(TASK_ID);answer=final_answer(t);check_common(j,t,TASK_ID);j.check('opened_about',visited_path(t,'/about'),'required=/about');j.check('undergraduate_bound',number_bound_in_comparison(answer,46820,('undergraduate','undergrads')),repr(answer));j.check('graduate_bound',number_bound_in_comparison(answer,14000,('graduate','graduate students')),repr(answer));j.check('exact_difference',has_number(answer,32820),repr(answer));check_read_only(j,a);j.emit() +if __name__=='__main__':main() diff --git a/sites/osu/verify/verify_8.py b/sites/osu/verify/verify_8.py new file mode 100644 index 00000000..4f4bbb90 --- /dev/null +++ b/sites/osu/verify/verify_8.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import Judge, affirmative_contains, check_common, check_read_only, contains_any, final_answer, has_number, load_run, number_bound_in_comparison, parse_args, visited_path +TASK_ID='Ohio State University--8' +def main(): + a=parse_args();t=load_run(a.run_dir);j=Judge(TASK_ID);answer=final_answer(t);check_common(j,t,TASK_ID);j.check('opened_academics',visited_path(t,'/academics'),'required=/academics');j.check('engineering_count_bound',number_bound_in_comparison(answer,8000,('Engineering',)),repr(answer));j.check('fisher_count_bound',number_bound_in_comparison(answer,4500,('Fisher',)),repr(answer));j.check('difference_and_winner',has_number(answer,3500) and affirmative_contains(answer,'Engineering') and contains_any(answer,('more','higher')),repr(answer));check_read_only(j,a);j.emit() +if __name__=='__main__':main() diff --git a/sites/osu/verify/verify_9.py b/sites/osu/verify/verify_9.py new file mode 100644 index 00000000..d732cbe4 --- /dev/null +++ b/sites/osu/verify/verify_9.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import Judge, check_common, check_read_only, contains_word, final_answer, has_number, load_run, parse_args, visited_query +TASK_ID='Ohio State University--9' +def main(): + a=parse_args();t=load_run(a.run_dir);j=Judge(TASK_ID);answer=final_answer(t);check_common(j,t,TASK_ID);j.check('engineering_filter',visited_query(t,'/programs',{'college':'engineering'}),'college=engineering');j.check('answer_all_types',all(contains_word(answer,value) for value in ('BS','MS','PhD')) and has_number(answer,3),repr(answer));j.check('no_extra_degree_types',not any(contains_word(answer,value) for value in ('BA','MA','MBA','JD','MD','MPH','PharmD','DVM','OD')),repr(answer));check_read_only(j,a);j.emit() +if __name__=='__main__':main() diff --git a/sites/osu/verify/verify_lib.py b/sites/osu/verify/verify_lib.py new file mode 100644 index 00000000..56ccc187 --- /dev/null +++ b/sites/osu/verify/verify_lib.py @@ -0,0 +1,372 @@ +#!/usr/bin/env python3 +"""Shared deterministic helpers for Ohio State University task verifiers.""" + +from __future__ import annotations + +import argparse +import ipaddress +import json +import os +import re +import sqlite3 +import subprocess +import tempfile +import unicodedata +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable, Sequence +from urllib.parse import parse_qs, urlparse + +SITE = "osu" +DEFAULT_CONTAINER = os.environ.get("WH_CONTAINER", "wh-review") + +@dataclass(frozen=True) +class VerifyArgs: + run_dir: str + initial_db: str | None + after_db: str | None + container: str + no_llm: bool + + +def _bool_value(value: str) -> bool: + return str(value).casefold() in {"1", "true", "yes", "on"} + + +def parse_args() -> VerifyArgs: + parser = argparse.ArgumentParser() + parser.add_argument("--run_dir", required=True) + parser.add_argument("--initial_db") + parser.add_argument("--after_db") + parser.add_argument("--container", default=DEFAULT_CONTAINER) + parser.add_argument("--no_llm", nargs="?", const=True, default=False, type=_bool_value) + args = parser.parse_args() + run_dir = Path(args.run_dir) + initial_snapshot = run_dir / "initial.db" + after_snapshot = run_dir / "after.db" + return VerifyArgs( + run_dir=args.run_dir, + initial_db=args.initial_db or (str(initial_snapshot) if initial_snapshot.is_file() else None), + after_db=args.after_db or (str(after_snapshot) if after_snapshot.is_file() else None), + container=args.container, + no_llm=bool(args.no_llm), + ) + + +def load_run(run_dir: str | os.PathLike[str]) -> dict[str, Any]: + trajectory = json.loads((Path(run_dir) / "trajectory.json").read_text(encoding="utf-8")) + if not isinstance(trajectory, dict): + raise ValueError("trajectory.json must contain a JSON object") + return trajectory + + +def normalize_text(value: Any) -> str: + text = unicodedata.normalize("NFKC", str(value or "")) + text = text.replace("’", "'").replace("“", '"').replace("”", '"').replace("–", "-").replace("—", "-") + return re.sub(r"\s+", " ", text).strip().casefold() + + +def final_answer(trajectory: dict[str, Any]) -> str: + return str(trajectory.get("final_answer") or "").strip() + + +def trajectory_urls(trajectory: dict[str, Any]) -> list[str]: + urls: list[str] = [] + if trajectory.get("start_url"): + urls.append(str(trajectory["start_url"])) + for step in trajectory.get("steps") or []: + if not isinstance(step, dict): + continue + for key in ("url_before", "url", "url_after"): + value = str(step.get(key) or "") + if value and (not urls or value != urls[-1]): + urls.append(value) + final_url = str(trajectory.get("final_url") or "") + if final_url and (not urls or final_url != urls[-1]): + urls.append(final_url) + return urls + + +def _loopback(hostname: str) -> bool: + if hostname.casefold() == "localhost": + return True + try: + return ipaddress.ip_address(hostname).is_loopback + except ValueError: + return False + + +def is_site_url(url: str, trajectory: dict[str, Any]) -> bool: + parsed = urlparse(str(url or "")) + start = urlparse(str(trajectory.get("start_url") or "")) + return bool( + parsed.scheme in {"http", "https"} + and parsed.hostname + and start.hostname + and _loopback(parsed.hostname) + and _loopback(start.hostname) + and parsed.scheme == start.scheme + and parsed.port == start.port + ) + + +def normalized_path(url: str) -> str: + path = urlparse(str(url or "")).path or "/" + return path.rstrip("/") or "/" + + +def query_matches(url: str, expected: dict[str, str]) -> bool: + params = parse_qs(urlparse(url).query) + return all(normalize_text((params.get(key) or [""])[0]) == normalize_text(value) for key, value in expected.items()) + + +def visited_path(trajectory: dict[str, Any], path: str) -> bool: + expected = normalized_path(path) + return any(is_site_url(url, trajectory) and normalized_path(url) == expected for url in trajectory_urls(trajectory)) + + +def visited_query(trajectory: dict[str, Any], path: str, expected: dict[str, str]) -> bool: + return any( + is_site_url(url, trajectory) + and normalized_path(url) == normalized_path(path) + and query_matches(url, expected) + for url in trajectory_urls(trajectory) + ) + + +def visited_in_order(trajectory: dict[str, Any], requirements: list[tuple[str, dict[str, str]]]) -> bool: + urls = trajectory_urls(trajectory) + cursor = 0 + for path, query in requirements: + found = False + for index in range(cursor, len(urls)): + url = urls[index] + if is_site_url(url, trajectory) and normalized_path(url) == normalized_path(path) and query_matches(url, query): + cursor = index + 1 + found = True + break + if not found: + return False + return True + + +def transition_pairs(trajectory: dict[str, Any]): + steps = trajectory.get("steps") or [] + for index, step in enumerate(steps): + if not isinstance(step, dict): + continue + current = str(step.get("url") or step.get("url_before") or "") + if not is_site_url(current, trajectory): + continue + following = str(step.get("url_after") or "") + if not following and index + 1 < len(steps) and isinstance(steps[index + 1], dict): + following = str(steps[index + 1].get("url") or steps[index + 1].get("url_after") or "") + if following and is_site_url(following, trajectory): + yield normalize_text(step.get("action")), current, following + + +def clicked_transition(trajectory: dict[str, Any], from_path: str, to_path: str) -> bool: + return any( + action == "click" + and normalized_path(current) == normalized_path(from_path) + and normalized_path(following) == normalized_path(to_path) + for action, current, following in transition_pairs(trajectory) + ) + + +def submitted_from_path(trajectory: dict[str, Any], path: str, destination: str | None = None) -> bool: + for action, current, following in transition_pairs(trajectory): + if action != "click" or normalized_path(current) != normalized_path(path): + continue + if destination is None or normalized_path(following) == normalized_path(destination): + return True + return False + + +def input_values(trajectory: dict[str, Any], path: str | None = None) -> list[str]: + values: list[str] = [] + for step in trajectory.get("steps") or []: + if not isinstance(step, dict) or normalize_text(step.get("action")) not in {"input", "fill", "type", "select"}: + continue + url = str(step.get("url") or step.get("url_before") or "") + if not is_site_url(url, trajectory) or (path and normalized_path(url) != normalized_path(path)): + continue + params = step.get("params") or {} + value = params.get("text", params.get("value", params.get("option", params.get("label")))) if isinstance(params, dict) else None + if value is not None: + values.append(str(value)) + return values + + +def entered_text(trajectory: dict[str, Any], expected: str, path: str | None = None) -> bool: + expected_value = normalize_text(expected) + return any(normalize_text(value) == expected_value for value in input_values(trajectory, path)) + + +def last_entered_email(trajectory: dict[str, Any], path: str = "/login") -> str: + emails = [normalize_text(value) for value in input_values(trajectory, path) if re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", value.strip())] + return emails[-1] if emails else "" + + +def login_submitted_as(trajectory: dict[str, Any], email: str) -> bool: + return visited_path(trajectory, "/login") and last_entered_email(trajectory) == normalize_text(email) and submitted_from_path(trajectory, "/login") + + +NEGATIONS = {"not", "no", "never", "without", "isn't", "isnt", "wasn't", "wasnt", "doesn't", "doesnt", "didn't", "didnt"} + + +def _negated_before(text: str, start: int) -> bool: + clause = re.split(r"[.!?;:\n]+|\b(?:and|but|however|instead)\b", text[:start])[-1] + words = re.findall(r"[a-z0-9]+(?:'[a-z]+)?", clause) + return any(word in NEGATIONS for word in words) + + +def _negated_after(text: str, end: int) -> bool: + suffix = re.sub(r"^\s*[-,:;!?]*\s*", "", text[end:]) + return re.match(r"(?:(?:is|was|does|did|are|were)\s+)?(?:not|never|no)\b|(?:isn't|isnt|wasn't|wasnt|doesn't|doesnt|didn't|didnt|aren't|arent|weren't|werent)\b", suffix) is not None + + +def affirmative_contains(text: Any, expected: Any) -> bool: + normalized = normalize_text(text) + needle = normalize_text(expected) + matches = list(re.finditer(re.escape(needle), normalized)) + if not needle or not matches: + return False + match = matches[-1] + return not _negated_before(normalized, match.start()) and not _negated_after(normalized, match.end()) + + +def contains_all(text: Any, expected: Iterable[Any]) -> bool: + return all(affirmative_contains(text, value) for value in expected) + + +def contains_any(text: Any, expected: Iterable[Any]) -> bool: + return any(affirmative_contains(text, value) for value in expected) + + +def contains_word(text: Any, expected: str) -> bool: + normalized = normalize_text(text) + return re.search(rf"(? list[re.Match[str]]: + normalized = normalize_text(text) + matches = [] + for match in re.finditer(r"(? bool: + return bool(number_matches(text, value)) + + +def number_bound_to(text: Any, value: int | float, labels: Sequence[str], distance: int = 140) -> bool: + normalized = normalize_text(text) + for match in number_matches(normalized, value): + window = normalized[max(0, match.start() - distance):min(len(normalized), match.end() + distance)] + if any(normalize_text(label) in window for label in labels): + return True + return False + + +def number_bound_in_comparison(text: Any, value: int | float, labels: Sequence[str]) -> bool: + normalized = normalize_text(text) + segments = re.split(r"\b(?:versus|vs\.?|while|compared (?:with|to))\b|[;\n]", normalized) + return any(has_number(segment, value) and any(normalize_text(label) in segment for label in labels) for segment in segments) + + +def text_bound_in_comparison(text: Any, value: str, labels: Sequence[str]) -> bool: + normalized = normalize_text(text) + segments = re.split(r"\b(?:versus|vs\.?|while|compared (?:with|to))\b|[;\n]", normalized) + return any(normalize_text(value) in segment and any(normalize_text(label) in segment for label in labels) for segment in segments) + + +def fetch_db(container: str, kind: str) -> str: + if kind not in {"instance", "instance_seed"}: + raise ValueError(f"unsupported database kind: {kind}") + handle, destination = tempfile.mkstemp(prefix=f"osu_{kind}_", suffix=".db") + os.close(handle) + source = f"{container}:/opt/WebSyn/{SITE}/{kind}/{SITE}.db" + result = subprocess.run(["docker", "cp", source, destination], capture_output=True, text=True, check=False) + if result.returncode: + Path(destination).unlink(missing_ok=True) + raise RuntimeError(result.stderr.strip() or f"could not copy {source}") + return destination + + +def resolve_db(explicit: str | None, container: str, kind: str) -> str | None: + if explicit: + return explicit if Path(explicit).is_file() else None + try: + return fetch_db(container, kind) + except (OSError, RuntimeError): + return None + + +def db_query(path: str, sql: str, params: Sequence[Any] = ()) -> list[sqlite3.Row]: + connection = sqlite3.connect(path) + connection.row_factory = sqlite3.Row + try: + return connection.execute(sql, params).fetchall() + finally: + connection.close() + + +def row_dicts(path: str, sql: str, params: Sequence[Any] = ()) -> list[dict[str, Any]]: + return [dict(row) for row in db_query(path, sql, params)] + + +def database_tables(path: str) -> list[str]: + return [str(row["name"]) for row in db_query(path, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")] + + +def table_snapshot(path: str, table: str) -> list[tuple[Any, ...]]: + return [tuple(row) for row in db_query(path, f'SELECT * FROM "{table}" ORDER BY rowid')] + + +def changed_tables(initial_db: str, after_db: str) -> set[str]: + initial_tables = database_tables(initial_db) + if initial_tables != database_tables(after_db): + return {""} + return {table for table in initial_tables if table_snapshot(initial_db, table) != table_snapshot(after_db, table)} + + +def database_unchanged(initial_db: str | None, after_db: str | None) -> bool: + return bool(initial_db and after_db and not changed_tables(initial_db, after_db)) + + +def check_common(judge: "Judge", trajectory: dict[str, Any], task_id: str) -> None: + judge.check("task_id_matches", str(trajectory.get("task_id") or "") == task_id, f"observed={trajectory.get('task_id')!r}") + judge.check("final_answer_nonempty", bool(final_answer(trajectory)), repr(final_answer(trajectory))) + judge.check("start_url_is_site", is_site_url(str(trajectory.get("start_url") or ""), trajectory), f"start_url={trajectory.get('start_url')!r}") + + +def check_read_only(judge: "Judge", args: VerifyArgs) -> tuple[str | None, str | None]: + initial = resolve_db(args.initial_db, args.container, "instance_seed") + after = resolve_db(args.after_db, args.container, "instance") + judge.check("databases_readable", bool(initial and after), f"initial={initial} after={after}") + judge.check("read_only_database_unchanged", database_unchanged(initial, after), "complete database comparison") + return initial, after + + +class Judge: + def __init__(self, task_id: str, no_llm: bool = False): + self.task_id = task_id + self.passed = True + self.reason = "" + self.evidence: list[str] = [] + + def check(self, name: str, condition: bool, evidence: str = "", llm: bool = False) -> bool: + self.evidence.append(f"[{'PASS' if condition else 'FAIL'}] {name}: {evidence}") + if not condition: + self.passed = False + if not self.reason: + self.reason = name + return bool(condition) + + def emit(self) -> None: + print(json.dumps({"task_id": self.task_id, "pass": self.passed, "reason": self.reason, "evidence": self.evidence}, ensure_ascii=False, indent=2)) + raise SystemExit(0 if self.passed else 1) diff --git a/websyn_start.sh b/websyn_start.sh index d99624f0..2a2b602a 100644 --- a/websyn_start.sh +++ b/websyn_start.sh @@ -1,11 +1,11 @@ #!/bin/bash -# WebSyn startup: launch all mirror sites, then exec the original CMD. +# WebSyn startup: launch all mirror sites, then exec the control plane. # This preserves the base image's browser env server (port 8100) as PID 1. set -e SITES=(allrecipes amazon apple arxiv bbc_news booking github google_flights google_map google_search huggingface wolfram_alpha - cambridge_dictionary coursera espn merriam_webster ikea phys_org target ted + cambridge_dictionary coursera espn merriam_webster ikea phys_org target ted osu boardgamegeek) BASE_PORT=40000 PID_DIR=/tmp/websyn_pids @@ -81,6 +81,6 @@ done echo "[WebSyn] Starting control server on :8101 (PID 1)..." # Control server becomes PID 1 — receives SIGTERM on `docker stop`, -# keeps the container alive as long as it's running. The site +# keeps the container alive as long as it is running. Site # subprocesses are managed via /tmp/websyn_pids/.pid. exec python3 /opt/control_server.py --port 8101 From 82f618557c03fe7c5d8e9af8be6154caf885b75f Mon Sep 17 00:00:00 2001 From: JackJin <1037461232@qq.com> Date: Tue, 8 Sep 2026 17:26:21 +0800 Subject: [PATCH 7/8] fix(boardgamegeek): match current site visual shell --- sites/boardgamegeek/app.py | 6 + sites/boardgamegeek/static/css/bgg.css | 393 ++++++++++++++++++ .../templates/_hotness_rail.html | 13 + sites/boardgamegeek/templates/base.html | 74 ++-- sites/boardgamegeek/templates/browse.html | 10 +- sites/boardgamegeek/templates/index.html | 201 ++++----- sites/boardgamegeek/templates/item.html | 41 +- 7 files changed, 565 insertions(+), 173 deletions(-) create mode 100644 sites/boardgamegeek/templates/_hotness_rail.html diff --git a/sites/boardgamegeek/app.py b/sites/boardgamegeek/app.py index 85af085b..9cca3d39 100644 --- a/sites/boardgamegeek/app.py +++ b/sites/boardgamegeek/app.py @@ -590,10 +590,16 @@ def _tpl_thousands(v): @app.context_processor def inject_globals(): + nav_hot_games = (Game.query.filter(Game.featured == True) + .order_by(Game.overall_rank.asc()).limit(12).all()) + if not nav_hot_games: + nav_hot_games = (Game.query.filter(Game.overall_rank > 0) + .order_by(Game.overall_rank.asc()).limit(12).all()) return { 'site_name': 'BoardGameGeek', 'mirror_now': MIRROR_NOW, 'current_year': MIRROR_NOW.year, + 'nav_hot_games': nav_hot_games, } diff --git a/sites/boardgamegeek/static/css/bgg.css b/sites/boardgamegeek/static/css/bgg.css index d2d8d348..ececfb2c 100644 --- a/sites/boardgamegeek/static/css/bgg.css +++ b/sites/boardgamegeek/static/css/bgg.css @@ -446,3 +446,396 @@ button:hover, .btn:hover { .description em { color: var(--bgg-text); } .muted { color: var(--bgg-muted); font-size: 11px; } + +/* ----- 2026 live-site fidelity layer ----- */ +:root { + --bgg-purple: #3f3a60; + --bgg-purple-dark: #302c4c; + --bgg-canvas: #e7e7ea; + --bgg-link-modern: #1768ac; + --bgg-orange-modern: #f15a29; + --bgg-ink: #101014; +} + +html, body { + background: var(--bgg-canvas); + color: var(--bgg-ink); + font-family: proxima-nova, Arial, Helvetica, sans-serif; + font-size: 16px; + line-height: 1.35; +} + +a { color: var(--bgg-link-modern); } +a:hover { color: #0c4d83; text-decoration: none; } +h1, h2, h3, h4, h5 { + color: var(--bgg-ink); + font-family: proxima-nova, Arial, Helvetica, sans-serif; + font-weight: 700; +} +.sr-only { + position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; + overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; +} + +.site-header { + height: 52px; + background: var(--bgg-purple); + border: 0; + color: #fff; +} +.site-header .topbar { + width: 100%; max-width: none; height: 52px; margin: 0; + padding: 0 14px; gap: 11px; flex-wrap: nowrap; +} +.menu-toggle { + display: flex; flex-direction: column; justify-content: center; gap: 4px; + width: 22px; height: 40px; padding: 0; border: 0; background: none; +} +.menu-toggle span { display: block; width: 14px; height: 1px; background: rgba(255,255,255,.8); } +.menu-toggle:hover { background: none; } +.site-header .logo { + display: flex; align-items: center; gap: 6px; margin-right: 8px; + color: #fff; font-size: 23px; letter-spacing: .2px; +} +.logo-shield { + display: inline-block; width: 21px; height: 31px; + background: var(--bgg-orange-modern); + clip-path: polygon(12% 12%, 100% 0, 88% 84%, 35% 100%, 0 72%); + transform: rotate(4deg); +} +.logo-type { font-weight: 800; } +.logo-caret { color: rgba(255,255,255,.55); font-size: 11px; margin-left: 1px; } +.site-header .primary-nav { + display: flex; align-items: stretch; flex: 1; gap: 0; font-size: 14px; +} +.site-header .primary-nav a { + display: flex; align-items: center; padding: 0 10px; + color: #fff; font-weight: 700; +} +.site-header .primary-nav a:hover { background: var(--bgg-purple-dark); color: #fff; } +.site-header .primary-nav span { margin-left: 4px; color: rgba(255,255,255,.55); font-size: 9px; } +.site-header .user-area { + display: flex; flex: 0 0 auto; align-items: center; gap: 14px; + margin-left: auto; font-size: 14px; font-weight: 700; +} +.site-header .user-area a { color: #fff; } +.site-header .user-area form { margin: 0; } +.header-action { + padding: 0; border: 0; background: none; text-transform: none; + font-size: 14px; color: #fff !important; +} +.search-bar { + width: min(300px, 25vw); height: 34px; margin-left: 3px; + display: flex; align-items: center; border: 0; border-radius: 18px; + overflow: hidden; background: #fff; +} +.search-icon { + width: 14px; height: 14px; margin-left: 13px; flex: 0 0 auto; + border: 2px solid #8b8b95; border-radius: 50%; position: relative; +} +.search-icon:after { + content: ""; position: absolute; width: 6px; height: 2px; right: -5px; bottom: -2px; + background: #8b8b95; transform: rotate(-45deg); +} +.search-bar input[type="text"] { + flex: 1; width: auto; min-width: 0; height: 34px; padding: 6px 9px; + background: #fff; color: #222; font-size: 16px; +} +.search-bar select, .search-bar button { display: none; } + +.site-ad { + position: relative; max-width: 1200px; height: 126px; margin: 9px auto; + display: flex; align-items: center; justify-content: center; overflow: hidden; + background: + linear-gradient(115deg, transparent 0 24%, #76c8d8 24% 31%, #f7a937 31% 35%, #fff 35% 66%, #6bc3d5 66% 73%, #f8ab33 73% 77%, transparent 77%), + linear-gradient(35deg, #27344a, #899070); +} +.site-ad:before, .site-ad:after { + content: ""; position: absolute; inset: 0 62% 0 0; opacity: .7; + background: + radial-gradient(circle at 18% 30%, #cfb284 0 6%, transparent 6.5%), + radial-gradient(circle at 45% 65%, #727fbc 0 10%, transparent 10.5%), + radial-gradient(circle at 75% 35%, #e5ba56 0 8%, transparent 8.5%); +} +.site-ad:after { inset: 0 0 0 65%; transform: scaleX(-1); } +.site-ad-copy { z-index: 1; display: flex; flex-direction: column; align-items: center; line-height: .9; } +.site-ad-copy strong { color: #e95d37; font-size: 54px; letter-spacing: 2px; } +.site-ad-copy strong span { color: var(--bgg-purple); } +.site-ad-copy small { color: var(--bgg-purple); font-size: 28px; font-weight: 700; } + +.home-surface { + max-width: 1240px; margin: 0 auto 30px; background: #fff; + border-radius: 7px; overflow: hidden; +} +.home-tabs { + height: 57px; margin: 0 38px; display: flex; align-items: flex-end; + gap: 26px; border-bottom: 1px solid #d7d7da; +} +.home-tabs a { + height: 57px; padding: 20px 16px 13px; color: #56555b; font-size: 18px; +} +.home-tabs a.active { + color: #111; font-weight: 700; border-bottom: 3px solid var(--bgg-orange-modern); +} +.featured-stories { + padding: 38px; display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(300px, 1fr); + gap: 34px; +} +.featured-hero { + min-width: 0; display: grid; grid-template-columns: 1fr; color: #111; position: relative; +} +.featured-hero > img { + width: 100%; height: 308px; object-fit: cover; object-position: center; + border-radius: 7px; filter: brightness(.62) saturate(.9); +} +.featured-overlay { + position: absolute; top: 24px; left: 28px; color: #fff; + font-size: clamp(34px, 4vw, 57px); line-height: .9; font-weight: 800; letter-spacing: -1px; + text-shadow: 0 2px 15px rgba(0,0,0,.5); +} +.featured-hero > strong { margin-top: 15px; font-size: 21px; } +.featured-hero > small { margin-top: 7px; color: #64636a; font-size: 14px; } +.featured-list { display: flex; flex-direction: column; } +.featured-list a { + min-height: 92px; padding: 13px 0; display: flex; align-items: center; gap: 16px; + border-bottom: 1px solid #d6d6d8; color: #111; +} +.featured-list a:last-child { border-bottom: 0; } +.featured-list img { width: 76px; height: 76px; object-fit: cover; border-radius: 5px; } +.featured-list span { min-width: 0; display: flex; flex-direction: column; gap: 8px; } +.featured-list b { font-size: 17px; } +.featured-list small { color: #66656b; font-size: 14px; } + +.home-module { padding: 32px 38px; border-top: 1px solid #d6d6d8; overflow: hidden; } +.home-module-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 20px; } +.home-module-header h2 { margin: 0 0 2px; font-size: 20px; text-transform: uppercase; } +.home-module-header p { margin: 0; color: #66656b; } +.home-module-header > a { + color: #333; font-size: 12px; text-transform: uppercase; margin-top: 5px; +} +.card-strip { + display: grid; grid-auto-flow: column; grid-auto-columns: minmax(190px, 1fr); + gap: 13px; overflow-x: auto; scrollbar-width: thin; padding-bottom: 8px; +} +.editorial-card, .campaign-card { min-width: 0; color: #111; } +.editorial-card > img, .campaign-card > img { + width: 100%; height: 125px; object-fit: cover; border-radius: 6px; +} +.editorial-card h3, .campaign-card h3 { margin: 10px 0 5px; font-size: 15px; line-height: 1.15; } +.editorial-card h3 span { margin-right: 5px; } +.editorial-card p, .campaign-card p { margin: 0; font-size: 12px; line-height: 1.35; color: #333; } +.campaign-strip { grid-auto-columns: minmax(205px, 1fr); } +.campaign-card small { color: #66656b; } +.campaign-card > span { + display: inline-block; margin-top: 12px; padding: 6px 10px; + border: 1px solid #4f8987; border-radius: 4px; color: #397472; font-size: 12px; +} +.preview-events { + padding: 0 38px 32px; display: grid; grid-template-columns: 1fr 1fr; gap: 12px; +} +.preview-events a { + min-height: 82px; padding: 12px 18px; display: grid; + grid-template-columns: 115px 1fr 95px; align-items: center; gap: 12px; + border-radius: 10px; background: #060606; color: #fff; +} +.preview-events > a > strong { font-family: Georgia, serif; font-size: 24px; text-align: center; line-height: .8; } +.preview-events span { color: #e69a57; font-size: 10px; } +.preview-events span b { color: #fff; font-size: 14px; } +.preview-events span small { display: block; color: #aaa; } +.preview-events i { + padding: 9px; border-radius: 20px; background: #fff; color: #111; + font-style: normal; text-align: center; text-transform: uppercase; font-size: 12px; font-weight: 700; +} +.video-grid { display: grid; grid-template-columns: repeat(5, minmax(0,1fr)); gap: 14px; } +.video-grid a { color: #111; } +.video-grid a > div { position: relative; height: 118px; overflow: hidden; border-radius: 5px; background: #ddd; } +.video-grid img { width: 100%; height: 100%; object-fit: cover; } +.video-grid a > div span { + position: absolute; inset: 0; display: grid; place-items: center; + color: #fff; font-size: 28px; text-shadow: 0 2px 8px #000; +} +.video-grid b, .video-grid small { display: block; margin-top: 7px; } +.video-grid b { font-size: 14px; } +.video-grid small { color: #66656b; font-size: 12px; } + +.rail-layout { + max-width: 1250px; margin: 0 auto 30px; display: grid; + grid-template-columns: 150px minmax(0, 1fr); gap: 10px; align-items: start; +} +.hotness-rail { background: #fff; overflow: hidden; border-radius: 6px 6px 0 0; } +.rail-title { + padding: 8px 10px; background: var(--bgg-purple); color: #fff; + font-weight: 700; font-size: 12px; text-transform: uppercase; +} +.rail-filter { + padding: 8px; border-bottom: 1px solid #ddd; color: #222; + font-size: 11px; font-weight: 700; text-transform: uppercase; +} +.rail-more { float: right; font-size: 16px; } +.rail-game { + min-height: 42px; padding: 5px 7px; display: grid; + grid-template-columns: 31px minmax(0, 1fr) 9px; gap: 6px; align-items: center; + color: #15151a; border-bottom: 1px solid #e2e2e4; +} +.rail-game img { width: 31px; height: 31px; object-fit: cover; } +.rail-game span { min-width: 0; } +.rail-game b { + display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-size: 12px; line-height: 1.1; +} +.rail-game small { display: block; font-size: 10px; color: #666; } +.rail-game i { font-size: 8px; font-style: normal; } +.rail-game i.up { color: #438568; } +.rail-game i.down { color: #a64a53; } + +.rail-layout > .main { + min-width: 0; padding: 10px; background: #fff; border: 0; border-radius: 6px; + box-shadow: none; +} +.commission-note { + padding: 12px; margin-bottom: 12px; border: 1px solid #d9d9dc; + color: #64636a; font-size: 16px; +} +.commission-note span { float: right; color: #111; font-weight: 700; } +.browse-main h1 { margin: 10px 8px; font-size: 24px; } +.browse-main > .muted { margin: 8px; font-size: 13px; } +.browse-main .collection_table { font-size: 14px; } +.browse-main .collection_table th { + height: 55px; padding: 8px; background: #fff; color: var(--bgg-link-modern); + border-bottom: 1px solid #c8c8cc; text-align: center; font-size: 14px; text-transform: none; +} +.browse-main .collection_table th:nth-child(3) { text-align: center; } +.browse-main .collection_table td { + height: 88px; padding: 10px; border: 1px solid #d8d8da; border-width: 0 1px 1px 0; +} +.browse-main .collection_table tr:nth-child(even) { background: #fff; } +.browse-main .collection_table tr:hover { background: #f7f7f8; } +.browse-main .collection_table .collection_rank { width: 65px; color: #111; } +.browse-main .collection_table .collection_thumbnail { width: 96px; } +.browse-main .collection_table .collection_thumbnail img { width: 64px; height: 64px; } +.browse-main .collection_table .collection_objectname a.primary { color: var(--bgg-link-modern); font-size: 15px; } +.browse-main .collection_table .collection_objectname .muted { font-size: 13px; color: #555; } +.collection_shop { width: 86px; text-align: center; } + +.item-main { padding: 0 !important; overflow: hidden; } +.item-main > .crumb { display: none; } +.game-hero { + position: relative; isolation: isolate; overflow: hidden; + min-height: 445px; padding: 14px; + grid-template-columns: 180px minmax(0, 1fr) 210px; gap: 24px; + background: #09090d; color: #fff; +} +.game-hero:after { + content: ""; position: absolute; inset: 0; z-index: -1; + background: linear-gradient(90deg, rgba(5,5,9,.98) 0 55%, rgba(5,5,9,.58)); +} +.game-hero-art { + position: absolute; inset: 0 0 0 45%; z-index: -2; + width: 55%; height: 100%; object-fit: cover; filter: saturate(.65); +} +.game-hero .cover { z-index: 1; } +.game-hero .cover img { border: 0; border-radius: 2px; } +.game-hero .summary, .game-hero .stats-box { z-index: 1; } +.game-kicker { margin-bottom: 34px; color: #ddd; font-size: 12px; text-transform: uppercase; } +.game-hero .summary h1 { color: #fff; font-size: 26px; } +.game-hero .summary .year { color: #ddd; font-size: 16px; } +.game-hero .summary .muted, .game-hero .summary .meta { color: #eee !important; } +.game-hero .summary .meta { margin: 8px 0; } +.game-hero .summary a { color: #fff !important; opacity: .88; text-decoration: underline; } +.game-hero .stats-box { + border: 0; background: rgba(0,0,0,.72); color: #fff; padding: 12px; +} +.game-hero .stat-label { color: #bbb; } +.hero-rating { + width: 66px; height: 58px; margin: 0 auto 14px; display: grid; place-items: center; + background: #4d9468; color: #fff; font-size: 25px; font-weight: 800; + clip-path: polygon(50% 0, 93% 22%, 93% 78%, 50% 100%, 7% 78%, 7% 22%); +} +.game-actions { + padding: 0 20px 15px; margin-top: -61px; position: relative; z-index: 2; + display: flex; justify-content: flex-end; gap: 5px; background: transparent; +} +.game-actions a { + padding: 9px 12px; color: #fff; border-radius: 4px; font-size: 13px; font-weight: 700; +} +.game-actions .action-buy { background: #47935c; } +.game-actions .action-sleeve { background: #625b8a; } +.game-actions .action-collection { background: #3379c9; } +.game-actions .action-light { background: #f3f3f5; color: #111; } +.item-main .tab-bar { + margin: 0; padding: 0 24px; overflow-x: auto; border-bottom: 1px solid #ccc; + background: #fff; flex-wrap: nowrap; +} +.item-main .tab-bar a { + flex: 0 0 auto; padding: 14px 10px; color: #111; border: 0; border-radius: 0; + font-size: 13px; text-transform: none; +} +.item-main .tab-bar a.active { color: #111; background: #fff; border-bottom: 4px solid var(--bgg-orange-modern); } +.item-main > h2, .item-main > h3, .item-main > p, .item-main > form, +.item-main > .description, .item-main > .collection_table, .item-main > .thread-row, +.item-main > .user-actions-grid { margin-left: 24px; margin-right: 24px; } +.item-main > h2 { margin-top: 28px; } + +.site-footer { + margin-top: 0; padding: 28px; background: #29263f; color: #d7d4e4; + display: flex; justify-content: center; align-items: center; gap: 20px; +} +.site-footer a { color: #fff; } +.footer-logo { display: flex; align-items: center; gap: 7px; font-size: 20px; } +.footer-logo .logo-shield { width: 15px; height: 22px; } + +@media (max-width: 1050px) { + .site-header .primary-nav a { padding: 0 6px; } + .site-header .primary-nav a:nth-last-child(-n+3) { display: none; } + .search-bar { width: 220px; } + .site-ad { margin-left: 14px; margin-right: 14px; } +} + +@media (max-width: 900px) { + .site-header .primary-nav { display: none; } + .site-header .topbar { padding: 0 10px; } + .site-header .user-area { margin-left: auto; } + .site-ad { display: none; } + .rail-layout { display: block; margin: 0; } + .hotness-rail { display: none; } + .rail-layout > .main { border-radius: 0; } + .home-surface { margin: 0; border-radius: 0; } + .featured-stories { grid-template-columns: 1fr; padding: 25px; } + .featured-list { display: grid; grid-template-columns: 1fr 1fr; gap: 0 20px; } + .game-hero { grid-template-columns: 170px 1fr; } + .game-hero .stats-box { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(3,1fr); gap: 0 16px; } + .hero-rating { grid-row: span 4; } + .game-actions { margin-top: 0; padding: 10px 18px; justify-content: flex-start; flex-wrap: wrap; background: #111; } +} + +@media (max-width: 650px) { + .site-header .logo { font-size: 22px; margin-right: auto; } + .site-header .user-area { gap: 13px; font-size: 13px; } + .search-bar { width: 34px; margin-left: 0; background: transparent; } + .search-bar .search-icon { border-color: #c8c5d6; } + .search-bar .search-icon:after { background: #c8c5d6; } + .search-bar input { display: none; } + .home-tabs { height: 43px; margin: 0 15px; gap: 8px; } + .home-tabs a { height: 43px; padding: 12px 16px 8px; font-size: 16px; } + .featured-stories { padding: 18px 15px; } + .featured-hero > img { height: 245px; } + .featured-overlay { top: 20px; left: 20px; font-size: 39px; } + .featured-list { display: flex; } + .home-module { padding: 27px 15px; } + .home-module-header h2 { font-size: 16px; } + .card-strip { grid-auto-columns: 225px; } + .preview-events { padding: 0 15px 20px; grid-template-columns: 1fr; } + .video-grid { display: flex; overflow-x: auto; } + .video-grid a { flex: 0 0 225px; } + .commission-note { font-size: 14px; } + .commission-note span { float: none; display: block; margin-top: 4px; } + .browse-main { overflow-x: auto; } + .browse-main .collection_table { min-width: 790px; } + .game-hero { min-height: 0; grid-template-columns: 105px minmax(0,1fr); gap: 14px; padding: 14px; } + .game-kicker { margin-bottom: 10px; font-size: 9px; } + .game-hero .summary h1 { font-size: 21px; } + .game-hero .summary .meta { font-size: 12px; } + .game-hero .stats-box { display: none; } + .game-actions { gap: 4px; } + .game-actions a { padding: 7px 8px; font-size: 11px; } +} diff --git a/sites/boardgamegeek/templates/_hotness_rail.html b/sites/boardgamegeek/templates/_hotness_rail.html new file mode 100644 index 00000000..70cf6f52 --- /dev/null +++ b/sites/boardgamegeek/templates/_hotness_rail.html @@ -0,0 +1,13 @@ + diff --git a/sites/boardgamegeek/templates/base.html b/sites/boardgamegeek/templates/base.html index b81e07d7..3717a97c 100644 --- a/sites/boardgamegeek/templates/base.html +++ b/sites/boardgamegeek/templates/base.html @@ -12,59 +12,50 @@ +
+
+
GEEKUPUpgrade Your Game
+
+
+
{% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} @@ -79,9 +70,8 @@
- © {{ current_year }} BoardGameGeek Mirror · WebHarbor benchmark environment · - About · - Help + +
© {{ current_year }} BoardGameGeek · About · Help
diff --git a/sites/boardgamegeek/templates/browse.html b/sites/boardgamegeek/templates/browse.html index aa93843f..f8ae44e6 100644 --- a/sites/boardgamegeek/templates/browse.html +++ b/sites/boardgamegeek/templates/browse.html @@ -1,8 +1,12 @@ {% extends "base.html" %} {% block title %}Browse Board Games | {{ site_name }}{% endblock %} {% block content %} -
-
+
+ {% include "_hotness_rail.html" %} +
+
We may earn a commission when you buy through our links. + {{ page }}, Next » +

Browse Board Games

Showing {{ ((page - 1) * per_page) + 1 }} – {{ ((page - 1) * per_page) + games | length }} @@ -26,6 +30,7 @@

Browse Board Games

Avg Rating Num Voters Weight + Shop @@ -48,6 +53,7 @@

Browse Board Games

{{ g.avg_rating | one_decimal }} {{ g.num_ratings | thousands }} {{ g.weight | two_decimal }} + View {% endfor %} diff --git a/sites/boardgamegeek/templates/index.html b/sites/boardgamegeek/templates/index.html index 748eb364..27d48fe9 100644 --- a/sites/boardgamegeek/templates/index.html +++ b/sites/boardgamegeek/templates/index.html @@ -1,134 +1,93 @@ {% extends "base.html" %} {% block title %}{{ site_name }} | Gaming Unplugged Since 2000{% endblock %} {% block content %} -
-
- {# Hot games — large grid #} - +
+ - {# Top overall #} -
-
- Top Ranked Board Games - Full browse → -
-
- - - - - - - - - {% for g in top_overall %} - - - - - - - - - {% endfor %} - -
#TitleGeek RatingAvgVoters
{{ g.overall_rank }} - {% if g.thumb_filename %} - - {% endif %} - - {{ g.name }} - ({{ g.year_published or '—' }}) - {{ g.bayes_average | one_decimal }}{{ g.avg_rating | one_decimal }}{{ g.num_ratings | thousands }}
-
+ - {# Active forum threads #} -
-
- Active Forum Threads - All forums → -
-
- {% for t in active_threads %} -
-
- {% if t.is_pinned %}PIN{% endif %} - {{ t.subject }} - · in - {{ t.forum.title }} - {% if t.forum.game %} · - {{ t.forum.game.name }} - {% endif %} - -
-
{{ t.num_posts - 1 }} replies
-
{{ t.num_views | thousands }} views
-
- {{ t.last_post_at | time_ago }} - by {{ t.author.username }} -
-
- {% else %} -

No active threads yet.

- {% endfor %} -
+
+
+

The Hotness

Top trending games today

+ See All › +
+ -
+ - +
{% endblock %} diff --git a/sites/boardgamegeek/templates/item.html b/sites/boardgamegeek/templates/item.html index 8b49d69a..5cefc68c 100644 --- a/sites/boardgamegeek/templates/item.html +++ b/sites/boardgamegeek/templates/item.html @@ -1,15 +1,19 @@ {% extends "base.html" %} {% block title %}{{ g.name }} ({{ g.year_published or '—' }}) | {{ site_name }}{% endblock %} {% block content %} -
-
+
+ {% include "_hotness_rail.html" %} +
Browse › {% if g.subtype == 'boardgameexpansion' %}Expansion{% else %}Board Game{% endif %} › {{ g.name }}
-
+
+ {% if g.image_filename %} + + {% endif %}
{% if g.image_filename %} {{ g.name }} @@ -18,6 +22,9 @@ {% endif %}
+
REIMPLEMENTED BY: {{ g.name | upper }} · + {% if g.overall_rank %}RANK: OVERALL {{ g.overall_rank }}{% endif %} +

{{ g.name }} ({{ g.year_published or '—' }})

{% if g.short_description %}

{{ g.short_description }}

@@ -54,6 +61,7 @@

{{ g.name }} ({{ g.year_published or '—' }})

+
{{ g.avg_rating | one_decimal }}
Overall Rank {% if g.overall_rank %}#{{ g.overall_rank }}{% else %}—{% endif %} @@ -105,12 +113,29 @@

{{ g.name }} ({{ g.year_published or '—' }})

+
+ 🛒 Buy a Copy + ▣ Sleeve It + {% if current_user.is_authenticated %} + ☷ Add to Collection + {% else %} + ☷ Add to Collection + {% endif %} + Log Play + ♥ {{ g.num_owners | thousands }} +
+

Description

@@ -160,7 +185,7 @@

Recent Forum Activity

{# User actions #} {% if current_user.is_authenticated %} -

Your Stuff

+

Your Stuff

{% endblock %}