SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%

Dédup : priorité courtier + box « Aussi publiée sur… » sur la fiche

À autorité égale, une fiche avec courtier/agence bat désormais une fiche
anonyme (fb_marketplace rejoint les petites annonces). Nouvelle colonne
dup_of persistée par les 3 passes de dédup ; l'API expose les doublons de
la fiche visible et la page fiche les liste avec mention et liens.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 25, 2026) parent 2a0d7b3

5 changed files +81 −15

modified frontend/src/api.ts +10 −0
@@ -49,6 +49,7 @@ export interface Listing {
49 49 lat: number | null;
50 50 lng: number | null;
51 51 price_history?: { ts: number; price: number | null }[];
52 + duplicates?: DuplicateListing[]; // autres publications de la même propriété
52 53 poi?: Poi[]; // commodités de proximité (fiche seulement)
53 54 quartier?: Quartier | null; // stats de quartier (fiche seulement)
54 55 vraiprix?: VraiPrix | null; // estimation de valeur marchande (Vrai-Prix)
@@ -58,6 +59,15 @@ export interface Listing {
58 59 active?: number;
59 60 }
60 61
62 +export interface DuplicateListing {
63 + uid: string;
64 + source: string;
65 + url: string;
66 + broker_name: string;
67 + agency: string;
68 + price_label: string;
69 +}
70 +
61 71 export interface Facets {
62 72 cities: string[];
63 73 sectors: string[];
modified frontend/src/pages/Listing.tsx +25 −1
@@ -200,9 +200,11 @@ export default function ListingPage() {
200 200 const { uid } = useParams<{ uid: string }>();
201 201 const [l, setL] = useState<Listing | null>(null);
202 202 const [error, setError] = useState<string | null>(null);
203 + // re-render quand les noms de sources arrivent (sinon repli Title Case)
204 + const [, setSrcTick] = useState(0);
203 205
204 206 useEffect(() => {
205 − fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
207 + fetchSources().then((r) => { registerSourceNames(r.sources); setSrcTick(1); }).catch(() => {});
206 208 if (!uid) return;
207 209 setL(null); setError(null);
208 210 fetchListing(uid).then(setL).catch((e) => setError(String(e)));
@@ -396,6 +398,28 @@ export default function ListingPage() {
396 398 </div>
397 399 </section>
398 400
401 + {l.duplicates && l.duplicates.length > 0 && (
402 + <section className="f-bloc" id="publications">
403 + <h2>Aussi publiée sur</h2>
404 + <p className="dups-note">
405 + Cette propriété a été repérée sur {l.duplicates.length}{" "}
406 + autre{l.duplicates.length > 1 ? "s" : ""} plateforme{l.duplicates.length > 1 ? "s" : ""} —
407 + Immo-Ka affiche la version la plus complète.
408 + </p>
409 + <div className="dups-list">
410 + {l.duplicates.map((d) => (
411 + <a key={d.uid} className="dup-item" href={d.url} target="_blank" rel="noopener noreferrer">
412 + <span className="dup-src">{sourceName(d.source)}</span>
413 + {(d.broker_name || d.agency) && (
414 + <span className="dup-broker">{d.broker_name || d.agency}</span>
415 + )}
416 + <span className="dup-go">Voir l'annonce <Ico name="external" size={13} /></span>
417 + </a>
418 + ))}
419 + </div>
420 + </section>
421 + )}
422 +
399 423
400 424 {l.description && (
401 425 <section className="f-bloc f-desc" id="description">
modified frontend/src/styles.css +10 −0
@@ -294,6 +294,16 @@ table.rooms tr:nth-child(even) td { background: var(--surface-2); }
294 294 .f-foot { margin-top: 26px; }
295 295 .fine { font-size: 12px; color: var(--ink-3); }
296 296
297 +/* Box « Aussi publiée sur… » : autres publications de la même propriété */
298 +.dups-note { font-size: 13px; color: var(--ink-3); margin: 4px 0 10px; }
299 +.dups-list { display: flex; flex-direction: column; gap: 8px; }
300 +.dup-item { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 11px 13px; border: 1.5px solid var(--line-strong); border-radius: var(--r-ctl); background: var(--surface-2); transition: all 0.14s ease; min-height: 44px; }
301 +.dup-item:hover { border-color: var(--ink); box-shadow: 3px 3px 0 rgba(26, 18, 20, 0.18); }
302 +.dup-src { font-family: var(--font-display); font-weight: 700; font-size: 14px; }
303 +.dup-broker { font-size: 12.5px; color: var(--ink-3); }
304 +.dup-go { margin-left: auto; font-size: 12.5px; font-weight: 600; color: var(--green-deep); border-bottom: 1.5px solid var(--lime); white-space: nowrap; }
305 +.dup-go .ico { vertical-align: -2px; }
306 +
297 307 /* ================= Sources / Agences ================= */
298 308 .sources { padding: 44px 0 90px; }
299 309 .sources h1 { font-size: clamp(28px, 4vw, 40px); text-transform: uppercase; margin: 10px 0 6px; }
modified immoka/db.py +30 −14
@@ -135,6 +135,8 @@ def _init_schema(con: sqlite3.Connection) -> None:
135 135 con.execute("ALTER TABLE listings ADD COLUMN agency TEXT")
136 136 if "dup_hidden" not in cols:
137 137 con.execute("ALTER TABLE listings ADD COLUMN dup_hidden INTEGER DEFAULT 0")
138 + if "dup_of" not in cols: # uid de la fiche visible au profit de laquelle
139 + con.execute("ALTER TABLE listings ADD COLUMN dup_of TEXT") # celle-ci est masquée
138 140 if "dauid" not in cols: # aire de diffusion 2021 (stats de quartier)
139 141 con.execute("ALTER TABLE listings ADD COLUMN dauid TEXT")
140 142 if "vraiprix" not in cols: # estimation Vrai-Prix (JSON) + lien analyse
@@ -143,6 +145,7 @@ def _init_schema(con: sqlite3.Connection) -> None:
143 145 if "muni" not in gcols: # municipalité officielle (entrées « ville:… »)
144 146 con.execute("ALTER TABLE geocode_cache ADD COLUMN muni TEXT")
145 147 con.execute("CREATE INDEX IF NOT EXISTS idx_listings_duphidden ON listings(dup_hidden)")
148 + con.execute("CREATE INDEX IF NOT EXISTS idx_listings_dupof ON listings(dup_of)")
146 149 con.execute("CREATE INDEX IF NOT EXISTS idx_listings_geo ON listings(lat, lng)")
147 150 con.commit()
148 151
@@ -161,17 +164,24 @@ def refresh_dedup(con: sqlite3.Connection) -> int:
161 164 Retourne le nombre total de fiches masquées."""
162 165 AG = "source LIKE '%\\_ag\\_%' ESCAPE '\\'"
163 166 NOTAG = "source NOT LIKE '%\\_ag\\_%' ESCAPE '\\'"
164 − con.execute("UPDATE listings SET dup_hidden=0")
167 + con.execute("UPDATE listings SET dup_hidden=0, dup_of=NULL")
165 168 # 1) masquer les sous-agences dont le n° Centris est porté par une fiche
166 169 # canonique (non sous-agence) active — semi-jointure, rapide.
170 + # dup_of = la fiche canonique (pour la box « Aussi publiée sur… »).
167 171 con.execute(
168 − f"UPDATE listings SET dup_hidden=1 WHERE active=1 AND {AG}"
172 + f"UPDATE listings SET dup_hidden=1,"
173 + f" dup_of=(SELECT MIN(d.uid) FROM listings d WHERE d.active=1"
174 + f" AND d.external_id=listings.external_id AND d.{NOTAG})"
175 + f" WHERE active=1 AND {AG}"
169 176 f" AND external_id IN (SELECT external_id FROM listings"
170 177 f" WHERE active=1 AND {NOTAG})")
171 178 # 2) parmi les sous-agences restantes (sans canonique), ne garder que le plus
172 179 # petit uid par n° Centris.
173 180 con.execute(
174 − f"UPDATE listings SET dup_hidden=1 WHERE active=1 AND {AG} AND dup_hidden=0"
181 + f"UPDATE listings SET dup_hidden=1,"
182 + f" dup_of=(SELECT MIN(d.uid) FROM listings d WHERE d.active=1"
183 + f" AND d.external_id=listings.external_id AND d.{AG} AND d.dup_hidden=0)"
184 + f" WHERE active=1 AND {AG} AND dup_hidden=0"
175 185 f" AND uid > (SELECT MIN(d.uid) FROM listings d WHERE d.active=1"
176 186 f" AND d.external_id=listings.external_id AND d.{AG} AND d.dup_hidden=0)")
177 187 # 3) dédup INTER-SOURCES par adresse : la même propriété publiée sur deux
@@ -191,8 +201,9 @@ def refresh_dedup(con: sqlite3.Connection) -> int:
191 201
192 202
193 203 # petites annonces généralistes (republication d'annonces d'ailleurs) : moins
194 −# autoritaires que la source primaire (courtier / FSBO première main)
195 −_PETITES_ANNONCES = {"kijiji", "lespac"}
204 +# autoritaires que la source primaire (courtier / FSBO première main).
205 +# fb_marketplace : fiches anonymes/republication — jamais préférées à un courtier.
206 +_PETITES_ANNONCES = {"kijiji", "lespac", "fb_marketplace"}
196 207
197 208
198 209 def _source_rank(source: str) -> int:
@@ -217,12 +228,15 @@ def dedup_by_address(con: sqlite3.Connection) -> int:
217 228 doublons ; si une même source apparaît deux fois dans le groupe (probables
218 229 unités jumelles d'un projet neuf), le groupe ENTIER est ignoré. On garde
219 230 la fiche la plus autoritaire (source primaire > sous-agence _ag_ >
220 − petites annonces), puis le plus petit uid. Retourne le nb masqué."""
231 + petites annonces), puis à autorité égale celle qui a un COURTIER/agence
232 + (jamais une fiche anonyme devant un courtier), puis le plus petit uid.
233 + Retourne le nb masqué."""
221 234 import re as _re
222 235 from .vraiprix_local import _addr_parts, _norm, _muni_norm, _APP_RE
223 236 groups: dict[tuple, list] = {}
224 237 for r in con.execute(
225 − "SELECT uid, source, address, city, price, property_type"
238 + "SELECT uid, source, address, city, price, property_type,"
239 + " broker_name, agency"
226 240 " FROM listings WHERE active=1 AND dup_hidden=0 AND address<>''"
227 241 " AND city<>'' AND price IS NOT NULL AND property_type<>''"):
228 242 civs, words = _addr_parts(r["address"])
@@ -237,8 +251,9 @@ def dedup_by_address(con: sqlite3.Connection) -> int:
237 251 apt = toks[-1] if toks else ""
238 252 key = ("-".join(civs), " ".join(sorted(set(words))),
239 253 _muni_norm(r["city"]), _norm(r["property_type"]), apt)
254 + anonyme = 0 if (r["broker_name"] or r["agency"]) else 1
240 255 groups.setdefault(key, []).append(
241 − (r["price"], _source_rank(r["source"]), r["uid"], r["source"]))
256 + (r["price"], _source_rank(r["source"]), anonyme, r["uid"], r["source"]))
242 257 hidden = 0
243 258 for rows in groups.values():
244 259 if len(rows) < 2:
@@ -261,19 +276,20 @@ def _mask_cluster(con: sqlite3.Connection, cluster: list) -> int:
261 276 riche, superficies, année, GPS, téléphone) — le meilleur des deux sources."""
262 277 if len(cluster) < 2:
263 278 return 0
264 − sources = [c[3] for c in cluster]
279 + sources = [c[4] for c in cluster]
265 280 if len(set(sources)) != len(sources):
266 281 return 0 # même source en double = probables unités distinctes : prudence
267 − keep = min(cluster, key=lambda c: (c[1], c[2])) # (autorité, uid)
282 + keep = min(cluster, key=lambda c: (c[1], c[2], c[3])) # (autorité, anonyme, uid)
268 283 n = 0
269 284 donors = []
270 285 for c in cluster:
271 − if c[2] != keep[2]:
272 − con.execute("UPDATE listings SET dup_hidden=1 WHERE uid=?", (c[2],))
273 − donors.append(c[2])
286 + if c[3] != keep[3]:
287 + con.execute("UPDATE listings SET dup_hidden=1, dup_of=? WHERE uid=?",
288 + (keep[3], c[3]))
289 + donors.append(c[3])
274 290 n += 1
275 291 try:
276 − _merge_golden(con, keep[2], donors)
292 + _merge_golden(con, keep[3], donors)
277 293 except Exception:
278 294 pass # la fusion est un bonus — ne jamais casser la dédup
279 295 return n
modified immoka/web.py +6 −0
@@ -212,6 +212,12 @@ def get_listing(uid: str):
212 212 d["poi"] = json.loads(poi_row["pois"]) if poi_row else []
213 213 else:
214 214 d["poi"] = []
215 + # autres publications de la même propriété (doublons masqués par la
216 + # dédup, rattachés via dup_of) — pour la box « Aussi publiée sur… »
217 + d["duplicates"] = [dict(r) for r in con.execute(
218 + "SELECT uid, source, url, broker_name, agency, price_label"
219 + " FROM listings WHERE dup_of=? AND active=1 ORDER BY source",
220 + (uid,)).fetchall()]
215 221 # statistiques de quartier (recensement, proximité, chaleur, criminalité)
216 222 from . import quartier
217 223 dauid = d.get("dauid")
218 224