├── .github └── workflows │ └── tests.yml ├── .gitignore ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── AppStoreScraper │ ├── Models │ ├── Application.swift │ ├── Category.swift │ ├── CategoryFilter.swift │ ├── Country+Languages.swift │ ├── Country.swift │ ├── GamesSubcategory.swift │ ├── Language.swift │ ├── Ranking.swift │ └── RankingType.swift │ └── Scraper.swift └── Tests └── AppStoreScraperTests ├── CountryTests.swift └── ScraperTests.swift /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - develop 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | macos-run-tests: 14 | runs-on: macos-14 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | - name: Run tests 19 | run: swift test 20 | 21 | linux-run-tests: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v4 26 | - name: Run tests 27 | run: swift test 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/ 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Mikhail Akopov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "AppStoreScraper", 7 | platforms: [.iOS(.v13), .macOS(.v10_15), .watchOS(.v6), .tvOS(.v13)], 8 | products: [ 9 | .library( 10 | name: "AppStoreScraper", 11 | targets: ["AppStoreScraper"] 12 | ), 13 | ], 14 | dependencies: [ 15 | ], 16 | targets: [ 17 | .target( 18 | name: "AppStoreScraper", 19 | dependencies: [] 20 | ), 21 | .testTarget( 22 | name: "AppStoreScraperTests", 23 | dependencies: ["AppStoreScraper"] 24 | ), 25 | ] 26 | ) 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AppStoreScraper 2 | 3 | [![Status](https://github.com/wacumov/app-store-scraper/workflows/tests/badge.svg?branch=main)](https://github.com/wacumov/app-store-scraper/actions?query=workflow%3Atests+branch%3Amain) 4 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fwacumov%2Fapp-store-scraper%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/wacumov/app-store-scraper) 5 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fwacumov%2Fapp-store-scraper%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/wacumov/app-store-scraper) 6 | 7 | A library to scrape application data from the App Store. 8 | 9 | ## Installation 10 | 11 | AppStoreScraper is distributed using the [Swift Package Manager](https://swift.org/package-manager). To install it within another Swift package, add it as a dependency within your `Package.swift` manifest: 12 | 13 | ```swift 14 | let package = Package( 15 | ... 16 | dependencies: [ 17 | .package(url: "https://github.com/wacumov/app-store-scraper.git", from: "0.1.0") 18 | ], 19 | ... 20 | ) 21 | ``` 22 | 23 | If you’d like to use AppStoreScraper within an iOS, macOS, watchOS or tvOS app, then use Xcode’s `File > Add Packages...` menu command to add it to your project. 24 | 25 | Then import AppStoreScraper wherever you’d like to use it: 26 | 27 | ```swift 28 | import AppStoreScraper 29 | ``` 30 | 31 | For more information on how to use the Swift Package Manager, check out [its official documentation](https://swift.org/package-manager). 32 | -------------------------------------------------------------------------------- /Sources/AppStoreScraper/Models/Application.swift: -------------------------------------------------------------------------------- 1 | public struct Application: Codable { 2 | public let id: Int 3 | public let bundleId: String 4 | 5 | public let name: String 6 | public let description: String 7 | 8 | public let iconUrl60: String 9 | public let iconUrl100: String 10 | public let iconUrl512: String 11 | 12 | public let screenshotUrls: [String] 13 | public let ipadScreenshotUrls: [String] 14 | 15 | public let primaryCategoryName: String 16 | public let primaryCategoryId: Int 17 | public let categories: [String] 18 | public let categoryIds: [String] 19 | 20 | public let languageCodes: [String] 21 | 22 | public let fileSizeBytes: String 23 | public let minimumOsVersion: String 24 | public let supportedDevices: [String] 25 | 26 | public let developerId: Int 27 | public let developerName: String 28 | public let developerUrl: String? 29 | 30 | public let price: Double? 31 | public let currency: String 32 | public let formattedPrice: String? 33 | 34 | public let releaseDate: String 35 | public let currentVersionReleaseDate: String 36 | public let version: String 37 | public let releaseNotes: String? 38 | 39 | public let advisories: [String] 40 | public let contentAdvisoryRating: String 41 | public let appContentRating: String 42 | 43 | public enum CodingKeys: String, CodingKey { 44 | case id = "trackId" 45 | case bundleId 46 | 47 | case name = "trackName" 48 | case description 49 | 50 | case iconUrl60 = "artworkUrl60" 51 | case iconUrl100 = "artworkUrl100" 52 | case iconUrl512 = "artworkUrl512" 53 | 54 | case screenshotUrls, ipadScreenshotUrls 55 | 56 | case primaryCategoryName = "primaryGenreName" 57 | case primaryCategoryId = "primaryGenreId" 58 | case categories = "genres" 59 | case categoryIds = "genreIds" 60 | 61 | case languageCodes = "languageCodesISO2A" 62 | 63 | case fileSizeBytes, minimumOsVersion, supportedDevices 64 | 65 | case developerId = "artistId" 66 | case developerName = "artistName" 67 | case developerUrl = "sellerUrl" 68 | 69 | case price, currency, formattedPrice 70 | case releaseDate, currentVersionReleaseDate, version, releaseNotes 71 | 72 | case advisories, contentAdvisoryRating 73 | case appContentRating = "trackContentRating" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Sources/AppStoreScraper/Models/Category.swift: -------------------------------------------------------------------------------- 1 | public enum Category: Int, Codable, CaseIterable { 2 | case business = 6000 3 | case weather = 6001 4 | case utilities = 6002 5 | case travel = 6003 6 | case sports = 6004 7 | case socialNetworking = 6005 8 | case reference = 6006 9 | case productivity = 6007 10 | case photoAndVideo = 6008 11 | case news = 6009 12 | case navigation = 6010 13 | case music = 6011 14 | case lifestyle = 6012 15 | case healthAndFitness = 6013 16 | case games = 6014 17 | case finance = 6015 18 | case entertainment = 6016 19 | case education = 6017 20 | case books = 6018 21 | case medical = 6020 22 | case magazinesAndNewspapers = 6021 23 | case foodAndDrink = 6023 24 | case shopping = 6024 25 | case developerTools = 6026 26 | case graphicsAndDesign = 6027 27 | 28 | public var name: String { 29 | switch self { 30 | case .business: return "Business" 31 | case .weather: return "Weather" 32 | case .utilities: return "Utilities" 33 | case .travel: return "Travel" 34 | case .sports: return "Sports" 35 | case .socialNetworking: return "Social Networking" 36 | case .reference: return "Reference" 37 | case .productivity: return "Productivity" 38 | case .photoAndVideo: return "Photo & Video" 39 | case .news: return "News" 40 | case .navigation: return "Navigation" 41 | case .music: return "Music" 42 | case .lifestyle: return "Lifestyle" 43 | case .healthAndFitness: return "Health & Fitness" 44 | case .games: return "Games" 45 | case .finance: return "Finance" 46 | case .entertainment: return "Entertainment" 47 | case .education: return "Education" 48 | case .books: return "Books" 49 | case .medical: return "Medical" 50 | case .magazinesAndNewspapers: return "Magazines & Newspapers" 51 | case .foodAndDrink: return "Food & Drink" 52 | case .shopping: return "Shopping" 53 | case .developerTools: return "Developer Tools" 54 | case .graphicsAndDesign: return "Graphics & Design" 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/AppStoreScraper/Models/CategoryFilter.swift: -------------------------------------------------------------------------------- 1 | public enum CategoryFilter: RawRepresentable, Equatable, Hashable, CaseIterable { 2 | case allApps 3 | case category(Category) 4 | case gamesSubcategory(GamesSubcategory) 5 | 6 | public static var allCases: [CategoryFilter] { 7 | [CategoryFilter.allApps] + Category.allCases.map { 8 | CategoryFilter.category($0) 9 | } + GamesSubcategory.allCases.map { 10 | CategoryFilter.gamesSubcategory($0) 11 | } 12 | } 13 | 14 | public typealias RawValue = Int 15 | 16 | public init?(rawValue: Int) { 17 | if let category = Category(rawValue: rawValue) { 18 | self = .category(category) 19 | } else if let subcategory = GamesSubcategory(rawValue: rawValue) { 20 | self = .gamesSubcategory(subcategory) 21 | } else if rawValue == 0 { 22 | self = .allApps 23 | } else { 24 | return nil 25 | } 26 | } 27 | 28 | public var rawValue: Int { 29 | switch self { 30 | case .allApps: return 0 31 | case let .category(category): return category.rawValue 32 | case let .gamesSubcategory(subcategory): return subcategory.rawValue 33 | } 34 | } 35 | 36 | public var name: String { 37 | switch self { 38 | case .allApps: return "All apps" 39 | case let .category(category): return category.name 40 | case let .gamesSubcategory(subcategory): return "Games / \(subcategory.name)" 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/AppStoreScraper/Models/Country+Languages.swift: -------------------------------------------------------------------------------- 1 | public extension Country { 2 | var languages: (main: Language, additional: [Language]) { 3 | switch self { 4 | case .AE, .IQ, .JO, .KW, .LY, .OM, .QA, .SA, .YE: return (main: .en_GB, additional: [.ar_SA]) 5 | case .AF, .AG, .AI, .AL, .AM, .AO, .AZ, .BB, .BG, .BH, .BM, .BN, .BS, .BT, .BW, .BY, .CV, .DM, .EE, .FJ, .FM, .GB, .GD, .GE, .GH, .GM, .GY, .IE, .IS, .JM, .KE, .KG, .KN, .KY, .KZ, .LC, .LK, .LR, .LT, .LV, .MD, .MK, .MM, .MN, .MS, .MT, .MV, .MW, .MY, .MZ, .NA, .NE, .NG, .NP, .NR, .NZ, .PG, .PH, .PK, .PW, .SB, .SI, .SL, .ST, .SZ, .TC, .TJ, .TM, .TO, .TZ, .UG, .UZ, .VC, .VG, .XK, .ZA, .ZM, .ZW: return (main: .en_GB, additional: []) 6 | case .AR, .CL, .CO, .CR, .EC, .GT, .HN, .NI, .PA, .PE, .PY, .SV, .VE: return (main: .es_MX, additional: [.en_GB]) 7 | case .AT, .DE: return (main: .de_DE, additional: [.en_GB]) 8 | case .AU: return (main: .en_AU, additional: []) 9 | case .BA, .HR, .ME, .RS: return (main: .en_GB, additional: [.hr]) 10 | case .BE: return (main: .en_GB, additional: [.fr_FR, .nl_NL]) 11 | case .BF, .BJ, .CD, .CG, .GW, .KH, .LA, .MG, .ML, .MU, .RW, .SC, .SN, .TD, .TT, .VU: return (main: .en_GB, additional: [.fr_FR]) 12 | case .BO: return (main: .es_ES, additional: [.en_GB]) 13 | case .BR: return (main: .pt_BR, additional: [.en_GB]) 14 | case .BZ: return (main: .en_GB, additional: [.es_ES]) 15 | case .CA: return (main: .en_CA, additional: [.fr_CA]) 16 | case .CH: return (main: .de_DE, additional: [.en_GB, .fr_FR, .it]) 17 | case .CI, .CM, .FR, .GA: return (main: .fr_FR, additional: [.en_GB]) 18 | case .CN: return (main: .zh_Hans, additional: [.en_GB]) 19 | case .CY, .TR: return (main: .en_GB, additional: [.tr]) 20 | case .CZ: return (main: .en_GB, additional: [.cs]) 21 | case .DK: return (main: .en_GB, additional: [.da]) 22 | case .DO: return (main: .es_MX, additional: [.fr_FR]) 23 | case .DZ, .EG, .LB, .MA, .MR, .TN: return (main: .en_GB, additional: [.ar_SA, .fr_FR]) 24 | case .ES: return (main: .es_ES, additional: [.ca, .en_GB]) 25 | case .FI: return (main: .en_GB, additional: [.fi]) 26 | case .GR: return (main: .el, additional: []) 27 | case .HK, .MO, .TW: return (main: .zh_Hant, additional: [.en_GB]) 28 | case .HU: return (main: .en_GB, additional: [.hu]) 29 | case .ID: return (main: .en_GB, additional: [.id]) 30 | case .IL: return (main: .en_GB, additional: [.he]) 31 | case .IN: return (main: .en_GB, additional: [.hi]) 32 | case .IT: return (main: .it, additional: [.en_GB]) 33 | case .JP: return (main: .ja, additional: [.en_GB]) 34 | case .KR: return (main: .ko, additional: [.en_GB]) 35 | case .LU: return (main: .en_GB, additional: [.de_DE, .fr_FR]) 36 | case .MX: return (main: .es_MX, additional: []) 37 | case .NL: return (main: .nl_NL, additional: [.en_GB]) 38 | case .NO: return (main: .en_GB, additional: [.no]) 39 | case .PL: return (main: .en_GB, additional: [.pl]) 40 | case .PT: return (main: .pt_PT, additional: [.en_GB]) 41 | case .RO: return (main: .en_GB, additional: [.ro]) 42 | case .RU: return (main: .ru, additional: [.en_GB, .uk]) 43 | case .SE: return (main: .sv, additional: [.en_GB]) 44 | case .SG: return (main: .en_GB, additional: [.zh_Hans]) 45 | case .SK: return (main: .en_GB, additional: [.sk]) 46 | case .SR: return (main: .en_GB, additional: [.nl_NL]) 47 | case .TH: return (main: .en_GB, additional: [.th]) 48 | case .UA: return (main: .en_GB, additional: [.ru, .uk]) 49 | case .US: return (main: .en_US, additional: [.ar_SA, .es_MX, .fr_FR, .ko, .pt_BR, .ru, .vi, .zh_Hans, .zh_Hant]) 50 | case .UY: return (main: .en_GB, additional: [.es_MX]) 51 | case .VN: return (main: .en_GB, additional: [.vi]) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/AppStoreScraper/Models/Country.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum Country: String, Codable, CaseIterable { 4 | case AE, AF, AG, AI, AL, AM, AO, AR, AT, AU, AZ 5 | case BA, BB, BE, BF, BG, BH, BJ, BM, BN, BO, BR, BS, BT, BW, BY, BZ 6 | case CA, CD, CG, CH, CI, CL, CM, CN, CO, CR, CV, CY, CZ 7 | case DE, DK, DM, DO, DZ 8 | case EC, EE, EG, ES 9 | case FI, FJ, FM, FR 10 | case GA, GB, GD, GE, GH, GM, GR, GT, GW, GY 11 | case HK, HN, HR, HU 12 | case ID, IE, IL, IN, IQ, IS, IT 13 | case JM, JO, JP 14 | case KE, KG, KH, KN, KR, KW, KY, KZ 15 | case LA, LB, LC, LK, LR, LT, LU, LV, LY 16 | case MA, MD, ME, MV, MG, MK, ML, MM, MN, MO, MR, MS, MT, MU, MW, MX, MY, MZ 17 | case NA, NE, NG, NI, NL, NO, NP, NR, NZ 18 | case OM 19 | case PA, PE, PG, PH, PK, PL, PT, PW, PY 20 | case QA 21 | case RO, RS, RU, RW 22 | case SA, SB, SC, SE, SG, SI, SK, SL, SN, SR, ST, SV, SZ 23 | case TC, TD, TH, TJ, TM, TN, TO, TR, TT, TW, TZ 24 | case UA, UG, US, UY, UZ 25 | case VC, VE, VG, VN, VU 26 | case XK 27 | case YE 28 | case ZA, ZM, ZW 29 | 30 | public var name: String { 31 | let locale = Locale(identifier: "en_US") 32 | return locale.localizedString(forRegionCode: rawValue) ?? rawValue 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/AppStoreScraper/Models/GamesSubcategory.swift: -------------------------------------------------------------------------------- 1 | public enum GamesSubcategory: Int, Codable, CaseIterable { 2 | case action = 7001 3 | case adventure = 7002 4 | case casual = 7003 5 | case board = 7004 6 | case card = 7005 7 | case casino = 7006 8 | case family = 7009 9 | case music = 7011 10 | case puzzle = 7012 11 | case racing = 7013 12 | case rolePlaying = 7014 13 | case simulation = 7015 14 | case sports = 7016 15 | case strategy = 7017 16 | case trivia = 7018 17 | case word = 7019 18 | 19 | public var name: String { 20 | switch self { 21 | case .action: return "Action" 22 | case .adventure: return "Adventure" 23 | case .casual: return "Casual" 24 | case .board: return "Board" 25 | case .card: return "Card" 26 | case .casino: return "Casino" 27 | case .family: return "Family" 28 | case .music: return "Music" 29 | case .puzzle: return "Puzzle" 30 | case .racing: return "Racing" 31 | case .rolePlaying: return "Role Playing" 32 | case .simulation: return "Simulation" 33 | case .sports: return "Sports" 34 | case .strategy: return "Strategy" 35 | case .trivia: return "Trivia" 36 | case .word: return "Word" 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/AppStoreScraper/Models/Language.swift: -------------------------------------------------------------------------------- 1 | public enum Language: String, Codable, CaseIterable { 2 | case ar_SA 3 | case ca, cs 4 | case da, de_DE 5 | case el, en_AU, en_CA, en_GB, en_US, es_ES, es_MX 6 | case fi, fr_CA, fr_FR 7 | case he, hi, hr, hu 8 | case id, it 9 | case ja 10 | case ko 11 | case ms 12 | case nl_NL, no 13 | case pl, pt_BR, pt_PT 14 | case ro, ru 15 | case sk, sv 16 | case th, tr 17 | case uk 18 | case vi 19 | case zh_Hans, zh_Hant 20 | } 21 | -------------------------------------------------------------------------------- /Sources/AppStoreScraper/Models/Ranking.swift: -------------------------------------------------------------------------------- 1 | public struct Ranking: Codable { 2 | public let applications: [Application] 3 | 4 | public struct Application: Codable { 5 | public let name: Name 6 | public let icons: [Icon] 7 | public let price: Price 8 | public let developer: Developer 9 | public let releaseDate: ReleaseDate 10 | public let summary: Summary? 11 | public let rights: Rights? 12 | public let id: ID 13 | public let category: Category 14 | 15 | enum CodingKeys: String, CodingKey { 16 | case name = "im:name" 17 | case icons = "im:image" 18 | case price = "im:price" 19 | case developer = "im:artist" 20 | case releaseDate = "im:releaseDate" 21 | case summary, rights, id, category 22 | } 23 | 24 | public typealias Name = Label 25 | 26 | public struct Icon: Codable { 27 | public let url: String 28 | public let attributes: Attributes 29 | 30 | enum CodingKeys: String, CodingKey { 31 | case url = "label" 32 | case attributes 33 | } 34 | 35 | public struct Attributes: Codable { 36 | public let height: String 37 | } 38 | } 39 | 40 | public struct Price: Codable { 41 | public let attributes: Attributes 42 | 43 | public struct Attributes: Codable { 44 | public let amount: String 45 | public let currency: String 46 | } 47 | } 48 | 49 | public typealias Developer = Label 50 | public typealias ReleaseDate = Label 51 | public typealias Summary = Label 52 | public typealias Rights = Label 53 | 54 | public struct ID: Codable { 55 | public let attributes: Attributes 56 | 57 | public struct Attributes: Codable { 58 | public let id: String 59 | public let bundleId: String 60 | 61 | public enum CodingKeys: String, CodingKey { 62 | case id = "im:id" 63 | case bundleId = "im:bundleId" 64 | } 65 | } 66 | } 67 | 68 | public struct Category: Codable { 69 | public let attributes: Attributes 70 | 71 | public struct Attributes: Codable { 72 | public let id: String 73 | public let name: String 74 | 75 | public enum CodingKeys: String, CodingKey { 76 | case id = "im:id" 77 | case name = "label" 78 | } 79 | } 80 | } 81 | 82 | public struct Label: Codable { 83 | public let value: String 84 | 85 | public enum CodingKeys: String, CodingKey { 86 | case value = "label" 87 | } 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Sources/AppStoreScraper/Models/RankingType.swift: -------------------------------------------------------------------------------- 1 | public enum RankingType: String, CaseIterable { 2 | case topFree, topFreeIpad, topFreeMac 3 | case topPaid, topPaidIpad, topPaidMac 4 | case topGrossing, topGrossingIpad 5 | case new, newFree, newPaid 6 | 7 | public var name: String { 8 | switch self { 9 | case .topFree: return "Top Free Apps" 10 | case .topFreeIpad: return "Top Free iPad Apps" 11 | case .topFreeMac: return "Top Free Mac Apps" 12 | case .topPaid: return "Top Paid Apps" 13 | case .topPaidIpad: return "Top Paid iPad Apps" 14 | case .topPaidMac: return "Top Paid Mac Apps" 15 | case .topGrossing: return "Top Grossing Apps" 16 | case .topGrossingIpad: return "Top Grossing iPad Apps" 17 | case .new: return "New Apps" 18 | case .newFree: return "New Free Apps" 19 | case .newPaid: return "New Paid Apps" 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/AppStoreScraper/Scraper.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | #if canImport(FoundationNetworking) 3 | import FoundationNetworking 4 | #endif 5 | 6 | public struct Scraper { 7 | public init() {} 8 | 9 | public func getRanking( 10 | _ rankingType: RankingType, 11 | country: Country = .US, 12 | categoryFilter: CategoryFilter = .allApps, 13 | limit: Int = 10 14 | ) async throws -> Ranking { 15 | let feedTitle = makeFeedTitle(rankingType) 16 | let genre: String = { 17 | switch categoryFilter { 18 | case .allApps: 19 | return "" 20 | case let .category(category): 21 | return "genre=\(category.rawValue)/" 22 | case let .gamesSubcategory(subcategory): 23 | return "genre=\(subcategory.rawValue)/" 24 | } 25 | }() 26 | let url = "\(baseURL)/rss/\(feedTitle)/\(genre)limit=\(limit)/json?cc=\(country.rawValue.lowercased())" 27 | let feed: Feed = try await get(url) 28 | return .init(applications: feed.content.entry?.toArray ?? []) 29 | } 30 | 31 | public func searchApplications( 32 | _ term: String, 33 | country: Country = .US, 34 | language: Language? = nil, 35 | limit: Int = 10 36 | ) async throws -> [Application] { 37 | let encodedTerm = term.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? term 38 | let language = makeLanguage(language, country: country) 39 | let url = "\(baseURL)/search?term=\(encodedTerm)&country=\(country.rawValue.lowercased())&limit=\(limit)&entity=software\(language)" 40 | let result: SearchResult = try await get(url) 41 | return result.applications 42 | } 43 | 44 | public func getApplication( 45 | _ id: Int, 46 | country: Country = .US, 47 | language: Language? = nil 48 | ) async throws -> Application? { 49 | let language = makeLanguage(language, country: country) 50 | let url = "\(baseURL)/\(country.rawValue.lowercased())/lookup?id=\(id)\(language)" 51 | let result: SearchResult = try await get(url) 52 | return result.applications.first 53 | } 54 | 55 | // MARK: - Private 56 | 57 | private let baseURL = "https://itunes.apple.com" 58 | private let session = URLSession.shared 59 | private let decoder = JSONDecoder() 60 | 61 | private func get(_ url: String) async throws -> T { 62 | guard let url = URL(string: url) else { 63 | throw URLError(.badURL) 64 | } 65 | var request = URLRequest(url: url) 66 | request.httpMethod = "GET" 67 | let data = try await session.data(for: request) 68 | return try decoder.decode(T.self, from: data) 69 | } 70 | 71 | private func makeFeedTitle(_ rankingType: RankingType) -> String { 72 | let suffix: String = { 73 | switch rankingType { 74 | case .topFreeMac, .topPaidMac: 75 | return "apps" 76 | default: 77 | return "applications" 78 | } 79 | }() 80 | return [rankingType.rawValue.lowercased(), suffix].joined() 81 | } 82 | 83 | private func makeLanguage(_ language: Language?, country: Country) -> String { 84 | guard let language else { 85 | return "" 86 | } 87 | let supported = country.languages 88 | guard 89 | language != supported.main, 90 | supported.additional.contains(language) 91 | else { 92 | return "" 93 | } 94 | let code: String = { 95 | switch language { 96 | case .zh_Hans: return "zh_CN" 97 | default: return String(language.rawValue.prefix(2)) 98 | } 99 | }() 100 | return "&lang=\(code)" 101 | } 102 | 103 | private struct Feed: Decodable { 104 | let content: Entries 105 | 106 | enum CodingKeys: String, CodingKey { 107 | case content = "feed" 108 | } 109 | 110 | struct Entries: Decodable { 111 | let entry: OneOrMany? 112 | } 113 | } 114 | 115 | private struct SearchResult: Codable { 116 | let applications: [Application] 117 | 118 | enum CodingKeys: String, CodingKey { 119 | case applications = "results" 120 | } 121 | } 122 | } 123 | 124 | private enum OneOrMany: Decodable { 125 | case one(T) 126 | case many([T]) 127 | 128 | var toArray: [T] { 129 | switch self { 130 | case let .one(item): return [item] 131 | case let .many(items): return items 132 | } 133 | } 134 | 135 | init(from decoder: Decoder) throws { 136 | let container = try decoder.singleValueContainer() 137 | if let many = try? container.decode([T].self) { 138 | self = .many(many) 139 | } else if let one = try? container.decode(T.self) { 140 | self = .one(one) 141 | } else { 142 | throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode \(OneOrMany.self)") 143 | } 144 | } 145 | } 146 | 147 | private extension URLSession { 148 | func data(for request: URLRequest) async throws -> Data { 149 | var dataTask: URLSessionDataTask? 150 | let onCancel = { 151 | dataTask?.cancel() 152 | } 153 | return try await withTaskCancellationHandler { 154 | try await withCheckedThrowingContinuation { continuation in 155 | dataTask = self.dataTask(with: request) { data, _, error in 156 | guard let data else { 157 | let error = error ?? URLError(.badServerResponse) 158 | return continuation.resume(throwing: error) 159 | } 160 | continuation.resume(returning: data) 161 | } 162 | dataTask?.resume() 163 | } 164 | } onCancel: { 165 | onCancel() 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /Tests/AppStoreScraperTests/CountryTests.swift: -------------------------------------------------------------------------------- 1 | import AppStoreScraper 2 | import XCTest 3 | 4 | final class CountryTests: XCTestCase { 5 | func testCountryName() { 6 | let country = Country.EE 7 | XCTAssertEqual(country.name, "Estonia") 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Tests/AppStoreScraperTests/ScraperTests.swift: -------------------------------------------------------------------------------- 1 | import AppStoreScraper 2 | import XCTest 3 | 4 | final class ScraperTests: XCTestCase { 5 | func testGetTopFreeApps() async throws { 6 | let scraper = Scraper() 7 | let ranking = try await scraper.getRanking(.topFree, country: .EE, limit: 5) 8 | XCTAssertFalse(ranking.applications.isEmpty) 9 | } 10 | 11 | func testSearchApplications() async throws { 12 | let scraper = Scraper() 13 | let applications = try await scraper.searchApplications("pros and cons", language: .zh_Hans) 14 | XCTAssertFalse(applications.isEmpty) 15 | } 16 | 17 | func testGetApplication() async throws { 18 | let scraper = Scraper() 19 | let application = try await scraper.getApplication(668357845) 20 | XCTAssertNotNil(application) 21 | } 22 | } 23 | --------------------------------------------------------------------------------