Files
matchmaking-v2/app/engine/normalize.py
raulgupta 04e6fb64bb Engine: regression harness (Phase 0) + India non-tech actors (Phase 1)
Phase 0 — measurement gate:
- tests/test_contracts.py: BOARDS↔NORMALIZERS alignment, funnel-shape guards, overlap() None
  contract, and the frontend↔backend EXACT-STRING vocab check (scout.ts options must resolve in
  coerce.py, else silent keyword-fold).
- tests/run_regression.py + fixtures/regression_set.json (+ _seed_regression.py): a frozen 120-pair
  set (tech + non-tech) Opus-judged for recruiter-fit targets. Two metrics: score MAE and SIFT TOP-K
  MEMBERSHIP RECALL (do the best jobs reach Opus?). Baseline: MAE 24.7, recall 0.68 — i.e. ~32% of
  the genuinely-best jobs are cut before the curator ever sees them (worse for non-tech).

Phase 1 — coverage (India non-tech):
- Enable Indeed (misceres) + enrich indeed_to_scoutjob with details.description + location_mode
  (jobType is employment type, not skills → required_skills now []).
- Add WorkIndia (shahidirfan) — India blue/grey-collar non-tech: build_workindia_input +
  workindia_to_scoutjob (real skills + description; per-job apply URL from job_id since source_url
  is generic and would dedup-collapse the deck).
- Drop Wellfound from the stack (US-startup-heavy + 400s); kept registered-but-off.
- BOARDS_ENABLED = naukri,foundit,linkedin,indeed,workindia. Pool ~90 → ~140 jobs/search.
Regression scores unchanged (engine scoring untouched). 31 tests pass.
2026-06-25 15:44:28 +05:30

541 lines
23 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"
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
mode = "remote" if any("remote" in t.lower() for t in types) else None
# jobType is employment classification ("Full-time", "Remote") — NOT skills. The real signal for
# embedding + the curator is the description, which the actor DOES return (verified Phase 0).
details = _compact({
"description": _plain(it.get("description") or it.get("descriptionHTML")),
"employment_type": ", ".join(types) or None,
})
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",
"location_mode": mode,
"matchScore": 0,
"salary_lpa": lpa,
"payLabel": pay,
"required_skills": [], # Indeed exposes no skill list; description carries the signal
"tags": types[: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)
}