{"record":{"id":"02d8e34e89d732dc","repo":"santifer/career-ops","slug":"greenhouse-cannot-derive-api-url-for-entry-name","errorCode":null,"errorMessage":"greenhouse: cannot derive API URL for ${entry.name}","messagePattern":"greenhouse: cannot derive API URL for (.+?)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"providers/greenhouse.mjs","lineNumber":155,"sourceCode":"  return htmlToText(content);\n}\n\n/** @type {Provider} */\nexport default {\n  id: 'greenhouse',\n\n  detect(entry) {\n    try {\n      const apiUrl = resolveApiUrl(entry);\n      return apiUrl ? { url: apiUrl } : null;\n    } catch {\n      return null;\n    }\n  },\n\n  async fetch(entry, ctx) {\n    const apiUrl = resolveApiUrl(entry);\n    if (!apiUrl) throw new Error(`greenhouse: cannot derive API URL for ${entry.name}`);\n    assertGreenhouseUrl(apiUrl);\n    // content=true embeds each posting's body in the list response (one\n    // request, no per-job detail fetches). searchParams.set is idempotent, so\n    // an entry.api that already pins the param can't end up with a duplicate.\n    const listUrl = new URL(apiUrl);\n    listUrl.searchParams.set('content', 'true');\n    // Re-validate the final href: the guard chain runs on the exact string\n    // that goes over the wire, not just the pre-param base.\n    const listHref = assertGreenhouseUrl(listUrl.href);\n    // redirect:'error' prevents SSRF via server-side redirects; combined with\n    // assertGreenhouseUrl above it guarantees the final hostname stays in the allowlist.\n    const json = /** @type {any} */ (await ctx.fetchJson(listHref, { redirect: 'error' }));\n    const jobs = Array.isArray(json?.jobs) ? json.jobs : [];\n    const usable = jobs.filter(/** @param {any} j */ j => j.absolute_url);\n\n    // Only pay for /offices when this board actually hides its cities there.\n    let officeMap = null;\n    if (usable.some(/** @param {any} j */ j => isWorkModelOnly(j.location?.name))) {","sourceCodeStart":137,"sourceCodeEnd":173,"githubUrl":"https://github.com/santifer/career-ops/blob/aac998c7ed7248ea853b720ceeb1fdbeb322fc5d/providers/greenhouse.mjs#L137-L173","documentation":"The Greenhouse provider's fetch() derives the API URL either from an explicit entry.api or from entry.careers_url. If neither yields a usable API URL, resolveApiUrl returns null and fetch throws this error naming the portal entry. It catches config entries that cannot be served by the Greenhouse API path at all.","triggerScenarios":"A job_boards entry with provider: greenhouse whose entry has no api field and whose careers_url is absent, empty, or not in a form resolveApiUrl can convert into a boards-api.greenhouse.io URL (e.g. a vanity domain or a non-Greenhouse careers page).","commonSituations":"portals.yml entry added with only a human careers page URL that is not a greenhouse.io host, an entry with a typo'd/blank key (api:/careers_url misspelled), or an entry copied from a different provider's format.","solutions":["Add an explicit api: https://boards-api.greenhouse.io/<board-token>/jobs to the entry in portals.yml.","Verify the company actually uses Greenhouse; if not, switch the entry's provider to the correct one.","Check that the careers_url is a greenhouse.io URL so resolveApiUrl can derive the API host from it.","Inspect resolveApiUrl in providers/greenhouse.mjs to confirm which URL shapes it supports."],"exampleFix":"// before (portals.yml)\n- name: Acme\n  provider: greenhouse\n  careers_url: https://acme.com/careers\n// after\n- name: Acme\n  provider: greenhouse\n  api: https://boards-api.greenhouse.io/acme/jobs","handlingStrategy":"validation","validationCode":"function greenhouseEntryReady(entry) {\n  return Boolean(entry && (entry.api || entry.careers_url));\n}\nif (!greenhouseEntryReady(entry)) console.warn(`entry ${entry.name} lacks api/careers_url for greenhouse`);","typeGuard":"const hasApiOrCareersUrl = (e) => typeof e?.api === 'string' || typeof e?.careers_url === 'string';","tryCatchPattern":"try {\n  await provider.fetch(entry, ctx);\n} catch (err) {\n  if (new RegExp(`greenhouse: cannot derive API URL for ${entry.name}`).test(err.message)) {\n    console.error(`Add api: https://boards-api.greenhouse.io/<token>/jobs to entry \"${entry.name}\"`);\n    return;\n  }\n  throw err;\n}","preventionTips":["Always set an explicit api: URL for greenhouse entries instead of relying on derivation.","Confirm the company actually uses Greenhouse before assigning provider: greenhouse.","Validate portals.yml entries (required fields per provider) at startup.","Keep entry keys spelled exactly api and careers_url."],"tags":["config","url","greenhouse","scraper"],"backgroundTag":"invalid-config-value","analyzedSha":"aac998c7ed7248ea853b720ceeb1fdbeb322fc5d","analyzedAt":"2026-09-16T06:35:29.214Z","contentChangedAt":"2026-09-16T06:35:29.214Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}