krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.2.0: Hutch/Networking/ManPageService.swift · raw

  1import Foundation
  2
  3/// Fetches and parses man.sr.ht wiki pages over plain HTTP (no auth required).
  4struct ManPage: Sendable {
  5    let url: URL
  6    let title: String
  7    let contentHTML: String
  8}
  9
 10struct ManPageService {
 11    static let baseURL = URL(string: "https://man.sr.ht")!
 12    static let pagesBaseURL = URL(string: "https://srht.site/")!
 13
 14    /// Fetches a man.sr.ht page and extracts the article content.
 15    /// Uses an unauthenticated URLSession because man.sr.ht pages are public.
 16    static func fetch(url: URL) async throws -> ManPage {
 17        guard isTrustedDocumentationURL(url) else {
 18            throw URLError(.badURL)
 19        }
 20
 21        var request = URLRequest(url: url)
 22        request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
 23        let (data, response) = try await URLSession.shared.data(for: request)
 24        if let http = response as? HTTPURLResponse,
 25           !(200...299).contains(http.statusCode) {
 26            throw URLError(.badServerResponse)
 27        }
 28        guard let html = String(data: data, encoding: .utf8) else {
 29            throw URLError(.cannotDecodeContentData)
 30        }
 31
 32        return ManPage(
 33            url: url,
 34            title: extractTitle(from: html, fallbackURL: url),
 35            contentHTML: sanitizeContentHTML(extractContent(from: html))
 36        )
 37    }
 38
 39    static func isTrustedDocumentationURL(_ url: URL) -> Bool {
 40        guard url.scheme?.localizedCaseInsensitiveCompare("https") == .orderedSame,
 41              let host = url.host?.lowercased() else {
 42            return false
 43        }
 44
 45        return host == "man.sr.ht"
 46            || host.hasSuffix(".man.sr.ht")
 47            || host == "srht.site"
 48    }
 49
 50    private static func extractTitle(from html: String, fallbackURL: URL) -> String {
 51        if let headerHTML = substring(
 52            in: html,
 53            startingAtFirstOccurrenceOf: #"<div class="header-tabbed">"#
 54        ),
 55           let h2Contents = firstMatch(in: headerHTML, pattern: #"<h2\b[^>]*>(.*?)</h2>"#) {
 56            let title = stripHTML(from: h2Contents).trimmingCharacters(in: .whitespacesAndNewlines)
 57            if !title.isEmpty {
 58                return title
 59            }
 60        }
 61
 62        if let titleContents = firstMatch(in: html, pattern: #"<title\b[^>]*>(.*?)</title>"#) {
 63            let rawTitle = stripHTML(from: titleContents).trimmingCharacters(in: .whitespacesAndNewlines)
 64            let suffix = " - man.sr.ht"
 65            let normalizedTitle: String
 66            if rawTitle.hasSuffix(suffix) {
 67                normalizedTitle = String(rawTitle.dropLast(suffix.count))
 68            } else {
 69                normalizedTitle = rawTitle
 70            }
 71
 72            if !normalizedTitle.isEmpty {
 73                return normalizedTitle
 74            }
 75        }
 76
 77        let lastComponent = fallbackURL.pathComponents.last { $0 != "/" } ?? ""
 78        return lastComponent.isEmpty ? fallbackURL.absoluteString : lastComponent
 79    }
 80
 81    private static func extractContent(from html: String) -> String {
 82        if let content = extractDivBlock(from: html, className: "markdown"), !content.isEmpty {
 83            return content
 84        }
 85
 86        if let content = extractArticleBlock(from: html, className: "content"), !content.isEmpty {
 87            return content
 88        }
 89
 90        if let content = extractDivBlock(from: html, className: "content"), !content.isEmpty {
 91            return content
 92        }
 93
 94        return ""
 95    }
 96
 97    private static func sanitizeContentHTML(_ html: String) -> String {
 98        html.replacingOccurrences(
 99            of: ###"<a\b[^>]*aria-hidden="true"[^>]*href="#[^"]*"[^>]*>\s*#\s*</a>"###,
100            with: "",
101            options: [.regularExpression, .caseInsensitive]
102        )
103    }
104
105    private static func extractArticleBlock(from html: String, className: String) -> String? {
106        extractElementBlock(from: html, elementName: "article", className: className)
107    }
108
109    private static func extractDivBlock(from html: String, className: String) -> String? {
110        extractElementBlock(from: html, elementName: "div", className: className)
111    }
112
113    private static func extractElementBlock(
114        from html: String,
115        elementName: String,
116        className: String
117    ) -> String? {
118        guard let startRange = html.range(of: #"<\#(elementName) class="\#(className)""#) else {
119            return nil
120        }
121
122        let characters = Array(html)
123        var index = html.distance(from: html.startIndex, to: startRange.lowerBound)
124        var depth = 0
125        var foundOpeningDiv = false
126
127        while index < characters.count {
128            guard characters[index] == "<" else {
129                index += 1
130                continue
131            }
132
133            if hasPrefix("</\(elementName)", at: index, in: characters) {
134                if foundOpeningDiv {
135                    depth -= 1
136                    if depth == 0 {
137                        let closeEnd = endOfTag(startingAt: index, in: characters)
138                        return String(characters[html.distance(from: html.startIndex, to: startRange.lowerBound)..<closeEnd])
139                    }
140                }
141                index += 1
142                continue
143            }
144
145            if hasPrefix("<\(elementName)", at: index, in: characters) {
146                if !isSelfClosingTag(startingAt: index, in: characters) {
147                    depth += 1
148                    foundOpeningDiv = true
149                }
150                index += 1
151                continue
152            }
153
154            index += 1
155        }
156
157        return nil
158    }
159
160    private static func firstMatch(in text: String, pattern: String) -> String? {
161        guard let regex = try? NSRegularExpression(
162            pattern: pattern,
163            options: [.caseInsensitive, .dotMatchesLineSeparators]
164        ) else {
165            return nil
166        }
167
168        let range = NSRange(text.startIndex..., in: text)
169        guard let match = regex.firstMatch(in: text, range: range),
170              match.numberOfRanges > 1,
171              let captureRange = Range(match.range(at: 1), in: text) else {
172            return nil
173        }
174
175        return String(text[captureRange])
176    }
177
178    private static func substring(in text: String, startingAtFirstOccurrenceOf needle: String) -> String? {
179        guard let range = text.range(of: needle) else {
180            return nil
181        }
182
183        return String(text[range.lowerBound...])
184    }
185
186    private static func stripHTML(from text: String) -> String {
187        let noTags = text.replacingOccurrences(
188            of: #"<[^>]+>"#,
189            with: "",
190            options: .regularExpression
191        )
192
193        return decodeHTMLEntities(noTags)
194    }
195
196    private static func hasPrefix(_ prefix: String, at index: Int, in characters: [Character]) -> Bool {
197        guard index + prefix.count <= characters.count else { return false }
198        return String(characters[index..<(index + prefix.count)]).lowercased() == prefix
199    }
200
201    private static func isSelfClosingTag(startingAt index: Int, in characters: [Character]) -> Bool {
202        let tagEnd = endOfTag(startingAt: index, in: characters)
203        guard tagEnd > index else { return false }
204        let tagContents = String(characters[index..<tagEnd])
205        return tagContents.contains("/>")
206    }
207
208    private static func endOfTag(startingAt index: Int, in characters: [Character]) -> Int {
209        var current = index
210        while current < characters.count {
211            if characters[current] == ">" {
212                return current + 1
213            }
214            current += 1
215        }
216        return characters.count
217    }
218}