SPB Git forge

spb/ka-ios

Public
20commits 1branches 0releases
17.2 MBsize
maindefault branch
28 days agolast push
Swift 100%
11.2 KB · 224 lines swift
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// DetailService.swift — la fiche COMPLÈTE, comme sur le site : chaque univers3// expose un endpoint de détail (`<liste>/<uid>`) beaucoup plus riche que la4// liste (galerie complète, description longue, inclusions, caractéristiques,5// estimation Vrai-Prix, VIN, heures d'ouverture…). Ce service le lit, refait6// passer l'objet dans le mapping de l'univers, puis fusionne avec l'item de7// liste (l'id est conservé — clé des favoris et de l'historique).8import Foundation910enum DetailService {11    struct Extra {12        var facts: [KAItem.Fact] = []13        var tags: [String] = []14        var links: [KAItem.Fact] = []15        var brief: String?16    }1718    /// Fiche enrichie depuis l'API de détail du site (nil si indisponible).19    static func enrich(_ item: KAItem, universe u: Universe) async -> KAItem? {20        guard let map = u.map,21              let basePath = u.listPath?.split(separator: "?").first.map(String.init)22        else { return nil }23        let uidPart = String(item.id.dropFirst(u.id.count + 1))24        guard !uidPart.isEmpty else { return nil }25        let enc = uidPart.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)?26            .replacingOccurrences(of: "/", with: "%2F") ?? uidPart27        guard let url = URL(string: "https://\(u.domain)\(basePath)/\(enc)"),28              let root = try? await APIClient.shared.json(url, ttl: 300)29        else { return nil }30        // certains endpoints enveloppent l'objet (listing/item/…)31        var obj = root.object ?? [:]32        for key in ["listing", "item", "vehicle", "job", "event", "product", "restaurant", "creator"] {33            if let inner = obj[key]?.object { obj = inner; break }34        }35        guard !obj.isEmpty else { return nil }3637        var full = map(obj) ?? item38        full.id = item.id39        // fusion douce : ne jamais perdre ce que la liste savait déjà40        if full.imageURLs.isEmpty { full.imageURLs = item.imageURLs; full.imageURL = item.imageURL }41        if (full.detail ?? "").isEmpty { full.detail = item.detail }42        if full.priceLabel == nil { full.priceLabel = item.priceLabel }43        if (full.subtitle ?? "").isEmpty { full.subtitle = item.subtitle }44        if full.city == nil { full.city = item.city }45        if full.url == nil { full.url = item.url }46        if full.latitude == nil { full.latitude = item.latitude; full.longitude = item.longitude }47        if full.links.isEmpty { full.links = item.links }4849        let extra = extraContent(u.id, obj)50        var seenLabels = Set(full.facts.map { $0.label.lowercased() })51        var seenValues = Set(full.facts.map { $0.value.lowercased() })52        for f in extra.facts where !f.value.isEmpty53            && !seenLabels.contains(f.label.lowercased())54            && !seenValues.contains(f.value.lowercased()) {55            full.facts.append(f)56            seenLabels.insert(f.label.lowercased())57            seenValues.insert(f.value.lowercased())58        }59        if !extra.tags.isEmpty { full.tags = Array(extra.tags.prefix(24)) }60        full.brief = extra.brief61        for l in extra.links where !full.links.contains(l) { full.links.append(l) }62        return full63    }6465    // MARK: - extraction par univers (faits, étiquettes, liens, en-bref)6667    private static func extraContent(_ id: String, _ o: [String: JSONValue]) -> Extra {68        var e = Extra()6970        func fact(_ label: String, _ value: String?) {71            if let v = value, !v.isEmpty { e.facts.append(.init(label: label, value: v)) }72        }73        func yes(_ v: JSONValue?) -> Bool { if case .bool(true) = v ?? .null { return true }; return false }74        func strings(_ v: JSONValue?) -> [String] {75            (v?.array ?? []).compactMap(\.text).filter { !$0.isEmpty }76        }77        let details = o["details"]?.object ?? [:]7879        switch id {80        case "lou-ka":81            e.brief = o["digest"]?.object?["en_bref"]?.text82            fact("Secteur", o.str("sector"))83            fact("Disponible", o.str("availability_date", "availability")84                .map { $0 == "now" ? "Dès maintenant" : $0 })85            fact("Animaux", yes(o["pets"]) ? "Acceptés" : o["pets"].flatMap { if case .bool(false) = $0 { return "Non acceptés" }; return nil })86            fact("Meublé", yes(o["furnished"]) ? "Oui" : nil)87            fact("Immeuble", details.str("building_name"))88            fact("Gestionnaire", details.str("brokerage_name"))89            fact("Code postal", details.str("zipcode"))90            e.tags = strings(o["amenities"])91            if let inc = details["inclusions"]?.object {92                if yes(inc["heating"]) { e.tags.append("Chauffage inclus") }93                if yes(inc["electricity"]) { e.tags.append("Électricité incluse") }94                if yes(inc["hot_water"]) { e.tags.append("Eau chaude incluse") }95                if yes(inc["internet"]) { e.tags.append("Internet inclus") }96            }97            if let app = details["appliances"]?.object {98                if yes(app["fridge"]) { e.tags.append("Frigo") }99                if yes(app["stove"]) { e.tags.append("Cuisinière") }100                if yes(app["dishwasher"]) { e.tags.append("Lave-vaisselle") }101                if yes(app["washer"]) { e.tags.append("Laveuse") }102                if yes(app["dryer"]) { e.tags.append("Sécheuse") }103            }104            if yes(details["parking"]?.object?["available"]) { e.tags.append("Stationnement") }105106        case "immo-ka":107            fact("Année de construction", o.num("year_built").flatMap { $0 > 1500 ? String(Int($0)) : nil })108            fact("Terrain", o.num("lot_sqft").flatMap { $0 > 50 ? "\(Int($0)) pi²" : nil })109            fact("Salles d'eau", o.num("powder_rooms").map { String(Int($0)) })110            fact("Statut", o.str("status"))111            fact("MLS", o.str("mls"))112            fact("Courtier", o.str("broker_name"))113            fact("Agence", o.str("agency"))114            fact("Secteur", o.str("sector"))115            fact("Région", o.str("region"))116            if let vp = o["vraiprix"]?.object, let v = vp.num("value"), v > 10_000 {117                var s = v.money0118                if let lo = vp.num("low"), let hi = vp.num("high"), hi > lo {119                    s += " (\(lo.money0)–\(hi.money0))"120                }121                fact("Estimation Vrai-Prix", s)122            }123            e.tags = strings(o["features"])124125        case "auto-ka":126            fact("Version", o.str("trim"))127            fact("Moteur", o.str("engine"))128            fact("Couleur extérieure", o.str("exterior_color"))129            fact("Couleur intérieure", o.str("interior_color"))130            fact("Portes", o.num("doors").flatMap { $0 > 0 ? String(Int($0)) : nil })131            fact("Places", o.num("seats").flatMap { $0 > 0 ? String(Int($0)) : nil })132            fact("NIV", o.str("vin"))133            fact("No de stock", o.str("stock_number"))134            fact("Région", o.str("region"))135            fact("État", details.str("condition") == "used" ? "Occasion" : details.str("condition"))136            if let cf = o.str("carfax_url"), cf.hasPrefix("http") {137                e.links.append(.init(label: "Rapport Carfax", value: cf))138            }139            e.tags = strings(o["features"])140141        case "food-ka":142            fact("Marque", o.str("brand"))143            fact("Format", o.str("size_label"))144            fact("Prix unitaire", o.str("unit_price_label"))145            fact("Prix régulier", o.num("regular_price").flatMap { $0 > 0.2 ? $0.money2 : nil })146            fact("En stock", yes(o["in_stock"]) ? "Oui" : nil)147            fact("Vendeur", details.str("seller"))148            fact("Rayon", o.str("category_raw", "category"))149            e.tags = strings(o["keywords"])150151        case "sorti-ka":152            fact("Organisateur", o.str("organizer"))153            fact("Public", o.str("audience"))154            fact("Lieu", o.str("venue"))155            fact("Adresse", o.str("address"))156            fact("Région touristique", o.str("tourist_region"))157            fact("Code postal", o.str("postal_code"))158            if let site = o.str("website"), site.hasPrefix("http") {159                e.links.append(.init(label: "Site de l'événement", value: site))160            }161            e.tags = strings(o["categories"])162163        case "job-ka":164            fact("Salaire affiché", o.str("salary_label"))165            fact("Lieu", o.str("location_label"))166            fact("Région", o.str("region"))167            fact("Postuler avant", o.str("date_deadline"))168            fact("Postes ouverts", details.num("positions").flatMap { $0 > 0 ? String(Int($0)) : nil })169            fact("Code postal", o.str("postal_code"))170            e.tags = strings(o["benefits"])171172        case "fabri-ka":173            fact("Boutique", o.str("store_name", "store_id"))174            fact("Type de produit", o.str("product_type"))175            fact("Vendeur", o.str("vendor"))176            fact("Prix comparé", o.num("compare_at_price").flatMap { $0 > 0.2 ? $0.money2 : nil })177            fact("Disponible", yes(o["available"]) ? "Oui" : nil)178            e.tags = strings(o["tags"])179180        case "resto-ka":181            fact("Type", o.str("establishment_type"))182            fact("Fourchette de prix", o.str("price_range"))183            fact("Téléphone", o.str("phone"))184            fact("Adresse", o.str("address"))185            fact("Chaîne", o.str("chain"))186            fact("Région", o.str("region"))187            if let site = o.str("website"), site.hasPrefix("http") {188                e.links.append(.init(label: "Site du restaurant", value: site))189            }190            if let hours = o["hours"]?.object {191                let days = ["monday": "Lun", "tuesday": "Mar", "wednesday": "Mer",192                            "thursday": "Jeu", "friday": "Ven", "saturday": "Sam", "sunday": "Dim"]193                let order = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]194                for d in order {195                    if let h = hours[d]?.text, !h.isEmpty { fact(days[d] ?? d, h) }196                }197            }198            e.tags = strings(o["cuisines"]) + strings(o["services"])199                + strings(o["dietary_options"]) + strings(o["languages"])200201        default:202            break203        }204205        // repli générique : toute chaîne courte du bloc `details` devient un206        // fait — sauf si la MÊME VALEUR est déjà affichée sous un libellé207        // français (évite les doublons ZIPCODE/Code postal, etc.)208        let frLabels = ["listed_at": "Inscrite le", "neighborhood_name": "Quartier",209                        "transaction": "Transaction", "employment_label": "Type d'emploi"]210        for (k, v) in details.sorted(by: { $0.key < $1.key }) {211            guard e.facts.count < 30, let t = v.text, !t.isEmpty, t.count < 90,212                  !["condition"].contains(k) else { continue }213            let label = frLabels[k] ?? k.replacingOccurrences(of: "_", with: " ").capitalized214            let dupLabel = e.facts.contains { $0.label.caseInsensitiveCompare(label) == .orderedSame }215            let dupValue = e.facts.contains { $0.value.caseInsensitiveCompare(t) == .orderedSame }216            if !dupLabel && !dupValue {217                e.facts.append(.init(label: label, value: t))218            }219        }220        e.tags = e.tags.filter { $0.count < 40 }221        return e222    }223}224