src/applytrack/handshake.py
Rama main · réplica de lectura del repositorio original.
# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Aaron K. Clark
"""Handshake as a discovery source (#267) — campus recruiting, signed in.
Handshake is where US colleges run their campus recruiting. Its official API is not
usable here: the EDU API is read-only, scoped to one institution, and gated to Career
Services partners. So this takes the route the LinkedIn source takes (#233) — sign in
with the candidate's own account and call the endpoint Handshake's own web app calls
(``POST /hs/graphql``).
**One query does the whole job.** ``job.jobApplySetting`` is selectable on the search
result itself, so the search returns how each posting is applied to alongside its copy:
``externalUrl`` is the employer's own posting — the ATS Handshake tracked it from
(iCIMS, BambooHR, …) — and that becomes the lead's ``apply_link`` under the #191 rule.
A posting with no employer URL applies inside Handshake and is staged with the Handshake
posting as its link and **no** ``apply_link``, which is the poller's apply-by-hand lane.
Those are the postings that exist nowhere else — the school-scoped ones this source is
for — so they are staged rather than discarded.
**The session is one cookie.** ``hss-global`` (httpOnly, ~90 days) is the whole session;
a GraphQL call carrying only that cookie answers as the browser does. The .NET agent's
browser signs in with the board account saved for ``joinhandshake.com`` and seals it.
Every request is the tenant's own; nothing here is shared across tenants.
"""
from __future__ import annotations
import json
import logging
import time
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
import httpx
from applytrack.poll import Listing, classify
logger = logging.getLogger(__name__)
BASE = "https://app.joinhandshake.com"
SOURCE = "handshake"
#: Board-account hosts that mean "this tenant's Handshake sign-in". Mirrors the .NET
#: side's list, which normalizes the same way on the write path.
ACCOUNT_HOSTS = ("joinhandshake.com", "app.joinhandshake.com")
#: The whole session: one httpOnly cookie, which is what the renewer seals.
SESSION_COOKIE = "hss-global"
GRAPHQL_URL = BASE + "/hs/graphql"
JOB_URL = BASE + "/jobs"
#: The app's own page size.
PAGE_SIZE = 25
#: How deep one keyword is walked. A board this size has more than any poll needs.
MAX_PAGES = 4
#: How many of the tenant's keywords are worth a search. LinkedIn caps the same way;
#: without it the request budget is whatever the operator typed into the keyword box.
MAX_KEYWORD_QUERIES = 8
#: One request at a time, politely. Handshake is a third party and this is a poll.
PACE_SECONDS = 0.5
#: The channel the web app searches on.
CHANNEL = "NL_SEARCH_CHANNEL"
#: The app's own search document, trimmed to the fields a Listing needs and the one
#: that decides the employer link. GraphQL lets the client choose the fields, so a
#: field this does not ask for cannot break the walk by being renamed.
SEARCH_QUERY = """
query JobSearchQuery($first: Int, $after: String, $input: JobSearchInput) {
jobSearch(first: $first, after: $after, input: $input) {
totalCount
pageInfo { hasNextPage endCursor }
edges {
node {
id
job {
id
title
description
remote
locations { displayName }
salaryRange { min max currency paySchedule { friendlyName } }
employer { name }
jobApplySetting { applyType externalApplyType externalUrl alternativeExternalUrl }
}
}
}
}
}
""".strip()
class NeedsSignIn(Exception):
"""Handshake bounced the kept session: it has to be renewed through the browser."""
class RateLimited(Exception):
"""Handshake answered 429; the rest of the poll waits for the next pass."""
class SchemaError(Exception):
"""Handshake rejected the query document — almost always a schema change.
Deliberately distinct from an empty result: a source that quietly returns no leads
forever is the hardest kind of break for an operator to notice.
"""
@dataclass
class HandshakeAccount:
"""The tenant's Handshake sign-in and the session kept for it."""
username: str
session: str = ""
session_expires_at: datetime | None = None
@property
def session_fresh(self) -> bool:
return bool(self.cookie) and (
self.session_expires_at is None
or self.session_expires_at > datetime.now(timezone.utc)
)
@property
def cookie(self) -> str:
"""The kept session's cookie value, or nothing."""
return parse_session(self.session)
def parse_session(session: str) -> str:
"""The sealed session's plaintext — JSON ``{"hss-global": …}`` — as a cookie value.
The shape is the cross-runtime contract with the renewer's ``Encode``; anything
without the cookie is no session.
"""
if not session:
return ""
try:
data = json.loads(session)
except ValueError:
return ""
if not isinstance(data, dict):
return ""
return str(data.get(SESSION_COOKIE) or "").strip()
# -- reading the payloads ---------------------------------------------------
def jobs_from_search(payload: dict[str, Any]) -> tuple[list[dict[str, Any]], str, bool]:
"""``(jobs, end_cursor, has_next)`` from a ``JobSearchQuery`` response.
A thin or erroring payload is an empty page, not a crash — ``data`` is present and
null on a GraphQL error, which is not the same as absent.
"""
data = (payload or {}).get("data") or {}
search = data.get("jobSearch") or {}
jobs: list[dict[str, Any]] = []
for edge in search.get("edges") or []:
job = ((edge or {}).get("node") or {}).get("job")
if isinstance(job, dict) and str(job.get("id") or "").strip():
jobs.append(job)
page = search.get("pageInfo") or {}
cursor = str(page.get("endCursor") or "").strip()
return jobs, cursor, bool(page.get("hasNextPage"))
def total_count(payload: dict[str, Any]) -> int:
"""How many postings the search matched — the tripwire for a silent schema break."""
data = (payload or {}).get("data") or {}
search = data.get("jobSearch") or {}
try:
return int(search.get("totalCount") or 0)
except (TypeError, ValueError):
return 0
def employer_url(job: dict[str, Any]) -> str:
"""The employer's own posting, or ``""`` when the apply happens inside Handshake.
The URL is the evidence, not ``applyType``: an unrecognized type that still carries
an employer URL is the employer's posting, and only "no URL at all" means Handshake
hosts the apply. Whitelisting the enum would silently discard every posting whose
type Handshake adds later.
"""
setting = job.get("jobApplySetting") or {}
for key in ("externalUrl", "alternativeExternalUrl"):
url = str(setting.get(key) or "").strip()
if url.startswith("http://") or url.startswith("https://"):
return url
return ""
def apply_type(job: dict[str, Any]) -> str:
"""The posting's apply type, upper-cased, for the log line only."""
return str((job.get("jobApplySetting") or {}).get("applyType") or "").strip().upper()
def job_link(job: dict[str, Any]) -> str:
"""The Handshake posting's own URL — the listing's link, and its dedupe key."""
return f"{JOB_URL}/{str(job.get('id') or '').strip()}"
def location_text(job: dict[str, Any]) -> str:
"""Where the posting is, as one line. A remote posting keeps its anchor city."""
places: list[str] = []
for place in job.get("locations") or []:
name = str((place or {}).get("displayName") or "").strip()
if name and name not in places:
places.append(name)
where = " · ".join(places[:3])
if job.get("remote"):
return f"Remote · {where}" if where else "Remote"
return where
def salary_text(job: dict[str, Any]) -> str:
"""The posting's pay as one line, or ``""`` when it names none.
``salaryRange`` reports **minor units**: a security officer's hourly rate arrives as
``1751`` for $17.51, and a service manager's salary as ``9000000`` for $90,000.
Rendering the raw number would overstate every posting by a factor of a hundred.
"""
rng = job.get("salaryRange") or {}
low, high = rng.get("min"), rng.get("max")
if low is None and high is None:
return ""
currency = str(rng.get("currency") or "").strip()
period = str(((rng.get("paySchedule") or {}).get("friendlyName")) or "").strip()
def amount(value: object) -> str:
if value is None:
return ""
try:
number = float(value) / 100 # type: ignore[arg-type]
except (TypeError, ValueError):
return str(value)
return f"{number:,.0f}" if number >= 1000 else f"{number:,.2f}".rstrip("0").rstrip(".")
if low is not None and high is not None and amount(low) != amount(high):
body = f"{amount(low)}–{amount(high)}"
else:
body = amount(low if low is not None else high)
parts = [p for p in (currency, body) if p]
text = " ".join(parts)
return f"{text} {period.lower()}" if period else text
def listing_from_job(job: dict[str, Any]) -> Listing:
"""A search result as the poller's :class:`Listing`.
The Handshake posting is the link (the dedupe key, and an aggregator link the #191
rule resolves). The employer's URL, when there is one, rides as ``apply_link`` and
is what gets stored; when there is not, the lead is apply-by-hand.
"""
return Listing(
company=str(((job.get("employer") or {}).get("name")) or "").strip(),
role=str(job.get("title") or "").strip(),
link=job_link(job),
location=location_text(job),
salary=salary_text(job),
source=SOURCE,
description=str(job.get("description") or ""),
apply_link=employer_url(job),
)
def queries_for(keywords: Iterable[str]) -> list[str]:
"""The keyword searches worth a page each: the tenant's keywords, de-duped and
capped at :data:`MAX_KEYWORD_QUERIES` so the request budget is bounded."""
out: list[str] = []
seen: set[str] = set()
for keyword in keywords:
query = " ".join(str(keyword).split()).strip()
if query and query.lower() not in seen:
seen.add(query.lower())
out.append(query)
if len(out) >= MAX_KEYWORD_QUERIES:
break
return out
# -- the client -------------------------------------------------------------
class Client:
"""One tenant's view of Handshake's job search over an ``httpx.Client``.
The session travels as a request header rather than in the shared cookie jar: the
cookie is a property of this tenant's call, not of a client the poller may reuse.
"""
def __init__(
self,
client: httpx.Client,
account: HandshakeAccount | None = None,
*,
pace: Callable[[float], None] = time.sleep,
) -> None:
self._client = client
self._pace = pace
self._cookie = account.cookie if account else ""
@property
def signed_in(self) -> bool:
return bool(self._cookie)
def _post(self, operation: str, query: str, variables: dict[str, Any]) -> dict[str, Any]:
self._pace(PACE_SECONDS)
headers = {
"content-type": "application/json",
"accept": "application/json",
"referer": BASE + "/job-search",
"origin": BASE,
}
if self._cookie:
headers["cookie"] = f"{SESSION_COOKIE}={self._cookie}"
r = self._client.post(
GRAPHQL_URL,
json={"operationName": operation, "query": query, "variables": variables},
headers=headers,
)
self._check(r)
try:
payload = r.json()
except ValueError as exc:
raise NeedsSignIn("Handshake answered with a page, not JSON") from exc
# A GraphQL error is a 200 with an `errors` array, not an HTTP failure: read it,
# or a renamed field looks exactly like a board with no jobs.
errors = (payload or {}).get("errors") if isinstance(payload, dict) else None
if errors:
message = str((errors[0] or {}).get("message") or "unknown error")[:200]
if _looks_like_auth(message):
raise NeedsSignIn(f"Handshake rejected the session: {message}")
raise SchemaError(f"Handshake rejected the {operation} document: {message}")
return payload # type: ignore[no-any-return]
def _check(self, r: httpx.Response) -> None:
if r.status_code == 429:
raise RateLimited("Handshake answered 429")
# A stale cookie is a clean 401 with an empty body — verified against the live
# site, not assumed. A redirect to the sign-in surface is the same news.
if r.status_code in (401, 403) or (
r.status_code in (301, 302, 303, 307, 308)
and "/access" in r.headers.get("location", "")
):
raise NeedsSignIn(f"Handshake answered {r.status_code}")
if r.status_code in (301, 302, 303, 307, 308):
raise NeedsSignIn("Handshake redirected the request")
r.raise_for_status()
def search(
self, query: str, *, after: str | None = None, first: int = PAGE_SIZE
) -> dict[str, Any]:
"""One page of the signed-in job search for ``query``, newest first.
``after`` is the page cursor; ``None`` asks for the first page, which the endpoint
accepts — no assumption about how a cursor is encoded.
"""
return self._post(
"JobSearchQuery",
SEARCH_QUERY,
{
"first": first,
"after": after,
"input": {
"filter": {"query": query} if query else {},
"sort": {"direction": "DESC", "field": "POST_DATE"},
"channel": CHANNEL,
},
},
)
def _looks_like_auth(message: str) -> bool:
lowered = message.lower()
return any(word in lowered for word in ("unauthor", "not signed in", "forbidden", "session"))
def fetch_listings(
client: Client,
keywords: Iterable[str],
*,
limit: int,
already_seen: Callable[[str], bool] = lambda _url: False,
) -> list[Listing]:
"""Search each keyword and stage what matches, newest first.
The employer's URL, when the posting has one, becomes the lead's ``apply_link``; a
posting that applies inside Handshake is staged with the Handshake posting as its
link and no ``apply_link``, which is the poller's apply-by-hand lane. Nothing is
written to the ledger here — a skip is a decision that can change, and the ledger is
for a URL processed under a rule that will not.
"""
keyword_list = list(keywords)
out: list[Listing] = []
done: set[str] = set()
hosted = 0
try:
for query in queries_for(keyword_list):
after: str | None = None
for _ in range(MAX_PAGES):
page = client.search(query, after=after)
jobs, cursor, has_next = jobs_from_search(page)
if not jobs:
# A positive count with nothing to show is a schema break wearing a
# "no jobs" costume; say so rather than returning an empty poll.
if total_count(page) > 0:
logger.warning(
"handshake: %r matched %d postings but none parsed — the search "
"document may no longer match Handshake's schema",
query, total_count(page),
)
break
for job in jobs:
job_id = str(job.get("id") or "").strip()
if not job_id or job_id in done:
continue
done.add(job_id)
if already_seen(job_link(job)):
continue
if not classify(
str(job.get("title") or ""), str(job.get("description") or ""), keyword_list
)[1]:
continue
if not employer_url(job):
hosted += 1
out.append(listing_from_job(job))
if len(out) >= limit * 3:
return out
if not has_next or not cursor:
break
after = cursor
except RateLimited:
logger.warning(
"handshake: rate-limited (429) after %d listings; the rest waits for the next poll",
len(out),
)
except httpx.HTTPError as exc:
# A 500 or a read timeout must not throw away what was already gathered.
logger.warning(
"handshake: %s after %d listings; the rest waits for the next poll",
type(exc).__name__, len(out),
)
if hosted:
logger.info(
"handshake: %d of %d staged leads apply inside Handshake (apply by hand)",
hosted, len(out),
)
return out