# =============================================================================
# Job·Ka — Groupe KA
# Auteur : Simon-Pierre Boucher
# Contact : contact@spboucher.ai
# Fichier : jobka/connectors/wpjobmanager.py
# Rôle : Classe de plateforme WordPress + WP Job Manager — flux job_feed
# (contenu complet) + JSON-LD des pages poste (lieu, date limite).
# Utilisée par la plateforme « carrieresante.gouv.qc.ca » du réseau
# de la santé (un site WordPress par établissement Santé Québec).
# Créé : 2026-08-19 Modifié : 2026-08-19
# =============================================================================
"""Plateforme WordPress / WP Job Manager.
- liste : GET /?feed=job_feed&posts_per_page=500
-> RSS : - title / link (/poste//) / pubDate /
guid (?post_type=job_listing&p=) / content:encoded (HTML
complet de l'offre) — tout en une requête.
- détail : la page /poste// embarque un JSON-LD schema.org/JobPosting
(validThrough, jobLocation, employmentType) — cache BD + budget,
uniquement pour compléter lieu et date limite.
"""
from __future__ import annotations
import html as _html
import os
import re
from ..schema import JobPosting, clean_html
from . import _jsonld
from .base import BaseConnector
MAX_DETAILS = int(os.environ.get("JOBKA_WPJM_DETAIL_LIMIT", "60"))
_ITEM_RE = re.compile(r"
- (.*?)
", re.S | re.I)
_TAG_RE = {t: re.compile(rf"<{t}>(.*?){t}>", re.S | re.I)
for t in ("title", "link", "pubDate", "guid")}
_CONTENT_RE = re.compile(
r"", re.S | re.I)
_PID_RE = re.compile(r"[?&](?:amp;)?p=(\d+)")
class WPJobManagerConnector(BaseConnector):
"""Base WP Job Manager — sous-classes : définir source_id, EMPLOYER,
BASE (racine du site WordPress) et DEFAULT_CITY au besoin."""
ats = "wpjobmanager"
request_delay = 1.0
EMPLOYER = ""
BASE = ""
DEFAULT_CITY = ""
PER_PAGE = 500
def _cdata(self, s: str) -> str:
s = (s or "").strip()
if s.startswith(""):
s = s[:-3]
return _html.unescape(s.strip())
def _fetch_detail(self, url: str) -> dict:
html = self.get(url).text
node = _jsonld.extract_jobposting(html)
return _jsonld.jobposting_fields(node) if node else {}
def fetch(self) -> list[JobPosting]:
xml = self.get(f"{self.BASE}/",
params={"feed": "job_feed",
"posts_per_page": str(self.PER_PAGE)}).text
out: list[JobPosting] = []
details_used = 0
seen: set[str] = set()
for raw in _ITEM_RE.findall(xml):
def g(tag: str, raw=raw) -> str:
m = _TAG_RE[tag].search(raw)
return self._cdata(m.group(1)) if m else ""
link, guid = g("link"), g("guid")
m_id = _PID_RE.search(_html.unescape(guid))
eid = m_id.group(1) if m_id else ""
title = g("title")
if not link or not title:
continue
if not eid:
m_slug = re.search(r"/poste/([^/]+)/?$", link)
eid = m_slug.group(1) if m_slug else ""
if not eid or eid in seen:
continue
seen.add(eid)
m_c = _CONTENT_RE.search(raw)
job = JobPosting(
source=self.source_id, external_id=eid, url=link,
employer=self.EMPLOYER, title=title,
description=clean_html(_html.unescape(m_c.group(1)))
if m_c else "",
city=self.DEFAULT_CITY,
date_posted=g("pubDate") or None,
ats=self.ats,
)
key = f"{title}|{job.date_posted or ''}"
if details_used < MAX_DETAILS:
fresh = [False]
def _fn(u=link, fresh=fresh):
fresh[0] = True
return self._fetch_detail(u)
fields = self.detail(eid, key, _fn)
if fresh[0]:
details_used += 1
else:
fields = self.stale_detail(eid)
if fields:
if fields.get("date_deadline"):
job.date_deadline = fields["date_deadline"]
if fields.get("city"):
job.city = fields["city"]
if fields.get("postal_code"):
job.postal_code = fields["postal_code"]
if fields.get("employment_label"):
job.details["employment_label"] = fields["employment_label"]
if fields.get("lat") is not None:
job.lat, job.lng = fields["lat"], fields["lng"]
out.append(job)
return out