Przeglądaj źródła

Replace mock search results with live Indeed WebKit fetch and parse.

Production DI now loads real Indeed job cards (title, company, salary when present, viewjob links) instead of demo ios00x listings, with safer pagination so a later page timeout keeps earlier results.

Co-authored-by: Cursor <cursoragent@cursor.com>
faiz ali 18 godzin temu
rodzic
commit
25c56b3b90

+ 2 - 2
Docs/Phase4/Search_Pipeline.md

@@ -34,9 +34,9 @@ NL query
 
 ## Indeed source
 
-Live DI uses **`MockJobDataSource`** (via `JobDataSourceIndeedAdapter`) with openable Indeed **browse/search** URLs until the licensed Option C vendor is ready. Fixtures remain for mapper/contract tests.
+Live DI uses **`LiveIndeedJobDataSource`**: WebKit loads Indeed `/jobs` search pages, `IndeedHTMLJobParser` extracts real job cards (`jk`, title, company, location, salary when present), and `IndeedListingMapper` maps them. Mock/fixture catalogs remain for **SwiftUI previews and unit tests only**.
 
-**Why links used to look “expired”:** interim keys like `fixture-ios-…` / `ios001` are not real Indeed `jk` values. Opening `/viewjob?jk=…` with those keys shows Indeed’s expired/not-found page. The app now opens `/jobs?q=…&l=…` (live Indeed search for that role) when the key is not a real 16-char hex `jk`.
+**Open on Indeed:** real 16-char hex `jk` → `/viewjob?jk=…`. Fake keys (previews only) → company-scoped `/jobs` search.
 
 ## Open on Indeed
 

+ 4 - 7
indeed_ai_job_search/App/AppDependencies.swift

@@ -55,7 +55,8 @@ final class AppDependencies {
         )
     }
 
-    /// App launch — OpenAI (gpt-6-luna) when configured; Indeed fixtures until vendor contract.
+    /// App launch — live Indeed search pages + OpenAI interpret/rank when configured.
+    /// Mock listings are preview/tests only — never shown in production search.
     static var live: AppDependencies {
         let mockInterpreter = MockSearchInterpreter()
         let heuristicRanking = HeuristicJobRankingService()
@@ -77,13 +78,9 @@ final class AppDependencies {
             ranking = heuristicRanking
         }
 
-        // Interim: diverse mock catalog with openable Indeed browse URLs.
-        // Swap to licensed IndeedJobDataSource when Option C vendor is live.
-        let mockSource = MockJobDataSource(delayNanoseconds: 450_000_000)
-        let indeedSource = JobDataSourceIndeedAdapter(base: mockSource)
+        let indeedSource = LiveIndeedJobDataSource()
         let validation = CompositeJobValidationService(
             source: SourceSignalJobValidationService(),
-            // HEAD against Indeed deferred until vendor expired-signal / Phase 5 revalidation policy.
             http: nil
         )
         let pipeline = DefaultJobSearchPipeline(
@@ -95,7 +92,7 @@ final class AppDependencies {
         )
 
         return AppDependencies(
-            jobDataSource: mockSource,
+            jobDataSource: indeedSource,
             searchInterpreter: interpreter,
             validationService: validation,
             searchPipeline: pipeline,

+ 9 - 3
indeed_ai_job_search/DesignSystem/Components/JobBadges.swift

@@ -166,16 +166,22 @@ struct SaveJobButton: View {
 struct OpenIndeedLinkButton: View {
     let listing: JobListing
 
+    private var title: String {
+        IndeedBrowseURLBuilder.isLikelyRealJobKey(listing.indeedJobKey)
+            ? "Open on Indeed"
+            : "Search on Indeed"
+    }
+
     var body: some View {
         Button {
             JobListingOpener.open(listing)
         } label: {
-            Label("Open on Indeed", systemImage: "arrow.up.right.square")
+            Label(title, systemImage: "arrow.up.right.square")
                 .font(AppTypography.caption)
                 .foregroundStyle(AppColors.brand)
         }
         .buttonStyle(.plain)
-        .help("Open listing on Indeed")
-        .accessibilityLabel("Open on Indeed")
+        .help("\(title): \(listing.title) at \(listing.company)")
+        .accessibilityLabel(title)
     }
 }

+ 49 - 11
indeed_ai_job_search/DesignSystem/Components/JobRowView.swift

@@ -66,7 +66,7 @@ struct JobRowView: View {
                 }
                 .padding(.top, 2)
 
-                Text("Open on Indeed")
+                Text(openLinkTitle)
                     .font(AppTypography.caption)
                     .foregroundStyle(AppColors.brand)
                     .padding(.top, 4)
@@ -91,18 +91,33 @@ struct JobRowView: View {
             x: 0,
             y: showsCardChrome ? AppShadow.cardY : 0
         )
-        .help("Open \(listing.title) at \(listing.company) on Indeed")
+        .help("\(openLinkTitle): \(listing.title) at \(listing.company)")
         .accessibilityElement(children: .contain)
         .accessibilityAddTraits(.isButton)
-        .accessibilityHint("Opens this job on Indeed")
-        .accessibilityAction(named: "Open on Indeed", openListing)
+        .accessibilityHint(openAccessibilityHint)
+        .accessibilityAction(named: openLinkTitle, openListing)
+    }
+
+    /// Direct viewjob when we have a real Indeed `jk`; otherwise company-scoped search.
+    private var openLinkTitle: String {
+        IndeedBrowseURLBuilder.isLikelyRealJobKey(listing.indeedJobKey)
+            ? "Open on Indeed"
+            : "Search on Indeed"
+    }
+
+    private var openAccessibilityHint: String {
+        IndeedBrowseURLBuilder.isLikelyRealJobKey(listing.indeedJobKey)
+            ? "Opens this job on Indeed"
+            : "Searches Indeed for this title at \(listing.company)"
     }
 
     private var metaRow: some View {
         HStack(spacing: AppSpacing.sm) {
-            Label(listing.location, systemImage: "mappin")
-            Text("•")
-                .foregroundStyle(AppColors.textTertiary)
+            if showsLocationSeparately {
+                Label(listing.location, systemImage: "mappin")
+                Text("•")
+                    .foregroundStyle(AppColors.textTertiary)
+            }
             RemoteWorkBadge(type: listing.remoteType)
             if let employment = listing.employmentType {
                 Text("•")
@@ -114,6 +129,17 @@ struct JobRowView: View {
         .foregroundStyle(AppColors.textSecondary)
         .lineLimit(1)
     }
+
+    /// Avoid “Remote • Remote” when location text is only the work mode.
+    private var showsLocationSeparately: Bool {
+        let loc = listing.location.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+        guard !loc.isEmpty else { return false }
+        if listing.remoteType == .remote,
+           loc == "remote" || loc == "remote only" {
+            return false
+        }
+        return true
+    }
 }
 
 struct JobGridCardView: View {
@@ -162,7 +188,7 @@ struct JobGridCardView: View {
                 RelativeDateLabel(date: listing.postedAt)
             }
 
-            Text("Open on Indeed")
+            Text(openLinkTitle)
                 .font(AppTypography.caption)
                 .foregroundStyle(AppColors.brand)
                 .accessibilityHidden(true)
@@ -178,9 +204,21 @@ struct JobGridCardView: View {
                 .stroke(AppColors.border, lineWidth: 1)
         )
         .shadow(color: Color.black.opacity(AppShadow.cardOpacity), radius: AppShadow.cardRadius, x: 0, y: AppShadow.cardY)
-        .help("Open \(listing.title) at \(listing.company) on Indeed")
+        .help("\(openLinkTitle): \(listing.title) at \(listing.company)")
         .accessibilityAddTraits(.isButton)
-        .accessibilityHint("Opens this job on Indeed")
-        .accessibilityAction(named: "Open on Indeed", openListing)
+        .accessibilityHint(openAccessibilityHint)
+        .accessibilityAction(named: openLinkTitle, openListing)
+    }
+
+    private var openLinkTitle: String {
+        IndeedBrowseURLBuilder.isLikelyRealJobKey(listing.indeedJobKey)
+            ? "Open on Indeed"
+            : "Search on Indeed"
+    }
+
+    private var openAccessibilityHint: String {
+        IndeedBrowseURLBuilder.isLikelyRealJobKey(listing.indeedJobKey)
+            ? "Opens this job on Indeed"
+            : "Searches Indeed for this title at \(listing.company)"
     }
 }

+ 4 - 1
indeed_ai_job_search/Domain/Models/JobListing.swift

@@ -12,9 +12,12 @@ struct SalaryRange: Hashable, Codable {
     var period: SalaryPeriod
 
     var formatted: String? {
+        let code = currency.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
+        guard !code.isEmpty, min != nil || max != nil else { return nil }
+
         let formatter = NumberFormatter()
         formatter.numberStyle = .currency
-        formatter.currencyCode = currency
+        formatter.currencyCode = code
         formatter.maximumFractionDigits = 0
 
         let minText = min.flatMap { formatter.string(from: NSNumber(value: $0)) }

+ 54 - 52
indeed_ai_job_search/PreviewSupport/MockData/MockJobListings.swift

@@ -11,9 +11,9 @@ enum MockJobListings {
         let day: TimeInterval = 86_400
 
         /// Interim demo URLs — real `jk` keys come from a licensed feed later.
-        /// Browse/search URLs open live Indeed results instead of expired viewjob pages.
-        func url(title: String, location: String) -> URL {
-            IndeedBrowseURLBuilder.searchURL(query: title, location: location)
+        /// Browse URLs include company so Open on Indeed does not land on a different employer.
+        func url(title: String, company: String, location: String) -> URL {
+            IndeedBrowseURLBuilder.searchURL(query: title, location: location, company: company)
         }
 
         return [
@@ -28,7 +28,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 160_000, max: 220_000, currency: "USD", period: .year),
                 postedAt: now.addingTimeInterval(-day),
                 snippet: "Build delightful iOS experiences with SwiftUI and UIKit for millions of users.",
-                indeedURL: url(title: "Senior iOS Developer", location: "Cupertino, CA"),
+                indeedURL: url(title: "Senior iOS Developer", company: "Apple", location: "Cupertino, CA"),
                 isEasyApply: true,
                 isFeatured: true,
                 logoSymbol: "apple.logo"
@@ -37,14 +37,14 @@ enum MockJobListings {
                 indeedJobKey: "ios002",
                 title: "iOS Developer",
                 company: "Microsoft",
-                location: "Remote",
+                location: "United States",
                 remoteType: .remote,
                 employmentType: .fullTime,
                 experienceLevel: .mid,
                 salary: SalaryRange(min: 120_000, max: 160_000, currency: "USD", period: .year),
                 postedAt: now.addingTimeInterval(-2 * day),
                 snippet: "Join a distributed team shipping mobile productivity apps on iOS.",
-                indeedURL: url(title: "iOS Developer", location: "Remote"),
+                indeedURL: url(title: "iOS Developer", company: "Microsoft", location: "United States"),
                 isEasyApply: true,
                 isUrgentlyHiring: true,
                 logoSymbol: "square.grid.2x2.fill"
@@ -57,10 +57,10 @@ enum MockJobListings {
                 remoteType: .onsite,
                 employmentType: .fullTime,
                 experienceLevel: .junior,
-                salary: SalaryRange(min: 30_000, max: 48_000, currency: "USD", period: .year),
+                salary: SalaryRange(min: 1_200_000, max: 2_000_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-3 * day),
                 snippet: "Develop and maintain customer-facing iOS applications.",
-                indeedURL: url(title: "Mobile Software Engineer (iOS)", location: "Karachi, Pakistan"),
+                indeedURL: url(title: "Mobile Software Engineer (iOS)", company: "Alorica", location: "Karachi, Pakistan"),
                 isEasyApply: true,
                 logoSymbol: "phone.fill"
             ),
@@ -72,10 +72,10 @@ enum MockJobListings {
                 remoteType: .hybrid,
                 employmentType: .fullTime,
                 experienceLevel: .mid,
-                salary: SalaryRange(min: 40_000, max: 65_000, currency: "USD", period: .year),
+                salary: SalaryRange(min: 2_400_000, max: 3_800_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-4 * day),
                 snippet: "Own features end-to-end for the Careem rider and captain apps.",
-                indeedURL: url(title: "iOS Engineer", location: "Lahore, Pakistan"),
+                indeedURL: url(title: "iOS Engineer", company: "Careem", location: "Lahore, Pakistan"),
                 isFeatured: true,
                 logoSymbol: "car.fill"
             ),
@@ -87,10 +87,10 @@ enum MockJobListings {
                 remoteType: .remote,
                 employmentType: .fullTime,
                 experienceLevel: .senior,
-                salary: SalaryRange(min: 55_000, max: 85_000, currency: "USD", period: .year),
+                salary: SalaryRange(min: 3_200_000, max: 5_000_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-5 * day),
                 snippet: "Lead iOS architecture and mentor a growing mobile team.",
-                indeedURL: url(title: "Senior Mobile Developer", location: "Karachi, Pakistan"),
+                indeedURL: url(title: "Senior Mobile Developer", company: "Bykea", location: "Karachi, Pakistan"),
                 isEasyApply: true,
                 isUrgentlyHiring: true,
                 logoSymbol: "bicycle"
@@ -103,10 +103,10 @@ enum MockJobListings {
                 remoteType: .hybrid,
                 employmentType: .fullTime,
                 experienceLevel: .mid,
-                salary: SalaryRange(min: 48_000, max: 75_000, currency: "USD", period: .year),
+                salary: SalaryRange(min: 2_800_000, max: 4_200_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-6 * day),
                 snippet: "Ship modern SwiftUI interfaces for enterprise AI products.",
-                indeedURL: url(title: "SwiftUI Developer", location: "Islamabad, Pakistan"),
+                indeedURL: url(title: "SwiftUI Developer", company: "Afiniti", location: "Islamabad, Pakistan"),
                 isEasyApply: true,
                 logoSymbol: "sparkles"
             ),
@@ -118,10 +118,10 @@ enum MockJobListings {
                 remoteType: .onsite,
                 employmentType: .fullTime,
                 experienceLevel: .entry,
-                salary: SalaryRange(min: 18_000, max: 28_000, currency: "USD", period: .year),
+                salary: SalaryRange(min: 900_000, max: 1_500_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-7 * day),
                 snippet: "Learn production iOS development with senior mentors.",
-                indeedURL: url(title: "Junior iOS Developer", location: "Lahore, Pakistan"),
+                indeedURL: url(title: "Junior iOS Developer", company: "Systems Limited", location: "Lahore, Pakistan"),
                 isEasyApply: true,
                 logoSymbol: "desktopcomputer"
             ),
@@ -133,10 +133,11 @@ enum MockJobListings {
                 remoteType: .remote,
                 employmentType: .contract,
                 experienceLevel: .mid,
+                // Cross-border contract often quoted in USD/hour.
                 salary: SalaryRange(min: 35, max: 55, currency: "USD", period: .hour),
                 postedAt: now.addingTimeInterval(-8 * day),
                 snippet: "6-month contract building a consumer fintech iOS app.",
-                indeedURL: url(title: "iOS Contractor", location: "Pakistan"),
+                indeedURL: url(title: "iOS Contractor", company: "Remote Team Co", location: "Pakistan"),
                 isFeatured: true,
                 logoSymbol: "globe"
             ),
@@ -148,10 +149,10 @@ enum MockJobListings {
                 remoteType: .hybrid,
                 employmentType: .fullTime,
                 experienceLevel: .senior,
-                salary: SalaryRange(min: 70_000, max: 95_000, currency: "USD", period: .year),
+                salary: SalaryRange(min: 4_000_000, max: 6_000_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-9 * day),
                 snippet: "Drive mobile platform strategy for Pakistan’s top marketplace.",
-                indeedURL: url(title: "Lead iOS Engineer", location: "Karachi, Pakistan"),
+                indeedURL: url(title: "Lead iOS Engineer", company: "Daraz", location: "Karachi, Pakistan"),
                 isEasyApply: true,
                 isFeatured: true,
                 logoSymbol: "cart.fill"
@@ -160,28 +161,28 @@ enum MockJobListings {
                 indeedJobKey: "ios010",
                 title: "iOS Developer (Part-time)",
                 company: "EduTech PK",
-                location: "Remote, Pakistan",
+                location: "Pakistan",
                 remoteType: .remote,
                 employmentType: .partTime,
                 experienceLevel: .junior,
-                salary: SalaryRange(min: 12_000, max: 20_000, currency: "USD", period: .year),
+                salary: SalaryRange(min: 600_000, max: 1_000_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-10 * day),
                 snippet: "Part-time role improving an education app for students.",
-                indeedURL: url(title: "iOS Developer (Part-time)", location: "Remote, Pakistan"),
+                indeedURL: url(title: "iOS Developer (Part-time)", company: "EduTech PK", location: "Pakistan"),
                 logoSymbol: "book.fill"
             ),
             JobListing(
                 indeedJobKey: "ios011",
                 title: "macOS / iOS Engineer",
                 company: "Cursor Labs",
-                location: "Remote",
+                location: "United States",
                 remoteType: .remote,
                 employmentType: .fullTime,
                 experienceLevel: .senior,
                 salary: SalaryRange(min: 90_000, max: 130_000, currency: "USD", period: .year),
                 postedAt: now.addingTimeInterval(-1.5 * day),
                 snippet: "Build native Apple platform tools with Swift concurrency.",
-                indeedURL: url(title: "macOS / iOS Engineer", location: "Remote"),
+                indeedURL: url(title: "macOS / iOS Engineer", company: "Cursor Labs", location: "United States"),
                 isEasyApply: true,
                 isFeatured: true,
                 logoSymbol: "laptopcomputer"
@@ -194,10 +195,10 @@ enum MockJobListings {
                 remoteType: .hybrid,
                 employmentType: .fullTime,
                 experienceLevel: .mid,
-                salary: SalaryRange(min: 42_000, max: 60_000, currency: "USD", period: .year),
+                salary: SalaryRange(min: 2_200_000, max: 3_400_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-11 * day),
                 snippet: "Secure mobile payments experience for millions of users.",
-                indeedURL: url(title: "iOS Developer", location: "Islamabad, Pakistan"),
+                indeedURL: url(title: "iOS Developer", company: "JazzCash", location: "Islamabad, Pakistan"),
                 isUrgentlyHiring: true,
                 logoSymbol: "creditcard.fill"
             ),
@@ -209,10 +210,11 @@ enum MockJobListings {
                 remoteType: .onsite,
                 employmentType: .fullTime,
                 experienceLevel: .mid,
+                // Pay not published — omit salary rather than inventing currency/amount.
                 salary: nil,
                 postedAt: now.addingTimeInterval(-12 * day),
                 snippet: "Client projects spanning healthcare and fintech iOS apps.",
-                indeedURL: url(title: "Swift Developer", location: "Karachi, Pakistan"),
+                indeedURL: url(title: "Swift Developer", company: "10Pearls", location: "Karachi, Pakistan"),
                 isEasyApply: true,
                 logoSymbol: "hammer.fill"
             ),
@@ -224,10 +226,10 @@ enum MockJobListings {
                 remoteType: .onsite,
                 employmentType: .internship,
                 experienceLevel: .entry,
-                salary: SalaryRange(min: 400, max: 600, currency: "USD", period: .month),
+                salary: SalaryRange(min: 40_000, max: 70_000, currency: "PKR", period: .month),
                 postedAt: now.addingTimeInterval(-2 * day),
                 snippet: "Paid internship focused on Swift and SwiftUI fundamentals.",
-                indeedURL: url(title: "iOS Intern", location: "Lahore, Pakistan"),
+                indeedURL: url(title: "iOS Intern", company: "Arbisoft", location: "Lahore, Pakistan"),
                 logoSymbol: "graduationcap.fill"
             ),
             JobListing(
@@ -238,10 +240,10 @@ enum MockJobListings {
                 remoteType: .remote,
                 employmentType: .fullTime,
                 experienceLevel: .senior,
-                salary: SalaryRange(min: 60_000, max: 90_000, currency: "USD", period: .year),
+                salary: SalaryRange(min: 3_500_000, max: 5_500_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-0.5 * day),
                 snippet: "Remote-first team building consumer apps for global clients.",
-                indeedURL: url(title: "Senior iOS Developer", location: "Islamabad, Pakistan"),
+                indeedURL: url(title: "Senior iOS Developer", company: "Emumba", location: "Islamabad, Pakistan"),
                 isEasyApply: true,
                 isFeatured: true,
                 logoSymbol: "antenna.radiowaves.left.and.right"
@@ -254,10 +256,10 @@ enum MockJobListings {
                 remoteType: .hybrid,
                 employmentType: .fullTime,
                 experienceLevel: .mid,
-                salary: SalaryRange(min: 38_000, max: 58_000, currency: "USD", period: .year),
+                salary: SalaryRange(min: 2_000_000, max: 3_200_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-14 * day),
                 snippet: "Improve ordering flows and performance on iOS.",
-                indeedURL: url(title: "Mobile Engineer", location: "Lahore, Pakistan"),
+                indeedURL: url(title: "Mobile Engineer", company: "Foodpanda", location: "Lahore, Pakistan"),
                 logoSymbol: "fork.knife"
             ),
             JobListing(
@@ -268,10 +270,10 @@ enum MockJobListings {
                 remoteType: .remote,
                 employmentType: .fullTime,
                 experienceLevel: .mid,
-                salary: SalaryRange(min: 40_000, max: 55_000, currency: "USD", period: .year),
+                salary: nil,
                 postedAt: now.addingTimeInterval(-30 * day),
                 snippet: "This listing is no longer accepting applications.",
-                indeedURL: url(title: "iOS Developer (Unavailable)", location: "Pakistan"),
+                indeedURL: url(title: "iOS Developer (Unavailable)", company: "Closed Co", location: "Pakistan"),
                 availability: .unavailable,
                 logoSymbol: "xmark.circle.fill"
             ),
@@ -279,14 +281,14 @@ enum MockJobListings {
                 indeedJobKey: "ios018",
                 title: "Temporary iOS Support Engineer",
                 company: "Helpdesk Inc",
-                location: "Remote, Pakistan",
+                location: "Pakistan",
                 remoteType: .remote,
                 employmentType: .temporary,
                 experienceLevel: .junior,
                 salary: SalaryRange(min: 25, max: 35, currency: "USD", period: .hour),
                 postedAt: now.addingTimeInterval(-3.5 * day),
                 snippet: "Short-term engagement supporting crash triage and hotfixes.",
-                indeedURL: url(title: "Temporary iOS Support Engineer", location: "Remote, Pakistan"),
+                indeedURL: url(title: "Temporary iOS Support Engineer", company: "Helpdesk Inc", location: "Pakistan"),
                 isEasyApply: true,
                 logoSymbol: "wrench.and.screwdriver.fill"
             ),
@@ -298,10 +300,10 @@ enum MockJobListings {
                 remoteType: .hybrid,
                 employmentType: .fullTime,
                 experienceLevel: .senior,
-                salary: SalaryRange(min: 100_000, max: 140_000, currency: "USD", period: .year),
+                salary: SalaryRange(min: 5_500_000, max: 8_000_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-15 * day),
                 snippet: "Define multi-app mobile architecture and CI/CD standards.",
-                indeedURL: url(title: "Principal iOS Architect", location: "Lahore, Pakistan"),
+                indeedURL: url(title: "Principal iOS Architect", company: "Netsol", location: "Lahore, Pakistan"),
                 isFeatured: true,
                 logoSymbol: "building.columns.fill"
             ),
@@ -313,10 +315,10 @@ enum MockJobListings {
                 remoteType: .onsite,
                 employmentType: .fullTime,
                 experienceLevel: .mid,
-                salary: SalaryRange(min: 35_000, max: 52_000, currency: "USD", period: .year),
+                salary: SalaryRange(min: 1_800_000, max: 2_800_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-4.5 * day),
                 snippet: "Collaborate with product and design on high-quality apps.",
-                indeedURL: url(title: "iOS Developer", location: "Karachi, Pakistan"),
+                indeedURL: url(title: "iOS Developer", company: "VentureDive", location: "Karachi, Pakistan"),
                 isEasyApply: true,
                 isUrgentlyHiring: true,
                 logoSymbol: "person.3.fill"
@@ -329,10 +331,10 @@ enum MockJobListings {
                 remoteType: .hybrid,
                 employmentType: .fullTime,
                 experienceLevel: .mid,
-                salary: SalaryRange(min: 40_000, max: 62_000, currency: "USD", period: .year),
+                salary: SalaryRange(min: 2_200_000, max: 3_600_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-6.5 * day),
                 snippet: "Hybrid role with native iOS modules and React Native.",
-                indeedURL: url(title: "React Native / iOS Engineer", location: "Lahore, Pakistan"),
+                indeedURL: url(title: "React Native / iOS Engineer", company: "Confiz", location: "Lahore, Pakistan"),
                 logoSymbol: "arrow.triangle.2.circlepath"
             ),
             JobListing(
@@ -343,10 +345,10 @@ enum MockJobListings {
                 remoteType: .remote,
                 employmentType: .fullTime,
                 experienceLevel: .mid,
-                salary: SalaryRange(min: 32_000, max: 48_000, currency: "USD", period: .year),
+                salary: SalaryRange(min: 1_600_000, max: 2_500_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-7.5 * day),
                 snippet: "Automate XCUITest suites for mobile release quality.",
-                indeedURL: url(title: "iOS QA Automation Engineer", location: "Karachi, Pakistan"),
+                indeedURL: url(title: "iOS QA Automation Engineer", company: "Folio3", location: "Karachi, Pakistan"),
                 isEasyApply: true,
                 logoSymbol: "checkmark.seal.fill"
             ),
@@ -358,10 +360,10 @@ enum MockJobListings {
                 remoteType: .hybrid,
                 employmentType: .fullTime,
                 experienceLevel: .senior,
-                salary: SalaryRange(min: 85_000, max: 120_000, currency: "USD", period: .year),
+                salary: SalaryRange(min: 4_500_000, max: 7_000_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-0.8 * day),
                 snippet: "Own fleet-management mobile experiences at scale.",
-                indeedURL: url(title: "Staff iOS Engineer", location: "Islamabad, Pakistan"),
+                indeedURL: url(title: "Staff iOS Engineer", company: "KeepTruckin", location: "Islamabad, Pakistan"),
                 isEasyApply: true,
                 isFeatured: true,
                 logoSymbol: "truck.box.fill"
@@ -374,24 +376,24 @@ enum MockJobListings {
                 remoteType: .onsite,
                 employmentType: .fullTime,
                 experienceLevel: .junior,
-                salary: SalaryRange(min: 22_000, max: 35_000, currency: "USD", period: .year),
+                salary: SalaryRange(min: 1_000_000, max: 1_800_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-9.5 * day),
                 snippet: "Work on game-adjacent and consumer product iOS apps.",
-                indeedURL: url(title: "iOS Developer", location: "Lahore, Pakistan"),
+                indeedURL: url(title: "iOS Developer", company: "Tintash", location: "Lahore, Pakistan"),
                 logoSymbol: "gamecontroller.fill"
             ),
             JobListing(
                 indeedJobKey: "ios025",
                 title: "Remote iOS Developer",
                 company: "Global Soft",
-                location: "Remote, Pakistan",
+                location: "Pakistan",
                 remoteType: .remote,
                 employmentType: .fullTime,
                 experienceLevel: .mid,
-                salary: SalaryRange(min: 50_000, max: 78_000, currency: "USD", period: .year),
+                salary: SalaryRange(min: 2_500_000, max: 4_000_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-1.2 * day),
                 snippet: "Fully remote role building banking apps with Swift.",
-                indeedURL: url(title: "Remote iOS Developer", location: "Remote, Pakistan"),
+                indeedURL: url(title: "Remote iOS Developer", company: "Global Soft", location: "Pakistan"),
                 isEasyApply: true,
                 isFeatured: true,
                 isUrgentlyHiring: true,

+ 35 - 8
indeed_ai_job_search/Services/Indeed/IndeedBrowseURLBuilder.swift

@@ -3,7 +3,8 @@
 //  indeed_ai_job_search
 //
 //  Builds openable Indeed URLs. Fake / fixture `jk=` values always 404 on Indeed;
-//  until a licensed feed supplies real keys, open a live Indeed search for the role.
+//  until a licensed feed supplies real keys, open a live Indeed search for the
+//  role + company (not a title-only search that lands on unrelated employers).
 //
 
 import Foundation
@@ -25,37 +26,51 @@ enum IndeedBrowseURLBuilder {
             let browse = searchURL(
                 query: listing.title,
                 location: listing.location,
+                company: listing.company,
                 preferredHost: stored.host
             )
             return (
                 browse,
                 true,
-                "Listing marked unavailable (key=\(key)); opening Indeed search instead of expired viewjob."
+                "Listing marked unavailable (key=\(key)); opening Indeed search for title+company instead of expired viewjob."
             )
         }
 
-        if isLikelyRealJobKey(key),
-           stored.path.lowercased().contains("viewjob") {
-            return (stored, false, nil)
+        if isLikelyRealJobKey(key) {
+            if stored.path.lowercased().contains("viewjob") {
+                return (stored, false, nil)
+            }
+            var components = URLComponents()
+            components.scheme = "https"
+            components.host = stored.host ?? regionalHost(for: listing.location, preferredHost: stored.host)
+            components.path = "/viewjob"
+            components.queryItems = [URLQueryItem(name: "jk", value: key)]
+            if let viewjob = components.url {
+                return (viewjob, false, nil)
+            }
         }
 
         // Fixture / mock keys (`ios001`, `fixture-…`) are not real Indeed jobs.
+        // Always rebuild with company so the browser does not show a different employer.
         let browse = searchURL(
             query: listing.title,
             location: listing.location,
+            company: listing.company,
             preferredHost: stored.host
         )
         return (
             browse,
             true,
-            "Job key “\(key)” is not a real Indeed jk (expected 16-char hex). Stored URL \(stored.absoluteString) would show expired/not found; using live Indeed search."
+            "Job key “\(key)” is not a real Indeed jk (expected 16-char hex). Opening live Indeed search for “\(listing.title)” at “\(listing.company)” (not a direct viewjob)."
         )
     }
 
-    /// Indeed jobs search for title (+ optional location). Always a valid Indeed page.
+    /// Indeed jobs search for title + optional company filter + location.
+    /// Uses Indeed’s `company:` advanced-search operator when a company is known.
     static func searchURL(
         query: String,
         location: String,
+        company: String? = nil,
         preferredHost: String? = nil
     ) -> URL {
         let host = regionalHost(for: location, preferredHost: preferredHost)
@@ -64,12 +79,24 @@ enum IndeedBrowseURLBuilder {
         components.host = host
         components.path = "/jobs"
         components.queryItems = [
-            URLQueryItem(name: "q", value: query.trimmingCharacters(in: .whitespacesAndNewlines)),
+            URLQueryItem(name: "q", value: searchQuery(title: query, company: company)),
             URLQueryItem(name: "l", value: location.trimmingCharacters(in: .whitespacesAndNewlines))
         ]
         return components.url ?? URL(string: "https://www.indeed.com/jobs")!
     }
 
+    /// Builds the Indeed `q` value: `"Title" company:Name` when company is present.
+    static func searchQuery(title: String, company: String?) -> String {
+        let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
+        let trimmedCompany = company?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+        guard !trimmedCompany.isEmpty else { return trimmedTitle }
+        // Quote multi-word company names so Indeed treats them as one employer.
+        let companyToken = trimmedCompany.contains(where: \.isWhitespace)
+            ? "\"\(trimmedCompany)\""
+            : trimmedCompany
+        return "\(trimmedTitle) company:\(companyToken)"
+    }
+
     /// Prefer regional TLD when location implies one; otherwise www.indeed.com.
     static func regionalHost(for location: String, preferredHost: String?) -> String {
         let lower = location.lowercased()

+ 521 - 0
indeed_ai_job_search/Services/Indeed/IndeedHTMLJobParser.swift

@@ -0,0 +1,521 @@
+//
+//  IndeedHTMLJobParser.swift
+//  indeed_ai_job_search
+//
+//  Extracts real Indeed job cards from search-page HTML / embedded JSON.
+//  Never invents employers — only maps fields present on the page.
+//
+
+import Foundation
+import OSLog
+
+enum IndeedHTMLJobParser {
+    private static let log = Logger(
+        subsystem: Bundle.main.bundleIdentifier ?? "indeed_ai_job_search",
+        category: "IndeedParser"
+    )
+
+    /// True when the HTML is Cloudflare / Indeed security-check, not results.
+    static func isBlockedOrCaptcha(_ html: String) -> Bool {
+        let lower = html.lowercased()
+        if lower.contains("indeed_cloudflare_static_page") { return true }
+        if lower.contains("additional verification required") { return true }
+        if lower.contains("security check - indeed") { return true }
+        if lower.contains("cf-browser-verification") { return true }
+        if lower.contains("attention required") && lower.contains("cloudflare") { return true }
+        return false
+    }
+
+    static func parse(html: String, pageURL: URL) throws -> [IndeedJobDTO] {
+        if isBlockedOrCaptcha(html) {
+            throw IndeedDataError.forbidden
+        }
+
+        var byKey: [String: IndeedJobDTO] = [:]
+
+        for dto in parseMosaicResults(html: html, pageURL: pageURL) {
+            byKey[dto.jobKey ?? dto.url] = dto
+        }
+        for dto in parseEmbeddedJobObjects(html: html, pageURL: pageURL) {
+            byKey[dto.jobKey ?? dto.url] = dto
+        }
+        for dto in parseDataJKCards(html: html, pageURL: pageURL) {
+            let key = dto.jobKey ?? dto.url
+            if byKey[key] == nil {
+                byKey[key] = dto
+            }
+        }
+
+        let listings = Array(byKey.values)
+        Self.log.info("Parsed \(listings.count, privacy: .public) Indeed jobs from HTML")
+        return listings
+    }
+
+    // MARK: - Mosaic / embedded JSON
+
+    private static func parseMosaicResults(html: String, pageURL: URL) -> [IndeedJobDTO] {
+        var results: [IndeedJobDTO] = []
+
+        // Common Indeed patterns for inlined job-card JSON.
+        let patterns = [
+            #"mosaic-provider-jobcards"\s*:\s*\{"#,
+            #"mosaicProviderJobCardsModel"\s*:\s*\{"#,
+            #""results"\s*:\s*\["#
+        ]
+
+        // Prefer scanning for jobkey objects directly — resilient to mosaic key renames.
+        results.append(contentsOf: extractJobObjects(from: html, pageURL: pageURL))
+        _ = patterns
+        return dedupe(results)
+    }
+
+    private static func parseEmbeddedJobObjects(html: String, pageURL: URL) -> [IndeedJobDTO] {
+        extractJobObjects(from: html, pageURL: pageURL)
+    }
+
+    /// Finds JSON-ish objects that contain a 16-char hex jobkey / jobKey.
+    private static func extractJobObjects(from html: String, pageURL: URL) -> [IndeedJobDTO] {
+        var found: [IndeedJobDTO] = []
+        let ns = html as NSString
+        let keyRegex = try! NSRegularExpression(
+            pattern: #""(?:jobkey|jobKey)"\s*:\s*"([0-9a-f]{16})""#,
+            options: [.caseInsensitive]
+        )
+        let matches = keyRegex.matches(in: html, options: [], range: NSRange(location: 0, length: ns.length))
+
+        for match in matches {
+            guard match.numberOfRanges >= 2,
+                  let keyRange = Range(match.range(at: 1), in: html) else { continue }
+            let jobKey = String(html[keyRange]).lowercased()
+
+            // Window of surrounding JSON object text for field extraction.
+            let matchLocation = match.range.location
+            let start = max(0, matchLocation - 2_500)
+            let end = min(ns.length, matchLocation + 4_000)
+            let window = ns.substring(with: NSRange(location: start, length: end - start))
+
+            guard let title = firstJSONString(in: window, keys: ["displayTitle", "title", "jobTitle"]),
+                  !title.isEmpty else { continue }
+            let company = firstJSONString(in: window, keys: ["company", "companyName", "truncatedCompanyName"])
+                ?? ""
+            guard !company.isEmpty else { continue }
+
+            let location = firstJSONString(
+                in: window,
+                keys: ["formattedLocation", "jobLocationCity", "location", "jobLocation"]
+            ) ?? ""
+
+            let snippet = firstJSONString(
+                in: window,
+                keys: ["snippet", "jobDescription", "truncatedRelativeTime"]
+            ) ?? ""
+
+            let viewPath = firstJSONString(in: window, keys: ["link", "viewJobLink", "url"])
+            let url = viewJobURL(jobKey: jobKey, host: pageURL.host, pathOrURL: viewPath)
+
+            let remote = inferRemote(
+                location: location,
+                workplace: firstJSONString(in: window, keys: ["workplaceTypes", "remoteLocation", "jobTypes"]),
+                window: window
+            )
+            let employment = inferEmployment(from: window)
+            let salary = inferSalary(from: window)
+            let posted = inferPostedAt(from: window)
+
+            found.append(
+                IndeedJobDTO(
+                    jobKey: jobKey,
+                    title: decodeHTMLEntities(title),
+                    company: decodeHTMLEntities(company),
+                    location: decodeHTMLEntities(location.isEmpty ? "Indeed" : location),
+                    remoteType: remote,
+                    employmentType: employment,
+                    experienceLevel: nil,
+                    salaryMin: salary.min,
+                    salaryMax: salary.max,
+                    salaryCurrency: salary.currency,
+                    salaryPeriod: salary.period,
+                    postedAt: posted,
+                    snippet: decodeHTMLEntities(snippet),
+                    url: url.absoluteString,
+                    availability: "open",
+                    isEasyApply: window.lowercased().contains("easyapply") || window.lowercased().contains("indeed apply"),
+                    isFeatured: window.lowercased().contains("\"sponsored\":true") || window.lowercased().contains("\"featured\":true"),
+                    isUrgentlyHiring: window.lowercased().contains("urgently")
+                )
+            )
+        }
+        return dedupe(found)
+    }
+
+    // MARK: - HTML data-jk cards
+
+    private static func parseDataJKCards(html: String, pageURL: URL) -> [IndeedJobDTO] {
+        var found: [IndeedJobDTO] = []
+        let ns = html as NSString
+        let regex = try! NSRegularExpression(
+            pattern: #"data-jk\s*=\s*"([0-9a-f]{16})""#,
+            options: [.caseInsensitive]
+        )
+        let matches = regex.matches(in: html, options: [], range: NSRange(location: 0, length: ns.length))
+
+        for match in matches {
+            guard match.numberOfRanges >= 2,
+                  let keyRange = Range(match.range(at: 1), in: html) else { continue }
+            let jobKey = String(html[keyRange]).lowercased()
+
+            let start = max(0, match.range.location - 500)
+            let end = min(ns.length, match.range.location + 6_000)
+            let window = ns.substring(with: NSRange(location: start, length: end - start))
+
+            let title = firstHTMLText(
+                in: window,
+                patterns: [
+                    #"<h2[^>]*>[\s\S]*?<a[^>]*>([\s\S]*?)</a>"#,
+                    #"data-testid="job-title"[\s\S]*?>([\s\S]*?)</"#,
+                    #"class="[^"]*jobTitle[^"]*"[\s\S]*?>([\s\S]*?)</"#
+                ]
+            )
+            let company = firstHTMLText(
+                in: window,
+                patterns: [
+                    #"data-testid="company-name"[^>]*>([\s\S]*?)</"#,
+                    #"class="[^"]*companyName[^"]*"[^>]*>([\s\S]*?)</"#,
+                    #"<span[^>]*class="[^"]*companyName[^"]*"[^>]*>([\s\S]*?)</span>"#
+                ]
+            )
+            guard let title, !title.isEmpty, let company, !company.isEmpty else { continue }
+
+            let location = firstHTMLText(
+                in: window,
+                patterns: [
+                    #"data-testid="text-location"[^>]*>([\s\S]*?)</"#,
+                    #"class="[^"]*companyLocation[^"]*"[^>]*>([\s\S]*?)</"#
+                ]
+            ) ?? ""
+
+            let salaryText = firstHTMLText(
+                in: window,
+                patterns: [
+                    #"data-testid="attribute_snippet_testid"[^>]*>([\s\S]*?)</"#,
+                    #"class="[^"]*salary-snippet[^"]*"[^>]*>([\s\S]*?)</"#
+                ]
+            )
+            let salary = parseSalaryText(salaryText)
+            let remote = inferRemote(location: location, workplace: nil, window: window)
+
+            found.append(
+                IndeedJobDTO(
+                    jobKey: jobKey,
+                    title: decodeHTMLEntities(stripTags(title)),
+                    company: decodeHTMLEntities(stripTags(company)),
+                    location: decodeHTMLEntities(stripTags(location.isEmpty ? "Indeed" : location)),
+                    remoteType: remote,
+                    employmentType: inferEmployment(from: window),
+                    experienceLevel: nil,
+                    salaryMin: salary.min,
+                    salaryMax: salary.max,
+                    salaryCurrency: salary.currency,
+                    salaryPeriod: salary.period,
+                    postedAt: nil,
+                    snippet: "",
+                    url: viewJobURL(jobKey: jobKey, host: pageURL.host, pathOrURL: nil).absoluteString,
+                    availability: "open"
+                )
+            )
+        }
+        return dedupe(found)
+    }
+
+    // MARK: - Field helpers
+
+    private static func viewJobURL(jobKey: String, host: String?, pathOrURL: String?) -> URL {
+        if let pathOrURL, let absolute = URL(string: pathOrURL), absolute.host != nil {
+            if let jk = IndeedURLCanonicalizer.extractJobKey(from: absolute) ?? Optional(jobKey),
+               IndeedBrowseURLBuilder.isLikelyRealJobKey(jk) {
+                return absolute.scheme == nil
+                    ? URL(string: "https://\(host ?? "www.indeed.com")\(pathOrURL)") ?? absolute
+                    : absolute
+            }
+        }
+        if let pathOrURL, pathOrURL.hasPrefix("/") {
+            var components = URLComponents()
+            components.scheme = "https"
+            components.host = host ?? "www.indeed.com"
+            components.path = pathOrURL.split(separator: "?").first.map(String.init) ?? "/viewjob"
+            if pathOrURL.contains("jk=") {
+                return URL(string: "https://\(host ?? "www.indeed.com")\(pathOrURL)")
+                    ?? URL(string: "https://www.indeed.com/viewjob?jk=\(jobKey)")!
+            }
+            components.queryItems = [URLQueryItem(name: "jk", value: jobKey)]
+            if let url = components.url { return url }
+        }
+
+        var components = URLComponents()
+        components.scheme = "https"
+        components.host = host ?? "www.indeed.com"
+        components.path = "/viewjob"
+        components.queryItems = [URLQueryItem(name: "jk", value: jobKey)]
+        return components.url ?? URL(string: "https://www.indeed.com/viewjob?jk=\(jobKey)")!
+    }
+
+    private static func firstJSONString(in text: String, keys: [String]) -> String? {
+        for key in keys {
+            let pattern = #""\#(key)"\s*:\s*"((?:\\.|[^"\\])*)""#
+            if let regex = try? NSRegularExpression(pattern: pattern, options: []),
+               let match = regex.firstMatch(in: text, options: [], range: NSRange(text.startIndex..., in: text)),
+               match.numberOfRanges >= 2,
+               let range = Range(match.range(at: 1), in: text) {
+                return unescapeJSONString(String(text[range]))
+            }
+        }
+        return nil
+    }
+
+    private static func firstHTMLText(in text: String, patterns: [String]) -> String? {
+        for pattern in patterns {
+            if let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]),
+               let match = regex.firstMatch(in: text, options: [], range: NSRange(text.startIndex..., in: text)),
+               match.numberOfRanges >= 2,
+               let range = Range(match.range(at: 1), in: text) {
+                let value = String(text[range]).trimmingCharacters(in: .whitespacesAndNewlines)
+                if !value.isEmpty { return value }
+            }
+        }
+        return nil
+    }
+
+    private static func inferRemote(location: String, workplace: String?, window: String) -> String? {
+        let hay = "\(location) \(workplace ?? "") \(window)".lowercased()
+        if hay.contains("\"remotetype\":\"remote\"")
+            || hay.contains("workplace type: remote")
+            || hay.contains("\"workplacetypes\":[\"remote\"]")
+            || (location.lowercased() == "remote") {
+            return "remote"
+        }
+        if hay.contains("hybrid") { return "hybrid" }
+        if hay.contains("on-site") || hay.contains("onsite") || hay.contains("in person") {
+            return "onsite"
+        }
+        return nil
+    }
+
+    private static func inferEmployment(from window: String) -> String? {
+        let hay = window.lowercased()
+        if hay.contains("full-time") || hay.contains("fulltime") || hay.contains("\"full time\"") {
+            return "full-time"
+        }
+        if hay.contains("part-time") || hay.contains("parttime") { return "part-time" }
+        if hay.contains("contract") { return "contract" }
+        if hay.contains("temporary") || hay.contains("\"temp\"") { return "temporary" }
+        if hay.contains("internship") || hay.contains("intern") { return "internship" }
+        return nil
+    }
+
+    private static func inferSalary(from window: String) -> (min: Double?, max: Double?, currency: String?, period: String?) {
+        if let text = firstJSONString(in: window, keys: ["salaryText", "text", "salarySnippet"]) {
+            return parseSalaryText(text)
+        }
+        // estimatedSalary / extractedSalary numeric objects
+        if let min = firstJSONNumber(in: window, keys: ["min", "minValue"]),
+           let max = firstJSONNumber(in: window, keys: ["max", "maxValue"]) {
+            let currency = firstJSONString(in: window, keys: ["currencyCode", "currency", "unitOfWork"]) ?? "USD"
+            let period: String
+            let hay = window.lowercased()
+            if hay.contains("hour") { period = "hour" }
+            else if hay.contains("month") { period = "month" }
+            else if hay.contains("week") { period = "week" }
+            else { period = "year" }
+            return (min, max, currency, period)
+        }
+        return (nil, nil, nil, nil)
+    }
+
+    private static func parseSalaryText(_ raw: String?) -> (min: Double?, max: Double?, currency: String?, period: String?) {
+        guard let raw else { return (nil, nil, nil, nil) }
+        let text = decodeHTMLEntities(stripTags(raw))
+        guard !text.isEmpty else { return (nil, nil, nil, nil) }
+
+        let currency: String
+        let upper = text.uppercased()
+        if upper.contains("PKR") || text.contains("Rs") || text.contains("₨") {
+            currency = "PKR"
+        } else if upper.contains("GBP") || text.contains("£") {
+            currency = "GBP"
+        } else if upper.contains("EUR") || text.contains("€") {
+            currency = "EUR"
+        } else if upper.contains("USD") || text.contains("US$") || text.contains("$") {
+            currency = "USD"
+        } else {
+            // Unknown currency symbol — do not invent.
+            return (nil, nil, nil, nil)
+        }
+
+        let period: String
+        let lower = text.lowercased()
+        if lower.contains("hour") { period = "hour" }
+        else if lower.contains("month") { period = "month" }
+        else if lower.contains("week") { period = "week" }
+        else { period = "year" }
+
+        let numberPattern = #"(\d[\d,]*(?:\.\d+)?)\s*(K)?"#
+        let regex = try! NSRegularExpression(pattern: numberPattern, options: [.caseInsensitive])
+        let ns = text as NSString
+        let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: ns.length))
+        var values: [Double] = []
+        for match in matches.prefix(4) {
+            guard let numRange = Range(match.range(at: 1), in: text) else { continue }
+            let rawNum = String(text[numRange]).replacingOccurrences(of: ",", with: "")
+            guard var value = Double(rawNum) else { continue }
+            if match.numberOfRanges >= 3,
+               match.range(at: 2).location != NSNotFound {
+                value *= 1_000
+            }
+            // Skip tiny numbers that are likely years (e.g. 2024) when salary-sized values exist.
+            values.append(value)
+        }
+        let salaryLike = values.filter { $0 >= 10 }
+        guard let first = salaryLike.first else { return (nil, nil, nil, nil) }
+        let second = salaryLike.dropFirst().first
+        return (first, second ?? first, currency, period)
+    }
+
+    private static func firstJSONNumber(in text: String, keys: [String]) -> Double? {
+        for key in keys {
+            let pattern = #""\#(key)"\s*:\s*([0-9]+(?:\.[0-9]+)?)"#
+            if let regex = try? NSRegularExpression(pattern: pattern, options: []),
+               let match = regex.firstMatch(in: text, options: [], range: NSRange(text.startIndex..., in: text)),
+               match.numberOfRanges >= 2,
+               let range = Range(match.range(at: 1), in: text) {
+                return Double(text[range])
+            }
+        }
+        return nil
+    }
+
+    private static func inferPostedAt(from window: String) -> String? {
+        if let relative = firstJSONString(in: window, keys: ["formattedRelativeTime", "pubDate"]) {
+            return relativeDateISO(from: relative) ?? relative
+        }
+        return nil
+    }
+
+    private static func relativeDateISO(from text: String) -> String? {
+        let lower = text.lowercased()
+        let now = Date()
+        let calendar = Calendar.current
+        let formatter = ISO8601DateFormatter()
+        formatter.formatOptions = [.withInternetDateTime]
+
+        if lower.contains("just now") || lower.contains("today") {
+            return formatter.string(from: now)
+        }
+        if let days = firstInt(in: lower, suffixHints: ["day"]),
+           let date = calendar.date(byAdding: .day, value: -days, to: now) {
+            return formatter.string(from: date)
+        }
+        if let hours = firstInt(in: lower, suffixHints: ["hour"]),
+           let date = calendar.date(byAdding: .hour, value: -hours, to: now) {
+            return formatter.string(from: date)
+        }
+        if let weeks = firstInt(in: lower, suffixHints: ["week"]),
+           let date = calendar.date(byAdding: .day, value: -weeks * 7, to: now) {
+            return formatter.string(from: date)
+        }
+        if let months = firstInt(in: lower, suffixHints: ["month"]),
+           let date = calendar.date(byAdding: .month, value: -months, to: now) {
+            return formatter.string(from: date)
+        }
+        // Already ISO?
+        if text.contains("T"), formatter.date(from: text) != nil {
+            return text
+        }
+        return nil
+    }
+
+    private static func firstInt(in text: String, suffixHints: [String]) -> Int? {
+        for hint in suffixHints {
+            let pattern = #"(\d+)\s+\#(hint)"#
+            if let regex = try? NSRegularExpression(pattern: pattern, options: []),
+               let match = regex.firstMatch(in: text, options: [], range: NSRange(text.startIndex..., in: text)),
+               match.numberOfRanges >= 2,
+               let range = Range(match.range(at: 1), in: text) {
+                return Int(text[range])
+            }
+            if text.contains("a \(hint)") || text.contains("an \(hint)") || text.contains("1 \(hint)") {
+                return 1
+            }
+        }
+        return nil
+    }
+
+    private static func unescapeJSONString(_ value: String) -> String {
+        value
+            .replacingOccurrences(of: "\\u0026", with: "&")
+            .replacingOccurrences(of: "\\/", with: "/")
+            .replacingOccurrences(of: "\\\"", with: "\"")
+            .replacingOccurrences(of: "\\n", with: " ")
+            .replacingOccurrences(of: "\\t", with: " ")
+    }
+
+    private static func stripTags(_ html: String) -> String {
+        let noTags = html.replacingOccurrences(
+            of: #"<[^>]+>"#,
+            with: " ",
+            options: .regularExpression
+        )
+        return noTags
+            .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+    }
+
+    private static func decodeHTMLEntities(_ text: String) -> String {
+        var result = text
+        let named: [(String, String)] = [
+            ("&amp;", "&"),
+            ("&lt;", "<"),
+            ("&gt;", ">"),
+            ("&quot;", "\""),
+            ("&#39;", "'"),
+            ("&apos;", "'"),
+            ("&nbsp;", " ")
+        ]
+        for (entity, value) in named {
+            result = result.replacingOccurrences(of: entity, with: value)
+        }
+        // Numeric entities: &#123; / &#x1F;
+        if let regex = try? NSRegularExpression(pattern: #"&#(x?[0-9a-fA-F]+);"#) {
+            let ns = result as NSString
+            let matches = regex.matches(in: result, options: [], range: NSRange(location: 0, length: ns.length)).reversed()
+            for match in matches {
+                guard match.numberOfRanges >= 2,
+                      let range = Range(match.range(at: 1), in: result) else { continue }
+                let raw = String(result[range])
+                let value: UInt32?
+                if raw.lowercased().hasPrefix("x") {
+                    value = UInt32(raw.dropFirst(), radix: 16)
+                } else {
+                    value = UInt32(raw)
+                }
+                if let value,
+                   let scalar = UnicodeScalar(value),
+                   let fullRange = Range(match.range, in: result) {
+                    result.replaceSubrange(fullRange, with: String(Character(scalar)))
+                }
+            }
+        }
+        return result.trimmingCharacters(in: .whitespacesAndNewlines)
+    }
+
+    private static func dedupe(_ items: [IndeedJobDTO]) -> [IndeedJobDTO] {
+        var seen = Set<String>()
+        var out: [IndeedJobDTO] = []
+        for item in items {
+            let key = (item.jobKey ?? item.url).lowercased()
+            if seen.insert(key).inserted {
+                out.append(item)
+            }
+        }
+        return out
+    }
+}

+ 1 - 1
indeed_ai_job_search/Services/Indeed/IndeedJobDataSource.swift

@@ -68,7 +68,7 @@ enum IndeedDataError: LocalizedError, Equatable, Sendable {
         case .unauthorized:
             return "Indeed data source credentials are missing or invalid."
         case .forbidden:
-            return "This Indeed data source is not allowed for the current region or license."
+            return "Indeed blocked this search (security check). Try again in a moment, or open Indeed in Safari once and retry."
         case .rateLimited(let seconds):
             if let seconds {
                 return "Too many requests. Try again in about \(seconds) seconds."

+ 22 - 9
indeed_ai_job_search/Services/Indeed/IndeedListingMapper.swift

@@ -60,18 +60,24 @@ enum IndeedListingMapper {
             throw IndeedDataError.invalidResponse("Missing title or company for job key \(key).")
         }
 
-        // Fake fixture keys (`fixture-…`) are not real Indeed jobs — store a live browse URL
-        // so Open on Indeed never lands on Indeed’s “expired / not available” page.
+        // Real 16-char hex jk → always open that viewjob. Fake/fixture keys → company search.
         let openURL: URL
-        if IndeedBrowseURLBuilder.isLikelyRealJobKey(key),
-           canonical.path.lowercased().contains("viewjob") {
-            openURL = canonical
-        } else if canonical.path.lowercased().contains("/jobs") {
-            openURL = canonical
+        if IndeedBrowseURLBuilder.isLikelyRealJobKey(key) {
+            if canonical.path.lowercased().contains("viewjob") {
+                openURL = canonical
+            } else {
+                var components = URLComponents()
+                components.scheme = "https"
+                components.host = canonical.host ?? "www.indeed.com"
+                components.path = "/viewjob"
+                components.queryItems = [URLQueryItem(name: "jk", value: key)]
+                openURL = components.url ?? canonical
+            }
         } else {
             openURL = IndeedBrowseURLBuilder.searchURL(
                 query: title,
                 location: location,
+                company: company,
                 preferredHost: canonical.host
             )
         }
@@ -85,7 +91,8 @@ enum IndeedListingMapper {
             employmentType: mapEmployment(dto.employmentType),
             experienceLevel: mapExperience(dto.experienceLevel),
             salary: mapSalary(dto),
-            postedAt: parseDate(dto.postedAt) ?? Date(timeIntervalSince1970: 0),
+            // Unknown posted date → "now" so freshness filters do not drop live cards.
+            postedAt: parseDate(dto.postedAt) ?? Date(),
             snippet: (dto.snippet ?? "").trimmingCharacters(in: .whitespacesAndNewlines),
             indeedURL: openURL,
             availability: mapAvailability(dto.availability),
@@ -164,6 +171,12 @@ enum IndeedListingMapper {
 
     private static func mapSalary(_ dto: IndeedJobDTO) -> SalaryRange? {
         guard dto.salaryMin != nil || dto.salaryMax != nil else { return nil }
+        // Do not invent a currency — omit salary entirely when pay currency is unknown.
+        let currency = dto.salaryCurrency?
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+            .uppercased() ?? ""
+        guard !currency.isEmpty else { return nil }
+
         let period: SalaryPeriod
         switch dto.salaryPeriod?.lowercased() {
         case "month", "monthly": period = .month
@@ -174,7 +187,7 @@ enum IndeedListingMapper {
         return SalaryRange(
             min: dto.salaryMin,
             max: dto.salaryMax,
-            currency: dto.salaryCurrency ?? "USD",
+            currency: currency,
             period: period
         )
     }

+ 112 - 0
indeed_ai_job_search/Services/Indeed/IndeedSearchURLBuilder.swift

@@ -0,0 +1,112 @@
+//
+//  IndeedSearchURLBuilder.swift
+//  indeed_ai_job_search
+//
+//  Builds regional Indeed /jobs search URLs from SearchCriteria.
+//
+
+import Foundation
+
+enum IndeedSearchURLBuilder {
+    static func searchURL(
+        criteria: SearchCriteria,
+        query: String,
+        pageIndex: Int = 0,
+        pageSize: Int = IndeedSearchPageRequest.defaultPageSize
+    ) -> URL {
+        let what = whatQuery(criteria: criteria, query: query)
+        let whereValue = whereQuery(criteria: criteria)
+        let host = regionalHost(for: criteria)
+
+        var components = URLComponents()
+        components.scheme = "https"
+        components.host = host
+        components.path = "/jobs"
+
+        var items: [URLQueryItem] = [
+            URLQueryItem(name: "q", value: what),
+            URLQueryItem(name: "l", value: whereValue)
+        ]
+
+        if criteria.remoteTypes.contains(.remote), criteria.remoteTypes.count == 1 {
+            items.append(URLQueryItem(name: "sc", value: "0kf:attr(DSQF7);"))
+        }
+
+        let start = max(0, pageIndex) * max(1, pageSize)
+        if start > 0 {
+            items.append(URLQueryItem(name: "start", value: String(start)))
+        }
+
+        components.queryItems = items
+        return components.url ?? URL(string: "https://www.indeed.com/jobs")!
+    }
+
+    static func whatQuery(criteria: SearchCriteria, query: String) -> String {
+        let title = criteria.jobTitle.trimmingCharacters(in: .whitespacesAndNewlines)
+        let keywords = criteria.keywords.trimmingCharacters(in: .whitespacesAndNewlines)
+        let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
+
+        var parts: [String] = []
+        if !title.isEmpty {
+            parts.append(criteria.exactPhrase ? "\"\(title)\"" : title)
+        } else if !trimmedQuery.isEmpty {
+            parts.append(trimmedQuery)
+        }
+        if !keywords.isEmpty, keywords.caseInsensitiveCompare(title) != .orderedSame,
+           keywords.caseInsensitiveCompare(trimmedQuery) != .orderedSame {
+            parts.append(keywords)
+        }
+
+        let excluded = criteria.excludedKeywords
+            .split(separator: " ")
+            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+            .filter { !$0.isEmpty }
+        for term in excluded {
+            parts.append("-\(term)")
+        }
+
+        let joined = parts.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
+        return joined.isEmpty ? "jobs" : joined
+    }
+
+    static func whereQuery(criteria: SearchCriteria) -> String {
+        let city = criteria.city.trimmingCharacters(in: .whitespacesAndNewlines)
+        let country = criteria.country.trimmingCharacters(in: .whitespacesAndNewlines)
+
+        if criteria.remoteTypes.contains(.remote),
+           criteria.remoteTypes.count == 1,
+           city.isEmpty {
+            return "Remote"
+        }
+
+        if !city.isEmpty, !country.isEmpty {
+            return "\(city), \(country)"
+        }
+        if !city.isEmpty { return city }
+        if !country.isEmpty { return country }
+        return ""
+    }
+
+    static func regionalHost(for criteria: SearchCriteria) -> String {
+        let haystack = "\(criteria.country) \(criteria.city)".lowercased()
+        if haystack.contains("pakistan")
+            || haystack.contains("lahore")
+            || haystack.contains("karachi")
+            || haystack.contains("islamabad") {
+            return "pk.indeed.com"
+        }
+        if haystack.contains("united kingdom")
+            || haystack.contains("london")
+            || haystack.contains(" england")
+            || haystack.hasSuffix(" uk")
+            || haystack == "uk" {
+            return "uk.indeed.com"
+        }
+        if haystack.contains("canada")
+            || haystack.contains("toronto")
+            || haystack.contains("vancouver") {
+            return "ca.indeed.com"
+        }
+        return "www.indeed.com"
+    }
+}

+ 162 - 0
indeed_ai_job_search/Services/Indeed/IndeedWebKitPageFetcher.swift

@@ -0,0 +1,162 @@
+//
+//  IndeedWebKitPageFetcher.swift
+//  indeed_ai_job_search
+//
+//  Loads Indeed search pages via WebKit (browser engine). Plain URLSession
+//  is blocked by Cloudflare captcha; WKWebView matches real Safari enough
+//  to receive job-card HTML for parsing.
+//
+
+import AppKit
+import Foundation
+import OSLog
+import WebKit
+
+@MainActor
+final class IndeedWebKitPageFetcher: NSObject {
+    static let shared = IndeedWebKitPageFetcher()
+
+    private let log = Logger(
+        subsystem: Bundle.main.bundleIdentifier ?? "indeed_ai_job_search",
+        category: "IndeedFetch"
+    )
+
+    private var webView: WKWebView?
+    /// Kept alive so WebKit completes loads off-screen (macOS often stalls without a window).
+    private var hostWindow: NSWindow?
+    private var continuation: CheckedContinuation<String, Error>?
+    private var timeoutTask: Task<Void, Never>?
+
+    private override init() {
+        super.init()
+    }
+
+    /// Fetches fully rendered page HTML for `url`.
+    func html(from url: URL, timeoutSeconds: TimeInterval = 35) async throws -> String {
+        // Serialize loads — one WKWebView at a time.
+        if continuation != nil {
+            throw IndeedDataError.network("Another Indeed page load is already in progress.")
+        }
+
+        let configuration = WKWebViewConfiguration()
+        // Persistent cookies help when Indeed has already verified this Mac in Safari/WebKit.
+        configuration.websiteDataStore = .default()
+        configuration.defaultWebpagePreferences.allowsContentJavaScript = true
+
+        let view = WKWebView(frame: CGRect(x: 0, y: 0, width: 1280, height: 900), configuration: configuration)
+        view.navigationDelegate = self
+        self.webView = view
+
+        let window = NSWindow(
+            contentRect: NSRect(x: -10_000, y: -10_000, width: 1280, height: 900),
+            styleMask: [.titled, .closable],
+            backing: .buffered,
+            defer: false
+        )
+        window.isReleasedWhenClosed = false
+        window.contentView = view
+        window.orderBack(nil)
+        self.hostWindow = window
+
+        log.info("Loading Indeed page \(url.absoluteString, privacy: .public)")
+
+        return try await withCheckedThrowingContinuation { (cont: CheckedContinuation<String, Error>) in
+            self.continuation = cont
+            self.timeoutTask = Task { [weak self] in
+                try? await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
+                await MainActor.run {
+                    guard let self, self.continuation != nil else { return }
+                    self.fail(IndeedDataError.network("Timed out loading Indeed search results."))
+                }
+            }
+            view.load(URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: timeoutSeconds))
+        }
+    }
+
+    private func tearDownWebView() {
+        webView?.navigationDelegate = nil
+        webView?.stopLoading()
+        hostWindow?.contentView = nil
+        hostWindow?.orderOut(nil)
+        hostWindow = nil
+        webView = nil
+    }
+
+    private func finish(_ html: String) {
+        timeoutTask?.cancel()
+        timeoutTask = nil
+        guard let continuation else { return }
+        self.continuation = nil
+        tearDownWebView()
+        continuation.resume(returning: html)
+    }
+
+    private func fail(_ error: Error) {
+        timeoutTask?.cancel()
+        timeoutTask = nil
+        guard let continuation else { return }
+        self.continuation = nil
+        tearDownWebView()
+        continuation.resume(throwing: error)
+    }
+
+    private func extractHTML(from webView: WKWebView) {
+        let script = """
+        (function() {
+          if (window.INDEED_CLOUDFLARE_STATIC_PAGE) {
+            return JSON.stringify({ blocked: true, html: document.documentElement.outerHTML });
+          }
+          return JSON.stringify({ blocked: false, html: document.documentElement.outerHTML });
+        })();
+        """
+        webView.evaluateJavaScript(script) { [weak self] result, error in
+            Task { @MainActor in
+                guard let self else { return }
+                if let error {
+                    self.fail(IndeedDataError.network(error.localizedDescription))
+                    return
+                }
+                guard let json = result as? String,
+                      let data = json.data(using: .utf8),
+                      let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+                      let html = payload["html"] as? String else {
+                    self.fail(IndeedDataError.invalidResponse("Could not read Indeed page HTML."))
+                    return
+                }
+                if payload["blocked"] as? Bool == true || IndeedHTMLJobParser.isBlockedOrCaptcha(html) {
+                    self.log.error("Indeed returned a security/captcha page")
+                    self.fail(IndeedDataError.forbidden)
+                    return
+                }
+                self.finish(html)
+            }
+        }
+    }
+}
+
+extension IndeedWebKitPageFetcher: WKNavigationDelegate {
+    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+        // Give mosaic job-card scripts a moment to hydrate.
+        Task { @MainActor in
+            try? await Task.sleep(nanoseconds: 900_000_000)
+            guard self.webView === webView, self.continuation != nil else { return }
+            self.extractHTML(from: webView)
+        }
+    }
+
+    func webView(
+        _ webView: WKWebView,
+        didFail navigation: WKNavigation!,
+        withError error: Error
+    ) {
+        fail(IndeedDataError.network(error.localizedDescription))
+    }
+
+    func webView(
+        _ webView: WKWebView,
+        didFailProvisionalNavigation navigation: WKNavigation!,
+        withError error: Error
+    ) {
+        fail(IndeedDataError.network(error.localizedDescription))
+    }
+}

+ 10 - 1
indeed_ai_job_search/Services/Indeed/JobListingOpener.swift

@@ -13,10 +13,14 @@ enum JobListingOpener {
         category: "JobOpen"
     )
 
-    /// Opens the listing on Indeed (real viewjob when possible; otherwise live search).
+    /// Opens the listing on Indeed (real viewjob when possible; otherwise company-scoped live search).
     @discardableResult
     static func open(_ listing: JobListing) -> URL {
         let resolved = IndeedBrowseURLBuilder.openableURL(for: listing)
+        let salarySummary = listing.salary?.formatted ?? "(none)"
+        let fidelity = IndeedBrowseURLBuilder.isLikelyRealJobKey(listing.indeedJobKey)
+            ? "real-jk"
+            : "browse-fallback"
 
         log.info(
             """
@@ -24,6 +28,11 @@ enum JobListingOpener {
               title=\(listing.title, privacy: .public)
               company=\(listing.company, privacy: .public)
               key=\(listing.indeedJobKey, privacy: .public)
+              fidelity=\(fidelity, privacy: .public)
+              location=\(listing.location, privacy: .public)
+              remote=\(listing.remoteType.rawValue, privacy: .public)
+              employment=\(listing.employmentType?.rawValue ?? "(none)", privacy: .public)
+              salary=\(salarySummary, privacy: .public)
               availability=\(listing.availability.rawValue, privacy: .public)
               storedURL=\(listing.indeedURL.absoluteString, privacy: .public)
               openURL=\(resolved.url.absoluteString, privacy: .public)

+ 100 - 0
indeed_ai_job_search/Services/Indeed/LiveIndeedJobDataSource.swift

@@ -0,0 +1,100 @@
+//
+//  LiveIndeedJobDataSource.swift
+//  indeed_ai_job_search
+//
+//  Production Indeed search: load live /jobs pages, parse real job cards,
+//  map through IndeedListingMapper. Never returns mock/demo listings.
+//
+
+import Foundation
+import OSLog
+
+struct LiveIndeedJobDataSource: IndeedJobDataSource {
+    private static let log = Logger(
+        subsystem: Bundle.main.bundleIdentifier ?? "indeed_ai_job_search",
+        category: "LiveIndeed"
+    )
+
+    var pageSize: Int = IndeedSearchPageRequest.defaultPageSize
+
+    func searchPage(
+        criteria: SearchCriteria,
+        query: String,
+        page: IndeedSearchPageRequest
+    ) async throws -> IndeedSearchPage {
+        let url = IndeedSearchURLBuilder.searchURL(
+            criteria: criteria,
+            query: query,
+            pageIndex: page.pageIndex,
+            pageSize: page.pageSize > 0 ? page.pageSize : pageSize
+        )
+
+        Self.log.info(
+            "Live Indeed fetch page=\(page.pageIndex, privacy: .public) url=\(url.absoluteString, privacy: .public)"
+        )
+
+        let html: String
+        do {
+            html = try await IndeedWebKitPageFetcher.shared.html(from: url)
+        } catch let error as IndeedDataError {
+            throw error
+        } catch is CancellationError {
+            throw IndeedDataError.cancelled
+        } catch {
+            throw IndeedDataError.network(error.localizedDescription)
+        }
+
+        let dtos: [IndeedJobDTO]
+        do {
+            dtos = try IndeedHTMLJobParser.parse(html: html, pageURL: url)
+        } catch let error as IndeedDataError {
+            throw error
+        } catch {
+            throw IndeedDataError.invalidResponse(error.localizedDescription)
+        }
+
+        var listings: [JobListing] = []
+        listings.reserveCapacity(dtos.count)
+        for dto in dtos {
+            do {
+                let listing = try IndeedListingMapper.map(dto)
+                // Only accept real Indeed job keys — never demo ios00x / fixture keys.
+                guard IndeedBrowseURLBuilder.isLikelyRealJobKey(listing.indeedJobKey) else {
+                    Self.log.warning(
+                        "Skipping non-real jk=\(listing.indeedJobKey, privacy: .public) title=\(listing.title, privacy: .public)"
+                    )
+                    continue
+                }
+                listings.append(listing)
+            } catch {
+                Self.log.warning(
+                    "Skipping unmappable Indeed card: \(error.localizedDescription, privacy: .public)"
+                )
+            }
+        }
+
+        // Apply local criteria the page may not have enforced.
+        listings = JobListingFilter.filter(listings, criteria: criteria, query: query)
+
+        Self.log.info(
+            "Live Indeed page \(page.pageIndex, privacy: .public) → \(listings.count, privacy: .public) real listings"
+        )
+
+        // Indeed usually returns ~15 cards per page while our request size is 25.
+        // Only claim another page when this page looks full — otherwise page-1
+        // loads often hang and used to wipe a successful page-0 result.
+        let requestedSize = page.pageSize > 0 ? page.pageSize : pageSize
+        let looksFull = listings.count >= max(15, requestedSize - 5)
+        let canPage = page.pageIndex + 1 < IndeedCachePolicy.maxPagesPerSearchUI
+        let hasMore = looksFull && canPage && !listings.isEmpty
+
+        return IndeedSearchPage(
+            listings: listings,
+            pageIndex: page.pageIndex,
+            pageSize: page.pageSize,
+            totalEstimated: listings.count,
+            nextCursor: hasMore ? "start-\((page.pageIndex + 1) * page.pageSize)" : nil,
+            hasMore: hasMore
+        )
+    }
+}

+ 4 - 4
indeed_ai_job_search/Services/Search/JobListingFilter.swift

@@ -71,16 +71,16 @@ enum JobListingFilter {
             return false
         }
 
+        // Live Indeed cards often omit employment/experience — only drop when we
+        // know the value and it conflicts with the filter (keep unknowns).
         if !criteria.employmentTypes.isEmpty {
-            guard let type = job.employmentType,
-                  criteria.employmentTypes.contains(type) else {
+            if let type = job.employmentType, !criteria.employmentTypes.contains(type) {
                 return false
             }
         }
 
         if !criteria.experienceLevels.isEmpty {
-            guard let level = job.experienceLevel,
-                  criteria.experienceLevels.contains(level) else {
+            if let level = job.experienceLevel, !criteria.experienceLevels.contains(level) {
                 return false
             }
         }

+ 48 - 9
indeed_ai_job_search/Services/Search/JobSearchPipeline.swift

@@ -118,13 +118,33 @@ struct DefaultJobSearchPipeline: JobSearchPipeline {
         // Final dedupe after ranking in case batches reintroduced keys.
         listings = JobListingDeduper.dedupe(listings)
 
+        var browseFallbackCount = 0
+        var realJobKeyCount = 0
         for listing in listings {
             let resolved = IndeedBrowseURLBuilder.openableURL(for: listing)
+            if IndeedBrowseURLBuilder.isLikelyRealJobKey(listing.indeedJobKey) {
+                realJobKeyCount += 1
+            } else {
+                browseFallbackCount += 1
+            }
             if resolved.usedBrowseFallback, let reason = resolved.reason {
                 Self.log.info(
                     "Open URL note key=\(listing.indeedJobKey, privacy: .public): \(reason, privacy: .public)"
                 )
             }
+            let salarySummary = listing.salary?.formatted ?? "(none)"
+            // Info (not debug) so Xcode console shows fidelity while tuning interim mocks / vendor.
+            Self.log.info(
+                """
+                Result key=\(listing.indeedJobKey, privacy: .public) \
+                company=\(listing.company, privacy: .public) \
+                title=\(listing.title, privacy: .public) \
+                remote=\(listing.remoteType.rawValue, privacy: .public) \
+                location=\(listing.location, privacy: .public) \
+                salary=\(salarySummary, privacy: .public) \
+                openURL=\(resolved.url.absoluteString, privacy: .public)
+                """
+            )
         }
 
         let total = fetched.totalEstimated ?? listings.count
@@ -134,7 +154,14 @@ struct DefaultJobSearchPipeline: JobSearchPipeline {
             criteria: criteria
         )
 
-        Self.log.info("Search complete — \(listings.count, privacy: .public) listings (estimated \(total, privacy: .public))")
+        Self.log.info(
+            """
+            Search complete — \(listings.count, privacy: .public) listings \
+            (estimated \(total, privacy: .public); \
+            real jk=\(realJobKeyCount, privacy: .public), \
+            browse fallback=\(browseFallbackCount, privacy: .public))
+            """
+        )
 
         return JobSearchPipelineResult(
             interpreted: InterpretedSearch(
@@ -158,15 +185,27 @@ struct DefaultJobSearchPipeline: JobSearchPipeline {
         var pageIndex = 0
 
         while pageIndex < maxPages {
-            let page = try await dataSource.searchPage(
-                criteria: criteria,
-                query: query,
-                page: IndeedSearchPageRequest(
-                    pageIndex: pageIndex,
-                    pageSize: IndeedSearchPageRequest.defaultPageSize,
-                    cursor: cursor
+            let page: IndeedSearchPage
+            do {
+                page = try await dataSource.searchPage(
+                    criteria: criteria,
+                    query: query,
+                    page: IndeedSearchPageRequest(
+                        pageIndex: pageIndex,
+                        pageSize: IndeedSearchPageRequest.defaultPageSize,
+                        cursor: cursor
+                    )
                 )
-            )
+            } catch {
+                // Never discard a successful earlier page because a later page timed out.
+                if !all.isEmpty {
+                    Self.log.warning(
+                        "Stopping pagination after page \(pageIndex, privacy: .public) failed — keeping \(all.count, privacy: .public) listings. Error: \(error.localizedDescription, privacy: .public)"
+                    )
+                    break
+                }
+                throw error
+            }
 
             all.append(contentsOf: page.listings)
             if totalEstimated == nil {

+ 146 - 0
indeed_ai_job_searchTests/IndeedHTMLJobParserTests.swift

@@ -0,0 +1,146 @@
+//
+//  IndeedHTMLJobParserTests.swift
+//  indeed_ai_job_searchTests
+//
+
+import XCTest
+@testable import indeed_ai_job_search
+
+final class IndeedHTMLJobParserTests: XCTestCase {
+    func testDetectsCaptchaPage() {
+        let html = """
+        <html><title>Security Check - Indeed.com</title>
+        <script>window.INDEED_CLOUDFLARE_STATIC_PAGE={PAGE_TYPE:"captcha"};</script>
+        </html>
+        """
+        XCTAssertTrue(IndeedHTMLJobParser.isBlockedOrCaptcha(html))
+        XCTAssertThrowsError(
+            try IndeedHTMLJobParser.parse(
+                html: html,
+                pageURL: URL(string: "https://www.indeed.com/jobs")!
+            )
+        ) { error in
+            guard case IndeedDataError.forbidden = error else {
+                return XCTFail("Expected forbidden, got \(error)")
+            }
+        }
+    }
+
+    func testParsesEmbeddedJobKeyJSON() throws {
+        let html = """
+        <html><body>
+        <script>
+        var results = [
+          {
+            "jobkey": "5185d0dd8c286093",
+            "displayTitle": "iOS Engineer",
+            "company": "Tuyo",
+            "formattedLocation": "Remote",
+            "snippet": "Build SwiftUI apps",
+            "link": "/viewjob?jk=5185d0dd8c286093",
+            "jobTypes": ["Full-time"],
+            "salarySnippet": { "text": "$120,000 - $160,000 a year" }
+          },
+          {
+            "jobKey": "a1b2c3d4e5f60718",
+            "title": "Senior iOS Developer",
+            "companyName": "Acme Labs",
+            "formattedLocation": "Lahore, Pakistan",
+            "snippet": "PKR roles",
+            "url": "https://pk.indeed.com/viewjob?jk=a1b2c3d4e5f60718"
+          }
+        ];
+        </script>
+        <div data-jk="5185d0dd8c286093"></div>
+        </body></html>
+        """
+
+        let pageURL = URL(string: "https://www.indeed.com/jobs?q=iOS")!
+        let dtos = try IndeedHTMLJobParser.parse(html: html, pageURL: pageURL)
+        XCTAssertGreaterThanOrEqual(dtos.count, 2)
+
+        let tuyo = try XCTUnwrap(dtos.first { $0.jobKey == "5185d0dd8c286093" })
+        XCTAssertEqual(tuyo.title, "iOS Engineer")
+        XCTAssertEqual(tuyo.company, "Tuyo")
+        XCTAssertTrue(tuyo.url.contains("5185d0dd8c286093"))
+        XCTAssertTrue(IndeedBrowseURLBuilder.isLikelyRealJobKey(tuyo.jobKey ?? ""))
+
+        let mapped = try IndeedListingMapper.map(tuyo)
+        XCTAssertEqual(mapped.company, "Tuyo")
+        XCTAssertTrue(mapped.indeedURL.absoluteString.contains("viewjob"))
+        XCTAssertFalse(IndeedBrowseURLBuilder.openableURL(for: mapped).usedBrowseFallback)
+    }
+
+    func testParsesDataJKHTMLCards() throws {
+        let html = """
+        <html><body>
+          <div class="cardOutline" data-jk="abcdef0123456789">
+            <h2 class="jobTitle"><a href="/viewjob?jk=abcdef0123456789">SwiftUI Developer</a></h2>
+            <span data-testid="company-name">Nexlify Studio</span>
+            <div data-testid="text-location">Lahore, Pakistan</div>
+            <div data-testid="attribute_snippet_testid">Rs 1,800,000 - Rs 2,800,000 a year</div>
+          </div>
+        </body></html>
+        """
+        let dtos = try IndeedHTMLJobParser.parse(
+            html: html,
+            pageURL: URL(string: "https://pk.indeed.com/jobs")!
+        )
+        let job = try XCTUnwrap(dtos.first)
+        XCTAssertEqual(job.jobKey, "abcdef0123456789")
+        XCTAssertEqual(job.title, "SwiftUI Developer")
+        XCTAssertEqual(job.company, "Nexlify Studio")
+        XCTAssertEqual(job.salaryCurrency, "PKR")
+    }
+}
+
+final class IndeedSearchURLBuilderTests: XCTestCase {
+    func testBuildsPakistanHostFromCountry() {
+        var criteria = SearchCriteria()
+        criteria.jobTitle = "iOS Developer"
+        criteria.country = "Pakistan"
+        criteria.city = "Lahore"
+        let url = IndeedSearchURLBuilder.searchURL(criteria: criteria, query: "iOS developer")
+        XCTAssertEqual(url.host, "pk.indeed.com")
+        XCTAssertTrue(url.path.contains("/jobs"))
+        let q = URLComponents(url: url, resolvingAgainstBaseURL: false)?
+            .queryItems?.first { $0.name == "q" }?.value
+        XCTAssertEqual(q, "iOS Developer")
+    }
+
+    func testRemoteOnlySetsLocationRemote() {
+        var criteria = SearchCriteria()
+        criteria.jobTitle = "iOS"
+        criteria.remoteTypes = [.remote]
+        let url = IndeedSearchURLBuilder.searchURL(criteria: criteria, query: "iOS")
+        let loc = URLComponents(url: url, resolvingAgainstBaseURL: false)?
+            .queryItems?.first { $0.name == "l" }?.value
+        XCTAssertEqual(loc, "Remote")
+    }
+}
+
+final class JobListingFilterUnknownFieldTests: XCTestCase {
+    func testKeepsListingWhenEmploymentUnknownButFilterSet() {
+        var listing = JobListing(
+            indeedJobKey: "5185d0dd8c286093",
+            title: "iOS Developer",
+            company: "Acme",
+            location: "Remote",
+            remoteType: .remote,
+            employmentType: nil,
+            postedAt: Date(),
+            snippet: "Swift",
+            indeedURL: URL(string: "https://www.indeed.com/viewjob?jk=5185d0dd8c286093")!
+        )
+        var criteria = SearchCriteria()
+        criteria.jobTitle = "iOS"
+        criteria.employmentTypes = [.fullTime]
+
+        let filtered = JobListingFilter.filter([listing], criteria: criteria, query: "iOS")
+        XCTAssertEqual(filtered.count, 1)
+
+        listing.employmentType = .partTime
+        let dropped = JobListingFilter.filter([listing], criteria: criteria, query: "iOS")
+        XCTAssertTrue(dropped.isEmpty)
+    }
+}

+ 21 - 0
indeed_ai_job_searchTests/IndeedListingMapperTests.swift

@@ -89,6 +89,27 @@ final class IndeedListingMapperTests: XCTestCase {
         XCTAssertTrue(android.indeedJobKey.hasPrefix("url-"))
         XCTAssertTrue(android.indeedURL.path.contains("/jobs"))
         XCTAssertTrue(IndeedURLCanonicalizer.isIndeedHost(android.indeedURL.host ?? ""))
+        let q = URLComponents(url: android.indeedURL, resolvingAgainstBaseURL: false)?
+            .queryItems?
+            .first { $0.name == "q" }?
+            .value ?? ""
+        XCTAssertTrue(q.contains("company:\"Karachi Labs\"") || q.contains("company:Karachi"), q)
+        XCTAssertNil(android.salary, "Salary omitted when currency/amounts are not provided")
+    }
+
+    func testOmitsSalaryWhenCurrencyMissing() throws {
+        let dto = IndeedJobDTO(
+            jobKey: "fixture-no-currency",
+            title: "iOS Developer",
+            company: "Acme",
+            location: "Lahore, Pakistan",
+            salaryMin: 100,
+            salaryMax: 200,
+            salaryCurrency: nil,
+            url: "https://pk.indeed.com/jobs?q=iOS"
+        )
+        let listing = try IndeedListingMapper.map(dto)
+        XCTAssertNil(listing.salary)
     }
 
     func testRejectsNonIndeedURL() {

+ 12 - 0
indeed_ai_job_searchTests/JobSearchPipelineTests.swift

@@ -134,6 +134,18 @@ final class IndeedBrowseURLBuilderTests: XCTestCase {
         XCTAssertTrue(resolved.url.path.contains("/jobs"))
         XCTAssertEqual(resolved.url.host, "pk.indeed.com")
         XCTAssertFalse(resolved.url.absoluteString.contains("viewjob"))
+        // Company must be in `q` so Indeed does not show an unrelated employer.
+        let q = URLComponents(url: resolved.url, resolvingAgainstBaseURL: false)?
+            .queryItems?
+            .first { $0.name == "q" }?
+            .value ?? ""
+        XCTAssertTrue(q.contains("company:Acme"), "expected company filter in q, got \(q)")
+        XCTAssertTrue(q.contains("iOS Developer"))
+    }
+
+    func testSearchQueryQuotesMultiWordCompany() {
+        let q = IndeedBrowseURLBuilder.searchQuery(title: "iOS Developer", company: "Systems Limited")
+        XCTAssertEqual(q, "iOS Developer company:\"Systems Limited\"")
     }
 
     func testRealHexJobKeyKeepsViewJob() {