santifer/career-ops · error · Error

justjoin: invalid URL

Error message

justjoin: invalid URL: ${url}

What it means

assertJustJoinUrl in the JustJoin.it provider throws this when new URL(url) itself fails — i.e. the string is not a parseable absolute URL (missing scheme, whitespace, garbage). It is a fail-fast input validation guarding fetches so only well-formed https justjoin.it URLs ever reach the network layer. Related but distinct throws handle HTTPS violations, untrusted hostnames, and bad paths; this one is purely about URL parseability.

Solutions

  1. Prefix the scheme in the portal entry: set api/careers_url to the full 'https://justjoin.it/api/candidate-api/offers' (or an https://justjoin.it/job-offers/... URL)
  2. Trim whitespace and strip wrapping quotes from the configured URL before it reaches the provider
  3. Validate the entry config at load time (e.g. new URL(entry.api ?? entry.careers_url)) so a malformed URL fails at startup with a clear config message rather than mid-scan
  4. If the value comes from an env var or template, confirm it was substituted — a literal '${...}' placeholder will never parse
  5. Check portals.yml for typos (colon-slash count, protocol spelling) in the justjoin entry

Example fix

// before (portals.yml entry)
companies:
  - name: example
    api: justjoin.it/api/candidate-api/offers
// after
companies:
  - name: example
    api: https://justjoin.it/api/candidate-api/offers
Defensive patterns

Strategy: validation

Validate before calling

function isValidJustJoinUrl(url) {
  if (typeof url !== 'string') return false;
  let parsed;
  try { parsed = new URL(url.trim()); } catch { return false; }
  return parsed.protocol === 'https:'
    && parsed.hostname === 'justjoin.it'
    && (parsed.pathname.startsWith('/job-offers') || parsed.pathname === '/api/candidate-api/offers');
}
// in portals.yml loading: if (!isValidJustJoinUrl(entry.api)) fail fast with the entry name;

Type guard

function isAbsoluteHttpUrl(value) {
  if (typeof value !== 'string' || !value.trim()) return false;
  try { const u = new URL(value.trim()); return u.protocol === 'https:' || u.protocol === 'http:'; }
  catch { return false; }
}

Try / catch

try {
  const offers = await justjoinProvider.fetch(entry, ctx);
} catch (err) {
  if (String(err.message).includes('invalid URL')) {
    console.error(`justjoin entry ${entry.name} has a malformed api/careers_url: ${err.message} — fix portals.yml`);
    return [];
  }
  throw err;
}

Prevention

When it happens

Trigger: Raised wherever assertJustJoinUrl runs on entry input: buildApiUrl(entry, from) with entry.api set to a relative string ('/api/offers'), a URL missing the scheme ('justjoin.it/api/candidate-api/offers'), containing spaces or control characters, or entry.careers_url being a non-URL string passed straight through; also detect(), though detect catches the throw internally and returns null.

Common situations: Portal entry in portals.yml has api: 'justjoin.it/api/candidate-api/offers' (no https://); a copy-pasted URL carries trailing whitespace/newline or surrounding quotes; someone configured a relative path assuming the base is implied; an environment variable or template placeholder left unfilled so the value is a literal like '${API_URL}'.

Understand the failure class

Background: "Invalid URL" errors: why new URL(), URI.parse, and reqwest::Url reject your string — missing scheme, whitespace, and bad path format — this error's family across 39 libraries.

Related errors


AI-assisted analysis of santifer/career-ops@aac998c7ed (2026-09-16). Data as JSON: /api/errors/4528aff245591b42. Report an issue: GitHub.

Appendix: source

Thrown at providers/justjoin.mjs:19

// @ts-check
/** @typedef {import('./_types.js').Provider} Provider */

// JustJoin.it provider — hits the current candidate offers API.
// Browser URLs under https://justjoin.it/job-offers/... are accepted for
// detection, but fetches use https://justjoin.it/api/candidate-api/offers.

const ALLOWED_HOSTS = new Set(['justjoin.it']);
const API_BASE = 'https://justjoin.it/api/candidate-api/offers';
const JOB_BASE = 'https://justjoin.it/job-offer/';
const PAGE_SIZE = 100;
const MAX_PAGES = 50;

function assertJustJoinUrl(url) {
  let parsed;
  try {
    parsed = new URL(url);
  } catch {
    throw new Error(`justjoin: invalid URL: ${url}`);
  }
  if (parsed.protocol !== 'https:') throw new Error(`justjoin: URL must use HTTPS: ${url}`);
  if (!ALLOWED_HOSTS.has(parsed.hostname)) {
    throw new Error(`justjoin: untrusted hostname "${parsed.hostname}" — must be justjoin.it`);
  }
  if (!parsed.pathname.startsWith('/job-offers') && parsed.pathname !== '/api/candidate-api/offers') {
    throw new Error(`justjoin: URL path must be /job-offers or /api/candidate-api/offers: ${url}`);
  }
  return parsed;
}

function detectUrl(entry) {
  const url = entry.api || entry.careers_url || '';
  if (typeof url !== 'string' || !url.trim()) return null;
  try {
    const parsed = assertJustJoinUrl(url);
    return { url: parsed.href };
  } catch {

View on GitHub (pinned to aac998c7ed)