santifer/career-ops · error · Error

joinup: ${entry.name} page is missing __NEXT_DATA__ (structu

Error message

joinup: ${entry.name} page is missing __NEXT_DATA__ (structure changed?)

What it means

Thrown by joinup.fetch() when the fetched joinup.ch browse page HTML has no <script id="__NEXT_DATA__"> tag matching the provider's regex. Like the join.com provider, joinup scrapes server-rendered Next.js data; a missing tag is treated as a scraper break (board redesign, error page, or bot challenge) and fails closed so the scan logs it instead of silently reporting zero jobs.

Source

Thrown at providers/joinup.mjs:52

/** @type {Provider} */
export default {
  id: 'joinup',

  detect(entry) {
    let host;
    try { host = new URL(entry.careers_url || '').hostname; } catch { return null; }
    return /(^|\.)joinup\.ch$/i.test(host) ? { url: BROWSE_URL } : null;
  },

  async fetch(entry, ctx) {
    // redirect:'error' — BROWSE_URL is pinned to joinup.ch (https); a 3xx must
    // not be followed to a private/metadata IP (matches every other provider).
    const html = await ctx.fetchText(BROWSE_URL, { redirect: 'error' });
    const m = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
    // Fail closed: a missing/unparseable __NEXT_DATA__ is a scraper break, not an
    // empty board — throw so the scan logs it instead of silently reporting zero.
    if (!m) throw new Error(`joinup: ${entry.name} page is missing __NEXT_DATA__ (structure changed?)`);
    let hits = [];
    try {
      const data = JSON.parse(m[1]);
      const ir = data?.props?.pageProps?.serverState?.initialResults?.jobs?.results;
      hits = Array.isArray(ir) && ir[0]?.hits ? ir[0].hits : [];
    } catch (err) {
      throw new Error(`joinup: ${entry.name} failed to parse __NEXT_DATA__ — ${err.message}`);
    }
    return hits
      .filter(h => h && h.slug && (h.title || h.headline))
      .map(h => ({
        title: h.title || h.headline || '',
        url: `https://joinup.ch/job/${h.slug}`,
        company: h.startup || entry.name || '',
        location: typeof h.location === 'string' ? h.location
          : (h.location?.name || h.location?.city || ''),
        postedAt: toEpochMs(h.created),
      }));

View on GitHub (pinned to 9b17a8ac97)

Solutions

  1. Open the BROWSE_URL in a browser and confirm a __NEXT_DATA__ script tag is present.
  2. If the page is a bot challenge, slow down scans or route through a residential context.
  3. If joinup.ch redesigned, update the regex / data-access path in providers/joinup.mjs to match the new structure.
Defensive patterns

Strategy: try-catch

Validate before calling

async function joinupHasNextData(fetchText, browseUrl) {
  const html = await fetchText(browseUrl, { redirect: 'error' });
  return /<script id="__NEXT_DATA__"[^>]*>[\s\S]*?<\/script>/.test(html);
}

Try / catch

try {
  jobs = await provider.fetch(entry, ctx);
} catch (err) {
  if (/missing __NEXT_DATA__/.test(err.message)) {
    console.error(`joinup ${entry.name}: board missing __NEXT_DATA__ — redesign or block`);
  }
  throw err;
}

Prevention

When it happens

Trigger: joinup.ch redesigned its page and removed or renamed the __NEXT_DATA__ script; the request returned a non-board HTML (403/429/500, Cloudflare interstitial, maintenance page); the BROWSE_URL constant is stale and points at a retired route.

Common situations: joinup.ch shipped a frontend change; rate-limiting or geo-blocking returned a challenge page instead of the board; the site moved to a different rendering strategy (no SSR Next.js).

Related errors


AI-assisted analysis of santifer/career-ops@9b17a8ac97 (2026-08-13). Data as JSON: /api/errors/8251eda324c64758. Report an issue: GitHub.