santifer/career-ops · error · Error

joinup: page is missing __NEXT_DATA__ (structure changed?)

Error message

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

What it means

The joinup.ch provider throws this when the fetched browse page (https://joinup.ch/browse/jobs) contains no <script id="__NEXT_DATA__"> element at all. joinup.ch server-renders its newest job results into that script; without it there is nothing to parse. Per the code's stated policy, a missing __NEXT_DATA__ is treated as a scraper break, not an empty board, so the provider throws instead of returning zero results.

Solutions

  1. Fetch https://joinup.ch/browse/jobs manually (curl or browser) and check whether <script id="__NEXT_DATA__"> is still present; if it is gone, the site changed framework and providers/joinup.mjs must be rewritten for the new payload (e.g. self.__next_f flight data)
  2. Inspect the fetched HTML for Cloudflare/bot-challenge markers — if blocked, use a browser-based fetcher or rotate user-agent for this provider
  3. Retry later if joinup.ch is briefly down (maintenance page); add retry/backoff around ctx.fetchText
  4. Loosen the regex to match the script regardless of attribute order/extra attributes if the markup changed but the payload remains
  5. Confirm no proxy/CDN in your environment is rewriting or minifying the response HTML

Example fix

// before: exact attribute-form match only
const m = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
if (!m) throw new Error(`joinup: ${entry.name} page is missing __NEXT_DATA__ (structure changed?)`);
// after: tolerate attribute order / extra attributes
const m = html.match(/<script[^>]+__NEXT_DATA__[^>]*>([\s\S]*?)<\/script>/)
  || html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
if (!m) throw new Error(`joinup: ${entry.name} page is missing __NEXT_DATA__ (structure changed?)`);
Defensive patterns

Strategy: validation

Validate before calling

function joinupPageLooksParseable(html) {
  return typeof html === 'string'
    && html.includes('<script id="__NEXT_DATA__"')
    && !/cf-browser-verification|just a moment|attention required/i.test(html);
}
// before trusting fetchText output: if (!joinupPageLooksParseable(html)) retry/flag;

Type guard

function hasNextDataScript(html) {
  return typeof html === 'string'
    && /<script[^>]+__NEXT_DATA__[^>]*>[\s\S]+<\/script>/.test(html);
}

Try / catch

try {
  const hits = fetchJoinupJobs();
} catch (err) {
  if (String(err.message).includes('missing __NEXT_DATA__')) {
    console.error('joinup.ch page had no __NEXT_DATA__ — bot challenge, downtime, or framework migration; retrying later');
    return [];
  }
  throw err;
}

Prevention

When it happens

Trigger: Raised in fetch() immediately after ctx.fetchText when the regex /<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/ finds no match: the page is a bot-challenge/CAPTCHA or consent interstitial, the response is an HTML error page, joinup.ch migrated off Next.js pages-router __NEXT_DATA__ (e.g. to the App Router's flight payload), or a redirect/CDN transformed the markup so the id attribute no longer appears verbatim.

Common situations: joinup.ch ships a front-end rewrite (Next.js app router drops the classic __NEXT_DATA__ script); the scanner's user-agent is blocked and it receives a challenge page with HTTP 200; joinup.ch is down and the load balancer returns a maintenance HTML page; the site starts injecting the script with attributes ordered so the exact id="__NEXT_DATA__" pattern still matches but content is async-filled client-side.

Related errors


AI-assisted analysis of santifer/career-ops@aac998c7ed (2026-09-16). Data as JSON: /api/errors/8251eda324c64758. Report an issue: GitHub.

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