1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// Services.swift — réseau : client JSON avec cache disque (mode hors ligne de3// consultation), service d'univers (listes/recherche), stats live, et4// KA Agent (chat SSE vers l'API centrale api-ka).5import Foundation67// MARK: - Client HTTP + cache disque89actor APIClient {10 static let shared = APIClient()11 private let session: URLSession12 private let cacheDir: URL1314 init() {15 let cfg = URLSessionConfiguration.default16 cfg.timeoutIntervalForRequest = 1517 cfg.waitsForConnectivity = false18 session = URLSession(configuration: cfg)19 cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]20 .appendingPathComponent("ka-json", isDirectory: true)21 try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)22 }2324 private func cacheFile(for url: URL) -> URL {25 let name = url.absoluteString.data(using: .utf8)!.base64EncodedString()26 .replacingOccurrences(of: "/", with: "_")27 return cacheDir.appendingPathComponent(String(name.suffix(120)) + ".json")28 }2930 /// JSON brut ; en cas d'échec réseau, sert la dernière copie disque (hors ligne).31 func json(_ url: URL, ttl: TimeInterval = 120) async throws -> JSONValue {32 let file = cacheFile(for: url)33 if let attrs = try? FileManager.default.attributesOfItem(atPath: file.path),34 let date = attrs[.modificationDate] as? Date, Date().timeIntervalSince(date) < ttl,35 let data = try? Data(contentsOf: file),36 let cached = try? JSONDecoder().decode(JSONValue.self, from: data) {37 return cached38 }39 do {40 var req = URLRequest(url: url)41 req.setValue("KA-iOS/1.0 (+https://www.groupe-ka.com)", forHTTPHeaderField: "User-Agent")42 let (data, resp) = try await session.data(for: req)43 guard let http = resp as? HTTPURLResponse, http.statusCode == 200 else {44 throw URLError(.badServerResponse)45 }46 let value = try JSONDecoder().decode(JSONValue.self, from: data)47 try? data.write(to: file)48 return value49 } catch {50 if let data = try? Data(contentsOf: file),51 let cached = try? JSONDecoder().decode(JSONValue.self, from: data) {52 return cached // hors ligne : dernier contenu connu53 }54 throw error55 }56 }57}5859// MARK: - Univers : listes, recherche, stats6061enum UniverseService {62 static func listURL(_ u: Universe, query: String?, city: String? = nil, limit: Int = 30, params: [String: String] = [:]) -> URL? {63 guard let path = u.listPath else { return nil }64 var comps = URLComponents(url: u.baseURL.appendingPathComponent(""), resolvingAgainstBaseURL: false)!65 // listPath peut contenir déjà une query (ex. events?upcoming=true)66 let split = path.split(separator: "?", maxSplits: 1)67 comps.path = String(split[0])68 var items: [URLQueryItem] = split.count > 169 ? split[1].split(separator: "&").map {70 let kv = $0.split(separator: "=", maxSplits: 1)71 return URLQueryItem(name: String(kv[0]), value: kv.count > 1 ? String(kv[1]) : nil)72 } : []73 if let q = query, !q.isEmpty { items.append(.init(name: u.searchParam, value: q)) }74 if let c = city, !c.isEmpty { items.append(.init(name: "city", value: c)) }75 for (k, v) in params.sorted(by: { $0.key < $1.key }) where !v.isEmpty {76 items.append(.init(name: k, value: v))77 }78 items.append(.init(name: "limit", value: String(limit)))79 comps.queryItems = items80 return comps.url81 }8283 static func fetch(_ u: Universe, query: String? = nil, city: String? = nil, limit: Int = 30, params: [String: String] = [:]) async throws -> [KAItem] {84 // Trouve·Ka : /api/search EXIGE q (422 sinon) — jamais d'appel à vide85 if u.id == "trouve-ka", (query ?? "").isEmpty { return [] }86 guard let url = listURL(u, query: query, city: city, limit: limit, params: params), let map = u.map else { return [] }87 let root = try await APIClient.shared.json(url)88 let obj = root.object ?? [:]89 let raw = obj[u.itemsKey]?.array90 ?? obj["items"]?.array ?? obj["results"]?.array ?? obj["hits"]?.array91 ?? root.array ?? []92 return raw.compactMap { $0.object.flatMap(map) }93 }9495 /// Total d'une requête (champ `total` de la réponse liste) — pour les alertes.96 static func total(_ u: Universe, query: String? = nil, params: [String: String] = [:]) async -> Int? {97 guard let url = listURL(u, query: query, limit: 1, params: params),98 let root = try? await APIClient.shared.json(url, ttl: 60) else { return nil }99 return root.object?.num("total", "count").map(Int.init)100 }101102 /// Items GÉOLOCALISÉS pour une région de carte.103 /// lou-ka / immo-ka / job-ka : endpoint geojson avec bbox RÉEL (vérifié).104 /// Autres univers : liste standard filtrée client sur la région.105 static func mapItems(_ u: Universe, west: Double, south: Double, east: Double, north: Double,106 limit: Int = 150) async -> [KAItem] {107 let bboxCapable = ["lou-ka": "/api/listings.geojson",108 "immo-ka": "/api/listings.geojson",109 "job-ka": "/api/jobs.geojson"]110 if let path = bboxCapable[u.id], let map = u.map {111 var comps = URLComponents(string: "https://\(u.domain)\(path)")!112 comps.queryItems = [113 .init(name: "bbox", value: "\(west),\(south),\(east),\(north)"),114 .init(name: "limit", value: String(limit)),115 ]116 guard let url = comps.url,117 let root = try? await APIClient.shared.json(url, ttl: 90),118 let features = root.object?["features"]?.array else { return [] }119 return features.compactMap { f -> KAItem? in120 guard let fo = f.object,121 var props = fo["properties"]?.object,122 let coords = fo["geometry"]?.object?["coordinates"]?.array,123 coords.count >= 2, let lon = coords[0].number, let lat = coords[1].number124 else { return nil }125 props["lat"] = .number(lat)126 props["lng"] = .number(lon)127 return map(props)128 }129 }130 // repli : liste + filtre client131 let items = (try? await fetch(u, limit: limit)) ?? []132 return items.filter { it in133 guard let la = it.latitude, let lo = it.longitude else { return false }134 return la >= south && la <= north && lo >= west && lo <= east135 }136 }137138 /// Total « en direct » depuis /api/stats (clé selon l'univers, chemins a.b acceptés)139 static func liveTotal(_ u: Universe) async -> Int? {140 let path = u.id == "trouve-ka" ? "/api/status" : "/api/stats"141 guard let url = URL(string: "https://\(u.domain)\(path)"),142 let root = try? await APIClient.shared.json(url, ttl: 300) else { return nil }143 for key in u.statTotalKeys {144 var cur: JSONValue? = root145 for part in key.split(separator: ".") {146 cur = cur?.object?[String(part)]147 }148 if let n = cur?.number { return Int(n) }149 }150 return nil151 }152}153154// MARK: - KA Agent (SSE)155156struct AgentEvent { let kind: Kind; enum Kind { case delta(String), tool(String), done, error(String) } }157158enum AgentService {159 static let endpoint = URL(string: "https://www.api-ka.com/api/agent/chat")!160161 static func stream(site: String, messages: [[String: String]]) -> AsyncThrowingStream<AgentEvent, Error> {162 AsyncThrowingStream { continuation in163 let task = Task {164 var req = URLRequest(url: endpoint)165 req.httpMethod = "POST"166 req.setValue("application/json", forHTTPHeaderField: "Content-Type")167 req.timeoutInterval = 90168 req.httpBody = try JSONSerialization.data(withJSONObject: ["site": site, "messages": messages])169 let (bytes, resp) = try await URLSession.shared.bytes(for: req)170 guard (resp as? HTTPURLResponse)?.statusCode == 200 else { throw URLError(.badServerResponse) }171 var event = ""172 for try await line in bytes.lines {173 if line.hasPrefix("event: ") { event = String(line.dropFirst(7)) }174 else if line.hasPrefix("data: ") {175 let data = Data(line.dropFirst(6).utf8)176 let obj = (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:]177 switch event {178 case "delta": if let t = obj["text"] as? String { continuation.yield(.init(kind: .delta(t))) }179 case "tool": continuation.yield(.init(kind: .tool(obj["name"] as? String ?? "recherche")))180 case "done": continuation.yield(.init(kind: .done)); continuation.finish(); return181 case "error": continuation.yield(.init(kind: .error(obj["message"] as? String ?? "erreur")))182 default: break183 }184 }185 }186 continuation.finish()187 }188 continuation.onTermination = { _ in task.cancel() }189 }190 }191}192