{"record":{"id":"ed1d4673b2c181b5","repo":"santifer/career-ops","slug":"avature-url-still-contains-jobdetail-links-but-no-article","errorCode":null,"errorMessage":"avature: ${url} still contains JobDetail links but no article could be parsed — the listing markup changed","messagePattern":"avature: (.+?) still contains JobDetail links but no article could be parsed — the listing markup changed","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"providers/avature.mjs","lineNumber":127,"sourceCode":"}\n\n/**\n * A first page with zero parsed articles is either a genuinely empty board\n * or a markup change breaking the `<article class=\"article article--result\">`\n * selector — those must not look the same to a caller (the whole risk of\n * scraping is a silent-zero failure reading as \"no jobs\" instead of \"the\n * parser broke\"). Throws when the raw HTML still carries posting-shaped\n * `JobDetail/` links (the page has rows; the selector just isn't finding\n * them); returns silently for a page carrying neither — a genuinely empty\n * board. Mirrors `providers/itviec.mjs`'s `assertParsedSomething`; called on\n * the first page only (a later empty/short page is just the end of a real\n * board, not a parser regression).\n * @param {string} html\n * @param {string} url\n */\nexport function assertParsedSomething(html, url) {\n  if (!/\\/JobDetail\\/[^\"'\\s]+/i.test(String(html ?? ''))) return;\n  throw new Error(\n    `avature: ${url} still contains JobDetail links but no article could be parsed — the listing markup changed`,\n  );\n}\n\n/** @param {string} htmlText @param {string} origin */\nexport function parseArticles(htmlText, origin) {\n  const out = [];\n  // Tenants vary the result class: Synopsys uses `article--result`, Siemens\n  // appends a position index (`article--result 1`). Accept any suffix.\n  const re = /<article class=\"article article--result[^\"]*\"[\\s\\S]*?<\\/article>/g;\n  let a;\n  while ((a = re.exec(htmlText)) !== null) {\n    const block = a[0];\n    // JobDetail path may or may not sit under /careers/ (branded tenants vary),\n    // so anchor on JobDetail/ itself rather than a fixed prefix. Prefer the\n    // `class=\"link\"` title anchor (most tenants); fall back to any JobDetail\n    // anchor for tenants (e.g. Rohde & Schwarz) whose title link carries no\n    // class. Share/mailto buttons url-encode the path (%2FJobDetail%2F) so they","sourceCodeStart":109,"sourceCodeEnd":145,"githubUrl":"https://github.com/santifer/career-ops/blob/e7abd431fce9348a95261acac9e0c14779c35df8/providers/avature.mjs#L109-L145","documentation":"assertParsedSomething guards the Avature career-site scraper against silent-zero failures. When a fetched listing page still contains posting-shaped /JobDetail/ links but the parser produced no <article class=\"article--result\"> blocks, the listing markup has changed and the provider throws instead of reporting an empty board. This distinction matters because a genuinely empty board (no JobDetail links at all) returns silently.","triggerScenarios":"Called after parsing the first page of an Avature SearchJobs listing; throws when the raw HTML matches /\\/JobDetail\\// but zero articles were extracted — i.e. Avature or a branded tenant renamed/restructured the article--result markup while still rendering job links.","commonSituations":"Avature ships a tenant-wide template redesign; a branded tenant (e.g. Siemens-style indexed classes) diverges from the expected article class; a WAF/challenge page injects JobDetail-like URLs without real result articles; scraping an HTML snapshot in tests after the live site changed.","solutions":["Inspect the fetched HTML at url and update the <article class=\"article article--result...\"> regex in providers/avature.mjs parseArticles to match the new listing markup","Verify the page is a real listing and not a bot-challenge/login interstitial; if so, add the tenant's challenge handling or headers","Pin the entry's api: to the tenant's /careers/SearchJobs URL with current facet params and re-run to confirm the markup hypothesis","If the tenant has permanently moved off Avature's article markup, switch its portals.yml entry to the correct provider"],"exampleFix":"// before: parser expects old markup only\nconst re = /<article class=\"article article--result[^\"]*\"[\\s\\S]*?<\\/article>/g;\n// after: accept a redesigned result container\nclass=\"listing-card job-result\"' (adjust regex, e.g. /<article class=\"[^\"]*(?:article--result|job-result)[^\"]*\"[\\s\\S]*?<\\/article>/g)","handlingStrategy":"validation","validationCode":"function looksLikeEmptyAvatureBoard(html) {\n  return !/\\/JobDetail\\/[^\"'\\s]+/i.test(String(html ?? ''));\n}\n// if it contains JobDetail links, expect the parser to throw — surface that as a markup alert, not '0 jobs'","typeGuard":"function isAvatureListingHtml(html) {\n  return typeof html === 'string' &&\n    /<article class=\"article article--result[^\"]*\"[\\s\\S]*?<\\/article>/g.test(html);\n}","tryCatchPattern":"try {\n  const jobs = await provider.fetch(entry, ctx);\n} catch (e) {\n  if (String(e.message).startsWith('avature:') && e.message.includes('listing markup changed')) {\n    alertScrapeRegression(entry); // parser broke; do not treat as empty board\n  } else throw e;\n}","preventionTips":["Pin real listing-page HTML fixtures from live tenants in tests and run parseArticles against them on every change","Re-run audits (audit-portals) periodically to catch tenant markup drift early","Treat a sudden 0-posting result from a previously populated board as a regression signal, not a quiet empty board","Keep the article-class regex tolerant of tenant suffix variants (e.g. indexed classes like 'article--result 1')"],"tags":["scraping","html-parsing","markup-change","avature"],"backgroundTag":"unexpected-response-shape","analyzedSha":"e7abd431fce9348a95261acac9e0c14779c35df8","analyzedAt":"2026-09-22T13:19:01.448Z","contentChangedAt":"2026-09-22T13:19:01.448Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}