# -*- 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 = """
⬡   AI-POWERED JOB INTELLIGENCE   ⬡
NEXUS JOB
SEARCH AGENT
Real-time Gulf job intelligence · Firecrawl web scraping · GPT-4o-mini extraction · AI resume scoring
● Live Data GPT-4o-mini NaukriGulf Firecrawl Resume AI
""" STATS_HTML = """
Live Jobs Scraped
AI
GPT-4o-mini Powered
0–100
Resume Match Score
""" FOOTER_HTML = """ """ SUCCESS_BANNER_HTML = '
✦   SEARCH COMPLETE — EXPLORE YOUR OPPORTUNITIES BELOW   ✦
' SCORE_BANNER_HTML = '
✦   SCORING COMPLETE — YOUR MATCH RESULTS ARE READY   ✦
' # ══════════════════════════════════ # 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('
Configure API Keys
') gr.HTML( '
' '🔐 Your API keys are never stored — they exist only in your browser session and are sent directly to the respective APIs.

' '• Get your Firecrawl API key at firecrawl.dev
' '• Get your OpenAI API key at platform.openai.com/api-keys' '
' ) 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('
') # ════════════════════════════════════════ # TAB 1 — JOB SEARCH # ════════════════════════════════════════ with gr.Tab("🔍 Job Search"): gr.HTML('
Search Criteria
') 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( '
URL pattern: naukrigulf.com/{job-title}-jobs-in-{location} — All found listings are returned
' '
' ) 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('
Live Results
') jobs_out = gr.Markdown(value="_Enter a job title above and hit Search to begin._") gr.HTML('
') # ════════════════════════════════════════ # TAB 2 — RESUME SCORING # ════════════════════════════════════════ with gr.Tab("📄 Resume Scorer"): with gr.Group(elem_id="resume-panel"): gr.HTML('
Upload Resume
') gr.HTML( '

' 'First run a Job Search, then upload your resume to see ' 'how well you match each listing — scored 0–100 by AI.' '

' ) 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('
Match Analysis
') score_out = gr.Markdown( value="_Upload your resume and click Score after running a job search._" ) gr.HTML('
') 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()