|
1 |
+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
|
2 |
+// VraiPrixService.swift — l'estimation VRAI-PRIX (www.vrai-prix.com, modèle |
|
3 |
+// 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 carte |
|
5 |
+// affichée — jamais pour la file ni les marqueurs — avec cache mémoire par |
|
6 |
+// annonce (résultats vides compris) et échec silencieux. Aucun overload. |
|
7 |
+import Foundation |
|
8 |
+import CoreLocation |
|
9 |
+ |
|
10 |
+/// Estimation Vrai-Prix appariée à une annonce Lou-Ka / Immo-Ka. |
|
11 |
+struct VraiPrixEstimate { |
|
12 |
+ let value: Int // est. 2026 ($) du modèle hédonique Vrai-Prix |
|
13 |
+ 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é) |
|
15 |
+ |
|
16 |
+ /// 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 * 1000 |
|
23 |
+ return rounded.formatted(.number.locale(Locale(identifier: "fr_CA"))) + " $" |
|
24 |
+ } |
|
25 |
+ |
|
26 |
+ /// Écart % du prix affiché vs l'estimation. Seulement quand l'unité est |
|
27 |
+ /// confirmée (jamais sur une médiane d'immeuble ni un voisinage), ventes |
|
28 |
+ /// seulement (un loyer mensuel passe sous le plancher), et borné — un |
|
29 |
+ /// é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) * 100 |
|
33 |
+ guard abs(pct) <= 60 else { return nil } |
|
34 |
+ return Int(pct.rounded()) |
|
35 |
+ } |
|
36 |
+} |
|
37 |
+ |
|
38 |
+actor VraiPrixService { |
|
39 |
+ static let shared = VraiPrixService() |
|
40 |
+ private var cache: [String: VraiPrixEstimate?] = [:] |
|
41 |
+ |
|
42 |
+ 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] = r |
|
46 |
+ return r |
|
47 |
+ } |
|
48 |
+ |
|
49 |
+ 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 m |
|
55 |
+ .init(name: "halfLng", value: "0.0007"), |
|
56 |
+ .init(name: "limit", value: "20"), |
|
57 |
+ ] |
|
58 |
+ var req = URLRequest(url: comps.url!) |
|
59 |
+ req.timeoutInterval = 6 |
|
60 |
+ 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 } |
|
64 |
+ |
|
65 |
+ struct Candidate { |
|
66 |
+ let value: Int, civic: Bool, apt: String, meters: Double, terrain: Bool |
|
67 |
+ } |
|
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) |
|
71 |
+ |
|
72 |
+ let units: [Candidate] = results.compactMap { u in |
|
73 |
+ 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 proche |
|
77 |
+ let uTokens = Self.streetTokens(adresse) |
|
78 |
+ if !streetTokens.isEmpty, !uTokens.isEmpty, uTokens.isDisjoint(with: streetTokens) { |
|
79 |
+ return nil |
|
80 |
+ } |
|
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 |
+ } |
|
91 |
+ |
|
92 |
+ 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é confirme |
|
98 |
+ // l'unité ; sinon, médiane de l'immeuble sans écart de prix |
|
99 |
+ 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 |
+ } |
|
112 |
+ |
|
113 |
+ // MARK: appariement d'adresses |
|
114 |
+ |
|
115 |
+ /// Numéro civique d'une annonce : dernier nombre du premier groupe |
|
116 |
+ /// (les annonces QC écrivent souvent « apt-civique Rue », ex. « 311-1225 |
|
117 |
+ /// 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 |
+ } |
|
122 |
+ |
|
123 |
+ /// Candidats « appartement » d'une annonce : courts jetons avec chiffre |
|
124 |
+ /// (« #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 in |
|
130 |
+ t.count <= 5 && !t.isEmpty && t.contains(where: \.isNumber) && t != civicStr |
|
131 |
+ }) |
|
132 |
+ } |
|
133 |
+ |
|
134 |
+ /// 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 |
+ } |
|
138 |
+ |
|
139 |
+ /// « 1152-1154 Boulevard X » → 1152...1154 ; « 1163 Rue CLARK » → 1163...1163 |
|
140 |
+ 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) } ?? lo |
|
144 |
+ return min(lo, hi)...max(lo, hi) |
|
145 |
+ } |
|
146 |
+ |
|
147 |
+ /// 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 |
+} |