@@ -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)
|
||||
|
||||
@@ -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 Hasselt–Genk 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])],
|
||||
)
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
)
|
||||
@@ -19,6 +19,10 @@ DEFAULT_DENYLIST = {
|
||||
"stepstone.be",
|
||||
"jobat.be",
|
||||
"vdab.be",
|
||||
"ictjob.be",
|
||||
"careerjet.be",
|
||||
"randstad.be",
|
||||
"roberthalf.com",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
@@ -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")
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 72 KiB |
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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--
|
||||
@@ -0,0 +1,7 @@
|
||||
<!doctype html><html><body><main>
|
||||
<a href="/en/jobs/aws-cloud-devops-engineer">
|
||||
<h3>AWS Cloud & 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 & Security Engineer</a><span>Infrastructure & 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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&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&id=10">Lees meer</a></article>
|
||||
</div></body></html>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,7 @@
|
||||
<main>
|
||||
<article>
|
||||
<h2><a href="/nl/carriere/vacatures/automatisatie-ingenieur-plc-ot-netwerken">Automatisatie Ingenieur PLC & 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
<main>
|
||||
<a class="jobLink" data-item="readmore" data-jobid="397276" href="?companyGuid=eab9ca13-ee10-4504-8a87-a785d0b037ef&lang=nl-BE&job=397276&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&lang=nl-BE&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&job=999999">Fake IT - Campus Geel</a>
|
||||
</main>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,8 @@
|
||||
<!doctype html><html><body><main>
|
||||
<a data-item="readmore" data-jobid="352801" href="?lang=nl-BE&job=352801&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>
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||