santifer/career-ops · error · Error

generalist-world: gw-job-card anchor(s) matched but none…

Error message

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

What it means

parseGeneralistWorldJobs throws when gw-job-card anchors were found on the Generalist World board page but every one failed to yield a usable title, employer and /jobs/{slug}/ link. Anchors matching yet all failing means an inner field (title, company or href) moved inside the card — a card redesign — not an empty board. Individual malformed cards among usable ones are simply skipped; only an all-fail page throws.

Solutions

  1. Fetch the live board page and update TITLE_RE, COMPANY_RE or HREF_RE in providers/generalist-world.mjs to match the new card inner markup
  2. Diff against a known-good fixture to see which inner field moved, then fix that one regex
  3. Confirm the page is a real board render and not a bot challenge or partial hydration shell
  4. Update the test fixtures to the current markup so cardToJob yields jobs again

Example fix

// before: company selector misses moved field
const COMPANY_RE = /<span class="gw-card__company">([\s\S]*?)<\/span>/;
// after: accept the redesigned wrapper
const COMPANY_RE = /<div class="gw-card__employer[^"]*"[^>]*>([\s\S]*?)<\/div>/;
Defensive patterns

Strategy: try-catch

Validate before calling

function willParseGwCards(html) {
  const cards = [...String(html).matchAll(/<a class="gw-job-card"[\s\S]*?<\/a>/g)];
  return cards.length === 0 || cards.some(c => /\/jobs\/[\w-]+\//.test(c[0]) && /gw-card__title|<h[1-4]/.test(c[0]));
}

Type guard

function isGwBoardHtml(html) {
  return typeof html === 'string' && /gw-job-card/.test(html);
}

Try / catch

try {
  const jobs = await gwProvider.fetch(entry, ctx);
} catch (e) {
  if (e.message.includes('gw-job-card') && e.message.includes('markup likely changed')) {
    alertScrapeRegression('generalist-world'); // card internals moved; refresh regexes/fixtures
  } else throw e;
}

Prevention

When it happens

Trigger: Calling parseGeneralistWorldJobs (or the provider's fetch) on a page where CARDS_RE matches cards > 0 but cardToJob returns null for every anchor — e.g. the title, employer, or /jobs/{slug}/ href selector inside the card changed.

Common situations: Generalist World redesigns the card inner layout; a field like the company label is renamed or moved into a nested element; testing against a stale HTML fixture whose card internals no longer match the regexes; a challenge/partial page renders card shells without real content.

Related errors


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

Appendix: source

Thrown at providers/generalist-world.mjs:292

  if (typeof html !== 'string' || !html.trim()) return [];
  const rendered = stripNonRendered(html);
  /** @type {Job[]} */
  const jobs = [];
  const seen = new Set();
  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]) {

View on GitHub (pinned to e7abd431fc)