{"record":{"id":"4874b61f3b9be7ff","repo":"santifer/career-ops","slug":"justjoin-url-path-must-be-job-offers-or-api-can","errorCode":null,"errorMessage":"justjoin: URL path must be /job-offers or /api/candidate-api/offers: ${url}","messagePattern":"justjoin: URL path must be /job-offers or /api/candidate-api/offers: (.+?)","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"providers/justjoin.mjs","lineNumber":26,"sourceCode":"const ALLOWED_HOSTS = new Set(['justjoin.it']);\nconst API_BASE = 'https://justjoin.it/api/candidate-api/offers';\nconst JOB_BASE = 'https://justjoin.it/job-offer/';\nconst PAGE_SIZE = 100;\nconst MAX_PAGES = 50;\n\nfunction assertJustJoinUrl(url) {\n  let parsed;\n  try {\n    parsed = new URL(url);\n  } catch {\n    throw new Error(`justjoin: invalid URL: ${url}`);\n  }\n  if (parsed.protocol !== 'https:') throw new Error(`justjoin: URL must use HTTPS: ${url}`);\n  if (!ALLOWED_HOSTS.has(parsed.hostname)) {\n    throw new Error(`justjoin: untrusted hostname \"${parsed.hostname}\" — must be justjoin.it`);\n  }\n  if (!parsed.pathname.startsWith('/job-offers') && parsed.pathname !== '/api/candidate-api/offers') {\n    throw new Error(`justjoin: URL path must be /job-offers or /api/candidate-api/offers: ${url}`);\n  }\n  return parsed;\n}\n\nfunction detectUrl(entry) {\n  const url = entry.api || entry.careers_url || '';\n  if (typeof url !== 'string' || !url.trim()) return null;\n  try {\n    const parsed = assertJustJoinUrl(url);\n    return { url: parsed.href };\n  } catch {\n    return null;\n  }\n}\n\nfunction normalizeLocation(offer) {\n  const parts = [];\n  const workplace = String(offer?.workplaceType || '').trim();","sourceCodeStart":8,"sourceCodeEnd":44,"githubUrl":"https://github.com/santifer/career-ops/blob/aac998c7ed7248ea853b720ceeb1fdbeb322fc5d/providers/justjoin.mjs#L8-L44","documentation":"Thrown by assertJustJoinUrl when the hostname is trusted but the path is neither under /job-offers nor exactly /api/candidate-api/offers. The provider only knows how to build pagination requests against the candidate offers API, so arbitrary justjoin.it pages (homepage, blog, a single job-offer detail page) are rejected. Browser URLs under /job-offers/... are accepted for detection but fetches are rewritten to the API path (see buildApiUrl).","triggerScenarios":"entry.careers_url or entry.api set to e.g. 'https://justjoin.it/' , 'https://justjoin.it/job-offer/some-slug' (singular), or 'https://justjoin.it/some-other-page' — anything whose pathname fails startsWith('/job-offers') and !== '/api/candidate-api/offers'. Raised from buildApiUrl during fetch if entry.api has a bad path that wasn't caught earlier (detect returns null for bad paths, so usually this fires when entry.api is valid-host but wrong-path while careers_url is absent).","commonSituations":"Configuring the root domain as careers_url; copying an individual job-ad URL (job-offer/ slug) instead of the listing path; renaming after JustJoin moved from /job-offers to other routes; hand-building an API URL with a typo in the path.","solutions":["Set the URL path to /job-offers (browser listing) or exactly /api/candidate-api/offers (API) in portals.yml","Prefer omitting api entirely and letting the provider default to the correct API_BASE","Fix typos — the API path must match exactly, including the /api/candidate-api/ prefix"],"exampleFix":"// before (portals.yml)\ncareers_url: https://justjoin.it/job-offer/frontend-developer\n// after\ncareers_url: https://justjoin.it/job-offers","handlingStrategy":"validation","validationCode":"function hasAcceptedPath(url) {\n  try {\n    const p = new URL(url);\n    return p.hostname === 'justjoin.it' &&\n      (p.pathname.startsWith('/job-offers') || p.pathname === '/api/candidate-api/offers');\n  } catch { return false; }\n}","typeGuard":"const hasAcceptedPath = (u) => { try { const p = new URL(u); return p.pathname.startsWith('/job-offers') || p.pathname === '/api/candidate-api/offers'; } catch { return false; } };","tryCatchPattern":"try {\n  await provider.fetch(entry, ctx);\n} catch (e) {\n  if (e.message.startsWith('justjoin: URL path must be')) {\n    console.error(`Point the entry at /job-offers or /api/candidate-api/offers: ${e.message}`);\n  } else throw e;\n}","preventionTips":["Copy URLs from the job listing page (justjoin.it/job-offers), not a single ad page","Omit api and let the provider default to the correct API endpoint","Note the singular/plural trap: /job-offer/<slug> is rejected, /job-offers is accepted"],"tags":["validation","url","config"],"backgroundTag":"invalid-url","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"}