"""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" _TITLE_SENIORITY = [ (r"\b(intern|trainee|fresher|graduate|entry[- ]?level|jr|junior)\b", "junior"), (r"\b(associate)\b", "mid"), (r"\b(senior|sr|staff|manager|specialist)\b", "senior"), (r"\b(lead|principal|head|director|vp|chief|cxo|founding)\b", "lead"), ] def _seniority_from_title(title: str | None): """Infer seniority from a job title when the board omits it — so the experience factor + Opus brief aren't blind. Conservative: only clear signal words; ambiguous titles stay None.""" t = (title or "").lower() hit = None for pat, sen in _TITLE_SENIORITY: # last match wins → "Lead"/"Director" beats "Senior" if re.search(pat, t): hit = sen return hit 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, } # Indeed (valig actor) leaves location.city empty — only lat/long. Map to the nearest Indian metro so # the card + the location factor have a city (the board already filtered to the searched city anyway). _INDIA_METROS = { "Mumbai": (19.076, 72.877), "Delhi": (28.61, 77.21), "Bengaluru": (12.97, 77.59), "Hyderabad": (17.385, 78.486), "Chennai": (13.083, 80.27), "Kolkata": (22.57, 88.36), "Pune": (18.52, 73.856), "Ahmedabad": (23.03, 72.58), "Gurugram": (28.46, 77.03), "Noida": (28.535, 77.39), "Jaipur": (26.91, 75.79), "Chandigarh": (30.73, 76.78), "Kochi": (9.93, 76.27), "Indore": (22.72, 75.86), "Coimbatore": (11.02, 76.96), } def _city_from_latlon(lat, lon) -> str | None: try: lat, lon = float(lat), float(lon) except (TypeError, ValueError): return None best, bestd = None, 9e9 for name, (clat, clon) in _INDIA_METROS.items(): d = (lat - clat) ** 2 + (lon - clon) ** 2 if d < bestd: bestd, best = d, name return best if bestd < 1.5 else None # ~130km radius; farther → leave unknown, don't mislabel def indeed_to_scoutjob(it: dict, board: str = "Indeed") -> dict | None: # valig~indeed-jobs-scraper schema (7s vs misceres's 52s): title, employer.name, description.text, # location{city/lat/long}, jobUrl (offsite employer site), url (Indeed listing), key, baseSalary. if it.get("expired"): return None title = (it.get("title") or "").strip() org = ((it.get("employer") or {}).get("name") or "").strip() if not title or not org: return None loc = it.get("location") or {} city = (loc.get("city") or "").strip() or _city_from_latlon(loc.get("latitude"), loc.get("longitude")) country = loc.get("countryName") or "India" job_url = it.get("jobUrl") or "" offsite = bool(job_url and "indeed.com" not in job_url) # jobUrl = real offsite employer link apply_url = job_url if offsite else it.get("url") # offsite first, Indeed listing fallback bs = it.get("baseSalary") or {} lpa, pay = None, "Not disclosed" amt = bs.get("max") or bs.get("min") if amt and bs.get("currencyCode") in (None, "INR"): mult = {"YEAR": 1, "MONTH": 12, "WEEK": 52, "DAY": 260, "HOUR": 2080}.get(bs.get("unitOfWork"), 1) lpa = round(amt * mult / 100000, 1) pay = f"₹{lpa:g} LPA" etypes = [str(v) for v in (it.get("jobTypes") or {}).values()] occ = [str(v) for v in (it.get("occupations") or {}).values()] mode = "remote" if any("remote" in t.lower() for t in etypes) else None details = _compact({ "description": _plain((it.get("description") or {}).get("text")), "employment_type": ", ".join(etypes) or None, "role_category": occ[0] if occ else None, }) return { "id": f"indeed-{it.get('key')}", "title": title, "organization": org, "logo": _logo(org), "posted_date": (it.get("datePublished") or it.get("dateOnIndeed") or None), "location_city": city, "location_country": country, "location_mode": mode, "matchScore": 0, "salary_lpa": lpa, "payLabel": pay, "required_skills": [], # Indeed exposes no skill list; description carries the signal "tags": etypes[:3], "applicants": None, "note": f"Live from {board}", "apply_url": apply_url, "offsite_apply": offsite, "details": details, "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, } # LinkedIn seniorityLevel → our band (often "Not Applicable" for India → None, then title-inferred centrally). _LI_SENIORITY = {"internship": "junior", "entry level": "junior", "associate": "mid", "mid-senior level": "senior", "director": "lead", "executive": "lead"} def linkedin_cc_to_scoutjob(it: dict, board: str = "LinkedIn") -> dict | None: """curious_coder~linkedin-jobs-scraper — structured INDUSTRY + jobFunction (role) + applicantsCount. (seniorityLevel is present but often 'Not Applicable' for India; we map it when real, else infer.)""" title = (it.get("title") or "").strip() org = (it.get("companyName") or "").strip() if not title or not org: return None # location is "City, State, Country" — first part is the city, last is the country. parts = [p.strip() for p in str(it.get("location") or "").split(",") if p.strip()] city = parts[0] if parts else None country = parts[-1] if len(parts) >= 2 else "India" sen = _LI_SENIORITY.get(str(it.get("seniorityLevel") or "").strip().lower()) # None if "Not Applicable" inds = [s.strip() for s in str(it.get("industries") or "").split(",") if s.strip()] fn = (it.get("jobFunction") or "").strip() or None etype = (it.get("employmentType") or "").strip() mode = "remote" if "remote" in etype.lower() else None ext = it.get("applyUrl") offsite = bool(ext and "linkedin.com" not in ext) # applyUrl (when present) = offsite employer apply_url = ext if offsite else it.get("link") # else the LinkedIn job page details = _compact({ "description": _plain(it.get("descriptionText") or it.get("descriptionHtml")), "industry": inds[0] if inds else None, "role_category": fn, "employment_type": etype or None, }) return { "id": f"linkedin-{it.get('id')}", "title": title, "organization": org, "logo": _logo(org, it.get("companyLogo")), "posted_date": it.get("postedAt"), "location_city": city, "location_country": country, "location_mode": mode, "matchScore": 0, "salary_lpa": None, "payLabel": "Not disclosed", "seniority_level": sen, "required_skills": [], # LinkedIn exposes no skill tags; description + jobFunction carry it "industry": inds[0] if inds else None, "tags": [fn] if fn else [], "applicants": it.get("applicantsCount"), "note": f"Live from {board}", "apply_url": apply_url, "offsite_apply": offsite, "details": details, "qx": 10, } def timesjobs_to_scoutjob(it: dict, board: str = "TimesJobs") -> dict | None: """shahidirfan/Timesjobs-Scraper — the richest India board: skills + salary + experience + description + an OFFSITE employer link (external_job_url). Clean structured fields.""" title = (it.get("title") or "").strip() title = re.sub(r"^title:\s*", "", title, flags=re.I).strip() # some rows carry a stray "Title:" prefix org = (it.get("company") or "").strip() if not title or not org: return None if org.islower(): org = org.title() # location is "City" or "City, City2" — take the first concrete city. city = next((p.strip() for p in str(it.get("location") or "").split(",") if p.strip()), 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] # experience "6 - 10 Yrs" → seniority from the LOW end of the band. seniority = None m = re.search(r"\d+", str(it.get("experience") or "")) if m: seniority = _seniority_from_years(int(m.group())) # salary is usually the text "As per industry standard"; surface a ₹L band only if a real number is there. sal_raw = str(it.get("salary") or "") lpa, pay = (None, sal_raw or "Not disclosed") if re.search(r"\d", sal_raw): lpa, pay = _salary_from_amount(_digits(sal_raw), per_year=True) types = [t.strip() for t in str(it.get("job_type") or "").split(",") if t.strip()] mode = "remote" if any("remote" in t.lower() for t in types) else None ext = it.get("external_job_url") offsite = bool(ext and "timesjobs.com" not in (ext or "")) # external_job_url = real employer site apply_url = ext if offsite else it.get("url") details = _compact({ "description": _plain(it.get("description_text") or it.get("description_html")), "experience": it.get("experience"), "employment_type": ", ".join(types) or None, "company_about": _plain(it.get("company_description"), limit=400), "skills": skills, }) return { "id": f"timesjobs-{it.get('job_id')}", "title": title, "organization": org, "logo": _logo(org), "posted_date": it.get("date_posted"), "location_city": city, "location_country": "India", "location_mode": mode, "matchScore": 0, "salary_lpa": lpa, "payLabel": pay, "seniority_level": seniority, "required_skills": skills[:8], "tags": skills[:3], "applicants": None, "note": f"Live from {board}", "apply_url": apply_url, "offsite_apply": offsite, "details": details, "qx": 10, } def workindia_to_scoutjob(it: dict, board: str = "WorkIndia") -> dict | None: """shahidirfan/workindia-jobs-scraper (includeDetails=True) — India blue/grey-collar non-tech. Verified live (Phase 0): returns real `skills` + `profile_job_description`. Apply is on-platform.""" title = (it.get("profile_job_title") or "").strip() org = (it.get("branch_company_name") or "").strip() if not title or not org: return None city = (str(it.get("branch_location_city_name") or "").strip() or 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("job_experience") or it.get("experience") or "")) if m: seniority = _seniority_from_years(int(m.group())) # Blue/grey-collar pay is usually monthly ₹ and noisy — show the structure text, don't risk the ₹L band. details = _compact({ "description": _plain(it.get("profile_job_description") or it.get("profile_html_description") or it.get("profile_short_description")), "experience": it.get("job_experience") or it.get("experience"), "employment_type": it.get("employment_type"), "education": it.get("degree") or it.get("profile_qualification_required"), "industry": it.get("profile_industry_display_name"), "skills": skills, }) return { "id": f"workindia-{it.get('job_id') or it.get('id')}", "title": title.title() if title.isupper() else title, "organization": org, "logo": _logo(org, _logo_url(it)), "posted_date": it.get("job_published_on_platform") or it.get("created_at"), "location_city": city, "location_country": "India", "matchScore": 0, "salary_lpa": None, "payLabel": it.get("profile_salary_structure") or "Not disclosed", "seniority_level": seniority, "required_skills": skills[:6], "tags": skills[:3], "applicants": None, "note": f"Live from {board}", # source_url is a GENERIC listing page (same for every job) → would dedup-collapse the deck. # Build a per-job URL from job_id so each is unique AND clickable. "apply_url": (f"https://www.workindia.in/jobs/{it.get('job_id')}/" if it.get("job_id") else it.get("source_url")), "offsite_apply": False, # WorkIndia is a platform — apply on WorkIndia "details": details, "qx": 10, } def wellfound_to_scoutjob(it: dict, board: str = "Wellfound") -> dict | None: """blackfalcondata/wellfound-scraper (enrichDetail=True) — startup roles with skills, equity, funding stage. Apply is on-platform (Wellfound), so apply_url = the listing (offsite_apply=False).""" title = (it.get("title") or "").strip() org = (it.get("companyName") or "").strip() if not title or not org: return None locs = it.get("locationNames") or [] city = (str(locs[0]).split(",")[0].strip() or None) if isinstance(locs, list) and locs else None skills = [str(s) for s in (it.get("skills") or []) if s] # Salary → LPA only when the listing is in INR (don't pollute the India band with USD); else the # raw compensation string is the honest label. lpa, pay = None, "Not disclosed" cur = (it.get("salaryCurrency") or "").upper() if it.get("salaryMax") and cur == "INR": lpa, pay = _salary_from_amount(int(it["salaryMax"]), per_year=True) elif it.get("compensation"): pay = str(it["compensation"]) elif it.get("salaryMin") or it.get("salaryMax"): pay = f"{cur or '$'} {it.get('salaryMin') or ''}–{it.get('salaryMax') or ''}".strip() details = _compact({ "description": _plain(it.get("description")), "employment_type": (it.get("jobType") or "").replace("_", " ").title() or None, "skills": skills, "funding_stage": it.get("companyFundingStage"), "equity": it.get("equityMin") or it.get("equity"), "visa": it.get("visaSponsorship"), }) return { "id": f"wellfound-{it.get('id')}", "title": title, "organization": org, "logo": _logo(org, _logo_url(it)), "posted_date": _posted_date(it), "location_city": city, "location_country": None, "location_mode": "remote" if it.get("remote") else None, "matchScore": 0, "salary_lpa": lpa, "payLabel": pay, "required_skills": skills[:6], "tags": skills[:3], "applicants": None, "note": f"Live from {board}", "apply_url": it.get("portalUrl"), "offsite_apply": False, # apply happens on Wellfound (on-platform), not an offsite employer redirect "details": details, "qx": 10, } # board key → normalizer NORMALIZERS = { "naukri": naukri_feed_to_scoutjob, # blackfalcondata (primary) "foundit": foundit_to_scoutjob, "linkedin": linkedin_cc_to_scoutjob, # curious_coder (structured industry+jobFunction+applicants) "linkedin_v1": linkedin_to_scoutjob, # harvestapi (fallback) "naukri_v1": naukri_to_scoutjob, # muhammetakkurtt (fallback) "indeed": indeed_to_scoutjob, "ats": ats_to_scoutjob, "wellfound": wellfound_to_scoutjob, # blackfalcondata (startup roles) "workindia": workindia_to_scoutjob, # shahidirfan (India blue/grey-collar non-tech) "timesjobs": timesjobs_to_scoutjob, # shahidirfan (India — full signal: skills+salary+experience) }