Jobich Gap Analysis
This is a behavioural study, not a code reference. Jobich is the proven Swiss
job-aggregation reference; we have no licence to its code. Every observation
below is described in our vocabulary (Adapter, Observation, Env,
Method, family, source, binding) so we can act on it without
re-opening Jobich source files.
Coverage gap
| JobCache (today) | Jobich (reference) | |
|---|---|---|
| Distinct sources shipping | 5 direct Adapters | ~180 |
| Source families | 3 (jobs-ch, jobup-ch, Yousty factory -> 3 sources) |
23+ ATS families + boards |
| Multi-tenant factory pattern in code | yes (Yousty factory builds ostjob-ch / zentraljob-ch / nzz-jobs from one source file) | core scaling lever for ATS families |
| Hostile-source coverage | jobs.ch via public __INIT__ listing + JSON-LD detail (no JS, no Browserbase) |
LinkedIn, Xing, Indeed, jobs.ch, Phenom SPAs |
| Browserbase / Firecrawl cascade | exists in scrapers/; the device runtime can back browser-capable Env.fetch when Adapter method requires it |
n/a — Jobich has nothing equivalent |
Direct Adapters: jobs-ch, jobup-ch, ostjob-ch, zentraljob-ch,
nzz-jobs. The three Yousty-platform boards share one factory in
adapters/yousty.ts.
Families we need (priority-ranked)
Drawn from Jobich's actual coverage, ranked by volume per Adapter:
Tier A — multi-tenant ATS families (one Adapter factory, many sources):
- Workday — ~27 enterprise tenants (Roche, Novartis, Julius Baer, Logitech, …). Tenant URL pattern; JSON-paginated.
- Greenhouse — ~20+ tenants (Stripe, Figma, On, Frontify, Proton, …). Open JSON API per company slug — the cleanest family to implement.
- SmartRecruiters — ~8+ tenants (Bosch, Vitol, Swissquote, Roland Berger, Syngenta). JSON API.
- Lever — ~10+ tenants (ANYbotics, Wingtra, Sonar, Neural Concept, …). JSON API.
- SuccessFactors (HTML) — ~20 tenants (ILO, ITU, ICRC, Rolex, Octapharma, SAP, Nestle, Sonova, Zurich, Helvetia, …). HTML scrape, JSON-LD on detail.
- SuccessFactors (SPA / RMK) — Pictet, Breitling, PostFinance. JS-rendered;
__NEXT_DATA__cache parsing avoids Browserbase on most. - Oracle HCM — IOM, UBP, EFG, JP Morgan, ELCA. JSON.
- Phenom — large enterprise SPAs; embedded-state parsing first, JS fallback.
- Personio, Teamtailor (RSS), Ashby, Hibob, Workable, Recruitee — each adds 5–10 tenants.
- Refline, Prospective, Softgarden, Umantis — Swiss-specific ATS, smaller fanout but high CH relevance.
Tier B — search-driven general boards (single source per family, high hostility):
- LinkedIn (public listings only — auth-walled detail pages are out of scope by contract).
- jobup.ch, Indeed, Xing, jobwatch, Stellenanzeiger, Adzuna, Reed.
Tier C — Swiss employer career pages, RSS feeds, niche boards:
- Migros, Axpo, Alpiq, BKW, SBB, Sika, CERN, ETH Zurich, EPFL, Swiss TPH, PSI, EMPA, Eawag, myScience.
- RSS family: Climeworks, Deloitte CH, Leonteq, myScience — one generic RSS Adapter.
- HotelCareer, HotelJob, EuroPharmajobs, Reliefweb (UN/NGOs), SwissDevJobs, ICTCareer.
End state: every Jobich-covered source has a corresponding (family, source, binding) entry in JobCache.
Patterns to adopt
1. Sources-as-config (not sources-as-code)
Jobich's biggest scaling lever: adding a new Workday tenant is a YAML edit, not
a Python module. JobCache's equivalent: a TypeScript per-source config consumed
by a family factory that returns N Adapter instances, each with a distinct
id (greenhouse:stripe, greenhouse:figma, …) and pinned source_id. The
Yousty factory in adapters/yousty.ts is the existing precedent for this
shape; new family adapters use the same factory pattern and emit Observations
directly.
2. JSON-LD first, HTML last
Many ATS families expose JobPosting JSON-LD on detail pages. Parse JSON-LD
before falling back to selector-based HTML. adapters/schema-org-jobposting.ts
already wraps json-ld.ts and emits a full Observation; every JSON-LD-capable
direct Adapter should delegate observe() to it (jobs-ch does already).
3. Embedded JSON before headless
Many SPA boards hydrate from <script id="__NEXT_DATA__">,
<script type="application/json">, or window.__APOLLO_STATE__. Parsing
those is faster, more deterministic, and free of Browserbase quota. jobs-ch
already parses window.__INIT__ for listing discovery — same pattern works
for SF SPA, Phenom, Xing. Reserve JS-rendering capability (Browserbase /
Firecrawl via the Router) for sites that genuinely need it.
4. Query × location fanning for search-driven boards
For general boards (LinkedIn, Indeed, jobs.ch), discovery loops over a known set of role-keyword queries (~40 EN + ~20 DE) × Swiss city/canton list. This is how Jobich gets broad coverage without a sitemap. The query and city lists belong in shared catalog files consumed by every Adapter that needs them, not inside each Adapter.
5. Multi-stage post-extract enrichment
Jobich runs canton/region inference, language detection, industry
classification, work-mode/employment-type normalization, role/function
mapping, and salary parsing as separate post-scrape passes. JobCache's
shared Observation contract supports this: Adapter Observations carry only what the
source page directly provides; later enrichment passes (Codex side, separate
task class) attach additional annotations and observed fields against the
same ad_id. Adapters must not collapse the two into one pass.
6. Two-level deduplication
URL x language ad identity is necessary but not sufficient — the same role lands
on multiple boards with different URLs. Jobich computes a semantic hash from
(title.lower().alnum, company.lower().alnum) to cluster cross-source
duplicates. JobCache uses adIdFromUrlLanguage(url, language) for ad identity
and roleIdFromUrl(url) for the URL-family role above those ads. Cross-source
semantic role reduction is deferred async work over consistent title +
organization Observation fields; Adapters' job is to emit those fields in a
normalized form.
7. Staleness tracking via observation windows
scraped_at (first-seen) + last_seen_at (most-recent) + 60-day stale
threshold = jobs that disappear get marked inactive without losing history.
In our model, Adapters emit observed_at correctly on each field; the
observation application path (Codex) flips an ad's lifecycle cell when
no fresh Observations land within the window. Adapter-side concern: emit
observed_at.
8. Per-source kill switch
A DISABLED_SCRAPERS env var + a JSON file lets ops disable a broken source
without redeploy. JobCache equivalent: binding-level "paused" flag on the
catalog row, surfaced through ops/qa. Adapter-side: nothing — the runtime
simply stops leasing tasks for paused bindings.
9. Defunct-source tracking
Companies migrate ATS (~12 documented Workday departures alone). The per-family source list should mark defunct sources with a date + reason instead of deleting them, so the quarterly re-audit knows what to re-check.
10. Non-CH location blacklist
Many international boards leak DE/AT/FR roles into CH searches. A city-name
blacklist filters at discovery time, cheaper than per-job geocoding. Belongs
in a shared locations catalog consumed by every search-driven Adapter.
11. Incremental scrape decoupling — already better
Jobich's "scrape-to-JSON-then-upload" pattern decouples network conditions
from DB writes. JobCache's Observation-submission path already does this
differently and better: Adapters emit Observations; submitObservation writes the DB;
if the broker is down, Observations queue at the device. Do not re-implement
Jobich's two-phase pattern.
Patterns to reject
1. No bare time.sleep rate limiter
Jobich uses a flat 1.5s-per-domain sleep. scrapers/router.ts already
implements per-(scraper, host) spacing, 429 cooldown with exponential
backoff (30s → 30min), and a 6-attempt ceiling. Keep that for hostile-source
Adapters (Adapters with method: "headless-proxy" whose Env.fetch is
runtime-routed through the cascade); never regress to flat sleeps.
2. No flat vacancy/posting shape
Jobich stores everything as a single dict (title, company, location, url, …)
and dedups by URL. JobCache: every public ad is one Observation anchored
to one ad_id, with fields stored as Record<FieldKey, ObservedField> and
evidence refs proving every claim. Adapters MUST emit Observations; the old
Adapter/posting projection is gone.
3. No proxy rotation
Jobich has none. JobCache's Router cascade (Raw → Browserbase → Firecrawl)
ladders on capability (JS, anti-bot family) rather than on IP-rotation
alone. Adapters that need JS / anti-bot evasion declare
method: "headless-proxy" and the device runtime injects a cascade-backed
Env.fetch. Proxies are only justified if a specific source proves they're
the missing piece.
4. No raw HTML in hot SQL
Jobich keeps only the cleaned description text. Our contract demands online
S3-compatible cold evidence for retained raw HTML/JSON via object_refs;
hot rows store the content hash + object key. Adapters never write the
blob themselves — they declare the object ref the device runtime produced.
Where JobCache is already ahead
- Router cascade with cheapest-first execution (
scrapers/router.ts): capability-gated, quota-aware viaavailable(), host-spaced, 429-backed off. No Jobich equivalent. - Browserbase tier inside the cascade: 60 browser-minutes/month flat free tier absorbs hostile-board JS rendering at zero per-call cost; Firecrawl picks up the tail.
- S3-compatible cold evidence: replaceable raw storage by
object_refs; Jobich keeps none, so it can't re-extract or audit. - One canonical submitted shape:
Observationfrom@cv/jobcache/contract, validated byObservationSchema. Jobich's dict-shape is impossible to validate. - Frozen URL x language identity:
adIdFromUrlLanguage(url, language)from@cv/jobcache/ids— every caller shares the same ad identity contract, whileroleIdFromUrl(url)groups language-specific ads under the URL-family role. - Cockroach observation path: Observation →
submitObservation→ current current ad payload cells, device trust, and follow-up verification tasks. Jobich writes directly to Postgres; no explicit device trust loop.
Net gap summary
| Concern | JobCache | Action |
|---|---|---|
| Source coverage | 5 sources shipping | reach ~180 via family Adapter factories |
| Multi-tenant family Adapter pattern | precedent exists (Yousty factory) | replicate for Workday, Greenhouse, SmartRecruiters, Lever, SF, Oracle, Phenom, Personio, Ashby, Hibob, Workable, Recruitee, RSS, Refline, Prospective, Softgarden, Umantis, Teamtailor |
| Source catalog | per-family list in TypeScript next to the factory (precedent: Yousty's three boards) | continue the pattern; defer Cockroach catalog until binding count justifies it |
| JSON-LD reuse | adapters/schema-org-jobposting.ts exists, jobs-ch uses it |
every JSON-LD-capable Adapter delegates observe() to it |
| Embedded-JSON parsing | jobs-ch parses __INIT__ ad hoc |
extract __NEXT_DATA__ / <script type="application/json"> / Apollo-state helpers into a shared adapters/lib/ module |
| Query × city fanning | absent | shared adapters/lib/queries.ts + locations.ts consumed by search-driven Adapters |
| Post-extract enrichment | Codex side | Adapter must not enrich — flag if a slice tries to |
| Two-level dedup | ad_id is URL x language; role id is URL-family |
Adapter side: normalize title + organization consistently; async semantic reduction is Codex |
| Staleness tracking | Adapter emits observed_at correctly |
window logic is Codex |
| Per-source kill switch | env-var per scraper today | binding-level pause is Codex |
| Defunct registry | absent | per-family source list field |
| Non-CH blacklist | absent | shared locations catalog |
| Cold evidence | contract supports object_refs; Adapters don't emit them yet |
Adapters declare object refs the device runtime produced (presigned upload hooks land later — Codex) |
Closing this gap is the work the slice plan schedules. See adapter-slice-plan for ordering and adapter-code-inventory for what is kept, replaced, or retired along the way.