krz/hutch

an ios client for sourcehut

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

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