feat: add boilerplate

This commit is contained in:
2026-10-26 21:56:23 +01:00
commit cc6675f7c6
29 changed files with 2231 additions and 0 deletions

1
app/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Guess the RAL

18
app/config.py Normal file
View File

@@ -0,0 +1,18 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
admin_password: str = "changeme"
secret_key: str = "change-this-secret"
database_url: str = "sqlite:///./data/guesstheral.db"
linkedin_user_agent: str = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
)
class Config:
env_file = ".env"
extra = "ignore"
settings = Settings()

39
app/database.py Normal file
View File

@@ -0,0 +1,39 @@
from sqlalchemy import create_engine, event
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.config import settings
class Base(DeclarativeBase):
pass
connect_args = {}
if settings.database_url.startswith("sqlite"):
connect_args = {"check_same_thread": False, "timeout": 30}
engine = create_engine(
settings.database_url,
connect_args=connect_args,
pool_pre_ping=True,
)
if settings.database_url.startswith("sqlite"):
@event.listens_for(engine, "connect")
def _sqlite_pragmas(dbapi_connection, _connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

185
app/game.py Normal file
View File

@@ -0,0 +1,185 @@
import json
import random
from sqlalchemy.orm import Session
from app.models import Answer, GameSession, Job, Player, Round
from app.ral import option_payload
WORK_LABELS = {
"hybrid": "Hybrid",
"remote": "Full remote",
"onsite": "In-place only",
}
def playable_jobs(db: Session) -> list[Job]:
return (
db.query(Job)
.filter(Job.status.in_(["ready", "needs_salary"]))
.filter((Job.ral_exact.isnot(None)) | (Job.ral_min.isnot(None)))
.all()
)
def start_game(db: Session, session: GameSession) -> Round:
jobs = playable_jobs(db)
if not jobs:
raise ValueError("No playable jobs in the database. Add some from the admin panel.")
hard = [j for j in jobs if j.difficulty >= 2] or jobs
easy = [j for j in jobs if j.difficulty == 1] or jobs
random.shuffle(hard)
random.shuffle(easy)
selected: list[Job] = []
hi = ei = 0
for i in range(session.num_rounds):
bonus = (i + 1) % 3 == 0
pool = hard if bonus else easy
idx_source = hi if bonus else ei
job = pool[idx_source % len(pool)]
if bonus:
hi += 1
else:
ei += 1
selected.append(job)
for old in list(session.rounds):
db.delete(old)
db.flush()
first = None
for i, job in enumerate(selected, start=1):
payload = json.loads(option_payload(job))
rnd = Round(
session_id=session.id,
job_id=job.id,
round_number=i,
is_bonus=(i % 3 == 0),
options_json=json.dumps(payload["options"]),
correct_option=payload["correct"],
status="open" if i == 1 else "pending",
)
db.add(rnd)
if i == 1:
first = rnd
session.status = "playing"
session.current_round = 1
db.commit()
db.refresh(first)
return first
def current_round(db: Session, session: GameSession) -> Round | None:
return (
db.query(Round)
.filter(Round.session_id == session.id, Round.round_number == session.current_round)
.first()
)
def score_answer(rnd: Round, job: Job, choice: str, precise: int | None, work_type: str) -> int:
points = 0
mc_ok = choice == rnd.correct_option
if rnd.is_bonus:
if mc_ok:
points += 150
if precise is not None and job.ral_exact:
points += 300 if precise == job.ral_exact else -50
if work_type and work_type == job.work_type:
points += 50
else:
if mc_ok:
points += 100
if precise is not None and job.ral_exact:
points += 200 if precise == job.ral_exact else -50
return points
def submit_answer(
db: Session,
player: Player,
rnd: Round,
choice: str,
precise: int | None,
work_type: str,
) -> Answer:
existing = (
db.query(Answer)
.filter(Answer.round_id == rnd.id, Answer.player_id == player.id)
.first()
)
if existing:
return existing
if rnd.status != "open":
raise ValueError("This round is closed")
job = rnd.job
points = score_answer(rnd, job, choice, precise, work_type)
answer = Answer(
round_id=rnd.id,
player_id=player.id,
choice=choice,
precise_guess=precise,
work_type_guess=work_type or "",
points=points,
)
db.add(answer)
db.commit()
db.refresh(answer)
return answer
def maybe_close_round(db: Session, session: GameSession, rnd: Round) -> bool:
db.refresh(rnd)
active = [p for p in session.players if p.connected]
if not active:
return False
answers = db.query(Answer).filter(Answer.round_id == rnd.id).all()
answered_ids = {a.player_id for a in answers}
if not all(p.id in answered_ids for p in active):
return False
rnd.status = "revealed"
for answer in answers:
answer.player.score += answer.points
db.commit()
return True
def advance(db: Session, session: GameSession) -> Round | None:
nxt = (
db.query(Round)
.filter(Round.session_id == session.id, Round.round_number == session.current_round + 1)
.first()
)
if not nxt:
session.status = "finished"
db.commit()
return None
nxt.status = "open"
session.current_round += 1
db.commit()
db.refresh(nxt)
return nxt
def public_job(session: GameSession, job: Job, bonus: bool) -> dict:
description = job.description_clean
if bonus:
words = description.split()
description = " ".join(words[: max(40, len(words) // 2)])
if len(words) > 40:
description += " ..."
data = {
"title": job.title,
"description": description,
"has_exact": job.ral_exact is not None,
"location": job.location,
}
if not session.hide_company:
data["company"] = job.company
if not session.hide_subject:
data["subject"] = job.subject
if session.extra_guesses:
data["extra"] = job.extra_info
return data

141
app/linkedin.py Normal file
View File

@@ -0,0 +1,141 @@
import json
import re
import httpx
from bs4 import BeautifulSoup
from app.config import settings
from app.models import Job
from app.ral import detect_subject, detect_work_type, extract_salary, hide_salary
def _text(el) -> str:
return el.get_text(" ", strip=True) if el else ""
def _from_ld_json(data: dict) -> dict:
title = data.get("title") or ""
description = BeautifulSoup(data.get("description") or "", "lxml").get_text(" ", strip=True)
org = data.get("hiringOrganization") or {}
company = org.get("name") if isinstance(org, dict) else ""
loc = data.get("jobLocation") or {}
if isinstance(loc, list) and loc:
loc = loc[0]
location = ""
if isinstance(loc, dict):
address = loc.get("address") or {}
if isinstance(address, dict):
location = address.get("addressLocality") or address.get("addressRegion") or ""
extracted = {"ral_min": None, "ral_max": None, "ral_exact": None}
salary = data.get("baseSalary") or {}
value = salary.get("value") if isinstance(salary, dict) else {}
if isinstance(value, dict):
try:
if value.get("value"):
extracted["ral_exact"] = int(float(str(value["value"]).replace(",", "")))
if value.get("minValue"):
extracted["ral_min"] = int(float(str(value["minValue"]).replace(",", "")))
if value.get("maxValue"):
extracted["ral_max"] = int(float(str(value["maxValue"]).replace(",", "")))
except (TypeError, ValueError):
pass
return {
"title": title,
"description": description,
"company": company or "",
"location": location or "",
**extracted,
}
def parse_job_html(html: str, url: str) -> dict:
soup = BeautifulSoup(html, "lxml")
title = _text(soup.select_one("h1")) or _text(soup.select_one(".top-card-layout__title"))
company = _text(soup.select_one(".topcard__org-name-link")) or _text(
soup.select_one(".top-card-layout__card .topcard__flavor")
)
location = _text(soup.select_one(".topcard__flavor--bullet"))
description = _text(soup.select_one(".show-more-less-html__markup")) or _text(
soup.select_one(".description__text")
)
extracted = {"ral_min": None, "ral_max": None, "ral_exact": None}
for script in soup.select('script[type="application/ld+json"]'):
try:
data = json.loads(script.text)
except json.JSONDecodeError:
continue
if isinstance(data, list):
data = next(
(item for item in data if isinstance(item, dict) and item.get("@type") == "JobPosting"),
{},
)
if isinstance(data, dict) and data.get("@type") == "JobPosting":
parsed = _from_ld_json(data)
title = title or parsed["title"]
description = description or parsed["description"]
company = company or parsed["company"]
location = location or parsed["location"]
extracted = {k: parsed[k] for k in ("ral_min", "ral_max", "ral_exact")}
break
if not title:
og = soup.select_one('meta[property="og:title"]')
title = og["content"] if og and og.get("content") else "LinkedIn job"
blob = f"{title} {company} {description}"
if not any(extracted.values()):
extracted = extract_salary(blob)
extra_bits = []
if location:
extra_bits.append(f"Location: {location}")
extra_bits.extend(_text(el) for el in soup.select(".description__job-criteria-item") if _text(el))
has_salary = bool(extracted.get("ral_exact") or extracted.get("ral_min"))
spread = 0
if extracted.get("ral_max") and extracted.get("ral_min"):
spread = extracted["ral_max"] - extracted["ral_min"]
fields = {c.name for c in Job.__table__.columns}
payload = {
"linkedin_url": url,
"title": (title or "LinkedIn job")[:300],
"company": (company or "Unknown company")[:200],
"location": (location or "")[:200],
"description": description or "",
"description_clean": hide_salary(description or ""),
"extra_info": " | ".join(extra_bits)[:500],
"subject": detect_subject(title or "", description or ""),
"work_type": detect_work_type(blob),
"difficulty": 2 if spread > 15000 else 1,
"source": "linkedin",
"status": "ready" if has_salary else "needs_salary",
"fetch_error": "" if has_salary else "Salary not found; edit in admin",
**extracted,
}
return {k: v for k, v in payload.items() if k in fields}
async def fetch_linkedin_job(url: str) -> dict:
if "linkedin.com" not in url:
raise ValueError("Only LinkedIn job URLs are supported")
headers = {
"User-Agent": settings.linkedin_user_agent,
"Accept-Language": "en-US,en;q=0.9,it;q=0.8",
"Accept": "text/html,application/xhtml+xml",
}
async with httpx.AsyncClient(follow_redirects=True, timeout=25.0, headers=headers) as client:
response = await client.get(url)
response.raise_for_status()
if "authwall" in str(response.url):
raise RuntimeError("LinkedIn showed a login wall for this URL")
html = response.text
if "/jobs/view/" in url and "JobPosting" not in html:
guest = url.split("?")[0]
response = await client.get(guest)
html = response.text
parsed = parse_job_html(html, url)
if not parsed.get("description") and not parsed.get("title"):
raise RuntimeError("Could not parse job posting from LinkedIn page")
return parsed

385
app/main.py Normal file
View File

@@ -0,0 +1,385 @@
import json
from pathlib import Path
from fastapi import Depends, FastAPI, Form, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from starlette.middleware.sessions import SessionMiddleware
from app.config import settings
from app.database import Base, SessionLocal, engine, get_db
from app.game import (
WORK_LABELS,
advance,
current_round,
maybe_close_round,
playable_jobs,
public_job,
start_game,
submit_answer,
)
from app.models import FetchTask, GameSession, Job, Player, Round
from app.ral import hide_salary
from app.realtime import hub
from app.seed import seed_jobs
BASE = Path(__file__).resolve().parent
app = FastAPI(title="Guess the RAL")
app.add_middleware(SessionMiddleware, secret_key=settings.secret_key)
app.mount("/static", StaticFiles(directory=BASE / "static"), name="static")
templates = Jinja2Templates(directory=str(BASE / "templates"))
@app.on_event("startup")
def startup() -> None:
Path("data").mkdir(exist_ok=True)
Base.metadata.create_all(bind=engine)
db = SessionLocal()
seed_jobs(db)
db.close()
def render(request: Request, name: str, **ctx):
ctx["request"] = request
return templates.TemplateResponse(name, ctx)
def require_admin(request: Request):
if not request.session.get("admin"):
return False
return True
def snapshot(db: Session, session: GameSession) -> dict:
rnd = current_round(db, session) if session.status != "lobby" else None
players = [
{"id": p.id, "name": p.name, "score": p.score, "connected": p.connected, "host": p.is_host}
for p in sorted(session.players, key=lambda x: (-x.score, x.name.lower()))
]
payload = {
"type": "state",
"session": {
"id": session.id,
"status": session.status,
"current_round": session.current_round,
"num_rounds": session.num_rounds,
"max_players": session.max_players,
"extra_guesses": session.extra_guesses,
"hide_company": session.hide_company,
"hide_subject": session.hide_subject,
},
"players": players,
}
if rnd:
answered = {a.player_id for a in rnd.answers}
payload["round"] = {
"number": rnd.round_number,
"bonus": rnd.is_bonus,
"status": rnd.status,
"options": json.loads(rnd.options_json),
"job": public_job(session, rnd.job, rnd.is_bonus),
"answered_ids": list(answered),
"answered": [p.name for p in session.players if p.id in answered],
"waiting": [p.name for p in session.players if p.connected and p.id not in answered],
"work_types": WORK_LABELS,
}
if rnd.status == "revealed":
payload["round"]["correct"] = rnd.correct_option
payload["round"]["ral_exact"] = rnd.job.ral_exact
payload["round"]["ral_min"] = rnd.job.ral_min
payload["round"]["ral_max"] = rnd.job.ral_max
payload["round"]["work_type"] = rnd.job.work_type
payload["round"]["company"] = rnd.job.company
payload["round"]["answers"] = [
{
"name": a.player.name,
"choice": a.choice,
"precise": a.precise_guess,
"work_type": a.work_type_guess,
"points": a.points,
"correct": a.choice == rnd.correct_option,
}
for a in rnd.answers
]
if session.status == "finished":
payload["winner"] = players[0]["name"] if players else ""
return payload
@app.get("/", response_class=HTMLResponse)
def home(request: Request):
return render(request, "index.html")
@app.post("/create")
def create_session(
request: Request,
name: str = Form(...),
max_players: int = Form(8),
num_rounds: int = Form(8),
extra_guesses: str | None = Form(None),
hide_company: str | None = Form(None),
hide_subject: str | None = Form(None),
db: Session = Depends(get_db),
):
name = name.strip()[:24] or "Host"
session = GameSession(
max_players=max(2, min(12, max_players)),
num_rounds=max(4, min(32, num_rounds)),
extra_guesses=bool(extra_guesses),
hide_company=bool(hide_company),
hide_subject=bool(hide_subject),
)
db.add(session)
db.flush()
player = Player(session_id=session.id, name=name, is_host=True)
db.add(player)
db.commit()
request.session["player_token"] = player.token
request.session["player_name"] = player.name
return RedirectResponse(f"/play/{session.id}", status_code=303)
@app.get("/join/{session_id}", response_class=HTMLResponse)
def join_page(request: Request, session_id: str, db: Session = Depends(get_db)):
session = db.get(GameSession, session_id)
if not session:
return render(request, "index.html", error="Session not found")
return render(request, "join.html", session=session)
@app.post("/join/{session_id}")
def join_session(
request: Request,
session_id: str,
name: str = Form(...),
db: Session = Depends(get_db),
):
session = db.get(GameSession, session_id)
if not session:
return render(request, "index.html", error="Session not found")
name = name.strip()[:24] or "Player"
existing = next((p for p in session.players if p.name.lower() == name.lower()), None)
if existing:
player = existing
player.connected = True
else:
if session.status != "lobby":
return render(request, "join.html", session=session, error="Match already started")
connected = [p for p in session.players if p.connected]
if len(connected) >= session.max_players:
return render(request, "join.html", session=session, error="Lobby is full")
player = Player(session_id=session.id, name=name)
db.add(player)
db.commit()
request.session["player_token"] = player.token
request.session["player_name"] = player.name
return RedirectResponse(f"/play/{session.id}", status_code=303)
@app.get("/play/{session_id}", response_class=HTMLResponse)
def play(request: Request, session_id: str, db: Session = Depends(get_db)):
session = db.get(GameSession, session_id)
if not session:
return render(request, "index.html", error="Session not found")
token = request.session.get("player_token")
player = next((p for p in session.players if p.token == token), None)
if not player:
return RedirectResponse(f"/join/{session_id}", status_code=303)
return render(request, "play.html", session=session, player=player)
@app.post("/play/{session_id}/settings")
def update_settings(
request: Request,
session_id: str,
max_players: int = Form(8),
num_rounds: int = Form(8),
extra_guesses: str | None = Form(None),
hide_company: str | None = Form(None),
hide_subject: str | None = Form(None),
db: Session = Depends(get_db),
):
session = db.get(GameSession, session_id)
player = next((p for p in session.players if p.token == request.session.get("player_token")), None)
if not session or not player or not player.is_host or session.status != "lobby":
return RedirectResponse(f"/play/{session_id}", status_code=303)
session.max_players = max(2, min(12, max_players))
session.num_rounds = max(4, min(32, num_rounds))
session.extra_guesses = bool(extra_guesses)
session.hide_company = bool(hide_company)
session.hide_subject = bool(hide_subject)
db.commit()
return RedirectResponse(f"/play/{session_id}", status_code=303)
@app.websocket("/ws/{session_id}")
async def ws_play(websocket: WebSocket, session_id: str):
token = websocket.query_params.get("token", "")
db = SessionLocal()
try:
session = db.get(GameSession, session_id)
player = next((p for p in (session.players if session else []) if p.token == token), None)
if not session or not player:
await websocket.close()
return
player.connected = True
db.commit()
await hub.join(session_id, websocket)
await hub.broadcast(session_id, snapshot(db, session))
while True:
raw = await websocket.receive_text()
try:
msg = json.loads(raw)
except json.JSONDecodeError:
continue
db.expire_all()
session = db.get(GameSession, session_id)
player = db.get(Player, player.id)
action = msg.get("action")
if action == "start" and player.is_host and session.status == "lobby":
try:
start_game(db, session)
except ValueError as exc:
await websocket.send_text(json.dumps({"type": "error", "message": str(exc)}))
elif action == "answer" and session.status == "playing":
rnd = current_round(db, session)
if rnd and rnd.status == "open":
precise = msg.get("precise")
precise_val = int(precise) if precise not in (None, "", False) else None
try:
submit_answer(
db,
player,
rnd,
str(msg.get("choice") or ""),
precise_val,
str(msg.get("work_type") or ""),
)
except ValueError:
pass
db.refresh(rnd)
db.refresh(session)
if maybe_close_round(db, session, rnd):
db.refresh(session)
elif action == "next" and player.is_host:
rnd = current_round(db, session)
if rnd and rnd.status == "revealed":
advance(db, session)
await hub.broadcast(session_id, snapshot(db, session))
except WebSocketDisconnect:
pass
finally:
hub.leave(session_id, websocket)
try:
player = next((p for p in db.get(GameSession, session_id).players if p.token == token), None)
if player:
player.connected = False
db.commit()
session = db.get(GameSession, session_id)
await hub.broadcast(session_id, snapshot(db, session))
except Exception:
pass
db.close()
@app.get("/admin", response_class=HTMLResponse)
def admin(request: Request, db: Session = Depends(get_db)):
if not require_admin(request):
return render(request, "admin_login.html")
jobs = db.query(Job).order_by(Job.id.desc()).limit(80).all()
tasks = db.query(FetchTask).order_by(FetchTask.id.desc()).limit(40).all()
queued = db.query(FetchTask).filter(FetchTask.status.in_(["queued", "running"])).count()
return render(
request,
"admin.html",
jobs=jobs,
tasks=tasks,
queued=queued,
playable=len(playable_jobs(db)),
)
@app.post("/admin/login")
def admin_login(request: Request, password: str = Form(...)):
if password != settings.admin_password:
return render(request, "admin_login.html", error="Wrong password")
request.session["admin"] = True
return RedirectResponse("/admin", status_code=303)
@app.post("/admin/logout")
def admin_logout(request: Request):
request.session.pop("admin", None)
return RedirectResponse("/admin", status_code=303)
@app.post("/admin/fetch")
def admin_fetch(request: Request, url: str = Form(...), db: Session = Depends(get_db)):
if not require_admin(request):
return RedirectResponse("/admin", status_code=303)
db.add(FetchTask(url=url.strip(), status="queued"))
db.commit()
return RedirectResponse("/admin", status_code=303)
@app.post("/admin/job")
def admin_add_job(
request: Request,
title: str = Form(...),
company: str = Form(""),
subject: str = Form("other"),
location: str = Form(""),
work_type: str = Form("hybrid"),
ral_exact: str = Form(""),
ral_min: str = Form(""),
ral_max: str = Form(""),
extra_info: str = Form(""),
description: str = Form(""),
db: Session = Depends(get_db),
):
if not require_admin(request):
return RedirectResponse("/admin", status_code=303)
def num(v):
v = (v or "").replace(".", "").replace(" ", "")
return int(v) if v.isdigit() else None
exact, low, high = num(ral_exact), num(ral_min), num(ral_max)
db.add(
Job(
title=title,
company=company,
subject=subject,
location=location,
work_type=work_type,
ral_exact=exact,
ral_min=low,
ral_max=high,
extra_info=extra_info,
description=description,
description_clean=hide_salary(description),
status="ready" if (exact or low) else "needs_salary",
source="manual",
)
)
db.commit()
return RedirectResponse("/admin", status_code=303)
@app.post("/admin/job/{job_id}/status")
def admin_job_status(
request: Request,
job_id: int,
status: str = Form(...),
db: Session = Depends(get_db),
):
if not require_admin(request):
return RedirectResponse("/admin", status_code=303)
job = db.get(Job, job_id)
if job and status in {"ready", "needs_salary", "hidden"}:
job.status = status
db.commit()
return RedirectResponse("/admin", status_code=303)

114
app/models.py Normal file
View File

@@ -0,0 +1,114 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def new_uuid() -> str:
return str(uuid.uuid4())
class Job(Base):
__tablename__ = "jobs"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
linkedin_url: Mapped[str] = mapped_column(String(500), default="")
title: Mapped[str] = mapped_column(String(300))
company: Mapped[str] = mapped_column(String(200), default="")
subject: Mapped[str] = mapped_column(String(80), default="other")
location: Mapped[str] = mapped_column(String(200), default="")
description: Mapped[str] = mapped_column(Text, default="")
description_clean: Mapped[str] = mapped_column(Text, default="")
extra_info: Mapped[str] = mapped_column(Text, default="")
ral_min: Mapped[int | None] = mapped_column(Integer, nullable=True)
ral_max: Mapped[int | None] = mapped_column(Integer, nullable=True)
ral_exact: Mapped[int | None] = mapped_column(Integer, nullable=True)
work_type: Mapped[str] = mapped_column(String(20), default="hybrid")
difficulty: Mapped[int] = mapped_column(Integer, default=1)
status: Mapped[str] = mapped_column(String(20), default="ready")
fetch_error: Mapped[str] = mapped_column(Text, default="")
source: Mapped[str] = mapped_column(String(40), default="seed")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class FetchTask(Base):
__tablename__ = "fetch_tasks"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
url: Mapped[str] = mapped_column(String(500))
status: Mapped[str] = mapped_column(String(20), default="queued")
error: Mapped[str] = mapped_column(Text, default="")
job_id: Mapped[int | None] = mapped_column(ForeignKey("jobs.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
job: Mapped[Job | None] = relationship()
class GameSession(Base):
__tablename__ = "game_sessions"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
host_token: Mapped[str] = mapped_column(String(36), default=new_uuid)
max_players: Mapped[int] = mapped_column(Integer, default=8)
num_rounds: Mapped[int] = mapped_column(Integer, default=8)
extra_guesses: Mapped[bool] = mapped_column(Boolean, default=False)
hide_company: Mapped[bool] = mapped_column(Boolean, default=False)
hide_subject: Mapped[bool] = mapped_column(Boolean, default=False)
status: Mapped[str] = mapped_column(String(20), default="lobby")
current_round: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
players: Mapped[list["Player"]] = relationship(back_populates="session", cascade="all, delete-orphan")
rounds: Mapped[list["Round"]] = relationship(back_populates="session", cascade="all, delete-orphan")
class Player(Base):
__tablename__ = "players"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
session_id: Mapped[str] = mapped_column(ForeignKey("game_sessions.id"))
name: Mapped[str] = mapped_column(String(40))
score: Mapped[int] = mapped_column(Integer, default=0)
connected: Mapped[bool] = mapped_column(Boolean, default=True)
is_host: Mapped[bool] = mapped_column(Boolean, default=False)
token: Mapped[str] = mapped_column(String(36), default=new_uuid)
session: Mapped[GameSession] = relationship(back_populates="players")
answers: Mapped[list["Answer"]] = relationship(back_populates="player", cascade="all, delete-orphan")
class Round(Base):
__tablename__ = "rounds"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
session_id: Mapped[str] = mapped_column(ForeignKey("game_sessions.id"))
job_id: Mapped[int] = mapped_column(ForeignKey("jobs.id"))
round_number: Mapped[int] = mapped_column(Integer)
is_bonus: Mapped[bool] = mapped_column(Boolean, default=False)
options_json: Mapped[str] = mapped_column(Text)
correct_option: Mapped[str] = mapped_column(String(80))
status: Mapped[str] = mapped_column(String(20), default="open")
session: Mapped[GameSession] = relationship(back_populates="rounds")
job: Mapped[Job] = relationship()
answers: Mapped[list["Answer"]] = relationship(back_populates="round", cascade="all, delete-orphan")
class Answer(Base):
__tablename__ = "answers"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
round_id: Mapped[int] = mapped_column(ForeignKey("rounds.id"))
player_id: Mapped[int] = mapped_column(ForeignKey("players.id"))
choice: Mapped[str] = mapped_column(String(80))
precise_guess: Mapped[int | None] = mapped_column(Integer, nullable=True)
work_type_guess: Mapped[str] = mapped_column(String(20), default="")
points: Mapped[int] = mapped_column(Integer, default=0)
round: Mapped[Round] = relationship(back_populates="answers")
player: Mapped[Player] = relationship(back_populates="answers")

189
app/ral.py Normal file
View File

@@ -0,0 +1,189 @@
import json
import random
import re
from typing import Any
SALARY_PATTERNS = [
r"ral\s*(?:lorda|annua|annuale)?\s*[:\-]?\s*",
r"retribuzione\s*(?:annua\s*)?lorda\s*[:\-]?\s*",
r"stipendio\s*(?:annuo|lordo)?\s*[:\-]?\s*",
r"compensation\s*[:\-]?\s*",
r"salary\s*(?:range)?\s*[:\-]?\s*",
r"ctc\s*[:\-]?\s*",
r"package\s*[:\-]?\s*",
]
NUMBER = r"(?:€|eur|euro)?\s*(\d{1,3}(?:[.\s]\d{3})+|\d{2,6})\s*(?:€|eur|k|mila)?"
RANGE = (
r"(?:tra|from|between)?\s*"
+ NUMBER
+ r"\s*(?:-|–|—|/|e|and|to|a)\s*"
+ NUMBER
)
BANDS = [
(0, 25000, "Less than 25k"),
(25000, 30000, "Between 25k and 30k"),
(30000, 35000, "Between 30k and 35k"),
(35000, 40000, "Between 35k and 40k"),
(40000, 45000, "Between 40k and 45k"),
(45000, 50000, "Between 45k and 50k"),
(50000, 55000, "Between 50k and 55k"),
(55000, 60000, "Between 55k and 60k"),
(60000, 70000, "Between 60k and 70k"),
(70000, 80000, "Between 70k and 80k"),
(80000, 90000, "Between 80k and 90k"),
(90000, 100000, "Between 90k and 100k"),
(100000, 120000, "Between 100k and 120k"),
(120000, 150000, "Between 120k and 150k"),
(150000, 10**9, "Above 150k"),
]
def parse_amount(raw: str) -> int:
compact = raw.lower().replace(" ", "").replace("€", "").replace("eur", "")
multiplier = 1000 if compact.endswith("k") or compact.endswith("mila") else 1
compact = compact.replace("k", "").replace("mila", "").replace(".", "")
digits = re.sub(r"[^\d]", "", compact)
if not digits:
return 0
value = int(digits)
if multiplier == 1000 and value < 1000:
value *= 1000
if 1000 <= value < 10000 and not raw.lower().endswith(("k", "mila")):
# 35.000 style already handled; 35000 stays
pass
return value
def extract_salary(text: str) -> dict[str, int | None]:
blob = text.lower().replace("\xa0", " ")
ral_min = ral_max = ral_exact = None
for match in re.finditer(RANGE, blob, flags=re.I):
a, b = parse_amount(match.group(1)), parse_amount(match.group(2))
if 15000 <= a <= 400000 and 15000 <= b <= 400000:
ral_min, ral_max = min(a, b), max(a, b)
break
if ral_min is None:
labeled = re.search(
r"(?:ral|retribuzione|salary|stipendio|package)[^\n]{0,40}" + NUMBER,
blob,
flags=re.I,
)
if labeled:
value = parse_amount(labeled.group(1))
if 15000 <= value <= 400000:
ral_exact = value
if ral_min is None and ral_exact is None:
above = re.search(r"(?:above|oltre|maggiore di|>)\s*" + NUMBER, blob, flags=re.I)
if above:
value = parse_amount(above.group(1))
if 15000 <= value <= 400000:
ral_min = value
ral_max = value + 20000
less = re.search(r"(?:less than|fino a|inferiore a|<)\s*" + NUMBER, blob, flags=re.I)
if less and ral_min is None:
value = parse_amount(less.group(1))
if 15000 <= value <= 400000:
ral_min = max(15000, value - 15000)
ral_max = value
return {"ral_min": ral_min, "ral_max": ral_max, "ral_exact": ral_exact}
def hide_salary(text: str) -> str:
cleaned = text
replacements = [
RANGE,
r"(?:ral|retribuzione\s*annua\s*lorda|stipendio|salary|compensation|ctc)\s*[:\-]?\s*(?:€|eur|euro)?\s*\d[\d.\sKkmila€euro\-–toaeand/]{1,48}",
r"(?:€|eur)\s*\d{2,3}(?:[.\s]\d{3})+(?:\s*(?:-|–|to|a)\s*(?:€|eur)?\s*\d{2,3}(?:[.\s]\d{3})+)?",
r"\b\d{2,3}\s*k(?:\s*(?:-|–|to|a)\s*\d{2,3}\s*k)?\b",
r"\b\d{5,6}\s*(?:€|eur|euro)?\b",
]
for pattern in replacements:
cleaned = re.sub(pattern, "[RAL HIDDEN]", cleaned, flags=re.I)
cleaned = re.sub(r"(\[RAL HIDDEN\]\s*){2,}", "[RAL HIDDEN] ", cleaned)
return cleaned.strip()
def midpoint(job: Any) -> int:
if job.ral_exact:
return job.ral_exact
if job.ral_min and job.ral_max:
return (job.ral_min + job.ral_max) // 2
return job.ral_min or job.ral_max or 40000
def correct_band(value: int) -> tuple[int, int, str]:
for low, high, label in BANDS:
if low <= value < high:
return low, high, label
return BANDS[-1]
def generate_options(job: Any) -> tuple[list[dict], str]:
value = midpoint(job)
idx = next(i for i, (low, high, _) in enumerate(BANDS) if low <= value < high)
start = max(0, idx - 2)
end = min(len(BANDS), start + 4)
start = max(0, end - 4)
chosen = BANDS[start:end]
if len(chosen) < 4:
chosen = BANDS[:4]
options = []
correct = ""
for low, high, label in chosen:
if high >= 10**9:
key = f"above_{low}"
text = f"Above {low // 1000}k"
elif low == 0:
key = f"less_{high}"
text = f"Less than {high // 1000}k"
else:
key = f"between_{low}_{high}"
text = f"Between {low // 1000}k and {high // 1000}k"
is_correct = low <= value < high
if is_correct:
correct = key
options.append({"key": key, "label": text})
random.shuffle(options)
return options, correct
def option_payload(job: Any) -> str:
options, correct = generate_options(job)
return json.dumps({"options": options, "correct": correct})
def detect_work_type(text: str) -> str:
blob = text.lower()
if any(w in blob for w in ["full remote", "fully remote", "100% remote", "full-remote", "da remoto"]):
return "remote"
if any(w in blob for w in ["hybrid", "ibrido", "smart working"]):
return "hybrid"
if any(w in blob for w in ["on-site", "onsite", "in sede", "in office", "presenza"]):
return "onsite"
return "hybrid"
def detect_subject(title: str, description: str) -> str:
blob = f"{title} {description}".lower()
mapping = {
"technology": ["developer", "engineer", "software", "data", "devops", "it ", "python", "java", "cloud"],
"financial": ["bank", "finance", "fintech", "accounting", "auditor", "investment"],
"business": ["sales", "account manager", "business", "marketing", "product manager"],
"healthcare": ["nurse", "medical", "pharma", "clinic", "health"],
"legal": ["lawyer", "legal", "compliance", "avvocato"],
"hr": ["recruiter", "human resources", "hr ", "people"],
"design": ["designer", "ux", "ui", "graphic"],
}
for subject, words in mapping.items():
if any(w in blob for w in words):
return subject
return "other"

31
app/realtime.py Normal file
View File

@@ -0,0 +1,31 @@
import json
from collections import defaultdict
from fastapi import WebSocket
class Hub:
def __init__(self) -> None:
self.rooms: dict[str, list[WebSocket]] = defaultdict(list)
async def join(self, session_id: str, ws: WebSocket) -> None:
await ws.accept()
self.rooms[session_id].append(ws)
def leave(self, session_id: str, ws: WebSocket) -> None:
conns = self.rooms.get(session_id, [])
if ws in conns:
conns.remove(ws)
async def broadcast(self, session_id: str, payload: dict) -> None:
dead = []
for ws in list(self.rooms.get(session_id, [])):
try:
await ws.send_text(json.dumps(payload))
except Exception:
dead.append(ws)
for ws in dead:
self.leave(session_id, ws)
hub = Hub()

45
app/seed.py Normal file
View File

@@ -0,0 +1,45 @@
import json
from pathlib import Path
from sqlalchemy.orm import Session
from app.models import Job
from app.ral import hide_salary
def seed_jobs(db: Session) -> int:
path = Path(__file__).resolve().parent.parent / "seed_jobs.json"
if not path.exists():
return 0
jobs = json.loads(path.read_text(encoding="utf-8"))
created = 0
for item in jobs:
exists = (
db.query(Job)
.filter(Job.title == item["title"], Job.company == item["company"])
.first()
)
if exists:
continue
description = item["description"]
db.add(
Job(
title=item["title"],
company=item["company"],
subject=item.get("subject", "other"),
location=item.get("location", ""),
description=description,
description_clean=hide_salary(description),
extra_info=item.get("extra_info", ""),
ral_min=item.get("ral_min"),
ral_max=item.get("ral_max"),
ral_exact=item.get("ral_exact"),
work_type=item.get("work_type", "hybrid"),
difficulty=item.get("difficulty", 1),
status="ready",
source="seed",
)
)
created += 1
db.commit()
return created

183
app/static/css/style.css Normal file
View File

@@ -0,0 +1,183 @@
:root {
--bg: #0b0f1a;
--panel: #1a1433;
--ink: #f4e8c1;
--accent: #7dff6a;
--pink: #ff5d8f;
--cyan: #5ce1ff;
--gold: #ffd35c;
--shadow: #05060c;
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body {
background: radial-gradient(circle at 20% 0%, #2a1b5c 0%, var(--bg) 45%);
color: var(--ink);
font-family: "VT323", monospace;
font-size: 22px;
image-rendering: pixelated;
}
.scanlines {
pointer-events: none;
position: fixed;
inset: 0;
background: repeating-linear-gradient(
to bottom,
rgba(0,0,0,0.15) 0px,
rgba(0,0,0,0.15) 1px,
transparent 2px,
transparent 4px
);
z-index: 50;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-bottom: 4px solid var(--accent);
}
.logo {
font-family: "Press Start 2P", monospace;
font-size: 14px;
color: var(--gold);
text-decoration: none;
text-shadow: 3px 3px 0 #000;
}
nav a { color: var(--cyan); text-decoration: none; font-family: "Press Start 2P", monospace; font-size: 10px; }
main { max-width: 1100px; margin: 24px auto; padding: 0 16px 48px; }
.pixel-panel {
background: var(--panel);
border: 4px solid var(--ink);
box-shadow: 8px 8px 0 var(--shadow);
padding: 20px;
margin-bottom: 20px;
}
h1, h2, h3 { font-family: "Press Start 2P", monospace; font-size: 14px; color: var(--gold); }
.tiny { font-size: 16px; opacity: 0.8; word-break: break-all; }
.hint { text-align: center; opacity: 0.7; }
.stack { display: flex; flex-direction: column; gap: 12px; }
label { display: flex; flex-direction: column; gap: 6px; font-family: "Press Start 2P", monospace; font-size: 10px; }
input, select, textarea {
font-family: "VT323", monospace;
font-size: 22px;
background: #0d0820;
color: var(--ink);
border: 3px solid var(--cyan);
padding: 8px;
}
.check { flex-direction: row; align-items: center; gap: 10px; }
.check input { width: auto; }
.btn {
font-family: "Press Start 2P", monospace;
font-size: 12px;
background: var(--accent);
color: #041204;
border: 4px solid #000;
padding: 12px 16px;
cursor: pointer;
box-shadow: 4px 4px 0 #000;
}
.btn:hover { transform: translate(2px, 2px); box-shadow: 2px 2px 0 #000; }
.btn-ghost { background: var(--cyan); }
.btn-small, .btn-tiny { font-size: 9px; padding: 8px; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.alert { background: var(--pink); color: #000; padding: 8px; border: 3px solid #000; }
.play-layout { display: grid; grid-template-columns: 280px 1fr; gap: 16px; }
.board ol { padding-left: 22px; }
.board li { margin: 6px 0; }
.board li.host::after { content: " ★"; color: var(--gold); }
.board li.off { opacity: 0.4; }
.stage .job-title { font-family: "Press Start 2P", monospace; font-size: 13px; line-height: 1.6; }
.meta { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
.chip { background: #000; border: 2px solid var(--cyan); padding: 4px 8px; font-size: 16px; }
.chip.bonus { border-color: var(--gold); color: var(--gold); }
.description { background: #0d0820; border: 3px dashed var(--pink); padding: 12px; line-height: 1.3; max-height: 240px; overflow: auto; }
.options { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 16px 0; }
.opt {
background: #120c28;
border: 3px solid var(--ink);
padding: 12px;
cursor: pointer;
text-align: left;
color: var(--ink);
font-family: "VT323", monospace;
font-size: 22px;
}
.opt.picked { border-color: var(--accent); background: #16351a; }
.opt.good { border-color: var(--accent); background: #1d5a24; }
.opt.bad { border-color: var(--pink); }
.results { margin-top: 12px; }
.results li { list-style: none; }
.results ul { padding: 0; }
.row-between { display: flex; justify-content: space-between; align-items: center; }
.admin-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.pixel-table { width: 100%; border-collapse: collapse; font-size: 18px; }
.pixel-table th, .pixel-table td { border: 2px solid #3c2f66; padding: 6px; text-align: left; vertical-align: top; }
.clip { max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.st-ready, .st-done { color: var(--accent); }
.st-queued, .st-running, .st-needs_salary { color: var(--gold); }
.st-error, .st-hidden { color: var(--pink); }
.mascot-wrap { display: grid; grid-template-columns: 110px 1fr; gap: 12px; align-items: center; margin-bottom: 16px; }
.mascot-wrap.compact { grid-template-columns: 80px 1fr; }
.speech {
background: #fff8dc;
color: #1a1028;
border: 4px solid #000;
padding: 10px 12px;
position: relative;
min-height: 72px;
}
.speech::before {
content: "";
position: absolute;
left: -16px;
top: 18px;
border: 8px solid transparent;
border-right-color: #000;
}
.ralo { display: flex; flex-direction: column; align-items: center; gap: 6px; }
.ralo-name { font-family: "Press Start 2P", monospace; font-size: 8px; color: var(--accent); }
.ralo-pixel {
width: 16px;
height: 16px;
transform: scale(1.5);
transform-origin: top center;
margin-bottom: 48px;
animation: bounce 1.1s steps(2) infinite;
background: #000;
box-shadow:
4px 0 0 #000, 8px 0 0 #000, 12px 0 0 #000,
0 4px 0 #000, 4px 4px 0 #7dff6a, 8px 4px 0 #7dff6a, 12px 4px 0 #000, 16px 4px 0 #000,
0 8px 0 #000, 4px 8px 0 #7dff6a, 8px 8px 0 #ffd35c, 12px 8px 0 #7dff6a, 16px 8px 0 #000,
0 12px 0 #000, 4px 12px 0 #5ce1ff, 8px 12px 0 #7dff6a, 12px 12px 0 #5ce1ff, 16px 12px 0 #000,
4px 16px 0 #000, 8px 16px 0 #ff5d8f, 12px 16px 0 #000,
4px 20px 0 #000, 8px 20px 0 #000, 12px 20px 0 #000;
}
@keyframes bounce {
0% { translate: 0 0; }
100% { translate: 0 2px; }
}
@media (max-width: 800px) {
.play-layout, .admin-grid, .grid-2, .mascot-wrap, .options { grid-template-columns: 1fr; }
.logo { font-size: 10px; }
}

185
app/static/js/play.js Normal file
View File

@@ -0,0 +1,185 @@
const root = document.querySelector(".play-layout");
const sessionId = root.dataset.session;
const token = root.dataset.token;
const isHost = root.dataset.host === "1";
const playerId = Number(root.dataset.player);
let roundSeen = 0;
const playersEl = document.getElementById("players");
const stageEl = document.getElementById("stage");
const tipEl = document.getElementById("tip");
const inviteEl = document.getElementById("invite");
const hostForm = document.getElementById("host-form");
const startBtn = document.getElementById("start-btn");
const nextBtn = document.getElementById("next-btn");
inviteEl.textContent = "Invite: " + location.origin + "/join/" + sessionId;
let lastChoice = null;
let submitted = false;
let proto = location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${proto}://${location.host}/ws/${sessionId}?token=${token}`);
ws.onmessage = (ev) => {
const data = JSON.parse(ev.data);
if (data.type === "error") {
tipEl.textContent = data.message;
return;
}
if (data.type === "state") render(data);
};
function send(payload) {
ws.send(JSON.stringify(payload));
}
if (startBtn) startBtn.onclick = () => send({ action: "start" });
if (nextBtn) nextBtn.onclick = () => send({ action: "next" });
function render(data) {
const s = data.session;
playersEl.innerHTML = data.players
.map(
(p) =>
`<li class="${p.host ? "host" : ""} ${p.connected ? "" : "off"}">${escapeHtml(p.name)} — ${p.score} pts</li>`
)
.join("");
if (hostForm) hostForm.hidden = s.status !== "lobby";
if (startBtn) startBtn.hidden = s.status !== "lobby";
if (nextBtn) nextBtn.hidden = !(s.status === "playing" && data.round && data.round.status === "revealed");
if (s.status === "lobby") {
submitted = false;
lastChoice = null;
tipEl.textContent = "Lobby time! Host tweaks the rules, then hits START. Share the invite link.";
stageEl.innerHTML = `<p>Waiting for players (${data.players.length}/${s.max_players}). Rounds: ${s.num_rounds}.</p>`;
return;
}
if (s.status === "finished") {
tipEl.textContent = `Match over! ${data.winner} takes the crown. High score wins the RAL throne.`;
stageEl.innerHTML = `<h2>FINAL BOARD</h2>` + playersEl.outerHTML;
return;
}
const r = data.round;
if (!r) return;
if (r.number !== roundSeen) {
roundSeen = r.number;
lastChoice = null;
submitted = false;
}
if ((r.answered_ids || []).includes(playerId)) submitted = true;
if (r.status === "open") {
if (r.bonus) {
tipEl.textContent =
"BONUS ROUND! Harder posting. 150 for the right band, 300 if you nail the exact RAL, -50 if the exact guess is wrong. Also pick hybrid / remote / in-place (+50).";
} else {
tipEl.textContent =
"Read the offer. Pick the closest RAL band. 100 points if you hit it, 0 if not. Optional exact number: +200 or -50.";
}
} else {
tipEl.textContent = "Everyone locked in. Here's the reveal — host can move to the next round.";
}
const job = r.job;
const meta = [];
if (r.bonus) meta.push(`<span class="chip bonus">BONUS ROUND</span>`);
meta.push(`<span class="chip">ROUND ${r.number}/${s.num_rounds}</span>`);
if (job.company) meta.push(`<span class="chip">${escapeHtml(job.company)}</span>`);
if (job.subject) meta.push(`<span class="chip">${escapeHtml(job.subject)}</span>`);
if (job.location) meta.push(`<span class="chip">${escapeHtml(job.location)}</span>`);
if (job.extra) meta.push(`<span class="chip">${escapeHtml(job.extra)}</span>`);
const locked = r.status !== "open" || submitted;
const options = r.options
.map((o) => {
let cls = "opt";
if (lastChoice === o.key) cls += " picked";
if (r.status === "revealed") {
if (o.key === r.correct) cls += " good";
else if (lastChoice === o.key) cls += " bad";
}
return `<button class="${cls}" data-key="${o.key}" ${locked && r.status === "open" ? "disabled" : ""}>${escapeHtml(o.label)}</button>`;
})
.join("");
let extra = "";
if (job.has_exact) {
extra += `<label>EXACT RAL (optional)<input id="precise" type="number" min="15000" max="400000" step="1000" ${locked ? "disabled" : ""} /></label>`;
}
if (r.bonus) {
extra += `<label>WORK MODE
<select id="work-type" ${locked ? "disabled" : ""}>
<option value="hybrid">Hybrid</option>
<option value="remote">Full remote</option>
<option value="onsite">In-place only</option>
</select>
</label>`;
}
let results = "";
if (r.status === "revealed") {
const ral =
r.ral_exact != null
? `Exact RAL: ${r.ral_exact}`
: `RAL range: ${r.ral_min} - ${r.ral_max}`;
results = `<div class="results">
<p>${ral} · Company: ${escapeHtml(r.company)} · Work: ${escapeHtml(r.work_type)}</p>
<ul>${r.answers
.map(
(a) =>
`<li>${escapeHtml(a.name)}: ${a.correct ? "HIT" : "MISS"} ${a.precise != null ? "· exact " + a.precise : ""} → ${a.points} pts</li>`
)
.join("")}</ul>
</div>`;
} else {
results = `<p class="tiny">Locked in: ${r.answered.join(", ") || "nobody yet"}. Waiting: ${r.waiting.join(", ")}</p>`;
}
stageEl.innerHTML = `
<p class="job-title">${escapeHtml(job.title)}</p>
<div class="meta">${meta.join("")}</div>
<div class="description">${escapeHtml(job.description)}</div>
<div class="options">${options}</div>
${extra}
${r.status === "open" ? `<button class="btn" id="lock-btn" ${locked ? "disabled" : ""}>LOCK ANSWER</button>` : ""}
${results}
`;
stageEl.querySelectorAll(".opt").forEach((btn) => {
btn.onclick = () => {
if (locked) return;
lastChoice = btn.dataset.key;
stageEl.querySelectorAll(".opt").forEach((b) => b.classList.remove("picked"));
btn.classList.add("picked");
};
});
const lockBtn = document.getElementById("lock-btn");
if (lockBtn) {
lockBtn.onclick = () => {
if (!lastChoice) {
tipEl.textContent = "Pick a RAL band first, then lock it in.";
return;
}
const preciseEl = document.getElementById("precise");
const workEl = document.getElementById("work-type");
submitted = true;
send({
action: "answer",
choice: lastChoice,
precise: preciseEl && preciseEl.value ? Number(preciseEl.value) : null,
work_type: workEl ? workEl.value : "",
});
};
}
}
function escapeHtml(str) {
return String(str || "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}

94
app/templates/admin.html Normal file
View File

@@ -0,0 +1,94 @@
{% extends "base.html" %}
{% block title %}Admin · Guess the RAL{% endblock %}
{% block content %}
<section class="admin-grid">
<div class="pixel-panel">
<h1>WORKER</h1>
<p class="tiny">Playable jobs: {{ playable }} · queued/running: {{ queued }}</p>
<form class="stack" method="post" action="/admin/fetch">
<label>LINKEDIN JOB URL
<input name="url" required placeholder="https://www.linkedin.com/jobs/view/..." />
</label>
<button class="btn" type="submit">QUEUE FETCH</button>
</form>
<h2>TASKS</h2>
<table class="pixel-table">
<tr><th>ID</th><th>STATUS</th><th>URL</th><th>ERROR</th></tr>
{% for t in tasks %}
<tr>
<td>{{ t.id }}</td>
<td class="st-{{ t.status }}">{{ t.status }}</td>
<td class="clip">{{ t.url }}</td>
<td class="clip">{{ t.error[:120] }}</td>
</tr>
{% endfor %}
</table>
</div>
<div class="pixel-panel">
<h1>ADD JOB</h1>
<form class="stack" method="post" action="/admin/job">
<label>TITLE <input name="title" required /></label>
<label>COMPANY <input name="company" /></label>
<div class="grid-2">
<label>SUBJECT
<select name="subject">
<option>technology</option>
<option>financial</option>
<option>business</option>
<option>healthcare</option>
<option>legal</option>
<option>hr</option>
<option>design</option>
<option>other</option>
</select>
</label>
<label>WORK
<select name="work_type">
<option value="hybrid">hybrid</option>
<option value="remote">full remote</option>
<option value="onsite">in-place</option>
</select>
</label>
</div>
<label>LOCATION <input name="location" /></label>
<div class="grid-2">
<label>RAL EXACT <input name="ral_exact" /></label>
<label>RAL MIN <input name="ral_min" /></label>
</div>
<label>RAL MAX <input name="ral_max" /></label>
<label>EXTRA CLUES <input name="extra_info" /></label>
<label>DESCRIPTION <textarea name="description" rows="5"></textarea></label>
<button class="btn" type="submit">SAVE JOB</button>
</form>
</div>
</section>
<section class="pixel-panel">
<div class="row-between">
<h1>JOBS</h1>
<form method="post" action="/admin/logout"><button class="btn btn-ghost" type="submit">LOGOUT</button></form>
</div>
<table class="pixel-table">
<tr><th>ID</th><th>TITLE</th><th>COMPANY</th><th>RAL</th><th>TYPE</th><th>STATUS</th><th></th></tr>
{% for j in jobs %}
<tr>
<td>{{ j.id }}</td>
<td>{{ j.title }}</td>
<td>{{ j.company }}</td>
<td>{{ j.ral_exact or (j.ral_min and j.ral_max and (j.ral_min|string + '-' + j.ral_max|string)) or '?' }}</td>
<td>{{ j.work_type }}</td>
<td class="st-{{ j.status }}">{{ j.status }}</td>
<td>
<form method="post" action="/admin/job/{{ j.id }}/status">
<select name="status">
<option value="ready" {% if j.status=='ready' %}selected{% endif %}>ready</option>
<option value="needs_salary" {% if j.status=='needs_salary' %}selected{% endif %}>needs_salary</option>
<option value="hidden" {% if j.status=='hidden' %}selected{% endif %}>hidden</option>
</select>
<button class="btn btn-tiny" type="submit">SET</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</section>
{% endblock %}

View File

@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}Admin · Guess the RAL{% endblock %}
{% block content %}
<section class="hero pixel-panel">
<h1>ADMIN</h1>
{% if error %}<p class="alert">{{ error }}</p>{% endif %}
<form class="stack" method="post" action="/admin/login">
<label>PASSWORD
<input type="password" name="password" required />
</label>
<button class="btn" type="submit">ENTER</button>
</form>
</section>
{% endblock %}

22
app/templates/base.html Normal file
View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}Guess the RAL{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&family=VT323&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<body>
<div class="scanlines"></div>
<header class="topbar">
<a class="logo" href="/">GUESS THE RAL</a>
<nav><a href="/admin">ADMIN</a></nav>
</header>
<main>
{% block content %}{% endblock %}
</main>
{% block scripts %}{% endblock %}
</body>
</html>

28
app/templates/index.html Normal file
View File

@@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block content %}
<section class="hero pixel-panel">
<div class="mascot-wrap">
{% include "mascot.html" %}
<p class="speech">Yo! I'm RALO. Read the job, hide the salary, guess the RAL. First one to the top of the board wins!</p>
</div>
{% if error %}<p class="alert">{{ error }}</p>{% endif %}
<form class="stack" method="post" action="/create">
<label>YOUR NAME
<input name="name" maxlength="24" required placeholder="PLAYER 1" />
</label>
<div class="grid-2">
<label>MAX PLAYERS (2-12)
<input type="number" name="max_players" min="2" max="12" value="8" />
</label>
<label>ROUNDS (4-32)
<input type="number" name="num_rounds" min="4" max="32" value="8" />
</label>
</div>
<label class="check"><input type="checkbox" name="extra_guesses" /> Extra clues from the posting</label>
<label class="check"><input type="checkbox" name="hide_company" /> Hide company name</label>
<label class="check"><input type="checkbox" name="hide_subject" /> Hide subject / industry</label>
<button class="btn" type="submit">HOST MATCH</button>
</form>
<p class="hint">Got an invite? Open the unique link from the host.</p>
</section>
{% endblock %}

16
app/templates/join.html Normal file
View File

@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block content %}
<section class="hero pixel-panel">
<div class="mascot-wrap">
{% include "mascot.html" %}
<p class="speech">Session {{ session.id[:8] }} is waiting. Drop your name and jump in!</p>
</div>
{% if error %}<p class="alert">{{ error }}</p>{% endif %}
<form class="stack" method="post" action="/join/{{ session.id }}">
<label>YOUR NAME
<input name="name" maxlength="24" required placeholder="PLAYER 2" />
</label>
<button class="btn" type="submit">JOIN MATCH</button>
</form>
</section>
{% endblock %}

View File

@@ -0,0 +1,4 @@
<div class="ralo" aria-hidden="true">
<div class="ralo-pixel"></div>
<div class="ralo-name">RALO</div>
</div>

37
app/templates/play.html Normal file
View File

@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block content %}
<section class="play-layout" data-session="{{ session.id }}" data-token="{{ player.token }}" data-host="{{ '1' if player.is_host else '0' }}" data-player="{{ player.id }}">
<aside class="pixel-panel board">
<h2>PLAYERS</h2>
<ol id="players"></ol>
<p class="tiny" id="invite"></p>
{% if player.is_host %}
<form class="stack host-settings" method="post" action="/play/{{ session.id }}/settings" id="host-form">
<h3>HOST SETTINGS</h3>
<label>PLAYERS
<input type="number" name="max_players" min="2" max="12" value="{{ session.max_players }}" />
</label>
<label>ROUNDS
<input type="number" name="num_rounds" min="4" max="32" value="{{ session.num_rounds }}" />
</label>
<label class="check"><input type="checkbox" name="extra_guesses" {% if session.extra_guesses %}checked{% endif %} /> Extra clues</label>
<label class="check"><input type="checkbox" name="hide_company" {% if session.hide_company %}checked{% endif %} /> Hide company</label>
<label class="check"><input type="checkbox" name="hide_subject" {% if session.hide_subject %}checked{% endif %} /> Hide subject</label>
<button class="btn btn-small" type="submit">SAVE</button>
</form>
<button class="btn" id="start-btn" type="button">START GAME</button>
<button class="btn btn-ghost" id="next-btn" type="button" hidden>NEXT ROUND</button>
{% endif %}
</aside>
<div class="pixel-panel stage">
<div class="mascot-wrap compact">
{% include "mascot.html" %}
<p class="speech" id="tip">Waiting in the lobby. The host will start the match.</p>
</div>
<div id="stage"></div>
</div>
</section>
{% endblock %}
{% block scripts %}
<script src="/static/js/play.js"></script>
{% endblock %}

73
app/worker.py Normal file
View File

@@ -0,0 +1,73 @@
import asyncio
import traceback
from datetime import datetime
from app.database import Base, SessionLocal, engine
from app.linkedin import fetch_linkedin_job
from app.models import FetchTask, Job
from app.seed import seed_jobs
async def process_task(task_id: int) -> None:
db = SessionLocal()
try:
task = db.get(FetchTask, task_id)
if not task:
return
task.status = "running"
task.started_at = datetime.utcnow()
db.commit()
parsed = await fetch_linkedin_job(task.url)
cols = set(Job.__table__.c.keys())
job = Job(**{k: v for k, v in parsed.items() if k in cols and k != "id"})
db.add(job)
db.flush()
task.job_id = job.id
task.status = "done"
task.finished_at = datetime.utcnow()
task.error = ""
db.commit()
print(f"[worker] stored job {job.id} from {task.url}")
except Exception as exc:
db.rollback()
task = db.get(FetchTask, task_id)
if task:
task.status = "error"
task.error = f"{exc}\n{traceback.format_exc()[-800:]}"
task.finished_at = datetime.utcnow()
db.commit()
print(f"[worker] failed task {task_id}: {exc}")
finally:
db.close()
async def loop() -> None:
Base.metadata.create_all(bind=engine)
db = SessionLocal()
seed_jobs(db)
db.close()
print("[worker] listening for LinkedIn fetch tasks")
while True:
db = SessionLocal()
try:
task = (
db.query(FetchTask)
.filter(FetchTask.status == "queued")
.order_by(FetchTask.id.asc())
.first()
)
task_id = task.id if task else None
finally:
db.close()
if task_id:
await process_task(task_id)
else:
await asyncio.sleep(2)
def main() -> None:
asyncio.run(loop())
if __name__ == "__main__":
main()