# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Aaron K. Clark
"""Hourly job-discovery poller.
Pulls remote roles from public job-board APIs/feeds, scores each against the
user's keyword list, and adds *new* matches as ``lead`` ``.md`` files. It never
drafts a cover letter and never submits anything: drafting is on demand.
What counts as a match is no longer hardcoded — it comes from a tenant's
:class:`applytrack.criteria.Criteria`, which the .NET API persists as a
``search_profiles`` row and the poller loads through
:class:`applytrack.db.PollRepo`: the flat keyword list, the minimum fit score, a
remote-only toggle + location blocklist, which sources are enabled, and which
company ATS boards to scan.
This runtime no longer touches the disk store; it shares one Postgres with the
API and is driven against a tenant-scoped repo (see :class:`LeadRepo`).
Deduplication is load-bearing — the same posting must never be staged twice. For
each run an in-memory ledger is seeded from the tenant's existing ``applications``
rows. The posting URL is the authoritative key: a listing with a *distinct* URL is
a distinct lead, even when its ``company + role`` slug matches one already seen
(e.g. one role posted per city). The slug is only the fallback key for the
minority of sources that give no URL. Keys are recorded for any listing we
evaluate — staged, filtered, or link-dead — so we don't re-process it; the lone
exception is a genuine *write* failure, which leaves the listing unrecorded so a
later run can retry it.
"""
from __future__ import annotations
import json
import logging
import os
import re
import threading
from collections.abc import Callable, Collection, Iterable
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field, replace
from functools import lru_cache, partial
from html import unescape
from itertools import zip_longest
from typing import Protocol, TypeVar
from urllib.parse import urljoin, urlsplit
# Stdlib import is the Element *type* only — never a parser entry point; all parsing
# below goes through defusedxml, which is XXE / billion-laughs safe.
from xml.etree.ElementTree import Element # nosec B405
import defusedxml.ElementTree as ET # hardened XML parser (forbids entities/external)
import httpx
import psycopg
from applytrack.criteria import AtsBoard, Criteria, is_board_slug
from applytrack.linkcheck import (
BROWSER_HEADERS,
PublicFetchError,
fetch_public,
is_reachable,
is_ssrf_safe,
probe,
ssrf_safe_client,
)
from applytrack.store import AppFields
logger = logging.getLogger(__name__)
_TAG_RE = re.compile(r"<[^>]+>")
_WS_RE = re.compile(r"\s+")
_SLUG_RE = re.compile(r"[^a-z0-9]+")
_URL_RE = re.compile(r"https?://[^\s<>\"')]+")
# Signals that a role is remote-friendly, for the optional remote-only filter.
_REMOTE_HINTS = ("remote", "anywhere", "worldwide", "distributed", "work from home")
@dataclass
class Listing:
"""A raw role pulled from a job board, before scoring/dedup."""
company: str
role: str
link: str = ""
location: str = ""
salary: str = ""
source: str = ""
description: str = ""
#: The aggregator's own "apply" link when its API hands one over (remoteOK's
#: ``apply_url``): a redirect to the employer, cheaper and surer than scraping
#: the listing page for it.
apply_link: str = ""
# A fetcher takes an http client + per-source scan cap and returns listings.
# -- aggregator listings -----------------------------------------------------
#
# A lead from remoteOK, Remotive and their kind carries the aggregator's listing page
# as its link. There is no application form on that page: the agent judged such a
# lead, built a packet, opened it in the browser and found nothing to fill — a wasted
# model call and browser run per lead (#191). At discovery time the listing's apply
# link is followed to the employer's posting (through the same SSRF guard as the link
# check) and THAT is stored; when it cannot be, the lead keeps the aggregator link and
# the .NET side reads it as apply-by-hand. Both runtimes carry this host list — the
# schema is the contract, and this is a reading of its ``link`` column; keep it in
# step with ``AtsProvider.AggregatorHosts``.
AGGREGATOR_HOSTS = frozenset({
"remoteok.com", "remoteok.io", "remotive.com", "remotive.io", "jobicy.com",
"arbeitnow.com", "weworkremotely.com", "remotefirstjobs.com", "workanywhere.pro",
"news.ycombinator.com",
# A LinkedIn posting is a listing of the employer's (#233): its offsite Apply link
# is the lead's ``apply_link`` and what gets stored; Easy Apply ones are never staged.
"linkedin.com",
# Jobright reposts employers' openings on LinkedIn and elsewhere with its own
# "send my profile" flow in place of a form (#238).
"jobright.ai",
# Sundayy lists employers' openings and its Apply is an ad-monetised redirect chain
# (sundayy.com -> thebigjobsite.com -> appcast.io -> lensa.com) through more
# aggregators, never a form; all three job-board hosts are apply-by-hand (#243).
"sundayy.com", "thebigjobsite.com", "lensa.com",
# FetchJobs.co is the same chain under another name: a script-rendered listing whose
# Apply leads off to us.thebigjobsite.com, never a form (#251).
"fetchjobs.co",
# A Handshake posting is a listing of the employer's (#267): its external Apply URL
# is the lead's ``apply_link`` and what gets stored; Handshake-hosted ones are never
# staged.
"joinhandshake.com",
# BestJobTool reposts job listings across boards without direct employer forms.
"bestjobtool.com",
# Adzuna lists other boards' postings; its "Apply" is a tracked redirect off-site.
"adzuna.com",
})
# Aggregators whose Apply never reaches anything a person or a browser can fill: one
# ad-monetised redirect network under four names, its hop through us.thebigjobsite.com
# behind a bot check, its listings routinely another employer's posting under an invented
# title (#281). Not apply-by-hand — not staged at all. lensa.com, where the chain ends, is
# a board a person can sign up to and stays a plain aggregator. Keep in step with
# ``AtsProvider.DeadEndHosts``.
DEAD_END_HOSTS = frozenset({"sundayy.com", "thebigjobsite.com", "fetchjobs.co", "bestjobtool.com"})
def is_dead_end_link(url: str) -> bool:
"""True when ``url`` is a listing on a dead-end aggregator (#281)."""
host = (urlsplit(url or "").hostname or "").lower()
return any(host == h or host.endswith("." + h) for h in DEAD_END_HOSTS)
_APPLY_ANCHOR_RE = re.compile(
r"]*?href=[\"']([^\"']+)[\"'][^>]*>(.*?)", re.IGNORECASE | re.DOTALL
)
_APPLY_WORDS_RE = re.compile(r"\bapply\b|i['’]?m interested", re.IGNORECASE)
_MAX_APPLY_CANDIDATES = 3
def is_aggregator_link(url: str) -> bool:
"""True when ``url`` is on a job aggregator's host rather than an employer's."""
host = (urlsplit(url or "").hostname or "").lower()
return any(host == h or host.endswith("." + h) for h in AGGREGATOR_HOSTS)
def _apply_candidates(html: str, base: str) -> list[str]:
"""The anchors on a listing page that read as its Apply link, absolute, in order."""
out: list[str] = []
for href, inner in _APPLY_ANCHOR_RE.findall(html):
label = _strip_html(inner)
if not (_APPLY_WORDS_RE.search(label) or _APPLY_WORDS_RE.search(href)):
continue
url = urljoin(base, unescape(href).strip())
if url.startswith(("http://", "https://")) and url not in out:
out.append(url)
if len(out) >= _MAX_APPLY_CANDIDATES:
break
return out
# An employer's careers site that answers a bot with one of these is refusing the
# probe, not retiring the posting: Meta, Workday tenants and most Cloudflare-fronted
# boards do it to every unattended client, and a person's browser opens the same URL.
_REFUSED_STATUSES = frozenset({401, 403, 405, 406, 429, 451, 503})
def _resolve_employer(item: Listing, client: httpx.Client) -> tuple[str | None, bool]:
"""The employer's posting behind an aggregator listing, and whether it was *seen* live.
``(url, True)`` for the first candidate whose final URL is live and off every
aggregator's host. Failing that, ``(url, False)`` for the first one the employer's
server merely refused to show a bot (#276): the link LinkedIn handed over is still
the form to fill, and falling back to the listing page traded a posting the browser
can drive for one it never will. A 404, a redirect home or a generic index is dead
either way, and ``(None, False)`` leaves the listing's own link in place.
"""
candidates = [item.apply_link] if item.apply_link else []
if not candidates:
try:
html = fetch_public(item.link, client=client).decode("utf-8", "replace")
except PublicFetchError:
return None, False
candidates = _apply_candidates(html, item.link)
refused: str | None = None
for url in candidates[:_MAX_APPLY_CANDIDATES]:
status = probe(url, client=client)
final = status.final_url or url
if is_aggregator_link(final):
continue
if status.ok:
return final, True
if (
refused is None
and status.status_code in _REFUSED_STATUSES
and not status.redirected_to_home
and not status.generic_listing
):
refused = final
return refused, False
def resolve_employer_link(item: Listing, client: httpx.Client) -> str | None:
"""Follow an aggregator listing to the employer's own posting, or ``None``.
The aggregator's apply link when its API gave one, else the Apply anchors on the
listing page; each is walked through the SSRF-guarded probe (every redirect hop
validated) and the first live final URL that is *not* on an aggregator's host is
the employer's posting — or, when none is live, the first the employer only refused
to show a bot. Bounded to a few candidates; any failure is ``None``.
"""
return _resolve_employer(item, client)[0]
Fetcher = Callable[[httpx.Client, int], list[Listing]]
# -- text helpers -----------------------------------------------------------
def _strip_html(text: str) -> str:
return _WS_RE.sub(" ", _TAG_RE.sub(" ", text or "")).strip()
def _norm_url(url: str) -> str:
"""Scheme/host/trailing-slash-insensitive URL key (query & fragment dropped)."""
url = (url or "").strip()
if not url:
return ""
parts = urlsplit(url if "//" in url else "//" + url)
host = parts.netloc.lower().removeprefix("www.")
path = parts.path.rstrip("/").lower()
return f"{host}{path}" or url.lower()
def _norm_slug(company: str, role: str) -> str:
"""Normalized ``company + role`` key, robust to spacing/punctuation.
The *full* role is kept (parentheticals included), so per-city variants like
``Engineer (Campinas)`` vs ``Engineer (São Paulo)`` are distinct keys — they
are distinct openings. This is the *fallback* dedup key, used only for
listings that carry no URL; when a URL is present it is authoritative (see
:meth:`Seen.has`), so two distinct URLs are never collapsed by this slug.
"""
return _SLUG_RE.sub("-", f"{company} {role}".lower()).strip("-")
def _norm_company(company: str) -> str:
"""Normalized company key for the blacklist (spacing/punctuation-insensitive)."""
return _SLUG_RE.sub("-", (company or "").lower()).strip("-")
def _ats_label(slug: str) -> str:
"""Turn an ATS board slug into a display company name (``foo-bar`` -> ``Foo Bar``)."""
return re.sub(r"[-_]+", " ", slug).strip().title() or slug
# -- scoring ----------------------------------------------------------------
@lru_cache(maxsize=1024)
def _keyword_re(keyword: str) -> re.Pattern[str]:
"""Compile one keyword into a word-aware matcher (cached; keyword sets repeat).
Plain ``in`` matching made short keywords fire on the middle of unrelated
words. ``rag`` was the worst offender by far -- it hit storage,
leveraging, coverage, encourage -- and was single-handedly
responsible for most borderline scores on the live feeds, pulling in account
executives and customer-experience roles on the strength of a benefits blurb.
So a match may not *begin* mid-word. It may still run past the end, because
that is where the useful inflections live: ``backend engineer`` should match
"backend engineers" and ``prompt engineer`` should match "prompt engineering".
An arbitrary tail is not allowed, or ``ai`` would match "aid" -- only a short
set of English suffixes.
The suffix allowance is only granted to keywords of four or more characters.
Short acronyms have no inflections of their own, and granting them one just
re-opens the mid-word problem from the other side: ``rag`` would match "rages",
``ml`` "mled", ``go`` "going".
Deliberately not a regex word-boundary anchor on the front: keywords like
``.net`` and ``c#`` start with punctuation, which such an anchor mishandles. The
rule is the narrower "not preceded by a letter or digit". For a
punctuation-leading keyword that rule is relaxed to "not preceded by a run of
four or more": ``.net`` must still not match example.net, but an unconditional
lookbehind also blocked the ``P`` in ASP.NET, the ``B`` in VB.NET and the ``O``
in ADO.NET. Those framework prefixes are all short; hostnames' second-level
labels almost never are.
"""
prefix = r"(?= 4 else ""
return re.compile(rf"{prefix}{re.escape(keyword)}{suffix}(?![a-z0-9])")
def classify(title: str, description: str, keywords: Iterable[str]) -> tuple[int, list[str]]:
"""Score a role 0-100 against the flat keyword list.
Returns ``(score, matched_keywords)``; ``(0, [])`` when nothing matched.
Keywords match on word starts, not raw substrings -- see :func:`_keyword_re`.
A hit in the title is the strong signal and scores as it always has: base 50,
+9 per title keyword, +3 per keyword found only in the body. A listing with
*no* title hit is weaker evidence -- a generic role whose description happens
to name a technology -- so it starts from 40 and needs corroboration to clear
a typical floor: three body keywords reach 55, one reaches 45. Under the old
flat base a single passing mention scored 53, which sat just under the default
floor by luck rather than by judgement.
The result is clamped to 100. It used to be able to exceed it (six title hits
scored 104), which contradicted both this docstring and the 0-100 slider the
SPA offers for ``min_fit_score``.
"""
title_l = (title or "").lower()
text_l = f"{title} {description}".lower()
kws = [k.lower() for k in keywords if k]
hits = [k for k in kws if _keyword_re(k).search(text_l)]
if not hits:
return 0, []
title_hits = sum(1 for k in kws if _keyword_re(k).search(title_l))
body_hits = len(hits) - title_hits
score = 50 + 9 * title_hits + 3 * body_hits if title_hits else 40 + 5 * body_hits
return min(100, score), hits
# -- semantic fit (opt-in, #300) ----------------------------------------------
#
# The keyword regex can only ask "does this posting share words with the list?" Jev
# (TypeSafe's System One model) is asked the question the regex approximates -- is this
# posting's own work the kind of work the keywords describe? -- and answers with a
# calibrated probability. It is one plain REST call, off unless a tenant turns it on
# AND the operator sets TYPESAFE_API_KEY, and any failure falls back to classify() for
# the rest of the run: a self-hoster with no key loses nothing. Measured against the
# keyword score on 257 hand-labelled live listings in tools/jev_eval/.
JEV_INSTRUCTIONS = (
"`profile.keywords` lists the kinds of work a job seeker does. Is `posting` a job whose "
"actual day-to-day work is one of those kinds of work? Judge the role itself -- what the "
"hired person will spend their time doing. A posting that only mentions a keyword in the "
"company blurb, the product description, the benefits, or a list of tools the team happens "
"to use is NOT a match: a salesperson, recruiter, product manager or support agent at an AI "
"or data company is not doing AI or data work."
)
_JEV_CRITERIA = {
"true": "The role's own work is one of the kinds of work in `profile.keywords`",
"false": "A different kind of job, even if it shares words with `profile.keywords`",
}
# Well under the model's 32k-token state limit, and what the evaluation measured.
_JEV_MAX_DESCRIPTION = 6000
class JevJudge:
"""Asks Jev for a 0-100 fit per listing; ``None`` means "use the keyword score"."""
def __init__(self, api_key: str, *, base_url: str = "", model: str = "",
client: httpx.Client | None = None) -> None:
self._url = (base_url or "https://api.typesafe.ai").rstrip("/") + "/v1/systemone"
self._model = model or "jev-latest"
self._headers = {"Authorization": f"Bearer {api_key}"}
self._client = client or httpx.Client(timeout=15.0)
self.broken = False
def fit(self, item: Listing, keywords: Iterable[str]) -> int | None:
if self.broken:
return None
body = {
"model": self._model,
"state": {
"profile": {"keywords": [k for k in keywords if k]},
"posting": {
"title": item.role, "company": item.company, "location": item.location,
"description": item.description[:_JEV_MAX_DESCRIPTION],
},
},
"questions": {"fit": {"type": "noul", "instructions": JEV_INSTRUCTIONS,
"criteria": _JEV_CRITERIA}},
}
try:
res = self._client.post(self._url, json=body, headers=self._headers)
res.raise_for_status()
p = float(res.json()["answers"]["fit"]["noul"])
except Exception as exc: # noqa: BLE001 - any failure means "keywords decide"
# One failure ends Jev for this run: an outage or a revoked key must not cost a
# timeout per listing. The keyword score decides the rest of the pass.
self.broken = True
logger.warning("jev: %s — keyword scoring for the rest of this run", exc)
return None
if not 0.0 <= p <= 1.0:
self.broken = True
return None
return round(100 * p)
def close(self) -> None:
self._client.close()
def jev_judge_for(repo: object) -> JevJudge | None:
"""A judge when this tenant opted in (``agent_settings.jev_classify``) and the operator
configured a key; otherwise None, and :func:`classify` alone decides."""
enabled = getattr(repo, "jev_classify_enabled", None)
key = os.environ.get("TYPESAFE_API_KEY", "").strip()
if not key or enabled is None:
return None
try:
if not enabled():
return None
except Exception: # noqa: BLE001 - an unreadable setting is an off setting
logger.warning("jev: could not read the tenant's setting; using keywords", exc_info=True)
return None
return JevJudge(
key,
base_url=os.environ.get("TYPESAFE_BASE_URL", ""),
model=os.environ.get("JEV_MODEL", ""),
)
def _looks_remote(item: Listing) -> bool:
"""Best-effort: does this listing read as remote-friendly?
A blank location is treated as remote (most sources here are remote-only
boards that omit it); otherwise we look for an explicit remote signal in the
location or role text.
"""
loc = (item.location or "").strip().lower()
if not loc:
return True
blob = f"{loc} {item.role}".lower()
return any(h in blob for h in _REMOTE_HINTS)
def _passes_location(item: Listing, criteria: Criteria) -> bool:
"""Apply the remote-only toggle + exclude-locations blocklist."""
if criteria.remote_only and not _looks_remote(item):
return False
loc = (item.location or "").lower()
return not any(term.lower() in loc for term in criteria.exclude_locations if term)
# -- ledger -----------------------------------------------------------------
@dataclass
class Seen:
"""The per-run dedup ledger, backed by the ``seen`` table.
``urls`` / ``slugs`` hold the normalized keys already pinged — loaded from the
``seen`` table and seeded from the tenant's existing applications. ``sink``, if
set, persists each *newly* seen key back to the table so a company is never
re-pinged on a later run, even after its lead is deleted.
"""
urls: set[str]
slugs: set[str]
sink: Callable[[str, str], None] | None = None
def has(self, url: str, slug: str) -> bool:
"""Have we already processed this listing?
A listing is seen if its canonical URL has already been recorded in
the ledger, or if its normalized ``company + role`` slug already matches
an existing application or seen opportunity. This prevents re-posted
listings (e.g. from LinkedIn with changing job IDs) from staging
duplicate leads when the opportunity is already tracked.
"""
nu = _norm_url(url)
if nu and nu in self.urls:
return True
return bool(slug) and slug in self.slugs
def note(self, url: str, slug: str) -> tuple[str, str]:
"""Record keys in memory only; return the normalized keys newly added."""
nu = _norm_url(url)
new_url = nu if nu and nu not in self.urls else ""
new_slug = slug if slug and slug not in self.slugs else ""
if nu:
self.urls.add(nu)
if slug:
self.slugs.add(slug)
return new_url, new_slug
def add(self, url: str, slug: str) -> None:
"""Record keys and persist the newly seen ones through ``sink``."""
new_url, new_slug = self.note(url, slug)
if self.sink is not None and (new_url or new_slug):
self.sink(new_url, new_slug)
@dataclass
class Blacklist:
"""Companies to skip entirely, read from the ``blacklist`` table.
Unlike :class:`Seen` (which blocks one URL/role), a blacklisted company is
dropped wholesale, so no role from it is ever staged. The .NET API owns the
writes (``/api/blacklist``); the poller only reads, comparing on the same
normalized key.
"""
companies: set[str]
def has(self, company: str) -> bool:
key = _norm_company(company)
return bool(key) and key in self.companies
class LeadRepo(Protocol):
"""The tenant-scoped data access ``run_poll`` needs (see :class:`applytrack.db.PollRepo`).
Kept narrow so the offline tests can drive ``run_poll`` with an in-memory
fake — no Postgres, no network.
"""
def iter_existing(self) -> Iterable[tuple[str, str, str]]:
"""Yield ``(link, company, role)`` for every application already stored."""
...
def blacklist_companies(self) -> Iterable[str]:
"""Return the tenant's blacklisted company keys."""
...
def load_seen(
self, url_keys: Collection[str], slug_keys: Collection[str]
) -> tuple[set[str], set[str]]:
"""Return which of these ``(url_keys, slug_keys)`` the dedup ledger already holds."""
...
def mark_seen_many(self, keys: Iterable[tuple[str, str]]) -> None:
"""Persist newly seen normalized ``(url_key, slug_key)`` pairs (either may be empty)."""
...
def add_lead(self, fields: AppFields) -> str:
"""Stage one new lead and return its slug ``name``."""
...
def _seed_from_existing(repo: LeadRepo, seen: Seen) -> None:
"""Fold every application already stored for the tenant into the ledger.
Guarantees we never re-add a role you already have a row for — even one you
created by hand in the SPA — using the same URL + ``company + role`` keys the
live listings are checked against. In-memory only (``note``): existing rows are
not re-persisted to the ``seen`` table."""
for link, company, role in repo.iter_existing():
seen.note(link, _norm_slug(company, role))
# -- fetchers: remote job boards --------------------------------------------
def _fmt_salary(lo: object, hi: object) -> str:
def _int(v: object) -> int:
try:
return int(v) if isinstance(v, (int, float, str)) and v not in ("", None) else 0
except (TypeError, ValueError):
return 0
lo_i, hi_i = _int(lo), _int(hi)
if lo_i and hi_i:
return f"${lo_i:,}–${hi_i:,}"
return f"${lo_i:,}" if lo_i else (f"${hi_i:,}" if hi_i else "")
# Remotive's public JSON API is rationed to ~28 jobs; the website itself renders
# 35 fresh jobs per category into a `window.__INITIAL_SEARCH_RESULTS__` blob. We
# scrape page 1 of each relevant category to pull hundreds instead of dozens.
REMOTIVE_CATEGORIES = (
"software-development",
"data",
"devops-sysadmin",
"product",
"all-others",
)
_REMOTIVE_BLOB_RE = re.compile(r"__INITIAL_SEARCH_RESULTS__\s*=\s*(\{)")
def _extract_js_object(html: str, start_brace: int) -> str | None:
"""Return the balanced ``{...}`` JSON object beginning at ``start_brace``."""
depth = 0
in_str = False
esc = False
for i in range(start_brace, len(html)):
ch = html[i]
if in_str:
if esc:
esc = False
elif ch == "\\":
esc = True
elif ch == '"':
in_str = False
elif ch == '"':
in_str = True
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return html[start_brace : i + 1]
return None
def _remotive_hits(html: str) -> list[dict[str, object]]:
m = _REMOTIVE_BLOB_RE.search(html)
if not m:
return []
blob = _extract_js_object(html, m.start(1))
if not blob:
return []
try:
data = json.loads(blob)
except ValueError:
return []
results = data.get("results") if isinstance(data, dict) else None
envelope = results[0] if isinstance(results, list) and results else results
hits = envelope.get("hits") if isinstance(envelope, dict) else None
return [h for h in hits if isinstance(h, dict)] if isinstance(hits, list) else []
def _remotive_location(hit: dict[str, object]) -> str:
locs = hit.get("locations")
if isinstance(locs, list):
names = [str(x.get("name", x)) if isinstance(x, dict) else str(x) for x in locs]
return ", ".join(n for n in names if n)
return str(locs) if locs else ""
def _listing_from_remotive_hit(hit: dict[str, object]) -> Listing | None:
company = str(hit.get("company_name", "")).strip()
role = str(hit.get("title", "")).strip()
if not company or not role:
return None
skills = hit.get("skills")
skill_txt = " ".join(str(s) for s in skills) if isinstance(skills, list) else ""
desc = " ".join(
str(hit.get(k, "")) for k in ("category", "occupation", "seniority", "job_type")
)
return Listing(
company=company,
role=role,
link=str(hit.get("url", "")).strip(),
location=_remotive_location(hit),
salary=str(hit.get("salary", "") or "").strip(),
source="remotive",
description=f"{skill_txt} {desc}".strip(),
)
def fetch_remotive(client: httpx.Client, limit: int) -> list[Listing]:
out: list[Listing] = []
seen_ids: set[object] = set()
for cat in REMOTIVE_CATEGORIES:
if len(out) >= limit:
break
try:
r = client.get(f"https://remotive.com/remote-jobs/{cat}")
r.raise_for_status()
except httpx.HTTPError:
continue
for hit in _remotive_hits(r.text):
jid = hit.get("id", hit.get("url"))
if jid in seen_ids:
continue
seen_ids.add(jid)
listing = _listing_from_remotive_hit(hit)
if listing:
out.append(listing)
if len(out) >= limit:
break
return out
def fetch_remoteok(client: httpx.Client, limit: int) -> list[Listing]:
# RemoteOK blocks non-browser agents and prefixes the array with a legal
# notice; the client already sends a browser User-Agent (see _gather).
r = client.get("https://remoteok.com/api")
r.raise_for_status()
rows = [row for row in r.json() if isinstance(row, dict) and row.get("position")]
out: list[Listing] = []
for j in rows[:limit]:
out.append(
Listing(
company=str(j.get("company", "")).strip(),
role=str(j.get("position", "")).strip(),
link=str(j.get("url", "")).strip(),
location=str(j.get("location", "") or "Remote").strip(),
salary=_fmt_salary(j.get("salary_min"), j.get("salary_max")),
source="remoteok",
description=_strip_html(str(j.get("description", ""))),
apply_link=str(j.get("apply_url", "") or "").strip(),
)
)
return out
def fetch_arbeitnow(client: httpx.Client, limit: int) -> list[Listing]:
r = client.get("https://www.arbeitnow.com/api/job-board-api")
r.raise_for_status()
out: list[Listing] = []
for j in r.json().get("data", [])[:limit]:
tags = j.get("tags") or []
types = j.get("job_types") or []
extra = " ".join(str(t) for t in [*tags, *types])
loc = str(j.get("location", "") or ("Remote" if j.get("remote") else "")).strip()
out.append(
Listing(
company=str(j.get("company_name", "")).strip(),
role=str(j.get("title", "")).strip(),
link=str(j.get("url", "")).strip(),
location=loc,
source="arbeitnow",
description=f"{extra} {_strip_html(str(j.get('description', '')))}".strip(),
)
)
return out
def fetch_jobicy(client: httpx.Client, limit: int) -> list[Listing]:
r = client.get("https://jobicy.com/api/v2/remote-jobs", params={"count": min(limit, 50)})
r.raise_for_status()
out: list[Listing] = []
for j in r.json().get("jobs", [])[:limit]:
body = str(j.get("jobExcerpt", "") or j.get("jobDescription", ""))
out.append(
Listing(
company=str(j.get("companyName", "")).strip(),
role=str(j.get("jobTitle", "")).strip(),
link=str(j.get("url", "")).strip(),
location=str(j.get("jobGeo", "") or "Remote").strip(),
salary=str(j.get("annualSalaryMin", "") or "").strip(),
source="jobicy",
description=_strip_html(body),
)
)
return out
# -- fetchers: RSS job boards -----------------------------------------------
#
# Three boards publish RSS rather than JSON, each splitting its listings across
# per-stack category feeds. They need no bespoke parsing: :func:`parse_job_feed`
# (defined with the custom-feed plumbing below) already reads both title
# conventions these boards use -- "Company: Role" and "Role at Company".
# We Work Remotely category feeds. Item titles are "Company: Role".
WWR_FEEDS = (
"https://weworkremotely.com/categories/remote-programming-jobs.rss",
"https://weworkremotely.com/categories/remote-back-end-programming-jobs.rss",
"https://weworkremotely.com/categories/remote-full-stack-programming-jobs.rss",
"https://weworkremotely.com/categories/remote-front-end-programming-jobs.rss",
"https://weworkremotely.com/categories/remote-devops-sysadmin-jobs.rss",
)
# RemoteFirstJobs per-stack feeds. Item titles are "Role at Company".
REMOTEFIRSTJOBS_FEEDS = (
"https://remotefirstjobs.com/rss/jobs/ai.rss",
"https://remotefirstjobs.com/rss/jobs/python.rss",
"https://remotefirstjobs.com/rss/jobs/react.rss",
"https://remotefirstjobs.com/rss/jobs/golang.rss",
"https://remotefirstjobs.com/rss/jobs/data-science.rss",
)
# WorkAnywhere.pro role-family feeds; it aggregates and de-dupes other boards.
# Item titles are "Role — Company" (em dash) -- see :func:`_retitle_em_dash`.
#
# This host is fronted by Vercel and rate-limits harder than the others: hammered
# a few times in a row it starts answering with an HTTP 429 "Security Checkpoint"
# JS interstitial instead of the feed, and it 429s a request with no User-Agent
# outright. The pooled client's BROWSER_HEADERS and the hourly poll cadence both
# stay well inside that, and a 429 costs a logged WARNING, not a failed run.
WORKANYWHERE_FEEDS = (
"https://workanywhere.pro/rss/developer.xml",
"https://workanywhere.pro/rss/engineer.xml",
"https://workanywhere.pro/rss/data-ai.xml",
)
def _retitle_em_dash(listing: Listing) -> None:
"""Recover ``(role, company)`` from a "Role — Company" title, in place.
:func:`_split_company_role` knows "Company: Role" and "Role at Company"; a
board that uses an em dash instead falls through to its last resort, which
attributes every lead to the *feed's* own title and leaves the employer
buried in the role. Splitting on the last " — " fixes that, and only that:
a role legitimately containing a hyphen ("Developer Advocate - AI &
Developer Experiences — Snowflake") keeps it.
Deliberately *not* folded into the shared splitter. Custom tenant feeds run
through the same parser, and "Senior Engineer — Remote" is a common enough
title there that a global em-dash rule would invent "Remote" as an employer.
"""
role, sep, company = listing.role.rpartition(" — ")
if sep and role.strip() and company.strip():
listing.role, listing.company = role.strip(), company.strip()
def _fetch_feed_set(
client: httpx.Client,
feeds: tuple[str, ...],
limit: int,
source: str,
retitle: Callable[[Listing], None] | None = None,
) -> list[Listing]:
"""Walk one board's category feeds through the shared feed parser.
The per-source cap is split *evenly across feeds* rather than consumed
first-come. These boards are partitioned by stack and wildly uneven -- WWR's
full-stack feed carried ~118 items against front-end's ~4 when this was
written -- so a first-come budget lets one busy category swallow the whole cap
and starve every later feed, which is exactly the per-stack coverage the feeds
exist to provide.
Two things differ from a tenant's custom feeds. These URLs are hard-coded
rather than user-supplied, so they use the pooled client instead of
:func:`~applytrack.linkcheck.fetch_public`'s SSRF-guarded transport. And the
``source`` id is fixed, so leads are attributed to the board rather than to
:func:`_feed_source`'s per-host ``rss:``.
``retitle`` optionally repairs a board whose title convention the shared
splitter doesn't recognize.
A posting cross-listed in several of a board's categories is taken once. The
staging ledger would drop the repeat anyway, but only after it had consumed a
slot: counting duplicates against a feed's share silently shrinks how much of
the board a poll actually covers. Measured on the live feeds when this landed,
7 of RemoteFirstJobs' 40 slots were going to postings an earlier category had
already supplied.
"""
per_feed = max(1, limit // len(feeds))
out: list[Listing] = []
taken: set[str] = set()
for feed in feeds:
if len(out) >= limit:
break
try:
r = client.get(feed)
r.raise_for_status()
except httpx.HTTPError:
# One dead category must not cost the board its other feeds; _gather
# only sees (and names) a failure of the whole source.
logger.warning("source %s: feed %s unreachable", source, feed)
continue
# Parsed past this feed's share by exactly the number of postings an earlier
# feed has already taken, so duplicates are skipped over rather than counted
# against the share -- without materializing the whole-run cap per feed.
fresh = 0
for listing in parse_job_feed(r.content, feed, per_feed + len(taken)):
if fresh >= per_feed or len(out) >= limit:
break
listing.source = source
if retitle is not None:
retitle(listing)
# The URL is authoritative; a linkless entry falls back to company +
# role -- the same rule Seen.has applies once the listing reaches the
# ledger. Computed after retitle so the slug sees the split title.
key = _norm_url(listing.link) or _norm_slug(listing.company, listing.role)
if key in taken:
continue
taken.add(key)
out.append(listing)
fresh += 1
return out
def fetch_weworkremotely(client: httpx.Client, limit: int) -> list[Listing]:
return _fetch_feed_set(client, WWR_FEEDS, limit, "weworkremotely")
def fetch_remotefirstjobs(client: httpx.Client, limit: int) -> list[Listing]:
return _fetch_feed_set(client, REMOTEFIRSTJOBS_FEEDS, limit, "remotefirstjobs")
def fetch_workanywhere(client: httpx.Client, limit: int) -> list[Listing]:
return _fetch_feed_set(
client, WORKANYWHERE_FEEDS, limit, "workanywhere", retitle=_retitle_em_dash
)
# -- fetcher: Hacker News "Who is hiring?" ----------------------------------
def _latest_whoishiring_id(client: httpx.Client) -> str | None:
"""Find the objectID of the most recent monthly "Who is hiring?" thread."""
r = client.get(
"https://hn.algolia.com/api/v1/search",
params={"tags": "story,author_whoishiring", "query": "who is hiring", "hitsPerPage": 10},
)
r.raise_for_status()
best: tuple[int, str] | None = None
for hit in r.json().get("hits", []):
title = str(hit.get("title", "")).lower()
if "who is hiring" not in title or "wants to be hired" in title:
continue
created = int(hit.get("created_at_i", 0))
oid = str(hit.get("objectID", ""))
if oid and (best is None or created > best[0]):
best = (created, oid)
return best[1] if best else None
def _parse_hn_comment(text_html: str) -> Listing | None:
"""Best-effort parse of one "Who is hiring?" comment into a listing.
These are free-form, but the convention is a pipe-delimited first line like
``Company | Role | Location | REMOTE | ...``. We require at least a company
and a role from that header; prose-only comments are skipped (the keyword
filter downstream would mostly drop them anyway).
"""
if not text_html:
return None
first_para = re.split(r"
|\n", text_html, maxsplit=1)[0]
header = _strip_html(first_para)
parts = [p.strip() for p in header.split("|") if p.strip()]
if len(parts) < 2:
return None
company, role = parts[0], parts[1]
if not company or not role or len(company) > 80:
return None
location = next((p for p in parts[2:] if any(h in p.lower() for h in _REMOTE_HINTS)), "")
if not location:
location = parts[2] if len(parts) > 2 else ""
url_match = _URL_RE.search(text_html)
return Listing(
company=company,
role=role,
link=url_match.group(0) if url_match else "",
location=location,
source="hn_whoishiring",
description=_strip_html(text_html)[:600],
)
def fetch_hn_whoishiring(client: httpx.Client, limit: int) -> list[Listing]:
thread_id = _latest_whoishiring_id(client)
if not thread_id:
return []
r = client.get(f"https://hn.algolia.com/api/v1/items/{thread_id}")
r.raise_for_status()
children = r.json().get("children", [])
out: list[Listing] = []
for child in children:
if not isinstance(child, dict) or not child.get("text"):
continue
listing = _parse_hn_comment(str(child["text"]))
if listing:
out.append(listing)
if len(out) >= limit:
break
return out
# -- fetchers: company ATS boards (Greenhouse / Lever) ----------------------
def _require_board_slug(slug: str) -> None:
# The slug becomes a path segment on the provider's host; a ``/``, ``..`` or ``?``
# in it would rewrite the request (#346). Criteria parsing already drops such
# boards — this is the last check before the URL is built.
if not is_board_slug(slug):
raise ValueError(f"invalid ATS board slug: {slug!r}")
def fetch_greenhouse(client: httpx.Client, limit: int, slug: str) -> list[Listing]:
_require_board_slug(slug)
r = client.get(
f"https://boards-api.greenhouse.io/v1/boards/{slug}/jobs", params={"content": "true"}
)
r.raise_for_status()
company = _ats_label(slug)
out: list[Listing] = []
for j in r.json().get("jobs", [])[:limit]:
loc = j.get("location") or {}
out.append(
Listing(
company=company,
role=str(j.get("title", "")).strip(),
link=str(j.get("absolute_url", "")).strip(),
location=str(loc.get("name", "")).strip() if isinstance(loc, dict) else "",
source=f"greenhouse:{slug}",
description=_strip_html(str(j.get("content", ""))),
)
)
return out
def fetch_lever(client: httpx.Client, limit: int, slug: str) -> list[Listing]:
_require_board_slug(slug)
r = client.get(f"https://api.lever.co/v0/postings/{slug}", params={"mode": "json"})
r.raise_for_status()
rows = r.json()
company = _ats_label(slug)
out: list[Listing] = []
for j in rows[:limit] if isinstance(rows, list) else []:
cats = j.get("categories") or {}
location = str(cats.get("location", "")).strip() if isinstance(cats, dict) else ""
team = str(cats.get("team", "")).strip() if isinstance(cats, dict) else ""
out.append(
Listing(
company=company,
role=str(j.get("text", "")).strip(),
link=str(j.get("hostedUrl", "")).strip(),
location=location,
source=f"lever:{slug}",
description=f"{team} {_strip_html(str(j.get('descriptionPlain', '')))}".strip(),
)
)
return out
# Paylocity renders its public board as a React app, so there is no JSON API to
# call -- but the server embeds the whole job list in a ``window.pageData = {...}``
# assignment in the HTML it ships. That blob is the board: parsing it is a plain
# JSON decode, not HTML scraping, and it carries the same fields the page shows.
_PAYLOCITY_PAGE_DATA = "window.pageData = "
# A Paylocity board is addressed by the company's recruiting GUID (the long id in
# the middle of a board URL). Anchored here because the slug is user-supplied and
# gets interpolated into a URL path -- a GUID cannot traverse or add a query.
_PAYLOCITY_SLUG_RE = re.compile(r"^[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$")
def paylocity_slug(value: str) -> str:
"""The company GUID for a Paylocity board, from a raw GUID or a board URL.
Accepts what a user actually has in hand -- the address bar of a company's
Paylocity careers page (``.../recruiting/jobs/All//Acme``) -- and
returns just the GUID, or ``""`` when there is no GUID to be found.
"""
value = (value or "").strip()
if _PAYLOCITY_SLUG_RE.match(value):
return value.lower()
for part in re.split(r"[/?#]", value):
if _PAYLOCITY_SLUG_RE.match(part):
return part.lower()
return ""
def _paylocity_page_data(html: str) -> dict[str, object]:
"""Pull the ``window.pageData`` object out of a Paylocity board page."""
start = html.find(_PAYLOCITY_PAGE_DATA)
if start < 0:
return {}
try:
data, _ = json.JSONDecoder().raw_decode(html[start + len(_PAYLOCITY_PAGE_DATA) :])
except ValueError:
return {}
return data if isinstance(data, dict) else {}
def _paylocity_location(job: dict[str, object]) -> str:
"""Prefer the display location; fall back to the structured city/state."""
name = str(job.get("LocationName") or "").strip()
if not name:
loc = job.get("JobLocation")
if isinstance(loc, dict):
parts = [str(loc.get(k) or "").strip() for k in ("City", "State", "Country")]
name = ", ".join(p for p in parts if p)
# The board flags remote separately from the (often headquarters) location, so
# say so in the location text -- that is what _looks_remote reads.
if job.get("IsRemote") and "remote" not in name.lower():
name = f"{name} (Remote)".strip() if name else "Remote"
return name
def fetch_paylocity(client: httpx.Client, limit: int, slug: str) -> list[Listing]:
"""Scan one company's public Paylocity recruiting board.
``slug`` is the company GUID. The host is fixed and the GUID shape is
validated, so this can share the pooled client like the other ATS boards.
"""
guid = paylocity_slug(slug)
if not guid:
return []
r = client.get(
f"https://recruiting.paylocity.com/recruiting/jobs/All/{guid}",
headers=BROWSER_HEADERS,
)
r.raise_for_status()
data = _paylocity_page_data(r.text)
jobs = data.get("Jobs")
if not isinstance(jobs, list):
return []
company = str(data.get("ModuleTitle") or "").strip() or _ats_label(guid)
out: list[Listing] = []
for j in jobs[:limit]:
if not isinstance(j, dict) or j.get("IsInternal"):
continue
job_id = str(j.get("JobId") or "").strip()
out.append(
Listing(
company=company,
role=str(j.get("JobTitle", "")).strip(),
link=(
f"https://recruiting.paylocity.com/Recruiting/Jobs/Details/{job_id}"
if job_id
else ""
),
location=_paylocity_location(j),
source=f"paylocity:{guid}",
description=_strip_html(str(j.get("Description", ""))),
)
)
return out
# -- fetcher: custom RSS / Atom feeds ---------------------------------------
#
# Unlike every fetcher above, the URL here comes from the user (Settings ·
# Criteria), so it is attacker-influenced: the fetch goes through
# linkcheck.fetch_public, whose transport pins each hop to a validated public
# address, and the document is parsed by defusedxml. One feed key is
# "rss:{url}" — the same key the worker's shared gather routes by.
# A "Company: Role" head longer than this is almost certainly a sentence, not a
# company name; fall back to the feed's own title instead.
_MAX_COMPANY_LEN = 80
# What a careers feed tends to wrap its own title in ("Jobs at Hooli", "Hooli
# Careers"), stripped so the fallback company reads as just the company.
_FEED_TITLE_HEAD_RE = re.compile(
r"^\s*(?:jobs?|careers?|openings?|vacancies|positions?)\s+at\s+", re.I
)
_FEED_TITLE_TAIL_RE = re.compile(
r"[\s\-–—|:]*\b(?:jobs?|careers?|job\s+board|openings?|vacancies|positions?)\b\s*$", re.I
)
_ROLE_AT_COMPANY_RE = re.compile(r"\s+at\s+", re.I)
def _local(tag: object) -> str:
"""Local name of an XML tag, any ``{namespace}`` prefix discarded, lowercased."""
return str(tag).rsplit("}", 1)[-1].lower()
def _child(node: Element, name: str) -> Element | None:
"""First *direct* child with the given local name (namespace-agnostic)."""
return next((el for el in node if _local(el.tag) == name), None)
def _child_text(node: Element, *names: str) -> str:
"""Text of the first direct child matching any of ``names``, else ``""``.
Nested markup is flattened (``itertext``) so an Atom ````
yields its prose rather than nothing.
"""
for name in names:
el = _child(node, name)
if el is not None:
text = _WS_RE.sub(" ", "".join(el.itertext())).strip()
if text:
return text
return ""
def _entry_link(node: Element) -> str:
"""The item's URL: an RSS ```` body, an Atom ````, or a permalink guid."""
fallback = ""
for el in node:
if _local(el.tag) != "link":
continue
href = (el.get("href") or "").strip()
if href:
# Atom: prefer rel="alternate" (the human page); keep others as backup.
if (el.get("rel") or "alternate").strip().lower() == "alternate":
return href
fallback = fallback or href
continue
text = (el.text or "").strip()
if text:
return text
if not fallback:
guid = _child(node, "guid")
text = (guid.text or "").strip() if guid is not None else ""
if text.startswith(("http://", "https://")):
return text
return fallback
def _feed_company(feed_title: str) -> str:
"""Strip the boilerplate off a feed's own title to leave a usable company name."""
title = _FEED_TITLE_HEAD_RE.sub("", feed_title or "").strip()
return _FEED_TITLE_TAIL_RE.sub("", title).strip(" -–—|:").strip()
def _split_company_role(title: str, feed_company: str) -> tuple[str, str]:
"""Best-effort ``(company, role)`` from one feed item's title.
Three shapes cover nearly every job feed: ``Company: Role`` (We Work Remotely
and friends), ``Role at Company``, and a bare role on a company's own careers
feed — where the company is the feed's title. A pair we can't split at all
yields an empty company, and the caller drops the listing.
"""
title = _WS_RE.sub(" ", title or "").strip()
if not title:
return "", ""
company, sep, role = title.partition(":")
company, role = company.strip(), role.strip()
if sep and company and role and len(company) <= _MAX_COMPANY_LEN:
return company, role
# First " at " wins: a role prefix almost never contains one, while a company
# name sometimes does — so "Staff Engineer at Data at Scale" keeps the company whole.
split = _ROLE_AT_COMPANY_RE.search(title)
if split is not None:
role, company = title[: split.start()].strip(), title[split.end() :].strip()
if company and role and len(company) <= _MAX_COMPANY_LEN:
return company, role
return feed_company, title
def _feed_body_text(raw: str) -> str:
"""Flatten a feed item's body to prose.
Unlike the JSON sources, an RSS/Atom body is *escaped* HTML: the XML parser
hands back the markup as text, so the tags are dropped first and the entities
decoded after — otherwise every ``&`` and curly quote reaches the note (and the
cover-letter prompt) as ``&`` / ``’``.
"""
return _WS_RE.sub(" ", unescape(_TAG_RE.sub(" ", raw or ""))).strip()
def _feed_source(feed_url: str) -> str:
"""Source id for a custom feed's leads: ``rss:`` (``auto:rss:`` on the entry)."""
host = urlsplit(feed_url).netloc.lower().removeprefix("www.")
return f"rss:{host}" if host else "rss"
def parse_job_feed(raw: bytes, feed_url: str, limit: int) -> list[Listing]:
"""Map an RSS 2.0 or Atom document onto listings — pure, no network.
Parsed with defusedxml, so entity expansion and external references are refused;
the document comes from a URL the user supplied, not from us. A document we
can't parse yields no listings rather than raising, so one malformed feed never
aborts a poll.
"""
try:
root = ET.fromstring(raw)
except ET.ParseError:
logger.warning("could not parse feed %s as RSS or Atom", feed_url)
return []
channel = _child(root, "channel")
container = root if channel is None else channel
feed_company = _feed_company(_child_text(container, "title"))
source = _feed_source(feed_url)
out: list[Listing] = []
for node in root.iter():
if _local(node.tag) not in ("item", "entry"):
continue
company, role = _split_company_role(_child_text(node, "title"), feed_company)
if not company or not role:
continue
out.append(
Listing(
company=company,
role=role,
link=_entry_link(node),
location=_child_text(node, "location", "region"),
source=source,
description=_feed_body_text(
_child_text(node, "description", "summary", "content", "encoded")
),
)
)
if len(out) >= limit:
break
return out
def fetch_rss(client: httpx.Client, limit: int, url: str) -> list[Listing]:
"""Fetch and parse one custom RSS/Atom job feed.
``client`` — the shared plain client :func:`_gather` hands every fetcher — is
deliberately unused: this URL is user-supplied, so the request must go through
:func:`~applytrack.linkcheck.fetch_public`, whose transport connects only to
validated public addresses. The built-in sources are hard-coded and can safely
share the pooled client. An SSRF-safe client (the worker hands each of its
threads one) *is* used, so a run's feeds share its connections and DNS pins
instead of a fresh TLS handshake apiece (#347).
"""
if is_ssrf_safe(client):
return parse_job_feed(fetch_public(url, client=client), url, limit)
return parse_job_feed(fetch_public(url), url, limit)
def make_rss_fetcher(url: str) -> Fetcher:
"""Bind one feed URL into a plain :data:`Fetcher`, named ``rss:`` for logs."""
def _fetch(client: httpx.Client, limit: int) -> list[Listing]:
return fetch_rss(client, limit, url)
_fetch.__name__ = f"rss:{url}"
return _fetch
SOURCE_FETCHERS: dict[str, Fetcher] = {
"remotive": fetch_remotive,
"remoteok": fetch_remoteok,
"arbeitnow": fetch_arbeitnow,
"jobicy": fetch_jobicy,
"weworkremotely": fetch_weworkremotely,
"remotefirstjobs": fetch_remotefirstjobs,
"workanywhere": fetch_workanywhere,
"hn_whoishiring": fetch_hn_whoishiring,
}
def make_ats_fetcher(board: AtsBoard) -> Fetcher | None:
"""Bind a company slug to its provider's fetcher, yielding a plain Fetcher.
The returned callable carries a ``provider:slug`` ``__name__`` so a failure of
one board is named in the poll diagnostics rather than logged as ````.
"""
slug = board.slug
if board.provider == "greenhouse":
def _fetch(client: httpx.Client, limit: int) -> list[Listing]:
return fetch_greenhouse(client, limit, slug)
elif board.provider == "lever":
def _fetch(client: httpx.Client, limit: int) -> list[Listing]:
return fetch_lever(client, limit, slug)
elif board.provider == "paylocity":
def _fetch(client: httpx.Client, limit: int) -> list[Listing]:
return fetch_paylocity(client, limit, slug)
else:
return None
_fetch.__name__ = f"{board.provider}:{slug}"
return _fetch
def build_fetchers(criteria: Criteria) -> list[Fetcher]:
"""The fetchers to run for this config: enabled sources, ATS boards, custom feeds."""
out: list[Fetcher] = [
SOURCE_FETCHERS[name]
for name, on in criteria.sources.items()
if on and name in SOURCE_FETCHERS
]
for board in criteria.ats_boards:
fetcher = make_ats_fetcher(board)
if fetcher is not None:
out.append(fetcher)
out.extend(make_rss_fetcher(url) for url in criteria.rss_feeds)
return out
def _gather(fetchers: Iterable[Fetcher], limit: int) -> list[Listing]:
"""Run each fetcher, tolerating per-source failures (offline, 4xx, parse).
A failing source is skipped but logged at WARNING with its name, so a
dead/renamed source is visible in the worker logs instead of silently looking
like an empty job market. Any exception is caught — one broken source (e.g. a
changed upstream schema) must never abort the whole run.
"""
listings: list[Listing] = []
with httpx.Client(timeout=20.0, follow_redirects=True, headers=BROWSER_HEADERS) as client:
for fetch in fetchers:
label = getattr(fetch, "__name__", repr(fetch)).removeprefix("fetch_")
try:
listings.extend(fetch(client, limit))
except Exception: # noqa: BLE001 - one bad source must not abort the run
logger.warning("poll source %s failed", label, exc_info=True)
return listings
# -- bounded concurrency (#347) ---------------------------------------------
# A run's probes and source fetches go out this many at a time, and at most
# PER_HOST_WORKERS to any one host: parallel enough that 80 slow employer sites don't
# queue behind each other at 12 s apiece, polite enough not to earn a 429.
NETWORK_WORKERS = 8
PER_HOST_WORKERS = 2
_T = TypeVar("_T")
_K = TypeVar("_K")
def run_bounded(
tasks: Iterable[tuple[str, Callable[[], _T]]],
*,
workers: int = NETWORK_WORKERS,
per_host: int = PER_HOST_WORKERS,
) -> list[_T | Exception]:
"""Run ``(host, thunk)`` tasks on a bounded pool, results in task order.
No more than ``per_host`` tasks for one host key run at once. A task that raises
yields its exception in its slot instead, so one failure never costs the others.
"""
tasks = list(tasks)
gates = {host: threading.BoundedSemaphore(per_host) for host, _ in tasks}
def run(host: str, thunk: Callable[[], _T]) -> _T | Exception:
try:
with gates[host]:
return thunk()
except Exception as exc: # noqa: BLE001 - reported in the task's slot
return exc
if len(tasks) <= 1 or workers <= 1:
return [run(host, thunk) for host, thunk in tasks]
# Submit round-robin across hosts, so a run of one host's tasks doesn't fill the
# pool with threads parked on its gate while other hosts' work waits in the queue.
by_host: dict[str, list[int]] = {}
for i, (host, _) in enumerate(tasks):
by_host.setdefault(host, []).append(i)
order = [i for row in zip_longest(*by_host.values()) for i in row if i is not None]
with ThreadPoolExecutor(max_workers=min(workers, len(tasks))) as pool:
futures = {i: pool.submit(run, *tasks[i]) for i in order}
return [futures[i].result() for i in range(len(tasks))]
class ThreadClients:
"""One :func:`~applytrack.linkcheck.ssrf_safe_client` per worker thread, closed together.
A pinned client arms one fetch deadline at a time, so two threads can't share one;
one per thread still reuses its connections and DNS pins across every fetch that
thread makes, rather than a fresh client and TLS handshake per URL (#347).
"""
def __init__(self, timeout: float = 12.0) -> None:
self._timeout = timeout
self._local = threading.local()
self._lock = threading.Lock()
self._all: list[httpx.Client] = []
def get(self) -> httpx.Client:
client: httpx.Client | None = getattr(self._local, "client", None)
if client is None:
client = ssrf_safe_client(timeout=self._timeout)
self._local.client = client
with self._lock:
self._all.append(client)
return client
def close(self) -> None:
with self._lock:
clients, self._all = self._all, []
for client in clients:
client.close()
@dataclass
class LinkCache:
"""What the network said about a URL, kept for one whole poll run (#347).
The worker stages every tenant from the same shared listings, so without this the
same aggregator page is resolved and the same employer posting probed once per
tenant. ``resolved`` is keyed by ``(link, apply_link)``; ``reachable`` by URL.
"""
resolved: dict[tuple[str, str], tuple[str | None, bool]] = field(default_factory=dict)
reachable: dict[str, bool] = field(default_factory=dict)
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False, compare=False)
_gates: dict[object, threading.Lock] = field(default_factory=dict, repr=False, compare=False)
def once(self, table: dict[_K, _T], key: _K, compute: Callable[[], _T]) -> _T:
"""``table[key]``, computing it at most once even when pool threads race for it.
A computation that raises caches nothing; the next caller tries again.
"""
with self._lock:
if key in table:
return table[key]
gate = self._gates.setdefault((id(table), key), threading.Lock())
with gate:
with self._lock:
if key in table:
return table[key]
value = compute()
with self._lock:
table[key] = value
return value
def _host(url: str) -> str:
return (urlsplit(url or "").hostname or "").lower()
# -- orchestration ----------------------------------------------------------
def run_poll(
repo: LeadRepo,
profile: Criteria,
*,
limit_per_source: int = 40,
fetchers: Iterable[Fetcher] | None = None,
listings: Iterable[Listing] | None = None,
verify_links: bool | None = None,
) -> list[str]:
"""Fetch this tenant's enabled sources, then score and stage. Returns slug names.
``profile`` is the tenant's :class:`~applytrack.criteria.Criteria` (the caller
loads it via :meth:`applytrack.db.PollRepo.load_profile`); ``repo`` is the
tenant-scoped data access (see :class:`LeadRepo`). Network is only touched
when ``listings`` is not supplied; pass a fixed ``listings`` iterable to run
fully offline (tests, replay).
When ``verify_links`` is on, each candidate's posting URL is probed as a
browser before it becomes an entry, and unreachable/dead links are dropped.
It defaults to on for live runs and off when ``listings`` is supplied.
The multi-tenant worker (:mod:`applytrack.worker`) bypasses this and calls
:func:`score_and_stage` directly over a shared, fetched-once listing set.
"""
if verify_links is None:
verify_links = listings is None
if listings is None:
if fetchers is None:
fetchers = build_fetchers(profile)
listings = _gather(fetchers, limit_per_source)
return score_and_stage(repo, profile, listings, verify_links=verify_links)
def _select_for_profile(
gathered: dict[str, list[Listing]], profile: Criteria
) -> list[Listing]:
"""Pick the listings for one tenant from a per-source gather, by enabled source.
Lets the worker fetch each source once and route the shared buckets per tenant,
so ``score_and_stage`` stays source-agnostic and a tenant only ever sees roles
from the sources/boards/feeds it actually enabled."""
out: list[Listing] = []
for name, on in profile.sources.items():
if on:
out.extend(gathered.get(name, []))
for board in profile.ats_boards:
out.extend(gathered.get(f"{board.provider}:{board.slug}", []))
for url in profile.rss_feeds:
out.extend(gathered.get(f"rss:{url}", []))
return out
@dataclass
class _Candidate:
"""A listing that passed every offline filter and waits on the network to be staged."""
item: Listing
slug: str
score: int
hits: list[str]
judged: bool
# Filled by _verify_candidates: the employer posting behind an aggregator listing,
# whether it was seen live, and whether the link to be stored answered a browser.
resolved: str | None = None
seen_live: bool = False
reachable: bool = True
def _verify_candidate(
item: Listing, cache: LinkCache, clients: ThreadClients
) -> tuple[str | None, bool, bool]:
"""Resolve (for an aggregator listing) and probe one candidate, through ``cache``."""
# An Easy Apply posting IS where it is applied to — there is no employer page
# behind it to resolve — and LinkedIn's own API just served it, so it is not
# probed either: linkedin.com answers an unattended GET with a 999 (#278).
if item.source.endswith(":easy") or not item.link:
return None, False, True
resolved: str | None = None
seen_live = False
# An aggregator's listing page has no form on it: store the employer's
# posting instead when the apply link leads there (#191).
if is_aggregator_link(item.link):
resolved, seen_live = cache.once(
cache.resolved,
(item.link, item.apply_link),
lambda: _resolve_employer(item, clients.get()),
)
# The listing was live a moment ago and the employer's server will not talk to a
# bot: the reachability check would only drop the lead.
if resolved and not seen_live:
return resolved, seen_live, True
link = resolved or item.link
reachable = cache.once(
cache.reachable, link, lambda: is_reachable(link, client=clients.get())
)
return resolved, seen_live, reachable
def _verify_candidates(candidates: list[_Candidate], cache: LinkCache) -> list[_Candidate]:
"""Resolve and probe every candidate on a bounded pool; returns the ones that finished.
Each distinct listing is checked once, and a URL an earlier tenant's staging already
checked is not fetched again (#347). A candidate whose check raised is left out, and
left unseen for a later run to retry.
"""
unique: dict[tuple[str, str, bool], Listing] = {}
for cand in candidates:
it = cand.item
unique.setdefault((it.link, it.apply_link, it.source.endswith(":easy")), it)
keys = list(unique)
clients = ThreadClients(timeout=12.0)
try:
outcomes = run_bounded(
(_host(unique[k].link), partial(_verify_candidate, unique[k], cache, clients))
for k in keys
)
finally:
clients.close()
by_key = dict(zip(keys, outcomes, strict=True))
done: list[_Candidate] = []
for cand in candidates:
it = cand.item
outcome = by_key[(it.link, it.apply_link, it.source.endswith(":easy"))]
if isinstance(outcome, Exception):
logger.warning(
"link check failed for %s — %s", it.company, it.role, exc_info=outcome
)
continue
cand.resolved, cand.seen_live, cand.reachable = outcome
done.append(cand)
return done
def score_and_stage(
repo: LeadRepo,
profile: Criteria,
listings: Iterable[Listing],
*,
verify_links: bool = False,
link_cache: LinkCache | None = None,
) -> list[str]:
"""Dedupe, filter, score, and stage ``listings`` for one tenant. Returns slug names.
The dedup ledger is read from the ``seen`` table for just this batch's keys and
seeded from the tenant's existing applications; the newly seen keys are persisted
together at the end so a company is never re-pinged on a later run (#348). Network
is touched only when ``verify_links`` is on: the listings that pass every offline
filter are then resolved and probed together on a bounded pool, through
``link_cache`` — which the multi-tenant worker shares across every tenant of a
run, so a URL is fetched once per run, not per tenant (#347).
"""
batch = [it for it in listings if it.company and it.role]
# Nothing gathered (an --ats-only pass for a tenant with no boards, every ten
# minutes): nothing to dedupe, so no ledger, application or blacklist read (#348).
if not batch:
return []
# Only the keys this batch could hit are read from the ledger, never the whole
# history, which grows by every listing ever passed on (#348).
url_keys, slug_keys = repo.load_seen(
{k for it in batch for k in (_norm_url(it.link), _norm_url(it.apply_link)) if k},
{_norm_slug(it.company, it.role) for it in batch} - {""},
)
# Newly seen keys are written once, together, at the end of the run (#348).
fresh: list[tuple[str, str]] = []
seen = Seen(url_keys, slug_keys, sink=lambda u, s: fresh.append((u, s)))
try:
return _score_and_stage(repo, profile, batch, seen, verify_links, link_cache)
finally:
if fresh:
repo.mark_seen_many(fresh)
# Jev calls in flight at once for one tenant's batch: each can take up to its 15 s
# timeout, and a batch of 200 listings in series is most of an hour (#348).
JEV_WORKERS = 4
def _score_and_stage(
repo: LeadRepo,
profile: Criteria,
listings: list[Listing],
seen: Seen,
verify_links: bool,
link_cache: LinkCache | None,
) -> list[str]:
_seed_from_existing(repo, seen)
blacklist = Blacklist({_norm_company(c) for c in repo.blacklist_companies()})
# The free filters first: a listing they drop never costs a paid Jev call (#348).
scored: list[tuple[Listing, str, int, list[str]]] = []
pending: set[tuple[str, str]] = set()
for item in listings:
# Blacklisted companies are dropped wholesale — never seen, never staged.
if blacklist.has(item.company):
continue
slug = _norm_slug(item.company, item.role)
if seen.has(item.link, slug) or (item.apply_link and seen.has(item.apply_link, slug)):
continue
# The same posting twice in one batch is judged once; staging re-checks
# the ledger anyway, so the second copy could never be staged.
once = ("url", item.link) if item.link else ("slug", slug)
if once in pending:
continue
# We act on this listing one way or another below, so it is recorded
# as seen *except* on a genuine write failure (handled at stage time),
# which leaves it for a later run to retry.
if not _passes_location(item, profile):
seen.add(item.link, slug)
continue
# A dead-end aggregator's listing, or one whose Apply leads to it (a LinkedIn
# "employer" fronting the network): nothing to apply to, so nothing to stage (#281).
if is_dead_end_link(item.link) or is_dead_end_link(item.apply_link):
logger.info("skipped %s — %s: dead-end aggregator", item.company, item.role)
seen.add(item.link, slug)
continue
pending.add(once)
score, hits = classify(item.role, item.description, profile.keywords)
scored.append((item, slug, score, hits))
# Opted in: Jev's fit replaces the keyword score, held to the same floor. It is
# asked even with no keyword hit -- most of what it catches is a role the list
# never names ("Data Engineer", "Backend Software Engineer") (#300). The first call
# goes alone, so an outage still costs one timeout, not one per worker; the rest go
# out a few at a time rather than one after another (#348).
fits: list[int | None] = [None] * len(scored)
judge = jev_judge_for(repo) if scored else None
if judge is not None:
try:
fits[0] = judge.fit(scored[0][0], profile.keywords)
if not judge.broken:
outcomes = run_bounded(
(("jev", partial(judge.fit, item, profile.keywords))
for item, *_ in scored[1:]),
workers=JEV_WORKERS,
per_host=JEV_WORKERS,
)
fits[1:] = [None if isinstance(o, Exception) else o for o in outcomes]
finally:
judge.close()
candidates: list[_Candidate] = []
for (item, slug, score, hits), fit in zip(scored, fits, strict=True):
if fit is not None:
score = fit
if (fit is None and not hits) or score < profile.min_fit_score:
seen.add(item.link, slug)
continue
candidates.append(_Candidate(item, slug, score, hits, judged=fit is not None))
if verify_links and candidates:
candidates = _verify_candidates(
candidates, link_cache if link_cache is not None else LinkCache()
)
# The employer postings behind aggregator listings were not known when the
# ledger was read: read just those keys now (#348).
resolved = {_norm_url(c.resolved) for c in candidates if c.resolved} - {""}
if resolved:
for key in repo.load_seen(resolved, set())[0]:
seen.urls.add(key)
added: list[str] = []
for cand in candidates:
item, slug = cand.item, cand.slug
# An earlier candidate of this batch may have taken the key since it was queued.
if seen.has(item.link, slug) or (item.apply_link and seen.has(item.apply_link, slug)):
continue
# The dedupe ledger is keyed on the listing's own link, whatever we store.
listing_link = item.link
if cand.resolved:
logger.info(
"resolved %s -> %s%s",
item.link,
cand.resolved,
"" if cand.seen_live else " (the employer refused the probe; kept)",
)
if seen.has(cand.resolved, slug):
seen.add(listing_link, slug)
seen.add(cand.resolved, slug)
continue
item = replace(item, link=cand.resolved)
# Block dead postings: don't create an entry we can't actually open.
if not cand.reachable:
seen.add(listing_link, slug)
continue
fields = _to_fields(item, profile.default_lane, cand.score, cand.hits, judged=cand.judged)
try:
name = repo.add_lead(fields)
except psycopg.errors.UniqueViolation:
# Opportunity already tracked under this slug: mark keys seen and skip
logger.info(
"lead already tracked for %s — %s (slug collision)",
item.company,
item.role,
)
seen.add(listing_link, slug)
if item.link and item.link != listing_link:
seen.add(item.link, slug)
continue
if not name:
continue
# Staged: record keys, then collect. Cover letter is drafted on demand.
seen.add(listing_link, slug)
if item.link and item.link != listing_link:
seen.add(item.link, slug)
added.append(name)
return added
def _to_fields(
item: Listing, lane: str, score: int, hits: list[str], *, judged: bool = False
) -> AppFields:
snippet = item.description[:280].rstrip()
matched = f"Matched keywords: {', '.join(hits[:6])}." if hits else ""
if judged:
matched = f"Fit {score}/100 by Jev. {matched}".strip()
notes = f"**Auto-discovered** via {item.source}. {matched}\n\n{snippet}".strip()
return AppFields(
company=item.company,
role=item.role,
lane=lane,
status="lead",
link=item.link,
location=item.location,
salary=item.salary,
source=f"auto:{item.source}",
score=str(min(100, max(0, score))),
notes=notes,
)