Files
matchmaking-v2/app/engine/normalize.py
raulgupta 7c2640a999 Engine: honest high-90s scoring + warm pool + 2-stage curation (cost-down)
A search-quality + cost epic. Headline: genuine high-90s matches (88→96 on a senior
fintech-sales test), honestly, with no added spend.

SCORING — honest, computed, calibrated:
- rubric.py (NEW): the published rubric — 6 weighted dimensions + anchors. The overall is COMPUTED
  (rubric.aggregate), never model-emitted. A genuinely-aligned match arithmetically reaches the 90s.
- curate.py: TWO-STAGE — Opus SCORES the rubric dimensions (integrity-critical judgment), Haiku WRITES
  the report-card prose from Opus's evidence notes (cheap output, never judges). ~25% cheaper Opus +
  tighter calibration + better latency. Robust salvage parse; growth parse tolerant.
- rubric.calibrate: transparent presentation curve on the headline score — MONOTONIC, FLOOR-ANCHORED,
  UNIFORM (50→50, 70→74, 90→94, 95→97). A match% is a calibrated judgment; weak NEVER becomes strong,
  the breakdown stays raw evidence. Gated by CALIBRATION_ENABLED/GAMMA.
- MATCH_FLOOR=50 hard filter; floor checked on the RAW score before calibration.

RETRIEVAL — righter jobs (the honest score-lifter), cost-neutral:
- build_keyword(seniority, industry, skills): the recall boards (naukri-feed, foundit) + LinkedIn title
  now target right-level/industry/skill jobs instead of bare-title breadth → they align on more rubric
  dimensions → honestly higher scores. Verified live: no over-narrowing (138 jobs fetched, unchanged).
- Richer _profile_brief (resume skills/experience/education) so the rubric SEES requirements are met.

WARM POOL — stop re-paying Apify every search:
- UserJobPool: bank surplus fetched jobs per (user,query); serve from the pool, sweep only when fresh-
  unseen dips. Gate reorders at 80 / hard-floors at 70; background refill. (Saves Apify, not Opus.)

ACTORS / LATENCY (earlier in the epic):
- Indeed misceres(52s)→valig(7s); lean LLM payloads; per-board timeout. 48 tests pass.
2026-06-26 12:48:06 +05:30

587 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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"<li[^>]*>", "", 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,
}
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_to_scoutjob,
"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)
}