santifer/career-ops · error · Error
itviec: still contains job cards but none could be parsed —…
Error message
itviec: ${url} still contains job cards but none could be parsed — the listing markup changed What it means
assertParsedSomething is itviec's HTML-parser smoke test: if the raw HTML still matches the job-card link pattern (href to /it-jobs/<slug>-<digits>) but the parser extracted nothing, the page is clearly a listing whose markup changed, so it throws rather than silently returning an empty board.
Solutions
- Fetch the listing page, inspect a job card's current markup, and update the parser's selectors to match
- Loosen overly strict selectors (the link regex is intentionally broad, so the parser is usually the stricter part)
- Add/update unit tests with a saved copy of the new HTML before redeploying
- Skip itviec in portals.yml temporarily and report the provider breakage
Example fix
// before
const cards = html.querySelectorAll('.job-item-2.search__job');
// after
const cards = html.querySelectorAll('.job-item-2, [data-search-result]'); Defensive patterns
Strategy: try-catch
Validate before calling
function listingLooksParseable(html) {
const text = String(html ?? '');
const hasCards = /href=["'](?:https?:\/\/itviec\.com)?\/it-jobs\/[a-z0-9-]+-\d+/i.test(text);
return !hasCards; // true = nothing to parse; false = cards present, parser must succeed
}
// call assertParsedSomething in a probe before a full scan run Type guard
const pageHasJobCards = (html) => /href=["'](?:https?:\/\/itviec\.com)?\/it-jobs\/[a-z0-9-]+-\d+/i.test(String(html ?? ''));
Try / catch
try {
assertParsedSomething(html, url);
} catch (err) {
if (/still contains job cards/.test(err.message)) {
console.error('itviec markup changed; update parser selectors. Page saved for inspection.');
fs.writeFileSync(`itviec-breakage-${Date.now()}.html`, String(html));
return [];
}
throw err;
} Prevention
- Keep saved HTML fixtures of itviec listing pages in CI and run the parser against them
- Prefer broad structural selectors over brittle class names
- Alert on zero-result boards instead of treating them as quiet days
- Re-check the parser after any itviec redesign or A/B rollout
When it happens
Trigger: The itviec listing page contains job-card anchors matching the regex, but the selector/extraction logic produced zero parsed jobs — i.e. CSS class names or DOM structure of the cards changed upstream.
Common situations: Itviec ships a frontend redesign; A/B test serves new markup to some clients; the parser was updated to a stricter selector that no longer matches valid cards.
Related errors
- avature: still contains JobDetail links but no article…
- generalist-world: gw-job-card anchor(s) matched but none…
- generalist-world: no gw-job-card anchors and no listing…
- rheinmetall: page 1 returned no vacancy cards for
- careerviet: still contains job cards but none could be…
AI-assisted analysis of santifer/career-ops@aac998c7ed (2026-09-16).
Data as JSON: /api/errors/6255ef8bb5e63bf0.
Report an issue: GitHub.
Appendix: source
Thrown at providers/itviec.mjs:253
/**
* 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 card-marker SHAPE rather than a marker word: if the
* page still carries card slug attributes and the parser found none, the parser
* is what broke.
* @param {string} html
* @param {string} url
*/
export function assertParsedSomething(html, url) {
// The numeric suffix check is deliberately loose (\d+, not \d{4}): this is a
// heuristic that answers "is this still a listing page", so it must err
// toward throwing rather than toward silence.
if (!/href=["'](?:https?:\/\/itviec\.com)?\/it-jobs\/[a-z0-9-]+-\d+/i.test(String(html ?? ''))) return;
throw new Error(
`itviec: ${url} still contains job cards but none could be parsed — the listing markup changed`,
);
}
/** @type {Provider} */
export default {
id: 'itviec',
detect(entry) {
return entry?.provider === 'itviec' ? { url: buildListUrl(entry, 1) } : 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. Same shape as senjob.mjs.
const entryMaxPages = Number.isInteger(entry?.max_pages) && entry.max_pages > 0
? Math.min(entry.max_pages, MAX_PAGES_CAP)
: DEFAULT_MAX_PAGES;View on GitHub (pinned to aac998c7ed)