Files
matchmaking-v2/app/engine/board_adapters/adapters.py
raulgupta 75b2e538ef Manual-apply backend: per-opportunity state, unique-decks engine, offsite-first + logos
- OpportunityState model + repo: persist applied / dismissed / restored status and the
  generated resume-builder doc ids (never re-pay to re-tailor). annotate_and_filter drops
  dismissed + annotates applied/docs on read; handlers for dismiss/restore/mark_applied/
  save_apply_docs.
- Unique decks per run (spend only for NEW jobs, no cache, no re-fetch+dedup band-aid):
  per-(user,query) search cursor → LinkedIn page++ and Naukri incremental+stateKey (async
  run path in apify_client, since the actor's crawl exceeds the run-sync window); seen-net
  excludes already-shown ids (covers Foundit, which can't paginate).
- normalize: every board offsite-first (apply_url prefers the employer/ATS redirect over the
  board listing) + offsite_apply flag; real company logos (_logo_url across Naukri logoPath /
  LinkedIn company.logo / etc.).
2026-06-20 17:52:06 +05:30

233 lines
11 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 query builders — `ScoutPrefs` (dict) → each Apify actor's input.
See `docs/ENGINE_DESIGN.md` §1.2. Returns the `input` dict you POST to the actor's
`run-sync-get-dataset-items` endpoint.
System knobs (budget / freshness) are arguments with sane defaults, not user inputs.
"""
from __future__ import annotations
from typing import Any
from . import coerce as C
Prefs = dict[str, Any]
# ── Naukri (India workhorse — richest structured filters) ────────────────────
def build_naukri_input(prefs: Prefs, *, max_jobs: int = 80, freshness: str = "7", recall: bool = False) -> dict:
# recall=True (retrieve stage): keep keyword + city + work-mode only; drop the
# narrowing filters (experience/department/industry/salary/stage) — those become
# ENGINE ranking later, not hard board cuts. Wider freshness for recall.
if recall:
freshness = "30"
locs = C.parsed_locations(prefs)
cities: list[int] = []
for lp in locs:
if lp["city"]:
cities += C.CITY_NAUKRI.get(lp["city"], []) # unmapped city → dropped (kept in keyword)
out: dict[str, Any] = {
"keyword": C.title_of(prefs) or C.build_keyword(prefs),
"maxJobs": max_jobs, # REQUIRED — the budget
"freshness": freshness, # liveness gate
"sortBy": "date",
"fetchDetails": True, # → apply redirect / full details
}
if cities:
out["cities"] = [str(c) for c in sorted(set(cities))] # Naukri select codes are STRINGS
wm = [C.WORKMODE_NAUKRI[w] for w in (prefs.get("workMode") or []) if w in C.WORKMODE_NAUKRI]
if C.is_remote(prefs) and "remote" not in wm:
wm.append("remote")
if wm:
out["workMode"] = wm
if recall:
return out # retrieve stage stops here — keyword + cities + work-mode only
yrs = C.years_of(prefs)
if yrs is not None:
out["experience"] = str(yrs) # numeric-string enum "0".."30"
depts = [C.ROLE_NAUKRI_DEPT[r] for r in (prefs.get("role") or []) if r in C.ROLE_NAUKRI_DEPT]
if depts:
out["department"] = [str(d) for d in sorted(set(depts))] # codes → strings
inds = [C.INDUSTRY_NAUKRI[i] for i in (prefs.get("industry") or []) if i in C.INDUSTRY_NAUKRI]
if inds:
out["industry"] = [str(i) for i in sorted(set(inds))] # codes → strings
if prefs.get("targetComp") in C.COMP_NAUKRI_SALARY:
out["salaryRange"] = [C.COMP_NAUKRI_SALARY[prefs["targetComp"]]]
if prefs.get("companyStage") in C.COMPANY_TYPE_NAUKRI:
out["companyType"] = [C.COMPANY_TYPE_NAUKRI[prefs["companyStage"]]]
return out
# ── Naukri (blackfalcondata feed — city-precise, real offsite applyRedirectUrl) ──
def build_naukri_feed_input(prefs: Prefs, *, max_jobs: int = 50, fetch_details: bool = True,
state_key: str | None = None) -> dict:
# City-filtered at the board (hyper-relevant + cheap). fetch_details=True surfaces the
# offsite `applyRedirectUrl`; skipReposts dedups for cost. Narrowing (dept/industry/exp)
# stays off here — that's engine ranking later.
city = next((lp["city"] for lp in C.parsed_locations(prefs) if lp["city"]), "")
out: dict[str, Any] = {
"keyword": C.title_of(prefs) or C.build_keyword(prefs),
"location": city, # precise city, e.g. "New Delhi"
"maxResults": max_jobs,
"fetchDetails": fetch_details,
"skipReposts": True,
"sortBy": "date",
}
# Incremental mode: the actor persists what it has emitted under `stateKey` (a named state store)
# and returns only NEW / changed jobs on each run — so re-running the same search costs us only
# genuinely new postings, with ZERO re-fetch. Requires an ASYNC run (apify_client routes it there).
if state_key:
out["incremental"] = True
out["stateKey"] = state_key
return out
# ── Foundit / Monster India (city-precise; apply_url is the offsite redirect) ──
# Foundit uses legacy city names — it returns 0 for "New Delhi"/"Bengaluru".
_FOUNDIT_CITY = {
"new delhi": "Delhi", "delhi ncr": "Delhi", "bengaluru": "Bangalore",
"gurugram": "Gurgaon", "mumbai": "Mumbai", "pune": "Pune", "hyderabad": "Hyderabad",
"chennai": "Chennai", "kolkata": "Kolkata", "noida": "Noida",
}
def build_foundit_input(prefs: Prefs, *, results_wanted: int = 50) -> dict:
city = next((lp["city"] for lp in C.parsed_locations(prefs) if lp["city"]), "")
city = _FOUNDIT_CITY.get(city.lower(), city) # alias to Foundit's canonical name
return {
"keyword": C.title_of(prefs) or C.build_keyword(prefs),
"location": city,
"results_wanted": results_wanted,
}
# ── LinkedIn (takes the band, not years; ₹ comp incompatible) ────────────────
def build_linkedin_input(prefs: Prefs, *, max_items: int = 80, posted: str = "week",
recall: bool = False, page: int = 1) -> dict:
# recall=True (retrieve stage): title + COUNTRY-level location only — drop the narrowing
# filters (easyApply / workplaceType / experienceLevel / industryIds / freshness), which
# become ENGINE ranking later. City-level location + a wrong industryId zero the actor out.
out: dict[str, Any] = {
"jobTitles": [C.title_of(prefs)],
"maxItems": max_items,
"sortBy": "date",
"page": page, # Start Page Number — advance per re-run for NEW jobs
}
cities: list[str] = [] # bare city names ("New Delhi") — harvestapi's location format
cities_q: list[str] = [] # city, country ("New Delhi, India") — used outside recall
countries: list[str] = []
for lp in C.parsed_locations(prefs):
if lp["country"] and lp["country"] not in countries:
countries.append(lp["country"])
if lp["city"]:
cities.append(lp["city"])
cities_q.append(f"{lp['city']}, {lp['country']}" if lp["country"] else lp["city"])
if recall:
# City-level for precision (engine ranks finer later). Fall back to country if no city.
out["locations"] = cities or countries
return out
out["postedLimit"] = posted # freshness
out["easyApply"] = False # favour offsite (auto-applyable) over login-gated easy-apply
if cities_q or countries:
out["locations"] = cities_q or countries
# TODO: geoIds = [lookup(loc)] is more reliable than free-text locations.
wm = [C.WORKMODE_LINKEDIN[w] for w in (prefs.get("workMode") or []) if w in C.WORKMODE_LINKEDIN]
if C.is_remote(prefs) and "remote" not in wm:
wm.append("remote")
if wm:
out["workplaceType"] = wm
band = C.seniority_band(prefs)
if band in C.SENIORITY_LINKEDIN:
out["experienceLevel"] = [C.SENIORITY_LINKEDIN[band]]
ind_ids = [C.INDUSTRY_LINKEDIN_ID[i] for i in (prefs.get("industry") or []) if i in C.INDUSTRY_LINKEDIN_ID]
if ind_ids:
out["industryIds"] = ind_ids # TODO verify IDs; unmapped industries already fall to keyword
return out
# ── Indeed (blunt: what + where + country only; everything else folds in) ────
def build_indeed_input(prefs: Prefs, *, max_items: int = 80) -> dict:
loc = next((l for l in C.parsed_locations(prefs) if l["city"] or l["country"]), {})
country_name = loc.get("country") or "India"
return {
# Indeed has no work-mode/seniority/industry params → fold them into the query.
"position": C.build_keyword(prefs, seniority=True, industry=True),
"location": loc.get("city") or "",
"country": C.COUNTRY_ISO2.get(country_name, "IN"),
"maxItemsPerSearch": max_items,
"followApplyRedirects": True, # → real offsite apply URL
"saveOnlyUniqueItems": True,
}
def build_indeed_searches(prefs: Prefs, *, max_items: int = 80) -> list[dict]:
"""Fan-out: Indeed `position`/`location` are single strings, so multi title×city
becomes N runs to merge+dedup downstream."""
titles = (prefs.get("targetTitles") or [])[:3] or [C.title_of(prefs)]
cities = [l["city"] for l in C.parsed_locations(prefs) if l["city"]] or [None]
base = build_indeed_input(prefs, max_items=max_items)
runs = []
for t in titles:
for city in cities:
r = dict(base)
r["position"] = " ".join(x for x in [t, *(["remote"] if C.is_remote(prefs) else [])] if x)
if city:
r["location"] = city
runs.append(r)
return runs
# ── Direct-ATS (curated-company lane — `companies` is passed in, NOT from prefs) ──
def build_ats_input(prefs: Prefs, *, companies: list[dict], recent_days: int = 30,
max_per_company: int = 50, output_profile: str = "compact",
recall: bool = False) -> dict:
loc = next((l for l in C.parsed_locations(prefs) if l["city"]), {})
inds = [i for i in (prefs.get("industry") or []) if i and i != "Any"]
out: dict[str, Any] = {
"companies": companies, # curated [{company}] — the lane's identity
"titleKeyword": C.title_of(prefs), # filters within those companies (role relevance)
"remoteOnly": C.is_remote(prefs),
"keywords": inds, # loose industry fold
"recentWindowDays": recent_days,
"maxJobsPerCompany": max_per_company,
"outputProfile": output_profile, # "compact" = cheaper (enough for display); "full" adds descriptions
}
# recall=True: skip the city filter — these curated companies are global/remote, so a
# city lock ("Bengaluru") empties the deck. Location becomes engine ranking later.
if not recall:
out["locationKeyword"] = loc.get("city", "")
return out
if __name__ == "__main__":
import json
demo: Prefs = {
"targetTitles": ["Payments PM"], "title": "Associate Product Manager",
"role": ["Product"], "industry": ["Fintech"],
"location": ["Bengaluru · India", "Delhi NCR · India"],
"workMode": ["Hybrid"], "experience": ["Junior"], "years": 2,
"targetComp": "₹1622L", "companyStage": "Growth-stage",
# engine-side only (ignored by adapters): priorities/stretch/intent/availability/dealBreakers/sort
}
print("MVQ ok:", C.has_mvq(demo), "\n")
for name, fn in [("NAUKRI", build_naukri_input), ("LINKEDIN", build_linkedin_input),
("INDEED", build_indeed_input)]:
print(f"── {name} ──"); print(json.dumps(fn(demo), indent=2, ensure_ascii=False), "\n")
print("── DIRECT-ATS ──")
print(json.dumps(build_ats_input(demo, companies=[{"ats": "ashby", "company": "razorpay"}]), indent=2))