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.).
This commit is contained in:
raulgupta
2026-06-20 17:52:06 +05:30
parent ecf5060acd
commit 75b2e538ef
7 changed files with 341 additions and 30 deletions
+61 -5
View File
@@ -46,6 +46,11 @@ class MatchmakingAgentSession:
"tailor_resume": self.handle_tailor_resume, "tailor_resume": self.handle_tailor_resume,
"submit_application": self.handle_submit_application, "submit_application": self.handle_submit_application,
"get_apply_proof": self.handle_get_apply_proof, "get_apply_proof": self.handle_get_apply_proof,
# per-opportunity state — persists across deck refreshes (applied / dismissed / saved docs)
"dismiss_opportunity": self.handle_dismiss_opportunity,
"restore_opportunity": self.handle_restore_opportunity,
"mark_applied": self.handle_mark_applied,
"save_apply_docs": self.handle_save_apply_docs,
} }
handler = handlers.get(action) handler = handlers.get(action)
if not handler: if not handler:
@@ -86,7 +91,19 @@ class MatchmakingAgentSession:
await self.push("agent_thinking", await self.push("agent_thinking",
message="Fetching fresh roles across job boards…" if fresh message="Fetching fresh roles across job boards…" if fresh
else "Scanning live roles across job boards…") else "Scanning live roles across job boards…")
# Search cursor: re-running the SAME query advances the page (LinkedIn) + reuses the Naukri
# incremental stateKey, so each run fetches GENUINELY NEW jobs — not the same set re-charged.
from app.db import repo as _repo
sig = _repo.search_signature(prefs)
page = await _repo.get_search_cursor(self.user_id, sig)
prefs["_page"] = page
# colon-free key (state-store record id charset is alphanumeric/_-.)
prefs["_naukri_state"] = f"{self.user_id}_{sig}".replace(":", "_")
sweep = await search.run_sweep(prefs, fresh=fresh) sweep = await search.run_sweep(prefs, fresh=fresh)
# Safety net: drop anything already shown / applied / dismissed (covers boards that can't
# paginate, so the same posting never reappears even if a board re-returns it).
seen = await _repo.get_seen_ids(self.user_id)
candidates = [o for o in sweep["opportunities"] if o.get("id") not in seen]
# Engine: assemble FULL context (profile + prefs). Cheap rankers SIFT 90 → top ~18 (white-box ‖ # Engine: assemble FULL context (profile + prefs). Cheap rankers SIFT 90 → top ~18 (white-box ‖
# embedding vibe); then Opus reads only those and curates the honest shortlist + report cards. # embedding vibe); then Opus reads only those and curates the honest shortlist + report cards.
from app.engine import curate as _curate from app.engine import curate as _curate
@@ -96,7 +113,7 @@ class MatchmakingAgentSession:
await self.push("agent_thinking", message="Scoring roles against your profile…") await self.push("agent_thinking", message="Scoring roles against your profile…")
# Run the blocking LLM work (embeddings + Opus) OFF the event loop, or the long sync OpenAI # Run the blocking LLM work (embeddings + Opus) OFF the event loop, or the long sync OpenAI
# call freezes the loop and the Redis response can't publish (→ the loader hangs forever). # call freezes the loop and the Redis response can't publish (→ the loader hangs forever).
top, dbg = await asyncio.to_thread(_sift.sift, prefs, user_context, sweep["opportunities"]) top, dbg = await asyncio.to_thread(_sift.sift, prefs, user_context, candidates)
await self.push("agent_thinking", message="Scout is reading your top roles…") await self.push("agent_thinking", message="Scout is reading your top roles…")
curated = await asyncio.to_thread(_curate.curate, prefs, user_context, top) # Opus's call is final curated = await asyncio.to_thread(_curate.curate, prefs, user_context, top) # Opus's call is final
if curated is None: # safety net: sift + templated cards if curated is None: # safety net: sift + templated cards
@@ -107,11 +124,18 @@ class MatchmakingAgentSession:
result = {"opportunities": curated, "sources": sweep["sources"], result = {"opportunities": curated, "sources": sweep["sources"],
"shortlisted": len(curated), "scanned": dbg["scored"], "engine": engine, "shortlisted": len(curated), "scanned": dbg["scored"], "engine": engine,
"has_profile": bool(user_context and user_context.get("skills"))} "has_profile": bool(user_context and user_context.get("skills"))}
# Persist the RAW deck + ADVANCE the cursor (page+1) so the next run of this query fetches new
# jobs. Per-opportunity state (applied/dismissed/saved docs) lives separately, applied on read.
clean_prefs = {k: v for k, v in prefs.items()
if k not in ("user_context", "_fresh", "_page", "_naukri_state")}
await _repo.save_feed(self.user_id, clean_prefs, result, query_sig=sig, cursor_page=page + 1)
# Mark the shown deck as SEEN so it never reappears in a future run.
await _repo.mark_seen(self.user_id, [o.get("id") for o in (result["opportunities"] or []) if o.get("id")])
# Hand the UI a state-aware view: dismissed dropped, applied + saved-doc ids annotated.
states = await _repo.get_opportunity_states(self.user_id)
result["opportunities"] = _repo.annotate_and_filter(result["opportunities"], states)
result["shortlisted"] = len(result["opportunities"])
await self.push("agent_data", action="search_complete", data=result) await self.push("agent_data", action="search_complete", data=result)
# Persist the feed so it survives navigation/refresh (replayed by handle_get_feed).
from app.db.repo import save_feed
clean_prefs = {k: v for k, v in prefs.items() if k not in ("user_context", "_fresh")}
await save_feed(self.user_id, clean_prefs, result)
async def handle_suggest_bubbles(self, params: dict): async def handle_suggest_bubbles(self, params: dict):
"""Fine-tune bubbling — stage-aware (broad→narrow→role) bubbles from the picks so far.""" """Fine-tune bubbling — stage-aware (broad→narrow→role) bubbles from the picks so far."""
@@ -131,3 +155,35 @@ class MatchmakingAgentSession:
async def handle_get_apply_proof(self, params: dict): async def handle_get_apply_proof(self, params: dict):
await self._stub("apply_proof", "return captured confirmation screenshots") await self._stub("apply_proof", "return captured confirmation screenshots")
# ── per-opportunity state (persists across deck refreshes) ──
async def handle_dismiss_opportunity(self, params: dict):
"""Mark a posting stale/dismissed → it never resurfaces in the deck."""
from app.db.repo import set_opportunity_state
await set_opportunity_state(self.user_id, params.get("opportunity_id", ""), status="dismissed")
await self.push("agent_data", action="opportunity_state_saved",
data={"opportunity_id": params.get("opportunity_id"), "status": "dismissed"})
async def handle_restore_opportunity(self, params: dict):
"""Undo a dismiss/pass → clear the status so the role returns to the deck."""
from app.db.repo import set_opportunity_state
await set_opportunity_state(self.user_id, params.get("opportunity_id", ""), status=None)
await self.push("agent_data", action="opportunity_state_saved",
data={"opportunity_id": params.get("opportunity_id"), "status": None})
async def handle_mark_applied(self, params: dict):
"""Record that the user applied to a role (sticks across reloads)."""
from app.db.repo import set_opportunity_state
await set_opportunity_state(self.user_id, params.get("opportunity_id", ""), status="applied")
await self.push("agent_data", action="opportunity_state_saved",
data={"opportunity_id": params.get("opportunity_id"), "status": "applied"})
async def handle_save_apply_docs(self, params: dict):
"""Remember the resume-builder document ids generated for a role so we never re-pay to
re-tailor / re-generate them. Only the provided ids are written."""
from app.db.repo import set_opportunity_state
fields = {k: params[k] for k in ("tailored_resume_id", "tailored_version_id", "cover_letter_id")
if params.get(k)}
await set_opportunity_state(self.user_id, params.get("opportunity_id", ""), **fields)
await self.push("agent_data", action="opportunity_state_saved",
data={"opportunity_id": params.get("opportunity_id"), **fields})
+23 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from sqlalchemy import JSON, DateTime, Integer, String, func from sqlalchemy import JSON, Boolean, DateTime, Integer, String, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -22,6 +22,28 @@ class UserFeed(Base):
sources: Mapped[dict] = mapped_column(JSON, default=dict) # per-board counts sources: Mapped[dict] = mapped_column(JSON, default=dict) # per-board counts
engine: Mapped[str] = mapped_column(String, default="") # "opus" | "fallback:…" engine: Mapped[str] = mapped_column(String, default="") # "opus" | "fallback:…"
scanned: Mapped[int] = mapped_column(Integer, default=0) scanned: Mapped[int] = mapped_column(Integer, default=0)
# Search cursor — advances per re-run of the SAME query so each run fetches NEW jobs (LinkedIn
# page++, Naukri incremental stateKey). Resets when the query signature changes.
query_sig: Mapped[str] = mapped_column(String, default="")
cursor_page: Mapped[int] = mapped_column(Integer, default=1)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class OpportunityState(Base):
"""Per-(user, opportunity) state that SURVIVES deck refreshes. Persists three things:
`applied`/`dismissed` status, and the resume-builder document ids already generated for the
role (so we never re-tailor / re-generate a paid document twice). Keyed by (user, opportunity)."""
__tablename__ = "opportunity_state"
user_id: Mapped[str] = mapped_column(String, primary_key=True)
opportunity_id: Mapped[str] = mapped_column(String, primary_key=True)
status: Mapped[str | None] = mapped_column(String, nullable=True) # 'applied' | 'dismissed' | None
tailored_resume_id: Mapped[str | None] = mapped_column(String, nullable=True) # resume-builder resume id
tailored_version_id: Mapped[str | None] = mapped_column(String, nullable=True) # the tailored version
cover_letter_id: Mapped[str | None] = mapped_column(String, nullable=True) # the generated cover letter
seen: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") # shown in a deck → exclude from new runs
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now() DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
) )
+146 -6
View File
@@ -2,16 +2,32 @@
when the DB is off or unreachable (persistence is optional; the engine must never crash on it).""" when the DB is off or unreachable (persistence is optional; the engine must never crash on it)."""
from __future__ import annotations from __future__ import annotations
import hashlib
import json
import logging import logging
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from app.db.models import Base, UserFeed from app.db.models import Base, OpportunityState, UserFeed
from app.db.session import engine, session_factory from app.db.session import engine, session_factory
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# fields a caller may set on an opportunity's state (status + the generated document ids)
_STATE_FIELDS = ("status", "tailored_resume_id", "tailored_version_id", "cover_letter_id")
# the search-defining pref fields — same values → same signature → cursor advances (new jobs);
# change any → new signature → cursor resets to page 1.
_SIG_FIELDS = ("title", "role", "location", "industry", "experience", "years", "work_mode", "remote")
def search_signature(prefs: dict) -> str:
"""Stable short hash of the search-defining prefs (ignores transient flags / user_context)."""
payload = {k: prefs.get(k) for k in _SIG_FIELDS}
blob = json.dumps(payload, sort_keys=True, default=str)
return hashlib.sha1(blob.encode()).hexdigest()[:16]
async def init_db() -> None: async def init_db() -> None:
"""Create tables if the DB is configured (best-effort; logged, never fatal).""" """Create tables if the DB is configured (best-effort; logged, never fatal)."""
@@ -27,8 +43,8 @@ async def init_db() -> None:
logger.warning("feed store init failed (persistence off): %s", e) logger.warning("feed store init failed (persistence off): %s", e)
async def save_feed(user_id: str, prefs: dict, result: dict) -> None: async def save_feed(user_id: str, prefs: dict, result: dict, *, query_sig: str = "", cursor_page: int = 1) -> None:
"""Upsert the user's last completed search. No-op if the DB is off.""" """Upsert the user's last completed search + the advanced search cursor. No-op if the DB is off."""
factory = session_factory() factory = session_factory()
if factory is None or not user_id: if factory is None or not user_id:
return return
@@ -39,13 +55,15 @@ async def save_feed(user_id: str, prefs: dict, result: dict) -> None:
"sources": result.get("sources") or {}, "sources": result.get("sources") or {},
"engine": result.get("engine") or "", "engine": result.get("engine") or "",
"scanned": int(result.get("scanned") or 0), "scanned": int(result.get("scanned") or 0),
"query_sig": query_sig,
"cursor_page": cursor_page,
} }
try: try:
async with factory() as s: async with factory() as s:
stmt = pg_insert(UserFeed).values(**row) stmt = pg_insert(UserFeed).values(**row)
stmt = stmt.on_conflict_do_update( stmt = stmt.on_conflict_do_update(
index_elements=[UserFeed.user_id], index_elements=[UserFeed.user_id],
set_={k: row[k] for k in ("prefs", "opportunities", "sources", "engine", "scanned")}, set_={k: row[k] for k in ("prefs", "opportunities", "sources", "engine", "scanned", "query_sig", "cursor_page")},
) )
await s.execute(stmt) await s.execute(stmt)
await s.commit() await s.commit()
@@ -66,13 +84,135 @@ async def get_feed(user_id: str) -> dict | None:
return None return None
if not row: if not row:
return None return None
states = await get_opportunity_states(user_id)
opps = annotate_and_filter(row.opportunities or [], states)
return { return {
"opportunities": row.opportunities or [], "opportunities": opps,
"sources": row.sources or {}, "sources": row.sources or {},
"prefs": row.prefs or {}, "prefs": row.prefs or {},
"engine": row.engine, "engine": row.engine,
"scanned": row.scanned, "scanned": row.scanned,
"shortlisted": len(row.opportunities or []), "shortlisted": len(opps),
"cached": True, "cached": True,
"updated_at": row.updated_at.isoformat() if row.updated_at else None, "updated_at": row.updated_at.isoformat() if row.updated_at else None,
} }
async def set_opportunity_state(user_id: str, opportunity_id: str, **fields) -> None:
"""Upsert per-opportunity state (status and/or document ids). No-op if the DB is off.
Only the provided fields are written, so partial updates don't clobber existing values."""
factory = session_factory()
if factory is None or not user_id or not opportunity_id:
return
vals = {k: fields[k] for k in _STATE_FIELDS if k in fields}
if not vals:
return
row = {"user_id": user_id, "opportunity_id": opportunity_id, **vals}
try:
async with factory() as s:
stmt = pg_insert(OpportunityState).values(**row)
stmt = stmt.on_conflict_do_update(
index_elements=[OpportunityState.user_id, OpportunityState.opportunity_id],
set_=vals,
)
await s.execute(stmt)
await s.commit()
except Exception as e: # noqa: BLE001
logger.warning("set_opportunity_state failed (non-fatal): %s", e)
async def get_opportunity_states(user_id: str) -> dict[str, dict]:
"""{opportunity_id: {status, tailored_resume_id, tailored_version_id, cover_letter_id}}."""
factory = session_factory()
if factory is None or not user_id:
return {}
try:
async with factory() as s:
rows = (await s.execute(
select(OpportunityState).where(OpportunityState.user_id == user_id)
)).scalars().all()
except Exception as e: # noqa: BLE001
logger.warning("get_opportunity_states failed (non-fatal): %s", e)
return {}
return {
r.opportunity_id: {
"status": r.status,
"tailored_resume_id": r.tailored_resume_id,
"tailored_version_id": r.tailored_version_id,
"cover_letter_id": r.cover_letter_id,
}
for r in rows
}
def annotate_and_filter(opportunities: list[dict], states: dict[str, dict]) -> list[dict]:
"""Drop dismissed opportunities and annotate the rest with their persisted state (applied flag
+ already-generated document ids) so the UI can restore without re-tailoring."""
out: list[dict] = []
for o in opportunities:
st = states.get(o.get("id"))
if st and st.get("status") == "dismissed":
continue # never resurface
if st:
o = {**o,
"applied": st.get("status") == "applied",
"tailored_resume_id": st.get("tailored_resume_id"),
"tailored_version_id": st.get("tailored_version_id"),
"cover_letter_id": st.get("cover_letter_id")}
out.append(o)
return out
async def get_search_cursor(user_id: str, query_sig: str) -> int:
"""The next LinkedIn start-page for this query. Same signature as last run → advance; a new
query (different signature) → start at page 1. Returns 1 when the DB is off or query changed."""
factory = session_factory()
if factory is None or not user_id:
return 1
try:
async with factory() as s:
row = (await s.execute(select(UserFeed).where(UserFeed.user_id == user_id))).scalar_one_or_none()
except Exception as e: # noqa: BLE001
logger.warning("get_search_cursor failed (non-fatal): %s", e)
return 1
if not row or row.query_sig != query_sig:
return 1 # new/changed query → fresh cursor
return int(row.cursor_page or 1)
async def get_seen_ids(user_id: str) -> set[str]:
"""Every opportunity the user has already been shown / acted on — excluded from new runs."""
factory = session_factory()
if factory is None or not user_id:
return set()
try:
async with factory() as s:
rows = (await s.execute(
select(OpportunityState.opportunity_id).where(OpportunityState.user_id == user_id)
)).scalars().all()
return set(rows)
except Exception as e: # noqa: BLE001
logger.warning("get_seen_ids failed (non-fatal): %s", e)
return set()
async def mark_seen(user_id: str, opportunity_ids: list[str]) -> None:
"""Record that these opportunities were shown in a deck (so future runs skip them). No-op on DB off.
Sets seen=true without touching any existing status/document ids."""
factory = session_factory()
if factory is None or not user_id or not opportunity_ids:
return
rows = [{"user_id": user_id, "opportunity_id": oid, "seen": True} for oid in opportunity_ids if oid]
if not rows:
return
try:
async with factory() as s:
stmt = pg_insert(OpportunityState).values(rows)
stmt = stmt.on_conflict_do_update(
index_elements=[OpportunityState.user_id, OpportunityState.opportunity_id],
set_={"seen": True},
)
await s.execute(stmt)
await s.commit()
except Exception as e: # noqa: BLE001
logger.warning("mark_seen failed (non-fatal): %s", e)
+43
View File
@@ -47,6 +47,44 @@ def _cache_write(path: Path, items: list[dict]) -> None:
# would double the cost for no gain (an empty result is usually a real "0 matches"). # would double the cost for no gain (an empty result is usually a real "0 matches").
_MAX_ATTEMPTS = 2 _MAX_ATTEMPTS = 2
_BACKOFF = 2.0 _BACKOFF = 2.0
_RUNS_BASE = "https://api.apify.com/v2"
_POLL = 3.0 # async-run poll interval (s)
async def _run_actor_async(actor_id: str, run_input: dict, *, timeout: float, token: str) -> list[dict]:
"""Start an ASYNC run, poll to completion, return its dataset items. Needed for incremental /
stateful actors (state persisted across runs via `stateKey`) whose crawl exceeds the run-sync
window — that window aborts the run mid-crawl. No cache: the actor itself is the dedup."""
async with httpx.AsyncClient(timeout=60.0) as client:
r = await client.post(f"{_BASE}/{actor_id}/runs", params={"token": token}, json=run_input)
r.raise_for_status()
run = r.json().get("data", {})
run_id = run.get("id")
ds_id = run.get("defaultDatasetId")
if not run_id:
return []
elapsed = 0.0
while elapsed < timeout:
await asyncio.sleep(_POLL)
elapsed += _POLL
s = await client.get(f"{_RUNS_BASE}/actor-runs/{run_id}", params={"token": token})
data = s.json().get("data", {})
status = data.get("status")
ds_id = data.get("defaultDatasetId") or ds_id
if status == "SUCCEEDED":
break
if status in ("FAILED", "ABORTED", "TIMED-OUT", "TIMING-OUT"):
logger.warning("apify async run %s ended %s", actor_id, status)
return []
else:
logger.warning("apify async run %s did not finish within %ss", actor_id, timeout)
return []
d = await client.get(f"{_RUNS_BASE}/datasets/{ds_id}/items", params={"token": token, "format": "json"})
d.raise_for_status()
items = d.json()
n = len(items) if isinstance(items, list) else 0
logger.info("apify async run %s SUCCEEDED — %d items (incremental)", actor_id, n)
return items if isinstance(items, list) else []
async def run_actor(actor_id: str, run_input: dict, *, timeout: float = 240.0, async def run_actor(actor_id: str, run_input: dict, *, timeout: float = 240.0,
@@ -70,6 +108,11 @@ async def run_actor(actor_id: str, run_input: dict, *, timeout: float = 240.0,
token = get_settings().APIFY_TOKEN token = get_settings().APIFY_TOKEN
if not token: if not token:
raise RuntimeError("APIFY_TOKEN not configured") raise RuntimeError("APIFY_TOKEN not configured")
# Incremental/stateful actors need an async run (the sync window aborts the longer crawl).
if run_input.get("incremental"):
return await _run_actor_async(actor_id, run_input, timeout=timeout, token=token)
url = f"{_BASE}/{actor_id}/run-sync-get-dataset-items" url = f"{_BASE}/{actor_id}/run-sync-get-dataset-items"
async with httpx.AsyncClient(timeout=timeout) as client: async with httpx.AsyncClient(timeout=timeout) as client:
+12 -3
View File
@@ -67,12 +67,13 @@ def build_naukri_input(prefs: Prefs, *, max_jobs: int = 80, freshness: str = "7"
# ── Naukri (blackfalcondata feed — city-precise, real offsite applyRedirectUrl) ── # ── Naukri (blackfalcondata feed — city-precise, real offsite applyRedirectUrl) ──
def build_naukri_feed_input(prefs: Prefs, *, max_jobs: int = 50, fetch_details: bool = True) -> dict: 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 # City-filtered at the board (hyper-relevant + cheap). fetch_details=True surfaces the
# offsite `applyRedirectUrl`; skipReposts dedups for cost. Narrowing (dept/industry/exp) # offsite `applyRedirectUrl`; skipReposts dedups for cost. Narrowing (dept/industry/exp)
# stays off here — that's engine ranking later. # stays off here — that's engine ranking later.
city = next((lp["city"] for lp in C.parsed_locations(prefs) if lp["city"]), "") city = next((lp["city"] for lp in C.parsed_locations(prefs) if lp["city"]), "")
return { out: dict[str, Any] = {
"keyword": C.title_of(prefs) or C.build_keyword(prefs), "keyword": C.title_of(prefs) or C.build_keyword(prefs),
"location": city, # precise city, e.g. "New Delhi" "location": city, # precise city, e.g. "New Delhi"
"maxResults": max_jobs, "maxResults": max_jobs,
@@ -80,6 +81,13 @@ def build_naukri_feed_input(prefs: Prefs, *, max_jobs: int = 50, fetch_details:
"skipReposts": True, "skipReposts": True,
"sortBy": "date", "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 / Monster India (city-precise; apply_url is the offsite redirect) ──
@@ -103,7 +111,7 @@ def build_foundit_input(prefs: Prefs, *, results_wanted: int = 50) -> dict:
# ── LinkedIn (takes the band, not years; ₹ comp incompatible) ──────────────── # ── LinkedIn (takes the band, not years; ₹ comp incompatible) ────────────────
def build_linkedin_input(prefs: Prefs, *, max_items: int = 80, posted: str = "week", def build_linkedin_input(prefs: Prefs, *, max_items: int = 80, posted: str = "week",
recall: bool = False) -> dict: recall: bool = False, page: int = 1) -> dict:
# recall=True (retrieve stage): title + COUNTRY-level location only — drop the narrowing # recall=True (retrieve stage): title + COUNTRY-level location only — drop the narrowing
# filters (easyApply / workplaceType / experienceLevel / industryIds / freshness), which # filters (easyApply / workplaceType / experienceLevel / industryIds / freshness), which
# become ENGINE ranking later. City-level location + a wrong industryId zero the actor out. # become ENGINE ranking later. City-level location + a wrong industryId zero the actor out.
@@ -111,6 +119,7 @@ def build_linkedin_input(prefs: Prefs, *, max_items: int = 80, posted: str = "we
"jobTitles": [C.title_of(prefs)], "jobTitles": [C.title_of(prefs)],
"maxItems": max_items, "maxItems": max_items,
"sortBy": "date", "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: list[str] = [] # bare city names ("New Delhi") — harvestapi's location format
+52 -13
View File
@@ -46,11 +46,33 @@ def _compact(d: dict) -> dict:
return {k: v for k, v in d.items() if v not in (None, "", [], {})} return {k: v for k, v in d.items() if v not in (None, "", [], {})}
def _logo(org: str) -> dict: def _logo(org: str, url: str | None = None) -> dict:
org = (org or "").strip() org = (org or "").strip()
label = org[0].upper() if org else "?" label = org[0].upper() if org else "?"
bg = _PALETTE[sum(map(ord, org)) % len(_PALETTE)] if org else "#9CA3AF" bg = _PALETTE[sum(map(ord, org)) % len(_PALETTE)] if org else "#9CA3AF"
return {"label": label, "bg": bg} 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
def _digits(s: str) -> int | None: def _digits(s: str) -> int | None:
@@ -114,11 +136,16 @@ def naukri_to_scoutjob(it: dict, board: str = "Naukri") -> dict | None:
sd = jd.get("salaryDetail") or {} sd = jd.get("salaryDetail") or {}
if isinstance(sd, dict) and not sd.get("hideSalary") and sd.get("maximumSalary"): if isinstance(sd, dict) and not sd.get("hideSalary") and sd.get("maximumSalary"):
lpa, pay = _salary_from_amount(int(sd["maximumSalary"]), per_year=True) 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 { return {
"id": f"naukri-{jd.get('jobId')}", "id": f"naukri-{jd.get('jobId')}",
"title": title, "title": title,
"organization": org, "organization": org,
"logo": _logo(org), "logo": _logo(org, _logo_url(it)),
"location_city": city, "location_city": city,
"location_country": "India", "location_country": "India",
"matchScore": 0, # placeholder — set after merge "matchScore": 0, # placeholder — set after merge
@@ -129,7 +156,8 @@ def naukri_to_scoutjob(it: dict, board: str = "Naukri") -> dict | None:
"tags": skills[:3], "tags": skills[:3],
"applicants": jd.get("applyCount"), "applicants": jd.get("applyCount"),
"note": f"Live from {board}", "note": f"Live from {board}",
"apply_url": jd.get("staticUrl"), "apply_url": redirect or jd.get("staticUrl"), # offsite employer link first, Naukri listing fallback
"offsite_apply": bool(redirect),
"qx": 10, "qx": 10,
} }
@@ -151,14 +179,14 @@ def indeed_to_scoutjob(it: dict, board: str = "Indeed") -> dict | None:
pass pass
sal = it.get("salary") or "" sal = it.get("salary") or ""
lpa, pay = _salary_from_amount(_digits(sal), per_year="year" in sal.lower()) if sal else (None, "Not disclosed") lpa, pay = _salary_from_amount(_digits(sal), per_year="year" in sal.lower()) if sal else (None, "Not disclosed")
apply_url = it.get("externalApplyLink") ext = it.get("externalApplyLink")
if not apply_url or apply_url == "None": offsite = bool(ext and ext != "None") # externalApplyLink = real offsite employer link
apply_url = it.get("url") apply_url = ext if offsite else it.get("url") # offsite first, Indeed listing fallback
return { return {
"id": f"indeed-{it.get('id')}", "id": f"indeed-{it.get('id')}",
"title": title, "title": title,
"organization": org, "organization": org,
"logo": _logo(org), "logo": _logo(org, _logo_url(it)),
"location_city": city, "location_city": city,
"location_country": "India", "location_country": "India",
"matchScore": 0, "matchScore": 0,
@@ -169,6 +197,7 @@ def indeed_to_scoutjob(it: dict, board: str = "Indeed") -> dict | None:
"applicants": None, "applicants": None,
"note": f"Live from {board}", "note": f"Live from {board}",
"apply_url": apply_url, "apply_url": apply_url,
"offsite_apply": offsite,
"qx": 10, "qx": 10,
} }
@@ -195,11 +224,19 @@ def linkedin_to_scoutjob(it: dict, board: str = "LinkedIn") -> dict | None:
"skills": industries, "skills": industries,
"company_about": _plain(co.get("description"), 700) if isinstance(co, dict) else "", "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 { return {
"id": f"linkedin-{it.get('id')}", "id": f"linkedin-{it.get('id')}",
"title": title, "title": title,
"organization": org, "organization": org,
"logo": _logo(org), "logo": _logo(org, _logo_url(it)),
"location_city": city, "location_city": city,
"location_country": country, "location_country": country,
"location_mode": mode, "location_mode": mode,
@@ -211,7 +248,8 @@ def linkedin_to_scoutjob(it: dict, board: str = "LinkedIn") -> dict | None:
"tags": industries[:3], "tags": industries[:3],
"applicants": it.get("applicants"), "applicants": it.get("applicants"),
"note": f"Live from {board}", "note": f"Live from {board}",
"apply_url": it.get("linkedinUrl"), "apply_url": offsite or it.get("linkedinUrl"), # offsite employer link first, board link fallback
"offsite_apply": bool(offsite),
"details": details, "details": details,
"qx": 10, "qx": 10,
} }
@@ -231,7 +269,7 @@ def ats_to_scoutjob(it: dict, board: str = "Direct") -> dict | None:
"id": f"ats-{it.get('global_id') or it.get('job_id')}", "id": f"ats-{it.get('global_id') or it.get('job_id')}",
"title": title, "title": title,
"organization": org, "organization": org,
"logo": _logo(org), "logo": _logo(org, _logo_url(it)),
"location_city": city, "location_city": city,
"location_country": None, "location_country": None,
"location_mode": mode, "location_mode": mode,
@@ -244,6 +282,7 @@ def ats_to_scoutjob(it: dict, board: str = "Direct") -> dict | None:
"applicants": None, "applicants": None,
"note": f"Live from {board}", # direct employer posting → real offsite apply link "note": f"Live from {board}", # direct employer posting → real offsite apply link
"apply_url": it.get("apply_url") or it.get("url"), "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, "qx": 10,
} }
@@ -288,7 +327,7 @@ def naukri_feed_to_scoutjob(it: dict, board: str = "Naukri") -> dict | None:
"id": f"naukri-{it.get('jobId')}", "id": f"naukri-{it.get('jobId')}",
"title": title.title() if title.isupper() else title, "title": title.title() if title.isupper() else title,
"organization": org, "organization": org,
"logo": _logo(org), "logo": _logo(org, _logo_url(it)),
"location_city": city, "location_city": city,
"location_country": "India", "location_country": "India",
"location_mode": _WORKMODE.get((it.get("wfhType") or "").lower()), "location_mode": _WORKMODE.get((it.get("wfhType") or "").lower()),
@@ -335,7 +374,7 @@ def foundit_to_scoutjob(it: dict, board: str = "Foundit") -> dict | None:
"id": f"foundit-{it.get('job_id')}", "id": f"foundit-{it.get('job_id')}",
"title": title, "title": title,
"organization": org, "organization": org,
"logo": _logo(org), "logo": _logo(org, _logo_url(it)),
"location_city": city, "location_city": city,
"location_country": "India", "location_country": "India",
"matchScore": 0, "matchScore": 0,
+4 -2
View File
@@ -25,13 +25,15 @@ def _ats_companies() -> list[dict]:
# all city-filtered at the board. The rest stay registered (code ready) but off unless enabled. # all city-filtered at the board. The rest stay registered (code ready) but off unless enabled.
BOARDS = { BOARDS = {
"naukri": ("blackfalcondata~naukri-jobs-feed", "naukri": ("blackfalcondata~naukri-jobs-feed",
lambda p: A.build_naukri_feed_input(p, max_jobs=get_settings().NAUKRI_MAX_JOBS), lambda p: A.build_naukri_feed_input(p, max_jobs=get_settings().NAUKRI_MAX_JOBS,
state_key=p.get("_naukri_state")),
N.naukri_feed_to_scoutjob), N.naukri_feed_to_scoutjob),
"foundit": ("shahidirfan~Foundit-Jobs-Scraper", "foundit": ("shahidirfan~Foundit-Jobs-Scraper",
lambda p: A.build_foundit_input(p, results_wanted=get_settings().FOUNDIT_MAX_JOBS), lambda p: A.build_foundit_input(p, results_wanted=get_settings().FOUNDIT_MAX_JOBS),
N.foundit_to_scoutjob), N.foundit_to_scoutjob),
"linkedin": ("harvestapi~linkedin-job-search", "linkedin": ("harvestapi~linkedin-job-search",
lambda p: A.build_linkedin_input(p, recall=True, max_items=get_settings().LINKEDIN_MAX_JOBS), lambda p: A.build_linkedin_input(p, recall=True, max_items=get_settings().LINKEDIN_MAX_JOBS,
page=int(p.get("_page") or 1)),
N.linkedin_to_scoutjob), N.linkedin_to_scoutjob),
# ── registered fallbacks / other lanes (off unless added to BOARDS_ENABLED) ── # ── registered fallbacks / other lanes (off unless added to BOARDS_ENABLED) ──
"ats": ("bovi~greenhouse-lever-ashby-job-scraper", "ats": ("bovi~greenhouse-lever-ashby-job-scraper",