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

  1. Add a valid careers_url to the personio entry: https://<slug>.jobs.personio.de or https://<slug>.jobs.personio.com.
  2. Verify the entry's provider field is 'personio' and the URL is a Personio board, not another ATS.
  3. Call detect(entry) before fetch() and skip entries returning null.
  4. 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

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


AI-assisted analysis of santifer/career-ops@9b17a8ac97 (2026-08-13). Data as JSON: /api/errors/35e14bfe571d67a3. Report an issue: GitHub.