krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.3.1: 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}