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
- 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 (&) or attribute order changed and relax the regex accordingly
- 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
- 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
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
- avature: still contains JobDetail links but no article…
- careerviet: still contains job cards but none could be…
- generalist-world: gw-job-card anchor(s) matched but none…
- generalist-world: no gw-job-card anchors and no listing…
- glints: unexpected API response
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)