// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // DetailService.swift — la fiche COMPLÈTE, comme sur le site : chaque univers // expose un endpoint de détail (`/`) beaucoup plus riche que la // liste (galerie complète, description longue, inclusions, caractéristiques, // estimation Vrai-Prix, VIN, heures d'ouverture…). Ce service le lit, refait // passer l'objet dans le mapping de l'univers, puis fusionne avec l'item de // liste (l'id est conservé — clé des favoris et de l'historique). import Foundation enum DetailService { struct Extra { var facts: [KAItem.Fact] = [] var tags: [String] = [] var links: [KAItem.Fact] = [] var brief: String? } /// Fiche enrichie depuis l'API de détail du site (nil si indisponible). static func enrich(_ item: KAItem, universe u: Universe) async -> KAItem? { guard let map = u.map, let basePath = u.listPath?.split(separator: "?").first.map(String.init) else { return nil } let uidPart = String(item.id.dropFirst(u.id.count + 1)) guard !uidPart.isEmpty else { return nil } let enc = uidPart.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)? .replacingOccurrences(of: "/", with: "%2F") ?? uidPart guard let url = URL(string: "https://\(u.domain)\(basePath)/\(enc)"), let root = try? await APIClient.shared.json(url, ttl: 300) else { return nil } // certains endpoints enveloppent l'objet (listing/item/…) var obj = root.object ?? [:] for key in ["listing", "item", "vehicle", "job", "event", "product", "restaurant", "creator"] { if let inner = obj[key]?.object { obj = inner; break } } guard !obj.isEmpty else { return nil } var full = map(obj) ?? item full.id = item.id // fusion douce : ne jamais perdre ce que la liste savait déjà if full.imageURLs.isEmpty { full.imageURLs = item.imageURLs; full.imageURL = item.imageURL } if (full.detail ?? "").isEmpty { full.detail = item.detail } if full.priceLabel == nil { full.priceLabel = item.priceLabel } if (full.subtitle ?? "").isEmpty { full.subtitle = item.subtitle } if full.city == nil { full.city = item.city } if full.url == nil { full.url = item.url } if full.latitude == nil { full.latitude = item.latitude; full.longitude = item.longitude } if full.links.isEmpty { full.links = item.links } let extra = extraContent(u.id, obj) var seenLabels = Set(full.facts.map { $0.label.lowercased() }) var seenValues = Set(full.facts.map { $0.value.lowercased() }) for f in extra.facts where !f.value.isEmpty && !seenLabels.contains(f.label.lowercased()) && !seenValues.contains(f.value.lowercased()) { full.facts.append(f) seenLabels.insert(f.label.lowercased()) seenValues.insert(f.value.lowercased()) } if !extra.tags.isEmpty { full.tags = Array(extra.tags.prefix(24)) } full.brief = extra.brief for l in extra.links where !full.links.contains(l) { full.links.append(l) } return full } // MARK: - extraction par univers (faits, étiquettes, liens, en-bref) private static func extraContent(_ id: String, _ o: [String: JSONValue]) -> Extra { var e = Extra() func fact(_ label: String, _ value: String?) { if let v = value, !v.isEmpty { e.facts.append(.init(label: label, value: v)) } } func yes(_ v: JSONValue?) -> Bool { if case .bool(true) = v ?? .null { return true }; return false } func strings(_ v: JSONValue?) -> [String] { (v?.array ?? []).compactMap(\.text).filter { !$0.isEmpty } } let details = o["details"]?.object ?? [:] switch id { case "lou-ka": e.brief = o["digest"]?.object?["en_bref"]?.text fact("Secteur", o.str("sector")) fact("Disponible", o.str("availability_date", "availability") .map { $0 == "now" ? "Dès maintenant" : $0 }) fact("Animaux", yes(o["pets"]) ? "Acceptés" : o["pets"].flatMap { if case .bool(false) = $0 { return "Non acceptés" }; return nil }) fact("Meublé", yes(o["furnished"]) ? "Oui" : nil) fact("Immeuble", details.str("building_name")) fact("Gestionnaire", details.str("brokerage_name")) fact("Code postal", details.str("zipcode")) e.tags = strings(o["amenities"]) if let inc = details["inclusions"]?.object { if yes(inc["heating"]) { e.tags.append("Chauffage inclus") } if yes(inc["electricity"]) { e.tags.append("Électricité incluse") } if yes(inc["hot_water"]) { e.tags.append("Eau chaude incluse") } if yes(inc["internet"]) { e.tags.append("Internet inclus") } } if let app = details["appliances"]?.object { if yes(app["fridge"]) { e.tags.append("Frigo") } if yes(app["stove"]) { e.tags.append("Cuisinière") } if yes(app["dishwasher"]) { e.tags.append("Lave-vaisselle") } if yes(app["washer"]) { e.tags.append("Laveuse") } if yes(app["dryer"]) { e.tags.append("Sécheuse") } } if yes(details["parking"]?.object?["available"]) { e.tags.append("Stationnement") } case "immo-ka": fact("Année de construction", o.num("year_built").flatMap { $0 > 1500 ? String(Int($0)) : nil }) fact("Terrain", o.num("lot_sqft").flatMap { $0 > 50 ? "\(Int($0)) pi²" : nil }) fact("Salles d'eau", o.num("powder_rooms").map { String(Int($0)) }) fact("Statut", o.str("status")) fact("MLS", o.str("mls")) fact("Courtier", o.str("broker_name")) fact("Agence", o.str("agency")) fact("Secteur", o.str("sector")) fact("Région", o.str("region")) if let vp = o["vraiprix"]?.object, let v = vp.num("value"), v > 10_000 { var s = v.money0 if let lo = vp.num("low"), let hi = vp.num("high"), hi > lo { s += " (\(lo.money0)–\(hi.money0))" } fact("Estimation Vrai-Prix", s) } e.tags = strings(o["features"]) case "auto-ka": fact("Version", o.str("trim")) fact("Moteur", o.str("engine")) fact("Couleur extérieure", o.str("exterior_color")) fact("Couleur intérieure", o.str("interior_color")) fact("Portes", o.num("doors").flatMap { $0 > 0 ? String(Int($0)) : nil }) fact("Places", o.num("seats").flatMap { $0 > 0 ? String(Int($0)) : nil }) fact("NIV", o.str("vin")) fact("No de stock", o.str("stock_number")) fact("Région", o.str("region")) fact("État", details.str("condition") == "used" ? "Occasion" : details.str("condition")) if let cf = o.str("carfax_url"), cf.hasPrefix("http") { e.links.append(.init(label: "Rapport Carfax", value: cf)) } e.tags = strings(o["features"]) case "food-ka": fact("Marque", o.str("brand")) fact("Format", o.str("size_label")) fact("Prix unitaire", o.str("unit_price_label")) fact("Prix régulier", o.num("regular_price").flatMap { $0 > 0.2 ? $0.money2 : nil }) fact("En stock", yes(o["in_stock"]) ? "Oui" : nil) fact("Vendeur", details.str("seller")) fact("Rayon", o.str("category_raw", "category")) e.tags = strings(o["keywords"]) case "sorti-ka": fact("Organisateur", o.str("organizer")) fact("Public", o.str("audience")) fact("Lieu", o.str("venue")) fact("Adresse", o.str("address")) fact("Région touristique", o.str("tourist_region")) fact("Code postal", o.str("postal_code")) if let site = o.str("website"), site.hasPrefix("http") { e.links.append(.init(label: "Site de l'événement", value: site)) } e.tags = strings(o["categories"]) case "job-ka": fact("Salaire affiché", o.str("salary_label")) fact("Lieu", o.str("location_label")) fact("Région", o.str("region")) fact("Postuler avant", o.str("date_deadline")) fact("Postes ouverts", details.num("positions").flatMap { $0 > 0 ? String(Int($0)) : nil }) fact("Code postal", o.str("postal_code")) e.tags = strings(o["benefits"]) case "fabri-ka": fact("Boutique", o.str("store_name", "store_id")) fact("Type de produit", o.str("product_type")) fact("Vendeur", o.str("vendor")) fact("Prix comparé", o.num("compare_at_price").flatMap { $0 > 0.2 ? $0.money2 : nil }) fact("Disponible", yes(o["available"]) ? "Oui" : nil) e.tags = strings(o["tags"]) case "resto-ka": fact("Type", o.str("establishment_type")) fact("Fourchette de prix", o.str("price_range")) fact("Téléphone", o.str("phone")) fact("Adresse", o.str("address")) fact("Chaîne", o.str("chain")) fact("Région", o.str("region")) if let site = o.str("website"), site.hasPrefix("http") { e.links.append(.init(label: "Site du restaurant", value: site)) } if let hours = o["hours"]?.object { let days = ["monday": "Lun", "tuesday": "Mar", "wednesday": "Mer", "thursday": "Jeu", "friday": "Ven", "saturday": "Sam", "sunday": "Dim"] let order = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] for d in order { if let h = hours[d]?.text, !h.isEmpty { fact(days[d] ?? d, h) } } } e.tags = strings(o["cuisines"]) + strings(o["services"]) + strings(o["dietary_options"]) + strings(o["languages"]) default: break } // repli générique : toute chaîne courte du bloc `details` devient un // fait — sauf si la MÊME VALEUR est déjà affichée sous un libellé // français (évite les doublons ZIPCODE/Code postal, etc.) let frLabels = ["listed_at": "Inscrite le", "neighborhood_name": "Quartier", "transaction": "Transaction", "employment_label": "Type d'emploi"] for (k, v) in details.sorted(by: { $0.key < $1.key }) { guard e.facts.count < 30, let t = v.text, !t.isEmpty, t.count < 90, !["condition"].contains(k) else { continue } let label = frLabels[k] ?? k.replacingOccurrences(of: "_", with: " ").capitalized let dupLabel = e.facts.contains { $0.label.caseInsensitiveCompare(label) == .orderedSame } let dupValue = e.facts.contains { $0.value.caseInsensitiveCompare(t) == .orderedSame } if !dupLabel && !dupValue { e.facts.append(.init(label: label, value: t)) } } e.tags = e.tags.filter { $0.count < 40 } return e } }