"""Per-board normalizers: raw Apify item → the frontend `ScoutJob` shape. Slice-1a: no engine, so matchScore/note/qx are placeholders (matchScore is assigned in search.py after merge). Each board's output differs — one mapper per board. """ from __future__ import annotations import html as _html import json import re _PALETTE = ["#4F46E5", "#EA580C", "#0F6E56", "#DB2777", "#0EA5E9", "#7C3AED", "#D97706", "#059669"] def _plain(s, limit: int = 1600) -> str: """HTML/Markdown → readable plain text for the detail modal (keeps paragraph breaks). We surface ONLY what the board/employer provided — and strip the scraper's own aggregator disclaimer boilerplate (we add nothing of our own). """ if not s: return "" # HTML → text s = re.sub(r"<\s*(br|/p|/li|/div|/h\d|/tr)\s*[^>]*>", "\n", str(s), flags=re.I) # breaks → newlines s = re.sub(r"]*>", "• ", s, flags=re.I) s = re.sub(r"<[^>]+>", "", s) # drop remaining tags s = _html.unescape(s) # Markdown leftovers (when the source is markdown, not HTML) s = re.sub(r"(?m)^\s{0,3}#{1,6}\s*", "", s) # ATX headers → plain line s = re.sub(r"(?m)^[ \t]*[-*+][ \t]+", "• ", s) # list bullets → • s = re.sub(r"\*\*([^*]+)\*\*", r"\1", s) # **bold** → text s = re.sub(r"`([^`]+)`", r"\1", s) # `code` → text # Broken-tag remnants (e.g. a leaked "> from malformed HTML) s = re.sub(r'"\s*>', " ", s) s = s.lstrip(' "\'>') # Drop the scraper's appended disclaimer boilerplate (NOT employer content) s = re.split(r"\n?\s*Disclaimer\s*[:\-]", s, maxsplit=1, flags=re.I)[0] # whitespace s = re.sub(r"[ \t]+", " ", s) s = re.sub(r"\n{3,}", "\n\n", s).strip() return (s[:limit].rstrip() + "…") if len(s) > limit else s def _compact(d: dict) -> dict: """Drop empty values so the details payload stays lean.""" return {k: v for k, v in d.items() if v not in (None, "", [], {})} def _logo(org: str, url: str | None = None) -> dict: org = (org or "").strip() label = org[0].upper() if org else "?" bg = _PALETTE[sum(map(ord, org)) % len(_PALETTE)] if org else "#9CA3AF" d = {"label": label, "bg": bg} # Real company logo from the actor (used by the UI; the label/bg stay as the fallback). if url and isinstance(url, str) and url.startswith("http"): d["url"] = url return d # actor field names for the company logo, across boards + nesting (Naukri=logoPath, # LinkedIn=company.logo, Indeed=companyLogo, …). First http(s) value wins; None → letter badge. _LOGO_KEYS = ("logoPath", "logoPathV3", "logo", "companyLogo", "company_logo", "logoUrl", "image", "companyImage") def _logo_url(it: dict) -> str | None: scopes = [it, it.get("company") or {}, it.get("jobDetails") or {}, (it.get("jobDetails") or {}).get("companyDetail") or {}] for sc in scopes: if not isinstance(sc, dict): continue for k in _LOGO_KEYS: v = sc.get(k) if isinstance(v, str) and v.startswith("http"): return v return None # posting-date field across boards (Naukri createdDate · LinkedIn postedDate · Foundit date_posted). # Feeds the dashboard "Apply window" (freshness urgency) + honest "posted Nd ago" age signals. _POSTED_KEYS = ("postedDate", "createdDate", "date_posted", "postedAt", "listedAt", "datePosted", "published") def _posted_date(it: dict) -> str | None: scopes = [it, it.get("jobDetails") or {}] for sc in scopes: if not isinstance(sc, dict): continue for k in _POSTED_KEYS: v = sc.get(k) if isinstance(v, str) and v[:4].isdigit(): # ISO-ish "2026-06-18T…" return v return None def _digits(s: str) -> int | None: nums = re.sub(r"[^\d]", "", s or "") return int(nums) if nums else None def _salary_from_amount(amount: int | None, per_year: bool) -> tuple[float | None, str]: """amount in ₹ → (lpa, payLabel).""" if not amount: return None, "Not disclosed" annual = amount if per_year else amount * 12 lpa = round(annual / 100_000, 1) return lpa, f"₹{lpa:g}L/yr" def _seniority_from_years(min_yrs: int | None): if min_yrs is None: return None return ("junior" if min_yrs <= 1 else "junior" if min_yrs <= 4 else "mid" if min_yrs <= 8 else "senior" if min_yrs <= 12 else "lead") # free-text seniority (LinkedIn experienceLevel / ATS seniority) → our Seniority enum _SENIORITY_TEXT = { "internship": "junior", "entry level": "junior", "entry": "junior", "junior": "junior", "associate": "mid", "mid": "mid", "mid-senior level": "senior", "senior": "senior", "director": "lead", "executive": "lead", "lead": "lead", } def _seniority_from_text(s: str | None): if not s: return None return _SENIORITY_TEXT.get(s.strip().lower()) _WORKMODE = {"remote": "remote", "hybrid": "hybrid", "on_site": "onsite", "onsite": "onsite"} def naukri_to_scoutjob(it: dict, board: str = "Naukri") -> dict | None: # The naukri-job-scraper (fetchDetails=True) nests the real fields under `jobDetails`. jd = it.get("jobDetails") or {} title = (jd.get("title") or "").strip() org = ((jd.get("companyDetail") or {}).get("name") or "").strip() if not title or not org: return None locs = jd.get("locations") or [] city = (locs[0].get("label") if locs and isinstance(locs[0], dict) else None) or None # keySkills is {other:[{label}], preferred:[{label}], ...} — flatten the labels. skills: list[str] = [] ks = jd.get("keySkills") or {} if isinstance(ks, dict): for bucket in ks.values(): for s in bucket or []: lbl = (s.get("label") if isinstance(s, dict) else str(s)).strip() if lbl and lbl not in skills: skills.append(lbl) # salaryDetail is already a dict here (not a JSON string). lpa, pay = None, "Not disclosed" sd = jd.get("salaryDetail") or {} if isinstance(sd, dict) and not sd.get("hideSalary") and sd.get("maximumSalary"): lpa, pay = _salary_from_amount(int(sd["maximumSalary"]), per_year=True) # Offsite employer/ATS apply link first; the Naukri listing (staticUrl) is the fallback. redirect = ( jd.get("applyRedirectUrl") or jd.get("companyApplyUrl") or it.get("applyRedirectUrl") or it.get("companyApplyUrl") ) return { "id": f"naukri-{jd.get('jobId')}", "title": title, "organization": org, "logo": _logo(org, _logo_url(it)), "posted_date": _posted_date(it), "location_city": city, "location_country": "India", "matchScore": 0, # placeholder — set after merge "salary_lpa": lpa, "payLabel": pay, "seniority_level": _seniority_from_years(_digits(str(jd.get("minimumExperience", "")))), "required_skills": skills[:6], "tags": skills[:3], "applicants": jd.get("applyCount"), "note": f"Live from {board}", "apply_url": redirect or jd.get("staticUrl"), # offsite employer link first, Naukri listing fallback "offsite_apply": bool(redirect), "qx": 10, } def indeed_to_scoutjob(it: dict, board: str = "Indeed") -> dict | None: if str(it.get("isExpired", "False")).lower() == "true": return None title = (it.get("positionName") or "").strip() org = (it.get("company") or "").strip() if not title or not org: return None # Indeed location is "Area, City, State" (or "City, State") — the city is the 2nd-to-last part. parts = [p.strip() for p in (it.get("location") or "").split(",") if p.strip()] city = (parts[-2] if len(parts) >= 2 else (parts[0] if parts else None)) types = [] try: types = [t.strip() for t in eval(it.get("jobType") or "[]")] if it.get("jobType") else [] except Exception: # noqa: BLE001 pass sal = it.get("salary") or "" lpa, pay = _salary_from_amount(_digits(sal), per_year="year" in sal.lower()) if sal else (None, "Not disclosed") ext = it.get("externalApplyLink") offsite = bool(ext and ext != "None") # externalApplyLink = real offsite employer link apply_url = ext if offsite else it.get("url") # offsite first, Indeed listing fallback return { "id": f"indeed-{it.get('id')}", "title": title, "organization": org, "logo": _logo(org, _logo_url(it)), "posted_date": _posted_date(it), "location_city": city, "location_country": "India", "matchScore": 0, "salary_lpa": lpa, "payLabel": pay, "required_skills": types, "tags": types[:3], "applicants": None, "note": f"Live from {board}", "apply_url": apply_url, "offsite_apply": offsite, "qx": 10, } def linkedin_to_scoutjob(it: dict, board: str = "LinkedIn") -> dict | None: title = (it.get("title") or "").strip() org = ((it.get("company") or {}).get("name") or "").strip() if not title or not org: return None loc = it.get("location") or {} parsed = loc.get("parsed") or {} if isinstance(loc, dict) else {} city = parsed.get("city") or None country = parsed.get("country") or parsed.get("countryFull") or None mode = _WORKMODE.get((it.get("workplaceType") or "").lower()) industries = [s for s in (it.get("industries") or []) if isinstance(s, str)] sal = it.get("salary") or {} pay = sal.get("text") if isinstance(sal, dict) and sal.get("text") else "Not disclosed" co = it.get("company") or {} details = _compact({ "description": _plain(it.get("descriptionText") or it.get("descriptionHtml")), "experience": it.get("experienceLevel"), "employment_type": (it.get("employmentType") or "").replace("_", " ").title() or None, "industry": ", ".join(industries[:3]) or None, "skills": industries, "company_about": _plain(co.get("description"), 700) if isinstance(co, dict) else "", }) # Prefer the OFFSITE employer/ATS apply link over the LinkedIn listing — the # board URL can go stale, the employer's own apply page is the durable target. _apply_method = it.get("applyMethod") or {} offsite = ( it.get("companyApplyUrl") or (_apply_method.get("companyApplyUrl") if isinstance(_apply_method, dict) else None) or it.get("applyRedirectUrl") ) return { "id": f"linkedin-{it.get('id')}", "title": title, "organization": org, "logo": _logo(org, _logo_url(it)), "posted_date": _posted_date(it), "location_city": city, "location_country": country, "location_mode": mode, "matchScore": 0, "salary_lpa": None, "payLabel": pay, "seniority_level": _seniority_from_text(it.get("experienceLevel")), "required_skills": industries[:6], "tags": industries[:3], "applicants": it.get("applicants"), "note": f"Live from {board}", "apply_url": offsite or it.get("linkedinUrl"), # offsite employer link first, board link fallback "offsite_apply": bool(offsite), "details": details, "qx": 10, } def ats_to_scoutjob(it: dict, board: str = "Direct") -> dict | None: title = (it.get("title") or "").strip() org = (it.get("company") or "").strip() if not title or not org: return None org = org.replace("-", " ").replace("_", " ").title() # ats slugs ("stripe") → "Stripe" parts = [p.strip() for p in (it.get("location") or "").split(",") if p.strip()] city = parts[0] if parts else None mode = _WORKMODE.get((it.get("remote_type") or "").lower()) or ("remote" if it.get("remote") else None) dept = [d for d in [it.get("department"), it.get("team")] if d] return { "id": f"ats-{it.get('global_id') or it.get('job_id')}", "title": title, "organization": org, "logo": _logo(org, _logo_url(it)), "posted_date": _posted_date(it), "location_city": city, "location_country": None, "location_mode": mode, "matchScore": 0, "salary_lpa": None, "payLabel": "Not disclosed", "seniority_level": _seniority_from_text(it.get("seniority")), "required_skills": dept[:6], "tags": dept[:3], "applicants": None, "note": f"Live from {board}", # direct employer posting → real offsite apply link "apply_url": it.get("apply_url") or it.get("url"), "offsite_apply": bool(it.get("apply_url") or it.get("url")), # ATS direct posting = employer's own apply page "qx": 10, } def naukri_feed_to_scoutjob(it: dict, board: str = "Naukri") -> dict | None: """blackfalcondata/naukri-jobs-feed (fetchDetails=True) — flat shape with a real offsite `applyRedirectUrl` when the employer uses an external ATS.""" title = (it.get("title") or "").strip() org = (it.get("companyName") or it.get("staticCompanyName") or "").strip() if not title or not org: return None loc = it.get("location") or "" city = (loc.split("/")[0].split(",")[0].strip() or None) if isinstance(loc, str) else None # keySkills = {preferred:[{label}], other:[{label}]} — flatten labels. skills: list[str] = [] ks = it.get("keySkills") or {} if isinstance(ks, dict): for bucket in ks.values(): for s in bucket or []: lbl = (s.get("label") if isinstance(s, dict) else str(s)).strip() if lbl and lbl not in skills: skills.append(lbl) elif isinstance(ks, list): skills = [str(s) for s in ks if s] lpa, pay = None, "Not disclosed" if not it.get("salaryHidden") and it.get("salaryMax"): lpa, pay = _salary_from_amount(int(it["salaryMax"]), per_year=True) redirect = it.get("applyRedirectUrl") education = "; ".join(it.get("educationUG") or []) or it.get("degreeCombination") or None details = _compact({ "description": _plain(it.get("descriptionMarkdown") or it.get("description") or it.get("shortDescription")), "experience": it.get("experienceText"), "employment_type": it.get("employmentType"), "education": education, "industry": it.get("industry"), "role_category": it.get("roleCategory"), "skills": skills, "company_about": _plain(it.get("companyDescription"), 700), "vacancies": it.get("vacancy"), }) return { "id": f"naukri-{it.get('jobId')}", "title": title.title() if title.isupper() else title, "organization": org, "logo": _logo(org, _logo_url(it)), "posted_date": _posted_date(it), "location_city": city, "location_country": "India", "location_mode": _WORKMODE.get((it.get("wfhType") or "").lower()), "matchScore": 0, "salary_lpa": lpa, "payLabel": pay, "seniority_level": _seniority_from_years(it.get("minimumExperience")), "required_skills": skills[:6], "tags": skills[:3], "applicants": it.get("applyCount"), "note": f"Live from {board}", "apply_url": redirect or it.get("staticUrl") or it.get("portalUrl"), "offsite_apply": bool(redirect), # True = real 3rd-party employer/ATS link (auto-applyable) "details": details, "qx": 10, } def foundit_to_scoutjob(it: dict, board: str = "Foundit") -> dict | None: title = (it.get("title") or "").strip() org = (it.get("company") or "").strip() if not title or not org: return None loc = it.get("location") or "" city = (loc.split("/")[0].split(",")[0].strip() or None) if isinstance(loc, str) else None skills = it.get("skills") or [] if isinstance(skills, str): skills = [s.strip() for s in skills.split(",") if s.strip()] skills = [str(s) for s in skills if s] seniority = None m = re.search(r"\d+", str(it.get("experience") or "")) if m: seniority = _seniority_from_years(int(m.group())) redirect = it.get("apply_url") details = _compact({ "description": _plain(it.get("description_text") or it.get("description_html")), "experience": it.get("experience"), "employment_type": it.get("employment_type"), "industry": it.get("industry"), "role_category": it.get("function"), "skills": skills, }) return { "id": f"foundit-{it.get('job_id')}", "title": title, "organization": org, "logo": _logo(org, _logo_url(it)), "posted_date": _posted_date(it), "location_city": city, "location_country": "India", "matchScore": 0, "salary_lpa": None, "payLabel": "Not disclosed", "seniority_level": seniority, "required_skills": skills[:6], "tags": [it.get("function")][:1] if it.get("function") else skills[:3], "applicants": None, "note": f"Live from {board}", "apply_url": redirect or it.get("url"), "offsite_apply": bool(redirect), "details": details, "qx": 10, } # board key → normalizer NORMALIZERS = { "naukri": naukri_feed_to_scoutjob, # blackfalcondata (primary) "foundit": foundit_to_scoutjob, "linkedin": linkedin_to_scoutjob, "naukri_v1": naukri_to_scoutjob, # muhammetakkurtt (fallback) "indeed": indeed_to_scoutjob, "ats": ats_to_scoutjob, }