santifer/career-ops · error · Error

vdab: entry "${entry.name || '(unnamed)'}" has no vdab.keywo

Error message

vdab: entry "${entry.name || '(unnamed)'}" has no vdab.keywords[] and no config/profile.yml target_roles to fall back to

What it means

The VDAB provider queries Flanders' public employment service search API, which is keyword-driven (there is no board-wide listing endpoint). It resolves search terms from entry.vdab.keywords[], then falls back to config/profile.yml target_roles.primary[] plus target_roles.archetypes[].name via _profile-keywords.mjs. This throws when both sources are empty after trimming/dedup, because a keywordless POST would return an unbounded nationwide dump.

Source

Thrown at providers/vdab.mjs:194

export default {
  id: 'vdab',

  /**
   * Fetches and normalizes postings from VDAB's vacatureLight search API.
   * @param {{ name?: string, vdab?: any }} entry
   * @param {{ fetchJson: (url: string, opts?: object) => Promise<any>, fetchText: (url: string, opts?: object) => Promise<string> }} ctx
   * @returns {Promise<Array<{title: string, url: string, company: string, location: string, postedAt?: number}>>}
   */
  async fetch(entry, ctx) {
    const { days, size, fetchDetails, detailLimit, keywords: ownKeywords } = parseVdabConfig(entry);
    let keywords = ownKeywords;
    // Fall back to config/profile.yml's target_roles when this entry has no
    // vdab.keywords[] of its own — most users who onboarded already have
    // target roles recorded, so this avoids duplicating that into every
    // keyword-required provider's config by hand.
    if (!keywords.length) keywords = resolveProfileKeywords();
    if (!keywords.length) {
      throw new Error(`vdab: entry "${entry.name || '(unnamed)'}" has no vdab.keywords[] and no config/profile.yml target_roles to fall back to`);
    }

    // Scoped to this fetch() call: try the hardcoded key first (fast path);
    // on a 403 (VDAB rotated it), re-derive once from the live bundle and
    // keep using the fresh key for every remaining request this run.
    let activeKey = VEJ_KEY_MONITOR;
    let rederiveAttempted = false;

    /**
     * Runs a VDAB JSON request with the active public frontend key. If VDAB
     * rotates that key, re-derive it once from the live bundle and retry.
     *
     * @param {string} url
     * @param {object} requestOpts
     */
    const keyedFetchJson = async (url, requestOpts) => {
      const opts = {
        ...requestOpts,

View on GitHub (pinned to 9b17a8ac97)

Solutions

  1. Add a vdab: { keywords: ["Machine Learning Engineer", "Data Scientist"] } block to the portals.yml entry.
  2. Alternatively populate target_roles: { primary: [...] } in config/profile.yml so every keyword-required provider inherits it.
  3. Verify config/profile.yml parses cleanly, since resolveProfileKeywords swallows parse errors and returns [] (run node doctor.mjs --json).
  4. Confirm the entry has enabled: true and provider: vdab exactly.

Example fix

# before
- name: VDAB — AI/ML Vlaanderen
  provider: vdab
  enabled: true
# after
- name: VDAB — AI/ML Vlaanderen
  provider: vdab
  enabled: true
  vdab:
    keywords: ["Machine Learning Engineer", "Data Scientist"]
    days: 30
    size: 100
Defensive patterns

Strategy: validation

Validate before calling

import { readFileSync } from "fs";
import * as yaml from "js-yaml";

function vdabEntryHasKeywords(entry, profilePath = "config/profile.yml") {
  const kws = Array.isArray(entry?.vdab?.keywords)
    ? entry.vdab.keywords.filter(k => typeof k === "string" && k.trim()).map(k => k.trim())
    : [];
  if (kws.length) return true;
  try {
    const p = yaml.load(readFileSync(profilePath, "utf8")) || {};
    const roles = p?.target_roles || {};
    const fallback = [
      ...(Array.isArray(roles.primary) ? roles.primary : []),
      ...(Array.isArray(roles.archetypes) ? roles.archetypes.map(a => a?.name) : []),
    ].filter(s => typeof s === "string" && s.trim());
    return fallback.length > 0;
  } catch { return false; }
}

// before invoking the provider:
if (entry.provider === "vdab" && !vdabEntryHasKeywords(entry)) {
  console.warn(`skip ${entry.name}: add vdab.keywords or target_roles`);
  continue;
}

Type guard

const isNonEmptyStringArray = (v) =>
  Array.isArray(v) && v.every(x => typeof x === "string") && v.some(x => x.trim());

Prevention

When it happens

Trigger: A job_boards/tracked_companies entry with provider: vdab whose vdab: block is absent or whose vdab.keywords is missing/empty/all-whitespace, AND config/profile.yml does not exist, fails to parse (resolveProfileKeywords fails open to []), or has an empty target_roles block. The "(unnamed)" variant fires when entry.name is also unset.

Common situations: User copied a VDAB sample entry but forgot to fill in keywords; onboarding started but target_roles never populated; profile.yml has a YAML indentation error that silently fails to parse; the entry was renamed so its name reads (unnamed).

Related errors


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