feat: add boilerplate

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

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
.git
__pycache__
*.pyc
.venv
venv
data/*.db
.env

4
.env.example Normal file
View 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
View 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
View 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
View 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
View File

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

18
app/config.py Normal file
View File

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

39
app/database.py Normal file
View File

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

185
app/game.py Normal file
View File

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

141
app/linkedin.py Normal file
View File

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

385
app/main.py Normal file
View File

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

114
app/models.py Normal file
View File

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

189
app/ral.py Normal file
View File

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

31
app/realtime.py Normal file
View File

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

45
app/seed.py Normal file
View File

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

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

View File

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

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

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

73
app/worker.py Normal file
View File

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

0
data/.gitkeep Normal file
View File

27
docker-compose.yml Normal file
View 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
View 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
View 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."
}
]