santifer/career-ops · error · Error

generalist-world: no gw-job-card anchors and no listing…

Error message

generalist-world: no gw-job-card anchors and no listing container in the response; the page structure likely changed

What it means

parseGeneralistWorldJobs throws when the rendered page contains neither gw-job-card anchors nor the board listing container (BOARD_MARKER_RE). A page with the container but no cards is an alive-but-empty board (returns []); a page with neither means the page structure changed or the response is not the board at all. This prevents a redesign or a challenge page from quietly reading as zero postings forever.

Solutions

  1. Inspect the HTML actually fetched from LIST_URL; identify whether it is a challenge/error page or a redesigned board
  2. If redesigned, update BOARD_MARKER_RE and CARDS_RE in providers/generalist-world.mjs to the new container and card selectors
  3. Retry the request — a transient challenge may resolve with different headers or after a delay
  4. If the board permanently moved URL/template, update LIST_URL or the provider wiring

Example fix

// before: stale marker misses new template
const BOARD_MARKER_RE = /id="job-listing"/;
// after: match the redesigned container
const BOARD_MARKER_RE = /<div class="board[^"]*jobs?[^"]*"/i;
Defensive patterns

Strategy: fallback

Validate before calling

function looksLikeGwBoardOrEmpty(html) {
  const s = String(html ?? '');
  return /gw-job-card/.test(s) || /job-listing|board/.test(s) || !s.trim();
}

Type guard

function isNonBoardHtml(html) {
  const s = String(html ?? '');
  return s.trim().length > 0 && !/gw-job-card/.test(s) && !/job-listing|board/.test(s);
}

Try / catch

try {
  const jobs = await gwProvider.fetch(entry, ctx);
} catch (e) {
  if (e.message.includes('no listing container')) {
    // retry once (transient challenge/error page), then alert on structure change
    const jobs2 = await gwProvider.fetch(entry, ctx).catch(err => { alertScrapeRegression('generalist-world'); throw err; });
    return jobs2;
  }
  throw e;
}

Prevention

When it happens

Trigger: Calling parseGeneralistWorldJobs on HTML with no gw-job-card anchors and no listing-container marker — e.g. the list page moved to a new template, a CDN/bot-challenge page was served, or an empty/whitespace body (which returns [] earlier) was not the case.

Common situations: Generalist World restructures its board page so the marker and card classes disappear; fetchText follows to an error or login page; an interposed proxy returns an HTML error page; tests feed a fixture from an unrelated page.

Related errors


AI-assisted analysis of santifer/career-ops@e7abd431fc (2026-09-22). Data as JSON: /api/errors/16294a0686dccd1f. Report an issue: GitHub.

Appendix: source

Thrown at providers/generalist-world.mjs:297

  let cards = 0;
  for (const m of rendered.matchAll(CARDS_RE)) {
    cards++;
    const job = cardToJob(m[1], m[2]);
    if (!job || seen.has(job.url)) continue;
    seen.add(job.url);
    jobs.push(job);
  }
  // Anchors that all fail to yield a job mean an inner field moved, not an
  // empty board: the listing container is still there, so without this check
  // a card redesign would read as zero postings forever. A malformed card
  // among usable ones is still just skipped.
  if (jobs.length === 0 && cards > 0) {
    throw new Error(
      `generalist-world: ${cards} gw-job-card anchor(s) matched but none carried a usable title, employer and /jobs/{slug}/ link; the card markup likely changed`,
    );
  }
  if (jobs.length === 0 && !BOARD_MARKER_RE.test(rendered)) {
    throw new Error(
      'generalist-world: no gw-job-card anchors and no listing container in the response; the page structure likely changed',
    );
  }
  return jobs;
}

/** @param {import('./_types.js').PortalEntry | null | undefined} entry */
function detectGeneralistWorldEntry(entry) {
  if (!entry || typeof entry !== 'object') return null;
  if (entry.provider === 'generalist-world') return { url: LIST_URL };
  if (entry.provider) return null;

  for (const value of [entry.api, entry.careers_url]) {
    if (typeof value !== 'string') continue;
    try {
      const parsed = new URL(value);
      if (parsed.protocol === 'https:' && TRUSTED_HOSTS.has(parsed.hostname)) return { url: LIST_URL };
    } catch {

View on GitHub (pinned to e7abd431fc)