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):

  1. Workday — ~27 enterprise tenants (Roche, Novartis, Julius Baer, Logitech, …). Tenant URL pattern; JSON-paginated.
  2. Greenhouse — ~20+ tenants (Stripe, Figma, On, Frontify, Proton, …). Open JSON API per company slug — the cleanest family to implement.
  3. SmartRecruiters — ~8+ tenants (Bosch, Vitol, Swissquote, Roland Berger, Syngenta). JSON API.
  4. Lever — ~10+ tenants (ANYbotics, Wingtra, Sonar, Neural Concept, …). JSON API.
  5. SuccessFactors (HTML) — ~20 tenants (ILO, ITU, ICRC, Rolex, Octapharma, SAP, Nestle, Sonova, Zurich, Helvetia, …). HTML scrape, JSON-LD on detail.
  6. SuccessFactors (SPA / RMK) — Pictet, Breitling, PostFinance. JS-rendered; __NEXT_DATA__ cache parsing avoids Browserbase on most.
  7. Oracle HCM — IOM, UBP, EFG, JP Morgan, ELCA. JSON.
  8. Phenom — large enterprise SPAs; embedded-state parsing first, JS fallback.
  9. Personio, Teamtailor (RSS), Ashby, Hibob, Workable, Recruitee — each adds 5–10 tenants.
  10. 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 via available(), 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: Observation from @cv/jobcache/contract, validated by ObservationSchema. 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, while roleIdFromUrl(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.

Source: jobcache/wiki/content/architecture/jobich-gap-analysis.md