{"record":{"id":"5bb361159fea50b4","repo":"santifer/career-ops","slug":"careerviet-url-still-contains-job-cards-but-no","errorCode":null,"errorMessage":"careerviet: ${url} still contains job cards but none could be parsed — the listing markup changed","messagePattern":"careerviet: (.+?) still contains job cards but none could be parsed — the listing markup changed","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"providers/careerviet.mjs","lineNumber":284,"sourceCode":"    });\n  }\n\n  return [...byId.values()];\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\n * as a board with no openings, indistinguishable from a healthy quiet board.\n *\n * The emptiness test is the card-marker SHAPE (a job-item DOM id) rather than\n * a marker word, so it survives the board's own \"no results\" copy changing.\n * @param {string} html\n * @param {string} url\n */\nexport function assertParsedSomething(html, url) {\n  if (!/id=[\"']job-item-[A-Za-z0-9]+[\"']/.test(String(html ?? ''))) return;\n  throw new Error(\n    `careerviet: ${url} still contains job cards but none could be parsed — the listing markup changed`,\n  );\n}\n\n/** @type {Provider} */\nexport default {\n  id: 'careerviet',\n\n  detect(entry) {\n    return entry?.provider === 'careerviet' ? { url: buildListUrl(entry, 1) } : 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. Same shape as itviec.mjs.\n    const entryMaxPages = Number.isInteger(entry?.max_pages) && entry.max_pages > 0\n      ? Math.min(entry.max_pages, MAX_PAGES_CAP)\n      : DEFAULT_MAX_PAGES;","sourceCodeStart":266,"sourceCodeEnd":302,"githubUrl":"https://github.com/santifer/career-ops/blob/1696bec4d021768e7359f9aad6b329cba883da20/providers/careerviet.mjs#L266-L302","documentation":"careerviet's scraper fetched a listing page whose HTML still contains job-card DOM markers (`id=\"job-item-...\"`) but `parseListingPage` extracted zero jobs. The library throws instead of returning [] because an empty result would be indistinguishable from a healthy board with no openings, hiding a real scraping failure. It means the board's markup changed such that the card-window detection still matches but the inner title/href extraction regexes no longer do.","triggerScenarios":"Provider scan of a careerviet.vn listing URL where: (1) the board changed the anchor class/structure inside job-item cards so TITLE_LINK_RE no longer matches; (2) cards now resolve hrefs to a non-https or non-careerviet.vn host so every card is skipped; (3) titles or hrefs are empty for all cards. assertParsedSomething(html, url) fires whenever the job-item marker shape exists but parseListingPage returned nothing.","commonSituations":"Careerviet deployed a frontend redesign; an ad slot or partner card reuses the job-item id shape while real postings moved to new markup; a CDN serves a stale/partial page; the scraper version lags behind a site update.","solutions":["Inspect the live listing HTML at the thrown URL and update the card/title/href regexes in providers/careerviet.mjs to the new markup.","Confirm whether the page is a real listing (not a bot-block or ad-only page) by opening the URL in a browser; if blocked, address the block.","Update or pin the library to a version with current careerviet selectors; file/await an upstream fix for the markup change.","If the board is no longer needed, disable the careerviet entry in portals.yml."],"exampleFix":"// providers/careerviet.mjs — markup redesign moved the title anchor\n// before\nconst TITLE_LINK_RE = /<a[^>]+class=\"[^\"]*job-title[^\"]*\"[^>]+href=\"([^\"]+)\"[^>]*>(.*?)<\\/a>/s;\n// after\nconst TITLE_LINK_RE = /<a[^>]+data-qa=\"job-item-link\"[^>]+href=\"([^\"]+)\"[^>]*>(.*?)<\\/a>/s;","handlingStrategy":"try-catch","validationCode":"// Pre-check the listing HTML before running the provider\nconst res = await fetch('https://www.careerviet.vn/viec-lam-it-trang-1-vi.html');\nconst html = await res.text();\nif (/id=[\"']job-item-[A-Za-z0-9]+[\"']/.test(html)) {\n  console.log('board serves job-card markup — provider selectors may be stale if this scan fails');\n}","typeGuard":null,"tryCatchPattern":"try {\n  await runScan('careerviet');\n} catch (e) {\n  if (e.message.includes('listing markup changed')) {\n    // stale selectors, not an empty board — alert, don't record 0 openings\n    reportScrapeDrift('careerviet', e.message);\n  } else throw e;\n}","preventionTips":["Pin the provider to a maintained version and watch for careerviet markup-change releases.","Alert on this error specifically — it means 0 jobs recorded, which silently empties the board if swallowed.","Periodically spot-check the live listing page against the provider's selectors.","Never treat an empty careerviet result as 'no openings' without checking for this error upstream."],"tags":["scraping","markup-change","parser","provider"],"backgroundTag":"listing-markup-changed","analyzedSha":"1696bec4d021768e7359f9aad6b329cba883da20","analyzedAt":"2026-09-01T19:19:23.111Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-14T00:17:10.932Z"}