{"record":{"id":"739cf9a0e204bed1","repo":"santifer/career-ops","slug":"senjob-url-still-contains-posting-links-but-no","errorCode":null,"errorMessage":"senjob: ${url} still contains posting links but none could be parsed — the listing markup changed","messagePattern":"senjob: (.+?) still contains posting links but none could be parsed — the listing markup changed","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"critical","filePath":"providers/senjob.mjs","lineNumber":178,"sourceCode":"\n  return [...byId.values()].filter((job) => job.title && job.url);\n}\n\n/**\n * A listing page that parses to nothing is either a markup change or a block —\n * both are failures, and both must be reported. Returning [] would show up as a\n * board with no openings, which is indistinguishable from a healthy quiet board\n * and is the failure mode that makes scrapers untrustworthy.\n *\n * The emptiness test is the posting-link SHAPE rather than a marker word: if the\n * page still contains posting links and the parser found none, the parser is\n * what broke.\n * @param {string} html\n * @param {string} url\n */\nexport function assertParsedSomething(html, url) {\n  if (!/\\/jobseekers\\/[^\"]*?_e_\\d+\\.html/i.test(String(html ?? ''))) return;\n  throw new Error(\n    `senjob: ${url} still contains posting links but none could be parsed — the listing markup changed`,\n  );\n}\n\n/** @type {Provider} */\nexport default {\n  id: 'senjob',\n\n  detect(entry) {\n    return entry?.provider === 'senjob' ? { url: LIST_URL } : null;\n  },\n\n  async fetch(entry, ctx) {\n    // `max_pages` on the portals entry is the user's setting; `ctx.maxPages` is a\n    // caller-side bound — verify-portals' health probe passes 1. Reading only the\n    // latter ignored the configuration entirely. Same shape as alibaba.mjs.\n    const entryMaxPages = Number.isInteger(entry?.max_pages) && entry.max_pages > 0\n      ? Math.min(entry.max_pages, MAX_PAGES_CAP)","sourceCodeStart":160,"sourceCodeEnd":196,"githubUrl":"https://github.com/santifer/career-ops/blob/aac998c7ed7248ea853b720ceeb1fdbeb322fc5d/providers/senjob.mjs#L160-L196","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["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)","Update POSTING_LINK_RE / POSTING_ANCHOR_RE / the <tr> split to match the new markup, keeping the /jobseekers/..._e_{id}.html contract","Check whether links are now HTML-escaped (&amp;) or attribute order changed and relax the regex accordingly","Verify you are not feeding preprocessed HTML (entity-decoded or tag-stripped) into parseListingPage"],"exampleFix":"// before — parser no longer matches new markup\nconst POSTING_LINK_RE = /href=\"(https:\\/\\/senjob\\.com\\/jobseekers\\/^\"]*?_e_(\\d+)\\.html)\"/i;\n// after — tolerate single-quoted or unquoted hrefs introduced by a redesign\nconst POSTING_LINK_RE = /href=[\"'](https:\\/\\/senjob\\.com\\/jobseekers\\/[^\"']*?_e_(\\d+)\\.html)[\"']/i;","handlingStrategy":"try-catch","validationCode":"function looksLikeSenjobListing(html) {\n  return /senjob\\.com\\/jobseekers\\/[^\"']*?_e_\\d+\\.html/i.test(String(html ?? ''));\n}\n// After fetch: if it throws this error, the page had posting links the parser missed —\n// save the raw HTML for a fixture before reporting.","typeGuard":null,"tryCatchPattern":"try {\n  const jobs = await senjobProvider.fetch(entry, ctx);\n} catch (e) {\n  if (String(e.message).includes('listing markup changed')) {\n    console.error('Senjob markup drifted; pin a fixture and update the parser regexes');\n    // alert/monitor: this is a parser bug, not an empty board\n  } else throw e;\n}","preventionTips":["Treat this error as a parser regression alert, never as 'no jobs'","Keep saved HTML fixtures from live senjob pages in unit tests so markup drift is caught early","Only anchor the parser to the stable URL shape (/jobseekers/..._e_{id}.html), not CSS classes","When the error fires, diff the live HTML against the regexes before patching"],"tags":["scraper","parser-drift","markup-change","silent-failure"],"backgroundTag":"unexpected-response-shape","analyzedSha":"aac998c7ed7248ea853b720ceeb1fdbeb322fc5d","analyzedAt":"2026-09-16T06:35:29.214Z","contentChangedAt":"2026-09-16T06:35:29.214Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}