santifer/career-ops · critical · Error

senjob: still contains posting links but none could be…

Error message

senjob: ${url} still contains posting links but none could be parsed — the listing markup changed

What it means

assertParsedSomething is the senjob provider's silent-zero guard: if a fetched listing page still contains the recognizable posting-link shape (/jobseekers/..._e_<id>.html) but parseListingPage extracted no jobs, the parser — not the board — is broken, so the provider throws instead of returning an empty array. An empty result would falsely report a country board as having no openings, so markup drift must surface loudly.

Solutions

  1. Fetch the live page at https://senjob.com/offres-d-emploi.php and diff its markup against the regexes in providers/senjob.mjs (POSTING_LINK_RE, POSTING_ANCHOR_RE)
  2. Update POSTING_LINK_RE / POSTING_ANCHOR_RE / the <tr> split to match the new markup, keeping the /jobseekers/..._e_{id}.html contract
  3. Check whether links are now HTML-escaped (&amp;) or attribute order changed and relax the regex accordingly
  4. Verify you are not feeding preprocessed HTML (entity-decoded or tag-stripped) into parseListingPage

Example fix

// before — parser no longer matches new markup
const POSTING_LINK_RE = /href="(https:\/\/senjob\.com\/jobseekers\/^"]*?_e_(\d+)\.html)"/i;
// after — tolerate single-quoted or unquoted hrefs introduced by a redesign
const POSTING_LINK_RE = /href=["'](https:\/\/senjob\.com\/jobseekers\/[^"']*?_e_(\d+)\.html)["']/i;
Defensive patterns

Strategy: try-catch

Validate before calling

function looksLikeSenjobListing(html) {
  return /senjob\.com\/jobseekers\/[^"']*?_e_\d+\.html/i.test(String(html ?? ''));
}
// After fetch: if it throws this error, the page had posting links the parser missed —
// save the raw HTML for a fixture before reporting.

Try / catch

try {
  const jobs = await senjobProvider.fetch(entry, ctx);
} catch (e) {
  if (String(e.message).includes('listing markup changed')) {
    console.error('Senjob markup drifted; pin a fixture and update the parser regexes');
    // alert/monitor: this is a parser bug, not an empty board
  } else throw e;
}

Prevention

When it happens

Trigger: Senjob changes its listing HTML so rows no longer match POSTING_LINK_RE / POSTING_ANCHOR_RE (e.g. new attribute order, escaped hrefs, links built by JS) while the raw href shape still appears in the source; page 1 of the listing fetches OK but parses to zero jobs.

Common situations: A senjob.com site redesign or CMS update; a CDN or caching layer rewriting markup (e.g. minified/attribute-quoted differently); testing against a saved/stripped HTML fixture that contains raw links but no <tr> row structure the parser splits on.

Related errors


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

Appendix: source

Thrown at providers/senjob.mjs:178

  return [...byId.values()].filter((job) => job.title && job.url);
}

/**
 * A listing page that parses to nothing is either a markup change or a block —
 * both are failures, and both must be reported. Returning [] would show up as a
 * board with no openings, which is indistinguishable from a healthy quiet board
 * and is the failure mode that makes scrapers untrustworthy.
 *
 * The emptiness test is the posting-link SHAPE rather than a marker word: if the
 * page still contains posting links and the parser found none, the parser is
 * what broke.
 * @param {string} html
 * @param {string} url
 */
export function assertParsedSomething(html, url) {
  if (!/\/jobseekers\/[^"]*?_e_\d+\.html/i.test(String(html ?? ''))) return;
  throw new Error(
    `senjob: ${url} still contains posting links but none could be parsed — the listing markup changed`,
  );
}

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

  detect(entry) {
    return entry?.provider === 'senjob' ? { url: LIST_URL } : null;
  },

  async fetch(entry, ctx) {
    // `max_pages` on the portals entry is the user's setting; `ctx.maxPages` is a
    // caller-side bound — verify-portals' health probe passes 1. Reading only the
    // latter ignored the configuration entirely. Same shape as alibaba.mjs.
    const entryMaxPages = Number.isInteger(entry?.max_pages) && entry.max_pages > 0
      ? Math.min(entry.max_pages, MAX_PAGES_CAP)

View on GitHub (pinned to aac998c7ed)