feat: add boilerplate
This commit is contained in:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.git
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
data/*.db
|
||||||
|
.env
|
||||||
4
.env.example
Normal file
4
.env.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
ADMIN_PASSWORD=changeme
|
||||||
|
SECRET_KEY=change-this-secret
|
||||||
|
DATABASE_URL=sqlite:////app/data/guesstheral.db
|
||||||
|
LINKEDIN_USER_AGENT=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36
|
||||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
data/*.db
|
||||||
|
data/*.db-journal
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.egg-info/
|
||||||
|
.DS_Store
|
||||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
gcc libxml2-dev libxslt1-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app ./app
|
||||||
|
COPY seed_jobs.json ./seed_jobs.json
|
||||||
|
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
36
README.md
Normal file
36
README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Guess the RAL
|
||||||
|
|
||||||
|
Browser multiplayer game: read a job offer, guess the **RAL** (gross annual salary).
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # already present in this repo
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:8000
|
||||||
|
|
||||||
|
- Host a match, share the invite UUID link
|
||||||
|
- Admin panel: http://localhost:8000/admin (password from `ADMIN_PASSWORD`)
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- 4 salary-band answers. Correct band = **100** pts, else **0**
|
||||||
|
- Optional exact RAL (when known): **+200** or **-50**
|
||||||
|
- Every 3rd round is **BONUS**: 150 / 300 / -50, plus hybrid vs full remote vs in-place (**+50**)
|
||||||
|
- Results reveal when every connected player has locked an answer
|
||||||
|
|
||||||
|
## LinkedIn worker
|
||||||
|
|
||||||
|
The `worker` service polls queued URLs from the admin panel, fetches public LinkedIn job pages, strips salary text, and stores them. LinkedIn often shows a login wall; if a fetch fails, add the job manually in admin. Seed jobs are loaded so you can play immediately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Initial Prompt
|
||||||
|
|
||||||
|
```
|
||||||
|
Create a multiplayer game on the browser where users connects, put their name and start playing to a game called "guess the RAL". In this game, written in python with a sqlite database, with a docker compose enabled setup, users connects to the website and create or join new sessions. The overall idea is to review work offer from LinkedIn and guess the correct amount of RAL based on the job description and the company title as well as job title. The job description must hide the ral amount if exposed. Moreover, each player gets 100 point if they choose the correct answer from a bunch of 4 possibilities. There might be "Above", "Less than", "Between x and y" as possible answers. It should be as closer as possible to the real answer. When all players have at least one option, they get the results all together. If a player does not find the correct answer they get 0. The game allows also to guess the precise amount (if given) to submit as number. If the user gets the correct amount they get 200 points, or -50 if wrong. Every 3 rounds there's a "ROUND with BONUS" where players get 150 if they get right, 300 if they guess the precise amount and -50 if they get it wrong. In this bonus round they can also guess if the work is hybrid, full remote or in-place only. The question in this case is more difficault. When the user hosts a match (with a uuid to get invited via link to be unique) they can have some settings, such as the number of players (maximum 12), the number of matches (from 4 to 32), extra guesses (so there might be some extra information to show based on the description) and also the possibility to hide the company name (if present) and the subject (e.g. technology, finacial, business, etc others). The background script should be able to fetch real data from linkedin and store it in the database. There's an admin panel protected with a password where you can fetch and check the fetched data from linkedin with corresponding status (it should work as a running worker task). In terms of UI and interface make is nice to see and add also a mascotte that explains what you have to todo each round. Make it simple, like a 8 bit sprite type game. Dont check too much the code, it must work and use the least amount of tests to create this.
|
||||||
|
|
||||||
|
Add also 200 static facts about RAL that piss several people off in italy like "RAL commisurata all'esperienza" and add custom swear to the that.. You can keep those phrases static in a JSON file and make it funny and dank to read. They should show up during loading screen between one round to the other and during the wait in the lobby.
|
||||||
|
```
|
||||||
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Guess the RAL
|
||||||
18
app/config.py
Normal file
18
app/config.py
Normal 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
39
app/database.py
Normal 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
185
app/game.py
Normal 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
141
app/linkedin.py
Normal 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
385
app/main.py
Normal 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
114
app/models.py
Normal 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
189
app/ral.py
Normal 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
31
app/realtime.py
Normal 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
45
app/seed.py
Normal 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
183
app/static/css/style.css
Normal 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
185
app/static/js/play.js
Normal 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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """);
|
||||||
|
}
|
||||||
94
app/templates/admin.html
Normal file
94
app/templates/admin.html
Normal 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 %}
|
||||||
14
app/templates/admin_login.html
Normal file
14
app/templates/admin_login.html
Normal 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
22
app/templates/base.html
Normal 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
28
app/templates/index.html
Normal 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
16
app/templates/join.html
Normal 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 %}
|
||||||
4
app/templates/mascot.html
Normal file
4
app/templates/mascot.html
Normal 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
37
app/templates/play.html
Normal 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
73
app/worker.py
Normal 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()
|
||||||
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
27
docker-compose.yml
Normal file
27
docker-compose.yml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
command: uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- game-data:/app/data
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=sqlite:////app/data/guesstheral.db
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
worker:
|
||||||
|
build: .
|
||||||
|
command: python -m app.worker
|
||||||
|
volumes:
|
||||||
|
- game-data:/app/data
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=sqlite:////app/data/guesstheral.db
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
game-data:
|
||||||
10
requirements.txt
Normal file
10
requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
sqlalchemy==2.0.36
|
||||||
|
jinja2==3.1.4
|
||||||
|
python-multipart==0.0.20
|
||||||
|
httpx==0.28.1
|
||||||
|
beautifulsoup4==4.12.3
|
||||||
|
lxml==5.3.0
|
||||||
|
itsdangerous==2.2.0
|
||||||
|
pydantic-settings==2.7.0
|
||||||
314
seed_jobs.json
Normal file
314
seed_jobs.json
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"title": "Junior Python Backend Developer",
|
||||||
|
"company": "Nimbus Labs",
|
||||||
|
"subject": "technology",
|
||||||
|
"location": "Milan, hybrid",
|
||||||
|
"work_type": "hybrid",
|
||||||
|
"ral_exact": 32000,
|
||||||
|
"ral_min": 30000,
|
||||||
|
"ral_max": 35000,
|
||||||
|
"difficulty": 1,
|
||||||
|
"extra_info": "1-2 years experience | Python, FastAPI, PostgreSQL",
|
||||||
|
"description": "We are looking for a Junior Python Backend Developer to join our SaaS platform team in Milan. You will build REST APIs, write tests and work with senior engineers. Stack: Python, FastAPI, PostgreSQL. RAL 32.000 euro. Hybrid 2 days in office."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Senior Software Engineer",
|
||||||
|
"company": "Alpe Digital",
|
||||||
|
"subject": "technology",
|
||||||
|
"location": "Turin, hybrid",
|
||||||
|
"work_type": "hybrid",
|
||||||
|
"ral_exact": 52000,
|
||||||
|
"ral_min": 50000,
|
||||||
|
"ral_max": 55000,
|
||||||
|
"difficulty": 1,
|
||||||
|
"extra_info": "5+ years | Java/Kotlin | fintech scale-up",
|
||||||
|
"description": "Alpe Digital is hiring a Senior Software Engineer for our payments core. Kotlin services, event-driven architecture, mentoring. Compensation: RAL 52.000. Hybrid Turin HQ."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Full Stack Developer (React + Node)",
|
||||||
|
"company": "Pixel Forge",
|
||||||
|
"subject": "technology",
|
||||||
|
"location": "Full remote Italy",
|
||||||
|
"work_type": "remote",
|
||||||
|
"ral_exact": 42000,
|
||||||
|
"ral_min": 40000,
|
||||||
|
"ral_max": 45000,
|
||||||
|
"difficulty": 1,
|
||||||
|
"extra_info": "3 years | React, Node | full remote",
|
||||||
|
"description": "Pixel Forge needs a Full Stack Developer. React, Node.js, TypeScript. Fully remote across Italy. Salary package RAL 42.000 euro plus meal vouchers."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "DevOps Engineer",
|
||||||
|
"company": "CloudVesuvio",
|
||||||
|
"subject": "technology",
|
||||||
|
"location": "Naples, onsite",
|
||||||
|
"work_type": "onsite",
|
||||||
|
"ral_exact": 45000,
|
||||||
|
"ral_min": 43000,
|
||||||
|
"ral_max": 48000,
|
||||||
|
"difficulty": 1,
|
||||||
|
"extra_info": "AWS, Kubernetes, Terraform | in-office Naples",
|
||||||
|
"description": "Join CloudVesuvio as DevOps Engineer. Kubernetes, Terraform, AWS. This role is in-place only at our Naples office. Retribuzione annua lorda 45.000."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Data Analyst",
|
||||||
|
"company": "MarketPulse",
|
||||||
|
"subject": "business",
|
||||||
|
"location": "Rome, hybrid",
|
||||||
|
"work_type": "hybrid",
|
||||||
|
"ral_exact": 36000,
|
||||||
|
"ral_min": 34000,
|
||||||
|
"ral_max": 38000,
|
||||||
|
"difficulty": 1,
|
||||||
|
"extra_info": "SQL, Tableau | 2 years",
|
||||||
|
"description": "MarketPulse seeks a Data Analyst to build dashboards for retail clients. SQL, Python, Tableau. Hybrid Rome. RAL between 34.000 and 38.000, target 36.000."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Machine Learning Engineer",
|
||||||
|
"company": "Orion AI",
|
||||||
|
"subject": "technology",
|
||||||
|
"location": "Full remote EU",
|
||||||
|
"work_type": "remote",
|
||||||
|
"ral_exact": 68000,
|
||||||
|
"ral_min": 65000,
|
||||||
|
"ral_max": 72000,
|
||||||
|
"difficulty": 2,
|
||||||
|
"extra_info": "NLP, PyTorch | 4 years | English",
|
||||||
|
"description": "Orion AI is hiring an ML Engineer for NLP products used by banks. PyTorch, MLOps, production models. Full remote. Compensation RAL 68.000 euro."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Product Manager",
|
||||||
|
"company": "VerdeCart",
|
||||||
|
"subject": "business",
|
||||||
|
"location": "Milan, hybrid",
|
||||||
|
"work_type": "hybrid",
|
||||||
|
"ral_exact": 55000,
|
||||||
|
"ral_min": 50000,
|
||||||
|
"ral_max": 60000,
|
||||||
|
"difficulty": 2,
|
||||||
|
"extra_info": "B2C marketplace | 4-6 years PM",
|
||||||
|
"description": "VerdeCart, a grocery marketplace, looks for a Product Manager. Roadmap, discovery, stakeholder management. Hybrid Milan. Salary range 50k-60k RAL, we usually close at 55.000."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "UX Designer",
|
||||||
|
"company": "Studio Lampo",
|
||||||
|
"subject": "design",
|
||||||
|
"location": "Florence, hybrid",
|
||||||
|
"work_type": "hybrid",
|
||||||
|
"ral_exact": 38000,
|
||||||
|
"ral_min": 36000,
|
||||||
|
"ral_max": 40000,
|
||||||
|
"difficulty": 1,
|
||||||
|
"extra_info": "Figma | portfolio required",
|
||||||
|
"description": "Studio Lampo wants a UX Designer for public-sector digital services. Figma, research, accessibility. Hybrid Florence. RAL 38.000."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "HR Specialist / Recruiter",
|
||||||
|
"company": "PeopleFirst Italia",
|
||||||
|
"subject": "hr",
|
||||||
|
"location": "Bologna, hybrid",
|
||||||
|
"work_type": "hybrid",
|
||||||
|
"ral_exact": 33000,
|
||||||
|
"ral_min": 30000,
|
||||||
|
"ral_max": 35000,
|
||||||
|
"difficulty": 1,
|
||||||
|
"extra_info": "Tech recruiting | ATS",
|
||||||
|
"description": "PeopleFirst Italia is looking for an HR Specialist focused on tech recruiting. Screening, interviews, employer branding. Hybrid Bologna. Stipendio RAL 33.000 euro."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Financial Analyst",
|
||||||
|
"company": "Banca Ligure",
|
||||||
|
"subject": "financial",
|
||||||
|
"location": "Genoa, onsite",
|
||||||
|
"work_type": "onsite",
|
||||||
|
"ral_exact": 41000,
|
||||||
|
"ral_min": 39000,
|
||||||
|
"ral_max": 43000,
|
||||||
|
"difficulty": 1,
|
||||||
|
"extra_info": "Excel, IFRS | in sede",
|
||||||
|
"description": "Banca Ligure seeks a Financial Analyst for controlling. Monthly closing, variance analysis. Role is on-site in Genoa. RAL 41.000."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Investment Associate",
|
||||||
|
"company": "Adriatic Capital",
|
||||||
|
"subject": "financial",
|
||||||
|
"location": "Milan, hybrid",
|
||||||
|
"work_type": "hybrid",
|
||||||
|
"ral_exact": 72000,
|
||||||
|
"ral_min": 68000,
|
||||||
|
"ral_max": 78000,
|
||||||
|
"difficulty": 2,
|
||||||
|
"extra_info": "Private equity | modelling",
|
||||||
|
"description": "Adriatic Capital is hiring an Investment Associate. Deal sourcing, financial modelling, IC memos. Hybrid Milan. Package RAL 72.000 plus bonus."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Legal Counsel",
|
||||||
|
"company": "NordLex",
|
||||||
|
"subject": "legal",
|
||||||
|
"location": "Milan, hybrid",
|
||||||
|
"work_type": "hybrid",
|
||||||
|
"ral_exact": 60000,
|
||||||
|
"ral_min": 58000,
|
||||||
|
"ral_max": 65000,
|
||||||
|
"difficulty": 2,
|
||||||
|
"extra_info": "Corporate law | 5 years",
|
||||||
|
"description": "NordLex needs in-house Legal Counsel for SaaS contracts and GDPR. Italian qualified. Hybrid. Retribuzione annua lorda 60.000 euro."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Clinical Research Associate",
|
||||||
|
"company": "Helix Pharma",
|
||||||
|
"subject": "healthcare",
|
||||||
|
"location": "Rome, hybrid",
|
||||||
|
"work_type": "hybrid",
|
||||||
|
"ral_exact": 39000,
|
||||||
|
"ral_min": 37000,
|
||||||
|
"ral_max": 42000,
|
||||||
|
"difficulty": 1,
|
||||||
|
"extra_info": "GCP | travel 30%",
|
||||||
|
"description": "Helix Pharma is hiring a CRA to monitor clinical sites in Lazio. GCP, travel required. Hybrid Rome. RAL 39.000."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Account Executive (SaaS)",
|
||||||
|
"company": "QuotaLoop",
|
||||||
|
"subject": "business",
|
||||||
|
"location": "Full remote Italy",
|
||||||
|
"work_type": "remote",
|
||||||
|
"ral_exact": 48000,
|
||||||
|
"ral_min": 40000,
|
||||||
|
"ral_max": 55000,
|
||||||
|
"difficulty": 2,
|
||||||
|
"extra_info": "OTE includes bonus | B2B SaaS",
|
||||||
|
"description": "QuotaLoop wants an Account Executive selling B2B analytics. Full remote. Base RAL 48.000 plus uncapped commission. Quota 250k ARR."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Cybersecurity Analyst",
|
||||||
|
"company": "Shield42",
|
||||||
|
"subject": "technology",
|
||||||
|
"location": "Rome, onsite",
|
||||||
|
"work_type": "onsite",
|
||||||
|
"ral_exact": 44000,
|
||||||
|
"ral_min": 42000,
|
||||||
|
"ral_max": 47000,
|
||||||
|
"difficulty": 1,
|
||||||
|
"extra_info": "SOC | SIEM | in-office",
|
||||||
|
"description": "Shield42 SOC is hiring a Cybersecurity Analyst. SIEM, incident response, night shifts rotating. In-place only Rome. RAL 44.000."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "CTO / Head of Engineering",
|
||||||
|
"company": "Starto",
|
||||||
|
"subject": "technology",
|
||||||
|
"location": "Milan, hybrid",
|
||||||
|
"work_type": "hybrid",
|
||||||
|
"ral_exact": 95000,
|
||||||
|
"ral_min": 90000,
|
||||||
|
"ral_max": 110000,
|
||||||
|
"difficulty": 2,
|
||||||
|
"extra_info": "Series A | team of 12",
|
||||||
|
"description": "Starto, Series A logistics startup, seeks a Head of Engineering. Own architecture, hiring, delivery. Hybrid Milan. RAL 95.000 plus equity."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Frontend Developer",
|
||||||
|
"company": "Laguna Web",
|
||||||
|
"subject": "technology",
|
||||||
|
"location": "Venice, hybrid",
|
||||||
|
"work_type": "hybrid",
|
||||||
|
"ral_exact": 34000,
|
||||||
|
"ral_min": 32000,
|
||||||
|
"ral_max": 36000,
|
||||||
|
"difficulty": 1,
|
||||||
|
"extra_info": "Vue.js | 2 years",
|
||||||
|
"description": "Laguna Web is looking for a Frontend Developer. Vue.js, accessibility, design systems. Hybrid Venice. Salary RAL 34.000 euro."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Customer Success Manager",
|
||||||
|
"company": "Helpdeskito",
|
||||||
|
"subject": "business",
|
||||||
|
"location": "Full remote",
|
||||||
|
"work_type": "remote",
|
||||||
|
"ral_exact": 37000,
|
||||||
|
"ral_min": 35000,
|
||||||
|
"ral_max": 40000,
|
||||||
|
"difficulty": 1,
|
||||||
|
"extra_info": "Italian + English | SaaS",
|
||||||
|
"description": "Helpdeskito needs a Customer Success Manager for mid-market accounts. Fully remote. RAL 37.000 plus quarterly bonus."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Embedded Software Engineer",
|
||||||
|
"company": "MotoChip",
|
||||||
|
"subject": "technology",
|
||||||
|
"location": "Modena, onsite",
|
||||||
|
"work_type": "onsite",
|
||||||
|
"ral_exact": 47000,
|
||||||
|
"ral_min": 45000,
|
||||||
|
"ral_max": 50000,
|
||||||
|
"difficulty": 1,
|
||||||
|
"extra_info": "C/C++ | automotive | in sede",
|
||||||
|
"description": "MotoChip hires an Embedded Software Engineer for ECU firmware. C/C++, AUTOSAR. On-site Modena plant. RAL 47.000."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Marketing Manager",
|
||||||
|
"company": "Barilla Digital (example brand studio)",
|
||||||
|
"subject": "business",
|
||||||
|
"location": "Parma, hybrid",
|
||||||
|
"work_type": "hybrid",
|
||||||
|
"ral_exact": 50000,
|
||||||
|
"ral_min": 48000,
|
||||||
|
"ral_max": 54000,
|
||||||
|
"difficulty": 2,
|
||||||
|
"extra_info": "Performance + brand | 6 years",
|
||||||
|
"description": "Our brand studio seeks a Marketing Manager for FMCG campaigns. Budget ownership, agencies, analytics. Hybrid Parma. Compensation between 48k and 54k, typical offer RAL 50.000."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Nurse Coordinator",
|
||||||
|
"company": "Clinica Adriatica",
|
||||||
|
"subject": "healthcare",
|
||||||
|
"location": "Trieste, onsite",
|
||||||
|
"work_type": "onsite",
|
||||||
|
"ral_exact": 31000,
|
||||||
|
"ral_min": 30000,
|
||||||
|
"ral_max": 33000,
|
||||||
|
"difficulty": 1,
|
||||||
|
"extra_info": "Registered nurse | shifts",
|
||||||
|
"description": "Clinica Adriatica is hiring a Nurse Coordinator. Team of 8, quality protocols. In-place only. RAL 31.000."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Salesforce Developer",
|
||||||
|
"company": "Crmonda",
|
||||||
|
"subject": "technology",
|
||||||
|
"location": "Hybrid Italy",
|
||||||
|
"work_type": "hybrid",
|
||||||
|
"ral_exact": 46000,
|
||||||
|
"ral_min": 44000,
|
||||||
|
"ral_max": 50000,
|
||||||
|
"difficulty": 1,
|
||||||
|
"extra_info": "Apex | Lightning",
|
||||||
|
"description": "Crmonda looks for a Salesforce Developer. Apex, Lightning, integrations. Hybrid. RAL 46.000 euro."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Quant Developer",
|
||||||
|
"company": "Lagoon Trading",
|
||||||
|
"subject": "financial",
|
||||||
|
"location": "Milan, hybrid",
|
||||||
|
"work_type": "hybrid",
|
||||||
|
"ral_exact": 85000,
|
||||||
|
"ral_min": 80000,
|
||||||
|
"ral_max": 95000,
|
||||||
|
"difficulty": 2,
|
||||||
|
"extra_info": "C++/Python | markets",
|
||||||
|
"description": "Lagoon Trading hires a Quant Developer for low-latency research tools. Python and C++. Hybrid Milan. RAL 85.000 plus discretionary bonus."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Office Manager",
|
||||||
|
"company": "Studio Bianchi",
|
||||||
|
"subject": "other",
|
||||||
|
"location": "Palermo, onsite",
|
||||||
|
"work_type": "onsite",
|
||||||
|
"ral_exact": 27000,
|
||||||
|
"ral_min": 25000,
|
||||||
|
"ral_max": 28000,
|
||||||
|
"difficulty": 1,
|
||||||
|
"extra_info": "Admin | vendors | in office",
|
||||||
|
"description": "Studio Bianchi needs an Office Manager. Calendar, vendors, reception. On-site Palermo. RAL 27.000."
|
||||||
|
}
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user