SPB Git forge

spb/job-ka

Public
229commits 1branches 0releases
38.1 MBsize
maindefault branch
4 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%
7.5 KB · 204 lines python
Raw Blame History
1# =============================================================================2# Job·Ka — Groupe KA3# Auteur  : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : jobka/dedup.py6# Rôle    : Déduplication inter-sources — blocage par (titre, employeur, ville)7#           + union-find, offres canoniques par autorité de la source8# Créé    : 2026-08-17   Modifié : 2026-08-179# =============================================================================10"""Déduplication des offres publiées par plusieurs sources.1112Même squelette que Lou·Ka (clé exacte + union-find, AUCUN fuzzy matching) :131. blocage par clé « titre normalisé | employeur normalisé | ville » ;142. confirmation de paire : clé identique obligatoire, refus sur signal15   contradictoire (type d'emploi différent, salaires connus s'écartant de16   plus de SALARY_TOL), et exigence d'un second signal concordant ;173. union-find avec contrainte : jamais deux offres de la même source dans18   une composante (et jamais de dédup intra-source) ;194. canonique = source la plus autoritaire (page carrière de l'employeur20   avant agrégat), puis richesse du contenu. Les doublons sont MASQUÉS21   (dup_of), jamais supprimés. Recalcul intégral idempotent à chaque passe.22"""23from __future__ import annotations2425import json26import re27import sqlite32829from . import db30from .normalize import strip_accents3132SALARY_TOL = 0.04      # ±4 % d'écart toléré entre salaires « identiques »33MAX_BLOCK = 200        # bloc anormalement gros = clé trop générique, ignorer3435# Sources agrégatrices (guichets, portails) — moins autoritaires que la page36# carrière de l'employeur : en cas de doublon, l'offre DIRECTE gagne la37# canonique et la copie portail est masquée.38AGGREGATORS: set[str] = {"guichet_emplois", "jobillico", "espresso_jobs"}3940# Mots de bruit RH retirés du titre pour le blocage (bilinguisme, urgence…)41_TITLE_NOISE = re.compile(42    r"\b(h/f|f/h|m/f|f/m|h-f|\(h/f\)|bilingue|bilingual|urgent|nouveau|new|"43    r"junior|senior|sr|jr|intermediaire|intermediate|principal[e]?|lead|"44    r"temps plein|temps partiel|full[- ]?time|part[- ]?time|permanent[e]?|"45    r"contractuel(?:le)?|stage|stagiaire|remote|teletravail|hybride|hybrid)\b",46    re.I)474849def _norm(s: str) -> str:50    s = strip_accents((s or "").lower())51    s = re.sub(r"[^a-z0-9]+", " ", s)52    return re.sub(r"\s+", " ", s).strip()535455def _block_key(title: str, employer: str, city: str) -> str:56    t = _TITLE_NOISE.sub(" ", strip_accents((title or "").lower()))57    t = re.sub(r"[^a-z0-9]+", " ", t)58    t = re.sub(r"\s+", " ", t).strip()59    e = _norm(employer)60    c = _norm(city)61    if not t or not e:62        return ""     # sans titre ou employeur, pas de blocage fiable63    return f"{t}|{e}|{c}"646566def _salary_close(a_min, a_max, b_min, b_max) -> bool | None:67    """True/False si comparables, None si l'un des deux est inconnu."""68    if a_min is None or b_min is None:69        return None70    a, b = float(a_min), float(b_min)71    if max(a, b) == 0:72        return None73    if abs(a - b) / max(a, b) > SALARY_TOL:74        return False75    if a_max is not None and b_max is not None:76        a2, b2 = float(a_max), float(b_max)77        if max(a2, b2) and abs(a2 - b2) / max(a2, b2) > SALARY_TOL:78            return False79    return True808182def _same_posting(a: sqlite3.Row, b: sqlite3.Row) -> bool:83    """Deux offres de la même clé de blocage sont-elles le même poste ?8485    Clé identique obligatoire (déjà garanti par le blocage). Ensuite :86    refus sur signal contradictoire, puis exigence d'un second signal87    concordant — la clé seule ne suffit pas (deux affichages distincts du88    même titre chez le même employeur restent deux postes).89    """90    # signaux contradictoires -> refus91    ta, tb = a["employment_type"], b["employment_type"]92    if ta and tb and ta != tb:93        return False94    sal = _salary_close(a["salary_year_min"], a["salary_year_max"],95                        b["salary_year_min"], b["salary_year_max"])96    if sal is False:97        return False9899    # second signal concordant requis100    if sal is True:101        return True102    if ta and tb and ta == tb:103        return True104    da, db_ = a["date_posted"], b["date_posted"]105    if da and db_:106        try:107            import datetime as _dt108            delta = abs((_dt.date.fromisoformat(da) - _dt.date.fromisoformat(db_)).days)109            if delta <= 7:110                return True111        except ValueError:112            pass113    pa, pb = a["postal_code"], b["postal_code"]114    if pa and pb and pa == pb:115        return True116    return False117118119def _authority(source: str) -> int:120    """0 = page carrière directe (le plus autoritaire), 10 = agrégateur."""121    return 10 if source in AGGREGATORS else 0122123124def run(con: sqlite3.Connection | None = None) -> dict:125    """Recalcule dup_of / dup_sources pour toutes les offres actives."""126    own = con is None127    if own:128        con = db.connect()129130    rows = con.execute(131        """SELECT uid, source, title, employer, city, employment_type,132                  salary_year_min, salary_year_max, date_posted, postal_code,133                  LENGTH(description) desc_len134           FROM jobs WHERE active=1""").fetchall()135136    by_key: dict[str, list[int]] = {}137    for i, r in enumerate(rows):138        k = _block_key(r["title"], r["employer"], r["city"])139        if k:140            by_key.setdefault(k, []).append(i)141142    # union-find avec contrainte anti-transitive : une composante ne peut143    # contenir deux offres de la même source144    parent = list(range(len(rows)))145    srcset: dict[int, set[str]] = {i: {rows[i]["source"]} for i in range(len(rows))}146147    def find(i: int) -> int:148        while parent[i] != i:149            parent[i] = parent[parent[i]]150            i = parent[i]151        return i152153    def union(i: int, j: int) -> bool:154        ri, rj = find(i), find(j)155        if ri == rj:156            return True157        if srcset[ri] & srcset[rj]:158            return False159        parent[rj] = ri160        srcset[ri] |= srcset.pop(rj)161        return True162163    for key, members in by_key.items():164        if len(members) < 2 or len(members) > MAX_BLOCK:165            continue166        for x in range(len(members)):167            for y in range(x + 1, len(members)):168                i, j = members[x], members[y]169                if rows[i]["source"] == rows[j]["source"]:170                    continue   # jamais de dédup intra-source171                if _same_posting(rows[i], rows[j]):172                    union(i, j)173174    # résolution : canonique par (autorité, richesse, uid)175    groups: dict[int, list[int]] = {}176    for i in range(len(rows)):177        groups.setdefault(find(i), []).append(i)178179    n_groups = hidden = 0180    con.execute("UPDATE jobs SET dup_of=NULL, dup_sources=NULL")181    for members in groups.values():182        if len(members) < 2:183            continue184        n_groups += 1185        members.sort(key=lambda i: (_authority(rows[i]["source"]),186                                    -(rows[i]["desc_len"] or 0), rows[i]["uid"]))187        canon = rows[members[0]]["uid"]188        others = [rows[i]["source"] for i in members[1:]]189        con.execute("UPDATE jobs SET dup_sources=? WHERE uid=?",190                    (json.dumps(sorted(set(others)), ensure_ascii=False), canon))191        for i in members[1:]:192            con.execute("UPDATE jobs SET dup_of=? WHERE uid=?",193                        (canon, rows[i]["uid"]))194            hidden += 1195    con.commit()196    if own:197        con.close()198    return {"groups": n_groups, "duplicates_hidden": hidden,199            "jobs_scanned": len(rows)}200201202if __name__ == "__main__":203    print(run())204