Files
matchmaking-v2/app/engine/normalize.py
raulgupta be921559e7 Real Scout dashboard backend + per-board cursor rework
- get_scout_stats aggregator (app/engine/stats): assembles the REAL Summary metrics —
  funnel + cohort engagement rank + active-window (activity timestamps by hour) from our DB,
  accumulating salary band (avg of each deck's peak, ₹L), match/competition stats from the
  feed, Momentum/QX + Q-Score trend (qscore-service), day streak (user-service). Honest:
  unsourced cards return None so the UI omits/locks them, never faked. posted_date extractor.
- Activity tracking: viewed/saved flags + search_count → funnel (Matches→Viewed→Shortlisted→
  Applied) + engagement percentile. matchesFound = all-time count.
- Per-board search cursors {board: page} (replaces the single cursor): only boards that truly
  paginate (LinkedIn) get one; cursor = LAST page fetched (1st search of a new query → 1).
  Resets on query change OR >24h (boards refresh ~daily). Dropped Naukri incremental/stateKey
  (opaque, exhausting, cross-account dedup state) — dedup is the PER-USER seen-net only.
- tests: stats helpers (posting-age, histogram, active-window, engagement score).
2026-06-21 12:12:28 +05:30

427 lines
17 KiB
Python

"""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
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,
}