santifer/career-ops · error · Error
personio: cannot derive feed URL for ${entry.name}
Error message
personio: cannot derive feed URL for ${entry.name} What it means
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.
Source
Thrown at providers/personio.mjs:66
// NaN-safe Date.parse — `|| undefined` would also coerce a valid epoch 0.
function toEpochMs(value) {
if (!value) return undefined;
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? undefined : parsed;
}
/** @type {Provider} */
export default {
id: 'personio',
detect(entry) {
const host = resolveHost(entry);
return host ? { url: `https://${host}/xml` } : null;
},
async fetch(entry, ctx) {
const host = resolveHost(entry);
if (!host) throw new Error(`personio: cannot derive feed URL for ${entry.name}`);
const feedUrl = `https://${host}/xml`;
assertPersonioUrl(feedUrl);
// redirect:'error' prevents SSRF via server-side redirects; combined with
// assertPersonioUrl above it guarantees the final hostname stays in-domain.
try {
const text = await ctx.fetchText(feedUrl, { redirect: 'error' });
return parsePersonioXml(text, entry.name, host);
} catch (err) {
if (err?.status !== 404) throw err;
// Some tenants disable the public XML feed. The careers page itself is
// still server-rendered with the full job list in the initial HTML, so
// fall back to scraping it directly instead of giving up.
// ?language=en forces English titles — unlike the XML feed (which has
// no language param and always renders in the tenant's default
// language), the HTML page respects it.
const pageUrl = `https://${host}/?language=en`;
assertPersonioUrl(pageUrl);
const html = await ctx.fetchText(pageUrl, { redirect: 'error' });View on GitHub (pinned to 9b17a8ac97)
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.
Example fix
// before — entry has no valid Personio URL
job_boards:
acme:
provider: personio
name: Acme
careers_url: 'https://careers.acme.com' // not a Personio board
// after
job_boards:
acme:
provider: personio
name: Acme
careers_url: 'https://acme.jobs.personio.de' Defensive patterns
Strategy: validation
Validate before calling
const PERSONIO_HOST_RE = /^[a-z0-9][a-z0-9-]*\.jobs\.personio\.(de|com)$/;
/** Replicate resolveHost to validate an entry before fetch. */
function canResolvePersonioHost(entry) {
const raw = typeof entry.careers_url === 'string' ? entry.careers_url : '';
if (!raw) return false;
try {
const p = new URL(raw);
return p.protocol === 'https:' && PERSONIO_HOST_RE.test(p.hostname);
} catch {
return false;
}
}
if (!canResolvePersonioHost(entry)) {
console.warn(`personio entry ${entry.name} cannot resolve host — skipping`);
continue;
} Type guard
/** @param {import('./_types.js').PortalEntry} entry @returns {boolean} */
function hasValidPersonioUrl(entry) {
const RE = /^[a-z0-9][a-z0-9-]*\.jobs\.personio\.(de|com)$/;
const url = entry.careers_url;
return typeof url === 'string'
&& url.startsWith('https://')
&& (() => { try { return RE.test(new URL(url).hostname); } catch { return false; } })();
} Try / catch
try {
await personioProvider.fetch(entry, ctx);
} catch (err) {
if (String(err.message).startsWith('personio: cannot derive feed URL')) {
console.warn(`skipping ${entry.name}: no valid Personio career URL`);
continue;
}
throw err;
} Prevention
- 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.
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- nofluffjobs: careers_url or api must be a trusted nofluffjob
- oraclecloud: cannot derive API URL for ${entry.name}
- personio: invalid URL: ${url}
- personio: URL must use HTTPS: ${url}
- flowxtra: untrusted hostname "${parsed.hostname}" — must be
AI-assisted analysis of santifer/career-ops@9b17a8ac97 (2026-08-13).
Data as JSON: /api/errors/35e14bfe571d67a3.
Report an issue: GitHub.