Files
raulgupta 13faed0a06 Engine: signal-weighted India stack + cursorless pool + honest-card hardening
Boards (signal-weighted, ~150/sweep):
- Add TimesJobs (shahidirfan) — richest board: skills+salary+experience+offsite. Build/normalize +
  register; legacy city alias (New Delhi→Delhi, Bengaluru→Bangalore, Gurugram→Gurgaon) — verified live
  it returns 0 for "New Delhi", 35 for "Delhi".
- Swap LinkedIn harvestapi → curious_coder (structured industry/jobFunction/applicants); harvestapi kept
  registered as linkedin_v1 fallback.
- Drop Indeed from enabled (0% skills, redundant). Budgets: Foundit 40, TimesJobs 35, Naukri 30,
  LinkedIn 30, WorkIndia 15.

Pool — cursorless freshness + safety:
- REMOVE pagination/cursors: curious_coder only honors start=0 (start>0 → empty), proven; the rest are
  date feeds. Freshness now = boards' date-sort + the per-user seen-net + pool_save id-dedup.
- Exhaustion guard: a refill adding < POOL_MIN_NEW_PER_REFILL new jobs flags the (user,query) exhausted →
  the gate relaxes the floor instead of block-fetching dupes (makes the 100/90 floor safe on niche queries).
- Storage cap: pool_save trims beyond POOL_MAX_PER_QUERY freshest (kills DB swell). 72h TTL verified.
- Thresholds 100/90 (deep pool, pay-for-volume).

Scoring / cards:
- Evidence-based fallback prose: when Haiku's cards stage gives nothing, the card uses Opus's REAL
  dimension notes (not a generic "Strong on X") + coverage logging + generic salvage parser.
- "Skills & requirements" → "Skills fit" (consistent dimension labels).

Bug fixes:
- mark_seen dedups ids (was CardinalityViolationError on a duplicate id in one batch).

Tests: deep stack contracts + committed board-sample fixture (no cache-pollution flakiness) + offline
e2e + opt-in live e2e. 64 pass.
2026-06-26 15:56:27 +05:30

298 lines
15 KiB
Python
Raw Permalink 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 urllib.parse import urlencode
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) -> dict:
# City-filtered at the board (hyper-relevant + cheap). fetch_details=True surfaces the
# offsite `applyRedirectUrl`; skipReposts dedups WITHIN a run. Narrowing (dept/industry/exp)
# stays off here — that's engine ranking later.
# NOTE: no `incremental`/`stateKey` — that put opaque, exhausting, cross-account dedup state on
# Apify. Cross-deck dedup is the PER-USER seen-net in our DB. (The feed actor has no page/offset.)
city = next((lp["city"] for lp in C.parsed_locations(prefs) if lp["city"]), "")
return {
# Targeted keyword (seniority + industry + a top skill) → fetch RIGHT-level/industry/skill jobs,
# not bare-title breadth. Text-targeting degrades gracefully (never zeroes); the pool gives breadth.
"keyword": C.build_keyword(prefs, seniority=True, industry=True, skills=True),
"location": city, # precise city, e.g. "New Delhi"
"maxResults": max_jobs,
"fetchDetails": fetch_details,
"skipReposts": True,
"sortBy": "date",
}
# Legacy India job boards (Foundit/Monster, TimesJobs) use OLD city names — they return 0 for the
# modern "New Delhi"/"Bengaluru"/"Gurugram". Map to their canonical names. (Verified live: TimesJobs
# returns 0 for "New Delhi", 15 for "Delhi".)
_LEGACY_IN_CITY = {
"new delhi": "Delhi", "delhi ncr": "Delhi", "bengaluru": "Bangalore",
"gurugram": "Gurgaon", "mumbai": "Mumbai", "pune": "Pune", "hyderabad": "Hyderabad",
"chennai": "Chennai", "kolkata": "Kolkata", "noida": "Noida",
}
_FOUNDIT_CITY = _LEGACY_IN_CITY # back-compat alias
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.build_keyword(prefs, seniority=True, industry=True, skills=True), # targeted
"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.
# jobTitles is a TITLE field — prepend the seniority band (real titles, e.g. "Senior Sales Manager")
# to target right-level roles. Industry stays OUT of the title field (it's not part of a job title).
_title = C.title_of(prefs)
if C.seniority_band(prefs) in ("Senior", "Lead") and _title:
_title = f"{C.seniority_band(prefs)} {_title}"
out: dict[str, Any] = {
"jobTitles": [_title],
"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:
# valig~indeed-jobs-scraper: title + location + country(lowercase ISO2) + limit. ~7s/run (vs misceres 52s).
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 title query.
"title": C.build_keyword(prefs, seniority=True, industry=True),
"location": loc.get("city") or "",
"country": C.COUNTRY_ISO2.get(country_name, "IN").lower(), # valig wants lowercase ("in")
"limit": max_items,
}
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
# ── WorkIndia (India blue/grey-collar — the non-tech workhorse: sales, BPO, retail, field, ops) ──
def build_linkedin_cc_input(prefs: Prefs, *, count: int = 30) -> dict:
# curious_coder~linkedin-jobs-scraper takes LinkedIn SEARCH URLs (structured industry+jobFunction+
# applicants). NO pagination — the actor only honors start=0 (start>0 returns empty). Every fetch
# pulls the freshest page-1 by date; the per-user seen-net dedups, and daily board refresh feeds new jobs.
kw = C.build_keyword(prefs, seniority=True, industry=True) # targeted search string
loc = next((f"{lp['city']}, {lp['country']}" if lp["country"] else lp["city"]
for lp in C.parsed_locations(prefs) if lp["city"]), None) \
or next((lp["country"] for lp in C.parsed_locations(prefs) if lp["country"]), "India")
q = urlencode({"keywords": kw, "location": loc, "sortBy": "DD"}) # DD = newest first
return {"urls": [f"https://www.linkedin.com/jobs/search/?{q}"],
"count": max(10, count), "scrapeCompany": False} # count min 10 (actor constraint)
def build_timesjobs_input(prefs: Prefs, *, results_wanted: int = 35) -> dict:
# shahidirfan/Timesjobs-Scraper — FULL signal (skills+salary+experience+offsite external_job_url).
# keyword + city + an experience BAND ("5-10") for right-level targeting. Clean keyword input, no page.
city = next((lp["city"] for lp in C.parsed_locations(prefs) if lp["city"]), "")
city = _LEGACY_IN_CITY.get(city.lower(), city) # TimesJobs needs "Delhi" not "New Delhi" (verified)
out: dict[str, Any] = {
"keyword": C.build_keyword(prefs, seniority=True, industry=True, skills=True),
"results_wanted": results_wanted,
}
if city:
out["location"] = city
yrs = C.years_of(prefs)
if yrs is not None:
out["experience"] = f"{max(0, yrs - 2)}-{yrs + 3}" # a band around the candidate's years
return out
def build_workindia_input(prefs: Prefs, *, results_wanted: int = 20) -> dict:
# keyword + city; includeDetails=True pulls the real skills + full description (verified live Phase 0).
# No page param → per-user seen-net dedups. City is a lowercased name slug ("mumbai", "pune").
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),
"results_wanted": results_wanted,
"includeDetails": True,
}
if city:
out["city"] = city.lower()
return out
# ── Wellfound / AngelList (startup roles — equity + skills; remote-friendly for India) ──
def build_wellfound_input(prefs: Prefs, *, max_results: int = 15) -> dict:
# query=title; city → location slug (lowercase-hyphen); remote honored. enrichDetail=True is
# REQUIRED — it pulls skills[] + the full description the curator scores on (the core fields
# alone are thin). No page param → dedup via the per-user seen-net (like Naukri/Foundit).
city = next((lp["city"] for lp in C.parsed_locations(prefs) if lp["city"]), "")
out: dict[str, Any] = {
"query": C.title_of(prefs) or C.build_keyword(prefs),
"maxResults": max_results,
"enrichDetail": True, # → skills + description (the curator's signal)
"remote": C.is_remote(prefs),
}
if city:
out["location"] = city.lower().replace(" ", "-") # "New Delhi" → "new-delhi"
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))