From a034d04df5a6201589366501f738bf6b5ca1b8b2 Mon Sep 17 00:00:00 2001 From: Ezequiel Santos Date: Sun, 11 Aug 2024 16:48:05 +0100 Subject: [PATCH] v1.3.0 --- README.md | 18 +++--- .../{ => Impl}/GoogleScholarFetcher.swift | 58 +++++++++++++++---- .../GoogleScholarSwift/Models/Author.swift | 31 ++++++++++ .../Models/AuthorMetrics.swift | 24 ++++++++ .../GoogleScholarSwift/Models/Scientist.swift | 31 ---------- .../GoogleScholarSwiftTests.swift | 50 ++++++++++------ 6 files changed, 144 insertions(+), 68 deletions(-) rename Sources/GoogleScholarSwift/{ => Impl}/GoogleScholarFetcher.swift (83%) create mode 100644 Sources/GoogleScholarSwift/Models/Author.swift create mode 100644 Sources/GoogleScholarSwift/Models/AuthorMetrics.swift delete mode 100644 Sources/GoogleScholarSwift/Models/Scientist.swift diff --git a/README.md b/README.md index 3c59e4a..d406bb5 100644 --- a/README.md +++ b/README.md @@ -101,12 +101,12 @@ let article = try await fetcher.fetchArticle(articleLink: articleLink) print(article) ``` -### `fetchScientistDetails` +### `fetchAuthorDetails` -Fetches the scientist's details such as name, affiliation, and picture URL from Google Scholar. +Fetches the Author's details such as name, affiliation, and picture URL from Google Scholar. ```swift -public func fetchScientistDetails(scholarID: GoogleScholarID) async throws -> Scientist +public func fetchAuthorDetails(scholarID: GoogleScholarID) async throws -> Author ``` #### Parameters @@ -115,7 +115,7 @@ public func fetchScientistDetails(scholarID: GoogleScholarID) async throws -> Sc #### Returns -- A `Scientist` object containing the scientist's details. +- A `Author` object containing the Author's details. #### Throws @@ -126,8 +126,8 @@ public func fetchScientistDetails(scholarID: GoogleScholarID) async throws -> Sc ```swift let fetcher = GoogleScholarFetcher() -let scientistDetails = try await fetcher.fetchScientistDetails(scholarID: GoogleScholarID("6nOPl94AAAAJ")) -print(scientistDetails) +let AuthorDetails = try await fetcher.fetchAuthorDetails(scholarID: GoogleScholarID("6nOPl94AAAAJ")) +print(AuthorDetails) ``` ### Complete Example @@ -157,9 +157,9 @@ if let firstPublication = publications.first { print("Article Details:", article) } -// Fetch scientist details -let scientistDetails = try await fetcher.fetchScientistDetails(scholarID: authorID) -print("Scientist Details:", scientistDetails) +// Fetch Author details +let AuthorDetails = try await fetcher.fetchAuthorDetails(scholarID: authorID) +print("Author Details:", AuthorDetails) ``` ## Contributing diff --git a/Sources/GoogleScholarSwift/GoogleScholarFetcher.swift b/Sources/GoogleScholarSwift/Impl/GoogleScholarFetcher.swift similarity index 83% rename from Sources/GoogleScholarSwift/GoogleScholarFetcher.swift rename to Sources/GoogleScholarSwift/Impl/GoogleScholarFetcher.swift index a9a35fc..3928ba0 100644 --- a/Sources/GoogleScholarSwift/GoogleScholarFetcher.swift +++ b/Sources/GoogleScholarSwift/Impl/GoogleScholarFetcher.swift @@ -4,6 +4,7 @@ import SwiftSoup /// A class responsible for fetching data from Google Scholar. public class GoogleScholarFetcher { private let session: URLSession + private var publicationCache: [GoogleScholarID: [Publication]] = [:] // MARK: Public Methods @@ -26,7 +27,7 @@ public class GoogleScholarFetcher { /// - Example: /// ```swift /// let fetcher = GoogleScholarFetcher() - /// let publications = try await fetcher.fetchAllPublications(authorID: GoogleScholarID("6nOPl94AAAAJ"), fetchQuantity: .specific(10)) + /// let publications = try await fetcher.fetchAllPublications(authorID: GoogleScholarID("RefX_60AAAAJ"), fetchQuantity: .specific(10)) /// print(publications) /// ``` public func fetchAllPublications( @@ -178,19 +179,19 @@ public class GoogleScholarFetcher { return publications } - /// Fetches the scientist's details such as name, affiliation, and picture URL from Google Scholar. + /// Fetches the author's details such as name, affiliation, and picture URL from Google Scholar. /// /// - Parameter scholarID: The Google Scholar author ID. - /// - Returns: A `Scientist` object containing the scientist's details. + /// - Returns: A `Author` object containing the author's details. /// - Throws: An error if fetching or parsing fails. /// /// - Example: /// ```swift /// let fetcher = GoogleScholarFetcher() - /// let scientistDetails = try await fetcher.fetchScientistDetails(scholarID: GoogleScholarID("6nOPl94AAAAJ")) - /// print(scientistDetails) + /// let authorDetails = try await fetcher.fetchAuthorDetails(scholarID: GoogleScholarID("RefX_60AAAAJ")) + /// print(authorDetails) /// ``` - public func fetchScientistDetails(scholarID: GoogleScholarID) async throws -> Scientist { + public func fetchAuthorDetails(scholarID: GoogleScholarID) async throws -> Author { guard var urlComponents = URLComponents(string: "https://scholar.google.com/citations") else { throw NSError(domain: "Invalid URL", code: 0, userInfo: nil) } @@ -216,7 +217,36 @@ public class GoogleScholarFetcher { throw NSError(domain: "Invalid Data", code: 0, userInfo: nil) } - return try parseScientistDetails(from: html, id: scholarID) + return try parseAuthorDetails(from: html, id: scholarID) + } + + /// Fetches the total number of citations and publications for a given author from Google Scholar. + /// + /// - Parameters: + /// - authorID: The Google Scholar author ID. + /// - fetchQuantity: The quantity of publications to fetch. Can be `.all` or `.specific(Int)`. Defaults to `.all`. + /// - Returns: An `AuthorMetrics` struct containing the total number of citations and total number of publications. + /// - Throws: An error if fetching or parsing fails. + /// + /// - Example: + /// ```swift + /// let fetcher = GoogleScholarFetcher() + /// let metrics = try await fetcher.getAuthorMetrics(authorID: GoogleScholarID("RefX_60AAAAJ")) + /// print("Total Citations: \(metrics.citations), Total Publications: \(metrics.publications)") + /// ``` + public func getAuthorMetrics( + authorID: GoogleScholarID, + fetchQuantity: FetchQuantity = .all + ) async throws -> AuthorMetrics { + let publications = try await fetchAllPublications(authorID: authorID, fetchQuantity: fetchQuantity) + + let totalCitations = publications.reduce(into: 0) { (sum, publication) in + sum += Int(publication.citations.onlyNumbers) ?? 0 + } + + let totalPublications = publications.count + + return AuthorMetrics(citations: totalCitations, publications: totalPublications) } // MARK: Private Methods @@ -297,21 +327,27 @@ public class GoogleScholarFetcher { return "" } - /// Parses the scientist's details from the HTML string. + /// Parses the author's details from the HTML string. /// /// - Parameters: /// - html: The HTML string to parse. /// - id: The Google Scholar author ID. - /// - Returns: A `Scientist` object containing the scientist's details. + /// - Returns: A `Author` object containing the author's details. /// - Throws: An error if parsing fails. - private func parseScientistDetails(from html: String, id: GoogleScholarID) throws -> Scientist { + private func parseAuthorDetails(from html: String, id: GoogleScholarID) throws -> Author { let doc: Document = try SwiftSoup.parse(html) let name = try doc.select("#gsc_prf_in").text() let affiliation = try doc.select(".gsc_prf_il").first()?.text() ?? "" let pictureURL = try doc.select("#gsc_prf_pua img").attr("src") - return Scientist(id: id, name: name, affiliation: affiliation, pictureURL: pictureURL) + return Author(id: id, name: name, affiliation: affiliation, pictureURL: pictureURL) } } +extension String { + /// A computed property that returns only the numeric characters in the string. + var onlyNumbers: String { + return self.filter { $0.isNumber } + } +} diff --git a/Sources/GoogleScholarSwift/Models/Author.swift b/Sources/GoogleScholarSwift/Models/Author.swift new file mode 100644 index 0000000..e2d566e --- /dev/null +++ b/Sources/GoogleScholarSwift/Models/Author.swift @@ -0,0 +1,31 @@ +import Foundation + +/// Model for a author's details. +public struct Author: Codable, Hashable, Identifiable, Equatable, CustomStringConvertible { + /// Unique identifier for the author. + public let id: GoogleScholarID + /// The name of the author. + public let name: String + /// The affiliation of the author. + public let affiliation: String + /// URL of the author's picture. + public let pictureURL: String + + /// Initializes a new `Author` instance. + /// + /// - Parameters: + /// - id: The Google Scholar author ID. + /// - name: The name of the author. + /// - affiliation: The affiliation of the author. + /// - pictureURL: The URL of the author's picture. + public init(id: GoogleScholarID, name: String, affiliation: String, pictureURL: String) { + self.id = id + self.name = name + self.affiliation = affiliation + self.pictureURL = pictureURL + } + + public var description: String { + return "Author(id: \(id), name: \(name), affiliation: \(affiliation), pictureURL: \(pictureURL))" + } +} diff --git a/Sources/GoogleScholarSwift/Models/AuthorMetrics.swift b/Sources/GoogleScholarSwift/Models/AuthorMetrics.swift new file mode 100644 index 0000000..3700e07 --- /dev/null +++ b/Sources/GoogleScholarSwift/Models/AuthorMetrics.swift @@ -0,0 +1,24 @@ +import Foundation + +/// A struct representing the total citations and publications for a given author. +public struct AuthorMetrics: Codable, Hashable, Equatable, CustomStringConvertible { + + /// The total number of citations across all fetched publications. + public let citations: Int + + /// The total number of publications fetched. + public let publications: Int + + /// Initializes a new `AuthorMetrics` instance. + /// - Parameters: + /// - citations: The total number of citations. + /// - publications: The total number of publications. + public init(citations: Int, publications: Int) { + self.citations = citations + self.publications = publications + } + + public var description: String { + return "AuthorMetrics(citations: \(citations), publications: \(publications)" + } +} diff --git a/Sources/GoogleScholarSwift/Models/Scientist.swift b/Sources/GoogleScholarSwift/Models/Scientist.swift deleted file mode 100644 index 144a71f..0000000 --- a/Sources/GoogleScholarSwift/Models/Scientist.swift +++ /dev/null @@ -1,31 +0,0 @@ -import Foundation - -/// Model for a scientist's details. -public struct Scientist: Codable, Hashable, Identifiable, Equatable, CustomStringConvertible { - /// Unique identifier for the scientist. - public let id: GoogleScholarID - /// The name of the scientist. - public let name: String - /// The affiliation of the scientist. - public let affiliation: String - /// URL of the scientist's picture. - public let pictureURL: String - - /// Initializes a new `Scientist` instance. - /// - /// - Parameters: - /// - id: The Google Scholar author ID. - /// - name: The name of the scientist. - /// - affiliation: The affiliation of the scientist. - /// - pictureURL: The URL of the scientist's picture. - public init(id: GoogleScholarID, name: String, affiliation: String, pictureURL: String) { - self.id = id - self.name = name - self.affiliation = affiliation - self.pictureURL = pictureURL - } - - public var description: String { - return "Scientist(id: \(id), name: \(name), affiliation: \(affiliation), pictureURL: \(pictureURL))" - } -} diff --git a/Tests/GoogleScholarFetcherTests/GoogleScholarSwiftTests.swift b/Tests/GoogleScholarFetcherTests/GoogleScholarSwiftTests.swift index 1e93575..7764395 100644 --- a/Tests/GoogleScholarFetcherTests/GoogleScholarSwiftTests.swift +++ b/Tests/GoogleScholarFetcherTests/GoogleScholarSwiftTests.swift @@ -1,7 +1,7 @@ import XCTest @testable import GoogleScholarSwift -// Take care because this is not unity tests, it's more End to end tests (needs internet) +// Take care because these are not unit tests, but more end-to-end tests (require internet) final class GoogleScholarFetcherTests: XCTestCase { @@ -26,7 +26,7 @@ final class GoogleScholarFetcherTests: XCTestCase { do { let publications = try await fetcher.fetchAllPublications(authorID: authorID, fetchQuantity: fetchQuantity, sortBy: .pubdate) XCTAssertEqual(publications.count, 1, "Number of publications should match the limit") - XCTAssertTrue(Int(publications[0].year) ?? 0 >= 2023) + XCTAssertTrue(Int(publications[0].year) ?? 0 >= 2023, "Publication year should be 2023 or later") } catch { XCTFail("Error fetching publications: \(error)") } @@ -40,7 +40,7 @@ final class GoogleScholarFetcherTests: XCTestCase { do { let publications = try await fetcher.fetchAllPublications(authorID: authorID, fetchQuantity: fetchQuantity, sortBy: .cited) XCTAssertEqual(publications.count, 1, "Number of publications should match the limit") - XCTAssertTrue(Int(publications[0].citations) ?? 0 > 2400) + XCTAssertTrue(Int(publications[0].citations.onlyNumbers) ?? 0 > 2400, "Citations should be greater than 2400") } catch { XCTFail("Error fetching publications: \(error)") } @@ -73,30 +73,46 @@ final class GoogleScholarFetcherTests: XCTestCase { do { let article = try await fetcher.fetchArticle(articleLink: articleLink) XCTAssertNotNil(article, "Article should not be nil") - XCTAssertNotNil(article.title) - XCTAssertNotNil(article.authors) - XCTAssertNotNil(article.publicationDate) - XCTAssertNotNil(article.publication) - XCTAssertNotNil(article.description) - XCTAssertNotNil(article.totalCitations) + XCTAssertNotNil(article.title, "Article title should not be nil") + XCTAssertNotNil(article.authors, "Article authors should not be nil") + XCTAssertNotNil(article.publicationDate, "Article publication date should not be nil") + XCTAssertNotNil(article.publication, "Article publication should not be nil") + XCTAssertNotNil(article.description, "Article description should not be nil") + XCTAssertNotNil(article.totalCitations.onlyNumbers, "Article total citations should not be nil") } catch { XCTFail("Error fetching article details: \(error)") } } - func test_FetchScientistDetails() async throws { + func test_FetchAuthorDetails() async throws { let fetcher = GoogleScholarFetcher() let scholarID = GoogleScholarID("RefX_60AAAAJ") do { - let scientist = try await fetcher.fetchScientistDetails(scholarID: scholarID) - XCTAssertNotNil(scientist, "Scientist should not be nil") - XCTAssertEqual(scientist.id, scholarID) - XCTAssertNotNil(scientist.name) - XCTAssertNotNil(scientist.affiliation) - XCTAssertNotNil(scientist.pictureURL) + let author = try await fetcher.fetchAuthorDetails(scholarID: scholarID) + XCTAssertNotNil(author, "Author should not be nil") + XCTAssertEqual(author.id, scholarID) + XCTAssertNotNil(author.name, "Author name should not be nil") + XCTAssertNotNil(author.affiliation, "Author affiliation should not be nil") + XCTAssertNotNil(author.pictureURL, "Author picture URL should not be nil") } catch { - XCTFail("Error fetching scientist details: \(error)") + XCTFail("Error fetching author details: \(error)") + } + } + + func test_AuthorMetrics() async throws { + let fetcher = GoogleScholarFetcher() + let authorID = GoogleScholarID("RefX_60AAAAJ") + let fetchQuantity = FetchQuantity.specific(10) + + do { + let metrics = try await fetcher.getAuthorMetrics(authorID: authorID, fetchQuantity: fetchQuantity) + + XCTAssertEqual(metrics.publications, 10, "Total publications should match the requested quantity") + XCTAssertTrue(metrics.citations > 0, "Total citations should be greater than 0") + } catch { + XCTFail("Error fetching author metrics: \(error)") } } } +