Files
edgeever/apps/ios/EdgeEver/Features/Share/WebClipper.swift
T

379 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import SwiftUI
import WebKit
struct WebClipDraft: Equatable, Sendable {
var title: String
var contentMarkdown: String
var tagsText: String
var createSeed: CreateMemoSeed {
CreateMemoSeed(title: title, contentMarkdown: contentMarkdown, tagsText: tagsText)
}
}
struct RenderedWebPage: Equatable, Sendable {
var title: String
var contentHTML: String
var finalURL: String
}
struct WebClipLabels: Equatable, Sendable {
var sourceLabel: String
var capturedAtLabel: String
var unavailableBody: String
var fallbackTitle: String
var imageAlt: String
var usesFullwidthColon: Bool
static let chinese = WebClipLabels(
sourceLabel: "来源",
capturedAtLabel: "剪藏时间",
unavailableBody: "正文暂时无法抓取,来源链接已保留,可稍后重试。",
fallbackTitle: "网页剪藏",
imageAlt: "图片",
usesFullwidthColon: true
)
func labeledLine(_ label: String, value: String) -> String {
usesFullwidthColon ? "\(label):\(value)" : "\(label): \(value)"
}
}
enum WebClipper {
static func sharedWebURL(from payloads: [ShareHandoffStore.SharePayload]) -> URL? {
for payload in payloads {
for candidate in [payload.url, payload.text].compactMap({ $0 }) {
guard let range = candidate.range(of: #"https?://[^\s<>\"'))]+"#, options: .regularExpression),
let url = URL(string: String(candidate[range])),
url.scheme == "http" || url.scheme == "https"
else { continue }
return url
}
}
return nil
}
static func isWeChatArticle(_ url: URL) -> Bool {
url.scheme?.lowercased() == "https"
&& url.host?.lowercased() == "mp.weixin.qq.com"
&& url.path.hasPrefix("/s")
}
static func build(
_ sourceURL: URL,
capturedAt: Date = Date(),
labels: WebClipLabels = .chinese,
session: URLSession = .shared
) async -> WebClipDraft {
var request = URLRequest(url: sourceURL)
request.setValue("text/html,application/xhtml+xml", forHTTPHeaderField: "Accept")
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse,
(200 ..< 300).contains(http.statusCode),
let html = String(data: data, encoding: .utf8)
?? String(data: data, encoding: .isoLatin1)
else { return fallback(sourceURL, capturedAt: capturedAt, labels: labels) }
let finalURL = response.url ?? sourceURL
let title = extractTitle(html) ?? hostnameTitle(sourceURL, fallbackTitle: labels.fallbackTitle)
let bodyHTML = firstInnerHTML(html, tags: ["article", "main"])
return buildRendered(
sourceURL,
page: RenderedWebPage(title: title, contentHTML: bodyHTML, finalURL: finalURL.absoluteString),
capturedAt: capturedAt,
labels: labels
)
} catch {
return fallback(sourceURL, capturedAt: capturedAt, labels: labels)
}
}
static func buildRendered(
_ sourceURL: URL,
page: RenderedWebPage,
capturedAt: Date = Date(),
labels: WebClipLabels = .chinese
) -> WebClipDraft {
let title = normalizedText(page.title).nilIfEmpty ?? hostnameTitle(sourceURL, fallbackTitle: labels.fallbackTitle)
let markdown = htmlToMarkdown(
page.contentHTML,
baseURL: URL(string: page.finalURL) ?? sourceURL,
imageAlt: labels.imageAlt
)
guard !markdown.isEmpty else {
var draft = fallback(sourceURL, capturedAt: capturedAt, labels: labels)
draft.title = title
return draft
}
let sourceLink = "[\(escapeMarkdown(sourceURL.absoluteString))](\(sourceURL.absoluteString))"
return WebClipDraft(
title: title,
contentMarkdown: [
labels.labeledLine(labels.sourceLabel, value: sourceLink),
labels.labeledLine(labels.capturedAtLabel, value: ISO8601DateFormatter.edgeEver.string(from: capturedAt)),
"---",
markdown,
].joined(separator: "\n\n"),
tagsText: isWeChatArticle(sourceURL) ? "web-clip, wechat" : "web-clip"
)
}
static func fallback(
_ sourceURL: URL,
capturedAt: Date = Date(),
labels: WebClipLabels = .chinese
) -> WebClipDraft {
let sourceLink = "[\(escapeMarkdown(sourceURL.absoluteString))](\(sourceURL.absoluteString))"
return WebClipDraft(
title: hostnameTitle(sourceURL, fallbackTitle: labels.fallbackTitle),
contentMarkdown: [
labels.labeledLine(labels.sourceLabel, value: sourceLink),
labels.labeledLine(labels.capturedAtLabel, value: ISO8601DateFormatter.edgeEver.string(from: capturedAt)),
labels.unavailableBody,
].joined(separator: "\n\n"),
tagsText: isWeChatArticle(sourceURL) ? "web-clip, wechat" : "web-clip"
)
}
static func htmlToMarkdown(_ html: String, baseURL: URL, imageAlt: String = "图片") -> String {
guard !html.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return "" }
var value = replace(html, pattern: #"<!--[\s\S]*?-->"#) { _ in "" }
value = replace(
value,
pattern: #"<(script|style|noscript|template|iframe|svg|canvas)\b[^>]*>[\s\S]*?</\1\s*>"#
) { _ in "" }
value = replace(value, pattern: #"<img\b[^>]*>"#) { match in
let source = attribute(match, "data-src").nilIfEmpty
?? attribute(match, "data-original").nilIfEmpty
?? attribute(match, "src").nilIfEmpty
guard let source, !source.hasPrefix("data:"), let resolved = resolve(source, against: baseURL) else { return "" }
let alt = normalizedText(attribute(match, "alt")).nilIfEmpty ?? imageAlt
return "\n\n![\(escapeMarkdown(alt))](\(resolved))\n\n"
}
value = replace(value, pattern: #"<a\b[^>]*>([\s\S]*?)</a\s*>"#) { match in
let href = attribute(match, "href")
let label = normalizedText(stripTags(capture(match, pattern: #"<a\b[^>]*>([\s\S]*?)</a\s*>"#) ?? ""))
guard !href.isEmpty, !href.lowercased().hasPrefix("javascript:"),
let resolved = resolve(href, against: baseURL)
else { return label }
return "[\(escapeMarkdown(label.isEmpty ? resolved : label))](\(resolved))"
}
value = replace(value, pattern: #"<h([1-6])\b[^>]*>"#) { match in
let level = Int(capture(match, pattern: #"<h([1-6])\b"#) ?? "1") ?? 1
return "\n\n\(String(repeating: "#", count: level)) "
}
let substitutions: [(String, String)] = [
(#"</h[1-6]\s*>"#, "\n\n"), (#"<br\s*/?>"#, "\n"),
(#"<hr\b[^>]*>"#, "\n\n---\n\n"), (#"<li\b[^>]*>"#, "\n- "),
(#"</li\s*>"#, "\n"), (#"<(strong|b)\b[^>]*>"#, "**"),
(#"</(strong|b)\s*>"#, "**"), (#"<(em|i)\b[^>]*>"#, "*"),
(#"</(em|i)\s*>"#, "*"), (#"<code\b[^>]*>"#, "`"),
(#"</code\s*>"#, "`"), (#"</?blockquote\b[^>]*>"#, "\n\n> "),
(#"</?(article|aside|div|figure|figcaption|footer|header|main|p|section|table|tbody|td|th|thead|tr)\b[^>]*>"#, "\n\n"),
(#"</?[a-z][a-z0-9-]*\b[^>]*>"#, ""),
]
for (pattern, replacement) in substitutions {
value = replace(value, pattern: pattern) { _ in replacement }
}
return decodeEntities(value)
.replacingOccurrences(of: "\r", with: "")
.replacingOccurrences(of: #"[ \t]+\n"#, with: "\n", options: .regularExpression)
.replacingOccurrences(of: #"\n[ \t]+"#, with: "\n", options: .regularExpression)
.replacingOccurrences(of: #"[ \t]{2,}"#, with: " ", options: .regularExpression)
.replacingOccurrences(of: #"\n{3,}"#, with: "\n\n", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func extractTitle(_ html: String) -> String? {
let metas = matches(html, pattern: #"<meta\b[^>]*>"#)
for key in ["og:title", "twitter:title"] {
if let tag = metas.first(where: {
(attribute($0, "property").nilIfEmpty ?? attribute($0, "name")).lowercased() == key
}), let title = normalizedText(attribute(tag, "content")).nilIfEmpty { return title }
}
guard let title = capture(html, pattern: #"<title\b[^>]*>([\s\S]*?)</title\s*>"#) else { return nil }
return normalizedText(stripTags(title)).nilIfEmpty
}
private static func firstInnerHTML(_ html: String, tags: [String]) -> String {
for tag in tags {
if let value = capture(html, pattern: "<\(tag)\\b[^>]*>([\\s\\S]*?)</\(tag)\\s*>") { return value }
}
return ""
}
private static func hostnameTitle(_ url: URL, fallbackTitle: String) -> String { url.host ?? fallbackTitle }
private static func normalizedText(_ value: String) -> String {
decodeEntities(value).replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func stripTags(_ value: String) -> String {
value.replacingOccurrences(of: #"<[^>]*>"#, with: " ", options: .regularExpression)
}
private static func escapeMarkdown(_ value: String) -> String {
value.replacingOccurrences(of: #"([\[\]])"#, with: #"\\$1"#, options: .regularExpression)
}
private static func resolve(_ value: String, against baseURL: URL) -> String? {
guard let url = URL(string: value, relativeTo: baseURL)?.absoluteURL,
url.scheme == "http" || url.scheme == "https" else { return nil }
return url.absoluteString
}
private static func attribute(_ tag: String, _ name: String) -> String {
let pattern = "(?:^|\\s)\(NSRegularExpression.escapedPattern(for: name))\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s>]+))"
guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive),
let match = regex.firstMatch(in: tag, range: NSRange(tag.startIndex..., in: tag)) else { return "" }
for index in 1 ..< match.numberOfRanges where match.range(at: index).location != NSNotFound {
if let range = Range(match.range(at: index), in: tag) { return decodeEntities(String(tag[range])) }
}
return ""
}
private static func capture(_ value: String, pattern: String) -> String? {
guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive),
let match = regex.firstMatch(in: value, range: NSRange(value.startIndex..., in: value)),
match.numberOfRanges > 1, let range = Range(match.range(at: 1), in: value) else { return nil }
return String(value[range])
}
private static func matches(_ value: String, pattern: String) -> [String] {
guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { return [] }
return regex.matches(in: value, range: NSRange(value.startIndex..., in: value)).compactMap {
Range($0.range, in: value).map { String(value[$0]) }
}
}
private static func replace(_ value: String, pattern: String, transform: (String) -> String) -> String {
guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { return value }
var output = value
for match in regex.matches(in: value, range: NSRange(value.startIndex..., in: value)).reversed() {
guard let originalRange = Range(match.range, in: value), let outputRange = Range(match.range, in: output) else { continue }
output.replaceSubrange(outputRange, with: transform(String(value[originalRange])))
}
return output
}
private static func decodeEntities(_ value: String) -> String {
let named = ["amp": "&", "apos": "'", "gt": ">", "hellip": "…", "ldquo": "“", "lsquo": "‘", "lt": "<", "middot": "·", "nbsp": " ", "ndash": "–", "quot": "\"", "rdquo": "”", "rsquo": "’"]
return replace(value, pattern: #"&(#x?[0-9a-f]+|[a-z][a-z0-9]+);?"#) { entity in
let key = String(entity.dropFirst().dropLast(entity.hasSuffix(";") ? 1 : 0))
if key.lowercased().hasPrefix("#x"), let scalar = UInt32(key.dropFirst(2), radix: 16).flatMap(UnicodeScalar.init) { return String(scalar) }
if key.hasPrefix("#"), let scalar = UInt32(key.dropFirst()).flatMap(UnicodeScalar.init) { return String(scalar) }
return named[key.lowercased()] ?? entity
}
}
}
/// Hidden rendered-page capture used for WeChat pages that server-side fetch cannot reliably read.
struct WebClipCaptureView: UIViewRepresentable {
let url: URL
let onCaptured: (RenderedWebPage) -> Void
let onFailed: (String) -> Void
func makeCoordinator() -> Coordinator { Coordinator(onCaptured: onCaptured, onFailed: onFailed) }
func makeUIView(context: Context) -> WKWebView {
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .default()
configuration.userContentController.add(context.coordinator, name: "webClip")
let view = WKWebView(frame: .zero, configuration: configuration)
view.navigationDelegate = context.coordinator
view.isOpaque = false
view.backgroundColor = .clear
return view
}
func updateUIView(_ view: WKWebView, context: Context) {
context.coordinator.onCaptured = onCaptured
context.coordinator.onFailed = onFailed
guard context.coordinator.loadedURL != url else { return }
context.coordinator.loadedURL = url
view.load(URLRequest(url: url))
context.coordinator.startTimeout()
}
static func dismantleUIView(_ view: WKWebView, coordinator: Coordinator) {
coordinator.timeout?.cancel()
view.stopLoading()
view.configuration.userContentController.removeScriptMessageHandler(forName: "webClip")
}
final class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
var onCaptured: (RenderedWebPage) -> Void
var onFailed: (String) -> Void
var loadedURL: URL?
var timeout: DispatchWorkItem?
private var completed = false
init(onCaptured: @escaping (RenderedWebPage) -> Void, onFailed: @escaping (String) -> Void) {
self.onCaptured = onCaptured
self.onFailed = onFailed
}
func startTimeout() {
completed = false
timeout?.cancel()
let work = DispatchWorkItem { [weak self] in self?.finishFailure("微信文章加载超时。") }
timeout = work
DispatchQueue.main.asyncAfter(deadline: .now() + 20, execute: work)
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
webView.evaluateJavaScript(Self.captureScript, completionHandler: nil)
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
finishFailure(error.localizedDescription)
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
finishFailure(error.localizedDescription)
}
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
guard !completed, let body = message.body as? [String: Any], let status = body["status"] as? String else { return }
if status == "captured", let html = body["contentHtml"] as? String, !html.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
completed = true
timeout?.cancel()
onCaptured(RenderedWebPage(
title: body["title"] as? String ?? "",
contentHTML: html,
finalURL: body["finalUrl"] as? String ?? loadedURL?.absoluteString ?? ""
))
} else {
finishFailure(body["message"] as? String ?? "没有找到可剪藏的正文。")
}
}
private func finishFailure(_ message: String) {
guard !completed else { return }
completed = true
timeout?.cancel()
onFailed(message)
}
private static let captureScript = #"""
(function(){
var attempts=0, completed=false;
function send(value){ if(completed)return; completed=true; window.webkit.messageHandlers.webClip.postMessage(value); }
function text(selector){ var el=document.querySelector(selector); return el&&el.textContent?el.textContent.trim():""; }
function capture(){
var content=document.getElementById("js_content")||document.querySelector("article")||document.querySelector("main");
if(content && content.textContent && content.textContent.trim()){
var meta=document.querySelector('meta[property="og:title"],meta[name="twitter:title"]');
send({status:"captured",title:(meta&&meta.getAttribute("content"))||text("#activity-name")||text(".rich_media_title")||document.title||"",contentHtml:content.innerHTML,finalUrl:location.href}); return;
}
var error=text(".weui-msg__title")||text(".weui-msg__desc");
if(error){send({status:"failed",message:error});return;}
attempts++; if(attempts>=50){send({status:"failed",message:"页面加载完成,但没有找到可剪藏的正文。"});return;}
setTimeout(capture,250);
}
capture();
})();
"""#
}
}
private extension String {
var nilIfEmpty: String? {
let value = trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
}