krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

e73d58ee5dd43eaeaaa717f7781cc22256df30f0

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-22T03:54:40Z

feat(v2.4.0): add JSON output, CLI foundation, and shared report layer

* introduce DomainReport as canonical output model
* add JSON export for single and batch results
* create DomainReportBuilder for reusable report construction
* add CLI target using shared inspection pipeline
* refactor services for UI-independent usage
* ensure consistency across TXT, CSV, and JSON outputs
 DomainDig.xcodeproj/project.pbxproj                | 123 +++++-
 .../xcschemes/xcschememanagement.plist             |   5 +
 DomainDig/ContentView.swift                        |  56 ++-
 DomainDig/DomainViewModel.swift                    | 430 ++++-----------------
 DomainDig/ExportPresenter.swift                    |   6 +-
 DomainDig/WatchlistView.swift                      |  29 +-
 DomainDigCLI.swift                                 |  39 ++
 DomainInspectionService.swift                      | 225 +++++++++++
 DomainReportBuilder.swift                          | 190 +++++++++
 DomainReportExporter.swift                         | 367 ++++++++++++++++++
 LookupSnapshot.swift                               |  89 +++++
 11 files changed, 1168 insertions(+), 391 deletions(-)

diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index 6ce65e4..40dadc9 100644
--- a/DomainDig.xcodeproj/project.pbxproj
+++ b/DomainDig.xcodeproj/project.pbxproj
@@ -6,25 +6,59 @@
 	objectVersion = 77;
 	objects = {
 
+/* Begin PBXBuildFile section */
+		8BBFEF082F9874AE00E8E144 /* DomainDigCLI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */; };
+		8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */; };
+		8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; };
+		8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; };
+		8BBFEF0C2F9874AE00E8E144 /* LookupSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */; };
+/* End PBXBuildFile section */
+
 /* Begin PBXFileReference section */
 		8B7800692F6090E300933221 /* DomainDig.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DomainDig.app; sourceTree = BUILT_PRODUCTS_DIR; };
+		8BF124F92F70000100933221 /* domaindig */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = domaindig; sourceTree = BUILT_PRODUCTS_DIR; };
+		8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDigCLI.swift; sourceTree = "<group>"; };
+		8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainInspectionService.swift; sourceTree = "<group>"; };
+		8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportBuilder.swift; sourceTree = "<group>"; };
+		8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportExporter.swift; sourceTree = "<group>"; };
+		8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LookupSnapshot.swift; sourceTree = "<group>"; };
 /* End PBXFileReference section */
 
 /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
 		8B1B506D2F666F64005C246F /* Exceptions for "DomainDig" folder in "DomainDig" target */ = {
 			isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
 			membershipExceptions = (
+				DomainDigCLI.swift,
 				Info.plist,
 			);
 			target = 8B7800682F6090E300933221 /* DomainDig */;
 		};
 /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
+		8BF124FA2F70000100933221 /* Exceptions for "DomainDig" folder in "DomainDigCLI" target */ = {
+			isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+			membershipExceptions = (
+				Assets.xcassets,
+				BatchResultsView.swift,
+				BatchSweepSummaryView.swift,
+				ContentView.swift,
+				DomainDigApp.swift,
+				DomainViewModel.swift,
+				ExportPresenter.swift,
+				HistoryView.swift,
+				Info.plist,
+				LocalNotificationService.swift,
+				SavedDomainsView.swift,
+				WatchlistView.swift,
+			);
+			target = 8BF124FB2F70000100933221 /* DomainDigCLI */;
+		};
 
 /* Begin PBXFileSystemSynchronizedRootGroup section */
 		8B78006B2F6090E300933221 /* DomainDig */ = {
 			isa = PBXFileSystemSynchronizedRootGroup;
 			exceptions = (
 				8B1B506D2F666F64005C246F /* Exceptions for "DomainDig" folder in "DomainDig" target */,
+				8BF124FA2F70000100933221 /* Exceptions for "DomainDig" folder in "DomainDigCLI" target */,
 			);
 			path = DomainDig;
 			sourceTree = "<group>";
@@ -39,6 +73,13 @@
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
+		8BF124FD2F70000100933221 /* Frameworks */ = {
+			isa = PBXFrameworksBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
 /* End PBXFrameworksBuildPhase section */
 
 /* Begin PBXGroup section */
@@ -47,6 +88,11 @@
 			children = (
 				8B78006B2F6090E300933221 /* DomainDig */,
 				8B78006A2F6090E300933221 /* Products */,
+				8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */,
+				8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */,
+				8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */,
+				8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */,
+				8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */,
 			);
 			sourceTree = "<group>";
 		};
@@ -54,6 +100,7 @@
 			isa = PBXGroup;
 			children = (
 				8B7800692F6090E300933221 /* DomainDig.app */,
+				8BF124F92F70000100933221 /* domaindig */,
 			);
 			name = Products;
 			sourceTree = "<group>";
@@ -83,6 +130,27 @@
 			productReference = 8B7800692F6090E300933221 /* DomainDig.app */;
 			productType = "com.apple.product-type.application";
 		};
+		8BF124FB2F70000100933221 /* DomainDigCLI */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = 8BF125042F70000100933221 /* Build configuration list for PBXNativeTarget "DomainDigCLI" */;
+			buildPhases = (
+				8BF124FC2F70000100933221 /* Sources */,
+				8BF124FD2F70000100933221 /* Frameworks */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+			);
+			fileSystemSynchronizedGroups = (
+				8B78006B2F6090E300933221 /* DomainDig */,
+			);
+			name = DomainDigCLI;
+			packageProductDependencies = (
+			);
+			productName = domaindig;
+			productReference = 8BF124F92F70000100933221 /* domaindig */;
+			productType = "com.apple.product-type.tool";
+		};
 /* End PBXNativeTarget section */
 
 /* Begin PBXProject section */
@@ -113,6 +181,7 @@
 			projectRoot = "";
 			targets = (
 				8B7800682F6090E300933221 /* DomainDig */,
+				8BF124FB2F70000100933221 /* DomainDigCLI */,
 			);
 		};
 /* End PBXProject section */
@@ -132,6 +201,11 @@
 			isa = PBXSourcesBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				8BBFEF082F9874AE00E8E144 /* DomainDigCLI.swift in Sources */,
+				8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */,
+				8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */,
+				8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */,
+				8BBFEF0C2F9874AE00E8E144 /* LookupSnapshot.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
@@ -267,7 +341,7 @@
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 16;
+				CURRENT_PROJECT_VERSION = 17;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -284,7 +358,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.3.0;
+				MARKETING_VERSION = 2.4.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -303,7 +377,7 @@
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 16;
+				CURRENT_PROJECT_VERSION = 17;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -320,7 +394,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.3.0;
+				MARKETING_VERSION = 2.4.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -333,6 +407,47 @@
 			};
 			name = Release;
 		};
+		8BF125002F70000100933221 /* Debug */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				CODE_SIGNING_ALLOWED = NO;
+				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 17;
+				DEVELOPMENT_TEAM = ZCNAX3VL9D;
+				ENABLE_TESTABILITY = YES;
+				GENERATE_INFOPLIST_FILE = YES;
+				MACOSX_DEPLOYMENT_TARGET = 14.0;
+				MARKETING_VERSION = 2.4.0;
+				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigCLI;
+				PRODUCT_NAME = domaindig;
+				SDKROOT = macosx;
+				SWIFT_APPROACHABLE_CONCURRENCY = YES;
+				SWIFT_EMIT_LOC_STRINGS = NO;
+				SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+				SWIFT_VERSION = 5.0;
+			};
+			name = Debug;
+		};
+		8BF125012F70000100933221 /* Release */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				CODE_SIGNING_ALLOWED = NO;
+				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 17;
+				DEVELOPMENT_TEAM = ZCNAX3VL9D;
+				GENERATE_INFOPLIST_FILE = YES;
+				MACOSX_DEPLOYMENT_TARGET = 14.0;
+				MARKETING_VERSION = 2.4.0;
+				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigCLI;
+				PRODUCT_NAME = domaindig;
+				SDKROOT = macosx;
+				SWIFT_APPROACHABLE_CONCURRENCY = YES;
+				SWIFT_EMIT_LOC_STRINGS = NO;
+				SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+				SWIFT_VERSION = 5.0;
+			};
+			name = Release;
+		};
 /* End XCBuildConfiguration section */
 
 /* Begin XCConfigurationList section */
diff --git a/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist b/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist
index bd2f39c..06d0111 100644
--- a/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist
+++ b/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -5,6 +5,11 @@
 	<key>SchemeUserState</key>
 	<dict>
 		<key>DomainDig.xcscheme_^#shared#^_</key>
+		<dict>
+			<key>orderHint</key>
+			<integer>1</integer>
+		</dict>
+		<key>DomainDigCLI.xcscheme_^#shared#^_</key>
 		<dict>
 			<key>orderHint</key>
 			<integer>0</integer>
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index 261b7ca..c5fc1fa 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -304,10 +304,13 @@ struct ContentView: View {
                 }
                 Menu {
                     Button("Export TXT") {
-                        shareSingleResults(asCSV: false)
+                        shareSingleResults(format: .text)
                     }
                     Button("Export CSV") {
-                        shareSingleResults(asCSV: true)
+                        shareSingleResults(format: .csv)
+                    }
+                    Button("Export JSON") {
+                        shareSingleResults(format: .json)
                     }
                 } label: {
                     Image(systemName: "square.and.arrow.up")
@@ -332,10 +335,13 @@ struct ContentView: View {
                 if !viewModel.currentBatchResultEntries.isEmpty {
                     Menu {
                         Button("Export Batch TXT") {
-                            shareBatchResults(asCSV: false)
+                            shareBatchResults(format: .text)
                         }
                         Button("Export Batch CSV") {
-                            shareBatchResults(asCSV: true)
+                            shareBatchResults(format: .csv)
+                        }
+                        Button("Export Batch JSON") {
+                            shareBatchResults(format: .json)
                         }
                     } label: {
                         Label("Export", systemImage: "square.and.arrow.up")
@@ -412,33 +418,51 @@ struct ContentView: View {
         return ports
     }
 
-    private func shareSingleResults(asCSV: Bool) {
-        let (filename, contents) = exportPayload(
+    private func shareSingleResults(format: DomainExportFormat) {
+        let (filename, data) = exportPayload(
             prefix: "domaindig_single",
+            format: format,
             text: viewModel.exportText(),
             csv: viewModel.exportCSV(),
-            asCSV: asCSV
+            json: viewModel.exportJSONData()
         )
-        ExportPresenter.share(filename: filename, contents: contents)
+        ExportPresenter.share(filename: filename, data: data)
     }
 
-    private func shareBatchResults(asCSV: Bool) {
-        let (filename, contents) = exportPayload(
+    private func shareBatchResults(format: DomainExportFormat) {
+        let (filename, data) = exportPayload(
             prefix: "domaindig_batch",
+            format: format,
             text: viewModel.exportBatchText(),
             csv: viewModel.exportBatchCSV(),
-            asCSV: asCSV
+            json: viewModel.exportBatchJSONData()
         )
-        ExportPresenter.share(filename: filename, contents: contents)
+        ExportPresenter.share(filename: filename, data: data)
     }
 
-    private func exportPayload(prefix: String, text: String, csv: String, asCSV: Bool) -> (String, String) {
+    private func exportPayload(
+        prefix: String,
+        format: DomainExportFormat,
+        text: String,
+        csv: String,
+        json: Data?
+    ) -> (String, Data) {
         let formatter = DateFormatter()
         formatter.dateFormat = "yyyyMMdd_HHmmss"
         let timestamp = formatter.string(from: Date())
-        let fileExtension = asCSV ? "csv" : "txt"
-        let filename = "\(timestamp)_\(prefix).\(fileExtension)"
-        return (filename, asCSV ? csv : text)
+        let filename = "\(timestamp)_\(prefix).\(format.fileExtension)"
+        let data: Data
+
+        switch format {
+        case .text:
+            data = Data(text.utf8)
+        case .csv:
+            data = Data(csv.utf8)
+        case .json:
+            data = json ?? Data("[]".utf8)
+        }
+
+        return (filename, data)
     }
 }
 
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index e895ce6..71209dc 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -91,94 +91,6 @@ struct DomainSuggestionViewData: Identifiable {
     let tone: ResultTone
 }
 
-struct LookupSnapshot {
-    let historyEntryID: UUID?
-    let domain: String
-    let timestamp: Date
-    let trackedDomainID: UUID?
-    let resolverDisplayName: String
-    let resolverURLString: String
-    let totalLookupDurationMs: Int?
-    let dnsSections: [DNSSection]
-    let dnsError: String?
-    let availabilityResult: DomainAvailabilityResult?
-    let suggestions: [DomainSuggestionResult]
-    let sslInfo: SSLCertificateInfo?
-    let sslError: String?
-    let hstsPreloaded: Bool?
-    let httpHeaders: [HTTPHeader]
-    let httpSecurityGrade: String?
-    let httpStatusCode: Int?
-    let httpResponseTimeMs: Int?
-    let httpProtocol: String?
-    let http3Advertised: Bool
-    let httpHeadersError: String?
-    let reachabilityResults: [PortReachability]
-    let reachabilityError: String?
-    let ipGeolocation: IPGeolocation?
-    let ipGeolocationError: String?
-    let emailSecurity: EmailSecurityResult?
-    let emailSecurityError: String?
-    let ownership: DomainOwnership?
-    let ownershipError: String?
-    let ptrRecord: String?
-    let ptrError: String?
-    let redirectChain: [RedirectHop]
-    let redirectChainError: String?
-    let subdomains: [DiscoveredSubdomain]
-    let subdomainsError: String?
-    let portScanResults: [PortScanResult]
-    let portScanError: String?
-    let changeSummary: DomainChangeSummary?
-    let isLive: Bool
-}
-
-extension HistoryEntry {
-    var snapshot: LookupSnapshot {
-        LookupSnapshot(
-            historyEntryID: id,
-            domain: domain,
-            timestamp: timestamp,
-            trackedDomainID: trackedDomainID,
-            resolverDisplayName: resolverDisplayName,
-            resolverURLString: resolverURLString,
-            totalLookupDurationMs: totalLookupDurationMs,
-            dnsSections: dnsSections,
-            dnsError: nil,
-            availabilityResult: availabilityResult,
-            suggestions: suggestions,
-            sslInfo: sslInfo,
-            sslError: sslError,
-            hstsPreloaded: hstsPreloaded,
-            httpHeaders: httpHeaders,
-            httpSecurityGrade: HTTPSecurityGrade.grade(for: httpHeaders).rawValue,
-            httpStatusCode: nil,
-            httpResponseTimeMs: nil,
-            httpProtocol: nil,
-            http3Advertised: false,
-            httpHeadersError: httpHeadersError,
-            reachabilityResults: reachabilityResults,
-            reachabilityError: reachabilityError,
-            ipGeolocation: ipGeolocation,
-            ipGeolocationError: ipGeolocationError,
-            emailSecurity: emailSecurity,
-            emailSecurityError: emailSecurityError,
-            ownership: ownership,
-            ownershipError: ownershipError,
-            ptrRecord: ptrRecord,
-            ptrError: ptrError,
-            redirectChain: redirectChain,
-            redirectChainError: redirectChainError,
-            subdomains: subdomains,
-            subdomainsError: subdomainsError,
-            portScanResults: portScanResults,
-            portScanError: portScanError,
-            changeSummary: changeSummary,
-            isLive: false
-        )
-    }
-}
-
 private struct BatchLookupPayload {
     let snapshot: LookupSnapshot
 }
@@ -271,6 +183,7 @@ final class DomainViewModel {
     private var lookupStartedAt: Date?
     private var activeBatchDomains: [String] = []
     private var lastBatchStartedAt: Date?
+    private let reportBuilder = DomainReportBuilder()
 
     private static let recentSearchesKey = "recentSearches"
     private static let maxRecent = 20
@@ -484,6 +397,18 @@ final class DomainViewModel {
         )
     }
 
+    var currentReport: DomainReport? {
+        guard !searchedDomain.isEmpty else { return nil }
+        return reportBuilder.build(
+            from: currentSnapshot,
+            previousSnapshot: previousSnapshot(
+                for: searchedDomain,
+                trackedDomainID: currentTrackedDomain?.id,
+                replacingLatest: false
+            )
+        )
+    }
+
     var summaryFields: [SummaryFieldViewData] {
         Self.summaryFields(from: currentSnapshot)
     }
@@ -768,64 +693,55 @@ final class DomainViewModel {
     }
 
     func exportText() -> String {
-        Self.formatExportText(
-            from: currentSnapshot,
-            trackedDomain: currentTrackedDomain,
-            changeSummary: currentChangeSummary,
-            diffSections: currentDiffSections
-        )
+        guard let currentReport else { return "No results available." }
+        return DomainReportExporter.text(for: currentReport)
     }
 
     func exportCSV() -> String {
-        Self.formatCSV(from: [currentSnapshot])
+        guard let currentReport else { return DomainReportExporter.csv(for: []) }
+        return DomainReportExporter.csv(for: [currentReport])
+    }
+
+    func exportJSONData() -> Data? {
+        guard let currentReport else { return nil }
+        return try? DomainReportExporter.data(for: currentReport, format: .json)
     }
 
     func exportBatchText() -> String {
-        Self.formatBatchExportText(
-            title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export",
-            entries: currentBatchResultEntries.map { entry in
-                (
-                    snapshot: entry.snapshot,
-                    trackedDomain: trackedDomains.first(where: { tracked in
-                        tracked.id == entry.trackedDomainID ||
-                        tracked.domain.caseInsensitiveCompare(entry.domain) == .orderedSame
-                    }),
-                    changeSummary: entry.changeSummary,
-                    diffSections: comparisonSnapshot(for: entry).map { DomainDiffService.diff(from: $0, to: entry.snapshot) } ?? []
-                )
-            }
+        DomainReportExporter.batchText(
+            for: currentBatchReports(),
+            title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export"
         )
     }
 
     func exportBatchCSV() -> String {
-        Self.formatCSV(from: currentBatchResultEntries.map(\.snapshot))
+        DomainReportExporter.csv(for: currentBatchReports())
+    }
+
+    func exportBatchJSONData() -> Data? {
+        try? DomainReportExporter.data(
+            for: currentBatchReports(),
+            format: .json,
+            title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export"
+        )
     }
 
     func exportTrackedDomainsCSV(domains: [TrackedDomain]) -> String {
-        Self.formatCSV(from: exportSnapshots(for: domains))
+        DomainReportExporter.csv(for: reports(for: domains))
     }
 
     func exportTrackedDomainsText(domains: [TrackedDomain]) -> String {
-        let latestEntries = latestSnapshots(for: domains)
-        return Self.formatBatchExportText(
-            title: "Tracked Domains Export",
-            entries: domains.map { trackedDomain in
-                if let entry = latestEntries.first(where: { $0.trackedDomainID == trackedDomain.id || $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame }) {
-                    return (
-                        snapshot: entry.snapshot,
-                        trackedDomain: trackedDomain,
-                        changeSummary: entry.changeSummary,
-                        diffSections: comparisonSnapshot(for: entry).map { DomainDiffService.diff(from: $0, to: entry.snapshot) } ?? []
-                    )
-                }
+        DomainReportExporter.batchText(
+            for: reports(for: domains),
+            title: "Tracked Domains Export"
+        )
+    }
 
-                return (
-                    snapshot: placeholderSnapshot(for: trackedDomain),
-                    trackedDomain: trackedDomain,
-                    changeSummary: trackedDomain.lastChangeSummary,
-                    diffSections: []
-                )
-            }
+    func exportTrackedDomainsJSONData(domains: [TrackedDomain]) -> Data? {
+        try? DomainReportExporter.data(
+            for: reports(for: domains),
+            format: .json,
+            title: "Tracked Domains Export"
         )
     }
 
@@ -1130,239 +1046,8 @@ final class DomainViewModel {
 
     private static func performBatchLookup(domain: String) async -> BatchLookupPayload? {
         guard !Task.isCancelled else { return nil }
-
-        let startedAt = Date()
-        let resolverDisplayName = DNSLookupService.currentResolverDisplayName()
-        let resolverURLString = DNSLookupService.currentResolverURLString()
-
-        async let dnsResult = DNSLookupService.lookupAll(domain: domain)
-        async let availabilityResult = DomainAvailabilityService.check(domain: domain)
-        async let sslResult = SSLCheckService.check(domain: domain)
-        async let hstsResult = SSLCheckService.checkHSTSPreload(domain: domain)
-        async let httpResult = HTTPHeadersService.fetch(domain: domain)
-        async let reachabilityResult = ReachabilityService.checkAll(domain: domain)
-        async let ownershipResult = DomainOwnershipService.lookup(domain: domain)
-        async let redirectResult = RedirectChainService.trace(domain: domain)
-        async let subdomainResult = SubdomainDiscoveryService.discover(for: domain)
-        async let portScanResult = PortScanService.scanAll(domain: domain)
-
-        let resolvedDNS = await dnsResult
-        let availability = await availabilityResult
-        let resolvedSSL = await sslResult
-        let hsts = await hstsResult
-        let http = await httpResult
-        let reachability = await reachabilityResult
-        let resolvedOwnership = await ownershipResult
-        let redirects = await redirectResult
-        let resolvedSubdomains = await subdomainResult
-        let ports = await portScanResult
-
+        let snapshot = await DomainInspectionService().inspectSnapshot(domain: domain)
         guard !Task.isCancelled else { return nil }
-
-        let dnsSections: [DNSSection]
-        let dnsError: String?
-        switch resolvedDNS {
-        case let .success(sections):
-            dnsSections = sections
-            dnsError = nil
-        case let .empty(message), let .error(message):
-            dnsSections = []
-            dnsError = message
-        }
-
-        let sslInfo: SSLCertificateInfo?
-        let sslError: String?
-        switch resolvedSSL {
-        case let .success(info):
-            sslInfo = info
-            sslError = nil
-        case let .empty(message), let .error(message):
-            sslInfo = nil
-            sslError = message
-        }
-
-        let httpHeaders: [HTTPHeader]
-        let httpSecurityGrade: String?
-        let httpStatusCode: Int?
-        let httpResponseTimeMs: Int?
-        let httpProtocol: String?
-        let http3Advertised: Bool
-        let httpHeadersError: String?
-        switch http {
-        case let .success(result):
-            httpHeaders = result.headers
-            httpSecurityGrade = HTTPSecurityGrade.grade(for: result.headers).rawValue
-            httpStatusCode = result.statusCode
-            httpResponseTimeMs = result.responseTimeMs
-            httpProtocol = result.httpProtocol
-            http3Advertised = result.http3Advertised
-            httpHeadersError = nil
-        case let .empty(message), let .error(message):
-            httpHeaders = []
-            httpSecurityGrade = nil
-            httpStatusCode = nil
-            httpResponseTimeMs = nil
-            httpProtocol = nil
-            http3Advertised = false
-            httpHeadersError = message
-        }
-
-        let reachabilityResults: [PortReachability]
-        let reachabilityError: String?
-        switch reachability {
-        case let .success(results):
-            reachabilityResults = results
-            reachabilityError = nil
-        case let .empty(message), let .error(message):
-            reachabilityResults = []
-            reachabilityError = message
-        }
-
-        let redirectChain: [RedirectHop]
-        let redirectChainError: String?
-        switch redirects {
-        case let .success(hops):
-            redirectChain = hops
-            redirectChainError = nil
-        case let .empty(message), let .error(message):
-            redirectChain = []
-            redirectChainError = message
-        }
-
-        let portScanResults: [PortScanResult]
-        let portScanError: String?
-        switch ports {
-        case let .success(results):
-            portScanResults = await enrichOpenPortBanners(results, domain: domain)
-            portScanError = nil
-        case let .empty(message), let .error(message):
-            portScanResults = []
-            portScanError = message
-        }
-
-        let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? []
-        let primaryIP = dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
-
-        async let emailResult = EmailSecurityService.analyze(domain: domain, txtRecords: txtRecords)
-        async let suggestions = availability.status == .registered ? DomainAvailabilityService.suggestions(for: domain) : []
-
-        let resolvedEmail = await emailResult
-        let resolvedSuggestions = await suggestions
-        let resolvedPTR: ServiceResult<String>?
-        let resolvedGeo: ServiceResult<IPGeolocation>?
-        if let primaryIP {
-            resolvedPTR = await ReverseDNSService.lookup(ip: primaryIP, resolverURLString: resolverURLString)
-            resolvedGeo = await IPGeolocationService.lookup(ip: primaryIP)
-        } else {
-            resolvedPTR = nil
-            resolvedGeo = nil
-        }
-
-        guard !Task.isCancelled else { return nil }
-
-        let emailSecurity: EmailSecurityResult?
-        let emailSecurityError: String?
-        switch resolvedEmail {
-        case let .success(result):
-            emailSecurity = result
-            emailSecurityError = nil
-        case let .empty(message), let .error(message):
-            emailSecurity = nil
-            emailSecurityError = message
-        }
-
-        let ownership: DomainOwnership?
-        let ownershipError: String?
-        switch resolvedOwnership {
-        case let .success(result):
-            ownership = result
-            ownershipError = nil
-        case let .empty(message), let .error(message):
-            ownership = nil
-            ownershipError = message
-        }
-
-        let ptrRecord: String?
-        let ptrError: String?
-        switch resolvedPTR {
-        case let .success(record):
-            ptrRecord = record
-            ptrError = nil
-        case let .empty(message), let .error(message):
-            ptrRecord = nil
-            ptrError = message
-        case .none:
-            ptrRecord = nil
-            ptrError = "No A record available"
-        }
-
-        let ipGeolocation: IPGeolocation?
-        let ipGeolocationError: String?
-        switch resolvedGeo {
-        case let .success(result):
-            ipGeolocation = result
-            ipGeolocationError = nil
-        case let .empty(message), let .error(message):
-            ipGeolocation = nil
-            ipGeolocationError = message
-        case .none:
-            ipGeolocation = nil
-            ipGeolocationError = "No A record available"
-        }
-
-        let subdomains: [DiscoveredSubdomain]
-        let subdomainsError: String?
-        switch resolvedSubdomains {
-        case let .success(result):
-            subdomains = result
-            subdomainsError = nil
-        case let .empty(message), let .error(message):
-            subdomains = []
-            subdomainsError = message
-        }
-
-        let snapshot = LookupSnapshot(
-            historyEntryID: nil,
-            domain: domain,
-            timestamp: Date(),
-            trackedDomainID: nil,
-            resolverDisplayName: resolverDisplayName,
-            resolverURLString: resolverURLString,
-            totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000),
-            dnsSections: dnsSections,
-            dnsError: dnsError,
-            availabilityResult: availability,
-            suggestions: resolvedSuggestions,
-            sslInfo: sslInfo,
-            sslError: sslError,
-            hstsPreloaded: hsts,
-            httpHeaders: httpHeaders,
-            httpSecurityGrade: httpSecurityGrade,
-            httpStatusCode: httpStatusCode,
-            httpResponseTimeMs: httpResponseTimeMs,
-            httpProtocol: httpProtocol,
-            http3Advertised: http3Advertised,
-            httpHeadersError: httpHeadersError,
-            reachabilityResults: reachabilityResults,
-            reachabilityError: reachabilityError,
-            ipGeolocation: ipGeolocation,
-            ipGeolocationError: ipGeolocationError,
-            emailSecurity: emailSecurity,
-            emailSecurityError: emailSecurityError,
-            ownership: ownership,
-            ownershipError: ownershipError,
-            ptrRecord: ptrRecord,
-            ptrError: ptrError,
-            redirectChain: redirectChain,
-            redirectChainError: redirectChainError,
-            subdomains: subdomains,
-            subdomainsError: subdomainsError,
-            portScanResults: portScanResults,
-            portScanError: portScanError,
-            changeSummary: nil,
-            isLive: false
-        )
-
         return BatchLookupPayload(snapshot: snapshot)
     }
 
@@ -1967,6 +1652,29 @@ final class DomainViewModel {
         }
     }
 
+    private func currentBatchReports() -> [DomainReport] {
+        currentBatchResultEntries.map(report(for:))
+    }
+
+    private func reports(for domains: [TrackedDomain]) -> [DomainReport] {
+        let latestEntries = latestSnapshots(for: domains)
+
+        return domains.map { trackedDomain in
+            if let entry = latestEntries.first(where: {
+                $0.trackedDomainID == trackedDomain.id ||
+                $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame
+            }) {
+                return report(for: entry)
+            }
+
+            return reportBuilder.build(from: placeholderSnapshot(for: trackedDomain))
+        }
+    }
+
+    private func report(for entry: HistoryEntry) -> DomainReport {
+        reportBuilder.build(from: entry, previousSnapshot: comparisonSnapshot(for: entry))
+    }
+
     private func placeholderSnapshot(for trackedDomain: TrackedDomain) -> LookupSnapshot {
         LookupSnapshot(
             historyEntryID: trackedDomain.lastSnapshotID,
diff --git a/DomainDig/ExportPresenter.swift b/DomainDig/ExportPresenter.swift
index 82ec7ae..2dbd1fc 100644
--- a/DomainDig/ExportPresenter.swift
+++ b/DomainDig/ExportPresenter.swift
@@ -3,10 +3,14 @@ import UIKit
 
 enum ExportPresenter {
     static func share(filename: String, contents: String) {
+        share(filename: filename, data: Data(contents.utf8))
+    }
+
+    static func share(filename: String, data: Data) {
         let url = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
 
         do {
-            try contents.write(to: url, atomically: true, encoding: .utf8)
+            try data.write(to: url, options: .atomic)
         } catch {
             return
         }
diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift
index 1f03a0d..7e42b53 100644
--- a/DomainDig/WatchlistView.swift
+++ b/DomainDig/WatchlistView.swift
@@ -143,11 +143,15 @@ struct WatchlistView: View {
                         .disabled(viewModel.batchLookupRunning)
 
                         Button("Export TXT") {
-                            shareTrackedDomains(asCSV: false)
+                            shareTrackedDomains(format: .text)
                         }
 
                         Button("Export CSV") {
-                            shareTrackedDomains(asCSV: true)
+                            shareTrackedDomains(format: .csv)
+                        }
+
+                        Button("Export JSON") {
+                            shareTrackedDomains(format: .json)
                         }
                     } label: {
                         Image(systemName: "line.3.horizontal.decrease.circle")
@@ -178,16 +182,23 @@ struct WatchlistView: View {
         domains.forEach(viewModel.deleteTrackedDomain)
     }
 
-    private func shareTrackedDomains(asCSV: Bool) {
+    private func shareTrackedDomains(format: DomainExportFormat) {
         let formatter = DateFormatter()
         formatter.dateFormat = "yyyyMMdd_HHmmss"
         let timestamp = formatter.string(from: Date())
-        let fileExtension = asCSV ? "csv" : "txt"
-        let filename = "\(timestamp)_domaindig_watchlist.\(fileExtension)"
-        let contents = asCSV
-            ? viewModel.exportTrackedDomainsCSV(domains: viewModel.filteredTrackedDomains)
-            : viewModel.exportTrackedDomainsText(domains: viewModel.filteredTrackedDomains)
-        ExportPresenter.share(filename: filename, contents: contents)
+        let filename = "\(timestamp)_domaindig_watchlist.\(format.fileExtension)"
+        let data: Data
+
+        switch format {
+        case .text:
+            data = Data(viewModel.exportTrackedDomainsText(domains: viewModel.filteredTrackedDomains).utf8)
+        case .csv:
+            data = Data(viewModel.exportTrackedDomainsCSV(domains: viewModel.filteredTrackedDomains).utf8)
+        case .json:
+            data = viewModel.exportTrackedDomainsJSONData(domains: viewModel.filteredTrackedDomains) ?? Data("[]".utf8)
+        }
+
+        ExportPresenter.share(filename: filename, data: data)
     }
 }
 
diff --git a/DomainDigCLI.swift b/DomainDigCLI.swift
new file mode 100644
index 0000000..73f7028
--- /dev/null
+++ b/DomainDigCLI.swift
@@ -0,0 +1,39 @@
+import Foundation
+
+@main
+struct DomainDigCLI {
+    static func main() async {
+        let arguments = Array(CommandLine.arguments.dropFirst())
+
+        guard let command = CommandLine.arguments.first else {
+            fputs("usage: domaindig <domain> [--json]\n", stderr)
+            Foundation.exit(1)
+        }
+        _ = command
+
+        let wantsJSON = arguments.contains("--json") || arguments.contains("-j")
+        let domains = arguments.filter { !$0.hasPrefix("-") }
+
+        guard let domain = domains.first, !domain.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+            fputs("usage: domaindig <domain> [--json]\n", stderr)
+            Foundation.exit(1)
+        }
+
+        let inspectionService = DomainInspectionService()
+        let report = await inspectionService.inspect(domain: domain)
+
+        do {
+            let data = try DomainReportExporter.data(
+                for: report,
+                format: wantsJSON ? .json : .text
+            )
+            FileHandle.standardOutput.write(data)
+            if data.last != 0x0A {
+                FileHandle.standardOutput.write(Data([0x0A]))
+            }
+        } catch {
+            fputs("domaindig: \(error.localizedDescription)\n", stderr)
+            Foundation.exit(1)
+        }
+    }
+}
diff --git a/DomainInspectionService.swift b/DomainInspectionService.swift
new file mode 100644
index 0000000..3f11290
--- /dev/null
+++ b/DomainInspectionService.swift
@@ -0,0 +1,225 @@
+import Foundation
+
+struct DomainInspectionService {
+    private let reportBuilder = DomainReportBuilder()
+
+    func inspect(domain: String) async -> DomainReport {
+        let snapshot = await inspectSnapshot(domain: domain)
+        return reportBuilder.build(from: snapshot)
+    }
+
+    func inspectSnapshot(domain: String) async -> LookupSnapshot {
+        let normalizedDomain = normalize(domain)
+        let startedAt = Date()
+        let resolverDisplayName = DNSLookupService.currentResolverDisplayName()
+        let resolverURLString = DNSLookupService.currentResolverURLString()
+
+        async let dnsResult = DNSLookupService.lookupAll(domain: normalizedDomain)
+        async let availabilityResult = DomainAvailabilityService.check(domain: normalizedDomain)
+        async let sslResult = SSLCheckService.check(domain: normalizedDomain)
+        async let hstsResult = SSLCheckService.checkHSTSPreload(domain: normalizedDomain)
+        async let httpResult = HTTPHeadersService.fetch(domain: normalizedDomain)
+        async let reachabilityResult = ReachabilityService.checkAll(domain: normalizedDomain)
+        async let ownershipResult = DomainOwnershipService.lookup(domain: normalizedDomain)
+        async let redirectResult = RedirectChainService.trace(domain: normalizedDomain)
+        async let subdomainResult = SubdomainDiscoveryService.discover(for: normalizedDomain)
+        async let portScanResult = PortScanService.scanAll(domain: normalizedDomain)
+
+        let resolvedDNS = await dnsResult
+        let availability = await availabilityResult
+        let resolvedSSL = await sslResult
+        let hsts = await hstsResult
+        let http = await httpResult
+        let reachability = await reachabilityResult
+        let resolvedOwnership = await ownershipResult
+        let redirects = await redirectResult
+        let resolvedSubdomains = await subdomainResult
+        let ports = await portScanResult
+
+        let dnsSections = mapServiceResult(resolvedDNS, emptyValue: [])
+        let sslInfo = mapOptionalValueServiceResult(resolvedSSL)
+        let httpHeadersResult = mapHTTPResult(http)
+        let reachabilityResultValue = mapServiceResult(reachability, emptyValue: [])
+        let redirectChain = mapServiceResult(redirects, emptyValue: [])
+        let ownership = mapOptionalValueServiceResult(resolvedOwnership)
+        let subdomains = mapServiceResult(resolvedSubdomains, emptyValue: [])
+        let portScanResults = await mapPortScanResult(ports, domain: normalizedDomain)
+
+        let txtRecords = dnsSections.value.first(where: { $0.recordType == .TXT })?.records ?? []
+        let primaryIP = dnsSections.value.first(where: { $0.recordType == .A })?.records.first?.value
+
+        async let emailResult = EmailSecurityService.analyze(domain: normalizedDomain, txtRecords: txtRecords)
+        async let suggestions = availability.status == .registered
+            ? DomainAvailabilityService.suggestions(for: normalizedDomain)
+            : []
+
+        let resolvedEmail = await emailResult
+        let resolvedSuggestions = await suggestions
+
+        let ptrResult: ServiceResult<String>?
+        let geoResult: ServiceResult<IPGeolocation>?
+        if let primaryIP {
+            ptrResult = await ReverseDNSService.lookup(ip: primaryIP, resolverURLString: resolverURLString)
+            geoResult = await IPGeolocationService.lookup(ip: primaryIP)
+        } else {
+            ptrResult = nil
+            geoResult = nil
+        }
+
+        let emailSecurity = mapOptionalValueServiceResult(resolvedEmail)
+        let ptrRecord = mapOptionalServiceResult(ptrResult, missingMessage: "No A record available")
+        let geolocation = mapOptionalServiceResult(geoResult, missingMessage: "No A record available")
+
+        return LookupSnapshot(
+            historyEntryID: nil,
+            domain: availability.domain,
+            timestamp: Date(),
+            trackedDomainID: nil,
+            resolverDisplayName: resolverDisplayName,
+            resolverURLString: resolverURLString,
+            totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000),
+            dnsSections: dnsSections.value,
+            dnsError: dnsSections.message,
+            availabilityResult: availability,
+            suggestions: resolvedSuggestions,
+            sslInfo: sslInfo.value,
+            sslError: sslInfo.message,
+            hstsPreloaded: hsts,
+            httpHeaders: httpHeadersResult.headers,
+            httpSecurityGrade: httpHeadersResult.securityGrade,
+            httpStatusCode: httpHeadersResult.statusCode,
+            httpResponseTimeMs: httpHeadersResult.responseTimeMs,
+            httpProtocol: httpHeadersResult.httpProtocol,
+            http3Advertised: httpHeadersResult.http3Advertised,
+            httpHeadersError: httpHeadersResult.error,
+            reachabilityResults: reachabilityResultValue.value,
+            reachabilityError: reachabilityResultValue.message,
+            ipGeolocation: geolocation.value,
+            ipGeolocationError: geolocation.message,
+            emailSecurity: emailSecurity.value,
+            emailSecurityError: emailSecurity.message,
+            ownership: ownership.value,
+            ownershipError: ownership.message,
+            ptrRecord: ptrRecord.value,
+            ptrError: ptrRecord.message,
+            redirectChain: redirectChain.value,
+            redirectChainError: redirectChain.message,
+            subdomains: subdomains.value,
+            subdomainsError: subdomains.message,
+            portScanResults: portScanResults.value,
+            portScanError: portScanResults.message,
+            changeSummary: nil,
+            isLive: false
+        )
+    }
+
+    private func normalize(_ domain: String) -> String {
+        domain
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+            .replacingOccurrences(of: "https://", with: "")
+            .replacingOccurrences(of: "http://", with: "")
+            .components(separatedBy: "/").first?
+            .lowercased() ?? domain.lowercased()
+    }
+
+    private func mapServiceResult<Value>(_ result: ServiceResult<Value>, emptyValue: Value) -> (value: Value, message: String?) {
+        switch result {
+        case let .success(value):
+            return (value, nil)
+        case let .empty(message), let .error(message):
+            return (emptyValue, message)
+        }
+    }
+
+    private func mapOptionalValueServiceResult<Value>(_ result: ServiceResult<Value>) -> (value: Value?, message: String?) {
+        switch result {
+        case let .success(value):
+            return (value, nil)
+        case let .empty(message), let .error(message):
+            return (nil, message)
+        }
+    }
+
+    private func mapOptionalServiceResult<Value>(
+        _ result: ServiceResult<Value>?,
+        missingMessage: String
+    ) -> (value: Value?, message: String?) {
+        guard let result else {
+            return (nil, missingMessage)
+        }
+
+        switch result {
+        case let .success(value):
+            return (value, nil)
+        case let .empty(message), let .error(message):
+            return (nil, message)
+        }
+    }
+
+    private func mapPortScanResult(_ result: ServiceResult<[PortScanResult]>, domain: String) async -> (value: [PortScanResult], message: String?) {
+        switch result {
+        case let .success(results):
+            return (await enrichOpenPortBanners(in: results, domain: domain), nil)
+        case let .empty(message), let .error(message):
+            return ([], message)
+        }
+    }
+
+    private func mapHTTPResult(_ result: ServiceResult<HTTPHeadersResult>) -> (
+        headers: [HTTPHeader],
+        securityGrade: String?,
+        statusCode: Int?,
+        responseTimeMs: Int?,
+        httpProtocol: String?,
+        http3Advertised: Bool,
+        error: String?
+    ) {
+        switch result {
+        case let .success(value):
+            return (
+                headers: value.headers,
+                securityGrade: HTTPSecurityGrade.grade(for: value.headers).rawValue,
+                statusCode: value.statusCode,
+                responseTimeMs: value.responseTimeMs,
+                httpProtocol: value.httpProtocol,
+                http3Advertised: value.http3Advertised,
+                error: nil
+            )
+        case let .empty(message), let .error(message):
+            return (
+                headers: [],
+                securityGrade: nil,
+                statusCode: nil,
+                responseTimeMs: nil,
+                httpProtocol: nil,
+                http3Advertised: false,
+                error: message
+            )
+        }
+    }
+
+    private func enrichOpenPortBanners(in results: [PortScanResult], domain: String) async -> [PortScanResult] {
+        let banners = await withTaskGroup(of: (UInt16, String?).self, returning: [UInt16: String].self) { group in
+            for result in results where result.open {
+                group.addTask {
+                    let banner = await PortScanService.grabBanner(host: domain, port: result.port)
+                    return (result.port, banner)
+                }
+            }
+
+            var collected: [UInt16: String] = [:]
+            for await (port, banner) in group {
+                if let banner {
+                    collected[port] = banner
+                }
+            }
+            return collected
+        }
+
+        return results.map { result in
+            var updated = result
+            updated.banner = banners[result.port]
+            return updated
+        }
+    }
+}
diff --git a/DomainReportBuilder.swift b/DomainReportBuilder.swift
new file mode 100644
index 0000000..8cb5c82
--- /dev/null
+++ b/DomainReportBuilder.swift
@@ -0,0 +1,190 @@
+import Foundation
+
+struct DomainReport: Codable {
+    let domain: String
+    let timestamp: Date
+    let availability: DomainAvailabilityStatus
+    let ownership: DomainOwnership?
+    let dns: DNSResultSummary
+    let web: WebResultSummary
+    let email: EmailSecuritySummary
+    let network: NetworkSummary
+    let subdomains: [String]
+    let changeSummary: DomainChangeSummary?
+}
+
+struct DNSResultSummary: Codable {
+    let resolverDisplayName: String
+    let resolverURLString: String
+    let lookupDurationMs: Int?
+    let recordSections: [DNSSection]
+    let primaryIP: String?
+    let ptrRecord: String?
+    let dnssecSigned: Bool?
+    let error: String?
+    let ptrError: String?
+}
+
+struct WebResultSummary: Codable {
+    let tls: SSLCertificateInfo?
+    let tlsStatus: String
+    let certificateWarningLevel: CertificateWarningLevel
+    let hstsPreloaded: Bool?
+    let headers: [HTTPHeader]
+    let headerCount: Int
+    let securityGrade: String?
+    let statusCode: Int?
+    let responseTimeMs: Int?
+    let httpProtocol: String?
+    let http3Advertised: Bool
+    let redirectChain: [RedirectHop]
+    let finalURL: String?
+    let tlsError: String?
+    let headersError: String?
+    let redirectError: String?
+}
+
+struct EmailSecuritySummary: Codable {
+    let records: EmailSecurityResult?
+    let summary: String
+    let error: String?
+}
+
+struct NetworkSummary: Codable {
+    let primaryIP: String?
+    let reachability: [PortReachability]
+    let reachabilitySummary: String
+    let reachabilityError: String?
+    let geolocation: IPGeolocation?
+    let geolocationSummary: String
+    let geolocationError: String?
+    let portScan: [PortScanResult]
+    let openPorts: [UInt16]
+    let portScanError: String?
+}
+
+struct DomainReportBuilder {
+    func build(from snapshot: LookupSnapshot, previousSnapshot: LookupSnapshot? = nil) -> DomainReport {
+        let primaryIP = primaryIPAddress(from: snapshot)
+
+        return DomainReport(
+            domain: snapshot.domain,
+            timestamp: snapshot.timestamp,
+            availability: snapshot.availabilityResult?.status ?? .unknown,
+            ownership: snapshot.ownership,
+            dns: DNSResultSummary(
+                resolverDisplayName: snapshot.resolverDisplayName,
+                resolverURLString: snapshot.resolverURLString,
+                lookupDurationMs: snapshot.totalLookupDurationMs,
+                recordSections: snapshot.dnsSections,
+                primaryIP: primaryIP,
+                ptrRecord: snapshot.ptrRecord,
+                dnssecSigned: dnssecSigned(from: snapshot),
+                error: snapshot.dnsError,
+                ptrError: snapshot.ptrError
+            ),
+            web: WebResultSummary(
+                tls: snapshot.sslInfo,
+                tlsStatus: tlsStatus(from: snapshot),
+                certificateWarningLevel: DomainDiffService.certificateWarningLevel(for: snapshot),
+                hstsPreloaded: snapshot.hstsPreloaded,
+                headers: snapshot.httpHeaders,
+                headerCount: snapshot.httpHeaders.count,
+                securityGrade: snapshot.httpSecurityGrade,
+                statusCode: snapshot.httpStatusCode,
+                responseTimeMs: snapshot.httpResponseTimeMs,
+                httpProtocol: snapshot.httpProtocol,
+                http3Advertised: snapshot.http3Advertised,
+                redirectChain: snapshot.redirectChain,
+                finalURL: snapshot.redirectChain.last?.url,
+                tlsError: snapshot.sslError,
+                headersError: snapshot.httpHeadersError,
+                redirectError: snapshot.redirectChainError
+            ),
+            email: EmailSecuritySummary(
+                records: snapshot.emailSecurity,
+                summary: emailSummary(from: snapshot),
+                error: snapshot.emailSecurityError
+            ),
+            network: NetworkSummary(
+                primaryIP: primaryIP,
+                reachability: snapshot.reachabilityResults,
+                reachabilitySummary: reachabilitySummary(from: snapshot),
+                reachabilityError: snapshot.reachabilityError,
+                geolocation: snapshot.ipGeolocation,
+                geolocationSummary: geolocationSummary(from: snapshot),
+                geolocationError: snapshot.ipGeolocationError,
+                portScan: snapshot.portScanResults,
+                openPorts: snapshot.portScanResults.filter(\.open).map(\.port),
+                portScanError: snapshot.portScanError
+            ),
+            subdomains: snapshot.subdomains.map(\.hostname),
+            changeSummary: snapshot.changeSummary ?? previousSnapshot.map {
+                DomainDiffService.summary(from: $0, to: snapshot, generatedAt: snapshot.timestamp)
+            }
+        )
+    }
+
+    func build(from entry: HistoryEntry, previousSnapshot: LookupSnapshot? = nil) -> DomainReport {
+        build(from: entry.snapshot, previousSnapshot: previousSnapshot)
+    }
+
+    private func primaryIPAddress(from snapshot: LookupSnapshot) -> String? {
+        snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
+    }
+
+    private func dnssecSigned(from snapshot: LookupSnapshot) -> Bool? {
+        snapshot.dnsSections.compactMap(\.dnssecSigned).first
+    }
+
+    private func tlsStatus(from snapshot: LookupSnapshot) -> String {
+        if snapshot.sslInfo != nil {
+            return "valid"
+        }
+        if let sslError = snapshot.sslError {
+            return sslError.localizedCaseInsensitiveContains("certificate") ? "invalid" : "failed"
+        }
+        return "unavailable"
+    }
+
+    private func emailSummary(from snapshot: LookupSnapshot) -> String {
+        guard let emailSecurity = snapshot.emailSecurity else {
+            return snapshot.emailSecurityError ?? "Unavailable"
+        }
+
+        return [
+            "SPF \(emailSecurity.spf.found ? "Yes" : "No")",
+            "DMARC \(emailSecurity.dmarc.found ? "Yes" : "No")",
+            "DKIM \(emailSecurity.dkim.found ? "Yes" : "No")",
+            "BIMI \(emailSecurity.bimi.found ? "Yes" : "No")",
+            "MTA-STS \(emailSecurity.mtaSts?.txtFound == true ? "Yes" : "No")"
+        ].joined(separator: " / ")
+    }
+
+    private func reachabilitySummary(from snapshot: LookupSnapshot) -> String {
+        guard !snapshot.reachabilityResults.isEmpty else {
+            return snapshot.reachabilityError ?? "Unavailable"
+        }
+
+        return snapshot.reachabilityResults
+            .sorted { $0.port < $1.port }
+            .map { "\($0.port):\($0.reachable ? "open" : "closed")" }
+            .joined(separator: ", ")
+    }
+
+    private func geolocationSummary(from snapshot: LookupSnapshot) -> String {
+        guard let geolocation = snapshot.ipGeolocation else {
+            return snapshot.ipGeolocationError ?? "Unavailable"
+        }
+
+        let parts = [geolocation.city, geolocation.region, geolocation.country_name]
+            .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
+            .filter { !$0.isEmpty }
+
+        if !parts.isEmpty {
+            return parts.joined(separator: ", ")
+        }
+
+        return geolocation.ip
+    }
+}
diff --git a/DomainReportExporter.swift b/DomainReportExporter.swift
new file mode 100644
index 0000000..19271fa
--- /dev/null
+++ b/DomainReportExporter.swift
@@ -0,0 +1,367 @@
+import Foundation
+
+enum DomainExportFormat: String {
+    case text = "txt"
+    case csv = "csv"
+    case json = "json"
+
+    var fileExtension: String { rawValue }
+}
+
+enum DomainReportExporter {
+    static func data(for report: DomainReport, format: DomainExportFormat) throws -> Data {
+        switch format {
+        case .text:
+            return Data(text(for: report).utf8)
+        case .csv:
+            return Data(csv(for: [report]).utf8)
+        case .json:
+            return try jsonEncoder.encode(report)
+        }
+    }
+
+    static func data(for reports: [DomainReport], format: DomainExportFormat, title: String) throws -> Data {
+        switch format {
+        case .text:
+            return Data(batchText(for: reports, title: title).utf8)
+        case .csv:
+            return Data(csv(for: reports).utf8)
+        case .json:
+            return try jsonEncoder.encode(reports)
+        }
+    }
+
+    static func text(for report: DomainReport) -> String {
+        var lines = [
+            "DomainDig Report",
+            "Domain: \(report.domain)",
+            "Timestamp: \(textDateFormatter.string(from: report.timestamp))",
+            "Availability: \(availabilityLabel(report.availability))"
+        ]
+
+        appendSection("Summary", to: &lines) {
+            [
+                "Primary IP: \(report.dns.primaryIP ?? "Unavailable")",
+                "TLS Status: \(report.web.tlsStatus)",
+                "HTTP: \(httpSummary(for: report))",
+                "Email: \(report.email.summary)",
+                "Subdomains: \(report.subdomains.count)"
+            ]
+        }
+
+        appendSection("Ownership", to: &lines) {
+            var ownershipLines = [
+                "Registrar: \(report.ownership?.registrar ?? "Unavailable")",
+                "Created: \(ownershipDateLabel(report.ownership?.createdDate))",
+                "Expires: \(ownershipDateLabel(report.ownership?.expirationDate))",
+                "Nameservers: \(joined(report.ownership?.nameservers) ?? "Unavailable")",
+                "Status: \(joined(report.ownership?.status) ?? "Unavailable")",
+                "Abuse Contact: \(report.ownership?.abuseEmail ?? "Unavailable")"
+            ]
+            if let error = report.dns.error, report.ownership == nil {
+                ownershipLines.append("Error: \(error)")
+            } else if let error = report.changeSummary?.message, report.ownership == nil, report.ownership == nil {
+                _ = error
+            }
+            return ownershipLines
+        }
+
+        appendSection("DNS", to: &lines) {
+            var dnsLines = [
+                "Resolver: \(report.dns.resolverDisplayName)",
+                "Resolver URL: \(report.dns.resolverURLString)",
+                "Lookup Duration: \(durationLabel(report.dns.lookupDurationMs))",
+                "Primary IP: \(report.dns.primaryIP ?? "Unavailable")",
+                "PTR: \(report.dns.ptrRecord ?? report.dns.ptrError ?? "Unavailable")",
+                "DNSSEC: \(dnssecLabel(report.dns.dnssecSigned))"
+            ]
+            if let error = report.dns.error {
+                dnsLines.append("Error: \(error)")
+            }
+            if report.dns.recordSections.isEmpty {
+                dnsLines.append("Records: None")
+            } else {
+                dnsLines.append("Records:")
+                for section in report.dns.recordSections {
+                    let values = (section.records + section.wildcardRecords).map(\.value)
+                    let renderedValues = values.isEmpty ? "None" : values.joined(separator: " | ")
+                    dnsLines.append("  \(section.recordType.rawValue): \(renderedValues)")
+                }
+            }
+            return dnsLines
+        }
+
+        appendSection("Web", to: &lines) {
+            var webLines = [
+                "TLS Status: \(report.web.tlsStatus)",
+                "Certificate Warning: \(report.web.certificateWarningLevel.title)",
+                "Security Grade: \(report.web.securityGrade ?? "Unavailable")",
+                "HTTP Status: \(report.web.statusCode.map(String.init) ?? "Unavailable")",
+                "Protocol: \(report.web.httpProtocol ?? "Unavailable")",
+                "HTTP/3 Advertised: \(report.web.http3Advertised ? "Yes" : "No")",
+                "Final URL: \(report.web.finalURL ?? "Unavailable")",
+                "HSTS Preloaded: \(booleanLabel(report.web.hstsPreloaded))",
+                "Header Count: \(report.web.headerCount)"
+            ]
+            if let tlsError = report.web.tlsError {
+                webLines.append("TLS Error: \(tlsError)")
+            }
+            if let headersError = report.web.headersError {
+                webLines.append("Headers Error: \(headersError)")
+            }
+            if let redirectError = report.web.redirectError {
+                webLines.append("Redirect Error: \(redirectError)")
+            }
+            if !report.web.headers.isEmpty {
+                webLines.append("Headers:")
+                for header in report.web.headers {
+                    webLines.append("  \(header.name): \(header.value)")
+                }
+            }
+            if !report.web.redirectChain.isEmpty {
+                webLines.append("Redirect Chain:")
+                for hop in report.web.redirectChain {
+                    webLines.append("  \(hop.stepNumber). \(hop.statusCode) \(hop.url)\(hop.isFinal ? " (final)" : "")")
+                }
+            }
+            return webLines
+        }
+
+        appendSection("Email", to: &lines) {
+            var emailLines = [report.email.summary]
+            if let records = report.email.records {
+                emailLines.append("SPF: \(recordLabel(records.spf))")
+                emailLines.append("DMARC: \(recordLabel(records.dmarc))")
+                emailLines.append("DKIM: \(recordLabel(records.dkim))")
+                emailLines.append("BIMI: \(recordLabel(records.bimi))")
+                emailLines.append("MTA-STS: \(records.mtaSts?.txtFound == true ? records.mtaSts?.policyMode ?? "found" : "Unavailable")")
+            }
+            if let error = report.email.error {
+                emailLines.append("Error: \(error)")
+            }
+            return emailLines
+        }
+
+        appendSection("Network", to: &lines) {
+            var networkLines = [
+                "Reachability: \(report.network.reachabilitySummary)",
+                "Geolocation: \(report.network.geolocationSummary)",
+                "Open Ports: \(report.network.openPorts.map(String.init).joined(separator: ", ").nilIfEmpty ?? "None")"
+            ]
+            if let error = report.network.reachabilityError {
+                networkLines.append("Reachability Error: \(error)")
+            }
+            if let error = report.network.geolocationError {
+                networkLines.append("Geolocation Error: \(error)")
+            }
+            if let error = report.network.portScanError {
+                networkLines.append("Port Scan Error: \(error)")
+            }
+            if !report.network.portScan.isEmpty {
+                networkLines.append("Port Scan:")
+                for result in report.network.portScan {
+                    networkLines.append(
+                        "  \(result.port) \(result.service): \(result.open ? "open" : "closed")\(result.banner.map { " banner=\($0)" } ?? "")"
+                    )
+                }
+            }
+            return networkLines
+        }
+
+        appendSection("Subdomains", to: &lines) {
+            if report.subdomains.isEmpty {
+                return ["None"]
+            }
+            return report.subdomains.map { "- \($0)" }
+        }
+
+        appendSection("Changes", to: &lines) {
+            guard let changeSummary = report.changeSummary else {
+                return ["No comparison available"]
+            }
+
+            return [
+                "Has Changes: \(changeSummary.hasChanges ? "Yes" : "No")",
+                "Severity: \(changeSummary.severity.title)",
+                "Summary: \(changeSummary.message)",
+                "Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))"
+            ]
+        }
+
+        return lines.joined(separator: "\n")
+    }
+
+    static func batchText(for reports: [DomainReport], title: String) -> String {
+        guard !reports.isEmpty else {
+            return "\(title)\nNo results available."
+        }
+
+        var lines = [title, String(repeating: "=", count: title.count), ""]
+        for (index, report) in reports.enumerated() {
+            if index > 0 {
+                lines.append("")
+                lines.append(String(repeating: "=", count: 48))
+                lines.append("")
+            }
+            lines.append(text(for: report))
+        }
+        return lines.joined(separator: "\n")
+    }
+
+    static func csv(for reports: [DomainReport]) -> String {
+        let headers = [
+            "domain",
+            "timestamp",
+            "availability",
+            "registrar",
+            "ownership_expires",
+            "nameservers",
+            "primary_ip",
+            "ptr_record",
+            "dnssec_signed",
+            "tls_status",
+            "certificate_warning_level",
+            "hsts_preloaded",
+            "http_status",
+            "http_security_grade",
+            "final_url",
+            "email_summary",
+            "subdomain_count",
+            "subdomains",
+            "open_ports",
+            "reachability_summary",
+            "geolocation_summary",
+            "change_summary"
+        ]
+
+        let rows = reports.map { report in
+            let expirationDate = report.ownership?.expirationDate.map(csvDateFormatter.string(from:)) ?? ""
+            let nameservers = joined(report.ownership?.nameservers) ?? ""
+            let dnssecSigned = report.dns.dnssecSigned.map { $0 ? "true" : "false" } ?? ""
+            let hstsPreloaded = report.web.hstsPreloaded.map { $0 ? "true" : "false" } ?? ""
+            let httpStatus = report.web.statusCode.map(String.init) ?? ""
+            let subdomainCount = String(report.subdomains.count)
+            let subdomains = report.subdomains.joined(separator: " | ")
+            let openPorts = report.network.openPorts.map(String.init).joined(separator: " | ")
+
+            return [
+                report.domain,
+                csvDateFormatter.string(from: report.timestamp),
+                availabilityLabel(report.availability),
+                report.ownership?.registrar ?? "",
+                expirationDate,
+                nameservers,
+                report.dns.primaryIP ?? "",
+                report.dns.ptrRecord ?? "",
+                dnssecSigned,
+                report.web.tlsStatus,
+                report.web.certificateWarningLevel.rawValue,
+                hstsPreloaded,
+                httpStatus,
+                report.web.securityGrade ?? "",
+                report.web.finalURL ?? "",
+                report.email.summary,
+                subdomainCount,
+                subdomains,
+                openPorts,
+                report.network.reachabilitySummary,
+                report.network.geolocationSummary,
+                report.changeSummary?.message ?? ""
+            ]
+        }
+
+        return ([headers] + rows)
+            .map { row in row.map(csvEscaped).joined(separator: ",") }
+            .joined(separator: "\n")
+    }
+
+    private static func appendSection(_ title: String, to lines: inout [String], body: () -> [String]) {
+        lines.append("")
+        lines.append(title)
+        lines.append(String(repeating: "-", count: title.count))
+        lines.append(contentsOf: body())
+    }
+
+    private static func availabilityLabel(_ status: DomainAvailabilityStatus) -> String {
+        switch status {
+        case .available:
+            return "Available"
+        case .registered:
+            return "Registered"
+        case .unknown:
+            return "Unknown"
+        }
+    }
+
+    private static func joined(_ values: [String]?) -> String? {
+        guard let values, !values.isEmpty else { return nil }
+        return values.joined(separator: " | ")
+    }
+
+    private static func dnssecLabel(_ value: Bool?) -> String {
+        switch value {
+        case true:
+            return "Signed"
+        case false:
+            return "Unsigned"
+        case nil:
+            return "Unavailable"
+        }
+    }
+
+    private static func ownershipDateLabel(_ date: Date?) -> String {
+        guard let date else { return "Unavailable" }
+        return textDateFormatter.string(from: date)
+    }
+
+    private static func durationLabel(_ durationMs: Int?) -> String {
+        durationMs.map { "\($0) ms" } ?? "Unavailable"
+    }
+
+    private static func httpSummary(for report: DomainReport) -> String {
+        let parts = [report.web.statusCode.map(String.init), report.web.securityGrade].compactMap { $0 }
+        return parts.isEmpty ? report.web.headersError ?? "Unavailable" : parts.joined(separator: " / ")
+    }
+
+    private static func booleanLabel(_ value: Bool?) -> String {
+        guard let value else { return "Unavailable" }
+        return value ? "Yes" : "No"
+    }
+
+    private static func recordLabel(_ record: EmailSecurityRecord) -> String {
+        if record.found {
+            return record.value ?? "Present"
+        }
+        return "Unavailable"
+    }
+
+    private static let textDateFormatter: DateFormatter = {
+        let formatter = DateFormatter()
+        formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
+        return formatter
+    }()
+
+    private static let csvDateFormatter: ISO8601DateFormatter = {
+        let formatter = ISO8601DateFormatter()
+        formatter.formatOptions = [.withInternetDateTime]
+        return formatter
+    }()
+
+    private static let jsonEncoder: JSONEncoder = {
+        let encoder = JSONEncoder()
+        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+        encoder.dateEncodingStrategy = .iso8601
+        return encoder
+    }()
+
+    nonisolated private static func csvEscaped(_ value: String) -> String {
+        let escaped = value.replacingOccurrences(of: "\"", with: "\"\"")
+        return "\"\(escaped)\""
+    }
+}
+
+private extension String {
+    var nilIfEmpty: String? {
+        isEmpty ? nil : self
+    }
+}
diff --git a/LookupSnapshot.swift b/LookupSnapshot.swift
new file mode 100644
index 0000000..3a35eff
--- /dev/null
+++ b/LookupSnapshot.swift
@@ -0,0 +1,89 @@
+import Foundation
+
+struct LookupSnapshot {
+    let historyEntryID: UUID?
+    let domain: String
+    let timestamp: Date
+    let trackedDomainID: UUID?
+    let resolverDisplayName: String
+    let resolverURLString: String
+    let totalLookupDurationMs: Int?
+    let dnsSections: [DNSSection]
+    let dnsError: String?
+    let availabilityResult: DomainAvailabilityResult?
+    let suggestions: [DomainSuggestionResult]
+    let sslInfo: SSLCertificateInfo?
+    let sslError: String?
+    let hstsPreloaded: Bool?
+    let httpHeaders: [HTTPHeader]
+    let httpSecurityGrade: String?
+    let httpStatusCode: Int?
+    let httpResponseTimeMs: Int?
+    let httpProtocol: String?
+    let http3Advertised: Bool
+    let httpHeadersError: String?
+    let reachabilityResults: [PortReachability]
+    let reachabilityError: String?
+    let ipGeolocation: IPGeolocation?
+    let ipGeolocationError: String?
+    let emailSecurity: EmailSecurityResult?
+    let emailSecurityError: String?
+    let ownership: DomainOwnership?
+    let ownershipError: String?
+    let ptrRecord: String?
+    let ptrError: String?
+    let redirectChain: [RedirectHop]
+    let redirectChainError: String?
+    let subdomains: [DiscoveredSubdomain]
+    let subdomainsError: String?
+    let portScanResults: [PortScanResult]
+    let portScanError: String?
+    let changeSummary: DomainChangeSummary?
+    let isLive: Bool
+}
+
+extension HistoryEntry {
+    var snapshot: LookupSnapshot {
+        LookupSnapshot(
+            historyEntryID: id,
+            domain: domain,
+            timestamp: timestamp,
+            trackedDomainID: trackedDomainID,
+            resolverDisplayName: resolverDisplayName,
+            resolverURLString: resolverURLString,
+            totalLookupDurationMs: totalLookupDurationMs,
+            dnsSections: dnsSections,
+            dnsError: nil,
+            availabilityResult: availabilityResult,
+            suggestions: suggestions,
+            sslInfo: sslInfo,
+            sslError: sslError,
+            hstsPreloaded: hstsPreloaded,
+            httpHeaders: httpHeaders,
+            httpSecurityGrade: HTTPSecurityGrade.grade(for: httpHeaders).rawValue,
+            httpStatusCode: nil,
+            httpResponseTimeMs: nil,
+            httpProtocol: nil,
+            http3Advertised: false,
+            httpHeadersError: httpHeadersError,
+            reachabilityResults: reachabilityResults,
+            reachabilityError: reachabilityError,
+            ipGeolocation: ipGeolocation,
+            ipGeolocationError: ipGeolocationError,
+            emailSecurity: emailSecurity,
+            emailSecurityError: emailSecurityError,
+            ownership: ownership,
+            ownershipError: ownershipError,
+            ptrRecord: ptrRecord,
+            ptrError: ptrError,
+            redirectChain: redirectChain,
+            redirectChainError: redirectChainError,
+            subdomains: subdomains,
+            subdomainsError: subdomainsError,
+            portScanResults: portScanResults,
+            portScanError: portScanError,
+            changeSummary: changeSummary,
+            isLive: false
+        )
+    }
+}