SPB Git forge

spb/ka-ios

Public
20commits 1branches 0releases
17.2 MBsize
maindefault branch
29 days agolast push
Swift 100%
7.9 KB · 161 lines swift
Raw Blame History
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