Spaces:
Running
Running
| # -*- 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">⬑ AI-POWERED JOB INTELLIGENCE ⬑</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">β¦ SEARCH COMPLETE β EXPLORE YOUR OPPORTUNITIES BELOW β¦</div>' | |
| SCORE_BANNER_HTML = '<div id="score-banner">β¦ SCORING COMPLETE β YOUR MATCH RESULTS ARE READY β¦</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() |