IndeedWebKitPageFetcher.swift 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. //
  2. // IndeedWebKitPageFetcher.swift
  3. // indeed_ai_job_search
  4. //
  5. // Loads Indeed search pages via WebKit (browser engine). Plain URLSession
  6. // is blocked by Cloudflare captcha; WKWebView matches real Safari enough
  7. // to receive job-card HTML for parsing.
  8. //
  9. import AppKit
  10. import Foundation
  11. import OSLog
  12. import WebKit
  13. @MainActor
  14. final class IndeedWebKitPageFetcher: NSObject {
  15. static let shared = IndeedWebKitPageFetcher()
  16. private let log = Logger(
  17. subsystem: Bundle.main.bundleIdentifier ?? "indeed_ai_job_search",
  18. category: "IndeedFetch"
  19. )
  20. private var webView: WKWebView?
  21. /// Kept alive so WebKit completes loads off-screen (macOS often stalls without a window).
  22. private var hostWindow: NSWindow?
  23. private var continuation: CheckedContinuation<String, Error>?
  24. private var timeoutTask: Task<Void, Never>?
  25. private override init() {
  26. super.init()
  27. }
  28. /// Fetches fully rendered page HTML for `url`.
  29. func html(from url: URL, timeoutSeconds: TimeInterval = 35) async throws -> String {
  30. // Serialize loads — one WKWebView at a time.
  31. if continuation != nil {
  32. throw IndeedDataError.network("Another Indeed page load is already in progress.")
  33. }
  34. let configuration = WKWebViewConfiguration()
  35. // Persistent cookies help when Indeed has already verified this Mac in Safari/WebKit.
  36. configuration.websiteDataStore = .default()
  37. configuration.defaultWebpagePreferences.allowsContentJavaScript = true
  38. let view = WKWebView(frame: CGRect(x: 0, y: 0, width: 1280, height: 900), configuration: configuration)
  39. view.navigationDelegate = self
  40. self.webView = view
  41. let window = NSWindow(
  42. contentRect: NSRect(x: -10_000, y: -10_000, width: 1280, height: 900),
  43. styleMask: [.titled, .closable],
  44. backing: .buffered,
  45. defer: false
  46. )
  47. window.isReleasedWhenClosed = false
  48. window.contentView = view
  49. window.orderBack(nil)
  50. self.hostWindow = window
  51. log.info("Loading Indeed page \(url.absoluteString, privacy: .public)")
  52. return try await withCheckedThrowingContinuation { (cont: CheckedContinuation<String, Error>) in
  53. self.continuation = cont
  54. self.timeoutTask = Task { [weak self] in
  55. try? await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
  56. await MainActor.run {
  57. guard let self, self.continuation != nil else { return }
  58. self.fail(IndeedDataError.network("Timed out loading Indeed search results."))
  59. }
  60. }
  61. view.load(URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: timeoutSeconds))
  62. }
  63. }
  64. private func tearDownWebView() {
  65. webView?.navigationDelegate = nil
  66. webView?.stopLoading()
  67. hostWindow?.contentView = nil
  68. hostWindow?.orderOut(nil)
  69. hostWindow = nil
  70. webView = nil
  71. }
  72. private func finish(_ html: String) {
  73. timeoutTask?.cancel()
  74. timeoutTask = nil
  75. guard let continuation else { return }
  76. self.continuation = nil
  77. tearDownWebView()
  78. continuation.resume(returning: html)
  79. }
  80. private func fail(_ error: Error) {
  81. timeoutTask?.cancel()
  82. timeoutTask = nil
  83. guard let continuation else { return }
  84. self.continuation = nil
  85. tearDownWebView()
  86. continuation.resume(throwing: error)
  87. }
  88. private func extractHTML(from webView: WKWebView) {
  89. let script = """
  90. (function() {
  91. if (window.INDEED_CLOUDFLARE_STATIC_PAGE) {
  92. return JSON.stringify({ blocked: true, html: document.documentElement.outerHTML });
  93. }
  94. return JSON.stringify({ blocked: false, html: document.documentElement.outerHTML });
  95. })();
  96. """
  97. webView.evaluateJavaScript(script) { [weak self] result, error in
  98. Task { @MainActor in
  99. guard let self else { return }
  100. if let error {
  101. self.fail(IndeedDataError.network(error.localizedDescription))
  102. return
  103. }
  104. guard let json = result as? String,
  105. let data = json.data(using: .utf8),
  106. let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
  107. let html = payload["html"] as? String else {
  108. self.fail(IndeedDataError.invalidResponse("Could not read Indeed page HTML."))
  109. return
  110. }
  111. if payload["blocked"] as? Bool == true || IndeedHTMLJobParser.isBlockedOrCaptcha(html) {
  112. self.log.error("Indeed returned a security/captcha page")
  113. self.fail(IndeedDataError.forbidden)
  114. return
  115. }
  116. self.finish(html)
  117. }
  118. }
  119. }
  120. }
  121. extension IndeedWebKitPageFetcher: WKNavigationDelegate {
  122. func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
  123. // Give mosaic job-card scripts a moment to hydrate.
  124. Task { @MainActor in
  125. try? await Task.sleep(nanoseconds: 900_000_000)
  126. guard self.webView === webView, self.continuation != nil else { return }
  127. self.extractHTML(from: webView)
  128. }
  129. }
  130. func webView(
  131. _ webView: WKWebView,
  132. didFail navigation: WKNavigation!,
  133. withError error: Error
  134. ) {
  135. fail(IndeedDataError.network(error.localizedDescription))
  136. }
  137. func webView(
  138. _ webView: WKWebView,
  139. didFailProvisionalNavigation navigation: WKNavigation!,
  140. withError error: Error
  141. ) {
  142. fail(IndeedDataError.network(error.localizedDescription))
  143. }
  144. }