Skip to content

Commit

Permalink
Merge pull request #1521 from planetary-social/open-graph-media-type
Browse files Browse the repository at this point in the history
#1165: Parse more Open Graph metadata
  • Loading branch information
joshuatbrown authored Sep 19, 2024
2 parents b9ab0ad + 8411704 commit 86791b5
Show file tree
Hide file tree
Showing 10 changed files with 362 additions and 76 deletions.
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: URL?

/// 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: URL?, 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) -> URL? {
if let url = stringValue(.image, from: document), !url.isEmpty {
return URL(string: url)
} else if let url = stringValue(.imageURL, from: document), !url.isEmpty {
return URL(string: url)
} else if let url = stringValue(.imageSecureURL, from: document), !url.isEmpty {
return URL(string: 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) -> URL? {
if let url = stringValue(.video, from: document), !url.isEmpty {
return URL(string: url)
} else if let url = stringValue(.videoURL, from: document), !url.isEmpty {
return URL(string: url)
} else if let url = stringValue(.videoSecureURL, from: document), !url.isEmpty {
return URL(string: 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

0 comments on commit 86791b5

Please sign in to comment.