{"record":{"id":"35e14bfe571d67a3","repo":"santifer/career-ops","slug":"personio-cannot-derive-feed-url-for-entry-name","errorCode":null,"errorMessage":"personio: cannot derive feed URL for ${entry.name}","messagePattern":"personio: cannot derive feed URL for (.+?)","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"providers/personio.mjs","lineNumber":66,"sourceCode":"// NaN-safe Date.parse — `|| undefined` would also coerce a valid epoch 0.\nfunction toEpochMs(value) {\n  if (!value) return undefined;\n  const parsed = Date.parse(value);\n  return Number.isNaN(parsed) ? undefined : parsed;\n}\n\n/** @type {Provider} */\nexport default {\n  id: 'personio',\n\n  detect(entry) {\n    const host = resolveHost(entry);\n    return host ? { url: `https://${host}/xml` } : null;\n  },\n\n  async fetch(entry, ctx) {\n    const host = resolveHost(entry);\n    if (!host) throw new Error(`personio: cannot derive feed URL for ${entry.name}`);\n    const feedUrl = `https://${host}/xml`;\n    assertPersonioUrl(feedUrl);\n    // redirect:'error' prevents SSRF via server-side redirects; combined with\n    // assertPersonioUrl above it guarantees the final hostname stays in-domain.\n    try {\n      const text = await ctx.fetchText(feedUrl, { redirect: 'error' });\n      return parsePersonioXml(text, entry.name, host);\n    } catch (err) {\n      if (err?.status !== 404) throw err;\n      // Some tenants disable the public XML feed. The careers page itself is\n      // still server-rendered with the full job list in the initial HTML, so\n      // fall back to scraping it directly instead of giving up.\n      // ?language=en forces English titles — unlike the XML feed (which has\n      // no language param and always renders in the tenant's default\n      // language), the HTML page respects it.\n      const pageUrl = `https://${host}/?language=en`;\n      assertPersonioUrl(pageUrl);\n      const html = await ctx.fetchText(pageUrl, { redirect: 'error' });","sourceCodeStart":48,"sourceCodeEnd":84,"githubUrl":"https://github.com/santifer/career-ops/blob/9b17a8ac97b398a496b38e423ae24e433b43254f/providers/personio.mjs#L48-L84","documentation":"Thrown by personio's fetch() when resolveHost(entry) returns null — the entry's careers_url does not resolve to a valid Personio tenant host. resolveHost() parses entry.careers_url, checks protocol is https and hostname matches PERSONIO_HOST_RE, returning the hostname string or null. This throw means the entry cannot produce a Personio XML feed URL.","triggerScenarios":"resolveHost returns null when: (1) entry.careers_url is missing, empty, or non-string; (2) it fails URL parsing; (3) protocol isn't https; (4) hostname fails PERSONIO_HOST_RE. detect() uses the same resolveHost and returns null, so this throw in fetch() means detect() was bypassed or the entry was mutated.","commonSituations":"Portals.yml personio entry missing careers_url entirely. The careers_url points to a non-Personio ATS (Lever, Greenhouse, etc.). The URL was cleared by a config merge or migration. A batch script generated entries without the careers_url field.","solutions":["Add a valid careers_url to the personio entry: https://<slug>.jobs.personio.de or https://<slug>.jobs.personio.com.","Verify the entry's provider field is 'personio' and the URL is a Personio board, not another ATS.","Call detect(entry) before fetch() and skip entries returning null.","Check for config-merge issues that might have overwritten the careers_url."],"exampleFix":"// before — entry has no valid Personio URL\njob_boards:\n  acme:\n    provider: personio\n    name: Acme\n    careers_url: 'https://careers.acme.com'  // not a Personio board\n\n// after\njob_boards:\n  acme:\n    provider: personio\n    name: Acme\n    careers_url: 'https://acme.jobs.personio.de'","handlingStrategy":"validation","validationCode":"const PERSONIO_HOST_RE = /^[a-z0-9][a-z0-9-]*\\.jobs\\.personio\\.(de|com)$/;\n\n/** Replicate resolveHost to validate an entry before fetch. */\nfunction canResolvePersonioHost(entry) {\n  const raw = typeof entry.careers_url === 'string' ? entry.careers_url : '';\n  if (!raw) return false;\n  try {\n    const p = new URL(raw);\n    return p.protocol === 'https:' && PERSONIO_HOST_RE.test(p.hostname);\n  } catch {\n    return false;\n  }\n}\n\nif (!canResolvePersonioHost(entry)) {\n  console.warn(`personio entry ${entry.name} cannot resolve host — skipping`);\n  continue;\n}","typeGuard":"/** @param {import('./_types.js').PortalEntry} entry @returns {boolean} */\nfunction hasValidPersonioUrl(entry) {\n  const RE = /^[a-z0-9][a-z0-9-]*\\.jobs\\.personio\\.(de|com)$/;\n  const url = entry.careers_url;\n  return typeof url === 'string'\n    && url.startsWith('https://')\n    && (() => { try { return RE.test(new URL(url).hostname); } catch { return false; } })();\n}","tryCatchPattern":"try {\n  await personioProvider.fetch(entry, ctx);\n} catch (err) {\n  if (String(err.message).startsWith('personio: cannot derive feed URL')) {\n    console.warn(`skipping ${entry.name}: no valid Personio career URL`);\n    continue;\n  }\n  throw err;\n}","preventionTips":["Call detect(entry) before fetch() — it returns null when resolveHost fails.","Validate Personio entries at config-load time for the <slug>.jobs.personio.(de|com) pattern.","Ensure config merges preserve the careers_url field for Personio entries."],"tags":["config-validation","ssrf-guard","personio","config"],"backgroundTag":null,"analyzedSha":"9b17a8ac97b398a496b38e423ae24e433b43254f","analyzedAt":"2026-08-13T00:48:39.135Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}