// // ContentExtractor.swift // Zyquo Atlas // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // Injects the extraction scripts (Mozilla Readability + AtlasExtractor driver) // into an isolated WKContentWorld and pulls a normalized PageContext out of a // live tab via callAsyncJavaScript. Isolation keeps the extractor from // colliding with or being observed by page JS (docs/AI-BROWSER-RESEARCH.md ยง2). // All page content is treated as untrusted downstream (Phase 3.C prompt // hygiene). // import Foundation import WebKit enum ContentExtractionError: LocalizedError { case scriptsUnavailable case scriptFailure(String) case decodeFailure(String) var errorDescription: String? { switch self { case .scriptsUnavailable: return "Content extraction scripts are missing from the app bundle." case .scriptFailure(let detail): return "Couldn't read this page's content (\(detail))." case .decodeFailure(let detail): return "Couldn't parse the extracted page content (\(detail))." } } } @MainActor final class ContentExtractor { /// Dedicated world so extraction never touches the page's JS globals. static let world = WKContentWorld.world(name: "ZyquoAtlasContent") /// Installs the extraction user scripts into a configuration. Call once per /// WKWebViewConfiguration (ProfileStore) before any tab loads. static func install(into config: WKWebViewConfiguration) { let ucc = config.userContentController for name in ["Readability", "Readability-readerable", "AtlasExtractor"] { guard let source = script(name) else { continue } ucc.addUserScript(WKUserScript( source: source, injectionTime: .atDocumentEnd, forMainFrameOnly: true, in: world )) } } /// Extracts a PageContext from a tab's web view. `mode` "auto" tries /// Readability first; "rawText" forces the visible-text fallback. static func extract(from webView: WKWebView, mode: String = "auto") async throws -> PageContext { let result: Any? do { result = try await webView.callAsyncJavaScript( "return window.__zyquoAtlas ? window.__zyquoAtlas.extract(mode, maxBytes) : { ok:false, error:'driver-missing' };", arguments: ["mode": mode, "maxBytes": 600_000], in: nil, contentWorld: world ) } catch { throw ContentExtractionError.scriptFailure(error.localizedDescription) } guard let dict = result as? [String: Any] else { throw ContentExtractionError.scriptFailure("no result") } if dict["ok"] as? Bool != true { throw ContentExtractionError.scriptFailure((dict["error"] as? String) ?? "unknown") } guard let context = dict["context"] else { throw ContentExtractionError.scriptFailure("empty context") } do { let data = try JSONSerialization.data(withJSONObject: context) return try JSONDecoder().decode(PageContext.self, from: data) } catch { throw ContentExtractionError.decodeFailure(error.localizedDescription) } } // MARK: - Script loading private static func script(_ name: String) -> String? { guard let url = Bundle.module.url(forResource: name, withExtension: "js"), let source = try? String(contentsOf: url, encoding: .utf8) else { return nil } return source } }