santifer/career-ops · warning

rheinmetall: page 1 returned no vacancy cards for

Error message

rheinmetall: page 1 returned no vacancy cards for ${entry.name} — markup may have changed

What it means

The Rheinmetall provider paginates an HTML careers list and parses vacancy cards from each page. When page 1 yields zero parsed rows, it warns that the site's markup may have changed (the parse selectors no longer match) and stops paginating, since page 1 empty means the parser is broken rather than past the last page.

Solutions

  1. Open `${origin}${listUrl}` in a browser and inspect the vacancy card markup; update parseVacancies selectors to match the new classes/structure.
  2. Check with curl whether the page requires JS rendering or a bot challenge; if so, switch the provider to a browser-based fetch or the underlying API if one exists.
  3. Confirm the locale/region variant of the URL still serves the same template.
  4. Re-run the scan after fixing to verify rows are parsed again.

Example fix

// before
const rows = html.querySelectorAll('.job-listing__card'); // old class
// after
const rows = html.querySelectorAll('article.vacancy-card'); // new markup
Defensive patterns

Strategy: retry

Validate before calling

const probe = await ctx.fetchText(listUrl, { headers: { accept: 'text/html' } });
if (!probe.includes('vacancy') && !probe.includes('job')) console.warn('page markup unknown — parser likely stale');

Try / catch

const rows = parseVacancies(html, origin);
if (rows.length === 0 && page === 1) {
  console.warn('page 1 returned no vacancy cards — markup may have changed');
  return; // stop instead of paging a broken parse
}

Prevention

When it happens

Trigger: fetch() fetching `${listUrl}?page=1` returns HTML in which parseVacancies finds no card elements — a CSS/class rename on the careers site, a JS-rendered list replacing server-rendered cards, a bot-challenge page, or an error page returned with 200.

Common situations: Rheinmetall redesigns its careers portal; a CDN/WAF starts serving a challenge page; region/locale variant of the listUrl renders different markup; scan.mjs run after a long gap.

Related errors


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

Appendix: source

Thrown at providers/rheinmetall.mjs:129

  async fetch(entry, ctx) {
    const listUrl = resolveListUrl(entry);
    if (!listUrl) throw new Error(`rheinmetall: cannot resolve vacancies URL for ${entry.name}`);
    const origin = new URL(listUrl).origin;

    const wait = (ms) => (ctx.sleep ? ctx.sleep(ms) : new Promise((r) => setTimeout(r, ms)));
    const maxPages = resolveMaxPages(entry);
    const jobs = [];
    const seen = new Set();

    for (let page = 1; page <= maxPages; page++) {
      if (page > 1) await wait(PAGE_DELAY_MS);
      const html = await ctx.fetchText(`${listUrl}?page=${page}`, {
        headers: { accept: 'text/html' },
      });
      const rows = parseVacancies(html, origin);
      if (rows.length === 0) {
        if (page === 1) console.warn(`rheinmetall: page 1 returned no vacancy cards for ${entry.name} — markup may have changed`);
        break; // past the last page
      }

      let fresh = 0;
      for (const row of rows) {
        if (seen.has(row.id)) continue;
        seen.add(row.id);
        fresh++;
        jobs.push({ title: row.title, url: row.url, company: entry.name, location: row.location });
      }
      // No new ids → the server clamped ?page= to the last page (or looped). Stop.
      if (fresh === 0) break;
      if (jobs.length >= MAX_JOBS) break;
    }
    return jobs.slice(0, MAX_JOBS);
  },
};

View on GitHub (pinned to aac998c7ed)