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
- Add a vdab: { keywords: ["Machine Learning Engineer", "Data Scientist"] } block to the portals.yml entry.
- Alternatively populate target_roles: { primary: [...] } in config/profile.yml so every keyword-required provider inherits it.
- Verify config/profile.yml parses cleanly, since resolveProfileKeywords swallows parse errors and returns [] (run node doctor.mjs --json).
- 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
- Always pair a provider: vdab entry with a vdab.keywords block, even if target_roles is set — keeps the entry self-describing.
- Run node doctor.mjs --json after onboarding to confirm config/profile.yml is present and parseable.
- Lint portals.yml in CI for any provider: vdab row lacking keywords.
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
- gem: cannot derive board id for ${entry.name}
- getro: ${entry.name} needs a numeric 'getro_collection' in p
- wttj: the WTTJ board is global — configure explicit searches
- Unsupported profile photo style: ${style} (expected rounded,
- ROW_NOT_FOUND
AI-assisted analysis of santifer/career-ops@9b17a8ac97 (2026-08-13).
Data as JSON: /api/errors/9dfb2dda44f8643a.
Report an issue: GitHub.