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.
298 lines
15 KiB
Python
298 lines
15 KiB
Python
"""
|
||
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": "₹16–22L", "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))
|