|
|
@@ -0,0 +1,532 @@
|
|
|
+//
|
|
|
+// OpenAIIndeedWebSearchDataSource.swift
|
|
|
+// indeed_ai_job_search
|
|
|
+//
|
|
|
+// Production Indeed search via OpenAI Responses API + hosted web_search,
|
|
|
+// restricted to Indeed domains. Returns only listings with valid Indeed URLs.
|
|
|
+//
|
|
|
+
|
|
|
+import Foundation
|
|
|
+import OSLog
|
|
|
+
|
|
|
+struct OpenAIIndeedWebSearchDataSource: IndeedJobDataSource {
|
|
|
+ private static let log = Logger(
|
|
|
+ subsystem: Bundle.main.bundleIdentifier ?? "indeed_ai_job_search",
|
|
|
+ category: "OpenAIIndeedSearch"
|
|
|
+ )
|
|
|
+
|
|
|
+ /// Soft prefer window (prompt). Hard drop above `maxPostedDaysAgo`.
|
|
|
+ static let preferredPostedDaysAgo = 14
|
|
|
+ /// Hard max age for AI-returned postings (client-side drop when age is known).
|
|
|
+ static let maxPostedDaysAgo = 30
|
|
|
+
|
|
|
+ /// Domains web_search may consult (subdomains of indeed.com included when listed).
|
|
|
+ static let indeedAllowedDomains = [
|
|
|
+ "indeed.com",
|
|
|
+ "www.indeed.com",
|
|
|
+ "pk.indeed.com",
|
|
|
+ "uk.indeed.com",
|
|
|
+ "ca.indeed.com",
|
|
|
+ "indeed.co.uk",
|
|
|
+ "indeed.com.au",
|
|
|
+ "ae.indeed.com",
|
|
|
+ "de.indeed.com",
|
|
|
+ "fr.indeed.com",
|
|
|
+ "in.indeed.com"
|
|
|
+ ]
|
|
|
+
|
|
|
+ var client: OpenAIResponsesClient
|
|
|
+ var maxResults: Int
|
|
|
+ var maxAgeDays: Int
|
|
|
+
|
|
|
+ init(
|
|
|
+ client: OpenAIResponsesClient,
|
|
|
+ maxResults: Int = 12,
|
|
|
+ maxAgeDays: Int = Self.maxPostedDaysAgo
|
|
|
+ ) {
|
|
|
+ self.client = client
|
|
|
+ self.maxResults = max(8, min(maxResults, 25))
|
|
|
+ self.maxAgeDays = max(1, maxAgeDays)
|
|
|
+ }
|
|
|
+
|
|
|
+ func searchPage(
|
|
|
+ criteria: SearchCriteria,
|
|
|
+ query: String,
|
|
|
+ page: IndeedSearchPageRequest
|
|
|
+ ) async throws -> IndeedSearchPage {
|
|
|
+ // Single-shot web search — no multi-page crawl.
|
|
|
+ if page.pageIndex > 0 {
|
|
|
+ return IndeedSearchPage(
|
|
|
+ listings: [],
|
|
|
+ pageIndex: page.pageIndex,
|
|
|
+ pageSize: page.pageSize,
|
|
|
+ totalEstimated: 0,
|
|
|
+ nextCursor: nil,
|
|
|
+ hasMore: false
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ let freshnessDays = criteria.dateFreshnessDays.map { min(max(1, $0), maxAgeDays) } ?? maxAgeDays
|
|
|
+ let preferredDays = min(Self.preferredPostedDaysAgo, freshnessDays)
|
|
|
+
|
|
|
+ // First pass: prefer last 14 days. If empty, one looser retry (same hard max).
|
|
|
+ var listings = try await runSearch(
|
|
|
+ query: query,
|
|
|
+ criteria: criteria,
|
|
|
+ maxResults: maxResults,
|
|
|
+ freshnessDays: freshnessDays,
|
|
|
+ preferredDays: preferredDays,
|
|
|
+ relaxed: false
|
|
|
+ )
|
|
|
+
|
|
|
+ if listings.isEmpty {
|
|
|
+ Self.log.warning("First Indeed web_search returned 0 — retrying with relaxed freshness instructions")
|
|
|
+ listings = try await runSearch(
|
|
|
+ query: query,
|
|
|
+ criteria: criteria,
|
|
|
+ maxResults: maxResults,
|
|
|
+ freshnessDays: freshnessDays,
|
|
|
+ preferredDays: freshnessDays,
|
|
|
+ relaxed: true
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ return IndeedSearchPage(
|
|
|
+ listings: listings,
|
|
|
+ pageIndex: 0,
|
|
|
+ pageSize: page.pageSize,
|
|
|
+ totalEstimated: listings.count,
|
|
|
+ nextCursor: nil,
|
|
|
+ hasMore: false
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ private func runSearch(
|
|
|
+ query: String,
|
|
|
+ criteria: SearchCriteria,
|
|
|
+ maxResults: Int,
|
|
|
+ freshnessDays: Int,
|
|
|
+ preferredDays: Int,
|
|
|
+ relaxed: Bool
|
|
|
+ ) async throws -> [JobListing] {
|
|
|
+ let prompt = Self.buildPrompt(
|
|
|
+ query: query,
|
|
|
+ criteria: criteria,
|
|
|
+ maxResults: maxResults,
|
|
|
+ preferredDays: preferredDays,
|
|
|
+ maxAgeDays: freshnessDays,
|
|
|
+ relaxed: relaxed
|
|
|
+ )
|
|
|
+
|
|
|
+ Self.log.info(
|
|
|
+ "Indeed web_search start relaxed=\(relaxed, privacy: .public) queryChars=\(query.count, privacy: .public) title=\(criteria.jobTitle.isEmpty ? "(none)" : criteria.jobTitle, privacy: .public) preferred=\(preferredDays, privacy: .public) maxAge=\(freshnessDays, privacy: .public)"
|
|
|
+ )
|
|
|
+
|
|
|
+ let outputText: String
|
|
|
+ do {
|
|
|
+ outputText = try await client.createWithWebSearch(
|
|
|
+ input: prompt,
|
|
|
+ allowedDomains: Self.indeedAllowedDomains,
|
|
|
+ requireWebSearch: true
|
|
|
+ )
|
|
|
+ } catch let error as OpenAIClientError {
|
|
|
+ throw Self.mapOpenAIError(error)
|
|
|
+ }
|
|
|
+
|
|
|
+ let preview = String(outputText.prefix(400))
|
|
|
+ Self.log.info("Indeed web_search rawPreview=\(preview, privacy: .public)")
|
|
|
+
|
|
|
+ let rows: [AIIndeedJobRow]
|
|
|
+ do {
|
|
|
+ rows = try Self.decodeJobRows(from: outputText)
|
|
|
+ } catch {
|
|
|
+ Self.log.error(
|
|
|
+ "Indeed web_search JSON parse failed: \(error.localizedDescription, privacy: .public) preview=\(preview, privacy: .public)"
|
|
|
+ )
|
|
|
+ throw error
|
|
|
+ }
|
|
|
+
|
|
|
+ var listings: [JobListing] = []
|
|
|
+ listings.reserveCapacity(rows.count)
|
|
|
+ var droppedStale = 0
|
|
|
+ var droppedClosed = 0
|
|
|
+ var droppedInvalid = 0
|
|
|
+
|
|
|
+ for row in rows {
|
|
|
+ guard row.isAcceptingApplications else {
|
|
|
+ droppedClosed += 1
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ guard let dto = row.asIndeedJobDTO(maxAgeDays: freshnessDays, allowMissingDate: true) else {
|
|
|
+ if let days = row.postedDaysAgo, days > freshnessDays {
|
|
|
+ droppedStale += 1
|
|
|
+ } else {
|
|
|
+ droppedInvalid += 1
|
|
|
+ }
|
|
|
+ continue
|
|
|
+ }
|
|
|
+
|
|
|
+ do {
|
|
|
+ let listing = try IndeedListingMapper.map(dto)
|
|
|
+ guard IndeedURLCanonicalizer.isIndeedHost(listing.indeedURL.host ?? "") else {
|
|
|
+ Self.log.warning("Dropped non-Indeed URL \(listing.indeedURL.absoluteString, privacy: .public)")
|
|
|
+ droppedInvalid += 1
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if let days = row.postedDaysAgo, days > freshnessDays {
|
|
|
+ droppedStale += 1
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ // Only age-check when we have a real posted date (not mapper "now" fallback).
|
|
|
+ if row.postedDaysAgo != nil || (row.postedAt?.isEmpty == false) {
|
|
|
+ let age = Calendar.current.dateComponents([.day], from: listing.postedAt, to: Date()).day ?? 0
|
|
|
+ if age > freshnessDays {
|
|
|
+ droppedStale += 1
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if listing.availability == .unavailable {
|
|
|
+ droppedClosed += 1
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ listings.append(listing)
|
|
|
+ } catch {
|
|
|
+ droppedInvalid += 1
|
|
|
+ Self.log.warning(
|
|
|
+ "Skipped unmappable AI job row: \(error.localizedDescription, privacy: .public)"
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ listings = JobListingFilter.filter(listings, criteria: criteria, query: query)
|
|
|
+ listings = JobListingDeduper.dedupe(listings)
|
|
|
+ listings.sort { $0.postedAt > $1.postedAt }
|
|
|
+
|
|
|
+ Self.log.info(
|
|
|
+ "Indeed web_search kept \(listings.count, privacy: .public) (raw \(rows.count, privacy: .public), stale=\(droppedStale, privacy: .public), closed=\(droppedClosed, privacy: .public), invalid=\(droppedInvalid, privacy: .public))"
|
|
|
+ )
|
|
|
+ return listings
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Prompt
|
|
|
+
|
|
|
+ static func buildPrompt(
|
|
|
+ query: String,
|
|
|
+ criteria: SearchCriteria,
|
|
|
+ maxResults: Int,
|
|
|
+ preferredDays: Int,
|
|
|
+ maxAgeDays: Int,
|
|
|
+ relaxed: Bool
|
|
|
+ ) -> String {
|
|
|
+ var filters: [String] = []
|
|
|
+ let title = criteria.jobTitle.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ if !title.isEmpty { filters.append("Job title focus: \(title)") }
|
|
|
+ let keywords = criteria.keywords.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ if !keywords.isEmpty { filters.append("Keywords: \(keywords)") }
|
|
|
+ if !criteria.country.isEmpty { filters.append("Country: \(criteria.country)") }
|
|
|
+ if !criteria.city.isEmpty { filters.append("City/region: \(criteria.city)") }
|
|
|
+ if !criteria.remoteTypes.isEmpty {
|
|
|
+ let modes = criteria.remoteTypes.map(\.rawValue).sorted().joined(separator: ", ")
|
|
|
+ filters.append("Work mode must be one of: \(modes)")
|
|
|
+ }
|
|
|
+ if !criteria.employmentTypes.isEmpty {
|
|
|
+ let types = criteria.employmentTypes.map(\.rawValue).sorted().joined(separator: ", ")
|
|
|
+ filters.append("Employment type preference: \(types)")
|
|
|
+ }
|
|
|
+ if !criteria.excludedKeywords.isEmpty {
|
|
|
+ filters.append("Exclude roles mentioning: \(criteria.excludedKeywords)")
|
|
|
+ }
|
|
|
+ if let floor = criteria.salaryFloor {
|
|
|
+ filters.append("Prefer roles at or above salary floor \(Int(floor)) (when salary is published)")
|
|
|
+ }
|
|
|
+
|
|
|
+ let filterBlock = filters.isEmpty
|
|
|
+ ? "No extra filters beyond the user request."
|
|
|
+ : filters.joined(separator: "\n")
|
|
|
+
|
|
|
+ let what = title.isEmpty
|
|
|
+ ? query.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ : title
|
|
|
+ let encodedWhat = what.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? what
|
|
|
+ let locationHint: String
|
|
|
+ if !criteria.city.isEmpty || !criteria.country.isEmpty {
|
|
|
+ locationHint = [criteria.city, criteria.country]
|
|
|
+ .filter { !$0.isEmpty }
|
|
|
+ .joined(separator: ", ")
|
|
|
+ } else if criteria.remoteTypes.contains(.remote) {
|
|
|
+ locationHint = "Remote"
|
|
|
+ } else {
|
|
|
+ locationHint = ""
|
|
|
+ }
|
|
|
+ let encodedLoc = locationHint.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
|
|
|
+
|
|
|
+ let exampleURL: String
|
|
|
+ if encodedLoc.isEmpty {
|
|
|
+ exampleURL = "https://www.indeed.com/jobs?q=\(encodedWhat)&fromage=\(preferredDays)"
|
|
|
+ } else {
|
|
|
+ exampleURL = "https://www.indeed.com/jobs?q=\(encodedWhat)&l=\(encodedLoc)&fromage=\(preferredDays)"
|
|
|
+ }
|
|
|
+
|
|
|
+ let freshnessTone = relaxed
|
|
|
+ ? "Return currently open Indeed jobs posted within the last \(maxAgeDays) days. Prefer newer ones."
|
|
|
+ : "Strongly prefer jobs posted within the last \(preferredDays) days (Indeed fromage=\(preferredDays)). You may include up to \(maxAgeDays) days if needed to fill the list."
|
|
|
+
|
|
|
+ let today = ISO8601DateFormatter().string(from: Date())
|
|
|
+
|
|
|
+ return """
|
|
|
+ You are an Indeed-only job search assistant for a macOS app.
|
|
|
+ Today’s date (UTC): \(today).
|
|
|
+
|
|
|
+ Use web_search to find CURRENTLY OPEN jobs on Indeed that match the user request.
|
|
|
+ Search ONLY Indeed sites (indeed.com and regional Indeed TLDs).
|
|
|
+
|
|
|
+ Required search approach:
|
|
|
+ 1) Start with Indeed search URLs that include fromage for freshness, for example:
|
|
|
+ \(exampleURL)
|
|
|
+ Also try regional hosts when location implies them (e.g. pk.indeed.com for Pakistan).
|
|
|
+ 2) From the results, collect specific job postings with /viewjob?jk=… (or rc/clk links that resolve to a job).
|
|
|
+ 3) Return a JSON array of those jobs — do not return an empty array if open Indeed postings exist for this query.
|
|
|
+
|
|
|
+ Freshness:
|
|
|
+ - \(freshnessTone)
|
|
|
+ - HARD EXCLUDE: “No longer accepting applications”, closed, expired, filled.
|
|
|
+ - HARD EXCLUDE: posted more than \(maxAgeDays) days ago (“30+ days ago”, “1 month ago”, “1 year ago”).
|
|
|
+
|
|
|
+ Other rules:
|
|
|
+ - Do NOT invent companies, titles, salaries, or URLs.
|
|
|
+ - Every url MUST be an Indeed absolute https URL.
|
|
|
+ - Deduplicate by company + title.
|
|
|
+ - Return \(max(8, min(maxResults, 12)))–\(maxResults) jobs when enough open Indeed postings exist (target ~10). Newest first.
|
|
|
+ - If salary/currency is unknown, omit salary fields.
|
|
|
+ - Always set accepting_applications and posted_days_ago when you can read them from the page.
|
|
|
+
|
|
|
+ User request:
|
|
|
+ \(query.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
|
+
|
|
|
+ Additional filters:
|
|
|
+ \(filterBlock)
|
|
|
+
|
|
|
+ Return ONLY a JSON array (no markdown fences, no commentary). Each object keys:
|
|
|
+ - "title": string
|
|
|
+ - "company": string
|
|
|
+ - "location": string
|
|
|
+ - "url": absolute https Indeed URL (include jk= when possible)
|
|
|
+ - "jobKey": optional 16-character hex Indeed jk
|
|
|
+ - "remoteType": optional "remote" | "hybrid" | "onsite"
|
|
|
+ - "employmentType": optional "full-time" | "part-time" | "contract" | "temporary" | "internship"
|
|
|
+ - "salaryMin": optional number
|
|
|
+ - "salaryMax": optional number
|
|
|
+ - "salaryCurrency": optional ISO code — required if salaryMin/Max present
|
|
|
+ - "salaryPeriod": optional "year" | "month" | "hour" | "week"
|
|
|
+ - "snippet": optional short description
|
|
|
+ - "postedAt": optional ISO-8601 date string
|
|
|
+ - "posted_days_ago": integer when known (0 = today)
|
|
|
+ - "accepting_applications": boolean (omit only if unknown; false if closed)
|
|
|
+ - "availability": optional "open" | "unavailable"
|
|
|
+ - "isEasyApply": optional boolean
|
|
|
+ - "isUrgentlyHiring": optional boolean
|
|
|
+ """
|
|
|
+ }
|
|
|
+
|
|
|
+ // Compatibility overload used by older tests.
|
|
|
+ static func buildPrompt(
|
|
|
+ query: String,
|
|
|
+ criteria: SearchCriteria,
|
|
|
+ maxResults: Int,
|
|
|
+ maxAgeDays: Int
|
|
|
+ ) -> String {
|
|
|
+ buildPrompt(
|
|
|
+ query: query,
|
|
|
+ criteria: criteria,
|
|
|
+ maxResults: maxResults,
|
|
|
+ preferredDays: min(preferredPostedDaysAgo, maxAgeDays),
|
|
|
+ maxAgeDays: maxAgeDays,
|
|
|
+ relaxed: false
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Decode
|
|
|
+
|
|
|
+ static func decodeJobRows(from outputText: String) throws -> [AIIndeedJobRow] {
|
|
|
+ let jsonText = extractJSONArray(from: outputText)
|
|
|
+ guard let data = jsonText.data(using: .utf8) else {
|
|
|
+ throw IndeedDataError.invalidResponse("Empty AI job search payload.")
|
|
|
+ }
|
|
|
+
|
|
|
+ if let decoded = try? JSONDecoder().decode([AIIndeedJobRow].self, from: data) {
|
|
|
+ return decoded
|
|
|
+ }
|
|
|
+ if let wrapped = try? JSONDecoder().decode(WrappedAIResults.self, from: data) {
|
|
|
+ return wrapped.results
|
|
|
+ }
|
|
|
+ // Model sometimes returns a single object instead of an array.
|
|
|
+ if let single = try? JSONDecoder().decode(AIIndeedJobRow.self, from: data),
|
|
|
+ single.asIndeedJobDTO(maxAgeDays: maxPostedDaysAgo, allowMissingDate: true) != nil
|
|
|
+ || !(single.title ?? "").isEmpty {
|
|
|
+ return [single]
|
|
|
+ }
|
|
|
+ throw IndeedDataError.invalidResponse(
|
|
|
+ "Could not parse Indeed job JSON from the AI response. Try searching again."
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ static func decodeJobDTOs(
|
|
|
+ from outputText: String,
|
|
|
+ maxAgeDays: Int = maxPostedDaysAgo
|
|
|
+ ) throws -> [IndeedJobDTO] {
|
|
|
+ try decodeJobRows(from: outputText).compactMap {
|
|
|
+ $0.asIndeedJobDTO(maxAgeDays: maxAgeDays, allowMissingDate: true)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ static func extractJSONArray(from text: String) -> String {
|
|
|
+ var trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ if trimmed.hasPrefix("```") {
|
|
|
+ trimmed = trimmed
|
|
|
+ .replacingOccurrences(of: "```json", with: "", options: .caseInsensitive)
|
|
|
+ .replacingOccurrences(of: "```", with: "")
|
|
|
+ .trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ }
|
|
|
+ if let start = trimmed.firstIndex(of: "["),
|
|
|
+ let end = trimmed.lastIndex(of: "]"),
|
|
|
+ start <= end {
|
|
|
+ return String(trimmed[start...end])
|
|
|
+ }
|
|
|
+ if let start = trimmed.firstIndex(of: "{"),
|
|
|
+ let end = trimmed.lastIndex(of: "}"),
|
|
|
+ start <= end {
|
|
|
+ return String(trimmed[start...end])
|
|
|
+ }
|
|
|
+ return trimmed
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Flexible decode shape — AI JSON is often incomplete; we normalize before mapping.
|
|
|
+ struct AIIndeedJobRow: Decodable, Equatable {
|
|
|
+ var title: String?
|
|
|
+ var company: String?
|
|
|
+ var location: String?
|
|
|
+ var url: String?
|
|
|
+ var jobKey: String?
|
|
|
+ var remoteType: String?
|
|
|
+ var employmentType: String?
|
|
|
+ var experienceLevel: String?
|
|
|
+ var salaryMin: Double?
|
|
|
+ var salaryMax: Double?
|
|
|
+ var salaryCurrency: String?
|
|
|
+ var salaryPeriod: String?
|
|
|
+ var snippet: String?
|
|
|
+ var postedAt: String?
|
|
|
+ var postedDaysAgo: Int?
|
|
|
+ var acceptingApplications: Bool?
|
|
|
+ var availability: String?
|
|
|
+ var isEasyApply: Bool?
|
|
|
+ var isFeatured: Bool?
|
|
|
+ var isUrgentlyHiring: Bool?
|
|
|
+
|
|
|
+ enum CodingKeys: String, CodingKey {
|
|
|
+ case title, company, location, url, jobKey, remoteType, employmentType, experienceLevel
|
|
|
+ case salaryMin, salaryMax, salaryCurrency, salaryPeriod, snippet, postedAt
|
|
|
+ case postedDaysAgo = "posted_days_ago"
|
|
|
+ case acceptingApplications = "accepting_applications"
|
|
|
+ case availability, isEasyApply, isFeatured, isUrgentlyHiring
|
|
|
+ }
|
|
|
+
|
|
|
+ var isAcceptingApplications: Bool {
|
|
|
+ if acceptingApplications == false { return false }
|
|
|
+ let availability = availability?.lowercased() ?? ""
|
|
|
+ if availability == "unavailable" || availability == "closed" || availability == "expired" {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ let hay = "\(snippet ?? "") \(title ?? "")".lowercased()
|
|
|
+ if hay.contains("no longer accepting")
|
|
|
+ || hay.contains("not accepting applications")
|
|
|
+ || hay.contains("job has expired") {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ func asIndeedJobDTO(maxAgeDays: Int, allowMissingDate: Bool) -> IndeedJobDTO? {
|
|
|
+ let title = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
|
+ let company = company?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
|
+ let url = url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
|
+ guard !title.isEmpty, !company.isEmpty, !url.isEmpty else { return nil }
|
|
|
+
|
|
|
+ let postedISO: String?
|
|
|
+ if let days = postedDaysAgo {
|
|
|
+ guard days >= 0, days <= maxAgeDays else { return nil }
|
|
|
+ if let postedAt, !postedAt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
|
+ postedISO = postedAt
|
|
|
+ } else if let date = Calendar.current.date(byAdding: .day, value: -days, to: Date()) {
|
|
|
+ postedISO = ISO8601DateFormatter().string(from: date)
|
|
|
+ } else {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ } else if let postedAt, let parsed = Self.parseISODate(postedAt) {
|
|
|
+ let days = Calendar.current.dateComponents([.day], from: parsed, to: Date()).day ?? maxAgeDays + 1
|
|
|
+ guard days <= maxAgeDays else { return nil }
|
|
|
+ postedISO = postedAt
|
|
|
+ } else if allowMissingDate {
|
|
|
+ // Keep open jobs even when Indeed snippet omitted a date.
|
|
|
+ postedISO = nil
|
|
|
+ } else {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+
|
|
|
+ let location = location?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ return IndeedJobDTO(
|
|
|
+ jobKey: jobKey,
|
|
|
+ title: title,
|
|
|
+ company: company,
|
|
|
+ location: (location?.isEmpty == false) ? location! : "Indeed",
|
|
|
+ remoteType: remoteType,
|
|
|
+ employmentType: employmentType,
|
|
|
+ experienceLevel: experienceLevel,
|
|
|
+ salaryMin: salaryMin,
|
|
|
+ salaryMax: salaryMax,
|
|
|
+ salaryCurrency: salaryCurrency,
|
|
|
+ salaryPeriod: salaryPeriod,
|
|
|
+ postedAt: postedISO,
|
|
|
+ snippet: snippet,
|
|
|
+ url: url,
|
|
|
+ availability: "open",
|
|
|
+ isEasyApply: isEasyApply,
|
|
|
+ isFeatured: isFeatured,
|
|
|
+ isUrgentlyHiring: isUrgentlyHiring
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ private static func parseISODate(_ raw: String) -> Date? {
|
|
|
+ let fractional = ISO8601DateFormatter()
|
|
|
+ fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
|
+ if let date = fractional.date(from: raw) { return date }
|
|
|
+ let basic = ISO8601DateFormatter()
|
|
|
+ basic.formatOptions = [.withInternetDateTime]
|
|
|
+ return basic.date(from: raw)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private struct WrappedAIResults: Decodable {
|
|
|
+ var results: [AIIndeedJobRow]
|
|
|
+ }
|
|
|
+
|
|
|
+ private static func mapOpenAIError(_ error: OpenAIClientError) -> IndeedDataError {
|
|
|
+ switch error {
|
|
|
+ case .notConfigured:
|
|
|
+ return .unauthorized
|
|
|
+ case .timeout:
|
|
|
+ return .network("The AI Indeed search timed out. Try again.")
|
|
|
+ case .cancelled:
|
|
|
+ return .cancelled
|
|
|
+ case .httpStatus(let code, let detail):
|
|
|
+ if code == 401 || code == 403 {
|
|
|
+ return .unauthorized
|
|
|
+ }
|
|
|
+ return .network(detail ?? "OpenAI HTTP \(code)")
|
|
|
+ case .network(let detail):
|
|
|
+ return .network(detail)
|
|
|
+ case .invalidResponse, .decodingFailed, .disabled:
|
|
|
+ return .invalidResponse(error.localizedDescription)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|