HTML 82.1%
Python 14.6%
TypeScript 1.9%
CSS 1%
JavaScript 0.5%
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