Adapter Dock
What this is
The Adapter dock is the language-neutral Rust contract every job-source
observer plugs into. One implementation per source (or per source produced
by a multi-tenant family factory) discovers candidate URLs and turns one
URL into one Observation. The dock itself does no fetching, no parsing,
no I/O — every host capability (HTTP, rate-limit hint, user-agent,
optional JS-rendered fetch) is injected through the Env trait.
This is the layer between [[jobcache-top-down-ingestion]] and the
Observation write path documented in JOBCACHE-ARCHITECTURE.md. The
shared trait and helpers live in crates/jobcache/; each adapter family
lives in crates/jobcache-adapter-<family>/.
Why Rust, why cdylib + rlib
Every adapter crate is configured as both cdylib (the WASM Component
Model artifact loaded by Node ingest, browsers, and sandboxed hosts) AND
rlib (native, directly linked into the Tauri desktop runtime and into
the crate's own unit tests). Same source, two targets, chosen at deploy
time. There are no #[cfg(target_arch = "wasm32")] branches inside an
adapter; every host capability comes in through the Env trait, so the
adapter never needs to know whether it is running native or in a WASM
component. From the file header at /home/richc/Documents/GitHub/careervector/crates/jobcache/src/adapter.rs:
This is what makes one Rust source crate compile to both a native
rlib(Tauri desktop direct-link, with a Tauri-backedEnv) and a WASM Component Model artifact (Node ingest, with a JS-fetch-backedEnv) — no#[cfg(target_arch = "wasm32")]branches inside the adapter.
Relationship to lib/jobcache TypeScript mirror
crates/jobcache/ is the Rust mirror of @cv/jobcache on the TypeScript
side (lib/jobcache/src/adapter.ts, contract.ts, parsed-ad.ts,
ids.ts). Both sides are intentionally byte-equivalent for shared
identity (url_id, ad_id_from_url_language, observation hash inputs),
and the serde representations match the TS JSON shape (snake_case field
keys, kebab-case enum variants for Method / EvidenceRefKind,
lowercase for ObservationStatus / TaskClass). Drift here means
ad_id divergence across the host boundary, which breaks dedup.
The Rust side is the surface every adapter implements. The TS mirror is
the surface the Node ingest broker reads/writes after a WASM-loaded
adapter has emitted an Observation.
The Adapter trait
Defined in /home/richc/Documents/GitHub/careervector/crates/jobcache/src/adapter.rs:
#[async_trait]
pub trait Adapter: Send + Sync {
fn descriptor(&self) -> AdapterDescriptor;
async fn discover(&self, env: &dyn Env) -> Result<Vec<String>, AdapterError>;
async fn observe(
&self,
url: &str,
env: &dyn Env,
opts: &ObservationOptions,
) -> Result<Option<Observation>, AdapterError>;
}
Three methods, intentionally minimal:
descriptor()— identity:id,version,label,homepage,method. Synchronous, infallible, must be cheap (the router calls it on every URL match attempt).discover(env)— return candidate URLs the source has on offer. Bounded — the adapter is expected to cap its own return (each crate definesMAX_DISCOVERor an equivalent) so the device runtime never receives unbounded lists.observe(url, env, opts)— fetch + parse one URL into oneObservation. ReturnOk(None)when the URL turned out to be irrelevant (expired, redirected to a non-job page, content missing). ReturnErr(AdapterError::Fetch | Parse | Contract | Other)for transport or parse failures the runtime should surface.
AdapterDescriptor
pub struct AdapterDescriptor {
pub id: String,
pub version: String,
pub label: String,
pub homepage: String,
pub method: Method,
}
id is the stable adapter identifier (e.g. "lever:anybotics",
"sf-spa:pictet") and persists into the Observation.adapter_id field.
homepage is also used by Router::match_url for URL → adapter dispatch
(see /home/richc/Documents/GitHub/careervector/ui/desktop/src-tauri/src/runtime/router.rs)
— the router treats homepage as a prefix and tolerates http:// ↔
https:// and www. ↔ bare-host variants.
Method enum
pub enum Method {
SitemapJsonld,
HtmlBespoke,
HeadlessProxy,
FeedApi,
}
Serialized as kebab-case ("sitemap-jsonld", "html-bespoke",
"headless-proxy", "feed-api"). It is surfaced on the dashboard AND
used by the device runtime to decide which kind of Env.fetch
implementation to inject (e.g. JS-capable proxy chain for
HeadlessProxy).
| Variant | When to pick it |
|---|---|
FeedApi |
Source exposes a stable JSON API. Plain env.fetch is enough. Examples: Lever, Greenhouse, Ashby, HiBob (uses an undocumented JSON endpoint — see below). |
SitemapJsonld |
Source exposes a sitemap.xml of detail URLs and detail pages carry schema.org JobPosting JSON-LD. |
HtmlBespoke |
Server-rendered HTML — selector- or text-based extraction, with or without schema.org JSON-LD on detail pages. Plain env.fetch. |
HeadlessProxy |
Listing and/or detail page requires JavaScript hydration before content is visible. The adapter uses env.fetch_rendered. Default Env impl returns FetchError::Unavailable, so hosts without a headless-render backend skip the source cleanly. |
Observation envelope and ParsedAd shape
The submitted shape is Observation (cross-reference
JOBCACHE-ARCHITECTURE.md: "Observation is the canonical submitted
envelope for public ad output. It is anchored to one ad_id and contains
shared payload fields/cells plus evidence, chunks, embeddings, and
provenance when produced"). Full type lives at
/home/richc/Documents/GitHub/careervector/crates/jobcache/src/contract.rs,
keyed off the 28-entry FieldKey vocabulary (title, organization,
description, description_html, location, locality, region,
country_code, remote, employment_type, language, industry,
salary, salary_min, salary_max, salary_currency, salary_period,
workload, source, source_id, source_ref, url, resolved_url,
canonical_url, posted_at, valid_through, raw — see
FieldKey::ALL). BTreeMap<FieldKey, ObservedField> ordering matches
Object.keys().sort() on the TS side (lexicographic on the serialized
name).
Adapters that produce a flat ad shape internally use the ParsedAd
helper at /home/richc/Documents/GitHub/careervector/crates/jobcache/src/parsed_ad.rs,
then call parsed_ad_to_observation(&ad, &opts) to wrap it into a full
envelope (evidence refs, content hash, default resource policy,
observation hash, fallback ids). ParsedAd is NOT a submitted shape —
only Observation crosses the dock.
ObservationOptions — runtime envelope hints
Optional hints the device runtime passes into observe() to fill in
envelope fields it owns (task/lease/device/session ids, app and runtime
versions, status, timing, hashes). Adapters use these as defaults when
constructing the Observation; the runtime can also fill them in
afterwards. Defined in crates/jobcache/src/adapter.rs. All fields
optional; ObservationOptions::default() serializes to {}. The
serializer matches the TS dock — camelCase on the wire
(taskId, leaseId, deviceId, adapterId, adapterVersion,
fetchedAt, observedAt, timingMs, bytesRead, contentHash,
adId, …).
Env trait — host-injected capabilities
#[async_trait]
pub trait Env: Send + Sync {
fn user_agent(&self) -> &str;
fn rate_limit(&self) -> Option<RateLimit> { None }
async fn fetch(&self, req: Request) -> Result<Response, FetchError>;
async fn fetch_rendered(&self, _req: Request) -> Result<Response, FetchError> {
Err(FetchError::Unavailable("rendered fetch".into()))
}
}
The adapter never constructs an Env; the device runtime (Tauri host,
Node host, test harness, etc.) implements Env and passes a borrowed
reference to the adapter on every discover() / observe() call.
user_agent()— UA string the adapter should claim. Required.rate_limit()— optionalRateLimit { per_minute }advisory the runtime is allowed to ignore.fetch(req)— plain HTTP fetch, no JavaScript execution. Used for sources whose listing/detail pages are server-rendered or expose a JSON API.fetch_rendered(req)— JavaScript-rendered fetch; drives a headless browser and returns the post-render DOM as HTML inresponse.body. Default impl returnsFetchError::Unavailable— adapters that need this capability should propagate the error so the runtime can mark the source as "needs rendered fetch backend" and skip it cleanly. Hosts that wire a real backend (Browserbase, Firecrawl, local Playwright) override this method.
Request carries url, HTTP method (defaults to "GET"),
header list, optional body, and timeout_ms (defaults to 30 000).
Build via Request::get(url) for the common case. Response carries
status, headers, raw body bytes, and final_url (which can differ
from the requested URL after redirects).
Current Env implementations
TauriEnv— native runtime backing for the Tauri desktop app (crates/jobcache/consumers wire it through the desktop runtime router).fetch_renderednot yet wired — see "Future capabilities" below.MockEnv— test-only impl at/home/richc/Documents/GitHub/careervector/crates/jobcache/src/test_env.rs. Used by every adapter crate's unit tests.- Node ingest runtime — future: a JS-fetch-backed
Envthat the WASM Component Model artifact will receive when loaded intojobcache/ingest.
FetchError taxonomy
pub enum FetchError {
Timeout,
Aborted,
Network(String),
RateLimited(u32),
BadStatus(u16),
Unavailable(String),
RobotsBlocked(String),
}
| Variant | When |
|---|---|
Timeout |
The runtime cancelled the request after request.timeout_ms. |
Aborted |
The runtime cancelled the request for any other reason (workspace closed, abort signal). |
Network(msg) |
Transport-level failure (DNS, TCP, TLS, mid-stream disconnect). |
RateLimited(secs) |
Source returned a 429 (or equivalent). The hint is "retry after N seconds"; the runtime decides whether to honor it. |
BadStatus(code) |
Source returned any non-2xx that isn't 429. |
Unavailable(capability) |
The runtime does not provide the requested capability. The canonical case is env.fetch_rendered(...) against a host without a headless-browser backend. Adapters propagate this so downstream code may decide to skip the source instead of treating it as a transport failure. |
RobotsBlocked(domain) |
The host runtime's robots.txt enforcement disallowed this fetch. Hard pre-check — no network for the URL request happens. Cached per-domain per process so subsequent requests against the same domain don't re-fetch robots.txt. Adapters that need to bypass (workspace owner explicitly added the URL) use Request::ignoring_robots(). Treat the same as Unavailable — skip the source cleanly, don't retry. |
Fetch policy
The Tauri desktop runtime's TauriEnv implements fetch and
fetch_rendered with FOUR layers, in this order:
- robots.txt (per-domain cached, hard pre-check). Disallowed →
FetchError::RobotsBlocked. Per-request opt-out viaRequest::ignoring_robots()is reserved for workspace-owner-consenting calls. - Per-host rate limit (governor GCRA bucket, default 30 req/min). Concurrent requests against the same host serialize on the bucket.
- Single attempt —
reqwest::Client::send()(or the hiddenWebviewWindowrender forfetch_rendered). The result propagates directly to the caller UNLESS the request opted into retry. - Opt-in retry loop — when the adapter built the request via
Request::get(url).with_retry(RetryPolicy { max_attempts, retry_on }), the single-attempt result feeds an exponential-backoff loop with ±25 % jitter.RateLimitedhonours the server's Retry-After.
Why one attempt by default
The default is ONE attempt + soft-fail because that's what years of
jobich production (Swiss + EU job-board scraping, parser/scrapers.py
in the jobich repo) say works. Hostile boards (LinkedIn, Indeed,
Glassdoor) account-flag on retry storms; multiple 429s in close
succession escalates to permanent bans. The polite-client shape —
rate limit + robots + soft-fail — beats aggressive retry every time on
this kind of source.
Per-source retry opt-in
A small set of adapters genuinely benefit from a bounded retry budget. Today these are:
| Adapter | Policy | Failure mode it protects against |
|---|---|---|
workday (all tenants) |
{ max_attempts: 2, retry_on: [ServerError] } |
Workday wd5 instances 502/503 on the jobReqId detail endpoint during vendor-side cache eviction. |
bamboohr (listing fetch) |
{ max_attempts: 2, retry_on: [ServerError] } |
Cold tenant subdomains 503 on first request as the careers JSON is rendered on demand from a cold cache. |
jobs-ch (listing pagination) |
{ max_attempts: 2, retry_on: [ServerError] } |
JobCloud's search-API tier 502s a single page under load. Per-page swallow remains the outer safety net. |
Adapters opt in via:
let req = jobcache::Request::get(url).with_retry(RetryPolicy {
max_attempts: 2,
retry_on: vec![RetryClass::ServerError],
});
let resp = env.fetch(req).await?;
RetryClass::ServerError is the safe default opt-in — covers 502/503/504
without touching the throttle classes (429, RateLimited) that escalate
to bans on hostile boards. Timeout / Network / RateLimited exist
for adapters with a documented failure mode they protect against
(specifically, a vendor-API tenant with measured intermittent network
glitches that one retry catches). Don't reach for them speculatively.
AdapterError taxonomy
pub enum AdapterError {
Fetch(FetchError), // #[from] FetchError
Parse(String),
Contract(String),
Other(String),
}
| Variant | When |
|---|---|
Fetch(e) |
Wraps a FetchError from env.fetch / env.fetch_rendered. Propagation is the common case — let resp = env.fetch(req).await?; lifts a FetchError into an AdapterError::Fetch. |
Parse(msg) |
The fetched body is well-typed transport but malformed at the source's own schema layer (broken JSON, unexpected XML shape, HTML structure the selector can't navigate). |
Contract(msg) |
The source returned something that violates an invariant the adapter expected (e.g. a posting with no id, a sitemap whose XML namespace is wrong). |
Other(msg) |
Anything else. Use sparingly. |
observe() returning Ok(None) is NOT an error: it means the URL turned
out to be irrelevant (expired, redirected, content missing) and the
runtime should drop it cleanly without raising a failure.
url_id contract (frozen)
crates/jobcache/src/url_id.rs is the byte-equivalent mirror of
lib/jobcache/src/ids.ts. Immutable contract — changing the rules
retroactively shifts every shared id and breaks dedup.
Public functions:
normalize_url(input) -> Result<String, UrlIdError>— parse, lowercase host, strip fragment, strip a fixed list of tracking params (utm_*,fbclid,gclid,mc_eid,mc_cid,_ga,_gl,ref,referrer,source), sort remaining params, trim a single trailing/from non-root paths, decode unreserved percent-escapes (and uppercase the hex of reserved ones per RFC 3986 §6.2.2.1).hash_text(input) -> Result<String, UrlIdError>— SHA-256, truncate to 10 bytes, RFC 4648 base32 lowercase no padding. 16 chars.hash_url(url) -> Result<String, UrlIdError>— normalize then hash.ad_id_from_url_language(url, language) -> Result<String, UrlIdError>—hash_text("ad:{normalized}\nlanguage:{lang}"). Splits by URL × language;language=Nonebecomes the"und"unknown-language bucket.role_id_from_url(url) -> Result<String, UrlIdError>—"role_{hash}"where the hash ishash_text("role:{normalized}"). Intentionally language-independent: ads split by URL × language, roles do not.job_id_from_url(url) -> Result<String, UrlIdError>—"job_{hash}"where the hash ishash_text("job:{normalized}"). The public workspace job id exposed in workspace APIs, routes, and Y.Doc keys. Intentionally NOT equal tohash_url(url)even though both run over the normalized URL.
Cross-language golden vectors are pinned in the test module at the
bottom of url_id.rs; any drift between the Rust and TypeScript impls
must keep those test vectors green.
SCHEMA_ID = "cv.jobcache"
pub const SCHEMA_ID: &str = "cv.jobcache";
pub const SCHEMA_VERSION: &str = "v0";
Defined in crates/jobcache/src/contract.rs. Every Observation carries
both. Frozen contract identifier — bump SCHEMA_VERSION to "v1" (etc.)
when an incompatible envelope shape ships.
Family catalog
All 33 currently-registered families. Tenant counts come from
/home/richc/Documents/GitHub/jobich/parser/sources.yaml (verified
2026-05-30). "Fetch mode" indicates which Env method the listing path
uses; detail pages may use a different mode (notes in the last column).
Wave 0 — original 19 (Rust port of the Python reference)
| Family id | Tenants | Method |
Fetch mode | Notes |
|---|---|---|---|---|
ashby |
8 | FeedApi |
plain | JSON API, full job in listing payload (observe re-fetches listing, filters by id). |
bamboohr |
3 | FeedApi |
plain | JSON API; tenant slug → public job board. |
breezyhr |
5 | FeedApi |
plain | Breezy HR JSON API. |
eightfold |
2 | FeedApi |
plain | Eightfold AI Talent Intelligence API. |
greenhouse |
19 | FeedApi |
plain | boards-api.greenhouse.io JSON API. |
homerun |
1 | HtmlBespoke |
plain | Server-rendered career page. |
icims |
1 | HtmlBespoke |
plain | Classic iCIMS careers-{slug}.icims.com. Listing parsed from HTML; detail page parsed via shared parse_schema_org_jobposting. CH location filter applied at listing time. |
jibe |
2 | FeedApi |
plain | Jibe (now part of iCIMS Talent Cloud) listing API. |
lever |
11 | FeedApi |
plain | api.lever.co/v0/postings/{slug} JSON API. |
oracle-hcm |
7 | FeedApi |
plain | Oracle Recruiting Cloud public REST. |
prospective |
7 | HtmlBespoke |
plain | Swiss prospective.ch board; all fields on listing page. |
recruitee |
1 | FeedApi |
plain | Recruitee public JSON. |
remoteok |
1 | FeedApi |
plain | RemoteOK public API. |
rss-feed |
4 | FeedApi |
plain | Generic RSS/Atom feed sources. |
sf-rmk |
4 | FeedApi |
plain | SuccessFactors RMK /services/recruiting/v1 JSON. Iterates per-locale, dedups by job id. |
smartrecruiters |
14 | FeedApi |
plain | SmartRecruiters public posting API. |
teamtailor |
7 | FeedApi |
plain | Teamtailor JSON API. |
workable-widget |
11 | FeedApi |
plain | Workable's embeddable widget JSON endpoint. |
workday |
34 | FeedApi |
plain | Workday WD3 search endpoint. Largest tenant set. |
Wave 1 — 9 static-HTML expansions
| Family id | Tenants | Method |
Fetch mode | Notes |
|---|---|---|---|---|
custom-html |
7 | HtmlBespoke |
plain | Catch-all for one-off bespoke scrapers (ETH Zurich, Helvetia, PSI, Hydromea, Page Executive, Helbling, H55). Per-tenant selectors live as data in sources::SOURCES. |
joincom |
11 | HtmlBespoke |
plain | join.com/companies/{slug} Next.js page; all jobs inline in <script id="__NEXT_DATA__">. |
personio |
6 | FeedApi |
plain | XML feed ({subdomain}.jobs.personio.{tld}/xml) with HTML fallback for tenants that disable the feed. Probes .com then .de. |
persoware |
6 | HtmlBespoke |
plain | Persoware / OnApply (CSS, Helsana, Sanitas, Concordia, AXA CH, Uni Basel). Detail pages use shared schema.org JSON-LD when present. |
refline |
5 | HtmlBespoke |
plain | Swiss apply.refline.ch. Dedups DE/EN variants of the same job_id, prefers EN. |
sf-html |
20 | HtmlBespoke |
plain | Legacy SuccessFactors server-rendered portal. Paginated via ?startrow=N; hard cap 20 pages. Detail pages delegate to shared schema.org parser when JSON-LD present. |
softgarden |
1 | HtmlBespoke |
plain | {slug}.softgarden.io server-rendered cards. Detail pages typically expose schema.org JSON-LD. |
umantis |
3 | HtmlBespoke |
plain | Abacus Umantis / Haufe Talent. CH-hint geography filter (mirrors Python reference) unless tenant is swiss_only. |
xiag-board |
2 | HtmlBespoke |
plain | xiag-platform Swiss regional boards (ostjob.ch, zentraljob.ch). All fields on listing page; observe re-walks the listing and matches by URL. |
Wave 2 — 5 JS-rendered (use env.fetch_rendered)
| Family id | Tenants | Method |
Fetch mode | Notes |
|---|---|---|---|---|
csod |
2 | HeadlessProxy |
rendered | Cornerstone OnDemand Angular SPA. JSON endpoint guarded by a session-bound JWT, so we drive the SPA and scrape the post-render requisition anchors. Both discover and observe use fetch_rendered. |
hibob |
3 | FeedApi |
plain | Exception: the user-facing site is an Angular SPA but the adapter found an undocumented but anonymous JSON endpoint at https://{tenant}.careers.hibob.com/api/job-ad that returns full job listings + descriptions in one call. Plain env.fetch is enough — Method::FeedApi, no rendered fetch required. |
phenom |
12 | HeadlessProxy |
rendered | Phenom Platform (BCG, Cisco, Allianz, Zimmer Biomet, Merck, ABB, Straumann, Givaudan, UCB, MSD, Thermo Fisher). Internal JSON API exists but is anti-CSRF-token-gated; we drive the Aurelia SPA. Detail pages embed schema.org JSON-LD → delegate to shared parser. |
playwright-html |
4 | HeadlessProxy |
rendered | Catch-all JS-rendered (Sensirion, CordenPharma, Goldman Sachs, Viseca). Detail pages tried with plain env.fetch first; falls back to rendered. |
sf-spa |
2 | HeadlessProxy |
rendered (listing) / plain-first (detail) | SuccessFactors v4 SPA (Pictet, UNIL). Listing requires fetch_rendered. Detail tries plain fetch first because many SF v4 tenants embed schema.org JSON-LD in the server shell for Google For Jobs — when present, the headless cost is skipped entirely; otherwise falls back to fetch_rendered. |
Wave 3 — 56 employer-bespoke + public boards + Yousty platform
Major public boards (11)
| Family id | Tenants | Method |
Fetch mode | Notes |
|---|---|---|---|---|
linkedin |
1 | HeadlessProxy |
rendered | Hostile; CH geofence via ?location=Switzerland&f_TPR=r604800. Strips ?refId=… tracking on canonicalise. |
xing |
1 | HeadlessProxy |
rendered | Hostile; Apollo window.crate.serverData.APOLLO_STATE preferred over DOM. DACH+CH/LI/FR/IT filter at Apollo layer. |
indeed |
1 | HeadlessProxy |
rendered | Hostile; ch.indeed.com host, start=N pagination. Canonicalises /rc/clk?jk= and /viewjob?jk= for dedup. |
glassdoor |
1 | HeadlessProxy |
rendered | Hostile; __NEXT_DATA__ preferred. CH location id IN226 baked into URLs. |
adzuna |
1 | HtmlBespoke |
plain | UK aggregator; w=switzerland geofence. Plain HTTP works. |
reed |
1 | HtmlBespoke |
plain | UK board; CH path; numeric-id detail-URL filter to skip search pages. |
totaljobs |
1 | HtmlBespoke |
plain | StepStone shell; hand-rolled brace-counter extracts window.__PRELOADED_STATE__. |
cwjobs |
1 | HtmlBespoke |
plain | Same StepStone shell, contractor-focused seed queries. |
weworkremotely |
1 | HtmlBespoke |
plain | Global remote (no geo filter); crawls 8 category surfaces. |
wttj |
1 | HtmlBespoke |
plain | Welcome to the Jungle; __NEXT_DATA__ reconstructs /en/companies/<org>/jobs/<role>. CH facet via URL refinement-list param. |
hackernews |
1 | FeedApi |
plain | Algolia API (hn.algolia.com/api/v1); 2-step search-then-fetch-comments. Parses Company | Title | Location | REMOTE first-line convention. |
Swiss job boards (13)
| Family id | Tenants | Method |
Fetch mode | Notes |
|---|---|---|---|---|
jobs-ch |
1 | FeedApi |
plain | JobCloud /api/v1/public/search; full-crawl variant on empty query. Largest Swiss board. |
jobup-ch |
1 | FeedApi |
plain | Same JobCloud backend, Romandy / French CH. |
jobwatch |
1 | HtmlBespoke |
plain | Watchmaking-focused; pipe-split text layout; date DD.MM.YYYY → ISO. |
jobscout24 |
1 | HtmlBespoke |
plain | ?location=switzerland URL-pinned filter. |
ictjobs-ch |
1 | FeedApi |
plain | WordPress REST /wp-json/wp/v2/posts; ACF aliases for location/company. |
ictcareer |
1 | HtmlBespoke |
plain | German short-date format (3T/2W/M/J); DSGVO boilerplate stripped. |
itjobs-ch |
1 | HtmlBespoke |
plain | Cloudflare-challenge gate returns empty cleanly; single-page (~50 jobs). |
swissdevjobs |
1 | FeedApi |
plain | Single /api/jobsLight JSON; no pagination. |
jobwinner |
1 | HtmlBespoke |
plain | JobCloud ecosystem; window.__INIT__ JSON preferred, anchor fallback. |
publicjobs |
1 | HeadlessProxy |
rendered (preferred) / plain (fallback) | SPA; numeric-prefix slug filter. |
ipersonal |
2 | HtmlBespoke |
plain | Two registered tenants behind one Adapter (:ipersonal, :medipersonal); 23 regions × pagination for the main board. |
stelle-admin |
2 | HeadlessProxy |
rendered | Swiss federal jobs portal; two language tenants :en and :de. |
eures |
1 | HeadlessProxy |
rendered | EU jobs portal; location=CH URL-level filter; jvId query param as source_id. |
Recruitment agencies (15)
| Family id | Tenants | Method |
Fetch mode | Notes |
|---|---|---|---|---|
hays |
1 | HtmlBespoke |
plain | hays.ch/en/jobsearch/job-offers; schema.org passthrough. |
randstad |
1 | HtmlBespoke |
plain | randstad.ch/en/jobs/?page=N; 5-page pagination. |
michaelpage |
1 | HtmlBespoke |
plain | EN-tagged listings. |
adecco |
1 | HtmlBespoke |
plain | adecco.com/en-ch/job-search?location=switzerland. |
manpower |
1 | HtmlBespoke |
plain | Category listings (technical-and-engineering, it-and-digital). |
experis |
1 | HtmlBespoke |
plain | Industry pinned IT; /pN pagination. |
roberthalf |
1 | HtmlBespoke |
plain | roberthalf.com/ch/en/jobs. |
swisslinx |
1 | HtmlBespoke |
plain | swisslinx.com/vacancies/. |
coopersgroup |
1 | FeedApi |
plain | Search-JSON endpoint; detail via refCode. |
huxley |
1 | HtmlBespoke |
plain | Industry pinned IT. |
rmgroup |
1 | HtmlBespoke |
plain | wp-admin paths filtered. |
morganphilips |
1 | HtmlBespoke |
plain | Parses inline "Posted on: DD/MM/YYYY" → ISO. |
darwinrecruitment |
1 | FeedApi |
plain | WordPress REST /wp-json/wp/v2/job-listings; Swiss-term filter. |
nexus |
1 | FeedApi |
plain | WordPress REST /shp_vacancy; ACF meta (jobtitel, arbeitsort_ort). |
divisions |
1 | HtmlBespoke |
plain | di-visions.ch (note: domain has hyphen); industry pinned IT. |
Single-employer JSON APIs (10)
| Family id | Tenants | Method |
Fetch mode | Notes |
|---|---|---|---|---|
glencore |
1 | FeedApi |
plain | Magnolia REST; offset+limit pagination (15/page); CH-localised. |
un |
1 | FeedApi |
plain | careers.un.org/api/site/jobOpening/latestJobOpening; 100/page; polymorphic dutyStation. |
alpha |
1 | FeedApi |
plain | Netiva job-board API; 1-indexed pagination; public URL alpha.ch/en/jobs/{id}. |
bcge |
1 | FeedApi |
plain | jobs.bcge.ch/api/offers; single-shot listing; always Geneva/CH. |
bankcler |
1 | FeedApi |
plain | Sitecore JSON; German-language; polymorphic link/location (string OR object). |
gavi |
1 | HtmlBespoke |
plain | Salesforce Sites Recruiting; HTML despite the URL shape (verified, not JSON). |
citi |
1 | HtmlBespoke |
plain | TalentBrew SEO HTML; a[data-job-id] cards. |
creditagricole |
1 | HtmlBespoke |
plain | HTML link-grep on nos-offres-emploi; pays=CHE filter best-effort. |
cargill |
1 | HtmlBespoke |
plain | TalentBrew listing HTML + per-job JSON-LD via shared schema.org parser. |
cbre |
1 | HtmlBespoke |
plain | Avature HTML; article.article--result cards. |
Specialized vertical boards (6)
| Family id | Tenants | Method |
Fetch mode | Notes |
|---|---|---|---|---|
reliefweb |
2 | FeedApi |
plain | Official REST API; CH + global sources; server-side country filter via filter[field]=country&filter[value]=Switzerland. |
hotelcareer |
3 | HtmlBespoke |
plain | DACH split: .ch/.de/.at tlds, each as separate tenant. |
hoteljob |
2 | HtmlBespoke |
plain | DE + CH; locale prefix gives language hint via JSON-LD inLanguage. |
europharmajobs |
2 | HtmlBespoke |
plain | CH + EU sources; a[href*='/job_display/'] anchor selector. |
devjobsscanner |
3 | FeedApi |
plain | RSS via feed-rs; global / CH / Rust-topic sources. |
wearedevelopers |
3 | FeedApi |
plain | EU + CH (client-side filter on location text) + Remote. |
Yousty platform (1 crate, 3 tenants)
The TS jobcache/ingest/src/portals/yousty.ts covers 3 Swiss regional
boards through a shared sitemap+JSON-LD abstraction. Ported as one
Rust crate:
| Family id | Tenants | Method |
Fetch mode | Notes |
|---|---|---|---|---|
yousty |
3 | SitemapJsonld |
plain | /sitemap.xml → /sitemap-vacancies.xml → per-vacancy <loc> entries, each detail page exposes schema.org JobPosting JSON-LD parsed via the shared parse_schema_org_jobposting helper. Tenants: yousty:ostjob (ostjob.ch), yousty:zentraljob (zentraljob.ch), yousty:jobs-nzz (jobs.nzz.ch). Overlaps xiag-board:ostjob and xiag-board:zentraljob — both registered; first-prefix-match in the Router. Deprecating xiag-board is a separate decision. |
custom-html extension (9 new tenants, total 16)
The existing custom-html crate's SOURCES table grew by 9 Swiss
employer one-offs: sbb, axpo, alpiq, bkw, migros, securitas, cern,
syzgroup, startupch. Each follows the same per-tenant selector pattern
already documented for the 7 original tenants.
How to add a new family
- Create the crate directory under
crates/jobcache-adapter-<name>/. The workspaceCargo.tomlalready has acrates/*glob, so a new directory is picked up automatically — no edit to the workspace manifest is required. - Implement
Adapterinsrc/lib.rs. ChooseMethodper the table above. If the source has more than one tenant, follow thepub struct <Family>Source { id, label, ... }+pub const SOURCES: &[<Family>Source] = &[...]pattern, and produce one<Family>Adapterper source. Bounddiscover()output (defineMAX_DISCOVERor an equivalent cap). - Add unit tests with
MockEnv:- For plain-fetch adapters, register canned bodies via
MockEnv::with_response(url, body)/with_json_response(url, body)/with_status(url, code)/with_redirect(url, final_url, body). - For JS-rendered adapters, ALSO register
with_rendered_response(url, body)— the fixture should be the HTML you'd see in the browser AFTER JavaScript has hydrated the SPA, not the raw server response.
- For plain-fetch adapters, register canned bodies via
- Expose
pub fn all_adapters() -> Vec<<Family>Adapter>— every crate ships this factory; the desktop router calls it at startup. - Wire into the desktop runtime at
/home/richc/Documents/GitHub/careervector/ui/desktop/src-tauri/src/runtime/router.rs: add apath = ".../jobcache-adapter-<name>"dep to that crate'sCargo.tomland append oneextend_with(&mut adapters, jobcache_adapter_<name>::all_adapters());line inRouter::default(). - Mirror in
jobcache/ingest/src/adapters/registry.tswhen the Node ingest broker is wired to call the WASM Component Model artifact.
Testing pattern
MockEnv (at crates/jobcache/src/test_env.rs) is the shared test
harness. Its builder methods all return Self so registrations chain:
let env = MockEnv::new()
.with_user_agent("jobcache-test/0.1")
.with_response("https://example.com/jobs", LISTING_HTML)
.with_json_response("https://example.com/api/jobs/42", DETAIL_JSON)
.with_redirect(
"https://example.com/jobs/42",
"https://example.com/jobs/42/final",
DETAIL_HTML,
)
.with_status("https://example.com/jobs/oops", 404)
.with_rendered_response("https://spa.example/career", POST_HYDRATION_HTML);
let urls = adapter.discover(&env).await.unwrap();
let obs = adapter.observe(&urls[0], &env, &ObservationOptions::default())
.await
.unwrap();
env.calls() and env.rendered_calls() return every URL each fetch
channel was invoked with, in order, so tests can assert e.g. that the
adapter prefers plain fetch and only falls back to fetch_rendered
when needed.
Worked examples to read before writing a new family:
crates/jobcache-adapter-lever/src/lib.rs— canonical JSON-API (Method::FeedApi) reference.crates/jobcache-adapter-icims/src/lib.rs— canonical HTML + schema.org reference (delegatesobservetoparse_schema_org_jobposting).crates/jobcache-adapter-sf-spa/src/lib.rs— canonicalMethod::HeadlessProxyreference, including the plain-first / rendered- fallback pattern for detail pages with optional JSON-LD.crates/jobcache-adapter-custom-html/src/lib.rs+crates/jobcache-adapter-playwright-html/src/lib.rs— per-tenant selector pattern (data-driven, oneAdapterimpl, N tenants).crates/jobcache-adapter-hibob/src/lib.rs— the "found a JSON endpoint" exception. Read before assuming a JS-rendered front-end forcesMethod::HeadlessProxy.
Future capabilities
TauriEnv::fetch_renderedbackend wiring — pending. Candidate backends: Browserbase, Firecrawl, local Playwright. Until one is wired, everyHeadlessProxyadapter surfacesFetchError::Unavailableon a desktop install and the runtime skips it.- Node ingest runtime providing
Env— the WASM Component Model loader insidejobcache/ingestwill pass in a JS-fetch-backedEnvso every adapter that compiles tocdylibruns there as well. - Multi-step rendered interactions —
fetch_renderedreturns one post-hydration page. Adapters that would benefit from "render, then click N times" (e.g. SF SPA pagination, CSOD pagination) currently harvest only the first rendered page. Extending the dock with a multi-step interaction primitive would unlock those tails; today it is intentionally out of scope.