{"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":79,"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":61,"sourceCodeEnd":97,"githubUrl":"https://github.com/santifer/career-ops/blob/e7abd431fce9348a95261acac9e0c14779c35df8/providers/personio.mjs#L61-L97","documentation":"The Personio provider derives its XML feed URL from a tenant host resolved from the entry (resolveHost). When no allowlisted *.jobs.personio.(de|com) host can be extracted, fetch() throws instead of building a URL from unknown input. Note that fetch applies the same host regex, so this is thrown even for URLs the generic URL parser accepts — the host just isn't a Personio tenant.","triggerScenarios":"Calling providers/personio.mjs fetch(entry, ctx) where entry.careers_url is absent, not a string, points to another ATS (Greenhouse/Lever), or points to a vanity domain; also when the YAML field name is misspelled so careers_url is undefined.","commonSituations":"Entry added with only a company name and no careers_url; wrong provider id assigned to the company; field typo (career_url / careersUrl) in portals.yml; company migrated off Personio.","solutions":["Add careers_url to the portals.yml entry pointing at https://<slug>.jobs.personio.de or .com (the /xml suffix is appended by the provider).","Verify the field name is exactly careers_url and the provider id is personio.","Run audit-portals.mjs to see which provider actually claims this careers_url; reassign the entry if the company uses a different ATS.","If the slug is unknown, check the company's careers page HTML for the personio tenant host and use it verbatim."],"exampleFix":"// before (portals.yml)\n- name: Acme\n  provider: personio\n// after\n- name: Acme\n  provider: personio\n  careers_url: https://acme.jobs.personio.de","handlingStrategy":"validation","validationCode":"function personioHostResolvable(entry) {\n  const raw = typeof entry.careers_url === 'string' ? entry.careers_url : '';\n  if (!raw) return false;\n  try {\n    return PERSONIO_HOST_RE.test(new URL(raw).hostname);\n  } catch { return false; }\n}\nif (!personioHostResolvable(entry)) throw new Error(`personio: entry ${entry.name} has no derivable feed host`);","typeGuard":"function hasPersonioCareersUrl(entry) {\n  return entry != null && typeof entry === 'object' &&\n    typeof entry.careers_url === 'string' && entry.careers_url.length > 0 &&\n    isPersonioTenantUrl(entry.careers_url);\n}","tryCatchPattern":"try {\n  await personioProvider.fetch(entry, ctx);\n} catch (e) {\n  if (String(e.message).startsWith('personio: cannot derive feed URL')) {\n    logger.warn({ entry: entry.name }, 'no careers_url / not a Personio host — check provider assignment');\n    return null;\n  }\n  throw e;\n}","preventionTips":["Require careers_url on every personio entry via a schema check before scanning.","Verify provider ids with audit-portals.mjs when adding companies.","Watch for key typos (career_url, careersUrl) in portals.yml — they silently yield undefined.","Document each entry's tenant slug next to the URL for future maintenance."],"tags":["config","missing-config","personio","url-derivation"],"backgroundTag":"missing-config-value","analyzedSha":"e7abd431fce9348a95261acac9e0c14779c35df8","analyzedAt":"2026-09-16T06:35:29.214Z","contentChangedAt":"2026-09-16T06:35:29.214Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}