adil9858's picture
Update app.py
83a3431 verified
# -*- coding: utf-8 -*-
# =======================================================
# Job Search Agent β€” Firecrawl + OpenAI + Gradio
# Source: naukrigulf.com | Cleaned by GPT-4o-mini
# + Resume Scoring Feature
# HuggingFace Spaces Version β€” API keys entered in UI
# =======================================================
import os
import re
import json
import logging
import warnings
from warnings import filterwarnings
filterwarnings("ignore")
warnings.filterwarnings("ignore")
os.environ["PYTHONWARNINGS"] = "ignore"
logging.getLogger("httpx").setLevel(logging.CRITICAL)
logging.getLogger("openai").setLevel(logging.CRITICAL)
from firecrawl import FirecrawlApp
from openai import OpenAI
import gradio as gr
# ══════════════════════════════════
# URL BUILDER
# ══════════════════════════════════
def build_naukrigulf_url(job_title: str, location: str) -> str:
def slugify(text: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", text.lower().strip()).strip("-")
title_slug = slugify(job_title)
if location.strip():
loc_slug = slugify(location)
slug = f"{title_slug}-jobs-in-{loc_slug}"
else:
slug = f"{title_slug}-jobs"
return f"www.naukrigulf.com/{slug}"
# ══════════════════════════════════
# FIRECRAWL SCRAPER
# ══════════════════════════════════
def scrape_jobs(slug: str, firecrawl_api_key: str) -> str:
try:
firecrawl = FirecrawlApp(api_key=firecrawl_api_key)
data = firecrawl.scrape(
f"https://{slug}",
only_main_content=False,
max_age=172800000,
parsers=["pdf"],
formats=["markdown"]
)
if isinstance(data, dict):
return data.get("markdown", data.get("content", ""))
return str(data)
except Exception as e:
return f"SCRAPE_ERROR: {str(e)}"
# ══════════════════════════════════
# LLM CLEANER
# ══════════════════════════════════
SYSTEM_PROMPT = """
You are a job data extraction specialist.
You receive raw scraped markdown from a job listing website (naukrigulf.com).
Your task is to extract structured job listings and return them ONLY as a JSON array.
Each job object must have these exact keys:
- "title": job title (string)
- "company": company name (string)
- "location": job location (string)
- "experience": required experience (string, e.g. "3-5 Years" or "N/A")
- "salary": salary info (string or "Not Disclosed")
- "posted": when posted (string, e.g. "2 days ago" or "N/A")
- "apply_link": direct apply URL β€” ONLY include if a real URL is explicitly present in the scraped data. If not found, use null. DO NOT invent URLs.
- "skills": top 3-5 key skills as a list of strings (or empty list)
- "description": one-sentence summary of the role (string)
Return ONLY a valid JSON array. No markdown fences, no preamble, no explanation.
If no jobs are found, return an empty array: []
"""
def clean_with_llm(raw_markdown: str, job_title: str, openai_api_key: str) -> list:
if raw_markdown.startswith("SCRAPE_ERROR:"):
return []
trimmed = raw_markdown[:30000]
user_msg = (
f"Extract ALL job listings for '{job_title}' from the following scraped content. Do not skip any jobs.\n\n"
f"---SCRAPED CONTENT---\n{trimmed}"
)
try:
openai_client = OpenAI(api_key=openai_api_key)
response = openai_client.chat.completions.create(
model="gpt-4o-mini",
temperature=0.0,
max_tokens=8000,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
],
)
raw_json = response.choices[0].message.content.strip()
raw_json = re.sub(r"^```[a-z]*\n?", "", raw_json)
raw_json = re.sub(r"\n?```$", "", raw_json)
jobs = json.loads(raw_json)
if isinstance(jobs, list):
return jobs
return []
except Exception as e:
return [{"title": f"Parsing Error: {e}", "company": "", "location": "",
"experience": "", "salary": "", "posted": "",
"apply_link": None, "skills": [], "description": ""}]
# ══════════════════════════════════
# RESUME PARSER
# ══════════════════════════════════
def extract_resume_text(file_path) -> str:
if file_path is None:
return ""
ext = os.path.splitext(file_path)[-1].lower()
try:
if ext == ".pdf":
import pypdf
text_parts = []
with open(file_path, "rb") as f:
reader = pypdf.PdfReader(f)
for page in reader.pages:
page_text = page.extract_text()
if page_text:
text_parts.append(page_text)
return "\n".join(text_parts).strip()
elif ext in (".txt", ".md", ""):
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
return f.read().strip()
else:
return f"RESUME_ERROR: Unsupported file type '{ext}'. Please upload a PDF or TXT file."
except Exception as e:
return f"RESUME_ERROR: {str(e)}"
# ══════════════════════════════════
# RESUME SCORER
# ══════════════════════════════════
SCORING_SYSTEM_PROMPT = """
You are an expert technical recruiter and resume analyst.
You will be given a candidate's resume text and a list of job listings (each with title, company, skills, description, experience required).
For EACH job, score how well the resume matches the job on a scale of 0–100, where:
- 0–20 : Very poor match (unrelated skills/domain)
- 21–40 : Weak match (some overlap but major gaps)
- 41–60 : Moderate match (relevant background but missing key requirements)
- 61–80 : Good match (strong overlap, minor gaps)
- 81–100: Excellent match (highly aligned skills, experience, and domain)
Return ONLY a valid JSON array (no markdown fences, no preamble).
Each element must have:
- "job_index": 1-based integer (matching the job's position in the input list)
- "title": job title string
- "company": company name string
- "score": integer 0–100
- "match_summary": 1-2 sentence explanation of the score (key strengths or gaps)
- "missing_skills": list of up to 3 skills the resume is lacking for this role (empty list if none)
Sort results by score descending.
"""
def score_resume_against_jobs(resume_text: str, jobs: list, openai_api_key: str) -> list:
if not resume_text or not jobs:
return []
jobs_summary = []
for i, job in enumerate(jobs, start=1):
jobs_summary.append({
"index": i,
"title": job.get("title", "N/A"),
"company": job.get("company", "N/A"),
"experience_required": job.get("experience", "N/A"),
"skills": job.get("skills", []),
"description": job.get("description", ""),
})
user_msg = (
f"RESUME:\n{resume_text[:6000]}\n\n"
f"JOBS:\n{json.dumps(jobs_summary, indent=2)}"
)
try:
openai_client = OpenAI(api_key=openai_api_key)
response = openai_client.chat.completions.create(
model="gpt-4o-mini",
temperature=0.0,
max_tokens=4000,
messages=[
{"role": "system", "content": SCORING_SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
],
)
raw_json = response.choices[0].message.content.strip()
raw_json = re.sub(r"^```[a-z]*\n?", "", raw_json)
raw_json = re.sub(r"\n?```$", "", raw_json)
scored = json.loads(raw_json)
if isinstance(scored, list):
return scored
return []
except Exception as e:
return [{"job_index": 0, "title": "Scoring Error", "company": "",
"score": 0, "match_summary": str(e), "missing_skills": []}]
def scores_to_markdown(scored_jobs: list, resume_filename: str) -> str:
if not scored_jobs:
return "⚠️ No scoring results available. Make sure jobs were found and resume was uploaded."
def score_badge(s: int) -> str:
if s >= 81: return f"🟒 **{s}/100** _(Excellent)_"
if s >= 61: return f"πŸ”΅ **{s}/100** _(Good)_"
if s >= 41: return f"🟑 **{s}/100** _(Moderate)_"
if s >= 21: return f"🟠 **{s}/100** _(Weak)_"
return f"πŸ”΄ **{s}/100** _(Poor)_"
fname = os.path.basename(resume_filename) if resume_filename else "your resume"
header = (
f"### πŸ“„ Resume Match Scores β€” `{fname}`\n"
f"*Jobs ranked by match score (highest first)*\n\n"
"| Rank | Job Title | Company | Score | Missing Skills |\n"
"|:----:|-----------|---------|:-----:|----------------|\n"
)
rows = []
for rank, item in enumerate(scored_jobs, start=1):
title = item.get("title", "N/A")
company = item.get("company", "N/A")
score = item.get("score", 0)
missing = item.get("missing_skills", [])
missing_str = ", ".join(f"`{s}`" for s in missing) if missing else "β€”"
badge = score_badge(score)
rows.append(f"| {rank} | {title} | {company} | {badge} | {missing_str} |")
details = "\n\n---\n### πŸ”Ž Detailed Match Analysis\n"
for rank, item in enumerate(scored_jobs, start=1):
title = item.get("title", "N/A")
company = item.get("company", "N/A")
score = item.get("score", 0)
summary = item.get("match_summary", "")
missing = item.get("missing_skills", [])
badge = score_badge(score)
missing_md = " Β· ".join(f"`{s}`" for s in missing) if missing else "None identified"
details += (
f"\n**{rank}. {title}** @ *{company}*   {badge}\n"
f"> {summary}\n\n"
f"**Skills to develop:** {missing_md}\n"
)
return header + "\n".join(rows) + details
# ══════════════════════════════════
# MARKDOWN TABLE RENDERER
# ══════════════════════════════════
def jobs_to_markdown(jobs: list, job_title: str, location: str, url: str) -> str:
if not jobs:
return (
f"❌ **No jobs found** for **{job_title}**"
+ (f" in **{location}**" if location else "")
+ f"\n\nπŸ”— You can try opening the URL manually: [https://{url}](https://{url})"
)
loc_str = f" in **{location}**" if location else ""
display_url = f"https://{url}"
header = (
f"### πŸ” Job Results: **{job_title}**{loc_str}\n"
f"*Source: [{display_url}]({display_url})*\n\n"
"| # | Job Title | Company | Location | Experience | Salary | Posted | Skills | Apply |\n"
"|:--:|-----------|---------|----------|:----------:|:------:|:------:|--------|:-----:|\n"
)
rows = []
for i, job in enumerate(jobs, start=1):
title = job.get("title", "N/A") or "N/A"
company = job.get("company", "N/A") or "N/A"
loc = job.get("location", "N/A") or "N/A"
experience = job.get("experience", "N/A") or "N/A"
salary = job.get("salary", "Not Disclosed") or "Not Disclosed"
posted = job.get("posted", "N/A") or "N/A"
skills_list = job.get("skills", []) or []
apply_link = job.get("apply_link")
skills_str = ", ".join(skills_list[:3]) if skills_list else "β€”"
apply_cell = f"[Apply]({apply_link})" if apply_link else "β€”"
rows.append(
f"| {i} | {title} | {company} | {loc} | {experience} | {salary} | {posted} | {skills_str} | {apply_cell} |"
)
details = "\n\n---\n### πŸ“‹ Job Details\n"
for i, job in enumerate(jobs, start=1):
title = job.get("title", "N/A")
company = job.get("company", "N/A")
desc = job.get("description", "")
skills_list = job.get("skills", [])
apply_link = job.get("apply_link")
apply_md = f"[πŸ”— Apply Now]({apply_link})" if apply_link else "_(Apply link not available)_"
skills_md = " Β· ".join(f"`{s}`" for s in skills_list) if skills_list else "β€”"
details += (
f"\n**{i}. {title}** @ *{company}*\n"
f"> {desc}\n\n"
f"**Skills:** {skills_md} \n"
f"**Apply:** {apply_md}\n"
)
return header + "\n".join(rows) + details
# ══════════════════════════════════
# SESSION STATE
# ══════════════════════════════════
_last_fetched_jobs: list = []
# ══════════════════════════════════
# MAIN SEARCH FUNCTION
# ══════════════════════════════════
def run_job_search(job_title: str, location: str, firecrawl_api_key: str, openai_api_key: str, progress=gr.Progress()):
global _last_fetched_jobs
if not firecrawl_api_key.strip():
return "⚠️ Please enter your Firecrawl API key in the **API Keys** tab.", gr.update(visible=False)
if not openai_api_key.strip():
return "⚠️ Please enter your OpenAI API key in the **API Keys** tab.", gr.update(visible=False)
if not job_title.strip():
return "⚠️ Please enter a job title.", gr.update(visible=False)
progress(0.05, desc="πŸ”— Building URL...")
url = build_naukrigulf_url(job_title, location)
progress(0.20, desc=f"🌐 Scraping {url} ...")
raw_md = scrape_jobs(url, firecrawl_api_key)
if raw_md.startswith("SCRAPE_ERROR:"):
return f"❌ Scraping failed: {raw_md}\n\nURL attempted: {url}", gr.update(visible=False)
if len(raw_md.strip()) < 100:
return (
f"⚠️ Page returned very little content.\n\nURL: [https://{url}](https://{url})\n\n"
"Try a different job title or location.",
gr.update(visible=False)
)
progress(0.55, desc="πŸ€– Cleaning data with GPT-4o-mini...")
jobs = clean_with_llm(raw_md, job_title, openai_api_key)
_last_fetched_jobs = jobs
progress(0.85, desc="πŸ“Š Rendering table...")
output = jobs_to_markdown(jobs, job_title, location, url)
progress(1.00, desc="βœ… Done!")
return output, gr.update(visible=True)
# ══════════════════════════════════
# RESUME SCORING FUNCTION
# ══════════════════════════════════
def run_resume_scoring(resume_file, openai_api_key: str, progress=gr.Progress()):
global _last_fetched_jobs
if not openai_api_key.strip():
return "⚠️ Please enter your OpenAI API key in the **API Keys** tab.", gr.update(visible=False)
if resume_file is None:
return "⚠️ Please upload a resume (PDF or TXT).", gr.update(visible=False)
if not _last_fetched_jobs:
return (
"⚠️ No jobs loaded yet. Please run a **Job Search** first, then come back to score your resume.",
gr.update(visible=False)
)
progress(0.10, desc="πŸ“„ Extracting resume text...")
resume_text = extract_resume_text(resume_file)
if not resume_text or resume_text.startswith("RESUME_ERROR:"):
return f"❌ Could not read resume: {resume_text}", gr.update(visible=False)
if len(resume_text.strip()) < 50:
return "⚠️ Resume appears to be empty or unreadable. Please try a different file.", gr.update(visible=False)
progress(0.40, desc=f"πŸ€– Scoring against {len(_last_fetched_jobs)} jobs with GPT-4o-mini...")
scored = score_resume_against_jobs(resume_text, _last_fetched_jobs, openai_api_key)
progress(0.85, desc="πŸ“Š Rendering scores...")
fname = resume_file if isinstance(resume_file, str) else str(resume_file)
output = scores_to_markdown(scored, fname)
progress(1.00, desc="βœ… Scoring complete!")
return output, gr.update(visible=True)
# ══════════════════════════════════════════════════════════════
# BILLION-DOLLAR UI β€” DARK LUXURY EDITORIAL DESIGN
# ══════════════════════════════════════════════════════════════
CSS = """
@import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,300&family=JetBrains+Mono:wght@400;500&display=swap');
/* ─── RESET & BASE ─────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--void: #04040a;
--deep: #080810;
--surface: #0d0d1a;
--raised: #12121f;
--border: rgba(255,255,255,0.06);
--border-hi: rgba(255,255,255,0.12);
--gold: #f5c842;
--gold-dim: #c9a235;
--gold-glow: rgba(245,200,66,0.15);
--cyan: #00e5ff;
--cyan-dim: #0099b3;
--cyan-glow: rgba(0,229,255,0.12);
--rose: #ff4d6d;
--rose-glow: rgba(255,77,109,0.12);
--sage: #4ade80;
--sage-glow: rgba(74,222,128,0.12);
--text: #f0f0f8;
--muted: #6b6b8a;
--subtle: #3a3a55;
--font-display: 'Syne', sans-serif;
--font-body: 'DM Sans', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--r-xl: 28px;
--ease-smooth: cubic-bezier(0.25, 0.46, 0.45, 0.94);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* ─── BODY & GRADIO CONTAINER ──────────────────────────────── */
html, body {
background: var(--void) !important;
color: var(--text) !important;
font-family: var(--font-body) !important;
min-height: 100vh;
}
.gradio-container {
background: var(--void) !important;
max-width: 1200px !important;
margin: 0 auto !important;
padding: 0 24px 80px !important;
}
/* Global noise texture overlay */
.gradio-container::before {
content: '';
position: fixed;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 0;
opacity: 0.4;
}
/* ─── AMBIENT GLOW ORBS ────────────────────────────────────── */
body::before {
content: '';
position: fixed;
top: -200px; left: -200px;
width: 600px; height: 600px;
background: radial-gradient(circle, rgba(245,200,66,0.04) 0%, transparent 70%);
pointer-events: none;
z-index: 0;
animation: orb1 20s ease-in-out infinite alternate;
}
body::after {
content: '';
position: fixed;
bottom: -200px; right: -100px;
width: 700px; height: 700px;
background: radial-gradient(circle, rgba(0,229,255,0.04) 0%, transparent 70%);
pointer-events: none;
z-index: 0;
animation: orb2 25s ease-in-out infinite alternate;
}
@keyframes orb1 { from { transform: translate(0,0) scale(1); } to { transform: translate(100px, 80px) scale(1.2); } }
@keyframes orb2 { from { transform: translate(0,0) scale(1); } to { transform: translate(-80px, -60px) scale(1.15); } }
/* ─── HEADER ───────────────────────────────────────────────── */
#nexus-header {
position: relative;
padding: 72px 0 56px;
text-align: center;
overflow: hidden;
}
#nexus-header::before {
content: 'NEXUS';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-family: var(--font-display);
font-size: clamp(80px, 18vw, 200px);
font-weight: 800;
color: transparent;
-webkit-text-stroke: 1px rgba(255,255,255,0.025);
letter-spacing: 0.2em;
white-space: nowrap;
pointer-events: none;
z-index: 0;
}
.header-eyebrow {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.35em;
color: var(--gold);
text-transform: uppercase;
margin-bottom: 20px;
opacity: 0;
animation: fadeUp 0.8s var(--ease-smooth) 0.1s forwards;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.header-eyebrow::before,
.header-eyebrow::after {
content: '';
width: 40px;
height: 1px;
background: linear-gradient(90deg, transparent, var(--gold-dim));
}
.header-eyebrow::after {
background: linear-gradient(90deg, var(--gold-dim), transparent);
}
.header-title {
font-family: var(--font-display);
font-size: clamp(36px, 6vw, 72px);
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.02em;
color: var(--text);
margin-bottom: 6px;
opacity: 0;
animation: fadeUp 0.8s var(--ease-smooth) 0.2s forwards;
position: relative;
z-index: 1;
}
.header-title .accent-gold { color: var(--gold); }
.header-title .accent-cyan { color: var(--cyan); }
.header-subtitle {
font-family: var(--font-body);
font-size: 16px;
font-weight: 300;
color: var(--muted);
margin-top: 16px;
letter-spacing: 0.02em;
opacity: 0;
animation: fadeUp 0.8s var(--ease-smooth) 0.35s forwards;
position: relative;
z-index: 1;
}
.header-pills {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-top: 28px;
flex-wrap: wrap;
opacity: 0;
animation: fadeUp 0.8s var(--ease-smooth) 0.5s forwards;
}
.pill {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.15em;
text-transform: uppercase;
padding: 6px 14px;
border-radius: 100px;
border: 1px solid var(--border-hi);
color: var(--muted);
background: rgba(255,255,255,0.03);
backdrop-filter: blur(8px);
}
.pill.live { color: var(--sage); border-color: rgba(74,222,128,0.3); background: var(--sage-glow); }
.pill.ai { color: var(--cyan); border-color: rgba(0,229,255,0.3); background: var(--cyan-glow); }
.pill.gulf { color: var(--gold); border-color: rgba(245,200,66,0.3); background: var(--gold-glow); }
/* ─── DIVIDER ──────────────────────────────────────────────── */
.section-divider {
display: flex;
align-items: center;
gap: 16px;
margin: 0 0 28px;
}
.section-divider span {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.3em;
text-transform: uppercase;
color: var(--muted);
white-space: nowrap;
}
.section-divider::before,
.section-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
/* ─── TABS ─────────────────────────────────────────────────── */
.tabs { border: none !important; background: transparent !important; }
.tab-nav {
display: flex !important;
gap: 4px !important;
padding: 6px !important;
background: var(--raised) !important;
border: 1px solid var(--border) !important;
border-radius: var(--r-lg) !important;
margin-bottom: 32px !important;
backdrop-filter: blur(20px) !important;
}
.tab-nav button {
font-family: var(--font-display) !important;
font-size: 13px !important;
font-weight: 600 !important;
letter-spacing: 0.04em !important;
color: var(--muted) !important;
background: transparent !important;
border: none !important;
border-radius: var(--r-md) !important;
padding: 10px 20px !important;
transition: all 0.2s var(--ease-smooth) !important;
cursor: pointer !important;
}
.tab-nav button.selected,
.tab-nav button[aria-selected="true"] {
background: var(--gold) !important;
color: var(--void) !important;
box-shadow: 0 4px 16px rgba(245,200,66,0.3) !important;
}
/* ─── PANELS ────────────────────────────────────────────────── */
#search-panel, #results-panel, #resume-panel, #score-panel, #api-keys-panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-xl);
padding: 32px;
margin-bottom: 20px;
position: relative;
overflow: hidden;
}
#search-panel::before, #results-panel::before,
#resume-panel::before, #score-panel::before,
#api-keys-panel::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, var(--border-hi), transparent);
}
/* ─── INPUTS ─────────────────────────────────────────────────── */
.gradio-textbox input,
.gradio-textbox textarea,
input[type="text"],
input[type="password"],
textarea {
background: var(--raised) !important;
border: 1px solid var(--border-hi) !important;
border-radius: var(--r-md) !important;
color: var(--text) !important;
font-family: var(--font-body) !important;
font-size: 15px !important;
padding: 14px 18px !important;
transition: all 0.2s var(--ease-smooth) !important;
outline: none !important;
}
.gradio-textbox input:focus,
.gradio-textbox textarea:focus,
input[type="text"]:focus,
input[type="password"]:focus,
textarea:focus {
border-color: var(--gold) !important;
box-shadow: 0 0 0 3px var(--gold-glow) !important;
}
label span {
font-family: var(--font-mono) !important;
font-size: 11px !important;
letter-spacing: 0.15em !important;
text-transform: uppercase !important;
color: var(--muted) !important;
}
/* ─── BUTTONS ─────────────────────────────────────────────────── */
#search-btn, #score-btn {
width: 100% !important;
padding: 18px 32px !important;
font-family: var(--font-display) !important;
font-size: 14px !important;
font-weight: 700 !important;
letter-spacing: 0.12em !important;
text-transform: uppercase !important;
background: linear-gradient(135deg, var(--gold), var(--gold-dim)) !important;
color: var(--void) !important;
border: none !important;
border-radius: var(--r-md) !important;
cursor: pointer !important;
margin: 20px 0 !important;
transition: all 0.25s var(--ease-spring) !important;
box-shadow: 0 4px 24px rgba(245,200,66,0.25) !important;
}
#search-btn:hover, #score-btn:hover {
transform: translateY(-2px) !important;
box-shadow: 0 8px 32px rgba(245,200,66,0.4) !important;
}
#search-btn:active, #score-btn:active {
transform: translateY(0) !important;
}
/* ─── BANNERS ─────────────────────────────────────────────────── */
#success-banner, #score-banner {
text-align: center;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.3em;
text-transform: uppercase;
color: var(--gold);
padding: 16px;
border: 1px solid rgba(245,200,66,0.2);
border-radius: var(--r-md);
background: var(--gold-glow);
margin-bottom: 16px;
}
/* ─── STATS BAR ───────────────────────────────────────────────── */
#stats-bar {
display: flex;
gap: 16px;
margin-bottom: 32px;
flex-wrap: wrap;
}
.stat-card {
flex: 1;
min-width: 140px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: 20px 24px;
text-align: center;
transition: border-color 0.2s;
}
.stat-card:hover { border-color: var(--border-hi); }
.stat-num {
font-family: var(--font-display);
font-size: 28px;
font-weight: 800;
color: var(--gold);
line-height: 1;
}
.stat-label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--muted);
margin-top: 8px;
}
/* ─── URL HINT ────────────────────────────────────────────────── */
#url-hint {
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted);
margin-top: 12px;
padding: 10px 14px;
background: rgba(255,255,255,0.02);
border-radius: var(--r-sm);
border-left: 2px solid var(--gold-dim);
}
#url-hint code {
color: var(--cyan);
background: rgba(0,229,255,0.08);
padding: 2px 6px;
border-radius: 4px;
}
/* ─── MARKDOWN OUTPUT ─────────────────────────────────────────── */
.prose, .markdown-body, [data-testid="markdown"] {
font-family: var(--font-body) !important;
color: var(--text) !important;
line-height: 1.7 !important;
}
.prose table, [data-testid="markdown"] table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
margin: 16px 0;
}
.prose th, [data-testid="markdown"] th {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--muted);
padding: 12px 16px;
border-bottom: 1px solid var(--border-hi);
text-align: left;
white-space: nowrap;
}
.prose td, [data-testid="markdown"] td {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
color: var(--text);
vertical-align: top;
}
.prose tr:hover td, [data-testid="markdown"] tr:hover td {
background: rgba(255,255,255,0.02);
}
.prose a, [data-testid="markdown"] a {
color: var(--cyan);
text-decoration: none;
}
.prose a:hover, [data-testid="markdown"] a:hover {
text-decoration: underline;
color: var(--gold);
}
.prose code, [data-testid="markdown"] code {
font-family: var(--font-mono);
font-size: 12px;
background: rgba(255,255,255,0.06);
padding: 2px 6px;
border-radius: 4px;
color: var(--cyan);
}
.prose blockquote, [data-testid="markdown"] blockquote {
border-left: 3px solid var(--gold-dim);
padding-left: 16px;
color: var(--muted);
font-style: italic;
margin: 8px 0;
}
/* ─── FILE UPLOAD ─────────────────────────────────────────────── */
.gradio-file {
background: var(--raised) !important;
border: 2px dashed var(--border-hi) !important;
border-radius: var(--r-lg) !important;
transition: all 0.2s !important;
}
.gradio-file:hover {
border-color: var(--gold-dim) !important;
background: rgba(245,200,66,0.03) !important;
}
/* ─── API KEYS PANEL ─────────────────────────────────────────── */
#api-keys-panel {
border-color: rgba(245,200,66,0.2);
background: linear-gradient(135deg, var(--surface) 0%, rgba(245,200,66,0.03) 100%);
}
.api-notice {
font-family: var(--font-body);
font-size: 13px;
color: var(--muted);
line-height: 1.6;
margin-bottom: 20px;
padding: 14px 18px;
background: rgba(0,229,255,0.05);
border: 1px solid rgba(0,229,255,0.15);
border-radius: var(--r-md);
}
.api-notice a { color: var(--cyan); }
.api-notice strong { color: var(--text); }
/* ─── FOOTER ──────────────────────────────────────────────────── */
#nexus-footer {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 20px;
padding: 40px 0 20px;
border-top: 1px solid var(--border);
margin-top: 40px;
}
.footer-brand {
font-family: var(--font-display);
font-size: 18px;
font-weight: 800;
color: var(--gold);
letter-spacing: -0.02em;
}
.footer-brand span { color: var(--muted); }
.footer-stack {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.footer-tag {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.15em;
color: var(--muted);
border: 1px solid var(--border);
padding: 4px 10px;
border-radius: 100px;
text-transform: uppercase;
}
.footer-copy {
font-family: var(--font-body);
font-size: 12px;
color: var(--subtle);
width: 100%;
text-align: center;
margin-top: 16px;
}
/* ─── ANIMATIONS ─────────────────────────────────────────────── */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes shimmer {
0% { background-position: -200% center; }
100% { background-position: 200% center; }
}
/* ─── SCROLLBAR ─────────────────────────────────────────────── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--deep); }
::-webkit-scrollbar-thumb { background: var(--subtle); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--muted); }
/* ─── PROGRESS BAR ──────────────────────────────────────────── */
.progress-bar, [data-testid="progress"] {
background: rgba(245,200,66,0.1) !important;
border-radius: 100px !important;
}
.progress-bar > div { background: var(--gold) !important; border-radius: 100px !important; }
/* ─── SELECTION ─────────────────────────────────────────────── */
::selection { background: rgba(245,200,66,0.2); color: var(--text); }
"""
HEADER_HTML = """
<div id="nexus-header">
<div class="header-eyebrow">⬑ &nbsp; AI-POWERED JOB INTELLIGENCE &nbsp; ⬑</div>
<div class="header-title">
NEXUS <span class="accent-gold">JOB</span><br>
<span class="accent-cyan">SEARCH</span> AGENT
</div>
<div class="header-subtitle">
Real-time Gulf job intelligence Β· Firecrawl web scraping Β· GPT-4o-mini extraction Β· AI resume scoring
</div>
<div class="header-pills">
<span class="pill live">● Live Data</span>
<span class="pill ai">GPT-4o-mini</span>
<span class="pill gulf">NaukriGulf</span>
<span class="pill">Firecrawl</span>
<span class="pill">Resume AI</span>
</div>
</div>
"""
STATS_HTML = """
<div id="stats-bar">
<div class="stat-card">
<div class="stat-num">∞</div>
<div class="stat-label">Live Jobs Scraped</div>
</div>
<div class="stat-card">
<div class="stat-num">AI</div>
<div class="stat-label">GPT-4o-mini Powered</div>
</div>
<div class="stat-card">
<div class="stat-num">0–100</div>
<div class="stat-label">Resume Match Score</div>
</div>
</div>
"""
FOOTER_HTML = """
<div id="nexus-footer">
<div class="footer-brand">NEXUS<span>AI</span></div>
<div class="footer-stack">
<span class="footer-tag">Firecrawl</span>
<span class="footer-tag">OpenAI GPT-4o-mini</span>
<span class="footer-tag">Gradio</span>
<span class="footer-tag">NaukriGulf</span>
</div>
<div class="footer-copy">
Real-time job data scraped and cleaned by AI Β· Gulf region specialist Β· Built for ambitious professionals
</div>
</div>
"""
SUCCESS_BANNER_HTML = '<div id="success-banner">✦ &nbsp; SEARCH COMPLETE β€” EXPLORE YOUR OPPORTUNITIES BELOW &nbsp; ✦</div>'
SCORE_BANNER_HTML = '<div id="score-banner">✦ &nbsp; SCORING COMPLETE β€” YOUR MATCH RESULTS ARE READY &nbsp; ✦</div>'
# ══════════════════════════════════
# GRADIO UI BUILD
# ══════════════════════════════════
with gr.Blocks(css=CSS, title="NEXUS β€” Job Search Agent") as demo:
gr.HTML(HEADER_HTML)
gr.HTML(STATS_HTML)
with gr.Tabs(elem_classes="tabs"):
# ════════════════════════════════════════
# TAB 0 β€” API KEYS
# ════════════════════════════════════════
with gr.Tab("πŸ”‘ API Keys"):
gr.HTML('<div id="api-keys-panel"><div class="section-divider"><span>Configure API Keys</span></div>')
gr.HTML(
'<div class="api-notice">'
'πŸ” <strong>Your API keys are never stored</strong> β€” they exist only in your browser session and are sent directly to the respective APIs.<br><br>'
'β€’ Get your <strong>Firecrawl API key</strong> at <a href="https://www.firecrawl.dev" target="_blank">firecrawl.dev</a><br>'
'β€’ Get your <strong>OpenAI API key</strong> at <a href="https://platform.openai.com/api-keys" target="_blank">platform.openai.com/api-keys</a>'
'</div>'
)
firecrawl_key_input = gr.Textbox(
label="Firecrawl API Key",
placeholder="fc-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
type="password",
interactive=True,
)
openai_key_input = gr.Textbox(
label="OpenAI API Key",
placeholder="sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
type="password",
interactive=True,
)
gr.HTML('</div>')
# ════════════════════════════════════════
# TAB 1 β€” JOB SEARCH
# ════════════════════════════════════════
with gr.Tab("πŸ” Job Search"):
gr.HTML('<div id="search-panel"><div class="section-divider"><span>Search Criteria</span></div>')
with gr.Row():
job_title = gr.Textbox(
label="Job Title",
placeholder="e.g. Data Scientist, Software Engineer, Marketing Manager",
scale=3,
)
location = gr.Textbox(
label="Location",
placeholder="e.g. UAE, Saudi Arabia, Qatar, Dubai",
scale=2,
)
gr.HTML(
'<div id="url-hint">URL pattern: <code>naukrigulf.com/{job-title}-jobs-in-{location}</code> β€” All found listings are returned</div>'
'</div>'
)
search_btn = gr.Button(
"⚑ Search Jobs on NaukriGulf",
elem_id="search-btn",
variant="primary",
)
result_banner = gr.HTML(
value=SUCCESS_BANNER_HTML,
visible=False,
)
gr.HTML('<div id="results-panel"><div class="section-divider"><span>Live Results</span></div>')
jobs_out = gr.Markdown(value="_Enter a job title above and hit Search to begin._")
gr.HTML('</div>')
# ════════════════════════════════════════
# TAB 2 β€” RESUME SCORING
# ════════════════════════════════════════
with gr.Tab("πŸ“„ Resume Scorer"):
with gr.Group(elem_id="resume-panel"):
gr.HTML('<div class="section-divider"><span>Upload Resume</span></div>')
gr.HTML(
'<p style="font-family:\'DM Sans\',sans-serif;font-size:14px;color:#6b6b8a;margin:0 0 20px;line-height:1.6;">'
'First run a <strong style="color:#f5c842;">Job Search</strong>, then upload your resume to see '
'how well you match each listing β€” scored 0–100 by AI.'
'</p>'
)
resume_upload = gr.File(
label="Upload Resume (PDF or TXT)",
file_types=[".pdf", ".txt"],
type="filepath",
)
score_btn = gr.Button(
"⚑ Score My Resume Against Jobs",
elem_id="score-btn",
variant="primary",
)
score_banner = gr.HTML(
value=SCORE_BANNER_HTML,
visible=False,
)
gr.HTML('<div id="score-panel"><div class="section-divider"><span>Match Analysis</span></div>')
score_out = gr.Markdown(
value="_Upload your resume and click Score after running a job search._"
)
gr.HTML('</div>')
gr.HTML(FOOTER_HTML)
# ── Wire Job Search ───────────────────────────────────────────
search_btn.click(
fn=run_job_search,
inputs=[job_title, location, firecrawl_key_input, openai_key_input],
outputs=[jobs_out, result_banner],
show_progress="full",
)
# ── Wire Resume Scoring ───────────────────────────────────────
score_btn.click(
fn=run_resume_scoring,
inputs=[resume_upload, openai_key_input],
outputs=[score_out, score_banner],
show_progress="full",
)
# ══════════════════════════════════
# LAUNCH
# ══════════════════════════════════
if __name__ == "__main__":
demo.launch()