pageta-native/Sources/Core/SharedHTMLStore.swift
Till JS 27ef644892 feat(share): ShareExtension erfasst gerendertes HTML gegen Consent-Wände (C1)
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>
2026-05-28 01:01:21 +02:00

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)
}
}
}
}