github Fighter90/career-ops-ui v1.237.0

3 hours ago

[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. parseJobstreetItem built 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 added www.seek.com.au, www.seek.co.nz, sg.jobstreet.com, my.jobstreet.com and hk.jobsdb.com to ALLOWED_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 asserted hk.jobsdb.com/id/job/… and www.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: TotalJobsCount 454, 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 until offset + 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). hasMore remains deliberately ignored — some tenants return hasMore:false on every page of a 7000-job board. With short pages no longer ending the walk, MAX_PAGES is 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 common role and position phrasings; 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: \b alone rejects closedown but still matches real prose like "This role is closed-loop control of the platform", and the lookahead is what stops it.
  • Liveness — job expired now sits in a soft tier that cannot beat a visible Apply button. HARD_EXPIRED_PATTERNS is checked before the apply-control test, so anything placed there wins over a live posting. /\bjob expired\b/i false-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 in SOFT_EXPIRED_PATTERNS, checked after the apply control and before the listing-page heuristic, returning expired_body_soft; a bare "JOB EXPIRED" banner with no apply control still expires. This direction matters because a false expired is written to scan history as skipped_expired and then dedup-filters a real job out of every later scan, indefinitely, unless scan_history.recheck_after_days is set.

Added

  • Personio — an explicit personio: <slug> pins the tenant. Many companies embed their Personio board as an iframe on a branded careers page, so careers_url points 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 existing new URL() parse, HTTPS check and PERSONIO_HOST_RE host 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's api/careers_url behaviour. Unlike the parent, web-ui's host regex is case-sensitive, so the pin is normalised through new URL()AcmeGroup resolves to acmegroup.jobs.personio.de rather than building a host its own allowlist would then reject.
  • Jobstreet — opt-in appendWorkType suffixes 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 empty workTypes is 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 on telegram-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 has telegram configured. Revisit only as a deliberate, announced removal.
  • jd-skill-gap.mjs gained 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.mjs and the scaffolder (web-ui neither mirrors nor shells into them); the Go dashboard TUI and web/ config form (separate surfaces); doctor.mjs and analyze-patterns.mjs (read-only relays whose fail-soft absorbs the change).
  • The fork's Cyrillic \p{L} divergence in providers/telegram-channel.mjs survived the upstream merge intact and was verified byte-for-byte against a pre-merge copy.

Don't miss a new career-ops-ui release

NewReleases is sending notifications on new releases.