Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#1165: Parse more Open Graph metadata #1521

Merged
merged 3 commits into from
Sep 19, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions Nos.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
030036852C5D39DD002C71F5 /* RefreshController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030036842C5D39DD002C71F5 /* RefreshController.swift */; };
030036942C5D3AD3002C71F5 /* RefreshController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030036842C5D39DD002C71F5 /* RefreshController.swift */; };
030036AB2C5D872B002C71F5 /* NewNotesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030036AA2C5D872B002C71F5 /* NewNotesButton.swift */; };
0304D0A72C9B4BF2001D16C7 /* OpenGraphMetatdata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0304D0A62C9B4BF2001D16C7 /* OpenGraphMetatdata.swift */; };
0304D0A82C9B4BF2001D16C7 /* OpenGraphMetatdata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0304D0A62C9B4BF2001D16C7 /* OpenGraphMetatdata.swift */; };
030AE4292BE3D63C004DEE02 /* FeaturedAuthor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030AE4282BE3D63C004DEE02 /* FeaturedAuthor.swift */; };
0314D5AC2C7D31060002E7F4 /* MediaService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0314D5AB2C7D31060002E7F4 /* MediaService.swift */; };
0314D5AD2C7D31060002E7F4 /* MediaService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0314D5AB2C7D31060002E7F4 /* MediaService.swift */; };
Expand Down Expand Up @@ -138,13 +140,13 @@
3FFB1D9729A6BBEC002A755D /* Collection+SafeSubscript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FFB1D9529A6BBEC002A755D /* Collection+SafeSubscript.swift */; };
3FFB1D9C29A7DF9D002A755D /* StackedAvatarsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FFB1D9B29A7DF9D002A755D /* StackedAvatarsView.swift */; };
3FFF3BD029A9645F00DD0B72 /* AuthorReference+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F43C47529A9625700E896A0 /* AuthorReference+CoreDataClass.swift */; };
500899F32C95C1F900834588 /* PushNotificationRegistrar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 502B6C3C2C9462A400446316 /* PushNotificationRegistrar.swift */; };
50089A012C9712EF00834588 /* JSONEvent+Kinds.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50089A002C9712EF00834588 /* JSONEvent+Kinds.swift */; };
50089A022C9712EF00834588 /* JSONEvent+Kinds.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50089A002C9712EF00834588 /* JSONEvent+Kinds.swift */; };
50089A0C2C97182200834588 /* CurrentUser+PublishEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50089A0B2C97182200834588 /* CurrentUser+PublishEvents.swift */; };
50089A0D2C97182200834588 /* CurrentUser+PublishEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50089A0B2C97182200834588 /* CurrentUser+PublishEvents.swift */; };
500899F32C95C1F900834588 /* PushNotificationRegistrar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 502B6C3C2C9462A400446316 /* PushNotificationRegistrar.swift */; };
502B6C3D2C9462A400446316 /* PushNotificationRegistrar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 502B6C3C2C9462A400446316 /* PushNotificationRegistrar.swift */; };
50089A172C98678600834588 /* View+ListRowGradientBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50089A162C98678600834588 /* View+ListRowGradientBackground.swift */; };
502B6C3D2C9462A400446316 /* PushNotificationRegistrar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 502B6C3C2C9462A400446316 /* PushNotificationRegistrar.swift */; };
5044546E2C90726A00251A7E /* Event+Fetching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5044546D2C90726A00251A7E /* Event+Fetching.swift */; };
504454702C90728500251A7E /* Event+Hydration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5044546F2C90728500251A7E /* Event+Hydration.swift */; };
504454712C90728E00251A7E /* Event+Fetching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5044546D2C90726A00251A7E /* Event+Fetching.swift */; };
Expand Down Expand Up @@ -576,6 +578,7 @@
/* Begin PBXFileReference section */
030036842C5D39DD002C71F5 /* RefreshController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshController.swift; sourceTree = "<group>"; };
030036AA2C5D872B002C71F5 /* NewNotesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewNotesButton.swift; sourceTree = "<group>"; };
0304D0A62C9B4BF2001D16C7 /* OpenGraphMetatdata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGraphMetatdata.swift; sourceTree = "<group>"; };
030AE4282BE3D63C004DEE02 /* FeaturedAuthor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeaturedAuthor.swift; sourceTree = "<group>"; };
0314D5AB2C7D31060002E7F4 /* MediaService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaService.swift; sourceTree = "<group>"; };
0315B5EE2C7E451C0020E707 /* MockMediaService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockMediaService.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -671,8 +674,8 @@
3FFB1D9B29A7DF9D002A755D /* StackedAvatarsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StackedAvatarsView.swift; sourceTree = "<group>"; };
50089A002C9712EF00834588 /* JSONEvent+Kinds.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSONEvent+Kinds.swift"; sourceTree = "<group>"; };
50089A0B2C97182200834588 /* CurrentUser+PublishEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CurrentUser+PublishEvents.swift"; sourceTree = "<group>"; };
502B6C3C2C9462A400446316 /* PushNotificationRegistrar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationRegistrar.swift; sourceTree = "<group>"; };
50089A162C98678600834588 /* View+ListRowGradientBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ListRowGradientBackground.swift"; sourceTree = "<group>"; };
502B6C3C2C9462A400446316 /* PushNotificationRegistrar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationRegistrar.swift; sourceTree = "<group>"; };
5044546D2C90726A00251A7E /* Event+Fetching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Event+Fetching.swift"; sourceTree = "<group>"; };
5044546F2C90728500251A7E /* Event+Hydration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Event+Hydration.swift"; sourceTree = "<group>"; };
5045540C2C81E10C0044ECAE /* EditableAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditableAvatarView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1391,6 +1394,7 @@
03E7118B2C936DE5000B6F96 /* OpenGraph */ = {
isa = PBXGroup;
children = (
0304D0A62C9B4BF2001D16C7 /* OpenGraphMetatdata.swift */,
03E711802C936DD1000B6F96 /* OpenGraphParser.swift */,
03C49AC12C938DE100502321 /* SoupOpenGraphParser.swift */,
);
Expand Down Expand Up @@ -1818,7 +1822,6 @@
5B6EB48D29EDBE0E006E750C /* NoteParser.swift */,
C9AC31AC2A55E0BD00A94E5A /* NotificationViewModel.swift */,
C9CF23162A38A58B00EBEC31 /* ParseQueue.swift */,
5BCA95D12C8A5F0D00A52D1A /* PreviewEventRepository.swift */,
C9F0BB6A29A503D6000547FC /* PublicKey.swift */,
C96D39262B61B6D200D3D0A1 /* RawNostrID.swift */,
C9C2B78429E073E300548B4A /* RelaySubscription.swift */,
Expand Down Expand Up @@ -2353,6 +2356,7 @@
C987F85B29BA9ED800B44E7A /* Font+Clarity.swift in Sources */,
C94D6D5C2AC5D14400F0F11E /* UNSWizardTextField.swift in Sources */,
3FB5E651299D28A200386527 /* OnboardingView.swift in Sources */,
0304D0A72C9B4BF2001D16C7 /* OpenGraphMetatdata.swift in Sources */,
C973AB5F2A323167002AED16 /* AuthorReference+CoreDataProperties.swift in Sources */,
A3B943CF299AE00100A15A08 /* Keychain.swift in Sources */,
C9671D73298DB94C00EE7E12 /* Data+Encoding.swift in Sources */,
Expand Down Expand Up @@ -2535,6 +2539,7 @@
035729CA2BE4173E005FEE85 /* PreviewData.swift in Sources */,
037975D12C0E341500ADDF37 /* MockFeatureFlags.swift in Sources */,
C92E7F682C4EFF3D00B80638 /* WebSocketErrorEvent.swift in Sources */,
0304D0A82C9B4BF2001D16C7 /* OpenGraphMetatdata.swift in Sources */,
50089A0D2C97182200834588 /* CurrentUser+PublishEvents.swift in Sources */,
504454722C90729100251A7E /* Event+Hydration.swift in Sources */,
5BD08BB22A38E96F00BB926C /* JSONRelayMetadata.swift in Sources */,
Expand Down
3 changes: 0 additions & 3 deletions Nos/Assets/Localization/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -5508,9 +5508,6 @@
}
}
},
"Key" : {
"extractionState" : "manual"
},
"keys" : {
"extractionState" : "manual",
"localizations" : {
Expand Down
79 changes: 79 additions & 0 deletions Nos/Models/OpenGraph/OpenGraphMetatdata.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import Foundation

/// Open Graph metadata for a URL.
struct OpenGraphMetadata: Equatable {
/// The title of the object.
let title: String?

/// The type of the object.
let type: OpenGraphMediaType?

/// Image metadata, if any.
let image: OpenGraphMedia?

/// Video metadata, if any.
let video: OpenGraphMedia?
}

/// Open Graph metadata for media, such as an image or video.
struct OpenGraphMedia: Equatable {
/// The URL of the media.
let url: String?

/// The width of the media.
let width: Double?

/// The height of the media.
let height: Double?

/// Initializes an `OpenGraphMedia` with the given parameters. Returns `nil` if all parameter values are `nil`.
/// - Parameters:
/// - url: The URL of the media.
/// - width: The width of the media.
/// - height: The height of the media.
init?(url: String?, width: Double?, height: Double?) {
if url == nil && width == nil && height == nil {
return nil
}
self.url = url
self.width = width
self.height = height
}
}

/// The type of Open Graph media.
/// - SeeAlso: [The Open Graph protocol: Object Types](https://ogp.me/#types)
enum OpenGraphMediaType {
/// Video
case video

/// Website
case website

/// An unknown type
case unknown
}

/// An Open Graph property in the HTML.
/// - SeeAlso: [The Open Graph protocol](https://ogp.me)
enum OpenGraphProperty: String {
// MARK: - Title
case title = "og:title"

// MARK: - Type
case type = "og:type"

// MARK: - Image
case image = "og:image"
case imageURL = "og:image:url"
case imageSecureURL = "og:image:secure_url"
case imageHeight = "og:image:height"
case imageWidth = "og:image:width"

// MARK: - Video
case video = "og:video"
case videoURL = "og:video:url"
case videoSecureURL = "og:video:secure_url"
case videoHeight = "og:video:height"
case videoWidth = "og:video:width"
}
12 changes: 3 additions & 9 deletions Nos/Models/OpenGraph/OpenGraphParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,8 @@ import Foundation

/// Parses the Open Graph metadata from an HTML document.
protocol OpenGraphParser {
/// Fetches the Open Graph video metadata from the given HTML document.
/// Fetches the Open Graph metadata from the given HTML document.
/// - Parameter html: An HTML document.
/// - Returns: The Open Graph video metadata from the HTML.
func videoMetadata(html: Data) -> OpenGraphMedia?
}

/// An Open Graph property in the HTML.
enum OpenGraphProperty: String {
case videoHeight = "og:video:height"
case videoWidth = "og:video:width"
/// - Returns: The Open Graph metadata from the HTML.
func metadata(html: Data) -> OpenGraphMetadata?
}
110 changes: 97 additions & 13 deletions Nos/Models/OpenGraph/SoupOpenGraphParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,114 @@ import SwiftSoup

/// Parses the Open Graph metadata from an HTML document using SwiftSoup.
struct SoupOpenGraphParser: OpenGraphParser {
func videoMetadata(html: Data) -> OpenGraphMedia? {
func metadata(html: Data) -> OpenGraphMetadata? {
let htmlString = String(decoding: html, as: UTF8.self)
guard let document = try? SwiftSoup.parse(htmlString) else { return nil }

guard let widthString = openGraphProperty(.videoWidth, from: document),
let width = Double(widthString) else {
let title = stringValue(.title, from: document)
let type = typeMetadata(from: document)
let imageMetadata = imageMetadata(from: document)
let videoMetadata = videoMetadata(from: document)
return OpenGraphMetadata(title: title, type: type, image: imageMetadata, video: videoMetadata)
}
}

extension SoupOpenGraphParser {
/// Gets the Open Graph property value from the given HTML document as a String.
/// - Parameters:
/// - property: The Open Graph property to fetch from the HTML document.
/// - document: The HTML document.
/// - Returns: The value of the Open Graph property as a String, or `nil` if none is found.
private func stringValue(_ property: OpenGraphProperty, from document: Document) -> String? {
try? document.select("meta[property=\(property.rawValue)]").attr("content")
}

/// Gets the Open Graph property value from the given HTML document as a Double.
/// - Parameters:
/// - property: The Open Graph property to fetch from the HTML document.
/// - document: The HTML document.
/// - Returns: The value of the Open Graph property as a Double, or `nil` if none is found.
private func doubleValue(_ property: OpenGraphProperty, from document: Document) -> Double? {
guard let string: String = stringValue(property, from: document) else {
return nil
}
guard let heightString = openGraphProperty(.videoHeight, from: document),
let height = Double(heightString) else {
return Double(string)
}
}

extension SoupOpenGraphParser {
/// Gets the Open Graph image metadata from the given HTML document.
/// - Parameter document: The HTML document.
/// - Returns: The Open Graph image metadata, or `nil` if none is found.
private func imageMetadata(from document: Document) -> OpenGraphMedia? {
let url = imageURL(from: document)
let width = doubleValue(.imageWidth, from: document)
let height = doubleValue(.imageHeight, from: document)

return OpenGraphMedia(url: url, width: width, height: height)
}

/// Gets the Open Graph type metadata from the given HTML document.
/// - Parameter document: The HTML document.
/// - Returns: The Open Graph type metadata, or `nil` if none is found
private func typeMetadata(from document: Document) -> OpenGraphMediaType? {
guard let type: String = stringValue(.type, from: document) else {
return nil
}

return OpenGraphMedia(type: .video, width: width, height: height)
if type.starts(with: "video") {
return .video
} else if type == "website" {
return .website
} else {
return .unknown
}
}

/// Gets the Open Graph video metadata from the given HTML document.
/// - Parameter document: The HTML document.
/// - Returns: The Open Graph video metadata, or `nil` if none is found.
private func videoMetadata(from document: Document) -> OpenGraphMedia? {
let url = videoURL(from: document)
let width = doubleValue(.videoWidth, from: document)
let height = doubleValue(.videoHeight, from: document)

return OpenGraphMedia(url: url, width: width, height: height)
}
}

extension SoupOpenGraphParser {
/// Gets the Open Graph property value from the given HTML document.
/// - Parameters:
/// - property: The Open Graph property to fetch from the HTML document.
/// - document: The HTML document.
/// - Returns: The value of the Open Graph property, or `nil` if none is found.
private func openGraphProperty(_ property: OpenGraphProperty, from document: Document) -> String? {
try? document.select("meta[property=\(property.rawValue)]").attr("content")
/// Gets the Open Graph image URL from the given HTML document.
/// - Parameter document: The HTML document.
/// - Returns: The Open Graph image URL, or `nil` if none is found.
/// - Note: The image URL may be in a variety of properties, including `"og:image"`, `"og:image:url`, or
/// `"og:image:secure_url"`.
func imageURL(from document: Document) -> String? {
if let url = stringValue(.image, from: document), !url.isEmpty {
return url
} else if let url = stringValue(.imageURL, from: document), !url.isEmpty {
return url
} else if let url = stringValue(.imageSecureURL, from: document), !url.isEmpty {
return url
} else {
return nil
}
}

/// Gets the Open Graph video URL from the given HTML document.
/// - Parameter document: The HTML document.
/// - Returns: The Open Graph video URL, or `nil` if none is found.
/// - Note: The video URL may be in a variety of properties, including `"og:video"`, `"og:video:url`, or
/// `"og:video:secure_url"`.
func videoURL(from document: Document) -> String? {
if let url = stringValue(.video, from: document), !url.isEmpty {
return url
} else if let url = stringValue(.videoURL, from: document), !url.isEmpty {
return url
} else if let url = stringValue(.videoSecureURL, from: document), !url.isEmpty {
return url
} else {
return nil
}
}
}
25 changes: 3 additions & 22 deletions Nos/Service/Media/OpenGraphService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,36 +5,17 @@ protocol OpenGraphService {
/// Fetches metadata for the given URL.
/// - Parameter url: The URL to fetch.
/// - Returns: The Open Graph metadata for the URL.
func fetchMetadata(for url: URL) async throws -> OpenGraphMetadata
func fetchMetadata(for url: URL) async throws -> OpenGraphMetadata?
}

/// A default implementation for `OpenGraphService`.
struct DefaultOpenGraphService: OpenGraphService {
let session: URLSessionProtocol
let parser: OpenGraphParser

func fetchMetadata(for url: URL) async throws -> OpenGraphMetadata {
func fetchMetadata(for url: URL) async throws -> OpenGraphMetadata? {
let request = URLRequest(url: url)
let (data, _) = try await session.data(for: request)
let videoMetadata = parser.videoMetadata(html: data)
return OpenGraphMetadata(media: videoMetadata)
return parser.metadata(html: data)
}
}

/// Open Graph metadata for a URL.
struct OpenGraphMetadata: Equatable {
let media: OpenGraphMedia?
}

/// Open Graph metadata for media, such as an image or video.
struct OpenGraphMedia: Equatable {
let type: OpenGraphMediaType?
let width: Double?
let height: Double?
}

/// The type of Open Graph media.
enum OpenGraphMediaType {
case image
case video
}
2 changes: 1 addition & 1 deletion NosTests/Models/OpenGraph/MockOpenGraphParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Foundation

/// A mock Open Graph parser that can be used for testing.
struct MockOpenGraphParser: OpenGraphParser {
func videoMetadata(html: Data) -> OpenGraphMedia? {
func metadata(html: Data) -> OpenGraphMetadata? {
nil
}
}
Loading
Loading