Swift 100%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// VraiPrixService.swift — l'estimation VRAI-PRIX (www.vrai-prix.com, modèle3// hédonique du Groupe Ka) dans le mode Découvrir. Volontairement frugal :4// UNE seule requête légère (/api/nearby, boîte ~60 m, JSON ~2 Ko) par carte5// affichée — jamais pour la file ni les marqueurs — avec cache mémoire par6// annonce (résultats vides compris) et échec silencieux. Aucun overload.7import Foundation8import CoreLocation910/// Estimation Vrai-Prix appariée à une annonce Lou-Ka / Immo-Ka.11struct VraiPrixEstimate {12 let value: Int // est. 2026 ($) du modèle hédonique Vrai-Prix13 let sameAddress: Bool // le numéro civique correspond (même immeuble)14 let unitConfirmed: Bool // l'unité précise est confirmée (unité unique ou apt apparié)1516 /// Montant à la québécoise : « 452 000 $ » ou « 1,17 M$ ».17 var label: String {18 if value >= 1_000_000 {19 return String(format: "%.2f", Double(value) / 1_000_000)20 .replacingOccurrences(of: ".", with: ",") + " M$"21 }22 let rounded = (value + 500) / 1000 * 100023 return rounded.formatted(.number.locale(Locale(identifier: "fr_CA"))) + " $"24 }2526 /// Écart % du prix affiché vs l'estimation. Seulement quand l'unité est27 /// confirmée (jamais sur une médiane d'immeuble ni un voisinage), ventes28 /// seulement (un loyer mensuel passe sous le plancher), et borné — un29 /// écart énorme trahit un mauvais appariement (stationnement, etc.).30 func deltaPct(asking: Double?) -> Int? {31 guard unitConfirmed, let asking, asking >= 25_000 else { return nil }32 let pct = (asking - Double(value)) / Double(value) * 10033 guard abs(pct) <= 60 else { return nil }34 return Int(pct.rounded())35 }36}3738actor VraiPrixService {39 static let shared = VraiPrixService()40 private var cache: [String: VraiPrixEstimate?] = [:]4142 func estimate(for poi: POIItem) async -> VraiPrixEstimate? {43 if let hit = cache[poi.id] { return hit }44 let r = await fetch(poi)45 cache[poi.id] = r46 return r47 }4849 private func fetch(_ poi: POIItem) async -> VraiPrixEstimate? {50 var comps = URLComponents(string: "https://www.vrai-prix.com/api/nearby")!51 comps.queryItems = [52 .init(name: "lat", value: "\(poi.coordinate.latitude)"),53 .init(name: "lng", value: "\(poi.coordinate.longitude)"),54 .init(name: "halfLat", value: "0.0005"), // ≈ 55 m55 .init(name: "halfLng", value: "0.0007"),56 .init(name: "limit", value: "20"),57 ]58 var req = URLRequest(url: comps.url!)59 req.timeoutInterval = 660 req.setValue("KaTrajet/1.0 (+https://www.groupe-ka.com)", forHTTPHeaderField: "User-Agent")61 guard let (data, _) = try? await URLSession.shared.data(for: req),62 let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],63 let results = root["results"] as? [[String: Any]] else { return nil }6465 struct Candidate {66 let value: Int, civic: Bool, apt: String, meters: Double, terrain: Bool67 }68 let civic = Self.civicNumber(in: poi.address)69 let streetTokens = Self.streetTokens(poi.address)70 let aptTokens = Self.aptTokens(in: poi.address, civic: civic)7172 let units: [Candidate] = results.compactMap { u in73 guard let est = u["est2026"] as? Double, est > 0,74 let lat = u["lat"] as? Double, let lng = u["lng"] as? Double,75 let adresse = u["adresse"] as? String else { return nil }76 // une rue clairement différente disqualifie l'unité, même toute proche77 let uTokens = Self.streetTokens(adresse)78 if !streetTokens.isEmpty, !uTokens.isEmpty, uTokens.isDisjoint(with: streetTokens) {79 return nil80 }81 let range = Self.civicRange(in: adresse)82 return Candidate(83 value: Int(est),84 civic: civic.flatMap { c in range.map { $0.contains(c) } } ?? false,85 apt: Self.normalized(u["apt"] as? String ?? ""),86 meters: CLLocation(latitude: lat, longitude: lng)87 .distance(from: CLLocation(latitude: poi.coordinate.latitude,88 longitude: poi.coordinate.longitude)),89 terrain: (u["typeProp"] as? String) == "terrain")90 }9192 let sameCivic = units.filter(\.civic)93 if let only = sameCivic.first, sameCivic.count == 1 {94 return VraiPrixEstimate(value: only.value, sameAddress: true, unitConfirmed: true)95 }96 if !sameCivic.isEmpty {97 // plusieurs unités au même civique (condos) : l'apt apparié confirme98 // l'unité ; sinon, médiane de l'immeuble sans écart de prix99 if !aptTokens.isEmpty,100 let am = sameCivic.first(where: { !$0.apt.isEmpty && aptTokens.contains($0.apt) }) {101 return VraiPrixEstimate(value: am.value, sameAddress: true, unitConfirmed: true)102 }103 let sorted = sameCivic.map(\.value).sorted()104 return VraiPrixEstimate(value: sorted[sorted.count / 2],105 sameAddress: true, unitConfirmed: false)106 }107 // repli : unité compatible la plus proche (≤ 30 m, pas un terrain)108 return units.filter { !$0.terrain && $0.meters <= 30 }109 .min { $0.meters < $1.meters }110 .map { VraiPrixEstimate(value: $0.value, sameAddress: false, unitConfirmed: false) }111 }112113 // MARK: appariement d'adresses114115 /// Numéro civique d'une annonce : dernier nombre du premier groupe116 /// (les annonces QC écrivent souvent « apt-civique Rue », ex. « 311-1225117 /// Rue Notre-Dame » → 1225 ; « SS2-224-98 Rue Charlotte » → 98).118 private static func civicNumber(in address: String) -> Int? {119 guard let m = address.firstMatch(of: #/(?:\w{1,4}\s*-\s*)*(\d{1,6})/#) else { return nil }120 return Int(m.output.1)121 }122123 /// Candidats « appartement » d'une annonce : courts jetons avec chiffre124 /// (« #14 », « App S3 », « 311- ») autres que le civique lui-même.125 private static func aptTokens(in address: String, civic: Int?) -> Set<String> {126 let civicStr = civic.map(String.init)127 return Set(address.uppercased()128 .components(separatedBy: CharacterSet.alphanumerics.inverted)129 .filter { t in130 t.count <= 5 && !t.isEmpty && t.contains(where: \.isNumber) && t != civicStr131 })132 }133134 /// Apt d'une unité d'évaluation, réduit à l'alphanumérique majuscule.135 private static func normalized(_ apt: String) -> String {136 String(apt.uppercased().unicodeScalars.filter(CharacterSet.alphanumerics.contains))137 }138139 /// « 1152-1154 Boulevard X » → 1152...1154 ; « 1163 Rue CLARK » → 1163...1163140 private static func civicRange(in address: String) -> ClosedRange<Int>? {141 guard let m = address.firstMatch(of: #/(\d{1,6})(?:\s*-\s*(\d{1,6}))?/#),142 let lo = Int(m.output.1) else { return nil }143 let hi = m.output.2.flatMap { Int($0) } ?? lo144 return min(lo, hi)...max(lo, hi)145 }146147 /// Jetons de nom de rue (≥ 4 lettres, sans accents, génériques exclus)148 /// pour un rapprochement souple « boul. Saint-Laurent » ↔ « Boulevard SAINT-LAURENT ».149 private static let genericTokens: Set<String> = [150 "RUE", "AVENUE", "BOULEVARD", "BOUL", "CHEMIN", "PLACE", "ROUTE",151 "MONTREAL", "QUEBEC", "LAVAL", "LONGUEUIL", "GATINEAU",152 "APPARTEMENT", "SAINT", "SAINTE", "OUEST", "NORD",153 ]154 private static func streetTokens(_ s: String) -> Set<String> {155 Set(s.folding(options: .diacriticInsensitive, locale: Locale(identifier: "fr_CA"))156 .uppercased()157 .components(separatedBy: CharacterSet.letters.inverted)158 .filter { $0.count >= 4 && !genericTokens.contains($0) })159 }160}161