From cc6675f7c641b12dd7b943dd85a09591f66ccde2 Mon Sep 17 00:00:00 2001 From: Maxelweb Date: Mon, 26 Oct 2026 21:56:23 +0100 Subject: [PATCH] feat: add boilerplate --- .dockerignore | 7 + .env.example | 4 + .gitignore | 10 + Dockerfile | 19 ++ README.md | 36 +++ app/__init__.py | 1 + app/config.py | 18 ++ app/database.py | 39 ++++ app/game.py | 185 ++++++++++++++++ app/linkedin.py | 141 ++++++++++++ app/main.py | 385 +++++++++++++++++++++++++++++++++ app/models.py | 114 ++++++++++ app/ral.py | 189 ++++++++++++++++ app/realtime.py | 31 +++ app/seed.py | 45 ++++ app/static/css/style.css | 183 ++++++++++++++++ app/static/js/play.js | 185 ++++++++++++++++ app/templates/admin.html | 94 ++++++++ app/templates/admin_login.html | 14 ++ app/templates/base.html | 22 ++ app/templates/index.html | 28 +++ app/templates/join.html | 16 ++ app/templates/mascot.html | 4 + app/templates/play.html | 37 ++++ app/worker.py | 73 +++++++ data/.gitkeep | 0 docker-compose.yml | 27 +++ requirements.txt | 10 + seed_jobs.json | 314 +++++++++++++++++++++++++++ 29 files changed, 2231 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/database.py create mode 100644 app/game.py create mode 100644 app/linkedin.py create mode 100644 app/main.py create mode 100644 app/models.py create mode 100644 app/ral.py create mode 100644 app/realtime.py create mode 100644 app/seed.py create mode 100644 app/static/css/style.css create mode 100644 app/static/js/play.js create mode 100644 app/templates/admin.html create mode 100644 app/templates/admin_login.html create mode 100644 app/templates/base.html create mode 100644 app/templates/index.html create mode 100644 app/templates/join.html create mode 100644 app/templates/mascot.html create mode 100644 app/templates/play.html create mode 100644 app/worker.py create mode 100644 data/.gitkeep create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 seed_jobs.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3e50c47 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +__pycache__ +*.pyc +.venv +venv +data/*.db +.env diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..73aa0a4 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ad9eeb4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.env +__pycache__/ +*.pyc +.pytest_cache/ +data/*.db +data/*.db-journal +.venv/ +venv/ +*.egg-info/ +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1df6ec9 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..02352b7 --- /dev/null +++ b/README.md @@ -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. +``` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..4ea9cb6 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# Guess the RAL diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..2c61d40 --- /dev/null +++ b/app/config.py @@ -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() diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..ad17e22 --- /dev/null +++ b/app/database.py @@ -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() diff --git a/app/game.py b/app/game.py new file mode 100644 index 0000000..cad3ed6 --- /dev/null +++ b/app/game.py @@ -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 diff --git a/app/linkedin.py b/app/linkedin.py new file mode 100644 index 0000000..0d9cf29 --- /dev/null +++ b/app/linkedin.py @@ -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 diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..a110ae6 --- /dev/null +++ b/app/main.py @@ -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) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..e5d4fd0 --- /dev/null +++ b/app/models.py @@ -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") diff --git a/app/ral.py b/app/ral.py new file mode 100644 index 0000000..28dfc04 --- /dev/null +++ b/app/ral.py @@ -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" diff --git a/app/realtime.py b/app/realtime.py new file mode 100644 index 0000000..4ffb539 --- /dev/null +++ b/app/realtime.py @@ -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() diff --git a/app/seed.py b/app/seed.py new file mode 100644 index 0000000..fc3d286 --- /dev/null +++ b/app/seed.py @@ -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 diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000..bda9903 --- /dev/null +++ b/app/static/css/style.css @@ -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; } +} diff --git a/app/static/js/play.js b/app/static/js/play.js new file mode 100644 index 0000000..f758b4f --- /dev/null +++ b/app/static/js/play.js @@ -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) => + `
  • ${escapeHtml(p.name)} — ${p.score} pts
  • ` + ) + .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 = `

    Waiting for players (${data.players.length}/${s.max_players}). Rounds: ${s.num_rounds}.

    `; + return; + } + + if (s.status === "finished") { + tipEl.textContent = `Match over! ${data.winner} takes the crown. High score wins the RAL throne.`; + stageEl.innerHTML = `

    FINAL BOARD

    ` + 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(`BONUS ROUND`); + meta.push(`ROUND ${r.number}/${s.num_rounds}`); + if (job.company) meta.push(`${escapeHtml(job.company)}`); + if (job.subject) meta.push(`${escapeHtml(job.subject)}`); + if (job.location) meta.push(`${escapeHtml(job.location)}`); + if (job.extra) meta.push(`${escapeHtml(job.extra)}`); + + 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 ``; + }) + .join(""); + + let extra = ""; + if (job.has_exact) { + extra += ``; + } + if (r.bonus) { + extra += ``; + } + + 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 = `
    +

    ${ral} · Company: ${escapeHtml(r.company)} · Work: ${escapeHtml(r.work_type)}

    + +
    `; + } else { + results = `

    Locked in: ${r.answered.join(", ") || "nobody yet"}. Waiting: ${r.waiting.join(", ")}

    `; + } + + stageEl.innerHTML = ` +

    ${escapeHtml(job.title)}

    +
    ${meta.join("")}
    +
    ${escapeHtml(job.description)}
    +
    ${options}
    + ${extra} + ${r.status === "open" ? `` : ""} + ${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('"', """); +} diff --git a/app/templates/admin.html b/app/templates/admin.html new file mode 100644 index 0000000..e0eb007 --- /dev/null +++ b/app/templates/admin.html @@ -0,0 +1,94 @@ +{% extends "base.html" %} +{% block title %}Admin · Guess the RAL{% endblock %} +{% block content %} +
    +
    +

    WORKER

    +

    Playable jobs: {{ playable }} · queued/running: {{ queued }}

    +
    + + +
    +

    TASKS

    + + + {% for t in tasks %} + + + + + + + {% endfor %} +
    IDSTATUSURLERROR
    {{ t.id }}{{ t.status }}{{ t.url }}{{ t.error[:120] }}
    +
    +
    +

    ADD JOB

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

    JOBS

    +
    +
    + + + {% for j in jobs %} + + + + + + + + + + {% endfor %} +
    IDTITLECOMPANYRALTYPESTATUS
    {{ j.id }}{{ j.title }}{{ j.company }}{{ j.ral_exact or (j.ral_min and j.ral_max and (j.ral_min|string + '-' + j.ral_max|string)) or '?' }}{{ j.work_type }}{{ j.status }} +
    + + +
    +
    +
    +{% endblock %} diff --git a/app/templates/admin_login.html b/app/templates/admin_login.html new file mode 100644 index 0000000..c7bc289 --- /dev/null +++ b/app/templates/admin_login.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block title %}Admin · Guess the RAL{% endblock %} +{% block content %} +
    +

    ADMIN

    + {% if error %}

    {{ error }}

    {% endif %} +
    + + +
    +
    +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..eda1158 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,22 @@ + + + + + + {% block title %}Guess the RAL{% endblock %} + + + + + +
    +
    + + +
    +
    + {% block content %}{% endblock %} +
    + {% block scripts %}{% endblock %} + + diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..1988c16 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block content %} +
    +
    + {% include "mascot.html" %} +

    Yo! I'm RALO. Read the job, hide the salary, guess the RAL. First one to the top of the board wins!

    +
    + {% if error %}

    {{ error }}

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

    Got an invite? Open the unique link from the host.

    +
    +{% endblock %} diff --git a/app/templates/join.html b/app/templates/join.html new file mode 100644 index 0000000..180a909 --- /dev/null +++ b/app/templates/join.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% block content %} +
    +
    + {% include "mascot.html" %} +

    Session {{ session.id[:8] }} is waiting. Drop your name and jump in!

    +
    + {% if error %}

    {{ error }}

    {% endif %} +
    + + +
    +
    +{% endblock %} diff --git a/app/templates/mascot.html b/app/templates/mascot.html new file mode 100644 index 0000000..e0e75d9 --- /dev/null +++ b/app/templates/mascot.html @@ -0,0 +1,4 @@ + diff --git a/app/templates/play.html b/app/templates/play.html new file mode 100644 index 0000000..5751a73 --- /dev/null +++ b/app/templates/play.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% block content %} +
    + +
    +
    + {% include "mascot.html" %} +

    Waiting in the lobby. The host will start the match.

    +
    +
    +
    +
    +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/app/worker.py b/app/worker.py new file mode 100644 index 0000000..bcc0316 --- /dev/null +++ b/app/worker.py @@ -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() diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8294182 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2457760 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/seed_jobs.json b/seed_jobs.json new file mode 100644 index 0000000..754ea87 --- /dev/null +++ b/seed_jobs.json @@ -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." + } +]