diff options
Diffstat (limited to 'Hutch/Networking/ManPageService.swift')
| -rw-r--r-- | Hutch/Networking/ManPageService.swift | 216 |
1 files changed, 216 insertions, 0 deletions
diff --git a/Hutch/Networking/ManPageService.swift b/Hutch/Networking/ManPageService.swift new file mode 100644 index 0000000..07d5c2d --- /dev/null +++ b/Hutch/Networking/ManPageService.swift @@ -0,0 +1,216 @@ +import Foundation + +/// Fetches and parses man.sr.ht wiki pages over plain HTTP (no auth required). +struct ManPage: Sendable { + let url: URL + let title: String + let contentHTML: String +} + +struct ManPageService { + static let baseURL = URL(string: "https://man.sr.ht")! + static let pagesBaseURL = URL(string: "https://srht.site/")! + + /// Fetches a man.sr.ht page and extracts the article content. + /// Uses an unauthenticated URLSession because man.sr.ht pages are public. + static func fetch(url: URL) async throws -> ManPage { + guard isTrustedDocumentationURL(url) else { + throw URLError(.badURL) + } + + let (data, response) = try await URLSession.shared.data(from: url) + if let http = response as? HTTPURLResponse, + !(200...299).contains(http.statusCode) { + throw URLError(.badServerResponse) + } + guard let html = String(data: data, encoding: .utf8) else { + throw URLError(.cannotDecodeContentData) + } + + return ManPage( + url: url, + title: extractTitle(from: html, fallbackURL: url), + contentHTML: sanitizeContentHTML(extractContent(from: html)) + ) + } + + static func isTrustedDocumentationURL(_ url: URL) -> Bool { + guard url.scheme?.localizedCaseInsensitiveCompare("https") == .orderedSame, + let host = url.host?.lowercased() else { + return false + } + + return host == "man.sr.ht" + || host.hasSuffix(".man.sr.ht") + || host == "srht.site" + } + + private static func extractTitle(from html: String, fallbackURL: URL) -> String { + if let headerHTML = substring( + in: html, + startingAtFirstOccurrenceOf: #"<div class="header-tabbed">"# + ), + let h2Contents = firstMatch(in: headerHTML, pattern: #"<h2\b[^>]*>(.*?)</h2>"#) { + let title = stripHTML(from: h2Contents).trimmingCharacters(in: .whitespacesAndNewlines) + if !title.isEmpty { + return title + } + } + + if let titleContents = firstMatch(in: html, pattern: #"<title\b[^>]*>(.*?)</title>"#) { + let rawTitle = stripHTML(from: titleContents).trimmingCharacters(in: .whitespacesAndNewlines) + let suffix = " - man.sr.ht" + let normalizedTitle: String + if rawTitle.hasSuffix(suffix) { + normalizedTitle = String(rawTitle.dropLast(suffix.count)) + } else { + normalizedTitle = rawTitle + } + + if !normalizedTitle.isEmpty { + return normalizedTitle + } + } + + let lastComponent = fallbackURL.pathComponents.last { $0 != "/" } ?? "" + return lastComponent.isEmpty ? fallbackURL.absoluteString : lastComponent + } + + private static func extractContent(from html: String) -> String { + if let content = extractDivBlock(from: html, className: "markdown"), !content.isEmpty { + return content + } + + if let content = extractArticleBlock(from: html, className: "content"), !content.isEmpty { + return content + } + + if let content = extractDivBlock(from: html, className: "content"), !content.isEmpty { + return content + } + + return "" + } + + private static func sanitizeContentHTML(_ html: String) -> String { + html.replacingOccurrences( + of: ###"<a\b[^>]*aria-hidden="true"[^>]*href="#[^"]*"[^>]*>\s*#\s*</a>"###, + with: "", + options: [.regularExpression, .caseInsensitive] + ) + } + + private static func extractArticleBlock(from html: String, className: String) -> String? { + extractElementBlock(from: html, elementName: "article", className: className) + } + + private static func extractDivBlock(from html: String, className: String) -> String? { + extractElementBlock(from: html, elementName: "div", className: className) + } + + private static func extractElementBlock( + from html: String, + elementName: String, + className: String + ) -> String? { + guard let startRange = html.range(of: #"<\#(elementName) class="\#(className)""#) else { + return nil + } + + let characters = Array(html) + var index = html.distance(from: html.startIndex, to: startRange.lowerBound) + var depth = 0 + var foundOpeningDiv = false + + while index < characters.count { + guard characters[index] == "<" else { + index += 1 + continue + } + + if hasPrefix("</\(elementName)", at: index, in: characters) { + if foundOpeningDiv { + depth -= 1 + if depth == 0 { + let closeEnd = endOfTag(startingAt: index, in: characters) + return String(characters[html.distance(from: html.startIndex, to: startRange.lowerBound)..<closeEnd]) + } + } + index += 1 + continue + } + + if hasPrefix("<\(elementName)", at: index, in: characters) { + if !isSelfClosingTag(startingAt: index, in: characters) { + depth += 1 + foundOpeningDiv = true + } + index += 1 + continue + } + + index += 1 + } + + return nil + } + + private static func firstMatch(in text: String, pattern: String) -> String? { + guard let regex = try? NSRegularExpression( + pattern: pattern, + options: [.caseInsensitive, .dotMatchesLineSeparators] + ) else { + return nil + } + + let range = NSRange(text.startIndex..., in: text) + guard let match = regex.firstMatch(in: text, range: range), + match.numberOfRanges > 1, + let captureRange = Range(match.range(at: 1), in: text) else { + return nil + } + + return String(text[captureRange]) + } + + private static func substring(in text: String, startingAtFirstOccurrenceOf needle: String) -> String? { + guard let range = text.range(of: needle) else { + return nil + } + + return String(text[range.lowerBound...]) + } + + private static func stripHTML(from text: String) -> String { + let noTags = text.replacingOccurrences( + of: #"<[^>]+>"#, + with: "", + options: .regularExpression + ) + + return decodeHTMLEntities(noTags) + } + + private static func hasPrefix(_ prefix: String, at index: Int, in characters: [Character]) -> Bool { + guard index + prefix.count <= characters.count else { return false } + return String(characters[index..<(index + prefix.count)]).lowercased() == prefix + } + + private static func isSelfClosingTag(startingAt index: Int, in characters: [Character]) -> Bool { + let tagEnd = endOfTag(startingAt: index, in: characters) + guard tagEnd > index else { return false } + let tagContents = String(characters[index..<tagEnd]) + return tagContents.contains("/>") + } + + private static func endOfTag(startingAt index: Int, in characters: [Character]) -> Int { + var current = index + while current < characters.count { + if characters[current] == ">" { + return current + 1 + } + current += 1 + } + return characters.count + } +} |
