joblin / backend /main.py
Britzzy's picture
feat: corporate ATS scrapers (Ashby, Greenhouse), O&G, government boards, posted_date fixes
7db43bc
Raw
History Blame Contribute Delete
39.1 kB
import json, os, re, random, threading, hashlib
from pathlib import Path
from contextlib import asynccontextmanager
from datetime import datetime, timedelta
from fastapi import FastAPI, Depends, HTTPException, Query, Body
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from pydantic import BaseModel
from backend.config import GENERATED_DIR, JOB_BOARDS
from backend.cron import run_nightly_scrape
from backend.database import (
init_db, create_user, get_user_by_email, get_user_by_id,
save_api_keys, get_api_keys, get_effective_api_keys,
save_user_settings, get_user_settings, get_and_increment_cv_gen_count,
save_cv, get_cv, get_cv_default, save_cv_raw_text, get_cv_raw_text,
save_global_jobs, get_global_jobs, get_global_job, get_global_stats,
get_categories, classify_job_title,
link_user_job, get_user_job_link, get_user_linked_jobs, update_link_tailoring,
deactivate_old_jobs,
create_reset_token, get_valid_token, mark_token_used, update_password,
)
from backend.auth import hash_password, verify_password, create_access_token, get_current_user
from backend.llm import tailor_application as llm_tailor, make_cv_from_scratch, parse_cv_text, generate_hr_email
from backend.cv_quality import score_cv_quality
from backend.docx_generator import generate_cv_docx, generate_cover_docx, generate_cv_pdf, generate_cover_pdf, generate_cv_preview_text, get_cv_profile
def _file_slug(name: str, company: str = "", counter: int = 0) -> str:
parts = [str(counter).zfill(2)]
if name:
parts.append(re.sub(r"[^a-zA-Z0-9]", "", name)[:20])
if company:
scompany = re.sub(r"[^a-zA-Z0-9]", "", company)[:10]
if scompany:
parts.append(scompany)
suffix = hashlib.md5(f"{counter}_{name}_{company}".encode()).hexdigest()[:6]
return "_".join(parts) + f"_{suffix}" if parts else f"{str(counter).zfill(2)}_{suffix}_cv"
from backend.cv_diversity import randomize_tailored_cv
from backend.excel_export import export_jobs_to_excel
from backend.scrapers.base import BaseScraper
from backend.scrapers.nigeria import NigerianJobScraper
from backend.scrapers.ngos import NGOJobScraper
from backend.scrapers.international import InternationalJobScraper
from backend.scrapers.highimpact import HighImpactScraper
from backend.scrapers.web3 import Web3JobScraper
from backend.scrapers.ats import ATSJobScraper
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
from backend.cron import setup_scheduler
scheduler = setup_scheduler(app)
yield
if scheduler:
scheduler.shutdown()
app = FastAPI(title="Joblin", version="2.0.0", lifespan=lifespan)
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
CRON_TOKEN = os.environ.get("JOBLIN_CRON_TOKEN", "")
# ── Models ──────────────────────────────────────────────────────────────────
class RegisterRequest(BaseModel):
email: str; password: str; name: str = ""
class LoginRequest(BaseModel):
email: str; password: str
class ForgotPasswordRequest(BaseModel):
email: str
class ResetPasswordRequest(BaseModel):
token: str; password: str
class ScrapeRequest(BaseModel):
title: str = ""; boards: list[str] = []
class KeysRequest(BaseModel):
groq: str = ""; nvidia: str = ""; gemini: str = ""; use_default_api: bool = True
# ── Auth Routes ─────────────────────────────────────────────────────────────
@app.post("/api/auth/register")
def register(req: RegisterRequest):
if not req.email or not req.password:
raise HTTPException(400, "Email and password required")
if len(req.password) < 6:
raise HTTPException(400, "Password must be at least 6 characters")
if get_user_by_email(req.email):
raise HTTPException(409, "Email already registered")
user = create_user(req.email, hash_password(req.password), req.name)
if not user:
raise HTTPException(500, "Failed to create user")
token = create_access_token(user["id"], user["email"], is_admin=False)
user["is_admin"] = False
return {"token": token, "user": user}
@app.post("/api/auth/login")
def login(req: LoginRequest):
user = get_user_by_email(req.email)
if not user or not verify_password(req.password, user["password_hash"]):
raise HTTPException(401, "Invalid email or password")
is_admin = bool(user.get("is_admin", 0))
token = create_access_token(user["id"], user["email"], is_admin=is_admin)
return {"token": token, "user": {"id": user["id"], "email": user["email"], "name": user.get("name", ""), "is_admin": is_admin}}
@app.get("/api/auth/me")
def me(current_user: dict = Depends(get_current_user)):
return get_user_by_id(current_user["user_id"])
@app.post("/api/auth/forgot-password")
def forgot_password(req: ForgotPasswordRequest):
if not req.email:
raise HTTPException(400, "Email is required")
raw_token = create_reset_token(req.email)
if not raw_token:
return {"message": "If that email is registered, a reset token has been generated"}
return {"token": raw_token, "message": "Use this token to reset your password (expires in 15 minutes)"}
@app.post("/api/auth/reset-password")
def reset_password(req: ResetPasswordRequest):
if not req.token or not req.password:
raise HTTPException(400, "Token and password are required")
if len(req.password) < 6:
raise HTTPException(400, "Password must be at least 6 characters")
entry = get_valid_token(req.token)
if not entry:
raise HTTPException(400, "Invalid or expired reset token")
mark_token_used(entry["id"])
update_password(entry["user_id"], hash_password(req.password))
return {"message": "Password reset successfully"}
# ── CV Routes ───────────────────────────────────────────────────────────────
@app.get("/api/cv")
def get_user_cv(current_user: dict = Depends(get_current_user)):
cv_json = get_cv(current_user["user_id"])
if not cv_json:
return get_cv_default()
try:
return json.loads(cv_json)
except json.JSONDecodeError:
return get_cv_default()
@app.put("/api/cv")
def put_user_cv(data: dict = Body(...), current_user: dict = Depends(get_current_user)):
try:
save_cv(current_user["user_id"], json.dumps(data, ensure_ascii=False))
return {"status": "ok"}
except Exception as e:
raise HTTPException(500, detail=f"Failed to save CV: {str(e)}")
@app.get("/api/cv/raw-text")
def get_raw_text(current_user: dict = Depends(get_current_user)):
return {"raw_text": get_cv_raw_text(current_user["user_id"])}
@app.put("/api/cv/raw-text")
def put_raw_text(data: dict = Body(...), current_user: dict = Depends(get_current_user)):
try:
save_cv_raw_text(current_user["user_id"], data.get("raw_text", ""))
return {"status": "ok"}
except Exception as e:
raise HTTPException(500, detail=f"Failed to save raw text: {str(e)}")
class ParseCVRequest(BaseModel):
raw_text: str = ""
@app.post("/api/cv/parse")
def parse_cv(req: ParseCVRequest, current_user: dict = Depends(get_current_user)):
api_keys = get_effective_api_keys(current_user["user_id"])
cv = parse_cv_text(req.raw_text, api_keys)
return cv
# ── Make CV from Scratch (for users without a CV) ──────────────────────────
class MakeCVRequest(BaseModel):
raw_text: str = ""
target_jobs: list[str] = []
target_type: str = "local" # "local" or "international"
remote: bool = False
timezone: str = "Africa/Lagos"
@app.post("/api/cv/make-cv")
def make_cv(req: MakeCVRequest, current_user: dict = Depends(get_current_user)):
user_id = current_user["user_id"]
api_keys = get_effective_api_keys(user_id)
if not req.raw_text.strip():
return {
"status": "template",
"cv": get_cv_default(),
"preview": "Paste your information and click Generate to create your CV.",
"provider": "none",
}
if not api_keys:
raise HTTPException(400, "No API keys found. Add keys in Settings or enable default AI.")
cv_data, provider = make_cv_from_scratch(
raw_text=req.raw_text,
target_jobs=req.target_jobs,
target_type=req.target_type,
remote_preferences={"remote": req.remote, "timezone": req.timezone},
api_keys=api_keys,
)
# Save the generated CV and raw text
try:
save_cv(user_id, json.dumps(cv_data, ensure_ascii=False), raw_text=req.raw_text)
except Exception as e:
raise HTTPException(500, f"Failed to save CV: {str(e)}")
try:
# Generate preview text + documents
preview = generate_cv_preview_text(cv_data)
name = (cv_data.get("personal_info") or {}).get("name", "")
counter = get_and_increment_cv_gen_count(user_id)
slug = _file_slug(name, counter=counter)
cv_path = str(GENERATED_DIR / f"{slug}_cv.docx")
cv_pdf_path = str(GENERATED_DIR / f"{slug}_cv.pdf")
profile = get_cv_profile(str(user_id), counter=counter)
generate_cv_docx(cv_data, cv_path, target_type=req.target_type, profile=profile, cv_seed=f"{user_id}_cv_{counter}")
generate_cv_pdf(cv_data, cv_pdf_path, target_type=req.target_type, profile=profile, cv_seed=f"{user_id}_cv_{counter}")
except Exception as e:
raise HTTPException(500, f"Document generation failed: {type(e).__name__}: {e}")
return {
"status": "ok",
"cv": cv_data,
"preview": preview,
"provider": provider,
"target_jobs": req.target_jobs,
"target_type": req.target_type,
"cv_path": cv_path,
"cv_pdf_path": cv_pdf_path,
}
# ── API Keys ────────────────────────────────────────────────────────────────
@app.get("/api/keys")
def get_user_keys(current_user: dict = Depends(get_current_user)):
keys = get_api_keys(current_user["user_id"])
settings = get_user_settings(current_user["user_id"])
keys["use_default_api"] = settings.get("use_default_api", True)
return keys
@app.put("/api/keys")
def put_user_keys(keys: KeysRequest, current_user: dict = Depends(get_current_user)):
data = {k: v.strip() for k, v in {"groq": keys.groq, "nvidia": keys.nvidia, "gemini": keys.gemini}.items() if v and v.strip()}
print(f"[keys] saving for user {current_user['user_id']}: providers={list(data.keys())}")
save_api_keys(current_user["user_id"], data)
save_user_settings(current_user["user_id"], {"use_default_api": keys.use_default_api})
saved = get_api_keys(current_user["user_id"])
settings = get_user_settings(current_user["user_id"])
saved["use_default_api"] = settings.get("use_default_api", True)
print(f"[keys] after save for user {current_user['user_id']}: {saved}")
return saved
# ── Scrape Routes ───────────────────────────────────────────────────────────
SCRAPER_MAP = {
"nigeria": NigerianJobScraper(),
"ngo": NGOJobScraper(),
"international": InternationalJobScraper(),
"highimpact": HighImpactScraper(),
"web3": Web3JobScraper(),
"ats": ATSJobScraper(),
}
def _scrape_board(board_name: str, cat: str, query: str) -> list[dict]:
scraper = SCRAPER_MAP.get(cat)
if not scraper:
return []
method = getattr(scraper, f"scrape_{board_name}", None)
if not method:
return []
try:
result = method(query)
if isinstance(result, list):
for j in result:
j["source"] = board_name; j["category"] = cat
return result
except Exception as e:
print(f"[scrape] Error scraping {board_name} ({cat}): {e}")
return []
@app.post("/api/scrape")
def scrape(req: ScrapeRequest):
query = req.title.strip() or "Data Analyst"
all_jobs = []
for cat, boards in JOB_BOARDS.items():
for name, cfg in boards.items():
if req.boards and name not in req.boards and cat not in req.boards:
continue
if not cfg.get("enabled", True) or cfg.get("type") in ("login_required", "login_optional", "playwright"):
continue
jobs = _scrape_board(name, cat, query)
all_jobs.extend(jobs)
saved = save_global_jobs(all_jobs)
return {"jobs": all_jobs[:50], "count": len(all_jobs), "saved": saved}
# ── Global Jobs Routes ──────────────────────────────────────────────────────
@app.post("/api/scrape/cron")
def scrape_cron(req: ScrapeRequest, token: str = Query("")):
if CRON_TOKEN and token != CRON_TOKEN:
raise HTTPException(403, "Invalid token")
threading.Thread(target=run_nightly_scrape, daemon=True).start()
return {"status": "started", "message": "Scraping in background"}
@app.get("/api/check-network")
def check_network():
import requests as _req
results = {}
for url in ["https://google.com", "https://www.myjobmag.com", "https://api.github.com"]:
try:
r = _req.get(url, timeout=8)
results[url] = f"OK ({r.status_code})"
except Exception as e:
results[url] = f"FAIL: {type(e).__name__}"
return results
@app.get("/api/scrape/test")
def scrape_test(board: str = ""):
from backend.scrapers.nigeria import NigerianJobScraper
from backend.database import save_global_jobs, get_global_stats
s = NigerianJobScraper()
if board in ("ngo", "intl", "hi"):
import io, contextlib
boards_map = {
"ngo": ("backend.scrapers.ngos", "NGOJobScraper", "scrape_myngojob"),
"intl": ("backend.scrapers.international", "InternationalJobScraper", "scrape_reed"),
"hi": ("backend.scrapers.highimpact", "HighImpactScraper", "scrape_anthropic"),
}
mod_path, cls_name, method_name = boards_map[board]
import importlib
mod = importlib.import_module(mod_path)
scraper_cls = getattr(mod, cls_name)
scraper = scraper_cls()
buf = io.StringIO()
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
try:
jobs = getattr(scraper, method_name)("Data Analyst")
except Exception as e:
return {"board": board, "error": str(e), "logs": buf.getvalue()[:2000]}
return {"board": board, "count": len(jobs or []), "first": (jobs[0] if jobs else None), "logs": buf.getvalue()[:2000]}
if board:
import io, contextlib, sys, requests as _req
url = f"https://www.{board}.com/search?q=Data+Analyst"
if board == "hotnigerianjobs":
url = "https://www.hotnigerianjobs.com/?s=Data+Analyst"
elif board == "myjobmag":
url = "https://www.myjobmag.com/search?q=Data+Analyst"
elif board == "ngcareers":
url = "https://ngcareers.com/jobs?q=Data+Analyst"
elif board == "jobzilla":
url = "https://www.jobzilla.ng/jobs?q=Data+Analyst"
elif board == "jobgurus":
url = "https://www.jobgurus.com.ng/?s=Data+Analyst"
r = _req.get(url, timeout=15, headers={"User-Agent": "Mozilla/5.0"})
method = getattr(s, f"scrape_{board}", None)
if not method:
return {"error": f"no scraper named {board}"}
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
with contextlib.redirect_stderr(buf):
try:
jobs = method("Data Analyst")
except Exception as e:
return {"board": board, "error": str(e), "logs": buf.getvalue()[:2000]}
return {
"board": board,
"count": len(jobs or []),
"first": (jobs[0] if jobs else None),
"logs": buf.getvalue()[:2000],
"direct_fetch": {"status": r.status_code, "length": len(r.text)},
}
all_jobs = []
results = {}
for name in dir(s):
if name.startswith("scrape_"):
board_name = name.replace("scrape_", "")
try:
jobs = getattr(s, name)("Data Analyst")
results[board_name] = len(jobs or [])
if jobs: all_jobs.extend(jobs)
except Exception as e:
results[board_name] = f"err: {e}"
saved = save_global_jobs(all_jobs)
return {"results": results, "total_scraped": len(all_jobs), "total_saved": saved, "stats": get_global_stats()}
@app.get("/api/jobs")
def list_global_jobs(
category: str = Query(""), source: str = Query(""), search: str = Query(""),
is_graduate: bool = Query(None), sort: str = Query("date"),
limit: int = Query(100), offset: int = Query(0),
):
jobs, total = get_global_jobs(category, source, search, is_graduate, limit, offset, sort)
return {"jobs": jobs, "total": total, "count": len(jobs)}
@app.get("/api/jobs/{job_id}")
def get_job(job_id: int):
job = get_global_job(job_id)
if not job:
raise HTTPException(404, "Job not found")
return job
# ── User-Job Link Routes ────────────────────────────────────────────────────
@app.post("/api/jobs/{job_id}/link")
def link_job(job_id: int, current_user: dict = Depends(get_current_user)):
job = get_global_job(job_id)
if not job:
raise HTTPException(404, "Job not found")
link_user_job(current_user["user_id"], job_id)
return {"status": "linked"}
@app.get("/api/my/jobs")
def my_jobs(current_user: dict = Depends(get_current_user)):
jobs = get_user_linked_jobs(current_user["user_id"])
return {"jobs": jobs, "count": len(jobs)}
# ── Manual Job Entry ────────────────────────────────────────────────────────
_scraper_base = BaseScraper()
@app.post("/api/jobs/extract-url")
def extract_job_url(url: str = Body(..., embed=True)):
if not url:
raise HTTPException(400, "URL required")
import traceback as _tb
print(f"[extract-url] fetching: {url[:100]}")
html = None
try:
html = _scraper_base.fetch(url, timeout=15)
except Exception as e:
print(f"[extract-url] method1 failed: {e}")
_tb.print_exc()
if not html:
try:
import requests as _req2
resp = _req2.get(url, headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
}, timeout=15)
if resp.status_code == 200:
html = resp.text
except Exception as e:
print(f"[extract-url] method2 failed: {e}")
_tb.print_exc()
if not html:
raise HTTPException(400, "Could not fetch URL. The site might be blocking automated access. Try pasting details manually.")
try:
from bs4 import BeautifulSoup
import json as _json
soup = BeautifulSoup(html, "html.parser")
title = ""
company = ""
location = ""
desc = ""
for script in soup.find_all("script", type="application/ld+json"):
text = script.string or ""
if not text.strip(): continue
try:
data = _json.loads(text)
except _json.JSONDecodeError:
continue
if not isinstance(data, dict) or data.get("@type") != "JobPosting":
continue
if data.get("title"):
title = data["title"]
ho = data.get("hiringOrganization") or {}
if isinstance(ho, dict) and ho.get("name"):
company = ho["name"]
jl = data.get("jobLocation") or {}
if isinstance(jl, dict):
addr = jl.get("address") or jl
parts = []
for key in ("addressLocality", "addressRegion", "addressCountry"):
v = addr.get(key, "") if isinstance(addr, dict) else ""
if v: parts.append(v)
if parts: location = ", ".join(parts)
if data.get("description"):
import html as _html
from bs4 import BeautifulSoup as _BS
desc_text = _html.unescape(data["description"])
desc_soup = _BS(desc_text, "html.parser")
for br in desc_soup.find_all("br"): br.replace_with("\n")
for p_tag in desc_soup.find_all("p"): p_tag.append("\n")
desc = desc_soup.get_text("\n", strip=True)
break
meta_title = soup.find("meta", property="og:title") or soup.find("meta", attrs={"name": "twitter:title"})
meta_desc = soup.find("meta", property="og:description") or soup.find("meta", attrs={"name": "twitter:description"}) or soup.find("meta", attrs={"name": "description"})
if not title:
if meta_title:
title = meta_title.get("content", "")
if not title:
for sel in ["h1", "h2", "title", "[class*='title'] h1", "[class*='job-title']", "[data-testid='job-title']", ".job-header h1"]:
el = soup.select_one(sel)
if el:
title = el.get_text(strip=True)
break
if not desc:
for sel in ["[class*='description']", "[class*='job-description']", "[data-testid='job-description']",
".job-details", ".vacancy-desc", "article", "main", "[role='main']"]:
el = soup.select_one(sel)
if el:
for br in el.find_all("br"): br.replace_with("\n")
for p_tag in el.find_all("p"): p_tag.append("\n")
desc = el.get_text("\n", strip=True)
break
if not desc and meta_desc:
desc = meta_desc.get("content", "")
if not company:
for sel in ["[class*='company']", "[class*='employer']", "[class*='org']",
"[data-testid='company-name']", ".hiring-org", "meta[name='author']"]:
el = soup.select_one(sel)
if el:
company = el.get("content", el.get_text(strip=True))
break
if not location:
for sel in ["[class*='location']", "[class*='place']", "[class*='city']", "[class*='country']", ".job-location"]:
el = soup.select_one(sel)
if el:
location = el.get_text(strip=True)
break
job_data = {
"title": title or "Unknown Position",
"company": company or "",
"location": location or "",
"description": (desc or "")[:5000] if desc else "No description extracted",
"url": url,
"source": "manual",
"category": classify_job_title(title, desc or ""),
}
return job_data
except Exception as e:
_tb.print_exc()
raise HTTPException(400, f"Failed to parse job page: {e}")
# ── Tailor Routes ───────────────────────────────────────────────────────────
def _tailor_with_quality(job_title, job_description, company, cv_data, api_keys, category, raw_text=""):
"""Tailor with quality gate + up to 3 regeneration attempts."""
MAX_ATTEMPTS = 3
best_result = None
best_score = -1
feedback = ""
for attempt in range(1, MAX_ATTEMPTS + 1):
result = llm_tailor(
job_title=job_title,
job_description=job_description,
company=company,
user_cv=cv_data,
api_keys=api_keys,
category=category,
feedback=feedback,
attempt=attempt,
raw_text=raw_text,
)
tailored_cv = dict(cv_data)
if result.get("professional_summary"):
tailored_cv["professional_summary"] = result["professional_summary"]
if result.get("tailored_skills"):
tailored_cv["skills"] = result["tailored_skills"]
if result.get("tailored_experience"):
tailored_cv["experience"] = result["tailored_experience"]
if result.get("cover_letter"):
tailored_cv["cover_letter"] = result["cover_letter"]
quality = score_cv_quality(tailored_cv)
total = sum(v["score"] for v in quality["scores"].values())
if total > best_score:
best_result = (result, tailored_cv, quality)
best_score = total
if quality["pass"]:
return best_result
feedback = quality["feedback"]
return best_result
# NOTE: /api/tailor/from-data MUST be defined before /api/tailor/{job_id}
# to prevent FastAPI from matching "from-data" as a job_id int parameter.
@app.post("/api/tailor/from-data")
def tailor_from_job_data(
job: dict = Body(...),
current_user: dict = Depends(get_current_user),
):
user_id = current_user["user_id"]
user_cv_json = get_cv(user_id)
if not user_cv_json:
raise HTTPException(400, "No CV found. Save your CV first.")
cv_data = json.loads(user_cv_json)
api_keys = get_effective_api_keys(user_id)
if not api_keys:
raise HTTPException(400, "No API keys found. Add keys in Settings or enable default AI.")
raw_text = get_cv_raw_text(user_id)
target_type = job.get("target_type", "local")
tailored = _tailor_with_quality(
job_title=job.get("title", ""),
job_description=job.get("description", ""),
company=job.get("company", ""),
cv_data=cv_data,
api_keys=api_keys,
category=job.get("category", ""),
raw_text=raw_text,
)
result, tailored_cv, quality = tailored
name = (tailored_cv.get("personal_info") or {}).get("name", "")
counter = get_and_increment_cv_gen_count(user_id)
diversity_seed = f"{user_id}_{job.get('title', '')}_{counter}"
tailored_cv = randomize_tailored_cv(tailored_cv, job.get("title", ""), seed=diversity_seed)
slug = _file_slug(name, job.get("company", ""), counter=counter)
cv_path = str(GENERATED_DIR / f"{slug}_cv.docx")
cover_path = str(GENERATED_DIR / f"{slug}_cover.docx")
cv_pdf_path = str(GENERATED_DIR / f"{slug}_cv.pdf")
cover_pdf_path = str(GENERATED_DIR / f"{slug}_cover.pdf")
profile = get_cv_profile(str(user_id), counter=counter)
generate_cv_docx(tailored_cv, cv_path, target_type=target_type, profile=profile, cv_seed=diversity_seed)
generate_cover_docx(result.get("cover_letter", "") or "", cover_path, personal_info=tailored_cv.get("personal_info"), company=job.get("company", ""), profile=profile)
generate_cv_pdf(tailored_cv, cv_pdf_path, target_type=target_type, profile=profile, cv_seed=diversity_seed)
generate_cover_pdf(result.get("cover_letter", "") or "", cover_pdf_path, personal_info=tailored_cv.get("personal_info"), company=job.get("company", ""), profile=profile)
hr_email = None
if job.get("generate_email"):
personal = cv_data.get("personal_info") or {}
hr_email = generate_hr_email(
job_title=job.get("title", ""),
company=job.get("company", ""),
job_description=job.get("description", ""),
candidate_name=personal.get("name", "Candidate"),
summary=cv_data.get("professional_summary", ""),
skills=cv_data.get("skills", []),
experiences=cv_data.get("experience", []),
education=cv_data.get("education", []),
api_keys=api_keys,
target_type=target_type,
)
return {
"status": "ok",
"match_score": result.get("match_score", 0),
"provider": result.get("provider", "rule-based"),
"keywords_hit": result.get("keywords_hit", []),
"professional_summary": result.get("professional_summary", ""),
"cover_letter": result.get("cover_letter", "") or "",
"cv_preview": generate_cv_preview_text(tailored_cv),
"quality_scores": quality["scores"],
"cv_path": cv_path,
"cover_path": cover_path,
"cv_pdf_path": cv_pdf_path,
"cover_pdf_path": cover_pdf_path,
"hr_email": hr_email,
}
@app.post("/api/tailor/{job_id}")
def tailor_job(job_id: int, current_user: dict = Depends(get_current_user), generate_email: bool = Query(False)):
user_id = current_user["user_id"]
job = get_global_job(job_id)
if not job:
raise HTTPException(404, "Job not found")
user_cv_json = get_cv(user_id)
if not user_cv_json:
raise HTTPException(400, "No CV found. Save your CV first.")
cv_data = json.loads(user_cv_json)
api_keys = get_effective_api_keys(user_id)
if not api_keys:
raise HTTPException(400, "No API keys found. Add keys in Settings or enable default AI.")
raw_text = get_cv_raw_text(user_id)
# Link job to user if not already
link_user_job(user_id, job_id)
target_type = job.get("category", "") if job.get("category", "") in ("international", "remote") else "local"
tailored = _tailor_with_quality(
job_title=job.get("title", ""),
job_description=job.get("description", ""),
company=job.get("company", ""),
cv_data=cv_data,
api_keys=api_keys,
category=job.get("job_category", ""),
raw_text=raw_text,
)
result, tailored_cv, quality = tailored
name = (tailored_cv.get("personal_info") or {}).get("name", "")
counter = get_and_increment_cv_gen_count(user_id)
diversity_seed = f"{user_id}_{job.get('title', '')}_{counter}"
tailored_cv = randomize_tailored_cv(tailored_cv, job.get("title", ""), seed=diversity_seed)
slug = _file_slug(name, job.get("company", ""), counter=counter)
cv_path = str(GENERATED_DIR / f"{slug}_cv.docx")
cover_path = str(GENERATED_DIR / f"{slug}_cover.docx")
cv_pdf_path = str(GENERATED_DIR / f"{slug}_cv.pdf")
cover_pdf_path = str(GENERATED_DIR / f"{slug}_cover.pdf")
profile = get_cv_profile(str(user_id), counter=counter)
generate_cv_docx(tailored_cv, cv_path, target_type=target_type, profile=profile, cv_seed=diversity_seed)
generate_cover_docx(result.get("cover_letter", "") or "", cover_path, personal_info=tailored_cv.get("personal_info"), company=job.get("company", ""), profile=profile)
generate_cv_pdf(tailored_cv, cv_pdf_path, target_type=target_type, profile=profile, cv_seed=diversity_seed)
generate_cover_pdf(result.get("cover_letter", "") or "", cover_pdf_path, personal_info=tailored_cv.get("personal_info"), company=job.get("company", ""), profile=profile)
update_link_tailoring(user_id, job_id, cv_path, cover_path)
hr_email = None
if generate_email:
personal = cv_data.get("personal_info") or {}
hr_email = generate_hr_email(
job_title=job.get("title", ""),
company=job.get("company", ""),
job_description=job.get("description", ""),
candidate_name=personal.get("name", "Candidate"),
summary=cv_data.get("professional_summary", ""),
skills=cv_data.get("skills", []),
experiences=cv_data.get("experience", []),
education=cv_data.get("education", []),
api_keys=api_keys,
target_type=target_type,
)
return {
"status": "ok",
"job_title": job.get("title", ""),
"match_score": result.get("match_score", 0),
"provider": result.get("provider", "rule-based"),
"keywords_hit": result.get("keywords_hit", []),
"professional_summary": result.get("professional_summary", ""),
"cover_letter": result.get("cover_letter", "") or "",
"cv_preview": generate_cv_preview_text(tailored_cv),
"quality_scores": quality["scores"],
"cv_path": cv_path,
"cover_path": cover_path,
"cv_pdf_path": cv_pdf_path,
"cover_pdf_path": cover_pdf_path,
"hr_email": hr_email,
}
@app.post("/api/jobs/{job_id}/email-hr")
def email_hr(job_id: int, current_user: dict = Depends(get_current_user)):
user_id = current_user["user_id"]
job = get_global_job(job_id)
if not job:
raise HTTPException(404, "Job not found")
user_cv_json = get_cv(user_id)
if not user_cv_json:
raise HTTPException(400, "No CV found. Save your CV first.")
cv_data = json.loads(user_cv_json)
api_keys = get_effective_api_keys(user_id)
if not api_keys:
raise HTTPException(400, "No API keys found. Add keys in Settings or enable default AI.")
personal = cv_data.get("personal_info") or {}
target_type = "international" if job.get("category", "") in ("international", "remote") else "local"
email = generate_hr_email(
job_title=job.get("title", ""),
company=job.get("company", ""),
job_description=job.get("description", ""),
candidate_name=personal.get("name", "Candidate"),
summary=cv_data.get("professional_summary", ""),
skills=cv_data.get("skills", []),
experiences=cv_data.get("experience", []),
education=cv_data.get("education", []),
api_keys=api_keys,
target_type=target_type,
)
if not email:
raise HTTPException(502, "Failed to generate email β€” AI provider unavailable.")
return {"status": "ok", "email": email.strip()}
# ── Download Routes ─────────────────────────────────────────────────────────
# NOTE: /api/download/manual must be defined before /api/download/{job_id}
@app.get("/api/download/manual/{filename}")
def download_manual(filename: str, current_user: dict = Depends(get_current_user)):
file_path = GENERATED_DIR / filename
if not file_path.exists():
raise HTTPException(404, "File not found")
ext = Path(filename).suffix.lower()
media_type = {
".pdf": "application/pdf",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
}.get(ext, "application/octet-stream")
return FileResponse(str(file_path), filename=filename, media_type=media_type, headers={
"Content-Disposition": f'attachment; filename="{filename}"',
})
@app.get("/api/download/{job_id}/{doc_type}")
def download(job_id: int, doc_type: str, current_user: dict = Depends(get_current_user)):
if doc_type not in ("cv", "cover"):
raise HTTPException(400, "doc_type must be 'cv' or 'cover'")
link = get_user_job_link(current_user["user_id"], job_id)
if not link:
raise HTTPException(404, "Job not linked. Tailor first.")
path = link.get("tailored_cv_path") if doc_type == "cv" else link.get("tailored_cover_path")
if not path or not os.path.exists(path):
raise HTTPException(404, "No tailored file found. Tailor first.")
filename = f"{doc_type}_{link.get('title', 'job')[:30].replace(' ', '_')}.docx"
return FileResponse(path, filename=filename, media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document")
# ── Excel Export ────────────────────────────────────────────────────────────
@app.get("/api/export/excel")
def export_excel(
category: str = Query(""), source: str = Query(""), search: str = Query(""),
current_user: dict = Depends(get_current_user),
):
jobs, _ = get_global_jobs(category, source, search, limit=5000)
path = export_jobs_to_excel(jobs)
return FileResponse(path, filename="joblin_jobs.xlsx", media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
# ── Dashboard / Stats ──────────────────────────────────────────────────────
@app.get("/api/stats")
def stats():
try:
return get_global_stats()
except Exception as e:
import traceback
traceback.print_exc()
raise HTTPException(500, f"Stats error: {type(e).__name__}: {e}")
@app.get("/api/health")
def health():
return {"status": "ok", "timestamp": datetime.now().isoformat()}
@app.get("/api/cleanup")
def cleanup_old_jobs(token: str = Query("")):
if CRON_TOKEN and token != CRON_TOKEN:
raise HTTPException(403, "Invalid token")
removed = deactivate_old_jobs(7)
return {"removed": removed, "status": "ok"}
@app.get("/api/config")
def get_config():
boards = {}
for cat, cat_boards in JOB_BOARDS.items():
boards[cat] = {name: {"name": name, "region": cfg.get("region", ""), "type": cfg.get("type", "public"), "enabled": cfg.get("enabled", True)} for name, cfg in cat_boards.items()}
categories = get_categories()
return {"boards": boards, "categories": categories}
# ── Serve frontend ──────────────────────────────────────────────────────────
@app.api_route("/{full_path:path}", methods=["GET"])
def serve_frontend(full_path: str):
if full_path.startswith("api/"):
raise HTTPException(404, "Not found")
if not full_path:
full_path = "index.html"
file_path = FRONTEND_DIR / full_path
if file_path.is_file():
return FileResponse(str(file_path))
html_path = file_path.with_suffix(".html")
if html_path.is_file():
return FileResponse(str(html_path))
login_path = FRONTEND_DIR / "login.html"
if login_path.is_file():
return FileResponse(str(login_path))
raise HTTPException(404, "Not found")
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="127.0.0.1", port=8002, reload=True)