[1.237.0] — 2026-09-21
Parent parity — career-ops main @ 93c4302b (VERSION 1.33.0, 75 commits pulled from career-ops-hq/main). No new sources this time: four mirrored fixes, and two of them were losing postings here silently.
Fixed
- Jobstreet / SEEK — every Australian, New Zealand, Singapore, Malaysian and Hong Kong posting linked to a dead page.
parseJobstreetItembuilt the detail URL as${baseUrl}/id/job/${id}unconditionally. The/id/segment is not a path — it is the Indonesian locale prefix, and it only exists on the Indonesian hosts. Every other SEEK-platform host answers 404 on/id/job/<id>. web-ui had already addedwww.seek.com.au,www.seek.co.nz,sg.jobstreet.com,my.jobstreet.comandhk.jobsdb.comtoALLOWED_JOBSTREET_HOSTS, so the scan happily returned those postings — each one pointing at a 404. The path is now keyed on the host:ID_LOCALE_HOSTS(id.jobstreet.com,www.jobstreet.co.id,jobstreet.co.id) keeps/id/job/, everything else gets/job/, and an unparseable origin falls through to the common path instead of throwing. Two of our own tests had encoded the bug — they assertedhk.jobsdb.com/id/job/…andwww.jobstreet.com/id/job/…— and were corrected. - Oracle Cloud — a short page mid-list ended the walk and dropped the tail of the board. The stop condition was
listLen === 0 || listLen < PAGE_SIZE, but ORC serves fewer rows than the limit in the middle of a list because some rows are filtered server-side. Measured upstream on American Express:TotalJobsCount454, pages of 200 / 199 / 54 — the 199 was read as the end and the last 54 postings, 12% of the board, were never fetched. Now an empty page always ends the walk; when the tenant reports a total, paging continues untiloffset + PAGE_SIZE >= total; only a tenant with no total to check against ends on a short page. This matches the wider convention that an API "may return fewer results than the number requested … even if not at the end of the collection" (Google AIP-158).hasMoreremains deliberately ignored — some tenants returnhasMore:falseon every page of a 7000-job board. With short pages no longer ending the walk,MAX_PAGESis now the only backstop against a tenant reporting a bogus total, so it is covered by its own test. - Liveness — "This role is closed" was not recognised, and a false expiry is permanent. The pattern was
/this job (listing )?is closed/i, which misses the equally commonroleandpositionphrasings; on one board upstream measured 111 of 111 uncertain postings using "role". Widened to/this (?:job|role|position)(?: listing)? is closed\b(?!-)/i. The trailing\b(?!-)is a compound-adjective guard:\balone rejectsclosedownbut still matches real prose like "This role is closed-loop control of the platform", and the lookahead is what stops it. - Liveness —
job expirednow sits in a soft tier that cannot beat a visible Apply button.HARD_EXPIRED_PATTERNSis checked before the apply-control test, so anything placed there wins over a live posting./\bjob expired\b/ifalse-fires on four real live-page shapes — a "Similar jobs" carousel entry, a "Hide job expired" filter chip, a footer FAQ, and the closed-loop prose above — because the classifier receives the whole page's text. It now lives inSOFT_EXPIRED_PATTERNS, checked after the apply control and before the listing-page heuristic, returningexpired_body_soft; a bare "JOB EXPIRED" banner with no apply control still expires. This direction matters because a falseexpiredis written to scan history asskipped_expiredand then dedup-filters a real job out of every later scan, indefinitely, unlessscan_history.recheck_after_daysis set.
Added
- Personio — an explicit
personio: <slug>pins the tenant. Many companies embed their Personio board as an iframe on a branded careers page, socareers_urlpoints at the company's own domain while the feed lives at<slug>.jobs.personio.de; without a pin those boards resolve to nothing. The slug is charset-restricted by an anchored allowlist (/^[a-z0-9][a-z0-9-]{0,62}$/i) and the resulting URL is still routed through the existingnew URL()parse, HTTPS check andPERSONIO_HOST_REhost gate — the pin is never a way to reach another host. A slug carrying.,/,@,:,.., whitespace, a leading-, or exceeding the 63-character DNS label limit falls through to today'sapi/careers_urlbehaviour. Unlike the parent, web-ui's host regex is case-sensitive, so the pin is normalised throughnew URL()—AcmeGroupresolves toacmegroup.jobs.personio.derather than building a host its own allowlist would then reject. - Jobstreet — opt-in
appendWorkTypesuffixes the title with the listing's work types, e.g.Strategy Consultant [Part time], so title filters and triage can see employment type at scan time. Off by default; a missing or emptyworkTypesis a no-op.
Notes
- Source and adapter counts are unchanged: 94 sources (89 EN + 5 RU), 89 EN adapters. This release moves only the test count, 3164 → 3201 (+13 jobstreet, +7 Oracle Cloud, +9 liveness, +8 Personio).
- The parent deleted
providers/telegram.mjs, consolidating ontelegram-channel. web-ui registers both as user-facing sources, so the deletion is not followed: it would drop the registry from 94 to 93 and silently break anyone who hastelegramconfigured. Revisit only as a deliberate, announced removal. jd-skill-gap.mjsgained an uncontracted "what we are looking for" heading form and a bold-marker strip. web-ui relays that script rather than mirroring it, so the fix arrives with the deployed parent and needs no change here.- Not ported, with reasons:
lib/latex-escape.mjs,browser-extract.mjs,openai-eval.mjs,update-system.mjsand the scaffolder (web-ui neither mirrors nor shells into them); the Go dashboard TUI andweb/config form (separate surfaces);doctor.mjsandanalyze-patterns.mjs(read-only relays whose fail-soft absorbs the change). - The fork's Cyrillic
\p{L}divergence inproviders/telegram-channel.mjssurvived the upstream merge intact and was verified byte-for-byte against a pre-merge copy.