Beim Teilen aus Safari greift ein JS-Preprocessing-File (GetPageHTML.js) das gerenderte outerHTML der bereits zustimmenden Seite und legt es im neuen SharedHTMLStore (App-Group, keyed auf SHA-256(url)) ab. Die App schickt dieses HTML bei der Extraktion an /extract/preview mit — das umgeht Cookie-/Pur-Wände, die der anonyme Server-Fetch sonst extrahiert (gleicher Bypass wie das Web-Bookmarklet). - SharedHTMLStore: put/peek/remove/prune, 4,9M-Char-Cap (API-Limit), 7-Tage-TTL. HTML wird erst nach erfolgreicher Extraktion verworfen, damit ein 401/502-Retry (enrich-Sweep) es erneut nutzen kann. - ShareViewController (iOS + macOS): liest das Property-List-Result, legt HTML ab, enqueued die URL wie bisher. - project.yml: NSExtensionJavaScriptPreprocessingFile + WebPage-Activation + JS-Resource + SharedHTMLStore.swift in beide Extension-Targets. - PagetaAPI.extractPreview(url:html:) + enrichArticle konsultiert den Store. Greift nur beim Teilen aus Safari (Web-Page-Kontext); nackte URL-Shares fallen sauber auf den URL-Pfad zurück. iOS+macOS BUILD SUCCEEDED, 41/41 Tests grün (3 neue für SharedHTMLStore). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
92 lines
3.6 KiB
Swift
92 lines
3.6 KiB
Swift
import CryptoKit
|
|
import Foundation
|
|
|
|
/// App-Group-Ablage für gerendertes Seiten-HTML, das die ShareExtension
|
|
/// per JavaScript-Preprocessing aus der **zustimmenden Safari-Session**
|
|
/// greift. Die App nutzt es bei der Extraktion (`/extract/preview` mit
|
|
/// `html`), um Cookie-/Consent-Wände zu umgehen — anders als der anonyme
|
|
/// Server-Fetch, der bei vielen DE-News-Seiten nur die Pur-/Cookie-Wand
|
|
/// sieht statt des Artikels.
|
|
///
|
|
/// **Keyed auf SHA-256(url)**, damit sowohl der Sofort-Flush
|
|
/// (`flushPendingShares`) als auch der spätere Enrichment-Sweep
|
|
/// (`enrichPendingArticles`) das HTML zur selben URL finden — egal in
|
|
/// welcher Reihenfolge oder Login-Phase sie laufen. Das HTML lebt als
|
|
/// Datei (nicht in UserDefaults), weil es bis ~5 MB groß werden kann.
|
|
///
|
|
/// **Lebenszyklus:** `put` (ShareExt) → `peek` (App bei Extraktion) →
|
|
/// `remove` nach erfolgreicher Extraktion. `prune()` wirft Reste weg, die
|
|
/// nie extrahiert wurden (z. B. dauerhaft ausgeloggt geteilt).
|
|
struct SharedHTMLStore: @unchecked Sendable {
|
|
/// Muss < 5 000 000 bleiben (`previewSchema.html.max` in pageta-api),
|
|
/// sonst antwortet die Extraktion mit 400. Knapp drunter als Puffer.
|
|
static let maxHTMLChars = 4_900_000
|
|
|
|
private let directory: URL?
|
|
private let maxAge: TimeInterval
|
|
|
|
init(appGroup: String = "group.ev.mana.pageta", maxAgeDays: Double = 7) {
|
|
let container = FileManager.default
|
|
.containerURL(forSecurityApplicationGroupIdentifier: appGroup)?
|
|
.appendingPathComponent("PendingShareHTML", isDirectory: true)
|
|
// Kein App-Group-Entitlement (Dev-Setup): Store ist ein No-op.
|
|
self.init(directory: container, maxAgeDays: maxAgeDays)
|
|
}
|
|
|
|
/// Test-/Injektions-Init mit explizitem Verzeichnis.
|
|
init(directory: URL?, maxAgeDays: Double = 7) {
|
|
self.maxAge = maxAgeDays * 86_400
|
|
if let directory {
|
|
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
}
|
|
self.directory = directory
|
|
}
|
|
|
|
private func fileURL(for url: URL) -> URL? {
|
|
guard let directory else { return nil }
|
|
let digest = SHA256.hash(data: Data(url.absoluteString.utf8))
|
|
let name = digest.map { String(format: "%02x", $0) }.joined()
|
|
return directory.appendingPathComponent(name).appendingPathExtension("html")
|
|
}
|
|
|
|
/// HTML für eine URL ablegen (ShareExtension-seitig). Zu großes HTML wird
|
|
/// verworfen — dann fällt die Extraktion sauber auf den URL-Pfad zurück.
|
|
func put(_ html: String, for url: URL) {
|
|
guard !html.isEmpty, html.count <= Self.maxHTMLChars, let file = fileURL(for: url) else {
|
|
return
|
|
}
|
|
try? Data(html.utf8).write(to: file, options: .atomic)
|
|
}
|
|
|
|
/// HTML lesen — ohne zu löschen (Extraktion kann scheitern + retried werden).
|
|
func peek(for url: URL) -> String? {
|
|
guard let file = fileURL(for: url), let data = try? Data(contentsOf: file) else {
|
|
return nil
|
|
}
|
|
return String(data: data, encoding: .utf8)
|
|
}
|
|
|
|
/// HTML löschen — nach erfolgreicher Extraktion.
|
|
func remove(for url: URL) {
|
|
guard let file = fileURL(for: url) else { return }
|
|
try? FileManager.default.removeItem(at: file)
|
|
}
|
|
|
|
/// Verwaiste HTML-Blobs wegwerfen, die älter als `maxAge` sind (Share, der
|
|
/// nie erfolgreich extrahiert wurde). Beim App-Start aufrufen.
|
|
func prune() {
|
|
guard let directory else { return }
|
|
let cutoff = Date().addingTimeInterval(-maxAge)
|
|
let files = (try? FileManager.default.contentsOfDirectory(
|
|
at: directory,
|
|
includingPropertiesForKeys: [.contentModificationDateKey]
|
|
)) ?? []
|
|
for file in files {
|
|
let mod = (try? file.resourceValues(forKeys: [.contentModificationDateKey]))?
|
|
.contentModificationDate
|
|
if let mod, mod < cutoff {
|
|
try? FileManager.default.removeItem(at: file)
|
|
}
|
|
}
|
|
}
|
|
}
|