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>
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 | |