Swift 100%
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