feat: release regional radar and mailbox integrations
deploy / deploy (push) Canceled after 0s

This commit is contained in:
Jens
2026-07-22 05:12:07 +02:00
parent 598d3ec18a
commit 551d0f46c2
131 changed files with 6209 additions and 336 deletions
+6 -9
View File
@@ -30,15 +30,12 @@ SMTP_USER=
SMTP_PASSWORD=
SMTP_USE_TLS=1
# Dedicated IMAP-mailbox. Geen gewone hoofdmailbox gebruiken.
IMAP_ENABLED=0
IMAP_HOST=
IMAP_PORT=993
IMAP_USER=
IMAP_PASSWORD=
IMAP_MAILBOX=INBOX
IMAP_USE_SSL=1
IMAP_MARK_SEEN=0
# Roteerbare Fernet-sleutels voor app-wachtwoorden; actieve sleutel eerst.
# Genereer met: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
MAILBOX_CREDENTIAL_KEYS=
IMAP_CONNECT_TIMEOUT_SECONDS=15
IMAP_MAX_MESSAGES_PER_POLL=200
IMAP_MAX_MESSAGE_BYTES=1000000
# Fetcher
FETCHER_USER_AGENT=VacatureRadar/0.1 (+local-personal-use; contact=local-admin)
+132
View File
@@ -2,6 +2,138 @@
Alle betekenisvolle wijzigingen worden hier bijgehouden. Het project volgt voorlopig een pre-1.0 semantische versieaanpak.
## 0.2.10 — 2026-07-22
### Gewijzigd
- De officiële Belgische GeoNames-postcodedataset 2026-04-03 is lokaal geladen onder CC BY 4.0; postcode 2400 is gecontroleerd als Mol met benaderend postcodecentrum 51.1919, 5.1166.
- Gemeente Mol, Ziekenhuis Geel, lokaal bestuur Geel, Stad Turnhout, Group Renotec, Ravago, Sanofi Geel en DAF Trucks Westerlo zijn als echte, gereviewde bronnen geactiveerd.
- Twaalf interessante regionale werkgevers zijn als afzonderlijke, niet-scanbare waaklijst toegevoegd, ook wanneer zij momenteel geen lokale vacature publiceren.
- De bronnenpagina toont 21 actieve bronnen, 12 waaklijstwerkgevers en de actieve GeoNames-afstandsstatus; waaklijstbronnen vallen buiten bulkacties en geplande scans.
- HTTP-, robots- en IMAP-verbindingen gebruiken expliciet de gepinde `certifi`-CA-bundel met verplichte hostnaam- en certificaatcontrole. Gemeente Mol en Ziekenhuis Geel werken daarmee zonder TLS-bypass.
### Verificatie
- De 8 nieuwe actieve bronnen leverden samen 53 kaarten per cyclus en voltooiden elk twee livecycli zonder parserwarnings; de tweede cycli maakten geen nieuwe dubbelen aan.
- GeoNames valideerde 2.781 unieke Belgische postcode-/gemeenterijen; bron- en afgeleide SHA-256-checksums zijn vastgelegd en het actieve profiel is opnieuw gescoord.
- 215 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,42% branch-aware codedekking; Ruff, Django, migraties, taakledger en repositoryvalidatie zijn groen.
- De Codex-browsercontrole bevestigde 21 actieve bronnen, 12 waaklijstkaarten, 8 nieuwe actieve werkgevers, één `h1`, een lege console en geen horizontale overflow op desktop of 390 px.
## 0.2.9 — 2026-07-22
### Gewijzigd
- Het actieve zoekprofiel gebruikt postcode 2400 en een straal van 40 km; de bronnenpagina maakt ontbrekende geocodedata expliciet zonder een afstand te verzinnen.
- SCK CEN, Cipal Schaubroeck, VanRoey, NTX/Netropolix en Thomas More zijn als gereviewde werkgeversbronnen rond Mol toegevoegd.
- De nieuwe regionale adapter accepteert alleen vijf exacte HTTPS-routes en gebruikt host-, pad-, plaats- en waar nodig werkgever-GUID-filters; login-, apply- en kandidaatendpoints blijven uitgesloten.
- Gemeente Mol en Ziekenhuis Geel zijn onderzocht maar niet geactiveerd omdat hun careersites de TLS-certificaatcontrole niet doorstonden.
### Verificatie
- De vijf nieuwe bronnen voltooiden twee livecycli zonder parserwarnings; Cipal verwerkte de tweede cyclus correct als HTTP 304. De radar toont 13 actieve bronnen en 210 actieve vacature-aliassen.
- 206 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,12% branch-aware codedekking.
- De Codex-browsercontrole bevestigde postcode 2400, 40 km, 13 bronkaarten, een lege console en geen horizontale overflow op desktop of 360 px.
## 0.2.8 — 2026-07-22
### Gewijzigd
- EuroBrussels en BrusselsJobs zijn uit de actuele platformkeuzes verwijderd; Careerjet, Randstad en Robert Half zijn toegevoegd met hun officiële vacaturealertingangen.
- Vijf gereviewde werkgeversbronnen uit de kernregio HasseltGenk zijn live toegevoegd: ACA Group, Xplore Group, Universiteit Hasselt, Hogeschool PXL en Ziekenhuis Oost-Limburg.
- Zeven eerder toegevoegde werkgevers buiten de regio zijn veilig uitgeschakeld en verborgen, met behoud van historische vacatures en bronprovenance.
- De regionale werkgeversadapter beperkt links tot vooraf gereviewde HTTPS-hosts en paden; vacaturedata blijft door bronbeleid, SSRF-validatie en sanitization gaan.
### Verificatie
- Alle vijf nieuwe werkgeversbronnen voltooiden twee geslaagde livecycli zonder parserwarnings; samen met VITO, Corda Campus en RPM Europe Logistics zijn 8 bronnen actief en 162 vacature-aliassen beschikbaar.
- 200 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 83,90% branch-aware codedekking.
- De Codex-browsercontrole bevestigde 9 actuele platformkeuzes, 8 regionale bronkaarten, een lege console en geen horizontale overflow op desktop of 375 px.
## 0.2.7 — 2026-07-22
### Gewijzigd
- Platformmailboxen ondersteunen nu ook LinkedIn, ictjob.be, Jobat, StepStone, EuroBrussels en BrusselsJobs naast VDAB en Indeed.
- De bronnenpagina verwijst per jobboard naar de actuele officiële alertconfiguratie; elk platform behoudt een eigen mailbox, interval, status en synchronisatieactie.
- Platformgebonden mailimport accepteert uitsluitend gelabelde HTTPS-links op het gekozen jobboarddomein en verwerpt externe tracking-, account-, help-, privacy- en uitschrijflinks.
- Alle brede jobboards blijven op de directe-fetchdenylist; VacatureRadar logt alleen in op de mailbox en nooit op het jobboard.
### Verificatie
- 192 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 83,61% branch-aware codedekking.
- De Codex-browsercontrole bevestigde acht platformkeuzes en alertkaarten, één `h1`, een lege console en geen horizontale overflow op desktop of 390 px.
## 0.2.6 — 2026-07-22
### Gewijzigd
- VDAB en Indeed kunnen elk aan een afzonderlijke Gmail-, Outlook- of aangepaste IMAP-mailbox worden gekoppeld vanuit de bronpagina.
- Iedere mailbox kiest een eigen interval van 5 minuten tot 6 uur en kan afzonderlijk worden gewijzigd, gepauzeerd of direct gesynchroniseerd.
- App-wachtwoorden worden met een aparte roteerbare MultiFernet-sleutel versleuteld; ciphertext, wachtwoorden en ruwe serverfouten worden niet in de UI, logs of taakresultaten getoond.
- De scheduler gebruikt per-mailboxleases en idempotentie per mailbox + Message-ID; een falende mailbox blokkeert andere platformmailboxen niet.
- Aangepaste IMAP-hosts worden op publieke DNS/IP-adressen gecontroleerd voordat een TLS-verbinding wordt gestart.
### Verificatie
- 178 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 83,56% branch-aware codedekking.
- De Codex-browsercontrole bevestigde de platformmailboxformulieren en statussen, één `h1`, een lege console en geen horizontale overflow op desktop of 390 px.
## 0.2.5 — 2026-07-22
### Gewijzigd
- De officiële VDAB-jobalertingang draait standaard eenmaal per dag om 06:15 en is via omgevingsvariabelen configureerbaar.
- VDAB-mails krijgen herkenbare provenance en hun vacatures stromen idempotent door de bestaande normalisatie-, dedupe- en rankingpipeline zonder automatische platformfetch.
- Mailboxbatches zijn begrensd op aantal en berichtgrootte; een fout of oversized bericht stopt andere mails niet.
- De bronpagina toont planning, configuratiestatus, laatste VDAB-import, een handmatige inplanknop en het onderscheid met de officiële VDAB Vacature API waarvoor partnershipgoedkeuring nodig is.
### Verificatie
- 164 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 83,55% branch-aware codedekking.
- De Codex-browsercontrole bevestigde de dagelijkse VDAB-sectie, officiële links, één `h1`, een lege console en geen horizontale overflow op desktop of 390 px.
## 0.2.4 — 2026-07-22
### Gewijzigd
- De livecatalogus groeit van twee naar tien gereviewde bronnen, met lokale Corda Campus-vacatures en werkgeversfeeds uit Genk, Brussel en België-breed.
- Gedeelde ATS-domeinen ondersteunen nu meerdere werkgevers op basis van hun exacte publieke feed-URL zonder records te overschrijven.
- De SmartRecruiters-adapter ondersteunt het gedocumenteerde publieke `content`-formaat, stabiele vacaturelinks, locaties, contracttype en remote-status.
- Corda Campus heeft een begrensde listingparser die werkgever, titel en regio bewaart en off-domain vacaturelinks weigert.
- De bronpagina legt VDAB- en Indeed-ingress via officiële vacaturealerts uit; directe platformcrawling blijft geblokkeerd.
### Verificatie
- Acht nieuwe bronnen leverden 84 actuele bronrecords in twee geslaagde livecycli zonder parserwarnings; lokaal staan 10 bronnen actief met 145 actieve vacatures.
- 159 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 82,91% branch-aware codedekking.
- De in-app-browsercontrole vond 10 bronkaarten, 2 platformalertlinks, geen horizontale overflow en geen consolefouten.
## 0.2.3 — 2026-07-21
### Gewijzigd
- Thuislocatie vraagt alleen nog een Belgische postcode; afgeleide gemeente en coördinaten zijn geen handmatige velden meer.
- Functietitels, skills, contractvormen, werkmodellen en regio's gebruiken toegankelijke checkboxgroepen met behoud van bestaande waarden.
- VITO en Deliverect zijn als gereviewde, functionele werkgeverfeeds toegevoegd en na twee geslaagde cycli geactiveerd.
- Recruitee `offers` en de publieke Lever Postings API worden volledig verwerkt, inclusief werkgeveridentiteit, directe-bronmarkering en actuele inventaristelling na HTTP 304.
- Robotsredirects worden opnieuw op publieke adressen gevalideerd; de oude fixturebron verdwijnt uit het normale bronoverzicht.
## 0.2.2 — 2026-07-21
### Gewijzigd
- Tweede Stitch-pass (`VR-119`) maakt de aangeleverde schermcompositie leidend in plaats van alleen de kleuren en componenttokens.
- De utilitybar bevat nu de brede vacaturezoeker en directe zoekprofielactie uit Stitch.
- Dashboard en vacatureoverzicht gebruiken een dichte, enkelkoloms radarfeed met echte scores, datakwaliteit en acties; één resultaat vult niet langer slechts een halve lege grid.
- Vacaturedetail heeft een echte intelligence-strip; pipeline, profieleditor, bronnenbeheer en systeemstatus volgen de bijbehorende Stitch-hiërarchie veel directer.
- Handmatige import is een compacte disclosure zodat brongezondheid opnieuw de primaire compositie vormt.
- Alle zichtbare profieleditorlabels zijn Nederlandstalig en de lokale favicon voorkomt een ontbrekend browserasset.
### Verificatie
- 146 tests geslaagd, 2 optionele lokale Playwrightvarianten overgeslagen en 82,02% branch-aware codedekking.
- Browsermatrix geslaagd op 1440×900, 1280×800, 1024×768, 768×1024, 390×844 en 360×800, inclusief dark/light, mobiele drawer, één `h1` en geen pagina-overflow.
## 0.2.1 — 2026-07-21
### Toegevoegd
+11 -2
View File
@@ -4,9 +4,18 @@ from typing import Any
from django.http import HttpRequest
from apps.profiles.models import SearchProfile
def navigation_context(request: HttpRequest) -> dict[str, Any]:
return {
context = {
"app_name": "VacatureRadar",
"app_version": "0.2.1",
"app_version": "0.2.2",
}
if request.user.is_authenticated:
context["navigation_profile"] = (
SearchProfile.objects.filter(user=request.user, is_active=True)
.only("id", "name")
.first()
)
return context
+7 -1
View File
@@ -102,6 +102,10 @@ class TodayView(LoginRequiredMixin, TemplateView):
context.update(
{
"score_cards": cards,
"priority_applications": Application.objects.filter(user=self.request.user)
.exclude(status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN])
.select_related("job", "job__employer")
.order_by("follow_up_date", "-updated_at")[:3],
"active_jobs": JobPosting.objects.filter(status=JobPosting.Status.ACTIVE).count(),
"new_today": JobPosting.objects.filter(
first_seen__date=timezone.localdate()
@@ -109,7 +113,9 @@ class TodayView(LoginRequiredMixin, TemplateView):
"applications_open": Application.objects.exclude(
status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN]
).count(),
"source_counts": Source.objects.aggregate(
"source_counts": Source.objects.exclude(
domain="jobs.example.org", status=Source.Status.DISABLED
).aggregate(
total=Count("id"),
unhealthy=Count("id", filter=~Q(status=Source.Status.ACTIVE)),
),
+21 -1
View File
@@ -8,6 +8,7 @@ from django.utils import timezone
from apps.jobs.models import Employer, FieldProvenance, JobPosting, JobSourceAlias, JobVersion
from apps.profiles.models import SearchProfile
from apps.sources.adapters.base import FieldEvidence
from apps.sources.adapters.registry import registry
from apps.sources.models import RawDocument, Source
from apps.sources.services.policy import is_denied_domain
@@ -89,7 +90,10 @@ def _source_is_direct(source: Source | None, draft: CanonicalJobDraft) -> bool:
host = (urlsplit(draft.canonical_url).hostname or "").lower()
if is_denied_domain(host):
return False
return bool(source and source.source_type == Source.Type.EMPLOYER)
if not source:
return False
metadata = source.metadata if isinstance(source.metadata, dict) else {}
return source.source_type == Source.Type.EMPLOYER or metadata.get("direct_employer") is True
def _alias_payload(
@@ -314,6 +318,22 @@ def process_raw_document(document: RawDocument) -> dict[str, int | str | list[st
)
created = updated = duplicates = 0
for extracted in result.jobs:
source_metadata = (
document.source.metadata
if document.source and isinstance(document.source.metadata, dict)
else {}
)
configured_employer = str(source_metadata.get("employer_name") or "").strip()
if configured_employer:
extracted.employer_name = configured_employer
extracted.evidence.append(
FieldEvidence(
"employer_name",
"reviewed-source-config",
0.99,
configured_employer,
)
)
draft = normalize_extracted_job(extracted)
_, decision, was_created = persist_draft(
draft,
+19 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Q
from django.db.models import CharField, DecimalField, OuterRef, Q, Subquery
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
@@ -31,6 +31,24 @@ class JobListView(LoginRequiredMixin, ListView):
def get_queryset(self):
queryset = JobPosting.objects.select_related("employer").all()
profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
if profile:
latest_score = ScoreRun.objects.filter(job=OuterRef("pk"), profile=profile).order_by(
"-created_at"
)
queryset = queryset.annotate(
match_score=Subquery(
latest_score.values("score")[:1],
output_field=DecimalField(max_digits=5, decimal_places=2),
),
match_confidence=Subquery(
latest_score.values("confidence")[:1],
output_field=DecimalField(max_digits=4, decimal_places=3),
),
match_recommendation=Subquery(
latest_score.values("recommendation")[:1], output_field=CharField()
),
)
query = self.request.GET.get("q", "").strip()
status = self.request.GET.get("status", "active").strip()
workplace = self.request.GET.get("workplace", "").strip()
+194 -36
View File
@@ -1,30 +1,141 @@
from __future__ import annotations
import re
from django import forms
from .models import SearchProfile
TITLE_CHOICES = (
("system engineer", "System engineer"),
("infrastructure engineer", "Infrastructure engineer"),
("cloud engineer", "Cloud engineer"),
("devops engineer", "DevOps engineer"),
("network engineer", "Network engineer"),
("security engineer", "Security engineer"),
("workplace engineer", "Workplace engineer"),
("support engineer", "Support engineer"),
("software engineer", "Software engineer"),
("data engineer", "Data engineer"),
("solution architect", "Solution architect"),
("project manager", "Projectmanager"),
)
EXCLUDED_TITLE_CHOICES = (
("sales", "Sales"),
("recruiter", "Recruiter"),
("account manager", "Accountmanager"),
("callcenter", "Callcenter"),
("stage", "Stage"),
("student", "Studentenjob"),
)
SKILL_CHOICES = (
("azure", "Microsoft Azure"),
("aws", "AWS"),
("microsoft 365", "Microsoft 365"),
("active directory", "Active Directory"),
("entra id", "Entra ID"),
("intune", "Microsoft Intune"),
("linux", "Linux"),
("windows server", "Windows Server"),
("networking", "Netwerken"),
("security", "Security"),
("python", "Python"),
("powershell", "PowerShell"),
("terraform", "Terraform"),
("docker", "Docker"),
("kubernetes", "Kubernetes"),
("ci/cd", "CI/CD"),
)
EXCLUDED_SKILL_CHOICES = (
("cold calling", "Cold calling"),
("door to door", "Deur-aan-deurverkoop"),
("commission only", "Alleen commissieloon"),
("night shift", "Nachtwerk"),
)
EMPLOYMENT_CHOICES = (
("full_time", "Voltijds"),
("part_time", "Deeltijds"),
("permanent", "Vast contract"),
("fixed_term", "Tijdelijk contract"),
("freelance", "Freelance"),
("internship", "Stage"),
)
WORKPLACE_CHOICES = (
("hybrid", "Hybride"),
("remote", "Volledig op afstand"),
("on_site", "Op locatie"),
)
REGION_CHOICES = (
("Antwerpen", "Antwerpen"),
("Brussels Hoofdstedelijk Gewest", "Brussel"),
("Henegouwen", "Henegouwen"),
("Limburg", "Limburg"),
("Luik", "Luik"),
("Luxemburg", "Luxemburg"),
("Namen", "Namen"),
("Oost-Vlaanderen", "Oost-Vlaanderen"),
("Vlaams-Brabant", "Vlaams-Brabant"),
("Waals-Brabant", "Waals-Brabant"),
("West-Vlaanderen", "West-Vlaanderen"),
)
class SearchProfileForm(forms.ModelForm):
desired_titles_text = forms.CharField(
desired_titles = forms.MultipleChoiceField(
label="Gewenste functietitels",
required=False,
widget=forms.Textarea(attrs={"rows": 4, "placeholder": "Eén titel per regel"}),
choices=TITLE_CHOICES,
widget=forms.CheckboxSelectMultiple,
help_text="Selecteer alle rollen die bij je zoekrichting passen.",
)
excluded_titles_text = forms.CharField(
label="Uitgesloten titelwoorden",
excluded_titles = forms.MultipleChoiceField(
label="Uitgesloten functietitels",
required=False,
widget=forms.Textarea(attrs={"rows": 3, "placeholder": "Eén term per regel"}),
choices=EXCLUDED_TITLE_CHOICES,
widget=forms.CheckboxSelectMultiple,
help_text="Vacatures met deze titelwoorden worden hard uitgesloten.",
)
desired_skills_text = forms.CharField(
desired_skills = forms.MultipleChoiceField(
label="Gewenste skills",
required=False,
widget=forms.Textarea(attrs={"rows": 4, "placeholder": "Eén skill per regel"}),
choices=SKILL_CHOICES,
widget=forms.CheckboxSelectMultiple,
)
excluded_skills_text = forms.CharField(
label="Uitgesloten skills",
excluded_skills = forms.MultipleChoiceField(
label="Uitgesloten kenmerken",
required=False,
widget=forms.Textarea(attrs={"rows": 3, "placeholder": "Eén skill per regel"}),
choices=EXCLUDED_SKILL_CHOICES,
widget=forms.CheckboxSelectMultiple,
)
allowed_employment_types = forms.MultipleChoiceField(
label="Toegestane contractvormen",
required=False,
choices=EMPLOYMENT_CHOICES,
widget=forms.CheckboxSelectMultiple,
)
preferred_workplace = forms.MultipleChoiceField(
label="Voorkeurswerkmodel",
required=False,
choices=WORKPLACE_CHOICES,
widget=forms.CheckboxSelectMultiple,
)
preferred_regions = forms.MultipleChoiceField(
label="Voorkeursregio's",
required=False,
choices=REGION_CHOICES,
widget=forms.CheckboxSelectMultiple,
)
excluded_regions = forms.MultipleChoiceField(
label="Uitgesloten regio's",
required=False,
choices=REGION_CHOICES,
widget=forms.CheckboxSelectMultiple,
)
class Meta:
@@ -33,10 +144,11 @@ class SearchProfileForm(forms.ModelForm):
"name",
"is_active",
"home_postal_code",
"home_municipality",
"home_latitude",
"home_longitude",
"max_distance_km",
"desired_titles",
"excluded_titles",
"desired_skills",
"excluded_skills",
"allowed_employment_types",
"preferred_workplace",
"preferred_regions",
@@ -46,35 +158,81 @@ class SearchProfileForm(forms.ModelForm):
"digest_time",
"learning_enabled",
]
labels = {
"name": "Naam",
"is_active": "Actief profiel",
"home_postal_code": "Thuispostcode",
"max_distance_km": "Maximale afstand (km)",
"recommendation_threshold": "Aanbevelingsdrempel",
"top_match_threshold": "Topmatchdrempel",
"digest_time": "Tijdstip dagelijkse samenvatting",
"learning_enabled": "Gecontroleerd leren",
}
help_texts = {
"home_postal_code": (
"Vier cijfers volstaan. Gemeente en coördinaten worden veilig afgeleid wanneer "
"lokale geodata beschikbaar is."
),
"learning_enabled": "Past alleen zachte voorkeuren aan; harde regels blijven vast.",
}
widgets = {
"allowed_employment_types": forms.TextInput(
attrs={"placeholder": '["full_time", "part_time"]'}
"home_postal_code": forms.TextInput(
attrs={"inputmode": "numeric", "pattern": "[0-9]{4}", "maxlength": 4}
),
"preferred_workplace": forms.TextInput(attrs={"placeholder": '["hybrid", "on_site"]'}),
"preferred_regions": forms.TextInput(attrs={"placeholder": '["Limburg"]'}),
"excluded_regions": forms.TextInput(attrs={"placeholder": '["Brussel"]'}),
"digest_time": forms.TimeInput(attrs={"type": "time"}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance and self.instance.pk:
self.fields["desired_titles_text"].initial = "\n".join(self.instance.desired_titles)
self.fields["excluded_titles_text"].initial = "\n".join(self.instance.excluded_titles)
self.fields["desired_skills_text"].initial = "\n".join(self.instance.desired_skills)
self.fields["excluded_skills_text"].initial = "\n".join(self.instance.excluded_skills)
self.fields["home_postal_code"].widget.attrs["maxlength"] = 4
if not self.instance or not self.instance.pk:
return
for field_name in (
"desired_titles",
"excluded_titles",
"desired_skills",
"excluded_skills",
"allowed_employment_types",
"preferred_workplace",
"preferred_regions",
"excluded_regions",
):
self._include_existing_choices(field_name)
@staticmethod
def _lines(value: str) -> list[str]:
return [line.strip() for line in value.splitlines() if line.strip()]
def _include_existing_choices(self, field_name: str) -> None:
field = self.fields[field_name]
stored = getattr(self.instance, field_name, [])
existing_by_casefold = {
str(value).casefold(): str(value) for value, _label in field.choices
}
extra = [
(str(value), str(value))
for value in stored
if str(value).casefold() not in existing_by_casefold
]
field.choices = [*field.choices, *extra]
field.initial = [
existing_by_casefold.get(str(value).casefold(), str(value)) for value in stored
]
def save(self, commit=True):
instance = super().save(commit=False)
instance.desired_titles = self._lines(self.cleaned_data["desired_titles_text"])
instance.excluded_titles = self._lines(self.cleaned_data["excluded_titles_text"])
instance.desired_skills = self._lines(self.cleaned_data["desired_skills_text"])
instance.excluded_skills = self._lines(self.cleaned_data["excluded_skills_text"])
if commit:
instance.save()
self.save_m2m()
return instance
def clean_home_postal_code(self) -> str:
value = str(self.cleaned_data.get("home_postal_code") or "").strip()
if value and not re.fullmatch(r"\d{4}", value):
raise forms.ValidationError("Gebruik een Belgische postcode van vier cijfers.")
return value
def clean(self):
cleaned = super().clean()
overlap_checks = (
("desired_titles", "excluded_titles", "functietitels"),
("desired_skills", "excluded_skills", "skills"),
("preferred_regions", "excluded_regions", "regio's"),
)
for desired_field, excluded_field, label in overlap_checks:
overlap = set(cleaned.get(desired_field) or []) & set(cleaned.get(excluded_field) or [])
if overlap:
self.add_error(
excluded_field,
f"Dezelfde {label} kunnen niet tegelijk gewenst en uitgesloten zijn.",
)
return cleaned
+24
View File
@@ -2,9 +2,33 @@ from __future__ import annotations
from django.db import transaction
from apps.jobs.services.geocoding import LocationMatchResult, resolve_cached_location
from .models import ProfileRevision, SearchProfile
@transaction.atomic
def resolve_profile_home_location(profile: SearchProfile) -> LocationMatchResult:
"""Resolve a user-entered postal code without exposing coordinate fields in the UI."""
query = profile.home_postal_code.strip()
result = (
resolve_cached_location(query) if query else LocationMatchResult(query=query, location=None)
)
location = result.location
profile.home_municipality = location.municipality or "" if location else ""
profile.home_latitude = location.point.latitude if location and location.point else None
profile.home_longitude = location.point.longitude if location and location.point else None
profile.save(
update_fields=[
"home_municipality",
"home_latitude",
"home_longitude",
"updated_at",
]
)
return result
@transaction.atomic
def save_profile_revision(profile: SearchProfile, *, reason: str) -> ProfileRevision:
latest = profile.revisions.order_by("-version").first()
+16 -2
View File
@@ -8,7 +8,7 @@ from django.views.generic import ListView, UpdateView
from .forms import SearchProfileForm
from .models import SearchProfile
from .services import save_profile_revision
from .services import resolve_profile_home_location, save_profile_revision
class ProfileListView(LoginRequiredMixin, ListView):
@@ -31,10 +31,24 @@ class ProfileUpdateView(LoginRequiredMixin, UpdateView):
def form_valid(self, form):
response = super().form_valid(form)
location_result = resolve_profile_home_location(self.object)
if self.object.is_active:
self.object.activate()
save_profile_revision(self.object, reason="user_edit")
messages.success(self.request, "Zoekprofiel opgeslagen.")
if self.object.home_postal_code and location_result.ambiguous:
messages.warning(
self.request,
"Zoekprofiel opgeslagen. Deze postcode omvat meerdere gemeenten; afstand blijft "
"onbekend tot de lokale geodata eenduidig is.",
)
elif self.object.home_postal_code and location_result.location is None:
messages.warning(
self.request,
"Zoekprofiel opgeslagen. De postcode is bewaard; lokale geodata ontbreekt nog "
"voor een afstandsberekening.",
)
else:
messages.success(self.request, "Zoekprofiel opgeslagen.")
return response
+6
View File
@@ -8,6 +8,9 @@ from .ats import (
from .base import ExtractedJob, ExtractionResult
from .generic_html import GenericHtmlAdapter
from .jsonld import JsonLdJobPostingAdapter
from .kempen import KempenEmployerAdapter
from .mol_region import MolRegionEmployerAdapter
from .regional import LocalEmployerListingAdapter
from .rss import RssAdapter
__all__ = [
@@ -16,7 +19,10 @@ __all__ = [
"GenericHtmlAdapter",
"GreenhouseAdapter",
"JsonLdJobPostingAdapter",
"KempenEmployerAdapter",
"LeverAdapter",
"LocalEmployerListingAdapter",
"MolRegionEmployerAdapter",
"RecruiteeAdapter",
"RssAdapter",
"SmartRecruitersAdapter",
+45 -6
View File
@@ -169,16 +169,20 @@ class _AtsAdapter(ABC):
def _extract_location(self, record: dict[str, object]) -> tuple[str, str, str, str]:
location_raw = _first_text(
record,
"location",
"location.city",
"location.name",
"location.address",
"location.raw",
"categories.location",
"city",
"cityName",
"place",
"office",
"location.address",
"address",
"data.location",
"officeLocation",
"locationName",
"location",
)
if not location_raw:
location_raw = _first_text(record, "data.city", "data.location")
@@ -223,6 +227,13 @@ class _AtsAdapter(ABC):
"remote_type",
"job_type",
).lower()
if not workplace_raw:
if record.get("hybrid") is True:
return "hybrid"
if record.get("remote") is True:
return "remote"
if record.get("on_site") is True:
return "on_site"
if "remote" in workplace_raw:
return "remote" if "hybrid" not in workplace_raw else "hybrid"
if "hybrid" in workplace_raw:
@@ -256,18 +267,21 @@ class _AtsAdapter(ABC):
"url",
"jobUrl",
"job_url",
"hostedUrl",
"applyUrl",
"link",
"absoluteUrl",
"careers_url",
"data.url",
)
employer_name = _first_text(
record,
"company_name",
"company.name",
"company",
"employer",
"organization",
"organizationName",
"company.name",
"department",
"hiringOrganization",
)
@@ -290,6 +304,7 @@ class _AtsAdapter(ABC):
"published_at",
"created_at",
"jobCreated",
"releasedDate",
)
valid_through = _first_text(
record,
@@ -308,6 +323,9 @@ class _AtsAdapter(ABC):
"jobType",
"type",
"data.employmentType",
"employment_type_code",
"categories.commitment",
"typeOfEmployment.label",
)
)
workplace_type = self._extract_workplace(record)
@@ -428,13 +446,20 @@ class GreenhouseAdapter(_AtsAdapter):
class LeverAdapter(_AtsAdapter):
parser_key = "ats-lever"
source_hosts = ("jobs.lever.co",)
source_hosts = (
"jobs.lever.co",
"jobs.eu.lever.co",
"api.lever.co",
"api.eu.lever.co",
)
support_markers = ("lever", "requisition", "posting")
listing_paths = (("data",), ("jobs",), ("results",))
detail_paths = (("data",), ("job",), ("position",), ("result",))
closed_statuses = CLOSED_STATUSES | {"archived", "deleted"}
def _extract_records(self, payload):
if isinstance(payload, list):
return [item for item in payload if isinstance(item, dict)]
for path in self.listing_paths:
value = payload
for key in path:
@@ -466,7 +491,7 @@ class RecruiteeAdapter(_AtsAdapter):
parser_key = "ats-recruitee"
source_hosts = ("recruitee.com",)
support_markers = ("recruitee", "career", "vacancy")
listing_paths = (("jobs",), ("data", "jobs"), ("vacancies",))
listing_paths = (("jobs",), ("offers",), ("data", "jobs"), ("vacancies",))
detail_paths = (("job",), ("data", "job"), ("vacancy",), ("result",))
closed_statuses = CLOSED_STATUSES | {"hidden", "paused"}
@@ -494,12 +519,26 @@ class RecruiteeAdapter(_AtsAdapter):
class SmartRecruitersAdapter(_AtsAdapter):
parser_key = "ats-smartrecruiters"
parser_version = "1.1.0"
source_hosts = ("smartrecruiters.com",)
support_markers = ("smartrecruiters", "smart recruiter")
listing_paths = (("jobs",), ("data", "jobs"), ("results",))
listing_paths = (("jobs",), ("data", "jobs"), ("results",), ("content",))
detail_paths = (("job",), ("data", "job"), ("posting",), ("result",))
closed_statuses = CLOSED_STATUSES | {"unpublished", "expired"}
def _to_job(self, record: dict[str, object], base_url: str) -> ExtractedJob:
prepared = dict(record)
if not _first_text(prepared, "url", "jobUrl", "hostedUrl", "applyUrl", "link"):
company_identifier = _first_text(prepared, "company.identifier")
posting_id = _first_text(prepared, "id", "uuid")
if company_identifier and posting_id:
prepared["url"] = (
f"https://jobs.smartrecruiters.com/{company_identifier}/{posting_id}"
)
if _find_nested(prepared, "location.remote") is True:
prepared["remote"] = True
return super()._to_job(prepared, base_url)
def _extract_records(self, payload):
for path in self.listing_paths:
value = payload
+59 -3
View File
@@ -9,17 +9,58 @@ from urllib.parse import urljoin, urlsplit
from bs4 import BeautifulSoup
from apps.sources.platforms import PLATFORM_ALERTS
from apps.sources.services.canonicalize import canonicalize_url
from .base import ExtractedJob, ExtractionResult, FieldEvidence
URL_RE = re.compile(r"https?://[^\s<>\"']+", re.I)
SKIP_TEXT = re.compile(r"unsubscribe|afmelden|uitschrijven|privacy|view in browser", re.I)
SKIP_TEXT = re.compile(
r"unsubscribe|afmelden|uitschrijven|privacy|view in browser|bekijk online|"
r"account|aanmelden|inloggen|login|voorkeuren|preferences|voorwaarden|terms|"
r"contact|help|hulp|over ons|about us",
re.I,
)
SKIP_PATH = re.compile(
r"/(?:unsubscribe|uitschrijven|afmelden|privacy|account|login|signin|preferences|"
r"settings|terms|legal|help|contact)(?:/|$)",
re.I,
)
def _matches_domain(hostname: str, domains: tuple[str, ...]) -> bool:
return any(hostname == domain or hostname.endswith(f".{domain}") for domain in domains)
class EmailAlertAdapter:
parser_key = "email-alert"
parser_version = "1.0.0"
parser_version = "1.1.0"
@staticmethod
def _provider_for_candidates(candidates: list[tuple[str, str]]) -> str:
hostnames = {
(urlsplit(href).hostname or "").lower()
for _, href in candidates
if href.lower().startswith(("http://", "https://"))
}
for provider, alert in PLATFORM_ALERTS.items():
if any(_matches_domain(hostname, alert.domains) for hostname in hostnames):
return provider
return "other"
@staticmethod
def _is_expected_platform_link(*, label: str, href: str, provider: str) -> bool:
alert = PLATFORM_ALERTS.get(provider)
if alert is None or len(label.strip()) < 4:
return False
parsed = urlsplit(href)
hostname = (parsed.hostname or "").lower()
return (
parsed.scheme.lower() == "https"
and _matches_domain(hostname, alert.domains)
and not SKIP_TEXT.search(label)
and not SKIP_PATH.search(parsed.path)
)
@staticmethod
def _decode_parts(message: Message) -> tuple[str, str]:
@@ -41,7 +82,9 @@ class EmailAlertAdapter:
html_parts.append(str(payload))
return "\n".join(plain_parts), "\n".join(html_parts)
def extract_message(self, raw_message: bytes) -> ExtractionResult:
def extract_message(
self, raw_message: bytes, *, expected_provider: str | None = None
) -> ExtractionResult:
message = BytesParser(policy=policy.default).parsebytes(raw_message)
plain, html_body = self._decode_parts(message)
candidates: list[tuple[str, str]] = []
@@ -61,6 +104,18 @@ class EmailAlertAdapter:
continue
candidates.append(("", clean_href))
alert_provider = expected_provider or self._provider_for_candidates(candidates)
if expected_provider:
candidates = [
(label, href)
for label, href in candidates
if self._is_expected_platform_link(
label=label,
href=href,
provider=expected_provider,
)
]
jobs: list[ExtractedJob] = []
seen: set[str] = set()
subject = str(message.get("subject") or "Vacature uit e-mail").strip()
@@ -84,6 +139,7 @@ class EmailAlertAdapter:
"email_subject": subject,
"email_sender": str(message.get("from") or ""),
"target_domain": hostname,
"alert_provider": alert_provider,
},
evidence=[FieldEvidence("url", "email-anchor", 0.75, label[:240])],
)
+290
View File
@@ -0,0 +1,290 @@
from __future__ import annotations
import re
from urllib.parse import parse_qs, urljoin, urlsplit
from bs4 import BeautifulSoup, Tag
from .base import ExtractedJob, ExtractionResult, FieldEvidence
class KempenEmployerAdapter:
"""Extract public employer lists around Mol from exact reviewed routes."""
parser_key = "regional-kempen-employers"
parser_version = "1.0.0"
supported_routes = {
"ziekenhuisgeel.careersite.be": {"/nl/vacatures"},
"geel.hro.be": {"/"},
"jobs.turnhout.be": {"/"},
"jobs.renotec.be": {"/nl/alle-jobs"},
"ravago.softgarden.io": {"/en/vacancies"},
"jobs.sanofi.com": {"/en/belgium"},
"www.daf.com": {"/nl-nl/werken-bij-daf/vacatures"},
}
def _route(self, url: str) -> tuple[str, str] | None:
parts = urlsplit(url)
host = (parts.hostname or "").lower()
path = parts.path.rstrip("/") or "/"
if parts.scheme != "https" or path not in self.supported_routes.get(host, set()):
return None
return host, path
def _supports_url(self, url: str) -> bool:
return self._route(url) is not None
@staticmethod
def _same_host_url(base_url: str, href: str) -> str:
job_url = urljoin(base_url, href)
parts = urlsplit(job_url)
if parts.scheme != "https":
return ""
if (parts.hostname or "").lower() != (urlsplit(base_url).hostname or "").lower():
return ""
return job_url
@staticmethod
def _job(
*,
url: str,
title: str,
employer: str,
location: str,
postal_code: str,
description: str,
external_id: str,
evidence_source: str,
) -> ExtractedJob:
return ExtractedJob(
url=url,
title=title,
employer_name=employer,
external_id=external_id,
location_text=location,
postal_code=postal_code,
region="Antwerpen",
country="BE",
description_text=description,
raw={"regional_listing": evidence_source, "region_center": "2400 Mol"},
evidence=[
FieldEvidence("title", evidence_source, 0.94, title[:240]),
FieldEvidence("employer_name", "reviewed-source", 0.97, employer),
FieldEvidence("location_text", "kempen-location-marker", 0.9, location),
],
)
def _ziekenhuis_geel(self, soup: BeautifulSoup, url: str) -> list[ExtractedJob]:
jobs = []
for card in soup.select(".vacature-tegel"):
link = card.select_one("a.vacature-tegel__link[href]")
title_node = card.select_one(".vacature-tegel__titel")
if not isinstance(link, Tag) or not isinstance(title_node, Tag):
continue
job_url = self._same_host_url(url, str(link.get("href") or ""))
match = re.fullmatch(r"/nl/vacature/(\d+)/[^/]+", urlsplit(job_url).path)
title = title_node.get_text(" ", strip=True)
if not title or not match:
continue
jobs.append(
self._job(
url=job_url,
title=title,
employer="Ziekenhuis Geel",
location="Geel, Antwerpen",
postal_code="2440",
description=card.get_text(" ", strip=True),
external_id=match.group(1),
evidence_source="ziekenhuis-geel-card",
)
)
return jobs
def _stad_geel(self, soup: BeautifulSoup, url: str) -> list[ExtractedJob]:
jobs = []
for card in soup.select(".vacatureKader[data-href]"):
href = str(card.get("data-href") or "")
match = re.fullmatch(r"vacature\.php\?id=(\d+)", href)
title_node = card.find("h4")
title = title_node.get_text(" ", strip=True) if title_node else ""
if not title or not match:
continue
jobs.append(
self._job(
url=self._same_host_url(url, href),
title=title,
employer="Lokaal bestuur Geel",
location="Geel, Antwerpen",
postal_code="2440",
description=card.get_text(" ", strip=True),
external_id=match.group(1),
evidence_source="stad-geel-hro-card",
)
)
return jobs
def _stad_turnhout(self, soup: BeautifulSoup, url: str) -> list[ExtractedJob]:
jobs = []
for link in soup.find_all("a", href=True):
href = str(link.get("href") or "")
match = re.fullmatch(r"vacature\.php\?id=(\d+)", href)
title_node = link.select_one(".block-update__body__title__inner")
title = title_node.get_text(" ", strip=True) if title_node else ""
if not title or not match:
continue
jobs.append(
self._job(
url=self._same_host_url(url, href),
title=title,
employer="Stad Turnhout",
location="Turnhout, Antwerpen",
postal_code="2300",
description=link.get_text(" ", strip=True),
external_id=match.group(1),
evidence_source="stad-turnhout-hro-card",
)
)
return jobs
def _renotec(self, soup: BeautifulSoup, url: str) -> list[ExtractedJob]:
jobs = []
for card in soup.select(".s-tile.s-card"):
card_text = card.get_text(" ", strip=True)
if "Geel" not in card_text:
continue
title_node = card.find("h3")
link = card.find("a", href=True)
title = title_node.get_text(" ", strip=True) if title_node else ""
job_url = self._same_host_url(url, str(link.get("href") or "")) if link else ""
parts = urlsplit(job_url)
query = parse_qs(parts.query)
external_id = (query.get("id") or [""])[0]
if parts.path != "/nl/detail/" or not external_id.isdigit() or not title:
continue
jobs.append(
self._job(
url=job_url,
title=title,
employer="Group Renotec",
location="Geel, Antwerpen (mogelijk meerdere werfregio's)",
postal_code="2440",
description=card_text,
external_id=external_id,
evidence_source="renotec-geel-card",
)
)
return jobs
def _ravago(self, soup: BeautifulSoup, url: str) -> list[ExtractedJob]:
jobs = []
for card in soup.select(".matchElement"):
locations = {
node.get_text(" ", strip=True) for node in card.select(".location-view-item")
}
local_places = locations.intersection({"Arendonk", "Olen"})
if not local_places:
continue
link = card.find("a", href=True)
title = link.get_text(" ", strip=True) if link else ""
job_url = self._same_host_url(url, str(link.get("href") or "")) if link else ""
match = re.fullmatch(r"/job/(\d+)/[^/]+/?", urlsplit(job_url).path)
if not title or not match:
continue
place = "Arendonk" if "Arendonk" in local_places else "Olen"
jobs.append(
self._job(
url=job_url,
title=title,
employer="Ravago",
location=f"{place}, Antwerpen",
postal_code="2370" if place == "Arendonk" else "2250",
description=card.get_text(" ", strip=True),
external_id=match.group(1),
evidence_source="ravago-softgarden-card",
)
)
return jobs
def _sanofi(self, soup: BeautifulSoup, url: str) -> list[ExtractedJob]:
jobs = []
for link in soup.select(".job-list a[data-job-id][href]"):
location_node = link.select_one(".job-location")
title_node = link.select_one(".job-title")
location = location_node.get_text(" ", strip=True) if location_node else ""
title = title_node.get_text(" ", strip=True) if title_node else ""
external_id = str(link.get("data-job-id") or "")
job_url = self._same_host_url(url, str(link.get("href") or ""))
if location != "Geel, Belgium" or not title or not external_id.isdigit():
continue
if not re.fullmatch(r"/en/job/geel/[^/]+/\d+/\d+", urlsplit(job_url).path):
continue
jobs.append(
self._job(
url=job_url,
title=title,
employer="Sanofi",
location="Geel, Antwerpen",
postal_code="2440",
description=link.get_text(" ", strip=True),
external_id=external_id,
evidence_source="sanofi-belgium-geel-card",
)
)
return jobs
def _daf(self, soup: BeautifulSoup, url: str) -> list[ExtractedJob]:
jobs = []
for card in soup.select("li.itemlist__item"):
location_node = card.select_one(".js-vac-metalocation")
title_link = card.select_one("a.js-vac-title[href]")
location = location_node.get_text(" ", strip=True) if location_node else ""
title = title_link.get_text(" ", strip=True) if title_link else ""
job_url = (
self._same_host_url(url, str(title_link.get("href") or "")) if title_link else ""
)
path = urlsplit(job_url).path
prefix = "/nl-nl/werken-bij-daf/vacatures/"
if location != "Westerlo" or not title or not path.startswith(prefix):
continue
slug = path.removeprefix(prefix).strip("/")
if not slug or "/" in slug:
continue
jobs.append(
self._job(
url=job_url,
title=title,
employer="DAF Trucks",
location="Westerlo, Antwerpen",
postal_code="2260",
description=card.get_text(" ", strip=True),
external_id=slug,
evidence_source="daf-westerlo-card",
)
)
return jobs
def extract(self, content: str, *, url: str) -> ExtractionResult:
route = self._route(url)
if route is None:
return ExtractionResult(
[], self.parser_key, self.parser_version, 0.0, ["Onherkende Kempen-bron"]
)
host, _ = route
extractors = {
"ziekenhuisgeel.careersite.be": self._ziekenhuis_geel,
"geel.hro.be": self._stad_geel,
"jobs.turnhout.be": self._stad_turnhout,
"jobs.renotec.be": self._renotec,
"ravago.softgarden.io": self._ravago,
"jobs.sanofi.com": self._sanofi,
"www.daf.com": self._daf,
}
jobs = extractors[host](BeautifulSoup(content, "lxml"), url)
unique_jobs = list({job.url: job for job in jobs if job.url}.values())
return ExtractionResult(
unique_jobs,
self.parser_key,
self.parser_version,
0.92 if unique_jobs else 0.0,
[] if unique_jobs else ["Geen actuele regionale vacatures gevonden"],
)
+253
View File
@@ -0,0 +1,253 @@
from __future__ import annotations
import re
from urllib.parse import parse_qs, urljoin, urlsplit
from bs4 import BeautifulSoup
from .base import ExtractedJob, ExtractionResult, FieldEvidence
class MolRegionEmployerAdapter:
"""Extract reviewed employer listings around postcode 2400 without detail fetches."""
parser_key = "regional-mol-employers"
parser_version = "1.0.0"
thomas_more_company_guid = "eab9ca13-ee10-4504-8a87-a785d0b037ef"
supported_routes = {
"www.sckcen.be": {"/nl/carriere/vacatures"},
"cipalschaubroeck.teamtailor.com": {"/jobs"},
"www.vanroey.be": {"/en/job-overview"},
"netropolix.recruitee.com": {"/"},
"jobpage.cvwarehouse.com": {"/"},
}
def _route(self, url: str) -> tuple[str, str] | None:
parts = urlsplit(url)
host = (parts.hostname or "").lower()
path = parts.path.rstrip("/") or "/"
if parts.scheme != "https" or path not in self.supported_routes.get(host, set()):
return None
if host == "jobpage.cvwarehouse.com":
query = parse_qs(parts.query)
if query.get("companyGuid") != [self.thomas_more_company_guid] or "job" in query:
return None
return host, path
def _supports_url(self, url: str) -> bool:
return self._route(url) is not None
@staticmethod
def _same_host_url(base_url: str, href: str) -> str:
job_url = urljoin(base_url, href)
if urlsplit(job_url).scheme != "https":
return ""
if (urlsplit(job_url).hostname or "").lower() != (
urlsplit(base_url).hostname or ""
).lower():
return ""
return job_url
@staticmethod
def _job(
*,
url: str,
title: str,
employer: str,
location: str,
postal_code: str,
description: str,
external_id: str,
evidence_source: str,
) -> ExtractedJob:
return ExtractedJob(
url=url,
title=title,
employer_name=employer,
external_id=external_id,
location_text=location,
postal_code=postal_code,
region="Antwerpen",
country="BE",
description_text=description,
raw={"regional_listing": evidence_source, "region_center": "2400 Mol"},
evidence=[
FieldEvidence("title", evidence_source, 0.94, title[:240]),
FieldEvidence("employer_name", "reviewed-source", 0.96, employer),
FieldEvidence("location_text", "mol-region-review", 0.82, location),
],
)
def _sck_cen(self, soup: BeautifulSoup, url: str) -> list[ExtractedJob]:
jobs = []
for link in soup.select("article a[href]"):
job_url = self._same_host_url(url, str(link.get("href") or ""))
path = urlsplit(job_url).path if job_url else ""
if not path.startswith("/nl/carriere/vacatures/"):
continue
title = link.get_text(" ", strip=True)
if not title:
continue
card = link.find_parent("article")
description = card.get_text(" ", strip=True) if card else title
jobs.append(
self._job(
url=job_url,
title=title,
employer="SCK CEN",
location="Mol, Antwerpen",
postal_code="2400",
description=description,
external_id=path.rstrip("/").rsplit("/", 1)[-1],
evidence_source="sck-cen-vacancy-card",
)
)
return jobs
def _cipal(self, soup: BeautifulSoup, url: str) -> list[ExtractedJob]:
jobs = []
for card in soup.select("li"):
card_text = card.get_text(" ", strip=True)
if "Westerlo" not in card_text and "Geel" not in card_text:
continue
link = card.find("a", href=True)
if not link:
continue
title = link.get_text(" ", strip=True)
job_url = self._same_host_url(url, str(link.get("href") or ""))
path = urlsplit(job_url).path if job_url else ""
match = re.fullmatch(r"/jobs/(\d+)-[^/]+", path.rstrip("/"))
if not title or not match:
continue
location = "Geel, Antwerpen" if "Geel" in card_text else "Westerlo, Antwerpen"
jobs.append(
self._job(
url=job_url,
title=title,
employer="Cipal Schaubroeck",
location=location,
postal_code="2440" if "Geel" in card_text else "2260",
description=card_text,
external_id=match.group(1),
evidence_source="cipal-teamtailor-card",
)
)
return jobs
def _vanroey(self, soup: BeautifulSoup, url: str) -> list[ExtractedJob]:
jobs = []
for heading in soup.select("h3.elementor-heading-title"):
link = heading.find("a", href=True)
if not link:
continue
title = link.get_text(" ", strip=True)
job_url = self._same_host_url(url, str(link.get("href") or ""))
path = urlsplit(job_url).path if job_url else ""
if not title or not re.fullmatch(r"/en/job/[^/]+/", path):
continue
slug = path.rstrip("/").rsplit("/", 1)[-1]
if "oost-vlaanderen" in slug:
continue
card = heading.find_parent("section") or heading.parent
description = card.get_text(" ", strip=True) if card else title
jobs.append(
self._job(
url=job_url,
title=title,
employer="VanRoey",
location="Turnhout/Geel (hybride; controleer vacaturedetail)",
postal_code="",
description=description,
external_id=slug,
evidence_source="vanroey-job-card",
)
)
return jobs
def _netropolix(self, soup: BeautifulSoup, url: str) -> list[ExtractedJob]:
jobs = []
for link in soup.find_all("a", href=True):
title = link.get_text(" ", strip=True)
if "Geel" not in title:
continue
job_url = self._same_host_url(url, str(link.get("href") or ""))
path = urlsplit(job_url).path if job_url else ""
if not re.fullmatch(r"/o/[^/]+", path.rstrip("/")):
continue
card = link.find_parent("div")
description = card.get_text(" ", strip=True) if card else title
jobs.append(
self._job(
url=job_url,
title=title,
employer="NTX (Netropolix)",
location="Geel, Antwerpen",
postal_code="2440",
description=description,
external_id=path.rstrip("/").rsplit("/", 1)[-1],
evidence_source="netropolix-recruitee-card",
)
)
return jobs
def _thomas_more(self, soup: BeautifulSoup, url: str) -> list[ExtractedJob]:
jobs = []
for link in soup.select("a.jobLink[data-item='readmore'][data-jobid][href]"):
title = link.get_text(" ", strip=True)
title_casefold = title.casefold()
if not any(place in title_casefold for place in ("geel", "turnhout", "vorselaar")):
continue
external_id = str(link.get("data-jobid") or "")
if not external_id.isdigit():
continue
job_url = self._same_host_url(url, str(link.get("href") or ""))
query = parse_qs(urlsplit(job_url).query) if job_url else {}
if query.get("companyGuid") != [self.thomas_more_company_guid] or query.get("job") != [
external_id
]:
continue
if "turnhout" in title_casefold:
location, postal_code = "Turnhout, Antwerpen", "2300"
elif "vorselaar" in title_casefold:
location, postal_code = "Vorselaar, Antwerpen", "2290"
else:
location, postal_code = "Geel, Antwerpen", "2440"
jobs.append(
self._job(
url=job_url,
title=title,
employer="Thomas More",
location=location,
postal_code=postal_code,
description=f"Publieke regionale vacature bij Thomas More: {title}.",
external_id=external_id,
evidence_source="thomas-more-cvwarehouse-card",
)
)
return jobs
def extract(self, content: str, *, url: str) -> ExtractionResult:
route = self._route(url)
if route is None:
return ExtractionResult(
[], self.parser_key, self.parser_version, 0.0, ["Onherkende Mol-regiobron"]
)
host, _ = route
soup = BeautifulSoup(content, "lxml")
extractors = {
"www.sckcen.be": self._sck_cen,
"cipalschaubroeck.teamtailor.com": self._cipal,
"www.vanroey.be": self._vanroey,
"netropolix.recruitee.com": self._netropolix,
"jobpage.cvwarehouse.com": self._thomas_more,
}
jobs = extractors[host](soup, url)
unique_jobs = list({job.url: job for job in jobs}.values())
warnings = [] if unique_jobs else ["Geen actuele vacatures rond Mol gevonden"]
return ExtractionResult(
unique_jobs,
self.parser_key,
self.parser_version,
0.9 if unique_jobs else 0.0,
warnings,
)
+312
View File
@@ -0,0 +1,312 @@
from __future__ import annotations
from urllib.parse import urljoin, urlsplit
from bs4 import BeautifulSoup
from .base import ExtractedJob, ExtractionResult, FieldEvidence
class CordaCampusAdapter:
"""Extract the public job cards intentionally published by Corda Campus."""
parser_key = "regional-corda-campus"
parser_version = "1.0.0"
source_hosts = ("cordacampus.com",)
def _supports_url(self, url: str) -> bool:
parts = urlsplit(url)
host = (parts.hostname or "").lower()
return host.endswith(self.source_hosts) and parts.path.rstrip("/") == "/jobs"
def extract(self, content: str, *, url: str) -> ExtractionResult:
if not self._supports_url(url):
return ExtractionResult(
[], self.parser_key, self.parser_version, 0.0, ["Onherkenbare regiobron"]
)
source_host = (urlsplit(url).hostname or "").lower()
soup = BeautifulSoup(content, "lxml")
jobs: list[ExtractedJob] = []
seen_urls: set[str] = set()
for card in soup.select('a.event-item[href*="/job/"]'):
job_url = urljoin(url, str(card.get("href") or ""))
job_host = (urlsplit(job_url).hostname or "").lower()
if job_host != source_host or job_url in seen_urls:
continue
title_node = card.select_one(".job-title")
employer_node = card.select_one(".company-title")
title = title_node.get_text(" ", strip=True) if title_node else ""
employer = employer_node.get_text(" ", strip=True) if employer_node else ""
if not title:
continue
date_node = card.select_one(".bottom-info")
date_posted = date_node.get_text(" ", strip=True) if date_node else ""
external_id = urlsplit(job_url).path.rstrip("/").rsplit("/", 1)[-1]
description = f"Vacature van {employer or 'een Corda-werkgever'} via Corda Campus."
jobs.append(
ExtractedJob(
url=job_url,
title=title,
employer_name=employer,
external_id=external_id,
location_text="Hasselt, Limburg",
region="Limburg",
country="BE",
description_text=description,
date_posted=date_posted,
raw={"regional_listing": "corda-campus"},
evidence=[
FieldEvidence("title", "corda-job-card", 0.95, title[:240]),
FieldEvidence("employer_name", "corda-job-card", 0.92, employer[:240]),
FieldEvidence(
"location_text",
"regional-source-scope",
0.72,
"Corda Campus, Hasselt",
),
],
)
)
seen_urls.add(job_url)
warnings = [] if jobs else ["Geen actuele Corda-vacatures gevonden"]
return ExtractionResult(
jobs,
self.parser_key,
self.parser_version,
0.9 if jobs else 0.0,
warnings,
)
class LocalEmployerListingAdapter:
"""Extract reviewed public listing pages of employers in the Hasselt-Genk area."""
parser_key = "regional-local-employers"
parser_version = "1.0.0"
supported_routes = {
"acagroup.be": {"/en/jobs"},
"www.xploregroup.be": {"/en/jobs"},
"www.uhasselt.be": {"/vacatures"},
"ses.pxl.be": {"/"},
"ziekenhuis-oost-limburg.cvw.io": {"/"},
}
def _supports_url(self, url: str) -> bool:
return self._route(url) is not None
def _route(self, url: str) -> tuple[str, str] | None:
parts = urlsplit(url)
host = (parts.hostname or "").lower()
path = parts.path.rstrip("/") or "/"
if parts.scheme != "https" or path not in self.supported_routes.get(host, set()):
return None
return host, path
@staticmethod
def _job(
*,
url: str,
title: str,
employer: str,
location: str,
description: str,
external_id: str,
evidence_source: str,
) -> ExtractedJob:
return ExtractedJob(
url=url,
title=title,
employer_name=employer,
external_id=external_id,
location_text=location,
region="Limburg",
country="BE",
description_text=description,
raw={"regional_listing": evidence_source},
evidence=[
FieldEvidence("title", evidence_source, 0.94, title[:240]),
FieldEvidence("employer_name", "reviewed-source", 0.96, employer),
FieldEvidence("location_text", "regional-source-scope", 0.78, location),
],
)
@staticmethod
def _same_host_url(base_url: str, href: str) -> str:
job_url = urljoin(base_url, href)
if (urlsplit(job_url).hostname or "").lower() != (
urlsplit(base_url).hostname or ""
).lower():
return ""
return job_url
def _aca(self, soup: BeautifulSoup, url: str) -> list[ExtractedJob]:
jobs = []
for link in soup.find_all("a", href=True):
href = str(link.get("href") or "")
if not href.startswith("/en/jobs/"):
continue
title_node = link.find("h3")
title = title_node.get_text(" ", strip=True) if title_node else ""
job_url = self._same_host_url(url, href)
if not title or not job_url:
continue
description_node = link.find("p")
description = (
description_node.get_text(" ", strip=True)
if description_node
else f"Vacature bij ACA Group: {title}."
)
jobs.append(
self._job(
url=job_url,
title=title,
employer="ACA Group",
location="Hasselt (hybride; kantoorselectie per vacature)",
description=description,
external_id=urlsplit(job_url).path.rstrip("/").rsplit("/", 1)[-1],
evidence_source="aca-job-card",
)
)
return jobs
def _xplore(self, soup: BeautifulSoup, url: str) -> list[ExtractedJob]:
jobs = []
for heading in soup.find_all("h3"):
link = heading.find("a", href=True)
if not link or not str(link.get("href") or "").startswith("/en/jobs/"):
continue
card = heading.parent
location_node = next(
(
node
for node in card.find_all("p")
if "Hasselt" in node.get_text(" ", strip=True)
),
None,
)
if not location_node:
continue
title = link.get_text(" ", strip=True)
job_url = self._same_host_url(url, str(link.get("href") or ""))
if not title or not job_url:
continue
location = location_node.get_text(" ", strip=True)
jobs.append(
self._job(
url=job_url,
title=title,
employer="Xplore Group",
location=location,
description=(
f"Vacature bij Xplore Group met Hasselt als mogelijke werklocatie: {title}."
),
external_id=urlsplit(job_url).path.rstrip("/").rsplit("/", 1)[-1],
evidence_source="xplore-job-card",
)
)
return jobs
def _uhasselt(self, soup: BeautifulSoup, url: str) -> list[ExtractedJob]:
jobs = []
for card in soup.select("section.vacancy-item"):
title_node = card.find("h3")
link = card.find("a", href=True)
title = title_node.get_text(" ", strip=True) if title_node else ""
job_url = self._same_host_url(url, str(link.get("href") or "")) if link else ""
if not title or not job_url or "/vacatures/detail/" not in urlsplit(job_url).path:
continue
external_id = urlsplit(job_url).path.split("/detail/", 1)[-1].split("-", 1)[0]
jobs.append(
self._job(
url=job_url,
title=title,
employer="Universiteit Hasselt",
location="Hasselt/Diepenbeek, Limburg",
description=card.get_text(" ", strip=True),
external_id=external_id,
evidence_source="uhasselt-vacancy-card",
)
)
return jobs
def _pxl(self, soup: BeautifulSoup, url: str) -> list[ExtractedJob]:
jobs = []
for card in soup.select(".vacature-card[id^='Vacature_']"):
external_id = str(card.get("id") or "").removeprefix("Vacature_")
title_node = card.select_one(".vacature-card-titel")
title = title_node.get_text(" ", strip=True) if title_node else ""
if not title or not external_id.isdigit():
continue
cells = [
node.get_text(" ", strip=True) for node in card.select("td.vacature-card-td-text")
]
campus = next((value for value in cells if value.startswith("Campus ")), "Hasselt")
job_url = f"{url.rstrip('/')}?vacature_id={external_id}"
jobs.append(
self._job(
url=job_url,
title=title,
employer="Hogeschool PXL",
location=f"{campus}, Limburg",
description=" · ".join(cells),
external_id=external_id,
evidence_source="pxl-vacancy-card",
)
)
return jobs
def _zol(self, soup: BeautifulSoup, url: str) -> list[ExtractedJob]:
jobs = []
for link in soup.select("a[data-item='readmore'][data-jobid][href]"):
external_id = str(link.get("data-jobid") or "")
title_node = link.select_one(".job-title")
title = title_node.get_text(" ", strip=True) if title_node else ""
job_url = self._same_host_url(url, str(link.get("href") or ""))
if not title or not external_id.isdigit() or not job_url:
continue
jobs.append(
self._job(
url=job_url,
title=title,
employer="Ziekenhuis Oost-Limburg",
location="Genk/Lanaken/Maaseik, Limburg",
description=f"Publieke vacature van Ziekenhuis Oost-Limburg: {title}.",
external_id=external_id,
evidence_source="zol-cvwarehouse-card",
)
)
return jobs
def extract(self, content: str, *, url: str) -> ExtractionResult:
route = self._route(url)
if route is None:
return ExtractionResult(
[],
self.parser_key,
self.parser_version,
0.0,
["Onherkenbare lokale werkgeversbron"],
)
host, _ = route
soup = BeautifulSoup(content, "lxml")
extractors = {
"acagroup.be": self._aca,
"www.xploregroup.be": self._xplore,
"www.uhasselt.be": self._uhasselt,
"ses.pxl.be": self._pxl,
"ziekenhuis-oost-limburg.cvw.io": self._zol,
}
jobs = extractors[host](soup, url)
unique_jobs = list({job.url: job for job in jobs}.values())
warnings = [] if unique_jobs else ["Geen actuele lokale vacatures gevonden"]
return ExtractionResult(
unique_jobs,
self.parser_key,
self.parser_version,
0.9 if unique_jobs else 0.0,
warnings,
)
+11
View File
@@ -12,6 +12,9 @@ from .ats import (
from .base import ExtractionResult
from .generic_html import GenericHtmlAdapter
from .jsonld import JsonLdJobPostingAdapter
from .kempen import KempenEmployerAdapter
from .mol_region import MolRegionEmployerAdapter
from .regional import CordaCampusAdapter, LocalEmployerListingAdapter
from .rss import RssAdapter
@@ -22,10 +25,18 @@ class AdapterRegistry:
self.recruitee = RecruiteeAdapter()
self.smartrecruiters = SmartRecruitersAdapter()
self.workable = WorkableAdapter()
self.corda_campus = CordaCampusAdapter()
self.local_employers = LocalEmployerListingAdapter()
self.mol_region_employers = MolRegionEmployerAdapter()
self.kempen_employers = KempenEmployerAdapter()
self.jsonld = JsonLdJobPostingAdapter()
self.generic = GenericHtmlAdapter()
self.rss = RssAdapter()
self.providers = [
self.corda_campus,
self.local_employers,
self.mol_region_employers,
self.kempen_employers,
self.greenhouse,
self.lever,
self.recruitee,
+17
View File
@@ -2,6 +2,7 @@ from django.contrib import admin
from .models import (
EmailMessageRecord,
MailboxConnection,
RawDocument,
Source,
SourceLease,
@@ -12,6 +13,22 @@ from .models import (
)
@admin.register(MailboxConnection)
class MailboxConnectionAdmin(admin.ModelAdmin):
list_display = (
"platform",
"provider",
"username",
"enabled",
"poll_interval_minutes",
"last_success_at",
"next_poll_at",
)
list_filter = ("platform", "provider", "enabled")
search_fields = ("username",)
exclude = ("encrypted_password", "lease_token")
@admin.register(Source)
class SourceAdmin(admin.ModelAdmin):
list_display = (
+52
View File
@@ -2,6 +2,58 @@ from __future__ import annotations
from django import forms
from .models import MailboxConnection
class MailboxConnectionForm(forms.ModelForm):
password = forms.CharField(
required=False,
label="App-wachtwoord",
help_text="Laat leeg bij wijzigen om het bestaande app-wachtwoord te behouden.",
widget=forms.PasswordInput(
attrs={"autocomplete": "new-password", "placeholder": "App-wachtwoord"},
render_value=False,
),
)
class Meta:
model = MailboxConnection
fields = (
"platform",
"provider",
"custom_host",
"port",
"username",
"mailbox",
"poll_interval_minutes",
"enabled",
)
labels = {
"platform": "Vacatureplatform",
"provider": "Mailboxprovider",
"custom_host": "Aangepaste IMAP-host",
"port": "IMAP-poort",
"username": "Mailboxaccount",
"mailbox": "Map",
"poll_interval_minutes": "Controlefrequentie",
"enabled": "Automatisch synchroniseren",
}
widgets = {
"username": forms.EmailInput(attrs={"autocomplete": "username"}),
"custom_host": forms.TextInput(
attrs={"autocomplete": "off", "placeholder": "imap.provider.be"}
),
"mailbox": forms.TextInput(attrs={"autocomplete": "off"}),
}
def clean(self):
data = super().clean()
if data.get("provider") != MailboxConnection.Provider.CUSTOM:
data["custom_host"] = ""
if not self.instance.pk and not data.get("password"):
self.add_error("password", "Een app-wachtwoord is verplicht voor een nieuwe koppeling.")
return data
class ManualImportForm(forms.Form):
source_url = forms.URLField(
@@ -31,11 +31,11 @@ class Command(BaseCommand):
if not hostname:
raise CommandError("--url bevat geen geldig domein")
source, _ = Source.objects.get_or_create(
domain=hostname,
base_url=options["url"],
source_type=options["source_type"],
defaults={
"domain": hostname,
"name": options["source_name"],
"base_url": options["url"],
"status": Source.Status.ACTIVE,
"policy": Source.Policy.ALLOW,
"parser_key": "auto",
@@ -0,0 +1,17 @@
from django.core.management.base import BaseCommand, CommandError
from apps.sources.services.mailbox_connections import (
MailboxCredentialError,
rotate_mailbox_credentials,
)
class Command(BaseCommand):
help = "Versleutel alle mailbox-app-wachtwoorden opnieuw met de eerste ingestelde sleutel."
def handle(self, *args, **options):
try:
count = rotate_mailbox_credentials()
except MailboxCredentialError as exc:
raise CommandError(str(exc)) from exc
self.stdout.write(self.style.SUCCESS(f"{count} mailboxcredential(s) geroteerd."))
@@ -5,8 +5,9 @@ from urllib.parse import urlsplit
import yaml
from django.core.management.base import BaseCommand, CommandError
from django.utils.dateparse import parse_datetime
from apps.sources.models import Source
from apps.sources.models import Source, SourcePolicyReview
from apps.sources.services.policy import is_denied_domain
@@ -21,7 +22,38 @@ class Command(BaseCommand):
if not path.is_file():
raise CommandError(f"Seedbestand ontbreekt: {path}")
payload = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
created = updated = 0
created = updated = reviews_created = 0
if payload.get("disable_demo_sources") is True:
demo_sources = Source.objects.filter(domain="jobs.example.org")
for demo_source in demo_sources:
demo_source.status = Source.Status.DISABLED
demo_source.policy = Source.Policy.DENY
demo_source.policy_reason = "Demobron verborgen na installatie van livebronnen"
demo_source.metadata = {**demo_source.metadata, "demo": True}
demo_source.save(
update_fields=[
"status",
"policy",
"policy_reason",
"metadata",
"updated_at",
]
)
retired = 0
for retired_url in payload.get("retire_source_urls", []):
for retired_source in Source.objects.filter(base_url=str(retired_url).strip()):
retired_source.status = Source.Status.DISABLED
retired_source.next_run_at = None
retired_source.metadata = {
**retired_source.metadata,
"retired": True,
"retired_reason": "Buiten de ingestelde regionale dekking",
"hidden_from_source_list": True,
}
retired_source.save(
update_fields=["status", "next_run_at", "metadata", "updated_at"]
)
retired += 1
for item in payload.get("sources", []):
url = str(item.get("url") or "").strip()
domain = (urlsplit(url).hostname or "").lower()
@@ -30,11 +62,11 @@ class Command(BaseCommand):
continue
deny = is_denied_domain(domain)
source, was_created = Source.objects.update_or_create(
domain=domain,
base_url=url,
source_type=item.get("type", Source.Type.EMPLOYER),
defaults={
"domain": domain,
"name": item.get("name") or domain,
"base_url": url,
"status": Source.Status.PAUSED
if deny
else item.get("status", Source.Status.TRIAL),
@@ -46,10 +78,39 @@ class Command(BaseCommand):
else item.get("policy_reason", ""),
"parser_key": item.get("parser", "auto"),
"crawl_interval_minutes": int(item.get("crawl_interval_minutes", 720)),
"minimum_interval_seconds": int(item.get("minimum_interval_seconds", 30)),
"allow_public_endpoint": bool(item.get("allow_public_endpoint", False)),
"metadata": item.get("metadata") or {},
},
)
review = item.get("review") or {}
if review:
expires_at = parse_datetime(str(review.get("expires_at") or ""))
if expires_at is None:
raise CommandError(
f"Bron {source.name}: review.expires_at moet een ISO-datetime zijn"
)
decision = str(review.get("decision") or "")
if decision not in SourcePolicyReview.Decision.values:
raise CommandError(f"Bron {source.name}: ongeldige reviewbeslissing")
_, review_created = SourcePolicyReview.objects.get_or_create(
source=source,
decision=decision,
evidence_link=str(review.get("evidence_link") or ""),
expires_at=expires_at,
defaults={
"scope": SourcePolicyReview.Scope.SOURCE,
"reason": str(review.get("reason") or "Bronseedreview"),
"notes": str(review.get("notes") or ""),
"metadata": review.get("metadata") or {},
},
)
reviews_created += int(review_created)
created += int(was_created)
updated += int(not was_created)
self.stdout.write(
self.style.SUCCESS(f"Bronnen: {created} aangemaakt, {updated} bijgewerkt")
self.style.SUCCESS(
f"Bronnen: {created} aangemaakt, {updated} bijgewerkt, "
f"{reviews_created} reviews geregistreerd, {retired} buiten regio uitgeschakeld"
)
)
@@ -0,0 +1,21 @@
# Generated by Django 5.2.16 on 2026-07-21 22:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sources', '0003_source_lease_and_origin_state'),
]
operations = [
migrations.RemoveConstraint(
model_name='source',
name='unique_domain_source_type',
),
migrations.AddConstraint(
model_name='source',
constraint=models.UniqueConstraint(condition=models.Q(('base_url', ''), _negated=True), fields=('source_type', 'base_url'), name='unique_source_type_base_url_nonempty'),
),
]
@@ -0,0 +1,67 @@
# Generated by Django 5.2.16 on 2026-07-22 00:12
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sources', '0004_remove_source_unique_domain_source_type_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='emailmessagerecord',
name='message_id',
field=models.CharField(max_length=998),
),
migrations.CreateModel(
name='MailboxConnection',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('platform', models.CharField(choices=[('vdab', 'VDAB'), ('indeed', 'Indeed')], max_length=24)),
('provider', models.CharField(choices=[('gmail', 'Gmail'), ('outlook', 'Outlook / Microsoft 365'), ('custom', 'Andere IMAP-provider')], max_length=24)),
('custom_host', models.CharField(blank=True, max_length=255)),
('port', models.PositiveIntegerField(default=993)),
('username', models.CharField(max_length=320)),
('encrypted_password', models.TextField()),
('mailbox', models.CharField(default='INBOX', max_length=255)),
('enabled', models.BooleanField(default=True)),
('poll_interval_minutes', models.PositiveIntegerField(choices=[(5, 'Elke 5 minuten'), (15, 'Elke 15 minuten'), (30, 'Elke 30 minuten'), (60, 'Elk uur'), (180, 'Elke 3 uur'), (360, 'Elke 6 uur')], default=30)),
('next_poll_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now)),
('last_polled_at', models.DateTimeField(blank=True, null=True)),
('last_success_at', models.DateTimeField(blank=True, null=True)),
('last_error_category', models.CharField(blank=True, max_length=80)),
('last_error_message', models.CharField(blank=True, max_length=500)),
('lease_token', models.CharField(blank=True, max_length=64)),
('lease_expires_at', models.DateTimeField(blank=True, db_index=True, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mailbox_connections', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['platform'],
},
),
migrations.AddField(
model_name='emailmessagerecord',
name='mailbox_connection',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='sources.mailboxconnection'),
),
migrations.AddConstraint(
model_name='emailmessagerecord',
constraint=models.UniqueConstraint(condition=models.Q(('mailbox_connection__isnull', False)), fields=('mailbox_connection', 'message_id'), name='unique_message_per_mailbox_connection'),
),
migrations.AddConstraint(
model_name='emailmessagerecord',
constraint=models.UniqueConstraint(condition=models.Q(('mailbox_connection__isnull', True)), fields=('message_id',), name='unique_legacy_email_message_id'),
),
migrations.AddConstraint(
model_name='mailboxconnection',
constraint=models.UniqueConstraint(fields=('user', 'platform'), name='unique_mailbox_platform_per_user'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.16 on 2026-07-22 00:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sources', '0005_alter_emailmessagerecord_message_id_and_more'),
]
operations = [
migrations.AlterField(
model_name='mailboxconnection',
name='platform',
field=models.CharField(choices=[('vdab', 'VDAB'), ('indeed', 'Indeed'), ('linkedin', 'LinkedIn'), ('ictjob', 'ictjob.be'), ('jobat', 'Jobat'), ('stepstone', 'StepStone'), ('eurobrussels', 'EuroBrussels'), ('brusselsjobs', 'BrusselsJobs')], max_length=24),
),
]
@@ -0,0 +1,26 @@
# Generated by Django 5.2.16 on 2026-07-22 01:25
from django.db import migrations, models
def disable_retired_brussels_mailboxes(apps, schema_editor):
mailbox_connection = apps.get_model("sources", "MailboxConnection")
mailbox_connection.objects.filter(
platform__in=("eurobrussels", "brusselsjobs")
).update(enabled=False)
class Migration(migrations.Migration):
dependencies = [
('sources', '0006_alter_mailboxconnection_platform'),
]
operations = [
migrations.RunPython(disable_retired_brussels_mailboxes, migrations.RunPython.noop),
migrations.AlterField(
model_name='mailboxconnection',
name='platform',
field=models.CharField(choices=[('vdab', 'VDAB'), ('indeed', 'Indeed'), ('linkedin', 'LinkedIn'), ('ictjob', 'ictjob.be'), ('jobat', 'Jobat'), ('stepstone', 'StepStone'), ('careerjet', 'Careerjet'), ('randstad', 'Randstad'), ('roberthalf', 'Robert Half')], max_length=24),
),
]
+109 -2
View File
@@ -62,7 +62,9 @@ class Source(TimeStampedModel):
ordering = ["name"]
constraints = [
models.UniqueConstraint(
fields=["domain", "source_type"], name="unique_domain_source_type"
fields=["source_type", "base_url"],
condition=~models.Q(base_url=""),
name="unique_source_type_base_url_nonempty",
)
]
@@ -315,8 +317,101 @@ class RawDocument(TimeStampedModel):
return self.final_url or self.url or f"Document {self.pk}"
class MailboxConnection(TimeStampedModel):
class Platform(models.TextChoices):
VDAB = "vdab", "VDAB"
INDEED = "indeed", "Indeed"
LINKEDIN = "linkedin", "LinkedIn"
ICTJOB = "ictjob", "ictjob.be"
JOBAT = "jobat", "Jobat"
STEPSTONE = "stepstone", "StepStone"
CAREERJET = "careerjet", "Careerjet"
RANDSTAD = "randstad", "Randstad"
ROBERTHALF = "roberthalf", "Robert Half"
class Provider(models.TextChoices):
GMAIL = "gmail", "Gmail"
OUTLOOK = "outlook", "Outlook / Microsoft 365"
CUSTOM = "custom", "Andere IMAP-provider"
class PollInterval(models.IntegerChoices):
FIVE_MINUTES = 5, "Elke 5 minuten"
FIFTEEN_MINUTES = 15, "Elke 15 minuten"
THIRTY_MINUTES = 30, "Elke 30 minuten"
HOURLY = 60, "Elk uur"
THREE_HOURS = 180, "Elke 3 uur"
SIX_HOURS = 360, "Elke 6 uur"
PROVIDER_HOSTS = {
Provider.GMAIL: "imap.gmail.com",
Provider.OUTLOOK: "outlook.office365.com",
}
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="mailbox_connections",
)
platform = models.CharField(max_length=24, choices=Platform.choices)
provider = models.CharField(max_length=24, choices=Provider.choices)
custom_host = models.CharField(max_length=255, blank=True)
port = models.PositiveIntegerField(default=993)
username = models.CharField(max_length=320)
encrypted_password = models.TextField()
mailbox = models.CharField(max_length=255, default="INBOX")
enabled = models.BooleanField(default=True)
poll_interval_minutes = models.PositiveIntegerField(
choices=PollInterval.choices,
default=PollInterval.THIRTY_MINUTES,
)
next_poll_at = models.DateTimeField(default=timezone.now, db_index=True)
last_polled_at = models.DateTimeField(null=True, blank=True)
last_success_at = models.DateTimeField(null=True, blank=True)
last_error_category = models.CharField(max_length=80, blank=True)
last_error_message = models.CharField(max_length=500, blank=True)
lease_token = models.CharField(max_length=64, blank=True)
lease_expires_at = models.DateTimeField(null=True, blank=True, db_index=True)
class Meta:
ordering = ["platform"]
constraints = [
models.UniqueConstraint(
fields=["user", "platform"], name="unique_mailbox_platform_per_user"
)
]
def __str__(self) -> str:
return f"{self.get_platform_display()} · {self.username}"
@property
def imap_host(self) -> str:
return self.PROVIDER_HOSTS.get(self.provider, self.custom_host).strip().lower()
def clean(self) -> None:
host = self.imap_host
if (
not host
or "://" in host
or "/" in host
or "@" in host
or any(c.isspace() for c in host)
):
raise ValidationError({"custom_host": "Vul een geldige IMAP-hostnaam in."})
if not 1 <= self.port <= 65535:
raise ValidationError({"port": "De IMAP-poort moet tussen 1 en 65535 liggen."})
if "\n" in self.mailbox or "\r" in self.mailbox:
raise ValidationError({"mailbox": "De mailboxnaam bevat ongeldige tekens."})
class EmailMessageRecord(TimeStampedModel):
message_id = models.CharField(max_length=998, unique=True)
mailbox_connection = models.ForeignKey(
MailboxConnection,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="messages",
)
message_id = models.CharField(max_length=998)
mailbox = models.CharField(max_length=255, default="INBOX")
sender = models.CharField(max_length=500, blank=True)
subject = models.CharField(max_length=998, blank=True)
@@ -330,6 +425,18 @@ class EmailMessageRecord(TimeStampedModel):
class Meta:
ordering = ["-received_at", "-created_at"]
constraints = [
models.UniqueConstraint(
fields=["mailbox_connection", "message_id"],
condition=models.Q(mailbox_connection__isnull=False),
name="unique_message_per_mailbox_connection",
),
models.UniqueConstraint(
fields=["message_id"],
condition=models.Q(mailbox_connection__isnull=True),
name="unique_legacy_email_message_id",
),
]
def __str__(self) -> str:
return self.subject or self.message_id
+88
View File
@@ -0,0 +1,88 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class PlatformAlert:
key: str
label: str
domains: tuple[str, ...]
alert_url: str
help_text: str
PLATFORM_ALERTS = {
alert.key: alert
for alert in (
PlatformAlert(
"vdab",
"VDAB",
("vdab.be",),
"https://www.vdab.be/jobs/job-alerts",
"Brede Vlaamse vacaturealerts.",
),
PlatformAlert(
"indeed",
"Indeed",
("indeed.com", "indeed.be"),
"https://support.indeed.com/hc/nl/articles/204488890-"
"Vacature-alerts-activeren-stopzetten-en-beheren",
"Algemene en regionale vacaturealerts.",
),
PlatformAlert(
"linkedin",
"LinkedIn",
("linkedin.com",),
"https://www.linkedin.com/help/linkedin/answer/a511279",
"Dagelijkse of wekelijkse alerts, maximaal twintig per LinkedIn-account.",
),
PlatformAlert(
"ictjob",
"ictjob.be",
("ictjob.be",),
"https://www.ictjob.be/nl/it-job-alert",
"Gespecialiseerde Belgische IT- en telecomvacatures.",
),
PlatformAlert(
"jobat",
"Jobat",
("jobat.be",),
"https://www.jobat.be/nl/jobalert",
"Belgische jobs per functie, regio en contracttype.",
),
PlatformAlert(
"stepstone",
"StepStone",
("stepstone.be",),
"https://www.stepstone.be/vacatures",
"Dagelijkse vergelijkbare vacatures vanuit een zoekopdracht.",
),
PlatformAlert(
"careerjet",
"Careerjet",
("careerjet.be",),
"https://www.careerjet.be/",
"Brede Belgische zoekresultaten met regio- en postcodegebonden e-mailalerts.",
),
PlatformAlert(
"randstad",
"Randstad",
("randstad.be",),
"https://www.randstad.be/jobalert-aanmaken/",
"Belgische jobs met instelbare specialisatie, locatie, contract en frequentie.",
),
PlatformAlert(
"roberthalf",
"Robert Half",
("roberthalf.com",),
"https://www.roberthalf.com/be/en/find-jobs/job-alerts",
"Belgische IT- en digitaljobs, waaronder systeem-, netwerk- en supportfuncties.",
),
)
}
def platform_label(key: str) -> str:
alert = PLATFORM_ALERTS.get(key)
return alert.label if alert else key
+21 -16
View File
@@ -140,23 +140,28 @@ def persist_discovery_candidates(candidates: list[SourceCandidate]) -> tuple[int
for candidate in ordered:
provenance = _provenance_entry(candidate)
now = _utc_now()
source, was_created = Source.objects.get_or_create(
domain=candidate.domain,
source_type=candidate.source_type,
defaults={
"name": _candidate_name(candidate),
"base_url": candidate.url,
"status": Source.Status.CANDIDATE,
"policy": Source.Policy.REVIEW,
"policy_reason": "Automatisch ontdekt",
"parser_key": "auto",
"strict_mode": True,
"metadata": {
"discovery": [provenance],
"discovered_at": now,
},
},
source = (
Source.objects.filter(
domain=candidate.domain,
source_type=candidate.source_type,
)
.order_by("id")
.first()
)
was_created = source is None
if source is None:
source = Source.objects.create(
domain=candidate.domain,
source_type=candidate.source_type,
name=_candidate_name(candidate),
base_url=candidate.url,
status=Source.Status.CANDIDATE,
policy=Source.Policy.REVIEW,
policy_reason="Automatisch ontdekt",
parser_key="auto",
strict_mode=True,
metadata={"discovery": [provenance], "discovered_at": now},
)
if was_created:
created += 1
continue
+41 -9
View File
@@ -13,7 +13,8 @@ from django.utils import timezone
from apps.jobs.services.normalization import normalize_extracted_job
from apps.jobs.services.pipeline import persist_draft
from apps.sources.adapters.email_alert import EmailAlertAdapter
from apps.sources.models import EmailMessageRecord, RawDocument, Source
from apps.sources.models import EmailMessageRecord, MailboxConnection, RawDocument, Source
from apps.sources.platforms import platform_label
def message_identity(raw_message: bytes) -> str:
@@ -24,22 +25,32 @@ def message_identity(raw_message: bytes) -> str:
@transaction.atomic
def ingest_email(raw_message: bytes, *, mailbox: str = "INBOX") -> EmailMessageRecord:
def ingest_email(
raw_message: bytes,
*,
mailbox: str = "INBOX",
mailbox_connection: MailboxConnection | None = None,
platform: str | None = None,
) -> EmailMessageRecord:
parsed = BytesParser(policy=policy.default).parsebytes(raw_message)
message_id = message_identity(raw_message)
existing = EmailMessageRecord.objects.filter(message_id=message_id).first()
existing = EmailMessageRecord.objects.filter(
message_id=message_id, mailbox_connection=mailbox_connection
).first()
if existing:
return existing
source_domain = f"{platform}.mailbox.local" if platform else "mailbox.local"
source_name = f"{platform_label(platform)} vacaturemailbox" if platform else "Vacaturemailbox"
source, _ = Source.objects.get_or_create(
domain="mailbox.local",
domain=source_domain,
source_type=Source.Type.EMAIL,
defaults={
"name": "Vacaturemailbox",
"name": source_name,
"status": Source.Status.ACTIVE,
"policy": Source.Policy.ALLOW,
"parser_key": "email-alert",
"crawl_interval_minutes": 10,
"crawl_interval_minutes": 1440,
},
)
content_hash = hashlib.sha256(raw_message).hexdigest()
@@ -52,7 +63,11 @@ def ingest_email(raw_message: bytes, *, mailbox: str = "INBOX") -> EmailMessageR
body_text=raw_message.decode("utf-8", errors="replace"),
byte_length=len(raw_message),
retain_until=retain_until,
metadata={"mailbox": mailbox},
metadata={
"mailbox": mailbox,
"mailbox_connection_id": mailbox_connection.pk if mailbox_connection else None,
"alert_provider": platform or "unknown",
},
)
received_at = None
if parsed.get("date"):
@@ -64,12 +79,28 @@ def ingest_email(raw_message: bytes, *, mailbox: str = "INBOX") -> EmailMessageR
received_at = None
adapter = EmailAlertAdapter()
result = adapter.extract_message(raw_message)
result = adapter.extract_message(raw_message, expected_provider=platform)
detected_provider = next(
(
str(extracted.raw.get("alert_provider"))
for extracted in result.jobs
if extracted.raw.get("alert_provider")
),
"unknown",
)
alert_provider = platform or detected_provider
document.parser_key = result.parser_key
document.parser_version = result.parser_version
document.extraction_confidence = result.confidence
document.metadata = {**document.metadata, "alert_provider": alert_provider}
document.save(
update_fields=["parser_key", "parser_version", "extraction_confidence", "updated_at"]
update_fields=[
"parser_key",
"parser_version",
"extraction_confidence",
"metadata",
"updated_at",
]
)
links: list[str] = []
errors: list[str] = []
@@ -87,6 +118,7 @@ def ingest_email(raw_message: bytes, *, mailbox: str = "INBOX") -> EmailMessageR
except Exception as exc:
errors.append(f"{exc.__class__.__name__}: {exc}")
return EmailMessageRecord.objects.create(
mailbox_connection=mailbox_connection,
message_id=message_id,
mailbox=mailbox,
sender=str(parsed.get("from") or "")[:500],
+2
View File
@@ -14,6 +14,7 @@ from django.conf import settings
from apps.sources.models import Source
from .policy import assess_url
from .tls import trusted_tls_context
from .url_security import ValidatedUrl, validate_public_url
ALLOWED_CONTENT_TYPES = (
@@ -123,6 +124,7 @@ def fetch_url(
timeout=httpx.Timeout(settings.FETCHER_TIMEOUT_SECONDS),
follow_redirects=False,
headers=headers,
verify=trusted_tls_context(),
)
current_url = url
previous_validation: ValidatedUrl | None = None
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
import imaplib
from collections.abc import Callable
from contextlib import suppress
from typing import Any
from django.conf import settings
from apps.sources.models import EmailMessageRecord, MailboxConnection
from apps.sources.services.email_import import ingest_email, message_identity
from apps.sources.services.mailbox_connections import decrypt_mailbox_password
from apps.sources.services.tls import trusted_tls_context
from apps.sources.services.url_security import validate_public_url
ImapClientFactory = Callable[..., Any]
HostValidator = Callable[[str], object]
def poll_imap_mailbox(
connection: MailboxConnection,
*,
client_factory: ImapClientFactory | None = None,
host_validator: HostValidator = validate_public_url,
) -> dict[str, int | str]:
"""Import one bounded mailbox batch without letting one bad message abort the batch."""
if not connection.enabled:
return {"status": "disabled", "imported": 0}
if client_factory is None:
client_factory = imaplib.IMAP4_SSL
host_validator(f"https://{connection.imap_host}")
password = decrypt_mailbox_password(connection)
client_options = {"timeout": settings.IMAP_CONNECT_TIMEOUT_SECONDS}
if client_factory is imaplib.IMAP4_SSL:
client_options["ssl_context"] = trusted_tls_context()
client = client_factory(connection.imap_host, connection.port, **client_options)
counters = {
"imported": 0,
"duplicates": 0,
"skipped_oversized": 0,
"failed": 0,
}
try:
client.login(connection.username, password)
status, _ = client.select(connection.mailbox, readonly=True)
if status != "OK":
raise RuntimeError("IMAP-mailbox kon niet worden geopend")
status, data = client.uid("search", None, "ALL")
if status != "OK":
raise RuntimeError("IMAP-zoekopdracht mislukt")
uids = (data[0] or b"").split()[-settings.IMAP_MAX_MESSAGES_PER_POLL :]
for uid in uids:
try:
status, payload = client.uid("fetch", uid, "(RFC822)")
if status != "OK" or not payload:
counters["failed"] += 1
continue
raw = next((part[1] for part in payload if isinstance(part, tuple)), None)
if not isinstance(raw, bytes):
counters["failed"] += 1
continue
if len(raw) > settings.IMAP_MAX_MESSAGE_BYTES:
counters["skipped_oversized"] += 1
continue
identity = message_identity(raw)
if EmailMessageRecord.objects.filter(
mailbox_connection=connection, message_id=identity
).exists():
counters["duplicates"] += 1
continue
ingest_email(
raw,
mailbox=connection.mailbox,
mailbox_connection=connection,
platform=connection.platform,
)
counters["imported"] += 1
except Exception:
counters["failed"] += 1
return {"status": "ok", **counters}
finally:
with suppress(Exception):
client.logout()
@@ -0,0 +1,148 @@
from __future__ import annotations
from datetime import timedelta
from uuid import uuid4
from cryptography.fernet import Fernet, InvalidToken, MultiFernet
from django.conf import settings
from django.db import transaction
from django.utils import timezone
from apps.sources.models import MailboxConnection
class MailboxCredentialError(RuntimeError):
"""Safe credential configuration error without secret material."""
def _cipher() -> MultiFernet:
keys = settings.MAILBOX_CREDENTIAL_KEYS
if not keys:
raise MailboxCredentialError(
"Credentialopslag is niet geconfigureerd. Stel MAILBOX_CREDENTIAL_KEYS in."
)
try:
return MultiFernet([Fernet(key.encode("ascii")) for key in keys])
except (ValueError, TypeError) as exc:
raise MailboxCredentialError(
"MAILBOX_CREDENTIAL_KEYS bevat een ongeldige sleutel."
) from exc
def encrypt_mailbox_password(password: str) -> str:
if not password:
raise MailboxCredentialError("Een app-wachtwoord is verplicht.")
return _cipher().encrypt(password.encode("utf-8")).decode("ascii")
def decrypt_mailbox_password(connection: MailboxConnection) -> str:
try:
return _cipher().decrypt(connection.encrypted_password.encode("ascii")).decode("utf-8")
except (InvalidToken, ValueError, UnicodeError) as exc:
raise MailboxCredentialError(
"Het opgeslagen app-wachtwoord kan niet worden ontsleuteld. Sla het opnieuw op."
) from exc
@transaction.atomic
def rotate_mailbox_credentials() -> int:
"""Re-encrypt every mailbox password with the first configured key."""
rotated = 0
for connection in MailboxConnection.objects.select_for_update().all():
password = decrypt_mailbox_password(connection)
connection.encrypted_password = encrypt_mailbox_password(password)
connection.save(update_fields=["encrypted_password", "updated_at"])
rotated += 1
return rotated
@transaction.atomic
def save_mailbox_connection(
*,
user,
cleaned_data: dict[str, object],
instance: MailboxConnection | None = None,
) -> MailboxConnection:
connection = instance or MailboxConnection(user=user)
if connection.pk and connection.user_id != user.pk:
raise PermissionError("Mailboxkoppeling behoort niet tot deze gebruiker.")
password = str(cleaned_data.pop("password", "") or "")
for field in (
"platform",
"provider",
"custom_host",
"port",
"username",
"mailbox",
"enabled",
"poll_interval_minutes",
):
setattr(connection, field, cleaned_data[field])
if password:
connection.encrypted_password = encrypt_mailbox_password(password)
elif not connection.encrypted_password:
raise MailboxCredentialError("Een app-wachtwoord is verplicht.")
connection.next_poll_at = timezone.now()
connection.last_error_category = ""
connection.last_error_message = ""
connection.full_clean()
connection.save()
return connection
@transaction.atomic
def claim_mailbox_connection(
*, connection_id: int, worker_token: str | None = None, force: bool = False
) -> tuple[MailboxConnection, str] | None:
now = timezone.now()
connection = MailboxConnection.objects.select_for_update().get(pk=connection_id)
if not connection.enabled:
return None
if connection.lease_expires_at and connection.lease_expires_at > now:
return None
if not force and connection.next_poll_at > now:
return None
token = worker_token or uuid4().hex
connection.lease_token = token
connection.lease_expires_at = now + timedelta(minutes=5)
connection.save(update_fields=["lease_token", "lease_expires_at", "updated_at"])
return connection, token
@transaction.atomic
def finish_mailbox_poll(
*,
connection_id: int,
worker_token: str,
success: bool,
error_category: str = "",
error_message: str = "",
) -> None:
connection = MailboxConnection.objects.select_for_update().get(pk=connection_id)
if connection.lease_token != worker_token:
return
now = timezone.now()
connection.last_polled_at = now
connection.next_poll_at = now + timedelta(minutes=connection.poll_interval_minutes)
connection.lease_token = ""
connection.lease_expires_at = None
if success:
connection.last_success_at = now
connection.last_error_category = ""
connection.last_error_message = ""
else:
connection.last_error_category = error_category[:80]
connection.last_error_message = error_message[:500]
connection.save(
update_fields=[
"last_polled_at",
"next_poll_at",
"lease_token",
"lease_expires_at",
"last_success_at",
"last_error_category",
"last_error_message",
"updated_at",
]
)
+4
View File
@@ -19,6 +19,10 @@ DEFAULT_DENYLIST = {
"stepstone.be",
"jobat.be",
"vdab.be",
"ictjob.be",
"careerjet.be",
"randstad.be",
"roberthalf.com",
}
+27 -9
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import datetime, timedelta
from urllib.parse import urlsplit, urlunsplit
from urllib.parse import urljoin, urlsplit, urlunsplit
import httpx
from django.conf import settings
@@ -11,6 +11,7 @@ from django.utils import timezone
from apps.sources.models import Source, SourceRobotsCache
from .tls import trusted_tls_context
from .url_security import UnsafeUrlError, validate_public_url
ALLOW = "allow"
@@ -123,20 +124,37 @@ def _max_bytes() -> int:
def _fetch_robots(origin: str, *, client: httpx.Client | None = None) -> tuple[str, int, str, str]:
robots_url = _robots_url(origin)
validate_public_url(
robots_url,
allow_nonstandard_ports=getattr(settings, "FETCHER_ALLOW_NONSTANDARD_PORTS", False),
)
own_client = client is None
http_client = client or httpx.Client(
timeout=httpx.Timeout(getattr(settings, "ROBOTS_FETCH_TIMEOUT_SECONDS", 5)),
follow_redirects=False,
verify=trusted_tls_context(),
)
try:
response = http_client.get(
robots_url,
headers={"User-Agent": getattr(settings, "FETCHER_USER_AGENT", "VacatureRadar")},
)
current_url = robots_url
for _ in range(getattr(settings, "FETCHER_MAX_REDIRECTS", 5) + 1):
validate_public_url(
current_url,
allow_nonstandard_ports=getattr(settings, "FETCHER_ALLOW_NONSTANDARD_PORTS", False),
)
response = http_client.get(
current_url,
headers={"User-Agent": getattr(settings, "FETCHER_USER_AGENT", "VacatureRadar")},
)
if response.status_code not in {301, 302, 303, 307, 308}:
break
location = response.headers.get("location")
if not location:
raise httpx.HTTPStatusError(
"Robotsredirect zonder Location-header",
request=response.request,
response=response,
)
current_url = urljoin(current_url, location)
else:
raise httpx.TooManyRedirects(
"Te veel redirects bij robotscontrole", request=response.request
)
finally:
if own_client:
http_client.close()
+16
View File
@@ -0,0 +1,16 @@
from __future__ import annotations
import ssl
from functools import lru_cache
from pathlib import Path
import certifi
@lru_cache(maxsize=1)
def trusted_tls_context() -> ssl.SSLContext:
"""Build a strict TLS context from the pinned application CA bundle."""
bundle = Path(certifi.where()).resolve(strict=True)
if not bundle.is_file():
raise RuntimeError("De vertrouwde CA-bundel ontbreekt.")
return ssl.create_default_context(cafile=str(bundle))
+63 -37
View File
@@ -1,7 +1,5 @@
from __future__ import annotations
import imaplib
from contextlib import suppress
from datetime import timedelta
from uuid import uuid4
@@ -11,8 +9,7 @@ from django.db.models import Q
from django.utils import timezone
from apps.jobs.services.pipeline import process_raw_document
from apps.sources.models import EmailMessageRecord, RawDocument, Source, SourceRun
from apps.sources.services.email_import import ingest_email, message_identity
from apps.sources.models import MailboxConnection, RawDocument, Source, SourceRun
from apps.sources.services.fetcher import (
FetchError,
FetchTimeoutError,
@@ -25,6 +22,12 @@ from apps.sources.services.health import (
evaluate_source_health,
start_health_canary,
)
from apps.sources.services.imap_import import poll_imap_mailbox
from apps.sources.services.mailbox_connections import (
MailboxCredentialError,
claim_mailbox_connection,
finish_mailbox_poll,
)
from apps.sources.services.policy import assess_url
from apps.sources.services.scheduling import (
acquire_source_lease,
@@ -192,40 +195,63 @@ def schedule_due_sources() -> dict[str, int]:
return {"scheduled": len(ids)}
@shared_task(name="apps.sources.tasks.poll_imap_inbox")
def poll_imap_inbox() -> dict[str, int | str]:
if not settings.IMAP_ENABLED:
return {"status": "disabled", "imported": 0}
if not settings.IMAP_HOST or not settings.IMAP_USER or not settings.IMAP_PASSWORD:
return {"status": "not_configured", "imported": 0}
client_cls = imaplib.IMAP4_SSL if settings.IMAP_USE_SSL else imaplib.IMAP4
client = client_cls(settings.IMAP_HOST, settings.IMAP_PORT)
imported = 0
@shared_task(name="apps.sources.tasks.schedule_mailbox_polls")
def schedule_mailbox_polls() -> dict[str, int]:
now = timezone.now()
due_ids = list(
MailboxConnection.objects.filter(enabled=True, next_poll_at__lte=now)
.filter(Q(lease_expires_at__isnull=True) | Q(lease_expires_at__lte=now))
.values_list("id", flat=True)[:100]
)
for connection_id in due_ids:
poll_mailbox.delay(connection_id)
return {"scheduled": len(due_ids)}
@shared_task(bind=True, name="apps.sources.tasks.poll_mailbox")
def poll_mailbox(self, connection_id: int, force: bool = False) -> dict[str, int | str]:
worker_token = str(self.request.id or uuid4())
claimed = claim_mailbox_connection(
connection_id=connection_id, worker_token=worker_token, force=force
)
if claimed is None:
return {"status": "skipped", "imported": 0}
connection, token = claimed
try:
client.login(settings.IMAP_USER, settings.IMAP_PASSWORD)
status, _ = client.select(settings.IMAP_MAILBOX, readonly=not settings.IMAP_MARK_SEEN)
if status != "OK":
raise RuntimeError("IMAP-mailbox kon niet worden geopend")
status, data = client.uid("search", None, "ALL")
if status != "OK":
raise RuntimeError("IMAP-zoekopdracht mislukt")
uids = (data[0] or b"").split()[-200:]
for uid in uids:
status, payload = client.uid("fetch", uid, "(RFC822)")
if status != "OK" or not payload:
continue
raw = next((part[1] for part in payload if isinstance(part, tuple)), None)
if not raw:
continue
message_id = message_identity(raw)
was_known = EmailMessageRecord.objects.filter(message_id=message_id).exists()
ingest_email(raw, mailbox=settings.IMAP_MAILBOX)
if not was_known:
imported += 1
return {"status": "ok", "imported": imported}
finally:
with suppress(Exception):
client.logout()
result = poll_imap_mailbox(connection)
finish_mailbox_poll(
connection_id=connection.pk,
worker_token=token,
success=result["status"] == "ok",
)
return result
except MailboxCredentialError as exc:
finish_mailbox_poll(
connection_id=connection.pk,
worker_token=token,
success=False,
error_category="credentials",
error_message=str(exc),
)
return {"status": "failed", "error_category": "credentials", "imported": 0}
except Exception as exc:
error_category = "imap"
if exc.__class__.__name__.lower().startswith("unsafe"):
error_category = "host_policy"
finish_mailbox_poll(
connection_id=connection.pk,
worker_token=token,
success=False,
error_category=error_category,
error_message="IMAP-verbinding of authenticatie mislukt.",
)
return {"status": "failed", "error_category": error_category, "imported": 0}
@shared_task(name="apps.sources.tasks.poll_imap_inbox")
def poll_imap_inbox() -> dict[str, int]:
"""Compatibility alias for old deployments; it now schedules due per-platform mailboxes."""
return schedule_mailbox_polls()
@shared_task(name="apps.sources.tasks.cleanup_raw_documents")
+14 -1
View File
@@ -1,10 +1,23 @@
from django.urls import path
from .views import SourceListView, bulk_candidate_action, manual_import_view, retry_source
from .views import (
SourceListView,
bulk_candidate_action,
edit_mailbox,
manual_import_view,
retry_source,
save_mailbox,
sync_platform_alerts,
toggle_mailbox,
)
urlpatterns = [
path("", SourceListView.as_view(), name="list"),
path("manual-import/", manual_import_view, name="manual_import"),
path("mailboxes/add/", save_mailbox, name="mailbox_add"),
path("mailboxes/<int:pk>/edit/", edit_mailbox, name="mailbox_edit"),
path("mailboxes/<int:pk>/sync/", sync_platform_alerts, name="sync_platform_alerts"),
path("mailboxes/<int:pk>/toggle/", toggle_mailbox, name="mailbox_toggle"),
path("bulk/", bulk_candidate_action, name="bulk"),
path("<int:pk>/retry/", retry_source, name="retry"),
]
+133 -6
View File
@@ -4,39 +4,88 @@ from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Count, Q
from django.http import HttpRequest
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.cache import never_cache
from django.views.decorators.http import require_POST
from django.views.generic import ListView
from apps.core.rate_limit import clear_rate_limit, is_rate_limited, register_rate_limit_failure
from apps.profiles.models import SearchProfile
from .forms import ManualImportForm
from .models import Source, SourcePolicyReview
from .forms import MailboxConnectionForm, ManualImportForm
from .models import MailboxConnection, Source, SourcePolicyReview
from .platforms import PLATFORM_ALERTS
from .services.mailbox_connections import MailboxCredentialError, save_mailbox_connection
from .services.manual_import import ManualImportError, ManualImportSummary, import_manual_source
from .services.policy import create_policy_review
from .tasks import fetch_source
from .tasks import fetch_source, poll_mailbox
def _source_list_queryset():
return Source.objects.prefetch_related("policy_reviews").all().order_by("name")
return (
Source.objects.prefetch_related("policy_reviews")
.exclude(domain="jobs.example.org", status=Source.Status.DISABLED)
.filter(
Q(metadata__hidden_from_source_list__isnull=True)
| Q(metadata__hidden_from_source_list=False)
)
.filter(Q(metadata__watchlist__isnull=True) | Q(metadata__watchlist=False))
.annotate(job_count=Count("job_aliases", distinct=True))
.order_by("name")
)
def _manual_import_context(request: HttpRequest, *, form: ManualImportForm, manual_result=None):
def _watchlist_queryset():
return (
Source.objects.filter(metadata__watchlist=True)
.filter(
Q(metadata__hidden_from_source_list__isnull=True)
| Q(metadata__hidden_from_source_list=False)
)
.order_by("name")
)
def _manual_import_context(
request: HttpRequest,
*,
form: ManualImportForm,
manual_result=None,
mailbox_form: MailboxConnectionForm | None = None,
editing_mailbox: MailboxConnection | None = None,
):
base_queryset = _source_list_queryset()
watchlist_queryset = _watchlist_queryset()
mailbox_connections = list(
request.user.mailbox_connections.filter(platform__in=PLATFORM_ALERTS)
)
active_search_profile = SearchProfile.objects.filter(user=request.user, is_active=True).first()
return {
"sources": base_queryset,
"active_source_count": Source.objects.filter(status=Source.Status.ACTIVE).count(),
"source_total_count": base_queryset.count(),
"review_pending_count": Source.objects.filter(policy=Source.Policy.REVIEW).count(),
"watchlist_sources": watchlist_queryset,
"watchlist_count": watchlist_queryset.count(),
"manual_import_form": form,
"manual_import_bookmarklet_endpoint": request.build_absolute_uri(
reverse("sources:manual_import")
),
"manual_import_paste_max_bytes": settings.MANUAL_IMPORT_PASTE_MAX_BYTES,
"manual_import_result": manual_result,
"platform_alert_ingress_ready": any(
connection.enabled for connection in mailbox_connections
),
"mailbox_connections": mailbox_connections,
"platform_alerts": PLATFORM_ALERTS.values(),
"mailbox_form": mailbox_form or MailboxConnectionForm(),
"editing_mailbox": editing_mailbox,
"mailbox_credential_key_ready": bool(settings.MAILBOX_CREDENTIAL_KEYS),
"active_search_profile": active_search_profile,
}
@@ -108,7 +157,7 @@ def _action_to_review(
def bulk_candidate_action(request):
action = request.POST.get("action")
source_ids = [int(raw_id) for raw_id in request.POST.getlist("source_ids") if raw_id.isdigit()]
sources = Source.objects.filter(id__in=source_ids).exclude(policy=Source.Policy.DENY)
sources = _source_list_queryset().filter(id__in=source_ids).exclude(policy=Source.Policy.DENY)
if not sources.exists():
messages.info(request, "Geen bruikbare bronregels geselecteerd.")
return redirect("sources:list")
@@ -148,6 +197,84 @@ def retry_source(request, pk: int):
return redirect("sources:list")
@login_required
@require_POST
def save_mailbox(request, pk: int | None = None):
instance = None
if pk is not None:
instance = get_object_or_404(MailboxConnection, pk=pk, user=request.user)
form = MailboxConnectionForm(request.POST, instance=instance)
if form.is_valid():
try:
connection = save_mailbox_connection(
user=request.user,
cleaned_data=dict(form.cleaned_data),
instance=instance,
)
except MailboxCredentialError as exc:
form.add_error("password", str(exc))
else:
poll_mailbox.delay(connection.pk, force=True)
messages.success(
request,
f"{connection.get_platform_display()}-mailbox opgeslagen; "
"verbindingstest ingepland.",
)
return redirect("sources:list")
messages.error(request, "Controleer de mailboxinstellingen.")
return render(
request,
"sources/list.html",
_manual_import_context(
request,
form=ManualImportForm(),
mailbox_form=form,
editing_mailbox=instance,
),
)
@login_required
def edit_mailbox(request, pk: int):
connection = get_object_or_404(MailboxConnection, pk=pk, user=request.user)
if request.method == "POST":
return save_mailbox(request, pk=pk)
return render(
request,
"sources/list.html",
_manual_import_context(
request,
form=ManualImportForm(),
mailbox_form=MailboxConnectionForm(instance=connection),
editing_mailbox=connection,
),
)
@login_required
@require_POST
def sync_platform_alerts(request, pk: int):
connection = get_object_or_404(MailboxConnection, pk=pk, user=request.user)
if not connection.enabled:
messages.error(request, "Activeer deze mailbox voordat je synchroniseert.")
else:
poll_mailbox.delay(connection.pk, force=True)
messages.success(request, f"{connection.get_platform_display()}-mailimport ingepland.")
return redirect("sources:list")
@login_required
@require_POST
def toggle_mailbox(request, pk: int):
connection = get_object_or_404(MailboxConnection, pk=pk, user=request.user)
connection.enabled = not connection.enabled
connection.next_poll_at = timezone.now()
connection.save(update_fields=["enabled", "next_poll_at", "updated_at"])
state = "geactiveerd" if connection.enabled else "gepauzeerd"
messages.success(request, f"{connection.get_platform_display()}-mailbox {state}.")
return redirect("sources:list")
@login_required
@never_cache
def manual_import_view(request):
+48 -8
View File
@@ -1,16 +1,56 @@
# Lokale geodata
Plaats hier de door de beheerder aangeleverde geodatabestanden.
Deze bestanden worden niet automatisch meegeleverd in de repository.
De gekozen bron is de Belgische GeoNames Postal Code export. Het bulkbestand blijft lokaal en
wordt niet in Git, de project-ZIP of logs opgenomen.
Aanbevolen bestandsformaat:
## Vastgelegde bron
- `geodata-belgie.csv` (of vergelijkbaar)
- kolommen: `postal_code,municipality,region,latitude,longitude`
- geexporteerd als UTF-8 met komma als scheidingsteken
- Herkomst: `https://download.geonames.org/export/zip/BE.zip`
- Upstreamversie/Last-Modified: `2026-04-03 03:39 CET`
- Download- en reviewdatum: `2026-07-22`
- Licentie: Creative Commons Attribution 4.0 (`https://creativecommons.org/licenses/by/4.0/`)
- Upstream-archief SHA-256: `4c412c8482c5002273e880289adc83ec7d5c37866a5cf5bff893d8b38fb7eefa`
- Afgeleide CSV SHA-256: `7ffddc97b07b4bac8e9aade68bbfbc2730dea5291505489adc97c3992ad16198`
- Afgeleide CSV: 2.781 unieke `postcode + plaatsnaam`-rijen; geen persoonsgegevens
- Benodigde attributie bij distributie of publieke presentatie: `GeoNames (www.geonames.org)`
Importeer met:
GeoNames beschrijft de coördinaten als geschatte WGS84-punten. VacatureRadar gebruikt ze als
postcode-/plaatscentrum, nooit als exact woon- of werkadres.
## Lokale bestanden
De gecontroleerde runtime gebruikt:
- `local/geodata/BE-2026-04-03.zip`
- `local/geodata/geonames-be-2026-04-03.csv`
- CSV-kolommen: `postal_code,municipality,region,latitude,longitude`
## Gecontroleerde steekproef
| Postcode | Plaats | Latitude | Longitude |
|---|---|---:|---:|
| 2400 | Mol | 51.1919 | 5.1166 |
| 2440 | Geel | 51.1656 | 4.9892 |
| 2260 | Westerlo | 51.0905 | 4.9154 |
| 2300 | Turnhout | 51.3225 | 4.9447 |
| 2250 | Olen | 51.1439 | 4.8598 |
| 2370 | Arendonk | 51.3227 | 5.0829 |
| 3920 | Lommel | 51.2307 | 5.3135 |
Valideer en importeer met:
```bash
python manage.py import_geodata config-data/geodata/geodata-belgie.csv --replace
python manage.py import_geodata local/geodata/geonames-be-2026-04-03.csv \
--source-name geonames-postal-be \
--dataset-version 2026-04-03 \
--license-name "Creative Commons Attribution 4.0" \
--license-url "https://creativecommons.org/licenses/by/4.0/" \
--validate-only
python manage.py import_geodata local/geodata/geonames-be-2026-04-03.csv \
--source-name geonames-postal-be \
--dataset-version 2026-04-03 \
--license-name "Creative Commons Attribution 4.0" \
--license-url "https://creativecommons.org/licenses/by/4.0/" \
--replace
```
+816 -9
View File
@@ -1,11 +1,818 @@
# Alleen expliciet beoordeelde bronnen mogen policy: allow krijgen.
# De demobron is niet bedoeld voor live crawling; fixtures gebruiken hetzelfde domein.
# Publieke vacaturelijsten van werkgevers binnen ongeveer 40 km van postcode 2400 Mol.
# Afstanden gebruiken de lokaal geïmporteerde GeoNames-postcodedataset; vacaturelocaties blijven bronafhankelijk.
disable_demo_sources: true
retire_source_urls:
- https://api.lever.co/v0/postings/deliverect?mode=json
- https://ypto.recruitee.com/api/offers/
- https://adesso1.recruitee.com/api/offers/
- https://monizze.recruitee.com/api/offers/
- https://api.lever.co/v0/postings/Qover?mode=json&location=Brussels
- https://api.lever.co/v0/postings/sfeir?mode=json&location=Bruxelles
- https://api.smartrecruiters.com/v1/companies/ARHS/postings?limit=100&country=be
sources:
- name: Voorbeeldwerkgever (fixture)
url: https://jobs.example.org/vacatures/
type: employer
status: paused
policy: review
policy_reason: Demobron; activeer geen voorbeeld.org-domein voor productie.
parser: auto
- name: VITO
url: https://vito.recruitee.com/api/offers/
type: ats
status: active
policy: allow
policy_reason: Publieke Recruitee-vacaturefeed van VITO; twee geslaagde livecycli op 2026-07-21.
parser: ats-recruitee
allow_public_endpoint: true
crawl_interval_minutes: 720
minimum_interval_seconds: 60
metadata:
employer_name: VITO
direct_employer: true
employer_homepage: https://vito.be/nl/jobs
public_job_page: https://jobs.vito.be/l/nl
region_scope: Kempen-Limburg
municipality_scope: Mol
country_scope: BE
review:
decision: allow
reason: Publieke werkgeversfeed, toegestane robotsroute en twee geslaagde livecycli.
evidence_link: https://jobs.vito.be/l/nl
expires_at: '2026-10-19T23:59:59+02:00'
notes: Alleen GET op de publieke offers-feed; nooit sollicitatie-endpoints.
metadata:
reviewed_at: '2026-07-21'
robots_url: https://jobs.vito.be/robots.txt
api_documentation: https://docs.recruitee.com/reference/offers
- name: Corda Campus werkgevers
url: https://www.cordacampus.com/jobs/
type: sitemap
status: active
policy: allow
policy_reason: Publieke regionale vacaturelijst van Corda Campus; twee geslaagde livecycli op 2026-07-22.
parser: regional-corda-campus
crawl_interval_minutes: 720
minimum_interval_seconds: 60
metadata:
direct_employer: false
source_scope: regional-employer-hub
region_scope: Limburg
municipality_scope: Hasselt
public_job_page: https://www.cordacampus.com/jobs/
country_scope: BE
review:
decision: allow
reason: Publieke regionale werkgeverslijst met rechtstreeks gepubliceerde vacaturedetailpagina's.
evidence_link: https://www.cordacampus.com/jobs/
expires_at: '2026-10-20T23:59:59+02:00'
notes: Alleen GET op de publieke joblijst; off-domain links worden geweigerd.
metadata:
reviewed_at: '2026-07-22'
robots_url: https://www.cordacampus.com/robots.txt
- name: RPM Europe Logistics Genk
url: https://rpmeuropelogisticsnv.recruitee.com/api/offers/
type: ats
status: active
policy: allow
policy_reason: Publieke Recruitee-feed van een werkgever in Genk; twee geslaagde livecycli op 2026-07-22.
parser: ats-recruitee
allow_public_endpoint: true
crawl_interval_minutes: 720
minimum_interval_seconds: 60
metadata:
employer_name: RPM Europe Logistics NV
direct_employer: true
employer_homepage: https://rpmeuropelogisticsnv.recruitee.com/
public_job_page: https://rpmeuropelogisticsnv.recruitee.com/
region_scope: Limburg
municipality_scope: Genk
country_scope: BE
review:
decision: allow
reason: Publieke werkgeverfeed via de gedocumenteerde Recruitee Offers API.
evidence_link: https://rpmeuropelogisticsnv.recruitee.com/
expires_at: '2026-10-20T23:59:59+02:00'
notes: Alleen publieke GET-feed; geen apply-requests of kandidaatdata.
metadata:
reviewed_at: '2026-07-22'
robots_url: https://rpmeuropelogisticsnv.recruitee.com/robots.txt
- name: ACA Group
url: https://acagroup.be/en/jobs
type: employer
status: active
policy: allow
policy_reason: Publieke werkgeverslijst van het Hasseltse hoofdkantoor; twee geslaagde livecycli op 2026-07-22.
parser: regional-local-employers
crawl_interval_minutes: 720
minimum_interval_seconds: 60
metadata:
employer_name: ACA Group
direct_employer: true
employer_homepage: https://acagroup.be/en/
public_job_page: https://acagroup.be/en/jobs
region_scope: Limburg
municipality_scope: Hasselt
country_scope: BE
review:
decision: allow
reason: Publieke vacaturekaarten op de eigen werkgeverwebsite met same-host detailpagina's.
evidence_link: https://acagroup.be/en/jobs
expires_at: '2026-10-20T23:59:59+02:00'
notes: Alleen GET; werkgever en lokale kantoorscope zijn vastgelegd, off-domain links worden geweigerd.
metadata:
reviewed_at: '2026-07-22'
robots_url: https://acagroup.be/robots.txt
- name: Xplore Group
url: https://www.xploregroup.be/en/jobs
type: employer
status: active
policy: allow
policy_reason: Publieke werkgeverslijst met expliciete Hasselt-locaties; twee geslaagde livecycli op 2026-07-22.
parser: regional-local-employers
crawl_interval_minutes: 720
minimum_interval_seconds: 60
metadata:
employer_name: Xplore Group
direct_employer: true
public_job_page: https://www.xploregroup.be/en/jobs
region_scope: Limburg
municipality_scope: Hasselt
country_scope: BE
review:
decision: allow
reason: Publieke vacaturekaarten; de adapter houdt alleen kaarten met Hasselt als locatie over.
evidence_link: https://www.xploregroup.be/en/jobs
expires_at: '2026-10-20T23:59:59+02:00'
notes: Alleen GET en same-host details; kaarten zonder Hasselt worden deterministisch geweigerd.
metadata:
reviewed_at: '2026-07-22'
robots_url: https://www.xploregroup.be/robots.txt
- name: Universiteit Hasselt
url: https://www.uhasselt.be/vacatures
type: employer
status: active
policy: allow
policy_reason: Publieke lokale vacaturelijst van UHasselt; twee geslaagde livecycli op 2026-07-22.
parser: regional-local-employers
crawl_interval_minutes: 720
minimum_interval_seconds: 60
metadata:
employer_name: Universiteit Hasselt
direct_employer: true
public_job_page: https://www.uhasselt.be/vacatures
region_scope: Limburg
municipality_scope: Hasselt-Diepenbeek
country_scope: BE
public_sector: true
review:
decision: allow
reason: Publieke eigen vacaturelijst met lokale same-host detailpagina's.
evidence_link: https://www.uhasselt.be/vacatures
expires_at: '2026-10-20T23:59:59+02:00'
notes: Alleen GET; ontbrekende robots.txt geldt niet als toestemming maar er zijn geen zichtbare padblokkades.
metadata:
reviewed_at: '2026-07-22'
robots_url: https://www.uhasselt.be/robots.txt
- name: Hogeschool PXL
url: https://ses.pxl.be/
type: employer
status: active
policy: allow
policy_reason: Publieke lokale vacaturelijst van PXL; twee geslaagde livecycli op 2026-07-22.
parser: regional-local-employers
crawl_interval_minutes: 720
minimum_interval_seconds: 60
metadata:
employer_name: Hogeschool PXL
direct_employer: true
public_job_page: https://ses.pxl.be/
region_scope: Limburg
municipality_scope: Hasselt-Diepenbeek
country_scope: BE
public_sector: true
review:
decision: allow
reason: Publieke eigen vacaturekaarten met campusinformatie zonder kandidaatlogin.
evidence_link: https://ses.pxl.be/
expires_at: '2026-10-20T23:59:59+02:00'
notes: Alleen GET op de lijst; sollicitatie- en kandidaatendpoints worden niet benaderd.
metadata:
reviewed_at: '2026-07-22'
robots_url: https://ses.pxl.be/robots.txt
- name: Ziekenhuis Oost-Limburg
url: https://ziekenhuis-oost-limburg.cvw.io/?lang=nl-BE
type: employer
status: active
policy: allow
policy_reason: Publieke lokale vacaturelijst van ZOL; twee geslaagde livecycli op 2026-07-22.
parser: regional-local-employers
crawl_interval_minutes: 720
minimum_interval_seconds: 60
metadata:
employer_name: Ziekenhuis Oost-Limburg
direct_employer: true
employer_homepage: https://www.zol.be/
public_job_page: https://ziekenhuis-oost-limburg.cvw.io/?lang=nl-BE
region_scope: Limburg
municipality_scope: Genk-Lanaken-Maaseik
country_scope: BE
public_sector: true
review:
decision: allow
reason: Publieke CVWarehouse-vacaturelijst van ZOL met same-host GET-detailpagina's.
evidence_link: https://ziekenhuis-oost-limburg.cvw.io/?lang=nl-BE
expires_at: '2026-10-20T23:59:59+02:00'
notes: Alleen publieke GET-lijst; geen login, sollicitatie-endpoint of kandidaatdata.
metadata:
reviewed_at: '2026-07-22'
robots_url: https://ziekenhuis-oost-limburg.cvw.io/robots.txt
- name: SCK CEN
url: https://www.sckcen.be/nl/carriere/vacatures
type: employer
status: active
policy: allow
policy_reason: Publieke eigen vacaturelijst van SCK CEN in postcode 2400 Mol; twee livecycli geslaagd.
parser: regional-mol-employers
crawl_interval_minutes: 720
minimum_interval_seconds: 60
metadata:
employer_name: SCK CEN
direct_employer: true
employer_homepage: https://www.sckcen.be/
public_job_page: https://www.sckcen.be/nl/carriere/vacatures
region_center: 2400 Mol
municipality_scope: Mol
country_scope: BE
review:
decision: allow
reason: Publieke same-host vacaturekaarten van de werkgever; robots blokkeert het vacaturepad niet.
evidence_link: https://www.sckcen.be/nl/carriere/vacatures
expires_at: '2026-10-20T23:59:59+02:00'
notes: Alleen GET op de lijst; geen SuccessFactors-sollicitatie- of kandidaatendpoint.
metadata:
reviewed_at: '2026-07-22'
robots_url: https://www.sckcen.be/robots.txt
- name: Cipal Schaubroeck
url: https://cipalschaubroeck.teamtailor.com/jobs
type: employer
status: active
policy: allow
policy_reason: Publieke werkgeverslijst met expliciete Westerlo- en Geel-locaties; twee livecycli geslaagd.
parser: regional-mol-employers
crawl_interval_minutes: 720
minimum_interval_seconds: 60
metadata:
employer_name: Cipal Schaubroeck
direct_employer: true
employer_homepage: https://www.cipalschaubroeck.be/
public_job_page: https://cipalschaubroeck.teamtailor.com/jobs
region_center: 2400 Mol
municipality_scope: Westerlo-Geel
country_scope: BE
review:
decision: allow
reason: Publieke Teamtailor-lijst; parser bewaart alleen kaarten die Westerlo of Geel expliciet vermelden.
evidence_link: https://cipalschaubroeck.teamtailor.com/jobs
expires_at: '2026-10-20T23:59:59+02:00'
notes: Alleen publieke GET-lijst; interne, login-, message- en sollicitatieroutes blijven uitgesloten.
metadata:
reviewed_at: '2026-07-22'
robots_url: https://cipalschaubroeck.teamtailor.com/robots.txt
- name: VanRoey
url: https://www.vanroey.be/en/job-overview/
type: employer
status: active
policy: allow
policy_reason: Publieke IT-vacaturelijst van een werkgever met vestigingen in Turnhout en Geel; twee livecycli geslaagd.
parser: regional-mol-employers
crawl_interval_minutes: 720
minimum_interval_seconds: 60
metadata:
employer_name: VanRoey
direct_employer: true
employer_homepage: https://www.vanroey.be/
public_job_page: https://www.vanroey.be/en/job-overview/
region_center: 2400 Mol
municipality_scope: Turnhout-Geel
country_scope: BE
review:
decision: allow
reason: Publieke eigen vacaturelijst met actuele netwerk-, cloud-, Microsoft- en securityrollen.
evidence_link: https://www.vanroey.be/en/job-overview/
expires_at: '2026-10-20T23:59:59+02:00'
notes: Alleen de toegestane overzichtspagina wordt gefetcht; robots sluit detailpaden uit en die worden niet opgehaald.
metadata:
reviewed_at: '2026-07-22'
robots_url: https://www.vanroey.be/robots.txt
- name: NTX (Netropolix)
url: https://netropolix.recruitee.com/
type: employer
status: active
policy: allow
policy_reason: Publieke Recruitee-carrièrepagina van een Kempense IT-werkgever; alleen Geel-kaarten worden verwerkt.
parser: regional-mol-employers
crawl_interval_minutes: 720
minimum_interval_seconds: 60
metadata:
employer_name: NTX (Netropolix)
direct_employer: true
employer_homepage: https://www.netropolix.be/
public_job_page: https://netropolix.recruitee.com/
region_center: 2400 Mol
municipality_scope: Geel
country_scope: BE
review:
decision: allow
reason: Publieke werkgeverpagina; parser weigert kaarten zonder expliciete Geel-locatie.
evidence_link: https://netropolix.recruitee.com/
expires_at: '2026-10-20T23:59:59+02:00'
notes: Alleen publieke GET-lijst en same-host vacaturelinks; kandidaat- en applyroutes worden niet benaderd.
metadata:
reviewed_at: '2026-07-22'
robots_url: https://netropolix.recruitee.com/robots.txt
- name: Thomas More
url: https://jobpage.cvwarehouse.com/?companyGuid=eab9ca13-ee10-4504-8a87-a785d0b037ef&lang=nl-BE
type: employer
status: active
policy: allow
policy_reason: Publieke Thomas More-vacaturelijst; alleen titels met Geel, Turnhout of Vorselaar worden verwerkt.
parser: regional-mol-employers
crawl_interval_minutes: 720
minimum_interval_seconds: 60
metadata:
employer_name: Thomas More
direct_employer: true
employer_homepage: https://thomasmore.be/nl/jobs
public_job_page: https://jobpage.cvwarehouse.com/?companyGuid=eab9ca13-ee10-4504-8a87-a785d0b037ef&lang=nl-BE
region_center: 2400 Mol
municipality_scope: Geel-Turnhout-Vorselaar
country_scope: BE
public_sector: true
review:
decision: allow
reason: Publieke CVWarehouse-lijst met vaste werkgever-GUID; regio staat expliciet in de bewaarde kaarttitel.
evidence_link: https://thomasmore.be/nl/jobs
expires_at: '2026-10-20T23:59:59+02:00'
notes: Alleen GET op de lijst; apply-, kandidaat- en andere werkgever-GUIDs worden geweigerd.
metadata:
reviewed_at: '2026-07-22'
robots_url: https://jobpage.cvwarehouse.com/robots.txt
- name: Gemeente Mol
url: https://jobs.gemeentemol.be/rss/vacatures.rss
type: rss
status: active
policy: allow
policy_reason: Officiële publieke vacature-RSS van Gemeente Mol; actuele CA-validatie en twee livecycli geslaagd.
parser: rss-atom
crawl_interval_minutes: 360
minimum_interval_seconds: 60
metadata:
employer_name: Gemeente Mol
direct_employer: true
employer_homepage: https://www.gemeentemol.be/
public_job_page: https://jobs.gemeentemol.be/vacatures
region_center: 2400 Mol
municipality_scope: Mol
country_scope: BE
public_sector: true
review:
decision: allow
reason: Door de werkgever aangeboden RSS-feed; alleen publieke GET en same-host vacaturelinks.
evidence_link: https://jobs.gemeentemol.be/rss/vacatures.rss
expires_at: '2026-10-20T23:59:59+02:00'
notes: TLS valideert met de vastgepinde CA-bundel; robots blokkeert de RSS-route niet.
metadata:
reviewed_at: '2026-07-22'
robots_url: https://jobs.gemeentemol.be/robots.txt
- name: Ziekenhuis Geel
url: https://ziekenhuisgeel.careersite.be/nl/vacatures
type: employer
status: active
policy: allow
policy_reason: Publieke eigen vacaturelijst in Geel; actuele CA-validatie en twee livecycli geslaagd.
parser: regional-kempen-employers
crawl_interval_minutes: 360
minimum_interval_seconds: 60
metadata:
employer_name: Ziekenhuis Geel
direct_employer: true
employer_homepage: https://www.ziekenhuisgeel.be/
public_job_page: https://ziekenhuisgeel.careersite.be/nl/vacatures
region_center: 2400 Mol
municipality_scope: Geel
country_scope: BE
public_sector: true
review:
decision: allow
reason: Publieke werkgeverslijst; adapter accepteert alleen exacte same-host vacaturepaden.
evidence_link: https://ziekenhuisgeel.careersite.be/nl/vacatures
expires_at: '2026-10-20T23:59:59+02:00'
notes: Geen TLS-bypass; actuele certifi-keten valideert. Apply- en controlcenterpaden blijven uitgesloten.
metadata:
reviewed_at: '2026-07-22'
robots_url: https://ziekenhuisgeel.careersite.be/robots.txt
- name: Lokaal bestuur Geel
url: https://geel.hro.be/
type: employer
status: active
policy: allow
policy_reason: Publieke HRO-vacaturelijst van het lokaal bestuur Geel met exacte vacature-ID's.
parser: regional-kempen-employers
crawl_interval_minutes: 360
minimum_interval_seconds: 60
metadata:
employer_name: Lokaal bestuur Geel
direct_employer: true
employer_homepage: https://www.geel.be/
public_job_page: https://geel.hro.be/
region_center: 2400 Mol
municipality_scope: Geel
country_scope: BE
public_sector: true
review:
decision: allow
reason: Publieke lijst op de officiële sollicitatiesite; alleen de rootlijst wordt opgehaald.
evidence_link: https://geel.hro.be/
expires_at: '2026-10-20T23:59:59+02:00'
notes: Private routes en spontane sollicitatie worden niet benaderd.
metadata:
reviewed_at: '2026-07-22'
robots_url: https://geel.hro.be/robots.txt
- name: Stad Turnhout
url: https://jobs.turnhout.be/
type: employer
status: active
policy: allow
policy_reason: Publieke eigen vacaturelijst van Stad Turnhout met exacte vacature-ID's.
parser: regional-kempen-employers
crawl_interval_minutes: 360
minimum_interval_seconds: 60
metadata:
employer_name: Stad Turnhout
direct_employer: true
employer_homepage: https://www.turnhout.be/
public_job_page: https://jobs.turnhout.be/
region_center: 2400 Mol
municipality_scope: Turnhout
country_scope: BE
public_sector: true
review:
decision: allow
reason: Publieke lijst van het lokaal bestuur; parser bewaart alleen lokale vacaturekaarten.
evidence_link: https://jobs.turnhout.be/
expires_at: '2026-10-20T23:59:59+02:00'
notes: Alleen GET op de rootlijst; private, jobstudent- en sollicitatieroutes worden niet gevolgd.
metadata:
reviewed_at: '2026-07-22'
robots_url: https://jobs.turnhout.be/robots.txt
- name: Group Renotec
url: https://jobs.renotec.be/nl/alle-jobs/
type: employer
status: active
policy: allow
policy_reason: Publieke werkgeverslijst; alleen kaarten die Geel expliciet als werkregio noemen worden verwerkt.
parser: regional-kempen-employers
crawl_interval_minutes: 720
minimum_interval_seconds: 60
metadata:
employer_name: Group Renotec
direct_employer: true
employer_homepage: https://renotec.be/
public_job_page: https://jobs.renotec.be/nl/alle-jobs/
region_center: 2400 Mol
municipality_scope: Geel
country_scope: BE
review:
decision: allow
reason: Publieke vacaturekaarten op de eigen jobsite met zichtbare regionale markers.
evidence_link: https://jobs.renotec.be/nl/alle-jobs/
expires_at: '2026-10-20T23:59:59+02:00'
notes: Meerdere werfregio's blijven expliciet gelabeld; sollicitatieformulieren worden niet benaderd.
metadata:
reviewed_at: '2026-07-22'
robots_url: https://jobs.renotec.be/robots.txt
- name: Ravago
url: https://ravago.softgarden.io/en/vacancies
type: employer
status: active
policy: allow
policy_reason: Publieke Softgarden-lijst; alleen kaarten met Arendonk of Olen als expliciete locatie.
parser: regional-kempen-employers
crawl_interval_minutes: 720
minimum_interval_seconds: 60
metadata:
employer_name: Ravago
direct_employer: true
employer_homepage: https://ravago.com/en-BE/lifeatravago
public_job_page: https://ravago.softgarden.io/en/vacancies
region_center: 2400 Mol
municipality_scope: Arendonk-Olen
country_scope: BE
review:
decision: allow
reason: Publieke werkgeverslijst met stabiele vacature-ID's en expliciete plaatsvelden.
evidence_link: https://ravago.softgarden.io/en/vacancies
expires_at: '2026-10-20T23:59:59+02:00'
notes: Alleen de lijst; Softgarden API-, apply-, HR- en loginroutes zijn uitgesloten.
metadata:
reviewed_at: '2026-07-22'
robots_url: https://ravago.softgarden.io/robots.txt
- name: Sanofi Geel
url: https://jobs.sanofi.com/en/belgium
type: employer
status: active
policy: allow
policy_reason: Publieke Belgische werkgeverspagina; parser bewaart alleen kaarten met Geel als exacte locatie.
parser: regional-kempen-employers
crawl_interval_minutes: 720
minimum_interval_seconds: 60
metadata:
employer_name: Sanofi
direct_employer: true
employer_homepage: https://www.sanofi.be/
public_job_page: https://jobs.sanofi.com/en/belgium
region_center: 2400 Mol
municipality_scope: Geel
country_scope: BE
review:
decision: allow
reason: Publieke werkgeverspagina met lokale jobkaarten en same-host details.
evidence_link: https://jobs.sanofi.com/en/belgium
expires_at: '2026-10-20T23:59:59+02:00'
notes: Search-, talentcommunity- en applyroutes worden niet gecrawld.
metadata:
reviewed_at: '2026-07-22'
robots_url: https://jobs.sanofi.com/robots.txt
- name: DAF Trucks Westerlo
url: https://www.daf.com/nl-nl/werken-bij-daf/vacatures
type: employer
status: active
policy: allow
policy_reason: Publieke DAF-vacaturelijst; alleen kaarten met Westerlo als exacte locatie.
parser: regional-kempen-employers
crawl_interval_minutes: 720
minimum_interval_seconds: 60
metadata:
employer_name: DAF Trucks
direct_employer: true
employer_homepage: https://www.daf.be/nl-be/over-daf/werken-bij-daf-trucks
public_job_page: https://www.daf.com/nl-nl/werken-bij-daf/vacatures
region_center: 2400 Mol
municipality_scope: Westerlo
country_scope: BE
review:
decision: allow
reason: Publieke globale werkgeverslijst met deterministische Westerlo-locatiefilter.
evidence_link: https://www.daf.be/nl-be/over-daf/werken-bij-daf-trucks/vacatures
expires_at: '2026-10-20T23:59:59+02:00'
notes: Alleen lijst-GET; Randstad-, jobalert- en sollicitatieroutes worden niet gevolgd.
metadata:
reviewed_at: '2026-07-22'
robots_url: https://www.daf.com/robots.txt
- name: Johnson & Johnson Beerse
url: https://www.careers.jnj.com/en/locations/emea/belgium/
type: employer
status: candidate
policy: review
policy_reason: Interessante regionale werkgever; geautomatiseerde toegang gaf een challenge en blijft gepauzeerd.
parser: auto
metadata:
employer_name: Johnson & Johnson
employer_homepage: https://www.jnj.com/
public_job_page: https://www.careers.jnj.com/en/locations/emea/belgium/
municipality_scope: Beerse
watchlist: true
watchlist_reason: Grote farmaceutische en digitale werkgever met veel rollen in Beerse; handmatig volgen zolang de publieke pagina automatisering blokkeert.
current_observation: Actuele Beerse-vacatures bevestigd; geen automatische fetch wegens HTTP 403/challenge.
review: &watchlist_review
decision: pause
reason: Waaklijst; eerst stabiele publieke lijst en parserbewijs vereist.
evidence_link: https://www.careers.jnj.com/en/locations/emea/belgium/
expires_at: '2026-10-20T23:59:59+02:00'
notes: Geen challenge- of botomzeiling.
metadata: {reviewed_at: '2026-07-22'}
- name: Nike European Logistics Campus
url: https://careers.nike.com/jobs
type: employer
status: candidate
policy: review
policy_reason: Interessante werkgever in Laakdal/Ham; globale dynamische lijst nog niet fail-closed te filteren.
parser: auto
metadata:
employer_name: Nike
public_job_page: https://careers.nike.com/jobs
municipality_scope: Laakdal-Ham
watchlist: true
watchlist_reason: Groot logistiek en technologisch campuswerkgever met operations-, data-, onderhouds- en leidinggevende rollen.
current_observation: Actuele Laakdal-vacatures bevestigd; locatiefilter wordt client-side toegepast.
review:
decision: pause
reason: Waaklijst; eerst stabiele publieke lijst en parserbewijs vereist.
evidence_link: https://careers.nike.com/jobs
expires_at: '2026-10-20T23:59:59+02:00'
notes: Geen clientfilter of verborgen endpoint omzeilen.
metadata: {reviewed_at: '2026-07-22'}
- name: AZ Turnhout
url: https://jobpage.cvwarehouse.com/?companyGuid=588d29e6-7c84-4f07-a5bb-42d21e04445a&lang=nl-BE
type: employer
status: candidate
policy: review
policy_reason: Regionaal ziekenhuis met veel jobs; sectiegebonden CVWarehouse-lijst vraagt nog adapteruitbreiding.
parser: auto
metadata:
employer_name: AZ Turnhout
public_job_page: https://www.azturnhout.be/werken-bij-az-turnhout
municipality_scope: Turnhout
watchlist: true
watchlist_reason: Grote regionale zorgwerkgever met ook administratie, techniek en IT-ondersteuning.
current_observation: Actuele vacatures bevestigd; secties moeten afzonderlijk fail-closed worden gelezen.
review:
decision: pause
reason: Waaklijst; eerst stabiele publieke lijst en parserbewijs vereist.
evidence_link: https://www.azturnhout.be/werken-bij-az-turnhout
expires_at: '2026-10-20T23:59:59+02:00'
notes: Geen sollicitatie- of kandidaatendpoint benaderen.
metadata: {reviewed_at: '2026-07-22'}
- name: Cartamundi Turnhout
url: https://cartamundi.com/en/careers/?country=belgium
type: employer
status: candidate
policy: review
policy_reason: Regionale hoofdzetel met publieke carrièrepagina; ATS-grens nog te valideren.
parser: auto
metadata:
employer_name: Cartamundi
public_job_page: https://cartamundi.com/en/careers/?country=belgium
municipality_scope: Turnhout
watchlist: true
watchlist_reason: Internationale productie- en hoofdzetelomgeving met engineering, IT, logistiek en ondersteunende functies.
current_observation: Carrièrepagina en CVWarehouse-ingang bevestigd; lokale vacaturetelling niet stabiel server-side.
review:
decision: pause
reason: Waaklijst; eerst stabiele publieke lijst en parserbewijs vereist.
evidence_link: https://cartamundi.com/en/careers/?country=belgium
expires_at: '2026-10-20T23:59:59+02:00'
notes: ATS-werkgevergrens eerst valideren.
metadata: {reviewed_at: '2026-07-22'}
- name: Nyrstar Balen/Pelt
url: https://jobs.nyrstar.com/go/BalenPelt-NL-BE/8741302/?locale=nl_BE
type: employer
status: candidate
policy: review
policy_reason: Grote industriële werkgever nabij Mol; momenteel geen lokale kaart in de publieke categoriepagina.
parser: auto
metadata:
employer_name: Nyrstar
public_job_page: https://jobs.nyrstar.com/go/BalenPelt-NL-BE/8741302/?locale=nl_BE
municipality_scope: Balen-Pelt
watchlist: true
watchlist_reason: Interessant voor techniek, automatisatie, operations en infrastructuur.
current_observation: Geen actuele Balen/Pelt-vacature aangetroffen; talent community beschikbaar.
review:
decision: pause
reason: Waaklijst; eerst stabiele publieke lijst en parserbewijs vereist.
evidence_link: https://jobs.nyrstar.com/go/BalenPelt-NL-BE/8741302/?locale=nl_BE
expires_at: '2026-10-20T23:59:59+02:00'
notes: Lege lokale lijst is geldig; geen globale vacatures importeren.
metadata: {reviewed_at: '2026-07-22'}
- name: Aurubis Olen
url: https://www.aurubis.com/olen/carriere/jobs
type: employer
status: candidate
policy: review
policy_reason: Grote Kempense procesindustrie; publieke pagina toont nu vooral spontane sollicitatie.
parser: auto
metadata:
employer_name: Aurubis Olen
public_job_page: https://www.aurubis.com/olen/carriere/jobs
municipality_scope: Olen
watchlist: true
watchlist_reason: Meer dan zeshonderd medewerkers en functies in engineering, OT, productie, logistiek en IT/process services.
current_observation: Geen passende reguliere Olen-vacature aangetroffen; spontane sollicitatie beschikbaar.
review:
decision: pause
reason: Waaklijst; eerst stabiele publieke lijst en parserbewijs vereist.
evidence_link: https://www.aurubis.com/olen/carriere/jobs
expires_at: '2026-10-20T23:59:59+02:00'
notes: Spontane sollicitatie wordt niet automatisch benaderd.
metadata: {reviewed_at: '2026-07-22'}
- name: Estée Lauder Oevel
url: https://careers.elcompanies.com/careers
type: employer
status: candidate
policy: review
policy_reason: Regionale productie- en distributiesite; lokale Oevel-filter nog niet betrouwbaar server-side.
parser: auto
metadata:
employer_name: Estée Lauder Companies
public_job_page: https://careers.elcompanies.com/careers
municipality_scope: Oevel-Westerlo
watchlist: true
watchlist_reason: Potentieel interessant voor supply chain, engineering, automatisatie en bedrijfs-IT.
current_observation: Geen actuele Oevel-vacature bevestigd; globale carrièrepagina blijft op de waaklijst.
review:
decision: pause
reason: Waaklijst; eerst stabiele publieke lijst en parserbewijs vereist.
evidence_link: https://careers.elcompanies.com/careers
expires_at: '2026-10-20T23:59:59+02:00'
notes: Alleen activeren met eenduidige Oevel-locatiefilter.
metadata: {reviewed_at: '2026-07-22'}
- name: Soudal Turnhout
url: https://jobs.soudalgroup.com/front/en
type: employer
status: candidate
policy: review
policy_reason: Regionale hoofdzetel met eigen HR-site; lokale lijstvorm vraagt nog een dedicated parser.
parser: auto
metadata:
employer_name: Soudal
public_job_page: https://jobs.soudalgroup.com/front/en
municipality_scope: Turnhout
watchlist: true
watchlist_reason: Internationale industriële werkgever met productie, techniek, finance en ondersteunende rollen.
current_observation: Actuele Turnhout-vacatures bevestigd; ATS-route nog niet geactiveerd.
review:
decision: pause
reason: Waaklijst; eerst stabiele publieke lijst en parserbewijs vereist.
evidence_link: https://jobs.soudalgroup.com/front/en
expires_at: '2026-10-20T23:59:59+02:00'
notes: Alleen publieke lijst; geen applyroute.
metadata: {reviewed_at: '2026-07-22'}
- name: H.Essers Tessenderlo
url: https://jobs.essers.com/nl-be/
type: employer
status: candidate
policy: review
policy_reason: Regionale logistieke werkgever; gemengde vestigingslijst vereist lokale filtering.
parser: auto
metadata:
employer_name: H.Essers
public_job_page: https://jobs.essers.com/nl-be/
municipality_scope: Tessenderlo
watchlist: true
watchlist_reason: Logistiek, sales, procesverbetering en bedrijfs-IT binnen de ruime regio.
current_observation: Actuele Tessenderlo-vacature bevestigd; nog geen fail-closed vestigingsparser.
review:
decision: pause
reason: Waaklijst; eerst stabiele publieke lijst en parserbewijs vereist.
evidence_link: https://jobs.essers.com/nl-be/
expires_at: '2026-10-20T23:59:59+02:00'
notes: Alleen activeren met eenduidige Tessenderlo-filter.
metadata: {reviewed_at: '2026-07-22'}
- name: Cegeka Hasselt
url: https://www.cegeka.com/nl-be/jobs/vacatures
type: employer
status: candidate
policy: review
policy_reason: Belangrijke regionale IT-werkgever; kaartlocaties zijn niet op elke serverresponse eenduidig.
parser: auto
metadata:
employer_name: Cegeka
public_job_page: https://www.cegeka.com/nl-be/jobs/vacatures
municipality_scope: Hasselt-Corda Campus
watchlist: true
watchlist_reason: Zeer relevante IT-werkgever met cloud, infrastructuur, security, data en softwarefuncties.
current_observation: Actuele Hasselt/flexibele IT-vacatures en officiële jobalert bevestigd.
review:
decision: pause
reason: Waaklijst; eerst stabiele publieke lijst en parserbewijs vereist.
evidence_link: https://www.cegeka.com/nl-be/jobs/vacatures
expires_at: '2026-10-20T23:59:59+02:00'
notes: Officiële jobalert blijft voorlopig de veilige route.
metadata: {reviewed_at: '2026-07-22'}
- name: IOK en IOK Afvalbeheer
url: https://www.iok.be/werken-bij-iok-en-iok-afvalbeheer
type: employer
status: candidate
policy: review
policy_reason: Regionale publieke werkgever; actuele vacaturedoelen worden via een gedeelde ingang aangeboden.
parser: auto
metadata:
employer_name: IOK en IOK Afvalbeheer
public_job_page: https://www.iok.be/werken-bij-iok-en-iok-afvalbeheer
municipality_scope: Geel-Kempen
watchlist: true
watchlist_reason: Meer dan vijfhonderd medewerkers in milieu, afval, ruimtelijke ontwikkeling, administratie en techniek.
current_observation: Werkgeverspagina bevestigt regelmatige werving; automatische bronroute nog te isoleren.
review:
decision: pause
reason: Waaklijst; eerst stabiele publieke lijst en parserbewijs vereist.
evidence_link: https://www.iok.be/werken-bij-iok-en-iok-afvalbeheer
expires_at: '2026-10-20T23:59:59+02:00'
notes: Gedeelde vacaturedoelen eerst isoleren.
metadata: {reviewed_at: '2026-07-22'}
- name: Jessa Ziekenhuis
url: https://werkenbijjessa.be/content/Vacatures/?locale=nl_NL
type: employer
status: candidate
policy: review
policy_reason: Grote werkgever binnen de 40-km-zone; gedeelde ATS-lijst vraagt nog lokale en werkgeversafscherming.
parser: auto
metadata:
employer_name: Jessa Ziekenhuis
public_job_page: https://werkenbijjessa.be/content/Vacatures/?locale=nl_NL
municipality_scope: Hasselt-Herk-de-Stad
watchlist: true
watchlist_reason: Grote zorgwerkgever met expliciete ICT-, administratie-, techniek- en staffuncties.
current_observation: Actuele vacatures bevestigd; kandidaatprofiel en applyroutes blijven buiten scope.
review:
decision: pause
reason: Waaklijst; eerst stabiele publieke lijst en parserbewijs vereist.
evidence_link: https://werkenbijjessa.be/content/Vacatures/?locale=nl_NL
expires_at: '2026-10-20T23:59:59+02:00'
notes: Geen profiel-, login- of applyroute benaderen.
metadata: {reviewed_at: '2026-07-22'}
+3
View File
@@ -14,6 +14,9 @@ deny_domains:
- stepstone.be
- jobat.be
- vdab.be
- ictjob.be
- eurobrussels.com
- brusselsjobs.com
review_on:
- login_required
- captcha_or_challenge
+33 -12
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
import base64
import hashlib
import os
from pathlib import Path
from urllib.parse import urlparse
@@ -17,6 +19,16 @@ def env_list(name: str, default: str = "") -> list[str]:
return [item.strip() for item in os.getenv(name, default).split(",") if item.strip()]
def env_int_range(name: str, default: int, *, minimum: int, maximum: int) -> int:
try:
value = int(os.getenv(name, str(default)))
except ValueError as exc:
raise ImproperlyConfigured(f"{name} moet een geheel getal zijn.") from exc
if not minimum <= value <= maximum:
raise ImproperlyConfigured(f"{name} moet tussen {minimum} en {maximum} liggen.")
return value
def _validate_production_security(
*,
debug: bool,
@@ -218,10 +230,28 @@ CELERY_WORKER_PREFETCH_MULTIPLIER = 1
CELERY_TASK_TIME_LIMIT = 300
CELERY_TASK_SOFT_TIME_LIMIT = 270
CELERY_TIMEZONE = TIME_ZONE
IMAP_MAX_MESSAGES_PER_POLL = env_int_range(
"IMAP_MAX_MESSAGES_PER_POLL", 200, minimum=1, maximum=1000
)
IMAP_MAX_MESSAGE_BYTES = env_int_range(
"IMAP_MAX_MESSAGE_BYTES", 1000000, minimum=1024, maximum=10000000
)
IMAP_CONNECT_TIMEOUT_SECONDS = env_int_range(
"IMAP_CONNECT_TIMEOUT_SECONDS", 15, minimum=3, maximum=60
)
MAILBOX_CREDENTIAL_KEYS = env_list("MAILBOX_CREDENTIAL_KEYS")
if DEBUG and not MAILBOX_CREDENTIAL_KEYS:
MAILBOX_CREDENTIAL_KEYS = [
base64.urlsafe_b64encode(
hashlib.sha256(f"vacatureradar-mailbox:{SECRET_KEY}".encode()).digest()
).decode("ascii")
]
CELERY_BEAT_SCHEDULE = {
"poll-imap": {
"task": "apps.sources.tasks.poll_imap_inbox",
"schedule": 600.0,
"schedule-mailbox-polls": {
"task": "apps.sources.tasks.schedule_mailbox_polls",
"schedule": 300.0,
},
"schedule-due-sources": {
"task": "apps.sources.tasks.schedule_due_sources",
@@ -276,15 +306,6 @@ DATA_UPLOAD_MAX_MEMORY_SIZE = int(os.getenv("DATA_UPLOAD_MAX_MEMORY_SIZE", str(8
FILE_UPLOAD_MAX_MEMORY_SIZE = 0
DATA_UPLOAD_MAX_NUMBER_FILES = 0
IMAP_ENABLED = env_bool("IMAP_ENABLED", False)
IMAP_HOST = os.getenv("IMAP_HOST", "")
IMAP_PORT = int(os.getenv("IMAP_PORT", "993"))
IMAP_USER = os.getenv("IMAP_USER", "")
IMAP_PASSWORD = os.getenv("IMAP_PASSWORD", "")
IMAP_MAILBOX = os.getenv("IMAP_MAILBOX", "INBOX")
IMAP_USE_SSL = env_bool("IMAP_USE_SSL", True)
IMAP_MARK_SEEN = env_bool("IMAP_MARK_SEEN", False)
OLLAMA_ENABLED = env_bool("OLLAMA_ENABLED", False)
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "")
+357 -12
View File
@@ -1,6 +1,6 @@
schema_version: 1
project: VacatureRadar
updated_at: '2026-07-21'
updated_at: '2026-07-22'
execution_policy:
order: file-order, then first ready task whose dependencies are done
no_product_questions: true
@@ -961,7 +961,8 @@ tasks:
- Dashboard, vacatures, detail, pipeline, profiel, bronnen, systeem en login gebruiken echte backenddata en acties.
- Externe auto-apply, nepmetrics, onbewezen werkgeverinzichten en onbetrouwbare activiteitenfeeds zijn niet overgenomen.
- Zes doelviewports hebben één h1, geen horizontale overflow en werkende mobiele navigatie.
- Stitch-bronnen, integratieplan, screenshots en verificatierapport zijn traceerbaar maar niet onderdeel van de productiecontainer.
- Stitch-bronnen, integratieplan, screenshots en verificatierapport zijn traceerbaar maar niet onderdeel van de
productiecontainer.
verification:
- uv run pytest tests/e2e tests/integration/test_views.py tests/integration/test_tasks_and_more_views.py
- ./scripts/codex_verify.sh
@@ -977,9 +978,341 @@ tasks:
completed_at: '2026-07-21'
note: Stitch-visuele taal geïntegreerd in CSP-veilige Django-templates; dark/light, responsive shell, pipeline,
broninteracties en browservalidatie toegevoegd zonder functionele scope-uitbreiding of fictieve data.
- id: VR-119
title: Stitch-schermcompositie dominant doorvoeren
status: done
priority: P0
requirement_ids:
- PR-050
- PR-051
- NFR-008
depends_on:
- VR-118
summary: Herwerk de cockpit na productfeedback zodat de aangeleverde Stitch-frontend niet alleen de visuele tokens
maar ook de schermcompositie, informatiedichtheid en interactiehiërarchie bepaalt, met behoud van echte Django-data,
toegankelijke server-rendered flows en alle bestaande veiligheidsgrenzen.
acceptance_criteria:
- De gedeelde shell, utilitybar en navigatie volgen aantoonbaar de Stitch-compositie op desktop en mobiel.
- Dashboard, vacatureoverzicht en detail nemen de primaire Stitch-layouts vrijwel volledig over met uitsluitend
aantoonbare databasevelden en bestaande veilige acties.
- Pipeline, profiel, bronnen, systeem en login gebruiken dezelfde dichte cockpit-hiërarchie en componentvormen.
- Lege, fout- en een-itemtoestanden behouden de bedoelde compositie zonder grote onverklaarde lege vlakken.
- Zes doelviewports hebben één h1, geen pagina-brede horizontale overflow en werkende toetsenbordnavigatie.
- Geen auto-apply, externe assets, fictieve metrics of onbewezen intelligence wordt toegevoegd.
verification:
- uv run pytest tests/e2e tests/integration/test_views.py tests/integration/test_tasks_and_more_views.py
- ./scripts/codex_verify.sh
- Browsercontrole op 1440x900, 1280x800, 1024x768, 768x1024, 390x844 en 360x800
primary_paths:
- apps/core/context_processors.py
- apps/core/views.py
- apps/jobs/views.py
- templates/
- static/css/app.css
- static/js/app.js
- docs/design/STITCH_INTEGRATION_PLAN.md
- docs/design/validation/
result:
completed_at: '2026-07-21'
note: Stitch-schermcompositie dominant doorgevoerd in shell, dashboard, vacaturefeed/detail, pipeline, profieleditor,
bronnen en systeem; geverifieerd met 146 tests, 82,02% coverage en browsermatrix op zes viewports, dark/light
en mobiele navigatie.
- id: VR-120
title: Begeleid zoekprofiel en functionele livebronnen opleveren
status: done
priority: P0
requirement_ids:
- PR-001
- PR-002
- PR-005
- PR-011
- PR-017
- PR-018
- PR-022
depends_on:
- VR-119
summary: Verwerk de functionele productfeedback door thuislocatie tot postcode te vereenvoudigen, technische JSON-invoer
door selectielijsten en checkboxes te vervangen en minimaal twee actuele publieke werkgeversfeeds end-to-end
te valideren.
acceptance_criteria:
- Het profiel vraagt alleen een viercijferige thuispostcode en leidt gemeente/coördinaten af wanneer lokale geodata
eenduidig beschikbaar is.
- Functietitels, skills, contractvormen, werkmodellen en regio's zijn begeleide keuzevelden die bestaande waarden
veilig behouden.
- Minimaal twee echte werkgeverfeeds hebben een actuele policyreview, parserfixtures en twee succesvolle livecycli.
- Demobronnen zijn niet zichtbaar in het normale bronoverzicht en actuele vacatures stromen door naar ranking.
- Profiel-, adapter-, pipeline-, view- en browsertests plus de volledige verificatiegate slagen.
verification:
- uv run pytest tests/integration/test_profile_preferences.py tests/integration/test_seed_sources.py tests/integration/test_pipeline.py
tests/unit/test_ats_adapters.py
- Volg docs/operations/SOURCE_ONBOARDING.md en controleer twee livecycli per geactiveerde bron
- ./scripts/codex_verify.sh
primary_paths:
- apps/profiles/
- templates/profiles/
- apps/sources/
- config-data/seed_sources.yaml
- docs/operations/SOURCE_ONBOARDING.md
result:
completed_at: '2026-07-21'
note: Postcode-only profiel, begeleide checkboxkeuzes, gereviewde VITO- en Deliverect-feeds met twee livecycli,
employeridentiteit, broninventaris en robotsredirecthardening; 155 tests geslaagd, 2 optioneel overgeslagen,
82,94% coverage en volledige codex_verify-gate groen.
- id: VR-121
title: Regionale werkgevers- en platformalertdekking uitbreiden
status: done
priority: P0
requirement_ids:
- PR-011
- PR-012
- PR-017
- PR-018
- PR-032
depends_on:
- VR-120
summary: Breid de livebroncatalogus uit met publieke werkgeversfeeds in Limburg en aangrenzende Belgische werkregio's,
maak gedeelde ATS-hosts first-class en documenteer de veilige alertingress voor VDAB en Indeed.
acceptance_criteria:
- Minimaal acht bijkomende publieke bronnen hebben een actuele policyreview, parserdekking en twee succesvolle
livecycli voordat ze actief worden.
- De catalogus bevat aantoonbaar regionale dekking voor Hasselt/Genk en aanvullende IT-werkgevers in Brussel,
Gent of Leuven.
- Meerdere werkgevers op één publieke ATS-host kunnen naast elkaar bestaan zonder bronrecords te overschrijven.
- Corda Campus-vacaturekaarten worden deterministisch uitgelezen en off-domain links worden verworpen.
- Indeed en VDAB worden uitsluitend via officiële vacaturealerts/mailimport of handmatige import gebruikt en blijven
op de directe-fetchdenylist.
verification:
- uv run pytest tests/unit/test_regional_adapters.py tests/unit/test_ats_adapters.py tests/unit/test_source_models.py
tests/integration/test_seed_sources.py
- Voer twee livecycli uit voor iedere nieuwe bron en controleer source health, parserwarnings en inventaristelling
- ./scripts/codex_verify.sh
primary_paths:
- apps/sources/
- config-data/seed_sources.yaml
- fixtures/pages/corda-campus-jobs.html
- fixtures/ats/smartrecruiters-public-postings.json
- docs/operations/LIVE_SOURCE_CATALOG.md
result:
completed_at: '2026-07-21'
note: Acht extra gereviewde livebronnen, gedeelde ATS-hostidentiteit, Corda-adapter en veilige VDAB/Indeed-alertingress
toegevoegd; twee livecycli per bron, 159 tests, 82,91% coverage en in-app-browsercontrole geslaagd.
- id: VR-122
title: Dagelijkse officiële VDAB-ingang operationaliseren
status: done
priority: P0
requirement_ids:
- PR-012
- PR-016
- PR-018
- PR-064
depends_on:
- VR-121
summary: Verwerk officiële VDAB-jobalerts eenmaal per dag via de bestaande dedicated mailbox, met herkenbare provenance,
begrensde verwerking en een duidelijke route naar de contractueel toegankelijke VDAB Vacature API.
acceptance_criteria:
- De IMAP-ingang draait standaard eenmaal per dag op een configureerbaar tijdstip en blijft idempotent bij replay.
- VDAB-alertmails worden als VDAB-provenance herkend en vacatures stromen zonder automatische linkfetch door de
bestaande normalisatie-, dedupe- en rankingpipeline.
- De mailboxpoll is begrensd op aantal en berichtgrootte; oversized of onleesbare berichten stoppen de batch niet.
- De bronpagina toont de dagelijkse planning, configuratiestatus, laatste VDAB-import en het onderscheid tussen
jobalertingang en de officiële API waarvoor VDAB-goedkeuring vereist is.
- Directe VDAB-scraping, platformlogin en niet-goedgekeurde API-toegang blijven geblokkeerd en dit is getest en
operationeel gedocumenteerd.
verification:
- uv run pytest tests/unit/test_email_adapter.py tests/integration/test_email_import_notifications.py tests/integration/test_tasks_and_more_views.py
- ./scripts/codex_verify.sh
primary_paths:
- apps/sources/adapters/email_alert.py
- apps/sources/services/email_import.py
- apps/sources/services/imap_import.py
- apps/sources/tasks.py
- config/settings.py
- templates/sources/list.html
- docs/architecture/adrs/0005-email-alerts-as-platform-ingress.md
result:
completed_at: '2026-07-21'
note: Dagelijkse configureerbare VDAB-jobalertimport met expliciete provenance, begrensde foutgeisoleerde IMAP-poll,
beveiligde handmatige inplanning en heldere officiele API-route; 164 tests, 2 optioneel overgeslagen, 83,55%
coverage en desktop/390px Codex-browsercontrole groen.
- id: VR-123
title: Platformmailboxen veilig in de UI koppelen en frequent synchroniseren
status: done
priority: P0
requirement_ids:
- PR-012
- PR-016
- PR-018
- PR-064
depends_on:
- VR-122
summary: Vervang de globale dagelijkse IMAP-configuratie door gebruikersgescopeerde mailboxkoppelingen per vacatureplatform,
met versleutelde app-wachtwoorden en een eigen frequent synchronisatie-interval.
acceptance_criteria:
- Een ingelogde gebruiker kan voor VDAB en Indeed afzonderlijk een Gmail-, Outlook- of aangepaste IMAP-mailbox
koppelen, wijzigen, pauzeren en direct laten synchroniseren.
- App-wachtwoorden worden met een afzonderlijke roteerbare sleutel versleuteld opgeslagen, nooit teruggetoond
en nooit in logs of taakresultaten opgenomen.
- Iedere mailbox heeft een interval van 5 tot 360 minuten; de scheduler plant alleen verschuldigde actieve mailboxen
en is concurrency- en replayveilig.
- Mailimport bewaart mailbox- en platformprovenance, is per mailbox idempotent en laat een fout in één mailbox
de andere mailboxen niet blokkeren.
- Alle mutaties zijn login-, user-scope- en CSRF-beveiligd; negatieve tests dekken cross-user toegang, ontbrekende
encryptiesleutel, ongeldige host/poort en secretlekken.
- Bronpagina, runbook, threat model, configuratie en migratiedocumentatie zijn bijgewerkt; directe platformfetch
en platformlogin blijven uitgesloten.
verification:
- uv run pytest tests/unit/test_mailbox_credentials.py tests/integration/test_mailbox_connections.py tests/integration/test_email_import_notifications.py
- uv run python manage.py makemigrations --check --dry-run
- ./scripts/codex_verify.sh
primary_paths:
- apps/sources/models.py
- apps/sources/forms.py
- apps/sources/services/mailbox_connections.py
- apps/sources/services/imap_import.py
- apps/sources/tasks.py
- apps/sources/views.py
- templates/sources/list.html
- config/settings.py
result:
completed_at: '2026-07-22'
note: 'Per-platform VDAB/Indeed-mailboxen met versleutelde app-wachtwoorden, intervallen van 5-360 minuten,
leases, user-scoped UI, securitytests en documentatie; volledige gate: 178 passed, 2 skipped, 83.56%.'
- id: VR-124
title: LinkedIn en Belgische jobboards via veilige jobalertmail toevoegen
status: done
priority: P0
requirement_ids:
- PR-012
- PR-016
- PR-021
- NFR-001
- NFR-003
depends_on:
- VR-123
summary: Breid de platformmailboxingang uit met LinkedIn, ictjob.be, Jobat, StepStone, EuroBrussels en BrusselsJobs
en beperk iedere parserrun tot vacaturelinks van het expliciet gekozen jobboard.
acceptance_criteria:
- De mailbox-UI biedt naast VDAB en Indeed ook LinkedIn, ictjob.be, Jobat, StepStone, EuroBrussels en BrusselsJobs
met een actuele officiele jobalertlink en dezelfde afzonderlijke mailbox-, interval-, pauze- en syncflow.
- Gesaneerde fixtures en adaptertests bewijzen per nieuw platform titel-, link- en provenance-extractie zonder
live netwerkcall.
- Een platformgebonden mail importeert alleen HTTPS-vacaturelinks op het verwachte jobboarddomein; externe, tracking-,
account-, privacy- en uitschrijflinks worden genegeerd.
- Mailboxrecords, bronprovenance, idempotentie, user scoping, encryptie, leases en foutisolatie blijven voor alle
platformkeuzes behouden.
- Bronbeleid, catalogus, threat model, onboarding, traceability en UI-copy vermelden dat directe platformlogin
en jobboardscraping uitgesloten blijven.
verification:
- uv run pytest tests/unit/test_email_adapter.py tests/integration/test_email_import_notifications.py tests/integration/test_mailbox_connections.py
- uv run python manage.py makemigrations --check --dry-run
- ./scripts/codex_verify.sh
primary_paths:
- apps/sources/models.py
- apps/sources/adapters/email_alert.py
- apps/sources/services/email_import.py
- templates/sources/list.html
- fixtures/emails/
- docs/operations/LIVE_SOURCE_CATALOG.md
result:
completed_at: '2026-07-22'
note: LinkedIn, ictjob.be, Jobat, StepStone, EuroBrussels en BrusselsJobs toegevoegd aan platformmailboxen;
domeingebonden HTTPS-parser, zes fixtures, denylist, UI en docs; 192 passed, 2 skipped, 83.61%, desktop/390px
browser groen.
- id: VR-125
title: Brusselse dekking vervangen door regionale werkgevers en Belgische jobplatformen
status: done
priority: P0
requirement_ids:
- PR-011
- PR-012
- PR-017
- PR-018
- PR-021
- NFR-001
- NFR-003
depends_on:
- VR-124
summary: Verwijder de expliciete Brusselse jobboards en werkgevers uit de actieve radar, voeg brede Belgische
jobalertplatformen toe en activeer veilige publieke vacaturelijsten van werkgevers rond Hasselt-Genk.
acceptance_criteria:
- EuroBrussels en BrusselsJobs zijn niet meer configureerbaar of actief; bestaande koppelingen worden veilig uitgeschakeld
en verborgen zonder historische provenance te verwijderen.
- Careerjet, Randstad en Robert Half zijn beschikbaar via hun officiele e-mailalerts en blijven op de directe-fetchdenylist.
- Werkgeversbronnen buiten de regionale scope worden gedeactiveerd en verborgen; bestaande vacaturehistoriek blijft
bewaard.
- Minstens vier lokale werkgeverspagina's uit de kern Hasselt-Genk worden met host- en padgebonden adapters, gesaneerde
fixtures, actuele bronreview en twee succesvolle livecycli toegevoegd.
- Tot een thuispostcode is ingevuld, vermeldt de catalogus expliciet dat Hasselt-Genk als omkeerbaar centrum voor
de 40-km-selectie is gebruikt; ranking blijft de exacte profielafstand toepassen zodra die bekend is.
- Bronbeleid, threat model, onboarding, traceability en gebruikerscopy blijven duidelijk over e-mailingress, robots,
SSRF, off-domain afwijzing en het verbod op platformlogin en scraping.
verification:
- .venv/Scripts/python.exe -m pytest tests/unit/test_regional_adapters.py tests/unit/test_email_adapter.py tests/integration/test_seed_sources.py
tests/integration/test_email_import_notifications.py
- .venv/Scripts/python.exe manage.py makemigrations --check --dry-run
- ./scripts/codex_verify.sh
primary_paths:
- apps/sources/adapters/regional.py
- apps/sources/platforms.py
- apps/sources/management/commands/seed_sources.py
- config-data/seed_sources.yaml
- fixtures/pages/
- fixtures/emails/
- docs/operations/LIVE_SOURCE_CATALOG.md
result:
completed_at: '2026-07-22'
note: Brusselse mailboxen en zeven buitenregionale bronnen veilig geretired; Careerjet, Randstad en Robert Half
toegevoegd; ACA, Xplore, UHasselt, PXL en ZOL na host/padreview en twee livecycli actief; 200 passed, 2 skipped,
83.90%, browser desktop/390px groen.
- id: VR-126
title: Werkgeversradar herankeren op postcode 2400 Mol
status: done
priority: P0
requirement_ids:
- PR-002
- PR-011
- PR-017
- PR-018
- PR-021
- NFR-001
- NFR-003
depends_on:
- VR-125
summary: Vervang de tijdelijke Hasselt-Genk-aanname door postcode 2400 Mol en voeg veilige publieke vacaturelijsten
toe van relevante werkgevers binnen ongeveer 40 km, met nadruk op infrastructuur-, systeem- en netwerkfuncties.
acceptance_criteria:
- De actieve profielruntime bewaart postcode 2400 en een maximale afstand van 40 km; ontbrekende gelicenseerde
coördinatendata blijft expliciet zichtbaar als extern restpunt en wordt niet stil ingevuld.
- De broncatalogus en UI noemen Mol als centrum en maken onderscheid tussen geografisch gereviewde bronnen en
exacte vacatureafstand die pas na geocoding kan worden berekend.
- Minstens vijf werkgevers uit Mol, Geel, Westerlo of Turnhout met regelmatig gepubliceerde eigen vacatures worden
host- en padgebonden toegevoegd, inclusief relevante IT-/infrastructuurwerkgevers.
- Iedere nieuwe bron heeft een gesaneerde fixture, positieve adaptertest, off-domain/route-negatie en minstens
twee geslaagde livecycli zonder parserwarnings voor activering.
- Bestaande regionale bronnen blijven alleen actief wanneer hun primaire vestiging of vacaturelocatie indicatief
binnen 40 km van Mol valt; historische vacatures en provenance worden bij retirement behouden.
- Bronbeleid, threat model, livecatalogus, onboarding, traceability en gebruikerscopy documenteren robots-, SSRF-,
sanitization- en locatiegrenzen; login, sollicitatie-endpoints en platformscraping blijven uitgesloten.
verification:
- .venv/Scripts/python.exe -m pytest tests/unit/test_regional_adapters.py tests/integration/test_seed_sources.py
tests/integration/test_profile_preferences.py tests/security/test_policy.py
- .venv/Scripts/python.exe manage.py makemigrations --check --dry-run
- ./scripts/codex_verify.sh
primary_paths:
- apps/sources/adapters/regional.py
- apps/sources/management/commands/seed_sources.py
- config-data/seed_sources.yaml
- fixtures/pages/
- templates/sources/list.html
- docs/operations/LIVE_SOURCE_CATALOG.md
result:
completed_at: '2026-07-22'
note: Profielruntime op postcode 2400 en 40 km gezet; SCK CEN, Cipal Schaubroeck, VanRoey, NTX en Thomas More
met fail-closed Mol-regioadapter, fixtures, docs en twee livecycli geactiveerd; 206 passed, 2 skipped, 84.12%,
browser desktop/360px groen.
- id: VR-201
title: Concrete live-bronnen inventariseren en juridisch/operationeel reviewen
status: blocked-external
status: done
priority: P0
requirement_ids:
- PR-011
@@ -1001,8 +1334,11 @@ tasks:
- config-data/seed_sources.local.yaml
- Source admin
- docs/operations/SOURCE_ONBOARDING.md
external_blocker: Vereist persoonlijke werkgeverskeuzes en een actuele menselijke bronvoorwaardenreview. Commit
lokale bronlijst niet wanneer die persoonlijke informatie bevat.
result:
completed_at: '2026-07-22'
note: Gemeente Mol en zeven Kempen-werkgevers na actuele menselijke review, fail-closed fixtures/tests en twee
livecycli geactiveerd; twaalf interessante regionale werkgevers als niet-scanbare waaklijst toegevoegd; TLS
gebruikt strikt de gepinde certifi-bundel zonder bypass.
- id: VR-202
title: Live IMAP- en SMTP-credentials configureren
status: blocked-external
@@ -1015,18 +1351,23 @@ tasks:
depends_on:
- VR-111
- VR-115
summary: Configureer een dedicated vacaturemailbox, app-password en digestrecipient; test read-only import en
mailverzending zonder credentials in Git/logs.
- VR-123
- VR-124
summary: Configureer de gewenste afzonderlijke platformmailboxen via de beveiligde UI, de Fernet-secretkey en
digestrecipient; test read-only import en mailverzending zonder credentials in Git/logs.
acceptance_criteria:
- IMAP gebruikt TLS, dedicated mailbox en mark-seen standaard uit tijdens acceptatie.
- Dezelfde live testmail importeert idempotent.
- Iedere platformmailbox gebruikt TLS, een dedicated account/app-wachtwoord en read-only mailboxselectie.
- Dezelfde live testmail importeert per mailbox idempotent en alle geconfigureerde intervallen worden door de
scheduler gerespecteerd.
- SMTP verstuurt één testdigest en outboxstatus klopt.
- Credentials zijn geroteerbaar en alleen in productie-secretstore/.env aanwezig.
- De encryptiesleutel staat alleen in de productie-secretstore/.env; app-wachtwoorden zijn als ciphertext in de
database aanwezig en sleutelrotatie is live getest.
verification:
- Volg Unraid mailboxchecklist
- Inspecteer logs op afwezigheid van secret/payload
primary_paths:
- /mnt/user/appdata/vacatureradar/config/.env
- Bronnen -> Platformmailboxen
external_blocker: Vereist mailbox-/SMTP-account en app-password van de gebruiker.
- id: VR-203
title: Productiedomein, TLS en reverse proxy activeren
@@ -1078,7 +1419,7 @@ tasks:
external_blocker: Vereist registrykeuze, account en credentials.
- id: VR-205
title: Gelicenseerde Belgische geodatabron laden
status: blocked-external
status: done
priority: P1
requirement_ids:
- PR-002
@@ -1098,7 +1439,11 @@ tasks:
primary_paths:
- lokale geodatafile
- config-data/geodata/README.local.md
external_blocker: Vereist een actuele datasetkeuze/licentiecheck; bulkdata hoort niet automatisch in de project-ZIP.
result:
completed_at: '2026-07-22'
note: GeoNames België postcodedataset 2026-04-03 onder CC BY 4.0 gereviewd, checksums vastgelegd, 2781 unieke
rijen gevalideerd en 5562 lookuprecords geïmporteerd; 2400 Mol opgelost naar 51.1919, 5.1166 en actieve profielscores
herberekend.
- id: VR-301
title: Multi-tenant SaaS, teams en billing
status: deferred
+157 -5
View File
@@ -1,7 +1,7 @@
# Projectstatus
- Laatst bijgewerkt: 2026-07-21
- Repositoryversie: 0.2.1 Stitch Intelligence Cockpit
- Laatst bijgewerkt: 2026-07-22
- Repositoryversie: 0.2.10 Kempen-werkgeversradar en GeoNames
- Uitvoeringsmodus: autonome backlog
- Eerste uitvoerbare taak: controleer met `python scripts/backlog.py next`
@@ -41,6 +41,93 @@ VR-118-herverificatie op 2026-07-21:
- taakledger geldig met 36 taken: 28 done, 5 blocked-external, 3 deferred en 0 ready;
- geïntegreerde browservalidatie op zes viewports en beide thema's geslaagd.
VR-119-herverificatie op 2026-07-21:
- 146 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; uitgebreide HTML/a11y-fallback geslaagd;
- 82,02% branch-aware codedekking;
- Ruff, Django system check, migratiecontrole, taakledger en repositoryvalidatie geslaagd;
- geïntegreerde browsercontrole op alle kernroutes, zes doelviewports, dark/light en mobiele drawer geslaagd zonder pagina-overflow of consolefouten;
- taakledger geldig met 37 taken: 29 done, 5 blocked-external, 3 deferred en 0 ready.
VR-120-herverificatie op 2026-07-21:
- 155 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback en in-app-browsercontrole geslaagd;
- 82,94% branch-aware codedekking;
- Ruff, Django system check, migratiecontrole, taakledger en repositoryvalidatie geslaagd via de volledige `scripts/codex_verify.sh`-gate;
- VITO leverde 37 vacatures in twee geslaagde cycli; Deliverect leverde 26 vacatures en verwerkte de tweede cyclus correct als HTTP 304;
- taakledger geldig met 38 taken: 30 done, 5 blocked-external, 3 deferred en 0 ready.
VR-121-herverificatie op 2026-07-22:
- 159 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd;
- 82,91% branch-aware codedekking;
- Ruff, Django system check, migratiecontrole en repositoryvalidatie geslaagd via `scripts/codex_verify.sh`;
- acht nieuwe bronnen passeerden elk twee livecycli zonder parserwarnings; de lokale inventaris telt 10 actieve bronnen en 145 actieve vacatures;
- in-app-browsercontrole bevestigde 10 bronkaarten, de VDAB/Indeed-alertingress, geen horizontale overflow en geen consolefouten;
- taakledger bevat 39 taken; na afronding van VR-121 blijven alleen 5 `blocked-external` en 3 `deferred` taken over.
VR-122-herverificatie op 2026-07-22:
- 164 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd;
- 83,55% branch-aware codedekking;
- Ruff, Django system check, migratiecontrole, taakledger en repositoryvalidatie geslaagd via `scripts/codex_verify.sh`;
- de dagelijkse mailboxingang is configureerbaar, idempotent en begrensd; VDAB-mails krijgen expliciete provenance zonder platformlinks automatisch te volgen;
- Codex-browsercontrole bevestigde de VDAB-/API-uitleg, desktop en 390 px zonder horizontale overflow en zonder consolefouten;
- taakledger bevat 40 taken; na afronding van VR-122 blijven alleen 5 `blocked-external` en 3 `deferred` taken over.
VR-123-herverificatie op 2026-07-22:
- 178 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd;
- 83,56% branch-aware codedekking;
- Ruff, Django system check, migratiecontrole, taakledger en repositoryvalidatie geslaagd via `scripts/codex_verify.sh`;
- VDAB en Indeed hebben afzonderlijke gebruikersgescopeerde mailboxkoppelingen met eigen provider, account, app-wachtwoord en interval van 5 tot 360 minuten;
- app-wachtwoorden zijn versleuteld met roteerbare MultiFernet-sleutels; schedulerleases, per-mailbox-idempotentie, SSRF-validatie en foutisolatie zijn regressiegedekt;
- Codex-browsercontrole bevestigde de platformmailboxformulieren, desktop en 390 px zonder horizontale overflow en zonder consolefouten;
- taakledger bevat 41 taken; na afronding van VR-123 blijven alleen 5 `blocked-external` en 3 `deferred` taken over.
VR-124-herverificatie op 2026-07-22:
- 192 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd;
- 83,61% branch-aware codedekking;
- Ruff, Django system check, migratiecontrole, taakledger en repositoryvalidatie geslaagd via `scripts/codex_verify.sh`;
- platformmailboxen ondersteunen naast VDAB en Indeed ook LinkedIn, ictjob.be, Jobat, StepStone, EuroBrussels en BrusselsJobs met afzonderlijke accounts, intervallen en status;
- zes gesaneerde jobboardfixtures bewijzen platformprovenance en de afwijzing van externe, HTTP-, tracking-, account-, help-, privacy- en uitschrijflinks;
- alle acht jobboardgroepen staan op de directe-fetchdenylist; platformlogin en scraping blijven uitgesloten;
- Codex-browsercontrole bevestigde acht keuzelijstopties en alertkaarten, desktop en 390 px zonder horizontale overflow en zonder consolefouten;
- taakledger bevat 42 taken; na afronding van VR-124 blijven alleen 5 `blocked-external` en 3 `deferred` taken over.
VR-125-herverificatie op 2026-07-22:
- 200 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd;
- 83,90% branch-aware codedekking;
- Ruff, Django system check, migratiecontrole, taakledger en repositoryvalidatie geslaagd via `scripts/codex_verify.sh`;
- EuroBrussels en BrusselsJobs zijn uit de configureerbare mailboxcatalogus verwijderd; Careerjet, Randstad en Robert Half gebruiken dezelfde versleutelde platformmailboxflow;
- Deliverect, YPTO, adesso, Monizze, Qover, SFEIR en ARHS zijn buiten de regionale scope uitgeschakeld en verborgen zonder vacaturehistoriek of provenance te wissen;
- ACA Group, Xplore Group, UHasselt, PXL en ZOL passeerden ieder twee livecycli zonder parserwarnings; de acht zichtbare actieve bronnen bevatten 162 vacature-aliassen;
- Codex-browsercontrole bevestigde exact acht regionale bronkaarten, negen platformkeuzes, geen Brusselse kaarten, desktop en 390 px zonder horizontale overflow en zonder consolefouten;
- taakledger bevat 43 taken; na afronding van VR-125 blijven alleen 5 `blocked-external` en 3 `deferred` taken over.
VR-126-herverificatie op 2026-07-22:
- 206 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd;
- 84,12% branch-aware codedekking;
- Ruff, Django system check, migratiecontrole, taakledger en repositoryvalidatie geslaagd via `scripts/codex_verify.sh`;
- het actieve profiel bewaart postcode 2400 en 40 km; ontbrekende gelicenseerde coördinatendata blijft zichtbaar en wordt niet geschat;
- SCK CEN, Cipal Schaubroeck, VanRoey, NTX en Thomas More passeerden twee livecycli zonder parserwarnings; de dertien actieve bronnen bevatten 210 vacature-aliassen;
- Codex-browsercontrole bevestigde 13 bronkaarten, de Mol/40-km-status, desktop en 360 px zonder horizontale overflow en zonder consolefouten;
- taakledger bevat 44 taken; na afronding van VR-126 blijven alleen 5 `blocked-external` en 3 `deferred` taken over.
VR-201/VR-205-herverificatie op 2026-07-22:
- 215 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd;
- 84,42% branch-aware codedekking;
- Ruff, Django system check, migratiecontrole, taakledger en repositoryvalidatie geslaagd via `scripts/codex_verify.sh`;
- GeoNames België 2026-04-03 is onder CC BY 4.0 met checksums gedocumenteerd, 2.781 unieke invoerrijen zijn gevalideerd en 2400 Mol is opgelost naar het benaderende postcodecentrum 51.1919, 5.1166;
- Gemeente Mol en zeven bijkomende Kempen-werkgevers passeerden elk twee livecycli zonder parserwarnings; samen leverden zij 53 kaarten per cyclus;
- twaalf regionale werkgevers staan afzonderlijk als niet-scanbare waaklijst in de UI, ook wanneer zij momenteel geen vacature hebben;
- Codex-browsercontrole bevestigde 21 actieve bronnen, 12 waaklijstkaarten, de GeoNames-status, één `h1`, geen horizontale overflow op desktop of 390 px en geen consolefouten;
- taakledger bevat 44 taken: 38 `done`, 3 `blocked-external`, 3 `deferred` en 0 `ready`.
## Deploymentstatus
- `main` is via de bestaande Gitea-SSH-sleutel naar `NuklearRabbit/VacatureRadar` gepusht.
@@ -51,6 +138,71 @@ VR-118-herverificatie op 2026-07-21:
## Laatste uitgevoerde backlogtaak
- 2026-07-22: `VR-201` en `VR-205` voltooid.
- De officiële GeoNames-postcodedataset lost postcode 2400 op naar Mol en levert indicatieve postcodecentrumafstanden; licentie, versie, reviewdatum en twee SHA-256-checksums zijn vastgelegd.
- Gemeente Mol, Ziekenhuis Geel, lokaal bestuur Geel, Stad Turnhout, Group Renotec, Ravago, Sanofi Geel en DAF Trucks Westerlo zijn na fixtures, negatieve routechecks en twee livecycli actief.
- Johnson & Johnson Beerse, Nike Laakdal, AZ Turnhout, Cartamundi, Nyrstar, Aurubis, Estée Lauder, Soudal, H.Essers, Cegeka, IOK en Jessa staan als interessante niet-scanbare werkgeverswaaklijst in de applicatie.
- HTTP, robots en IMAP gebruiken een strikte gepinde CA-bundel; de eerdere lokale Windows-CA-fout bij Gemeente Mol en Ziekenhuis Geel was geen geldige reden voor een bypass.
- Volgende taak: geen uitvoerbare `ready` taak; alleen `VR-202`, `VR-203` en `VR-204` blijven `blocked-external` voor productiecredentials/-infrastructuur.
- 2026-07-22: `VR-126` voltooid.
- Het actieve lokale profiel bewaart postcode 2400 en een straal van 40 km; gemeente en coördinaten blijven leeg tot de gelicenseerde geodata uit `VR-205` beschikbaar is.
- De catalogus is herankerd op Mol en uitgebreid met SCK CEN, Cipal Schaubroeck, VanRoey, NTX/Netropolix en Thomas More.
- Exacte host-, pad-, plaats- en CVWarehouse-GUID-filters weigeren off-domain, andere vestigingen en kandidaat-/sollicitatieroutes.
- Gemeente Mol en Ziekenhuis Geel zijn niet geactiveerd wegens falende TLS-certificaatvalidatie; er is geen bypass toegepast.
- Volgende taak: geen uitvoerbare `ready` taak; `VR-205` blijft `blocked-external` voor de gelicenseerde geocodedataset en `VR-202` voor productie-mailboxcredentials.
- 2026-07-22: `VR-125` voltooid.
- De regionale catalogus gebruikt voorlopig Hasselt-Genk als omkeerbaar centrum voor de indicatieve 40-km-selectie omdat het actieve profiel nog geen postcode bevat; de profielafstand wordt exact zodra die postcode is ingevuld.
- Careerjet, Randstad en Robert Half vervangen de expliciete Brusselse nicheboards in de platformmailboxflow; alle brede jobboards blijven op de directe-fetchdenylist.
- ACA Group, Xplore Group, Universiteit Hasselt, Hogeschool PXL en Ziekenhuis Oost-Limburg worden via host- en padgebonden publieke lijstparsers ingelezen; alle vijf slaagden tweemaal live zonder warnings.
- Zeven werkgeversbronnen buiten de regionale scope zijn gedeactiveerd en verborgen, maar hun historische aliassen, documenten en provenance blijven intact.
- Volgende taak: geen uitvoerbare `ready` taak; `VR-201` en `VR-202` blijven `blocked-external` voor persoonlijke postcode-/bronreview en productiecredentials.
- 2026-07-22: `VR-124` voltooid.
- LinkedIn, ictjob.be, Jobat, StepStone, EuroBrussels en BrusselsJobs zijn toegevoegd aan dezelfde versleutelde platformmailboxflow als VDAB en Indeed.
- De bronpagina toont per jobboard een actuele officiële alertingang; ieder platform behoudt een eigen mailbox, interval, status, pauze- en syncactie.
- De mailparser accepteert per koppeling uitsluitend gelabelde HTTPS-links op het gekozen jobboarddomein en verwerpt externe en administratieve links.
- Alle brede jobboards blijven directe-fetchdenylistbronnen; VacatureRadar logt alleen in op het mailboxaccount en nooit op een jobboard.
- Volgende taak: `VR-202` blijft `blocked-external` voor productieconfiguratie en live validatie met de gewenste afzonderlijke alertmailboxen.
- 2026-07-22: `VR-123` voltooid.
- Een ingelogde gebruiker kan VDAB en Indeed elk aan een afzonderlijke Gmail-, Outlook- of aangepaste IMAP-mailbox koppelen, wijzigen, pauzeren en direct synchroniseren.
- Iedere koppeling heeft een eigen interval van 5 minuten tot 6 uur; de scheduler verwerkt alleen verschuldigde mailboxen met leases en foutisolatie.
- App-wachtwoorden worden nooit teruggetoond en zijn versleuteld met afzonderlijke roteerbare sleutels; mailboxmutaties zijn login-, gebruikersscope- en CSRF-beveiligd.
- Mailimport bewaart platform- en mailboxprovenance en is idempotent per mailbox en Message-ID; directe platformfetch en platformlogin blijven uitgesloten.
- Volgende taak: `VR-202` blijft `blocked-external` voor productieconfiguratie en live validatie met de afzonderlijke VDAB- en Indeed-mailboxaccounts.
- 2026-07-22: `VR-122` voltooid.
- Officiële VDAB-jobalerts worden standaard dagelijks om 06:15 Europe/Brussels via de dedicated IMAP-mailbox verwerkt; planning en batchlimieten zijn configureerbaar.
- De mailadapter bewaart VDAB-provenance, replay blijft idempotent en een oversized of fout bericht stopt de rest van de batch niet.
- De bronpagina toont mailboxstatus, laatste VDAB-import, een beveiligde handmatige inplanknop en de contractueel afgeschermde officiële VDAB Vacature API als afzonderlijke route.
- Directe VDAB-scraping, platformlogin en niet-goedgekeurde API-toegang blijven geblokkeerd.
- Volgende taak: `VR-202` blijft `blocked-external` voor het invullen en live valideren van mailbox-/SMTP-credentials.
- 2026-07-22: `VR-121` voltooid.
- Corda Campus, RPM Europe Logistics, YPTO, adesso Belgium, Monizze, Qover, SFEIR Belgium en ARHS Belgium zijn na actuele review en twee succesvolle cycli geactiveerd.
- De catalogus omvat nu regionale Hasselt/Genk-dekking en aanvullende Belgische IT-werkgevers in Brussel en Leuven.
- Bronnen op gedeelde ATS-hosts worden uniek per exacte feed-URL bewaard; per-origin leases en rate limits blijven gezamenlijk gelden.
- De Corda-adapter bewaart werkgever en titel van publieke jobkaarten en weigert off-domain links.
- VDAB en Indeed blijven op de directe-fetchdenylist en worden in de UI via officiële e-mailalerts naar de bestaande beveiligde mailboxingress geleid.
- Volgende taak: `VR-201` blijft `blocked-external` voor verdere persoonlijke bronkeuzes en periodieke menselijke voorwaardenreview.
- 2026-07-21: `VR-120` voltooid.
- Thuislocatie is teruggebracht tot een viercijferige postcode; gemeente en coördinaten worden alleen uit lokale geodata afgeleid.
- Alle profieltaxonomie gebruikt begeleide checkboxgroepen met dynamisch behoud van bestaande waarden.
- VITO en Deliverect zijn na actuele policy-/robotsreview en twee livecycli geactiveerd; samen leverden ze 63 actuele vacaturealiases.
- Recruitee- en Leverresponsevormen, employeridentiteit, direct-employerstatus, 304-inventaristelling en robotsredirectvalidatie zijn functioneel hersteld.
- De fixturebron is uit het normale bronoverzicht verborgen; browserhandoff staat op de vernieuwde profieleditor.
- Volgende taak: `VR-201` blijft `blocked-external` voor verdere persoonlijke werkgevers- en overheidsdekking.
- 2026-07-21: `VR-119` voltooid.
- Stitch bepaalt nu de dominante schermcompositie: utilityzoeker, brede matchfeed, vaste radar-/actierails, dichte resultaatkaarten, intelligence-strip, kanban, profielpreview, compacte bronimport en echte systeemtelemetrie.
- Vacaturelijstscores worden gebruikersgescopeerd uit de laatste `ScoreRun` geannoteerd; er is geen fictieve frontenddata toegevoegd.
- Nederlandstalige profielvelden, lokale favicon en aanvullende HTML/a11y-regressieasserties zijn toegevoegd.
- Browserbewijs en exact verificatierapport staan onder `docs/design/validation/` met prefix `vr119-`.
- Volgende taak: `VR-201` (`blocked-external`).
- 2026-07-21: `VR-118` voltooid.
- Alle kernroutes delen nu één responsive Intelligence Cockpit-shell met lokale SVG-iconen en CSP-veilige assets.
- Stitch-dark en een volwaardige light variant zijn gekoppeld aan echte Django-data; externe quick apply, nepmetrics en onbewezen intelligence zijn bewust geweerd.
@@ -156,13 +308,13 @@ Een toekomstige agent moet altijd de actuele waarheid opnieuw bepalen met:
## Nog te bouwen
Momenteel staan er geen `ready` taken meer over; de volgende te behandelen item is `VR-201` als `blocked-external` (bronreview).
Momenteel staan er geen `ready` taken meer over; het volgende te behandelen item is `VR-202` als `blocked-external` voor live mailbox-/SMTP-credentials en echte jobalertmails van de gekozen platformen.
## Externe blokkades
Deze blokkeren onafhankelijke code niet:
- concrete werkgevers-/bronnenkeuze en actuele menselijke bronreview;
- verdere persoonlijke werkgevers-/bronnenkeuze en actuele menselijke review buiten de actieve VITO-/Deliverect-startercatalogus;
- live IMAP/SMTP-account en app-password;
- Unraid-domein, DNS, TLS en reverse proxy;
- containerregistry en credentials;
@@ -171,7 +323,7 @@ Deze blokkeren onafhankelijke code niet:
## Bekende verificatiebeperking van de aangeleverde basis
De Pythonapplicatie, Dockerimage en PostgreSQL/Redis/Unraid-stack zijn geverifieerd. De optionele pytest-Playwrightvarianten vallen lokaal terug op de HTML/a11y-probe; VR-118 is aanvullend handmatig met de geïntegreerde browser op zes viewports uitgevoerd. Een publieke HTTPS-smoke blijft afhankelijk van domein-, DNS- en reverse-proxyconfiguratie.
De Pythonapplicatie, Dockerimage en PostgreSQL/Redis/Unraid-stack zijn geverifieerd. De optionele pytest-Playwrightvarianten vallen lokaal terug op de HTML/a11y-probe; VR-119 is aanvullend met de geïntegreerde browser op alle kernroutes uitgevoerd, VR-120 op het begeleide profiel en de live vacaturefeed en VR-124 op de acht platformmailboxen en alertkaarten. Een publieke HTTPS-smoke blijft afhankelijk van domein-, DNS- en reverse-proxyconfiguratie.
## Hervatten
+6 -2
View File
@@ -23,6 +23,8 @@ Verplichte stappen:
5. response status/contenttype/grootte controleren;
6. alleen allowlisted headers bewaren.
Webfetches, robotsrequests en IMAP-TLS gebruiken expliciet de bij de release vastgelegde `certifi`-CA-bundel. Hostnaamcontrole en `CERT_REQUIRED` blijven verplicht. Een verouderde Windows-rootstore is daarom geen reden voor `verify=False`; werk de gepinde bundel en lockfile gecontroleerd bij en voer de TLS-negatietests opnieuw uit.
Geen adapter of view mag dit pad omzeilen.
## 3. SSRF
@@ -51,11 +53,13 @@ Resterend risico: klassieke DNS rebinding tussen resolutie en socketconnectie. P
## 5. E-mail
- dedicated mailbox;
- een dedicated mailbox per platform;
- attachments worden genegeerd;
- message identity voorkomt dubbele verwerking;
- platformgebonden import accepteert alleen gelabelde HTTPS-links op het expliciet gekozen jobboarddomein;
- externe tracking-, account-, help-, privacy- en uitschrijflinks worden niet als vacature opgeslagen;
- links worden niet automatisch gevolgd tenzij later door bronpolicy goedgekeurd;
- mailboxcredentials alleen environment/secret;
- mailbox-app-wachtwoorden versleuteld in de database met een afzonderlijke, roteerbare environmentkey;
- raw mail korte retentie;
- log geen volledige onderwerp/body wanneer daarin PII kan staan.
+16 -2
View File
@@ -47,8 +47,10 @@ Voor e-mail:
1. MIME decode zonder attachments;
2. linkextractie met unsubscribe/privacyfilter;
3. platformalias bewaren;
4. oorspronkelijke werkgever proberen te resolveren in een aparte begrensde taak.
3. bij een platformmailbox uitsluitend gelabelde HTTPS-links op het gekozen jobboarddomein aanvaarden;
4. externe tracking-, account-, help-, privacy- en uitschrijflinks verwerpen;
5. platformalias en provenance bewaren;
6. oorspronkelijke werkgever proberen te resolveren in een aparte begrensde taak.
## 3. Minimumvelden
@@ -153,8 +155,20 @@ Automatisch herstel mag veilige selectorvarianten proberen, maar nieuwe logica m
| SmartRecruiters | `*.smartrecruiters.com` | gespecialiseerd listing+detail parser | geen interne API-auth nodig; stop bij CAPTCHA/anti-bot | 30 requests/min per domein |
| Workable | `apply.workable.com` | gespecialiseerd listing+detail parser | alleen publieke vacaturepagina/JSON; geen private endpoints | 30 requests/min per domein |
Gedeelde ATS-hosts zijn geen bronidentiteit: meerdere werkgevers mogen hetzelfde providerdomein gebruiken zolang iedere bron een unieke publieke `base_url` heeft. De scheduler blijft per origin coördineren, zodat deze extra bronrecords de providerlimiet niet omzeilen.
Voor Corda Campus is een begrensde regionale listingadapter beschikbaar. Die leest uitsluitend de expliciete `/jobs/`-kaarten, bewaart de vermelde werkgever en weigert iedere joblink buiten `cordacampus.com`. Vacaturedetail en sollicitatie-URL's worden niet automatisch gevolgd.
Voor elke provider-adapter:
- `supports()` kijkt eerst op host/marker om andere pagina's niet te matchen;
- listingpayloads produceren kandidaten zonder fetch van detail;
- detailpayloads vullen een enkele vacaturestructuur;
- elke veldafklaring krijgt veld-evidence met method-labels en confidence.
## Regionale Mol-adapter (VR-126)
`MolRegionEmployerAdapter` verwerkt uitsluitend vijf vooraf gereviewde HTTPS-lijstroutes rond postcode 2400: SCK CEN, Cipal Schaubroeck, VanRoey, NTX/Netropolix en Thomas More. De adapter doet geen netwerkrequests en accepteert alleen same-host vacaturelinks. Cipal, NTX en Thomas More vereisen een expliciete lokale marker; bij Thomas More wordt bovendien de vaste publieke werkgever-GUID gecontroleerd. VanRoey-detailpaden zijn door robots uitgesloten en worden daarom nooit door VacatureRadar gefetcht; alleen de toegestane overzichtspagina dient als bron.
## Kempen-werkgeversadapter (VR-201)
`KempenEmployerAdapter` voegt zeven gereviewde werkgeversroutes toe voor Ziekenhuis Geel, lokaal bestuur Geel, Stad Turnhout, Group Renotec, Ravago, Sanofi en DAF Trucks Westerlo. Iedere route heeft een vaste HTTPS-host en een toegestaan lijstpad; vacaturelinks moeten same-host zijn. Voor gedeelde of landelijke lijsten blijft alleen een kaart met de expliciete gereviewde vestiging over. Gemeente Mol wordt via de officiële same-host RSS-feed verwerkt. Interessante werkgevers zonder stabiele fail-closed parser blijven als niet-scanbare waaklijstbron zichtbaar.
@@ -2,6 +2,7 @@
- Status: accepted
- Datum: 2026-07-20
- Aangevuld door: ADR-0009
## Context
@@ -9,11 +10,16 @@ Vacatureplatformen bieden nuttige zoekalgoritmes en alerts, terwijl directe scra
## Besluit
Gebruik een dedicated IMAP-mailbox en idempotente MIME/linkextractie. Bewaar platformlinks als bronalias. Volg een link alleen wanneer bronpolicy dat later toestaat; probeer bij voorkeur een oorspronkelijke werkgeverspagina te vinden.
Gebruik per vacatureplatform een dedicated IMAP-mailbox en idempotente MIME/linkextractie. Iedere mailbox heeft een eigen interval en kan vanuit de bronpagina afzonderlijk worden gepauzeerd of ingepland. Bewaar platformlinks als bronalias. Volg een link alleen wanneer bronpolicy dat later toestaat; probeer bij voorkeur een oorspronkelijke werkgeverspagina te vinden.
De gereviewde mailboxcatalogus omvat VDAB, Indeed, LinkedIn, ictjob.be, Jobat, StepStone, Careerjet, Randstad en Robert Half. De gebruiker activeert de officiële jobalert zelf. VacatureRadar logt alleen in op het opgegeven mailboxaccount, beperkt extractie tot gelabelde HTTPS-links op het gekozen platformdomein en gebruikt geen jobboardaccount, zoekbot of directe crawl. Expliciet Brusselse nicheboards zijn in VR-125 uit de configureerbare catalogus verwijderd omdat ze buiten de regionale productscope vallen.
Voor VDAB bestaan twee officiële routes. Door de gebruiker ingestelde [jobalerts](https://www.vdab.be/jobs/job-alerts) mogen onmiddellijk via deze mailboxingang worden verwerkt. De [VDAB Vacature API](https://extranet.vdab.be/api-center-excellence-coe/vacatures-ophalen-met-de-vacatures-api) mag pas als fetchbron worden geactiveerd nadat VDAB het partnership heeft goedgekeurd en de samenwerkingsovereenkomst en credentials beschikbaar zijn. Deze ADR staat geen scraping of gebruik van een verborgen/onbevoegd endpoint toe.
## Gevolgen
- gebruiker stelt alerts eenmalig in;
- parser moet variërende e-mailsjablonen verdragen;
- dedicated mailbox en retentie nodig;
- parser moet variërende e-mailsjablonen verdragen en een onbekende wijziging fail-closed als geen herkenbare vacaturelink behandelen;
- batches en berichtgrootte zijn begrensd en één fout bericht stopt de rest niet;
- dedicated mailbox per platform en retentie nodig;
- geen platformcredentials in VacatureRadar.
@@ -0,0 +1,32 @@
# ADR-0009 — Versleutelde platformmailboxen in de applicatie
- Status: accepted
- Datum: 2026-07-22
## Context
Alerts van VDAB, Indeed, LinkedIn en andere gereviewde jobboards komen in verschillende mailboxaccounts binnen en moeten vaker dan eenmaal per dag verwerkt kunnen worden. Alleen globale environmentcredentials bieden geen bruikbare beheerflow in het platform. Mailbox-app-wachtwoorden zijn secrets en mogen niet leesbaar in de database, logs, taakresultaten of HTML terechtkomen.
## Besluit
- Bewaar één gebruikersgescopeerde `MailboxConnection` per vacatureplatform.
- Ondersteun Gmail, Outlook / Microsoft 365 en een gecontroleerde aangepaste publieke IMAP-host, uitsluitend via IMAP over TLS.
- Versleutel app-wachtwoorden met Fernet/MultiFernet en een afzonderlijke `MAILBOX_CREDENTIAL_KEYS`-sleutellijst. De eerste sleutel versleutelt; volgende sleutels zijn alleen voor decryptie tijdens rotatie.
- Laat de scheduler elke vijf minuten verschuldigde mailboxen selecteren. Iedere mailbox kiest zelf 5, 15, 30, 60, 180 of 360 minuten.
- Gebruik een databaselease per mailbox, idempotentie per mailbox + Message-ID en foutisolatie per mailbox/bericht.
- Alle UI-mutaties zijn login-, user-scope- en CSRF-beveiligd. Het wachtwoordveld wordt nooit opnieuw gevuld.
- Valideer de IMAP-host als publieke host voor de verbinding. Platformlinks uit mail worden niet automatisch gevolgd.
## Sleutelrotatie
1. Voeg de nieuwe sleutel vóór de oude toe aan `MAILBOX_CREDENTIAL_KEYS`.
2. Herstart web, worker en scheduler.
3. Draai `python manage.py rotate_mailbox_credentials`.
4. Verwijder de oude sleutel pas nadat de opdracht slaagt en een mailboxsync is getest.
## Gevolgen
- `cryptography` is een productieafhankelijkheid en staat exact gepind in de lockfile.
- Een databasedump bevat ciphertext en blijft gevoelig; de encryptiesleutel wordt apart in de secretstore/back-up bewaard.
- Zonder expliciete sleutel is mailboxbeheer in productie fail-closed. Alleen DEBUG gebruikt een lokale sleutel afgeleid van de development `SECRET_KEY`.
- Dit is een mailboxlogin, geen login bij een jobboard en geen toestemming voor directe platformfetch.
+13
View File
@@ -6,6 +6,19 @@ Deze integratie vertaalt het Stitch-project **VacatureRadar Intelligence Cockpit
De volledige ontvangen referentie staat in `docs/design/stitch-reference/`. Deze map en de validatiescreenshots zijn uitgesloten van de productiecontainer via `.dockerignore`; er worden geen externe Tailwind-, font-, icoon-, afbeelding- of JavaScript-CDN's aan de applicatie toegevoegd.
## VR-119: compositie als primaire bron
Na productfeedback is de integratie aangescherpt: Stitch bepaalt nu niet alleen kleur, vorm en typografie, maar ook de dominante schermcompositie en informatiedichtheid. Alleen aantoonbaar onveilige of fictieve onderdelen worden vervangen door een functioneel equivalent met echte repositorydata.
- De utilitybar neemt de brede zoekbalk en directe profielactie over.
- Het dashboard gebruikt een enkelkoloms matchfeed met vaste radar-/actierail; één resultaat blijft daardoor een volwaardig primair signaal.
- De vacaturelijst toont een dichte intelligencefeed met de laatste gebruikersgescopeerde score via databaseannotaties.
- Het detail combineert scorehero, drie echte kernmetingen, analyse en een vaste menselijke actierail.
- Pipeline, profieleditor, bronkaarten en systeemtelemetrie volgen de Stitch-kolommen en panelhiërarchie.
- Handmatige import blijft volledig beschikbaar, maar staat compact achter een disclosure zodat brongezondheid visueel voorrang krijgt.
De bedoelde “circa 90% Stitch” slaat hiermee op overgenomen visuele structuur en interactiehiërarchie, niet op het kopiëren van fictieve content, externe assets, auto-apply of routes waarvoor het domeinmodel geen betrouwbare data bezit.
## Visuele richting
- Donkere cockpit als standaard: diep marineblauw, tonale panelen, fijne slate-randen, signaalcyaan voor primaire interactie, emerald voor aantoonbaar gezonde status en koraal/amber voor fouten of aandacht.
+21
View File
@@ -0,0 +1,21 @@
# VR-119 browservalidatie
Datum: 2026-07-21
## Scope
De tweede Stitch-pass is gecontroleerd tegen dashboard, vacaturelijst, vacaturedetail, sollicitatiepipeline, profieloverzicht/editor, bronnen en systeemstatus met de lokale `visualtest`-fixture. Alle weergegeven cijfers en acties kwamen uit de lokale Django-database; er is geen fictieve frontenddata toegevoegd.
## Resultaten
- Dashboardmatrix geslaagd op 1440×900, 1280×800, 1024×768, 768×1024, 390×844 en 360×800.
- Iedere gecontroleerde kernroute had exact één `h1`, zichtbare inhoud en geen pagina-brede horizontale overflow.
- De pipeline gebruikt bewust een intern horizontaal scrollgebied voor zes statuskolommen; de pagina zelf overflowt niet.
- Mobiele navigatie opent op 390×844, verplaatst focus naar de drawer en houdt de pagina binnen de viewport.
- Dark en light renderen met dezelfde informatiehiërarchie; de themawissel gaf geen consolefout.
- Externe vacaturelinks behouden `noopener`; er werden geen browserconsolefouten gevonden.
- Lokale CSS, JavaScript, iconsprite en favicon laden zonder externe productie-assets.
## Bewijs
Nieuwe screenshots gebruiken het prefix `vr119-` in deze map. De oudere VR-118-screenshots blijven bewaard als visuele voor/na-historie.
Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+1 -1
View File
@@ -76,7 +76,7 @@ Een hersteltest is pas geslaagd wanneer:
- een score en digest kunnen worden aangemaakt;
- geen live mailbox of bron onbedoeld wordt gepolld in de testomgeving.
Gebruik in een hersteltest `IMAP_ENABLED=0`, `CELERY_TASK_ALWAYS_EAGER=1`, consolemail en geen live source polling.
Pauzeer in een hersteltest alle `MailboxConnection`-records, gebruik `CELERY_TASK_ALWAYS_EAGER=1`, consolemail en geen live source polling. Bewaar `MAILBOX_CREDENTIAL_KEYS` afzonderlijk van de databaseback-up; zonder minstens één bijbehorende sleutel zijn opgeslagen app-wachtwoorden niet herstelbaar.
## Verwijdering en privacy
+8
View File
@@ -1,6 +1,8 @@
# Geodata import voor Belgische locaties
Deze module gebruikt lokaal beheerde postcode/gemeentedata om locatiecoördinaten te kunnen afleiden.
De actuele lokale keuze is GeoNames Postal Codes BE, versie 2026-04-03 onder CC BY 4.0. Bron,
checksums, reviewdatum en steekproef staan in `config-data/geodata/README.local.md`.
## CLI
@@ -34,3 +36,9 @@ python manage.py import_geodata <pad-naar-geodata.csv> \
## Fallback
Zonder geïmporteerde geodata blijft afstandsafleiding op "onbekend".
## Nauwkeurigheid en privacy
Een postcodepunt is een geschat centrum en geen routeafstand of exact adres. VacatureRadar bewaart
voor het thuisprofiel alleen de ingevoerde postcode en het bijbehorende lookup-punt. De dataset bevat
geen namen, adressen, contactgegevens of andere persoonsgegevens.
+73
View File
@@ -0,0 +1,73 @@
# Livebroncatalogus
Reviewdatum: 2026-07-22
Review vervalt: uiterlijk 2026-10-20
Deze catalogus bevat alleen publieke vacaturepagina's, officiële RSS-feeds en gedocumenteerde publieke ATS-feeds. Iedere actieve bron gebruikt uitsluitend `GET`, passeert bronbeleid, robotscontrole, TLS-controle en SSRF-validatie en benadert geen sollicitatie- of kandidaatendpoint.
## Regionale scope
Het actieve profiel gebruikt **postcode 2400**, gemeente **Mol**, en een maximale straal van **40 km**. Op 2026-07-22 is de officiële Belgische GeoNames-postcodedataset van 2026-04-03 lokaal geladen onder CC BY 4.0. Postcode 2400 is daarmee opgelost naar het benaderende postcodecentrum **51.1919, 5.1166**. Afstanden zijn bruikbaar voor regionale filtering, maar blijven indicatief omdat een postcodecentrum geen exact adres is.
## Actieve werkgeversbronnen
| Bron | Regionale reden en publieke ingang | Parser | Livevalidatie |
|---|---|---|---|
| Gemeente Mol | Mol; [officiële RSS-feed](https://jobs.gemeentemol.be/rss/vacatures.rss) | `rss` | 4 vacatures; twee geslaagde cycli, idempotent. |
| VITO | Mol; [eigen jobsite](https://jobs.vito.be/l/nl) | `ats-recruitee` | Bestaande bron; twee geslaagde cycli. |
| SCK CEN | Mol; [eigen vacaturelijst](https://www.sckcen.be/nl/carriere/vacatures) | `regional-mol-employers` | 6 kaarten; twee geslaagde cycli. |
| Ziekenhuis Geel | Geel; [eigen vacaturelijst](https://ziekenhuisgeel.careersite.be/nl/vacatures) | `regional-kempen-employers` | 14 kaarten; twee geslaagde cycli. |
| Lokaal bestuur Geel | Geel; [eigen jobsite](https://geel.hro.be/) | `regional-kempen-employers` | 2 kaarten; twee geslaagde cycli. |
| Group Renotec | Geel; [publieke vacaturelijst](https://jobs.renotec.be/) | `regional-kempen-employers` | 7 expliciet Geelse kaarten; twee geslaagde cycli. |
| Ravago | Arendonk/Olen; [publieke vacaturelijst](https://ravago.softgarden.io/en/vacancies) | `regional-kempen-employers` | 22 regionale kaarten; twee geslaagde cycli. |
| Sanofi | Geel; [Belgische carrièrepagina](https://jobs.sanofi.com/en/belgium) | `regional-kempen-employers` | 1 expliciet Geelse kaart; twee geslaagde cycli. |
| DAF Trucks | Westerlo; [publieke vacaturelijst](https://www.daf.be/nl-be/over-daf/werken-bij-daf-trucks/vacatures) | `regional-kempen-employers` | 1 expliciet Westerlose kaart; twee geslaagde cycli. |
| Cipal Schaubroeck | Westerlo/Geel; [publieke werkgeverlijst](https://cipalschaubroeck.teamtailor.com/jobs) | `regional-mol-employers` | 12 regionale kaarten; tweede cyclus correct als HTTP 304. |
| VanRoey | Turnhout/Geel; [eigen IT-vacaturelijst](https://www.vanroey.be/en/job-overview/) | `regional-mol-employers` | 23 kaarten; twee geslaagde cycli. |
| NTX (Netropolix) | Geel; [publieke werkgeverpagina](https://netropolix.recruitee.com/) | `regional-mol-employers` | 2 Geelse kaarten; twee geslaagde cycli. |
| Thomas More | Geel/Turnhout/Vorselaar; [publieke vacaturelijst](https://jobpage.cvwarehouse.com/?companyGuid=eab9ca13-ee10-4504-8a87-a785d0b037ef&lang=nl-BE) | `regional-mol-employers` | 5 regionale kaarten; twee geslaagde cycli. |
| Stad Turnhout | Turnhout; [eigen jobsite](https://jobs.turnhout.be/) | `regional-kempen-employers` | 2 kaarten; twee geslaagde cycli. |
| Universiteit Hasselt | Hasselt/Diepenbeek; [eigen vacaturelijst](https://www.uhasselt.be/vacatures) | `regional-local-employers` | 10 kaarten; twee geslaagde cycli. |
| Hogeschool PXL | Hasselt/Diepenbeek; [publieke jobsite](https://ses.pxl.be/) | `regional-local-employers` | 15 kaarten; twee geslaagde cycli. |
| Ziekenhuis Oost-Limburg | Genk/Lanaken/Maaseik; [publieke jobsite](https://ziekenhuis-oost-limburg.cvw.io/?lang=nl-BE) | `regional-local-employers` | 63 kaarten; twee geslaagde cycli. |
| Corda Campus werkgevers | Hasselt; [publieke Corda-joblijst](https://www.cordacampus.com/jobs/) | `regional-corda-campus` | Bestaande bron; twee geslaagde cycli. |
| RPM Europe Logistics NV | Genk; [publieke carrièrepagina](https://rpmeuropelogisticsnv.recruitee.com/) | `ats-recruitee` | Bestaande bron; twee geslaagde cycli. |
| ACA Group | Hasselt; [eigen vacaturelijst](https://acagroup.be/en/jobs) | `regional-local-employers` | 10 kaarten; twee geslaagde cycli. |
| Xplore Group | Hasselt; [eigen vacaturelijst](https://www.xploregroup.be/en/jobs) | `regional-local-employers` | 13 Hasselt-kaarten; twee geslaagde cycli. |
De Kempen-adapter accepteert uitsluitend zeven vooraf gereviewde HTTPS-host/padcombinaties en same-host detail-URL's. Hij bewaart per werkgever alleen kaarten waarvan de gepubliceerde locatie binnen de afgesproken regionale vestiging valt. Gemeente Mol gebruikt een officiële RSS-feed en heeft daarom geen maatwerkparser nodig. Geen adapter benadert login-, message-, apply- of kandidaatendpoints.
## Werkgeverswaaklijst
Interessante regionale werkgevers zonder voldoende stabiele, publiek verifieerbare automatische ingang staan zichtbaar in de applicatiewaaklijst. Ze worden niet door bulk- of geplande scans meegenomen. Dat omvat ook werkgevers die bij de review geen lokale vacature open hadden.
| Werkgever | Regio | Observatie op 2026-07-22 |
|---|---|---|
| Johnson & Johnson | Beerse | Actuele Belgische rollen; publieke lijst geeft een challenge/403, zonder omzeiling. |
| Nike European Logistics Campus | Laakdal | Actuele distributierollen; globale dynamische joblijst nog niet fail-closed regionaal parseerbaar. |
| AZ Turnhout | Turnhout | Actuele rollen; gedeelde dynamische CVWarehouse-lijst vraagt extra werkgever- en locatiefiltering. |
| Cartamundi | Turnhout | Interessante productie- en ondersteuningswerkgever; geen stabiele lokale publieke lijst bewezen. |
| Nyrstar | Balen/Pelt | Grote regionale industriële werkgever; geen actuele lokale vacature bevestigd. |
| Aurubis | Olen | Grote regionale werkgever; momenteel vooral spontane sollicitatie, geen regelmatige lokale vacaturelijst bevestigd. |
| Estée Lauder | Oevel/Westerlo | Grote regionale productievestiging; geen actuele lokale rol bevestigd. |
| Soudal | Turnhout | Grote regionale werkgever met eigen carrièreplatform; regionale route nog niet stabiel genoeg bewezen. |
| H.Essers | Tessenderlo | Logistieke werkgever met eigen jobsite; vestigingsfilter nog te herreviewen. |
| Cegeka | Hasselt | Relevante IT-werkgever; locatie is niet betrouwbaar aan elke kaart gekoppeld. |
| IOK / IOK Afvalbeheer | Kempen | Regionale publieke werkgever; geen stabiele vacaturefeed bewezen. |
| Jessa Ziekenhuis | Hasselt | Eigen jobs en jobmails; gedeeld ATS vereist nog fail-closed werkgeverafbakening. |
## Uitgeschakeld wegens buiten regio
Deliverect (Gent), YPTO, adesso Belgium, Monizze, Qover, SFEIR Belgium en ARHS Belgium zijn uitgeschakeld en uit het normale bronoverzicht verborgen. Historische documenten, vacature-aliassen en provenance blijven bewaard. Dit is geen denylistbeslissing: de bronnen zijn technisch toegestaan, maar vallen buiten de ingestelde regionale productscope.
## Jobboards via officiële jobalerts
Brede jobboards worden niet rechtstreeks gecrawld. De gebruiker activeert zelf een officiële alert en koppelt per platform een afzonderlijke mailbox. Ondersteund zijn VDAB, Indeed, LinkedIn, ictjob.be, Jobat, StepStone, Careerjet, Randstad en Robert Half. De parser accepteert per koppeling uitsluitend gelabelde HTTPS-links op het gekozen jobboarddomein en verwerpt externe tracking-, account-, privacy- en uitschrijflinks. Alle negen domeingroepen staan op de directe-fetchdenylist; loginbot, CAPTCHA-omzeiling, verborgen endpoints en jobboardscraping blijven uitgesloten.
De officiële [VDAB Vacature API](https://extranet.vdab.be/api-center-excellence-coe/vacatures-ophalen-met-de-vacatures-api) blijft een mogelijke rechtstreekse synchronisatieroute, maar vereist goedkeuring, partnership en een samenwerkingsovereenkomst. Zonder die externe toelating wordt geen API-client geactiveerd.
## TLS en herreview
Gemeente Mol en Ziekenhuis Geel hebben op 2026-07-22 met de applicatie-CA-bundel een geldige TLS-keten en antwoordden beide met HTTP 200. De eerdere fout kwam door een verouderde lokale Windows-certificaatketen, niet door het uitschakelen van certificaatcontrole. VacatureRadar gebruikt expliciet de gepinde `certifi`-bundel en voert nooit `verify=False` uit.
Uiterlijk 2026-10-20 controleert de beheerder opnieuw vacaturepagina's, robotsregels, responsevormen, regionale scope en twee proefcycli. Bij voorwaardenwijziging, parserdrift, onverwachte volumestijging, ontbrekende locatie of redirect naar een ander domein gaat de bron terug naar `trial`, `paused` of quarantaine; verlopen reviews blokkeren fail-closed.
+17 -5
View File
@@ -56,11 +56,23 @@ celery -A config inspect reserved
## IMAP-import stopt
1. Zet `IMAP_ENABLED=0` om lockout of herhaalde fouten te beperken.
2. Controleer host, poort, TLS, mailboxnaam en app-password.
3. Verifieer dat de mailbox niet is hernoemd en het account niet is vergrendeld.
4. Test eerst read-only met `IMAP_MARK_SEEN=0`.
5. Een reeds verwerkte mail is idempotent via message identity; verwijder geen `EmailMessageRecord` om opnieuw te proberen zonder analyse.
1. Pauzeer alleen de falende platformmailbox op de bronpagina; andere mailboxen blijven werken.
2. Controleer provider, publieke IMAP-host, poort 993, mailboxmap, account en app-wachtwoord.
3. Verifieer dat `MAILBOX_CREDENTIAL_KEYS` aanwezig is en dat bij een rotatie de oude sleutel nog achter de nieuwe staat.
4. Verifieer dat de mailbox niet is hernoemd en het account niet is vergrendeld.
5. Alle polls selecteren de mailbox read-only; een reeds verwerkte mail is per mailbox idempotent via message identity.
6. Controleer het gekozen interval en `next_poll_at`; de scheduler zoekt iedere vijf minuten naar verschuldigde koppelingen.
7. Controleer `IMAP_MAX_MESSAGES_PER_POLL` en `IMAP_MAX_MESSAGE_BYTES`. Vergroot die niet om een onverwacht groot of verdacht bericht blind te verwerken.
8. Controleer bij een leeg resultaat of het jobboardmailformaat nog gelabelde HTTPS-vacaturelinks op het gekozen platformdomein bevat; versoepel de domeinfilter niet zonder fixture en securitytest.
9. Activeer de mailbox opnieuw, kies **Nu synchroniseren** en controleer `last_success_at`. Verwijder geen `EmailMessageRecord` om zonder analyse te herhalen.
## Mailboxsleutel roteren
1. Maak een nieuwe Fernet-sleutel en zet die als eerste in `MAILBOX_CREDENTIAL_KEYS`; behoud de oude sleutel erachter.
2. Herstart web, worker en scheduler zodat alle processen dezelfde sleutellijst gebruiken.
3. Draai `python manage.py rotate_mailbox_credentials`.
4. Synchroniseer één mailbox en controleer dat geen ciphertext, app-wachtwoord of volledige mailpayload in logs staat.
5. Verwijder daarna de oude sleutel en herstart opnieuw.
## Digest ontbreekt of dubbel
+20
View File
@@ -1,5 +1,7 @@
# Bron onboarden
De actueel gereviewde werkgeverfeeds, proefresultaten en dekkingsgrenzen staan in [`LIVE_SOURCE_CATALOG.md`](LIVE_SOURCE_CATALOG.md). `config-data/seed_sources.yaml` installeert uitsluitend de daar vastgelegde bronnen en verbergt de oude fixturebron uit het normale overzicht.
Een nieuwe bron wordt nooit direct als onbeperkt actief beschouwd. Gebruik het traject **candidate → trial → active** en ga bij veiligheids- of kwaliteitsproblemen naar **quarantined**.
## Toegestane broncategorieën
@@ -144,3 +146,21 @@ Voor het onboarden van publieke ATS-bronnen moet het bronrecord minimaal een van
- Workable (`apply.workable.com`) — max 30 requests/min, alleen publieke vacaturepagina/feeds.
Bij twijfel altijd op `review` blijven en eerst via een trialrun met gesloten evaluatiecriteria (gesloten job, lege joblijst, markeringswijziging) valideren.
Meerdere werkgevers kunnen dezelfde ATS-host gebruiken. Registreer ze als afzonderlijke bronnen met een unieke exacte publieke `base_url`; gebruik het providerdomein nooit als werkgeveridentiteit. Rate limiting en leases blijven per origin gedeeld, dus laat opeenvolgende liveproeven op Lever of SmartRecruiters het ingestelde minimuminterval respecteren.
## Brede jobboards via platformmailboxen
VDAB, Indeed, LinkedIn, ictjob.be, Jobat, StepStone, Careerjet, Randstad en Robert Half blijven directe-fetchdenylistbronnen. Activeer op het jobboard zelf een officiële vacaturealert en koppel op de bronpagina per platform een afzonderlijke Gmail-, Outlook- of aangepaste IMAP-mailbox. Kies per mailbox een interval van 5 tot 360 minuten; de scheduler pakt alleen verschuldigde actieve koppelingen. De parser houdt per koppeling uitsluitend gelabelde HTTPS-links op het gekozen jobboarddomein over. Zonder mailboxcredentials gebruikt de beheerder handmatige URL- of tekstimport; dit is geen reden om platformpagina's automatisch te crawlen.
`seed_sources` ondersteunt daarnaast `retire_source_urls`. Zo wordt een bron die buiten de gekozen regio valt uitgeschakeld en uit het normale bronoverzicht verborgen, terwijl eerdere documenten, vacature-aliassen en provenance behouden blijven. Gebruik dit mechanisme voor een regionale koerswijziging; verwijder bronhistoriek niet handmatig.
Voor de Mol-regio geldt postcode 2400 met een straal van 40 km als cataloguscentrum. Leg per werkgever een expliciete gemeentemarker vast en behandel een ontbrekende concrete vacaturelocatie als onbekend. De `regional-mol-employers`-parser mag uitsluitend de vijf gereviewde routes uit de livecatalogus verwerken; een nieuwe host, pad, CVWarehouse-GUID of vestiging vereist eerst een nieuwe fixture, securitytest en bronreview.
De aanvullende `regional-kempen-employers`-parser volgt dezelfde regel voor zeven exacte routes: Ziekenhuis Geel, lokaal bestuur Geel, Stad Turnhout, Renotec, Ravago, Sanofi en DAF Trucks Westerlo. Gemeente Mol gebruikt de officiële RSS-route. Controleer bij landelijke of gedeelde joblijsten dat iedere kaart de gereviewde gemeente expliciet noemt; een onduidelijke locatie wordt niet stil aan een vestiging toegewezen.
Gebruik `metadata.watchlist: true` voor een interessante regionale werkgever die momenteel geen vacature heeft of nog geen stabiele publieke listing heeft. Zo'n bron staat op `paused`, verschijnt afzonderlijk in de UI en wordt niet door bulkacties of de scheduler gescand. Activeer hem pas na een nieuwe voorwaardenreview, fixture, negatieve host/padtest en twee geslaagde trialcycli.
Een mailboxkoppeling vraagt alleen IMAP-gegevens. VacatureRadar vraagt of bewaart geen LinkedIn-, ictjob-, Jobat-, StepStone- of ander jobboardwachtwoord en activeert alerts niet automatisch. Wanneer een jobboard zijn mailformaat wijzigt en geen herkenbare platformlink resteert, werk dan eerst een gesaneerde fixture en parsertest bij.
VDAB documenteert daarnaast een officiële Vacature API voor het ophalen en synchroniseren van vacatures. Registreer die pas als nieuwe adapter/brontype nadat VDAB het partnership schriftelijk heeft goedgekeurd, de samenwerkingsovereenkomst rond is en testcredentials beschikbaar zijn. Publieke developerportal-documentatie alleen is geen toestemming om de API te gebruiken.
+17 -12
View File
@@ -103,25 +103,30 @@ Wanneer een proxy op hetzelfde Docker-netwerk draait, publiceer poort 1226 allee
## Mailbox
Gebruik een aparte mailbox of alias, niet de hoofdmailbox. Begin met:
Gebruik per vacatureplatform een aparte mailbox of alias, niet de hoofdmailbox. Genereer eerst buiten Git een Fernet-sleutel:
```dotenv
IMAP_ENABLED=0
IMAP_MARK_SEEN=0
```bash
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
```
Na een succesvolle fixture- en read-onlytest:
Plaats de uitvoer uitsluitend in de Unraid-secretconfiguratie:
```dotenv
IMAP_ENABLED=1
IMAP_HOST=<host>
IMAP_USER=<account>
IMAP_PASSWORD=<app-password>
IMAP_MAILBOX=INBOX
IMAP_USE_SSL=1
MAILBOX_CREDENTIAL_KEYS=<fernet-key>
IMAP_CONNECT_TIMEOUT_SECONDS=15
IMAP_MAX_MESSAGES_PER_POLL=200
IMAP_MAX_MESSAGE_BYTES=1000000
```
Het account heeft alleen mailboxrechten nodig. Gebruik waar beschikbaar een app-password en schakel interactieve login op die mailbox niet uit zolang herstel nodig is.
Herstart web, worker en scheduler. Open daarna **Bronnen → Platformmailboxen** en koppel ieder gewenst platform afzonderlijk: VDAB, Indeed, LinkedIn, ictjob.be, Jobat, StepStone, Careerjet, Randstad of Robert Half. Kies Gmail, Outlook / Microsoft 365 of een aangepaste publieke IMAP-host, gebruik een app-wachtwoord en laat `INBOX` geselecteerd tenzij de alert naar een aparte map wordt gefilterd. De scheduler controleert iedere vijf minuten welke mailbox volgens haar eigen interval verschuldigd is.
Voor sleutelrotatie zet je `nieuwe-sleutel,oude-sleutel` in `MAILBOX_CREDENTIAL_KEYS`, herstart je de processen en draai je:
```bash
python manage.py rotate_mailbox_credentials
```
Verwijder de oude sleutel pas na een geslaagde sync. Een rollback van migratie `0005` verwijdert de mailboxkoppelingen; maak daarom eerst een versleutelde databaseback-up en pauzeer de scheduler.
## Ollama
+9 -2
View File
@@ -57,7 +57,7 @@ Django/Celery
| T-09 | MIME/HTML-bom of malformed mail | resource-uitputting/parsercrash | IMAPbatchlimiet, bodylimiet bij vervolgtaak, robuuste parser, idempotente recordidentiteit | `VR-115`, e-mailfixtures |
| T-10 | Cross-user objecttoegang | lek van sollicitaties/feedback | LoginRequired, user-scoped querysets/get_or_create, autorisatietests | integratie-viewtests |
| T-11 | CSRF op feedback/profiel | ongewenste status- of profielmutatie | Django CSRF-middleware, POST-only acties, SameSitecookies | viewtests en frameworkcheck |
| T-12 | Credentiallek in Git/logs | account- of infrastructuurcompromis | env-only secrets, gitignore, beperkte logs, voorbeeldwaarden, secretrotatierunbook | CI-secret scan gepland `VR-115`; review |
| T-12 | Credentiallek in Git/logs/database/UI | account- of infrastructuurcompromis | app-wachtwoorden met MultiFernet en afzonderlijke environmentkey versleuteld; nooit terugtonen; generieke fouttekst; gitignore, secretrotatierunbook | `tests/unit/test_mailbox_credentials.py`, `tests/integration/test_mailbox_connections.py`, CI-secret scan `VR-115` |
| T-13 | Onveilige reverse proxy | sessie over HTTP, host-header-/originproblemen | TLS, exact ALLOWED_HOSTS/CSRF origins, secure cookies, proxychecklist | deployment smoke-test |
| T-14 | Redis als waarheid behandeld | dataverlies/dubbele verwerking | PostgreSQL-state, idempotente taken/outbox, acks-late | integratietests |
| T-15 | Dubbele Celery-uitvoering | dubbele vacatures/mails | unieke keys, transacties, message identity, outbox-uniciteit | pipeline/digesttests |
@@ -73,6 +73,13 @@ Django/Celery
| T-31 | Sollicitatiedossier-export bevat niet-gewenste payload | data-lek of onbedoeld dossieroverdragen | export bevat alleen het gekozen dossier, ZIP-inhoud is beperkt tot snapshot/tijdlijn/print-HTML, bestandsnaam is geslugified zonder padseparators | `tests/integration/test_applications.py` |
| T-32 | XML external entity/entity-expansion via sitemap | lokale data-uitlezing of parseruitputting | sitemap-XML wordt met `defusedxml` verwerkt; fetcherlimieten blijven vóór parsing gelden | `tests/unit/test_discovery_rss.py`, Ruff securitycheck |
| T-33 | Externe UI-CDN of niet-vertrouwde Stitch-interactie | supply-chain-XSS, tracking of acties buiten Django-beleid | productie gebruikt alleen lokale CSS/JS en inline SVG-symbolen; Stitch-code is documentatiereferentie, alle mutaties blijven Django POST+CSRF; themavoorkeur bevat geen persoonsgegevens en opslagfouten worden afgevangen | `tests/e2e/test_vr114_browser_and_accessibility.py`, browserconsole- en assetcontrole VR-118 |
| T-34 | Publieke ATS-feed of robotsredirect wijzigt van host/identiteit | SSRF, verkeerd werkgeverslabel, ongewenst sollicitatie-endpoint of voortgezet fetchen na voorwaardenwijziging | iedere robotsredirect wordt opnieuw als publieke URL gevalideerd; feeds gebruiken alleen gereviewde GET-endpoints; werkgeveridentiteit komt uit source-config; reviews verlopen fail-closed en apply-endpoints zijn geen fetchbron | `tests/security/test_robots.py`, `tests/integration/test_pipeline.py`, `tests/unit/test_ats_adapters.py`, `VR-120` |
| T-35 | Regionale vacaturehub bevat een gemanipuleerde off-domain kaart of gedeelde ATS-host overschrijft een andere werkgever | phishinglink, foutieve herkomst, verlies van bronbeleid of verkeerde inventaris | regionale adapter accepteert alleen same-host `/job/`-links; bronuniciteit gebruikt type + exacte publieke base-URL; per-origin lease en minimuminterval blijven over alle ATS-tenants gedeeld | `tests/unit/test_regional_adapters.py`, `tests/unit/test_source_models.py`, `tests/integration/test_seed_sources.py`, `VR-121` |
| T-36 | Kwaadaardige/oversized platformmail of linkinjectie in een jobalert | parseruitputting, batchuitval, phishing, foutieve vacatures of verborgen scraping | IMAP-batch is begrensd; foutisolatie en idempotente identity per mailbox; platformparser accepteert alleen gelabelde HTTPS-links op het gekozen jobboarddomein en verwerpt externe/adminlinks; alle brede jobboards blijven op de directe-fetchdenylist | `tests/integration/test_email_import_notifications.py`, `tests/unit/test_email_adapter.py`, `tests/security/test_policy.py`, `VR-122/123/124/125` |
| T-37 | Lokale werkgeverslijst wijzigt markup, injecteert een externe kaart of vermengt functies buiten de regionale scope | phishing, foutieve locatie, te brede radar of onbetrouwbare ranking | lokale adapter is beperkt tot vijf gereviewde host/padcombinaties, accepteert alleen same-host details en past waar beschikbaar een expliciete Hasselt-filter toe; retire-seeding schakelt buitenregionale bronnen uit zonder provenance te wissen | `tests/unit/test_regional_adapters.py`, `tests/integration/test_seed_sources.py`, `VR-125` |
| T-38 | Cross-user mailboxbeheer, dubbele polls of IMAP naar private host | credentialmisbruik, duplicaten, toegang tot interne diensten | alle views scopen op gebruiker en vereisen CSRF; unieke user+platformkoppeling; transactionele lease; hostvorm en publieke DNS/IP-validatie vóór TLS-IMAP | `tests/integration/test_mailbox_connections.py`, `tests/unit/test_mailbox_credentials.py`, `apps/sources/services/imap_import.py`, `VR-123` |
| T-39 | Mol-/Kempenbron vermengt een andere werkgever, campus of vestiging | foutieve afstand, phishing of buitenregionale resultaten | exacte HTTPS-host/pad/GUID-routes; same-host details; expliciete plaatsfilters voor regionale en gedeelde lijsten; postcodecentra blijven als benadering herkenbaar; waaklijstbronnen worden niet gescand | `tests/unit/test_regional_adapters.py`, `tests/integration/test_tasks_and_more_views.py`, `VR-126/201` |
| T-40 | Verouderde lokale CA-store of ongeldige serverketen lokt een TLS-bypass uit | onderschepte vacature- of mailboxdata en credentialdiefstal | expliciet gepinde `certifi`-CA-bundel voor HTTP, robots en IMAP; hostnaamcontrole en `CERT_REQUIRED`; nooit `verify=False`; dependency- en negatieve TLS-tests bij update | `tests/unit/test_tls.py`, `tests/security/test_robots.py`, `apps/sources/services/tls.py`, `VR-201` |
## Misbruikscenario's
@@ -86,7 +93,7 @@ De redirectlocatie wordt opnieuw gevalideerd. Het request naar het private adres
### E-mailalert bevat een platformlink en uitschrijflink
De adapter herkent vacaturekandidaten, negeert bekende unsubscribepatronen en slaat de platformlink hoogstens als alias op. Automatisch volgen vereist opnieuw een toegestane source policy; denylistplatformen worden geweigerd.
De adapter gebruikt de gekozen platformidentiteit, accepteert alleen gelabelde HTTPS-links op dat jobboarddomein en negeert externe tracking-, account-, privacy- en uitschrijflinks. De platformlink wordt hoogstens als alias opgeslagen. Automatisch volgen vereist opnieuw een toegestane source policy; denylistplatformen worden geweigerd.
### Aanvaller laat scheduler dezelfde job herhalen
+14 -4
View File
@@ -17,7 +17,7 @@ Legenda: **I** = geïmplementeerd en getest in de huidige MVP; **P** = gedeeltel
| PR-013 | I | `url_security.py`, redirectfetcher | security URL/fetchtests | VR-115 transporthardening |
| PR-014 | I | `fetcher.py` | `test_fetcher.py` | — |
| PR-015 | I | `SourceLease`, `SourceOriginState`, `apps/sources/services/scheduling.py`, `apps/sources/tasks.py` | `test_scheduling.py`, `test_fetcher.py`, `test_tasks_and_more_views.py` | `VR-103` afgerond |
| PR-016 | I | `email_import.py`, recordmodel | e-mailintegratietests | |
| PR-016 | I | `email_import.py`, `MailboxConnection`, versleutelde platformmailboxservice en gereviewde jobboardcatalogus | e-mail- en mailboxintegratietests | VR-123/124 |
| PR-017 | I | candidate/status + basisdiscovery | `apps/sources/services/discovery.py`, `tests/unit/test_discovery_rss.py`, `tests/integration/test_discovery_command.py`, `tests/integration/test_tasks_and_more_views.py` | VR-101/102 |
| PR-018 | I | `apps/sources/services/health.py`, `apps/sources/tasks.py` | `tests/integration/test_source_health.py` | VR-112 |
| PR-020 | I | `JsonLdJobPostingAdapter` | JSON-LD-tests/fixtures | — |
@@ -39,8 +39,8 @@ Legenda: **I** = geïmplementeerd en getest in de huidige MVP; **P** = gedeeltel
| PR-044 | I | profiel/modelversievelden | scoring/profiletests | VR-109 promptversie |
| PR-045 | I | optionele Ollamaservice/fallback | `test_ai.py` | VR-109 |
| PR-046 | I | geen tools + guardrails | AI/securitytests | VR-109/115 evaluaties |
| PR-050 | I | dashboard view/template + Stitch-cockpitshell | viewtests, VR-118 browsermatrix | VR-114/118 e2e/a11y |
| PR-051 | I | detailview/template/provenance + intelligence-layout | viewtests, VR-118 screenshots | VR-114/118 |
| PR-050 | I | dashboard view/template + Stitch-dominante cockpitshell/feed/rail | viewtests, VR-119 browsermatrix | VR-114/118/119 e2e/a11y |
| PR-051 | I | detailview/template/provenance + scorehero/intelligence-strip | viewtests, VR-119 screenshots | VR-114/118/119 |
| PR-052 | I | feedbackendpoint/UI | feedback/viewtests | — |
| PR-053 | I | applicationmodel/serviceflow | `tests/integration/test_dedupe_lifecycle_feedback.py`, `tests/integration/test_applications.py` | VR-113 uitgebreid dossier |
| PR-054 | I | `notifications/models.py`, outbox/services/tasks | `tests/integration/test_email_import_notifications.py`, `tests/integration/test_reminders.py` | VR-111 |
@@ -63,7 +63,7 @@ Legenda: **I** = geïmplementeerd en getest in de huidige MVP; **P** = gedeeltel
| NFR-005 | I | domeinapps/services/adapters/ADRs | continue review |
| NFR-006 | I | offline pytest + coveragegate | VR-114 verbreedt e2e |
| NFR-007 | P | `scripts/benchmark.py`, `tests/benchmark/test_benchmark.py` | VR-116 |
| NFR-008 | I | semantische templates/designspec, zes viewports, dark/light en toetsenbordmenu | `tests/e2e/test_vr114_browser_and_accessibility.py`, `docs/design/validation/`, VR-118 |
| NFR-008 | I | semantische Stitch-dominante templates, zes viewports, dark/light en toetsenbordmenu | `tests/e2e/test_vr114_browser_and_accessibility.py`, `docs/design/validation/`, VR-118/119 |
| NFR-009 | I | lokale SQLite en Docker/Unraidconfig, release-smoke en restore | VR-117 afgerond |
| NFR-010 | I | `apps/sources/services/health.py`, `apps/sources/tasks.py`, `apps/core/views.py`, `config/settings.py` | `tests/integration/test_source_health.py` | VR-112 observability |
@@ -88,5 +88,15 @@ Iedere backlogtaak die een requirement wijzigt, werkt in dezelfde commit deze ma
- VR-107 is gerealiseerd met `apps/sources/views.py`, `apps/sources/services/manual_import.py`, `templates/sources/list.html`, `static/js/manual_import.js` en bijhorende unit/integration-tests.
- VR-115 is afgerond met productie-hardening voor `config/settings.py`, DNS-rebindmitigatie in `apps/sources/services/fetcher.py`, rate limiting op login en handmatige import, CI-secret- en kwetsbaarheidsscans, non-root/read-only containerhardening en bijbehorende security/unit/integration-tests.
- VR-118 vertaalt de aangeleverde Stitch Intelligence Cockpit naar de bestaande server-rendered routes. Bewijs staat in `docs/design/STITCH_INTEGRATION_PLAN.md`, `docs/design/validation/`, de view/a11ytests en de gedeelde lokale shell/assets.
- VR-119 herwerkt die vertaling na productfeedback zodat de Stitch-schermcompositie, feeddichtheid en hoofd-/zijpanelhiërarchie dominant zijn. Bewijs staat in `docs/design/validation/VR-119_REPORT.md`, de `vr119-*.png`-screenshots en de uitgebreide HTML/a11ytest.
- VR-120 vereenvoudigt thuislocatie tot postcode, vervangt JSON-/tekstinvoer door begeleide checkboxes en levert twee gereviewde livewerkgeverfeeds. Bewijs staat in `docs/operations/LIVE_SOURCE_CATALOG.md`, de profiel-/adapter-/pipelinetests en de bronruns in de lokale database.
- VR-121 breidt de catalogus uit tot tien livebronnen, ondersteunt meerdere werkgeverfeeds op gedeelde ATS-hosts, voegt een begrensde Corda Campus-adapter toe en houdt VDAB/Indeed uitsluitend op de bestaande veilige e-mail-/handmatige ingress. Bewijs staat in `docs/operations/LIVE_SOURCE_CATALOG.md`, de bronmigratie en de regionale/ATS/seedtests.
- VR-122 operationaliseert de veilige platformingress als dagelijkse, configureerbare en begrensde mailboxpoll. VDAB-alerts krijgen expliciete provenance, replay blijft idempotent en de bronpagina onderscheidt de werkende jobalertroute van de contractueel afgeschermde officiële Vacature API. Bewijs staat in de e-mailadapter-/importtests, `apps/sources/services/imap_import.py` en ADR-0005.
- VR-123 vervangt die globale poll door gebruikersgescopeerde VDAB- en Indeed-mailboxkoppelingen met versleutelde app-wachtwoorden, afzonderlijke intervallen, per-mailboxleases en UI-beheer. Bewijs staat in `tests/unit/test_mailbox_credentials.py`, `tests/integration/test_mailbox_connections.py`, de migratie en ADR-0009.
- VR-124 breidt dezelfde mailboxingang uit met LinkedIn, ictjob.be, Jobat, StepStone, EuroBrussels en BrusselsJobs. De platformgebonden parser accepteert uitsluitend gelabelde HTTPS-links op het gekozen jobboarddomein; fixtures, adapter-/integratietests en de centrale denylist bewijzen de afscherming.
- VR-125 vervangt de twee Brusselse nicheboards door Careerjet, Randstad en Robert Half, retireert buitenregionale werkgevers zonder provenanceverlies en voegt host- en padgebonden adapters toe voor ACA Group, Xplore Group, UHasselt, PXL en ZOL in de kern Hasselt-Genk.
- VR-126 herankert profiel en broncatalogus op postcode 2400 Mol en 40 km. De host-, pad- en locatiefilters voor SCK CEN, Cipal Schaubroeck, VanRoey, NTX en Thomas More worden gedekt door regionale adaptertests, source-viewtests, live bronruns en de bijgewerkte threat T-39.
- VR-201 activeert Gemeente Mol en zeven bijkomende Kempen-werkgevers na menselijke voorwaardenreview, fixtures, fail-closed locatie-/routefilters en twee livecycli. Twaalf interessante werkgevers zonder stabiele actieve ingang staan afzonderlijk en niet-scanbaar op de UI-waaklijst. Bewijs staat in de livecatalogus, seeddata, `KempenEmployerAdapter`, bronruns en adapter-/seed-/viewtests.
- VR-205 laadt de officiële Belgische GeoNames-postcodedataset 2026-04-03 onder CC BY 4.0, documenteert bron- en afgeleide checksums en lost 2400 Mol op naar het benaderende postcodecentrum. Bewijs staat in `config-data/geodata/README.local.md`, het importlog, geocodingtests en de opnieuw berekende profiel-vacaturescores.
+16
View File
@@ -0,0 +1,16 @@
[
{
"id": "deliverect-open-1",
"text": "Backend Engineer",
"hostedUrl": "https://jobs.lever.co/deliverect/deliverect-open-1",
"applyUrl": "https://jobs.lever.co/deliverect/deliverect-open-1/apply",
"categories": {
"location": "Ghent, Flemish Region",
"commitment": "Full-time Permanent",
"team": "Product"
},
"country": "BE",
"description": "<p>Build reliable services.</p>",
"workplaceType": "hybrid"
}
]
+19
View File
@@ -0,0 +1,19 @@
{
"offers": [
{
"id": 2680964,
"title": "Research Software Engineer",
"careers_url": "https://jobs.vito.be/o/research-software-engineer",
"company_name": "VITO",
"location": "Mol, Antwerpen, Belgium",
"state_name": "Antwerpen",
"postal_code": "2400",
"country": "Belgium",
"description": "<p>Ontwikkel software voor duurzaam onderzoek.</p>",
"employment_type_code": "fulltime_fixed_term",
"published_at": "2026-07-17 09:47:06 UTC",
"status": "published",
"hybrid": true
}
]
}
@@ -0,0 +1,47 @@
{
"limit": 100,
"offset": 0,
"totalFound": 2,
"content": [
{
"id": "744000137902340",
"name": "Forgerock IDM Developer",
"company": {
"identifier": "ARHS",
"name": "ARHS"
},
"releasedDate": "2026-07-20T08:30:00Z",
"location": {
"country": "be",
"region": "Brussels",
"city": "Brussels",
"remote": false
},
"typeOfEmployment": {
"id": "permanent",
"label": "Full-time"
},
"ref": "https://api.smartrecruiters.com/v1/companies/ARHS/postings/744000137902340"
},
{
"id": "744000136173889",
"name": "Cloud Engineer",
"company": {
"identifier": "ARHS",
"name": "ARHS"
},
"releasedDate": "2026-07-19T08:30:00Z",
"location": {
"country": "be",
"region": "Vlaams-Brabant",
"city": "Leuven",
"remote": true
},
"typeOfEmployment": {
"id": "permanent",
"label": "Permanent"
},
"ref": "https://api.smartrecruiters.com/v1/companies/ARHS/postings/744000136173889"
}
]
}
+13
View File
@@ -0,0 +1,13 @@
From: Careerjet Job Alert <alerts@careerjet.be>
To: radar@example.invalid
Date: Wed, 22 Jul 2026 08:15:00 +0200
Message-ID: <sanitized-careerjet-alert-001@example.invalid>
Subject: Nieuwe jobs rond Hasselt
MIME-Version: 1.0
Content-Type: text/html; charset="utf-8"
<html><body><h1>Nieuwe jobs</h1>
<a href="https://www.careerjet.be/jobad/be-fixture-1001">System Engineer</a>
<a href="https://outside.example.invalid/jobs/unsafe">Externe advertentie</a>
<a href="https://www.careerjet.be/account/alerts">Alert beheren</a>
</body></html>
+14
View File
@@ -0,0 +1,14 @@
From: ictjob.be Job Alert <alerts@ictjob.be>
To: radar@example.invalid
Date: Wed, 22 Jul 2026 08:05:00 +0200
Message-ID: <sanitized-ictjob-alert-001@example.invalid>
Subject: Nieuwe Belgische IT-vacatures
MIME-Version: 1.0
Content-Type: text/html; charset="utf-8"
<html><body>
<h1>Je IT Job Alert</h1>
<a href="https://www.ictjob.be/nl/it-vacatures-zoeken/cloud-engineer/fixture-1001">Cloud Engineer</a>
<a href="https://outside.example.invalid/jobs/unsafe">Advertentietracker</a>
<a href="https://www.ictjob.be/nl/account/preferences">Voorkeuren beheren</a>
</body></html>
+14
View File
@@ -0,0 +1,14 @@
From: Jobat Job Alert <alerts@jobat.be>
To: radar@example.invalid
Date: Wed, 22 Jul 2026 08:10:00 +0200
Message-ID: <sanitized-jobat-alert-001@example.invalid>
Subject: Jobs op maat in Limburg
MIME-Version: 1.0
Content-Type: text/html; charset="utf-8"
<html><body>
<h1>Jobs op maat</h1>
<a href="https://www.jobat.be/nl/jobs/data-engineer/fixture-1002">Data Engineer</a>
<a href="https://outside.example.invalid/jobs/unsafe">Partnerlink</a>
<a href="https://www.jobat.be/nl/privacy">Privacy</a>
</body></html>
+15
View File
@@ -0,0 +1,15 @@
From: LinkedIn Jobs <jobs-noreply@linkedin.com>
To: radar@example.invalid
Date: Wed, 22 Jul 2026 08:00:00 +0200
Message-ID: <sanitized-linkedin-alert-001@example.invalid>
Subject: Nieuwe jobs voor Software Engineer
MIME-Version: 1.0
Content-Type: text/html; charset="utf-8"
<html><body>
<h1>Nieuwe jobs voor jou</h1>
<a href="https://www.linkedin.com/jobs/view/400000001?trackingId=fixture">Software Engineer bij Example</a>
<a href="https://outside.example.invalid/jobs/unsafe">Externe trackingbestemming</a>
<a href="https://www.linkedin.com/help/linkedin/answer/123">Help en voorkeuren</a>
<a href="http://www.linkedin.com/jobs/view/400000002">Onveilige HTTP-link</a>
</body></html>
+13
View File
@@ -0,0 +1,13 @@
From: Randstad Job Alert <alerts@randstad.be>
To: radar@example.invalid
Date: Wed, 22 Jul 2026 08:15:00 +0200
Message-ID: <sanitized-randstad-alert-001@example.invalid>
Subject: Jobs in Limburg
MIME-Version: 1.0
Content-Type: text/html; charset="utf-8"
<html><body><h1>Jobalert</h1>
<a href="https://www.randstad.be/jobs/infrastructure-engineer_hasselt_fixture-1002/">Infrastructure Engineer</a>
<a href="https://outside.example.invalid/jobs/unsafe">Externe advertentie</a>
<a href="https://www.randstad.be/my-randstad/manage-email-alerts/">Accountinstellingen</a>
</body></html>
+13
View File
@@ -0,0 +1,13 @@
From: Robert Half Job Alert <alerts@roberthalf.com>
To: radar@example.invalid
Date: Wed, 22 Jul 2026 08:15:00 +0200
Message-ID: <sanitized-roberthalf-alert-001@example.invalid>
Subject: IT and digital jobs
MIME-Version: 1.0
Content-Type: text/html; charset="utf-8"
<html><body><h1>Job recommendations</h1>
<a href="https://www.roberthalf.com/be/en/job/hasselt/network-engineer/fixture-1003">Network Engineer</a>
<a href="https://outside.example.invalid/jobs/unsafe">External advertisement</a>
<a href="https://www.roberthalf.com/be/en/find-jobs/job-alerts">Account settings</a>
</body></html>
+14
View File
@@ -0,0 +1,14 @@
From: StepStone Job Alert <alerts@stepstone.be>
To: radar@example.invalid
Date: Wed, 22 Jul 2026 08:15:00 +0200
Message-ID: <sanitized-stepstone-alert-001@example.invalid>
Subject: Nieuwe jobs voor developer
MIME-Version: 1.0
Content-Type: text/html; charset="utf-8"
<html><body>
<h1>Je dagelijkse Job Alert</h1>
<a href="https://www.stepstone.be/vacatures/software-developer/in-hasselt/fixture-1003">Software Developer</a>
<a href="https://outside.example.invalid/jobs/unsafe">Externe advertentie</a>
<a href="https://www.stepstone.be/account/settings">Accountinstellingen</a>
</body></html>
+27
View File
@@ -0,0 +1,27 @@
From: VDAB Job Alerts <vacatures@vdab.be>
To: radar@example.invalid
Date: Tue, 21 Jul 2026 05:30:00 +0200
Message-ID: <sanitized-vdab-alert-001@example.invalid>
Subject: Nieuwe jobs voor softwareontwikkeling in Limburg
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="vacatureradar-boundary"
--vacatureradar-boundary
Content-Type: text/plain; charset="utf-8"
Er zijn nieuwe vacatures voor je VDAB-jobalert.
https://www.vdab.be/vindeenjob/vacatures/70000001
https://www.vdab.be/vindeenjob/vacatures/70000002
https://www.vdab.be/jobs/job-alerts/uitschrijven
--vacatureradar-boundary
Content-Type: text/html; charset="utf-8"
<html><body>
<h1>Nieuwe vacatures voor je jobalert</h1>
<a href="https://www.vdab.be/vindeenjob/vacatures/70000001">Python developer</a>
<a href="https://www.vdab.be/vindeenjob/vacatures/70000002">Cloud engineer</a>
<a href="https://www.vdab.be/jobs/job-alerts/uitschrijven">Uitschrijven</a>
</body></html>
--vacatureradar-boundary--
+7
View File
@@ -0,0 +1,7 @@
<!doctype html><html><body><main>
<a href="/en/jobs/aws-cloud-devops-engineer">
<h3>AWS Cloud &amp; DevOps Engineer</h3>
<p>Design and maintain secure cloud infrastructure.</p>
</a>
<a href="https://evil.example/jobs/phishing"><h3>External recruiter</h3></a>
</main></body></html>
@@ -0,0 +1,5 @@
<ul>
<li><a href="https://cipalschaubroeck.teamtailor.com/jobs/7990139-network-security-engineer">Network &amp; Security Engineer</a><span>Infrastructure &amp; Security · Cipal Schaubroeck - Westerlo · Hybride</span></li>
<li><a href="https://cipalschaubroeck.teamtailor.com/jobs/7800073-payroll-consulent-nazareth">Payroll Consulent</a><span>Cipal Schaubroeck - Nazareth</span></li>
<li><a href="https://evil.example/jobs/1234-fake">Fake Engineer</a><span>Geel</span></li>
</ul>
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="nl">
<body>
<main>
<a class="col-md-6 event-item" href="https://www.cordacampus.com/job/system-engineer/">
<span class="company-title">Voorbeeld IT</span>
<span class="job-title font-h2">System Engineer</span>
<div class="bottom-info">21 juli 2026</div>
</a>
<a class="event-item" href="/job/data-engineer/">
<span class="company-title">Voorbeeld Data</span>
<span class="job-title">Data Engineer</span>
<div class="bottom-info">20 juli 2026</div>
</a>
<a class="event-item" href="https://evil.example/job/nepjob/">
<span class="company-title">Onbetrouwbaar</span>
<span class="job-title">Nepjob</span>
</a>
</main>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
<!doctype html><html><body><ul>
<li class="itemlist__item"><a class="js-vac-title" href="/nl-nl/werken-bij-daf/vacatures/technieker-facilitaire-diensten">Technieker Facilitaire Diensten</a><p class="js-vac-metalocation">Westerlo</p><p>Operations Westerlo</p></li>
<li class="itemlist__item"><a class="js-vac-title" href="/nl-nl/werken-bij-daf/vacatures/engineer-eindhoven">Engineer</a><p class="js-vac-metalocation">Eindhoven</p></li>
<li class="itemlist__item"><a class="js-vac-title" href="https://evil.example/job">Onveilig</a><p class="js-vac-metalocation">Westerlo</p></li>
</ul></body></html>
+5
View File
@@ -0,0 +1,5 @@
<main>
<div><a href="/o/system-engineer-geel">System Engineer - Geel</a><span>Hybride · Geel, Antwerpen · Infrastructuur</span></div>
<div><a href="/o/system-engineer-kontich">System Engineer - Kontich</a><span>Kontich, Antwerpen</span></div>
<div><a href="https://evil.example/o/system-engineer-geel">Fake Engineer - Geel</a></div>
</main>
+10
View File
@@ -0,0 +1,10 @@
<!doctype html><html><body><main>
<div class="vacature-card" id="Vacature_1677">
<div class="vacature-card-titel">Lector netwerkbeheer</div>
<table><tr><td class="vacature-card-td-text">PXL-Digital</td></tr>
<tr><td class="vacature-card-td-text">Campus Diepenbeek</td></tr></table>
</div>
<div class="vacature-card" id="Vacature_onveilig">
<div class="vacature-card-titel">Onveilige vacature</div>
</div>
</main></body></html>
+5
View File
@@ -0,0 +1,5 @@
<!doctype html><html><body>
<article class="matchElement" id="job_id_64383078"><a href="../job/64383078/SAP-Operations-Analyst?l=en">SAP Operations Analyst</a><span class="location-view-item">Arendonk</span><span>IT</span></article>
<article class="matchElement"><a href="../job/1/Remote-role?l=en">Remote role</a><span class="location-view-item">Budapest</span></article>
<article class="matchElement"><a href="https://evil.example/job/2/fake">Onveilig</a><span class="location-view-item">Olen</span></article>
</body></html>
+4
View File
@@ -0,0 +1,4 @@
<!doctype html><html><body><div data-overview>
<article class="s-tile s-card"><h3>Technisch commercieel verantwoordelijke</h3><p>Andere functies · Brussel, Geel, Waremme</p><a href="/nl/detail/?name=Technisch%20commercieel%20verantwoordelijke&amp;id=6">Lees meer</a></article>
<article class="s-tile s-card"><h3>Projectleider Gent</h3><p>Gent</p><a href="/nl/detail/?name=Projectleider&amp;id=10">Lees meer</a></article>
</div></body></html>
+5
View File
@@ -0,0 +1,5 @@
<!doctype html><html><body><div class="job-list">
<a data-job-id="29580793664" href="/en/job/geel/maintenance-engineer/2649/29580793664"><span class="job-title">Maintenance Engineer</span><span class="job-location">Geel, Belgium</span></a>
<a data-job-id="1" href="/en/job/diegem/trainee/2649/1"><span class="job-title">Trainee</span><span class="job-location">Diegem, Belgium</span></a>
<a data-job-id="2" href="https://evil.example/en/job/geel/fake/2649/2"><span class="job-title">Onveilig</span><span class="job-location">Geel, Belgium</span></a>
</div></body></html>
+7
View File
@@ -0,0 +1,7 @@
<main>
<article>
<h2><a href="/nl/carriere/vacatures/automatisatie-ingenieur-plc-ot-netwerken">Automatisatie Ingenieur PLC &amp; OT-netwerken</a></h2>
<p>Ontwerp en beheer industriële netwerken op de site in Mol.</p>
</article>
<article><h2><a href="https://evil.example/job">Onveilige vacature</a></h2></article>
</main>
+6
View File
@@ -0,0 +1,6 @@
<!doctype html><html><body>
<article class="vacatureKader" data-href="vacature.php?id=804">
<div class="vacTekst"><h4>Administratief medewerker digitale dienstverlening</h4><p>Onbepaalde duur</p></div>
</article>
<article class="vacatureKader" data-href="solliciteer.php"><h4>Niets gevonden?</h4></article>
</body></html>
+4
View File
@@ -0,0 +1,4 @@
<!doctype html><html><body>
<a href="vacature.php?id=803"><section class="block-update"><span class="block-update__body__title__inner">Coördinator digitale projecten</span><p>Stad Turnhout</p></section></a>
<a href="https://evil.example/vacature.php?id=999"><span class="block-update__body__title__inner">Onveilig</span></a>
</body></html>
+5
View File
@@ -0,0 +1,5 @@
<main>
<a class="jobLink" data-item="readmore" data-jobid="397276" href="?companyGuid=eab9ca13-ee10-4504-8a87-a785d0b037ef&amp;lang=nl-BE&amp;job=397276&amp;q=Een-medewerker-lokale-IT-voor-Campus-Geel">Een medewerker lokale IT - Campus Geel</a>
<a class="jobLink" data-item="readmore" data-jobid="396128" href="?companyGuid=eab9ca13-ee10-4504-8a87-a785d0b037ef&amp;lang=nl-BE&amp;job=396128">IT developer - Campus Mechelen</a>
<a class="jobLink" data-item="readmore" data-jobid="999999" href="https://evil.example/?companyGuid=eab9ca13-ee10-4504-8a87-a785d0b037ef&amp;job=999999">Fake IT - Campus Geel</a>
</main>
+9
View File
@@ -0,0 +1,9 @@
<!doctype html><html><body><main>
<section class="vacancy-item"><h3>Expert systeembeheerder (100%)</h3>
<p>Vacaturenummer: 256/ATP/999</p>
<a href="/nl/over-uhasselt/werken-bij-uhasselt/vacatures/detail/3999-expert-systeembeheerder-100">Meer info</a>
</section>
<section class="vacancy-item"><h3>Onveilige kaart</h3>
<a href="https://evil.example/vacatures/detail/1-fake">Meer info</a>
</section>
</main></body></html>
+5
View File
@@ -0,0 +1,5 @@
<main>
<section><h3 class="elementor-heading-title"><a href="https://www.vanroey.be/en/job/network-engineer-2/">Network Engineer</a></h3><p>Bouw veilige netwerken voor klanten vanuit de Kempense vestigingen.</p></section>
<section><h3 class="elementor-heading-title"><a href="https://www.vanroey.be/en/job/oost-vlaanderen-microsoft-compliance-consultant/">Oost-Vlaanderen Consultant</a></h3></section>
<section><h3 class="elementor-heading-title"><a href="https://evil.example/en/job/fake/">Fake Engineer</a></h3></section>
</main>
+8
View File
@@ -0,0 +1,8 @@
<!doctype html><html><body><main>
<div><h3><a href="/en/jobs/platform-engineer">Platform Engineer</a></h3>
<p>Locations: Hasselt, Kontich</p></div>
<div><h3><a href="/en/jobs/data-engineer">Data Engineer</a></h3>
<p>Locations: Kontich</p></div>
<div><h3><a href="https://evil.example/jobs/fake">Fake</a></h3>
<p>Locations: Hasselt</p></div>
</main></body></html>
+11
View File
@@ -0,0 +1,11 @@
<!doctype html><html><body>
<article class="vacature-tegel">
<h5 class="vacature-tegel__titel">ICT Support Engineer</h5>
<p>Ondersteunende en technische diensten · 100%</p>
<a class="vacature-tegel__link" href="/nl/vacature/201/ict-support-engineer">Lees meer</a>
</article>
<article class="vacature-tegel">
<h5 class="vacature-tegel__titel">Onveilig</h5>
<a class="vacature-tegel__link" href="https://evil.example/job">Lees meer</a>
</article>
</body></html>
+8
View File
@@ -0,0 +1,8 @@
<!doctype html><html><body><main>
<a data-item="readmore" data-jobid="352801" href="?lang=nl-BE&amp;job=352801&amp;q=Senior-IT-Engineer-Infrastructuur">
<div class="job-title">(Senior) IT Engineer Infrastructuur</div>
</a>
<a data-item="readmore" data-jobid="999" href="https://evil.example/?job=999">
<div class="job-title">Onveilige vacature</div>
</a>
</main></body></html>
+2
View File
@@ -11,6 +11,7 @@ dependencies = [
"celery[redis]==5.6.3",
"psycopg[binary]==3.3.4",
"httpx==0.28.1",
"certifi==2026.6.17",
"beautifulsoup4==4.15.0",
"bleach==6.4.0",
"lxml==6.1.1",
@@ -20,6 +21,7 @@ dependencies = [
"defusedxml==0.7.1",
"gunicorn==26.0.0",
"whitenoise==6.11.0",
"cryptography==49.0.0",
]
[project.optional-dependencies]
+4 -1
View File
@@ -16,7 +16,10 @@ CELERY_TASK_ALWAYS_EAGER=1
VACATURERADAR_ADMIN_USERNAME=admin
VACATURERADAR_ADMIN_PASSWORD=codex-local-only
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
IMAP_ENABLED=0
MAILBOX_CREDENTIAL_KEYS=
IMAP_CONNECT_TIMEOUT_SECONDS=15
IMAP_MAX_MESSAGES_PER_POLL=200
IMAP_MAX_MESSAGE_BYTES=1000000
SOURCE_POLICY_MODE=strict
OLLAMA_ENABLED=0
ENV
+23 -6
View File
@@ -3,10 +3,27 @@ set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p local
uv run ruff check .
uv run python manage.py check
uv run python manage.py makemigrations --check --dry-run
uv run pytest --cov=apps --cov=config --cov-report=term-missing
python scripts/validate_task_ledger.py
python scripts/validate_repository.py
if command -v uv >/dev/null 2>&1; then
PYTHON=(uv run python)
RUFF=(uv run ruff)
PYTEST=(uv run pytest)
elif [[ -x .venv/Scripts/python.exe ]]; then
PYTHON=(.venv/Scripts/python.exe)
RUFF=(.venv/Scripts/python.exe -m ruff)
PYTEST=(.venv/Scripts/python.exe -m pytest)
elif [[ -x .venv/bin/python ]]; then
PYTHON=(.venv/bin/python)
RUFF=(.venv/bin/python -m ruff)
PYTEST=(.venv/bin/python -m pytest)
else
printf 'Geen uv of project-venv gevonden.\n' >&2
exit 1
fi
"${RUFF[@]}" check .
"${PYTHON[@]}" manage.py check
"${PYTHON[@]}" manage.py makemigrations --check --dry-run
"${PYTEST[@]}" --cov=apps --cov=config --cov-report=term-missing
"${PYTHON[@]}" scripts/validate_task_ledger.py
"${PYTHON[@]}" scripts/validate_repository.py
printf '\nAlle kwaliteitsgates geslaagd.\n'

Some files were not shown because too many files have changed in this diff Show More