From b733096b58d184cbc6453199fa12cc56e236a19c Mon Sep 17 00:00:00 2001 From: Benjamin Altpeter Date: Tue, 11 Jun 2024 18:38:21 +0200 Subject: [PATCH] Fixes #12: Searching for apps (#13) * Create endpoints folder to keep things organized * Move common types and consts to separate file * Fixes #12: Searching for apps --- README.md | 28 +++- docs/README.md | 151 +++++++++++++++++---- src/{app-details.ts => common/app-meta.ts} | 147 +------------------- src/endpoints/app-details.ts | 142 +++++++++++++++++++ src/endpoints/search.ts | 121 +++++++++++++++++ src/{ => endpoints}/top-charts.ts | 2 +- src/index.ts | 6 +- 7 files changed, 428 insertions(+), 169 deletions(-) rename src/{app-details.ts => common/app-meta.ts} (67%) create mode 100644 src/endpoints/app-details.ts create mode 100644 src/endpoints/search.ts rename src/{ => endpoints}/top-charts.ts (97%) diff --git a/README.md b/README.md index 0768c20..4079b87 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,13 @@ > Library for fetching select data on iOS apps from the Apple Apple Store via undocumented internal iTunes APIs. -This library is able to fetch and parse data from undocumented internal API endpoints of the Apple App Store. Currently, it can fetch the charts of the most popular apps, according to various criteria, and details (including privacy labels) for individual apps. We'll extend the supported API endpoints in the future. The focus will mostly be on functions useful for research into mobile privacy and data protection. +This library is able to fetch and parse data from undocumented internal API endpoints of the Apple App Store. Currently, it has the following features: + +* Fetch the **charts of the most popular apps**, including filtering by genre and chart. +* **Fetch details** (including **privacy labels**) for individual apps. +* **Search** for apps. + +We'll extend the supported API endpoints in the future. The focus will mostly be on functions useful for research into mobile privacy and data protection. As all the used endpoints are undocumented, we had to resort to reverse-engineering them. It is possible that we have misinterpreted the meaning of parameters or endpoints. It is also entirely possible that some or all of the endpoints will stop working out of the blue at some point, or change their request and/or response formats. @@ -196,6 +202,26 @@ The response looks like this: +### Search for apps + +The following example searches the German App Store for apps with the term "education" and lists their names: + +```ts +import { fetchAppDetails } from 'parse-tunes'; + +(async () => { + const apps = await searchApps({ searchTerm: 'education', country: 'DE', language: 'en-GB' }); + + for (const app of apps) console.log(app.name); + // Microsoft OneNote + // Goodnotes 6 + // StudyCards - Karteikarten + // … +})(); +``` + +The metadata in the search results has the same format as for the app details, but here, you cannot choose which fields you want to fetch. + ## License This code is licensed under the MIT license, see the [`LICENSE`](LICENSE) file for details. diff --git a/docs/README.md b/docs/README.md index 0916163..195d11f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,9 @@ parse-tunes - [AppDetailsRequest](README.md#appdetailsrequest) - [AppDetailsResponse](README.md#appdetailsresponse) - [AppDetailsResponseFragmentPerAttribute](README.md#appdetailsresponsefragmentperattribute) +- [AppSearchRequest](README.md#appsearchrequest) +- [AppSearchResponse](README.md#appsearchresponse) +- [AppSearchReturnedAttribute](README.md#appsearchreturnedattribute) - [Chart](README.md#chart) - [Genre](README.md#genre) - [GenreName](README.md#genrename) @@ -47,6 +50,7 @@ parse-tunes - [fetchAppDetails](README.md#fetchappdetails) - [fetchMediaApiToken](README.md#fetchmediaapitoken) - [fetchTopApps](README.md#fetchtopapps) +- [searchApps](README.md#searchapps) ## Type Aliases @@ -88,7 +92,7 @@ An artwork image in a response fragment. #### Defined in -[app-details.ts:169](https://github.com/tweaselORG/parse-tunes/blob/main/src/app-details.ts#L169) +[common/app-meta.ts:123](https://github.com/tweaselORG/parse-tunes/blob/main/src/common/app-meta.ts#L123) ___ @@ -100,7 +104,7 @@ An attribute (field) that can be requested from the app details endpoint. #### Defined in -[app-details.ts:131](https://github.com/tweaselORG/parse-tunes/blob/main/src/app-details.ts#L131) +[common/app-meta.ts:84](https://github.com/tweaselORG/parse-tunes/blob/main/src/common/app-meta.ts#L84) ___ @@ -108,12 +112,12 @@ ___ Ƭ **AppDetailsPlatformInRequest**: ``"web"`` \| ``"iphone"`` \| ``"appletv"`` \| ``"ipad"`` \| ``"mac"`` \| ``"watch"`` -A platform that can appear in the `platform` or `additionalPlatforms` parameter of a request to the app details -endpoint. +A platform that can appear in the `platform` or `additionalPlatforms` parameter of a request to the app details or +search endpoint. #### Defined in -[app-details.ts:139](https://github.com/tweaselORG/parse-tunes/blob/main/src/app-details.ts#L139) +[common/app-meta.ts:92](https://github.com/tweaselORG/parse-tunes/blob/main/src/common/app-meta.ts#L92) ___ @@ -121,7 +125,8 @@ ___ Ƭ **AppDetailsPlatformInResponse**: [`AppDetailsPlatformInResponseForRequest`](README.md#appdetailsplatforminresponseforrequest)[keyof [`AppDetailsPlatformInResponseForRequest`](README.md#appdetailsplatforminresponseforrequest)] -A platform that can appear in the response from the app details endpoint as a key of the `platformAttributes` object. +A platform that can appear in the response from the app details or search endpoint as a key of the +`platformAttributes` object. **`See`** @@ -129,7 +134,7 @@ A platform that can appear in the response from the app details endpoint as a ke #### Defined in -[app-details.ts:159](https://github.com/tweaselORG/parse-tunes/blob/main/src/app-details.ts#L159) +[common/app-meta.ts:113](https://github.com/tweaselORG/parse-tunes/blob/main/src/common/app-meta.ts#L113) ___ @@ -137,8 +142,8 @@ ___ Ƭ **AppDetailsPlatformInResponseForRequest**: `Object` -A type mapping from the platforms that can appear in a request to the app details endpoint to the key of the -`platformAttributes` object in the response that they cause to be included. +A type mapping from the platforms that can appear in a request to the app details or search endpoint to the key of +the `platformAttributes` object in the response that they cause to be included. **`See`** @@ -157,7 +162,7 @@ A type mapping from the platforms that can appear in a request to the app detail #### Defined in -[app-details.ts:146](https://github.com/tweaselORG/parse-tunes/blob/main/src/app-details.ts#L146) +[common/app-meta.ts:99](https://github.com/tweaselORG/parse-tunes/blob/main/src/common/app-meta.ts#L99) ___ @@ -188,7 +193,7 @@ Parameters for an app details request. #### Defined in -[app-details.ts:356](https://github.com/tweaselORG/parse-tunes/blob/main/src/app-details.ts#L356) +[endpoints/app-details.ts:58](https://github.com/tweaselORG/parse-tunes/blob/main/src/endpoints/app-details.ts#L58) ___ @@ -210,7 +215,7 @@ tested responses. They may not be 100 % accurate. #### Defined in -[app-details.ts:406](https://github.com/tweaselORG/parse-tunes/blob/main/src/app-details.ts#L406) +[endpoints/app-details.ts:108](https://github.com/tweaselORG/parse-tunes/blob/main/src/endpoints/app-details.ts#L108) ___ @@ -334,7 +339,70 @@ Type mapping from the possible attributes to the additional data they add in the #### Defined in -[app-details.ts:213](https://github.com/tweaselORG/parse-tunes/blob/main/src/app-details.ts#L213) +[common/app-meta.ts:167](https://github.com/tweaselORG/parse-tunes/blob/main/src/common/app-meta.ts#L167) + +___ + +### AppSearchRequest + +Ƭ **AppSearchRequest**<`Country`, `Platforms`\>: `Object` + +Parameters for an app search request. + +#### Type parameters + +| Name | Type | +| :------ | :------ | +| `Country` | extends [`MediaApiCountry`](README.md#mediaapicountry) | +| `Platforms` | extends [`AppDetailsPlatformInRequest`](README.md#appdetailsplatforminrequest)[] | + +#### Type declaration + +| Name | Type | Description | +| :------ | :------ | :------ | +| `country` | `Country` | Which country's App Store to use. | +| `language` | [`AllowedLanguagesPerCountryInMediaApi`](README.md#allowedlanguagespercountryinmediaapi)[`Country`] | The language in which to fetch the app details. | +| `platforms?` | `Platforms` | The platform(s) for which to fetch details about the found apps. Will fetch details for all platforms if this parameter isn't specified. | +| `searchTerm` | `string` | The term to search for. | + +#### Defined in + +[endpoints/search.ts:12](https://github.com/tweaselORG/parse-tunes/blob/main/src/endpoints/search.ts#L12) + +___ + +### AppSearchResponse + +Ƭ **AppSearchResponse**<`Platforms`\>: `UnionToIntersection`<[`AppDetailsResponseFragmentPerAttribute`](README.md#appdetailsresponsefragmentperattribute)<`Platforms`\>[[`AppSearchReturnedAttribute`](README.md#appsearchreturnedattribute)]\>[] + +The response from the app search API. + +Note: There is no publicly available documentation for the API responses. The types were extrapolated from a few +tested responses. They may not be 100 % accurate. + +#### Type parameters + +| Name | Type | +| :------ | :------ | +| `Platforms` | extends [`AppDetailsPlatformInResponse`](README.md#appdetailsplatforminresponse) | + +#### Defined in + +[endpoints/search.ts:99](https://github.com/tweaselORG/parse-tunes/blob/main/src/endpoints/search.ts#L99) + +___ + +### AppSearchReturnedAttribute + +Ƭ **AppSearchReturnedAttribute**: ``"supportsArcade"`` \| ``"familyShareEnabledDate"`` \| ``"isFirstPartyHideableApp"`` \| ``"contentRatingsBySystem"`` \| ``"deviceFamilies"`` \| ``"chartPositions"`` \| ``"url"`` \| ``"usesLocationBackgroundMode"`` \| ``"userRating"`` \| ``"name"`` \| ``"genreDisplayName"`` \| ``"isPreorder"`` \| ``"isIOSBinaryMacOSCompatible"`` \| ``"artistName"`` \| ``"reviewsRestricted"`` \| ``"sellerLabel"`` \| ``"hasEula"`` \| ``"seller"`` \| ``"copyright"`` \| ``"minimumMacOSVersion"`` \| ``"isStandaloneWithCompanionForWatchOS"`` \| ``"isAppleWatchSupported"`` \| ``"is32bitOnly"`` \| ``"hasSafariExtension"`` \| ``"languageList"`` \| ``"requiresGameController"`` \| ``"requiredCapabilities"`` \| ``"offers"`` \| ``"supportedLocales"`` \| ``"requires32bit"`` \| ``"isSiriSupported"`` \| ``"isGameCenterEnabled"`` \| ``"releaseDate"`` \| ``"minimumOSVersion"`` \| ``"hasInAppPurchases"`` \| ``"bundleId"`` \| ``"hasMessagesExtension"`` \| ``"supportsGameController"`` \| ``"artwork"`` \| ``"hasFamilyShareableInAppPurchases"`` \| ``"isStandaloneForWatchOS"`` \| ``"isHiddenFromSpringboard"`` \| ``"isDeliveredInIOSAppForWatchOS"`` \| ``"hasPrivacyPolicyText"`` \| ``"editorialArtwork"`` \| ``"supportsPassbook"`` \| ``"requirementsString"`` \| ``"externalVersionId"`` + +The attributes that are returned in the app search response. + +These are currently not configurable. + +#### Defined in + +[endpoints/search.ts:43](https://github.com/tweaselORG/parse-tunes/blob/main/src/endpoints/search.ts#L43) ___ @@ -346,7 +414,7 @@ The `popId` of a chart on the App Store. #### Defined in -[top-charts.ts:18](https://github.com/tweaselORG/parse-tunes/blob/main/src/top-charts.ts#L18) +[endpoints/top-charts.ts:18](https://github.com/tweaselORG/parse-tunes/blob/main/src/endpoints/top-charts.ts#L18) ___ @@ -407,7 +475,7 @@ Small helper for response fragments that are listed under `platformAttributes`. #### Defined in -[app-details.ts:163](https://github.com/tweaselORG/parse-tunes/blob/main/src/app-details.ts#L163) +[common/app-meta.ts:117](https://github.com/tweaselORG/parse-tunes/blob/main/src/common/app-meta.ts#L117) ___ @@ -420,7 +488,7 @@ attribute. #### Defined in -[app-details.ts:193](https://github.com/tweaselORG/parse-tunes/blob/main/src/app-details.ts#L193) +[common/app-meta.ts:147](https://github.com/tweaselORG/parse-tunes/blob/main/src/common/app-meta.ts#L147) ___ @@ -432,7 +500,7 @@ A list of privacy types as declared in a privacy label, in short format as retur #### Defined in -[app-details.ts:180](https://github.com/tweaselORG/parse-tunes/blob/main/src/app-details.ts#L180) +[common/app-meta.ts:134](https://github.com/tweaselORG/parse-tunes/blob/main/src/common/app-meta.ts#L134) ___ @@ -530,7 +598,7 @@ Parameters for a top chart request. #### Defined in -[top-charts.ts:21](https://github.com/tweaselORG/parse-tunes/blob/main/src/top-charts.ts#L21) +[endpoints/top-charts.ts:21](https://github.com/tweaselORG/parse-tunes/blob/main/src/endpoints/top-charts.ts#L21) ___ @@ -542,7 +610,7 @@ A list of the app IDs of the apps on the requested top chart. #### Defined in -[top-charts.ts:31](https://github.com/tweaselORG/parse-tunes/blob/main/src/top-charts.ts#L31) +[endpoints/top-charts.ts:31](https://github.com/tweaselORG/parse-tunes/blob/main/src/endpoints/top-charts.ts#L31) ## Variables @@ -763,7 +831,7 @@ Compiled through trial and error and from looking at requests made by the App St #### Defined in -[app-details.ts:61](https://github.com/tweaselORG/parse-tunes/blob/main/src/app-details.ts#L61) +[common/app-meta.ts:14](https://github.com/tweaselORG/parse-tunes/blob/main/src/common/app-meta.ts#L14) ___ @@ -780,7 +848,7 @@ These are in the order of their response size. We'll try the smallest one first. #### Defined in -[app-details.ts:12](https://github.com/tweaselORG/parse-tunes/blob/main/src/app-details.ts#L12) +[endpoints/app-details.ts:19](https://github.com/tweaselORG/parse-tunes/blob/main/src/endpoints/app-details.ts#L19) ___ @@ -807,7 +875,7 @@ https://github.com/tweaselORG/parse-tunes/issues/2#issuecomment-1377239436 #### Defined in -[top-charts.ts:9](https://github.com/tweaselORG/parse-tunes/blob/main/src/top-charts.ts#L9) +[endpoints/top-charts.ts:9](https://github.com/tweaselORG/parse-tunes/blob/main/src/endpoints/top-charts.ts#L9) ___ @@ -1167,7 +1235,7 @@ The app details, typed according to the attributes you specified. #### Defined in -[app-details.ts:420](https://github.com/tweaselORG/parse-tunes/blob/main/src/app-details.ts#L420) +[endpoints/app-details.ts:122](https://github.com/tweaselORG/parse-tunes/blob/main/src/endpoints/app-details.ts#L122) ___ @@ -1193,7 +1261,7 @@ The token. #### Defined in -[app-details.ts:29](https://github.com/tweaselORG/parse-tunes/blob/main/src/app-details.ts#L29) +[endpoints/app-details.ts:36](https://github.com/tweaselORG/parse-tunes/blob/main/src/endpoints/app-details.ts#L36) ___ @@ -1218,4 +1286,37 @@ A list of numerical app IDs in the requested top chart. The list is sorted by ra #### Defined in -[top-charts.ts:44](https://github.com/tweaselORG/parse-tunes/blob/main/src/top-charts.ts#L44) +[endpoints/top-charts.ts:44](https://github.com/tweaselORG/parse-tunes/blob/main/src/endpoints/top-charts.ts#L44) + +___ + +### searchApps + +▸ **searchApps**<`Country`, `Platforms`\>(`request`): `Promise`<[`AppSearchResponse`](README.md#appsearchresponse)<[`AppDetailsPlatformInResponseForRequest`](README.md#appdetailsplatforminresponseforrequest)[`Platforms`[`number`]]\>\> + +Search for apps on the the App Store. You can request a lot of different information about the app. The `attributes` +parameter specifies which attributes to fetch. See [appDetailsAvailableAttributes](README.md#appdetailsavailableattributes) for a list of all available +attributes. + +#### Type parameters + +| Name | Type | +| :------ | :------ | +| `Country` | extends ``"DZ"`` \| ``"AO"`` \| ``"AI"`` \| ``"AG"`` \| ``"AR"`` \| ``"AM"`` \| ``"AU"`` \| ``"AT"`` \| ``"AZ"`` \| ``"BH"`` \| ``"BB"`` \| ``"BY"`` \| ``"BE"`` \| ``"BZ"`` \| ``"BM"`` \| ``"BO"`` \| ``"BW"`` \| ``"BR"`` \| ``"VG"`` \| ``"BN"`` \| ``"BG"`` \| ``"CA"`` \| ``"KY"`` \| ``"CL"`` \| ``"CN"`` \| ``"CO"`` \| ``"CR"`` \| ``"CI"`` \| ``"HR"`` \| ``"CY"`` \| ``"CZ"`` \| ``"DK"`` \| ``"DM"`` \| ``"DO"`` \| ``"EC"`` \| ``"EG"`` \| ``"SV"`` \| ``"EE"`` \| ``"FI"`` \| ``"FR"`` \| ``"DE"`` \| ``"GH"`` \| ``"GR"`` \| ``"GD"`` \| ``"GT"`` \| ``"GY"`` \| ``"HN"`` \| ``"HK"`` \| ``"HU"`` \| ``"IS"`` \| ``"IN"`` \| ``"ID"`` \| ``"IE"`` \| ``"IL"`` \| ``"IT"`` \| ``"JM"`` \| ``"JP"`` \| ``"JO"`` \| ``"KZ"`` \| ``"KE"`` \| ``"KR"`` \| ``"KW"`` \| ``"LV"`` \| ``"LB"`` \| ``"LT"`` \| ``"LU"`` \| ``"MO"`` \| ``"MK"`` \| ``"MG"`` \| ``"MY"`` \| ``"MV"`` \| ``"ML"`` \| ``"MT"`` \| ``"MU"`` \| ``"MX"`` \| ``"MD"`` \| ``"MS"`` \| ``"NP"`` \| ``"NL"`` \| ``"NZ"`` \| ``"NI"`` \| ``"NE"`` \| ``"NG"`` \| ``"NO"`` \| ``"OM"`` \| ``"PK"`` \| ``"PA"`` \| ``"PY"`` \| ``"PE"`` \| ``"PH"`` \| ``"PL"`` \| ``"PT"`` \| ``"QA"`` \| ``"RO"`` \| ``"RU"`` \| ``"SA"`` \| ``"SN"`` \| ``"RS"`` \| ``"SG"`` \| ``"SK"`` \| ``"SI"`` \| ``"ZA"`` \| ``"ES"`` \| ``"LK"`` \| ``"KN"`` \| ``"LC"`` \| ``"VC"`` \| ``"SR"`` \| ``"SE"`` \| ``"CH"`` \| ``"TW"`` \| ``"TZ"`` \| ``"TH"`` \| ``"BS"`` \| ``"TT"`` \| ``"TN"`` \| ``"TR"`` \| ``"TC"`` \| ``"UG"`` \| ``"GB"`` \| ``"UA"`` \| ``"AE"`` \| ``"UY"`` \| ``"US"`` \| ``"UZ"`` \| ``"VE"`` \| ``"VN"`` \| ``"YE"`` \| ``"AF"`` \| ``"AL"`` \| ``"BJ"`` \| ``"BT"`` \| ``"BA"`` \| ``"BF"`` \| ``"KH"`` \| ``"CM"`` \| ``"CV"`` \| ``"TD"`` \| ``"CD"`` \| ``"SZ"`` \| ``"FJ"`` \| ``"GA"`` \| ``"GM"`` \| ``"GE"`` \| ``"GW"`` \| ``"IQ"`` \| ``"XK"`` \| ``"KG"`` \| ``"LA"`` \| ``"LR"`` \| ``"LY"`` \| ``"MW"`` \| ``"MR"`` \| ``"FM"`` \| ``"MN"`` \| ``"ME"`` \| ``"MA"`` \| ``"MZ"`` \| ``"MM"`` \| ``"NA"`` \| ``"NR"`` \| ``"PW"`` \| ``"PG"`` \| ``"CG"`` \| ``"RW"`` \| ``"SC"`` \| ``"SL"`` \| ``"SB"`` \| ``"ST"`` \| ``"TJ"`` \| ``"TO"`` \| ``"TM"`` \| ``"VU"`` \| ``"ZM"`` \| ``"ZW"`` | +| `Platforms` | extends [`AppDetailsPlatformInRequest`](README.md#appdetailsplatforminrequest)[] | + +#### Parameters + +| Name | Type | Description | +| :------ | :------ | :------ | +| `request` | [`AppSearchRequest`](README.md#appsearchrequest)<`Country`, `Platforms`\> | The request parameters. | + +#### Returns + +`Promise`<[`AppSearchResponse`](README.md#appsearchresponse)<[`AppDetailsPlatformInResponseForRequest`](README.md#appdetailsplatforminresponseforrequest)[`Platforms`[`number`]]\>\> + +The app details, typed according to the attributes you specified. + +#### Defined in + +[endpoints/search.ts:112](https://github.com/tweaselORG/parse-tunes/blob/main/src/endpoints/search.ts#L112) diff --git a/src/app-details.ts b/src/common/app-meta.ts similarity index 67% rename from src/app-details.ts rename to src/common/app-meta.ts index f563cbc..63d9f88 100644 --- a/src/app-details.ts +++ b/src/common/app-meta.ts @@ -1,51 +1,4 @@ -import fetch from 'cross-fetch'; -import type { UnionToIntersection } from 'type-fest'; -import type { AllowedLanguagesPerCountryInMediaApi, GenreName, MediaApiCountry } from './common/consts'; - -/** - * List of URLs to pages on the App Store that contain a token for Apple's media API (amp-api.apps.apple.com) in their - * response. - * - * @remarks - * These are in the order of their response size. We'll try the smallest one first. - */ -export const appDetailsTokenUrls = [ - 'https://apps.apple.com/404', - 'https://apps.apple.com/story/id1538632801', - 'https://apps.apple.com/us/app/facebook/id284882215', -] as const; - -/** - * Fetch a token for Apple's media API (amp-api.apps.apple.com), to be used with the {@link fetchAppDetails} function. - * The token can be used many times (until it expires). - * - * @remarks - * The token is extracted from the HTML of an App Store page (see: https://github.com/tweaselORG/parse-tunes/issues/6). - * - * The token appears to be the same for everyone, and changes from time to time (around every four months). It is a JWT, - * which you can parse to get the expiration date. - * @returns The token. - */ -export async function fetchMediaApiToken(): Promise { - let lastError: Error | undefined; - - for (const url of appDetailsTokenUrls) { - try { - const html = await fetch(url).then((r) => r.text()); - // I know, I know. Thou shalt not parse HTML using regex. But the page seems to be pretty stable, and it - // doesn't seem worth it to pull in an HTML parser just for this. *fingerscrossed* - const metaContent = html.match(//); - if (!metaContent?.[1]) continue; - - const config = JSON.parse(decodeURIComponent(metaContent[1])); - if (config.MEDIA_API.token) return config.MEDIA_API.token; - } catch (e) { - if (e instanceof Error) lastError = e; - } - } - - throw new Error('Failed to fetch token for media API.', { cause: lastError }); -} +import type { GenreName } from './consts'; /** * The attributes (fields) that can be requested from the app details endpoint. @@ -133,13 +86,13 @@ export type AppDetailsAvailableAttribute = (typeof appDetailsAvailableAttributes // Annoyingly, the API uses different platform names in the request and in the response, see // https://github.com/tweaselORG/parse-tunes/issues/6#issuecomment-1400240548. /** - * A platform that can appear in the `platform` or `additionalPlatforms` parameter of a request to the app details - * endpoint. + * A platform that can appear in the `platform` or `additionalPlatforms` parameter of a request to the app details or + * search endpoint. */ export type AppDetailsPlatformInRequest = 'web' | 'iphone' | 'appletv' | 'ipad' | 'mac' | 'watch'; /** - * A type mapping from the platforms that can appear in a request to the app details endpoint to the key of the - * `platformAttributes` object in the response that they cause to be included. + * A type mapping from the platforms that can appear in a request to the app details or search endpoint to the key of + * the `platformAttributes` object in the response that they cause to be included. * * @see {@link https://github.com/tweaselORG/parse-tunes/issues/6#issuecomment-1400240548} */ @@ -152,7 +105,8 @@ export type AppDetailsPlatformInResponseForRequest = { web: undefined; }; /** - * A platform that can appear in the response from the app details endpoint as a key of the `platformAttributes` object. + * A platform that can appear in the response from the app details or search endpoint as a key of the + * `platformAttributes` object. * * @see {@link https://github.com/tweaselORG/parse-tunes/issues/6#issuecomment-1400240548} */ @@ -351,90 +305,3 @@ export type AppDetailsResponseFragmentPerAttribute; websiteUrl: PlatformAttributes; }; - -/** Parameters for an app details request. */ -export type AppDetailsRequest< - Country extends MediaApiCountry, - Platforms extends AppDetailsPlatformInRequest[], - Attributes extends AppDetailsAvailableAttribute[] -> = { - /** The numerical ID of the app for which to fetch the details. */ - appId: number; - /** - * The platform(s) for which to fetch details about the requested app. Will fetch details for all platforms if this - * parameter isn't specified. - */ - platforms?: Platforms; - - /** The attributes to fetch. See {@link appDetailsAvailableAttributes} for a list. */ - attributes: Attributes; - - /** Which country's App Store to use. */ - country: Country; - /** The language in which to fetch the details. */ - language: AllowedLanguagesPerCountryInMediaApi[Country]; - - /** - * The token to use for authentication. - * - * If you don't provide one, it will be fetched automatically. However, if you want to fetch the details for - * multiple apps, it's recommended to fetch the token once and then pass it to all the requests instead of - * re-fetching the token for each request. You can use {@link fetchMediaApiToken} to fetch a token beforehand. - */ - token?: string; -}; - -export const appDetailsApiUrl = < - Country extends MediaApiCountry, - Platforms extends AppDetailsPlatformInRequest[], - Attributes extends AppDetailsAvailableAttribute[] ->( - request: AppDetailsRequest -) => - `https://amp-api.apps.apple.com/v1/catalog/${request.country}/apps/${request.appId}?platform=${ - request.platforms?.[0] || 'web' - }&additionalPlatforms=${ - request.platforms ? request.platforms?.slice(1).join(',') : 'iphone,appletv,ipad,mac,watch' - }&l=${request.language}&fields=${request.attributes.join(',')}`; - -/** - * The response from the app details API, typed according to the attributes specified in the request. - * - * Note: There is no publicly available documentation for the API responses. The types were extrapolated from a few - * tested responses. They may not be 100 % accurate. - */ -export type AppDetailsResponse< - Platforms extends AppDetailsPlatformInResponse, - Attributes extends AppDetailsAvailableAttribute -> = UnionToIntersection[Attributes]>; - -/** - * Fetch the details for an app from the App Store. You can request a lot of different information about the app. The - * `attributes` parameter specifies which attributes to fetch. See {@link appDetailsAvailableAttributes} for a list of - * all available attributes. - * - * @param request The request parameters. - * - * @returns The app details, typed according to the attributes you specified. - */ -export async function fetchAppDetails< - Country extends MediaApiCountry, - Platforms extends AppDetailsPlatformInRequest[], - Attributes extends AppDetailsAvailableAttribute[] ->( - request: AppDetailsRequest -): Promise> { - const token = request.token || (await fetchMediaApiToken()); - - const res = await fetch(appDetailsApiUrl(request), { - method: 'GET', - headers: { - Authorization: `Bearer ${token}`, - Origin: 'https://apps.apple.com', - }, - }) - .then((response) => response.text()) - .then((r) => JSON.parse(r.trim())); - - return res.data[0].attributes; -} diff --git a/src/endpoints/app-details.ts b/src/endpoints/app-details.ts new file mode 100644 index 0000000..bf42223 --- /dev/null +++ b/src/endpoints/app-details.ts @@ -0,0 +1,142 @@ +import fetch from 'cross-fetch'; +import type { UnionToIntersection } from 'type-fest'; +import type { + AppDetailsAvailableAttribute, + AppDetailsPlatformInRequest, + AppDetailsPlatformInResponse, + AppDetailsPlatformInResponseForRequest, + AppDetailsResponseFragmentPerAttribute, +} from '../common/app-meta'; +import type { AllowedLanguagesPerCountryInMediaApi, MediaApiCountry } from '../common/consts'; + +/** + * List of URLs to pages on the App Store that contain a token for Apple's media API (amp-api.apps.apple.com) in their + * response. + * + * @remarks + * These are in the order of their response size. We'll try the smallest one first. + */ +export const appDetailsTokenUrls = [ + 'https://apps.apple.com/404', + 'https://apps.apple.com/story/id1538632801', + 'https://apps.apple.com/us/app/facebook/id284882215', +] as const; + +/** + * Fetch a token for Apple's media API (amp-api.apps.apple.com), to be used with the {@link fetchAppDetails} function. + * The token can be used many times (until it expires). + * + * @remarks + * The token is extracted from the HTML of an App Store page (see: https://github.com/tweaselORG/parse-tunes/issues/6). + * + * The token appears to be the same for everyone, and changes from time to time (around every four months). It is a JWT, + * which you can parse to get the expiration date. + * @returns The token. + */ +export async function fetchMediaApiToken(): Promise { + let lastError: Error | undefined; + + for (const url of appDetailsTokenUrls) { + try { + const html = await fetch(url).then((r) => r.text()); + // I know, I know. Thou shalt not parse HTML using regex. But the page seems to be pretty stable, and it + // doesn't seem worth it to pull in an HTML parser just for this. *fingerscrossed* + const metaContent = html.match(//); + if (!metaContent?.[1]) continue; + + const config = JSON.parse(decodeURIComponent(metaContent[1])); + if (config.MEDIA_API.token) return config.MEDIA_API.token; + } catch (e) { + if (e instanceof Error) lastError = e; + } + } + + throw new Error('Failed to fetch token for media API.', { cause: lastError }); +} + +/** Parameters for an app details request. */ +export type AppDetailsRequest< + Country extends MediaApiCountry, + Platforms extends AppDetailsPlatformInRequest[], + Attributes extends AppDetailsAvailableAttribute[] +> = { + /** The numerical ID of the app for which to fetch the details. */ + appId: number; + /** + * The platform(s) for which to fetch details about the requested app. Will fetch details for all platforms if this + * parameter isn't specified. + */ + platforms?: Platforms; + + /** The attributes to fetch. See {@link appDetailsAvailableAttributes} for a list. */ + attributes: Attributes; + + /** Which country's App Store to use. */ + country: Country; + /** The language in which to fetch the details. */ + language: AllowedLanguagesPerCountryInMediaApi[Country]; + + /** + * The token to use for authentication. + * + * If you don't provide one, it will be fetched automatically. However, if you want to fetch the details for + * multiple apps, it's recommended to fetch the token once and then pass it to all the requests instead of + * re-fetching the token for each request. You can use {@link fetchMediaApiToken} to fetch a token beforehand. + */ + token?: string; +}; + +export const appDetailsApiUrl = < + Country extends MediaApiCountry, + Platforms extends AppDetailsPlatformInRequest[], + Attributes extends AppDetailsAvailableAttribute[] +>( + request: AppDetailsRequest +) => + `https://amp-api.apps.apple.com/v1/catalog/${request.country}/apps/${request.appId}?platform=${ + request.platforms?.[0] || 'web' + }&additionalPlatforms=${ + request.platforms ? request.platforms?.slice(1).join(',') : 'iphone,appletv,ipad,mac,watch' + }&l=${request.language}&fields=${request.attributes.join(',')}`; + +/** + * The response from the app details API, typed according to the attributes specified in the request. + * + * Note: There is no publicly available documentation for the API responses. The types were extrapolated from a few + * tested responses. They may not be 100 % accurate. + */ +export type AppDetailsResponse< + Platforms extends AppDetailsPlatformInResponse, + Attributes extends AppDetailsAvailableAttribute +> = UnionToIntersection[Attributes]>; + +/** + * Fetch the details for an app from the App Store. You can request a lot of different information about the app. The + * `attributes` parameter specifies which attributes to fetch. See {@link appDetailsAvailableAttributes} for a list of + * all available attributes. + * + * @param request The request parameters. + * + * @returns The app details, typed according to the attributes you specified. + */ +export async function fetchAppDetails< + Country extends MediaApiCountry, + Platforms extends AppDetailsPlatformInRequest[], + Attributes extends AppDetailsAvailableAttribute[] +>( + request: AppDetailsRequest +): Promise> { + const token = request.token || (await fetchMediaApiToken()); + + const res = await fetch(appDetailsApiUrl(request), { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + Origin: 'https://apps.apple.com', + }, + }) + .then((response) => response.text()) + .then((r) => JSON.parse(r.trim())); + + return res.data[0].attributes; +} diff --git a/src/endpoints/search.ts b/src/endpoints/search.ts new file mode 100644 index 0000000..c622a53 --- /dev/null +++ b/src/endpoints/search.ts @@ -0,0 +1,121 @@ +import fetch from 'cross-fetch'; +import type { UnionToIntersection } from 'type-fest'; +import type { + AppDetailsPlatformInRequest, + AppDetailsPlatformInResponse, + AppDetailsPlatformInResponseForRequest, + AppDetailsResponseFragmentPerAttribute, +} from '../common/app-meta'; +import type { AllowedLanguagesPerCountryInMediaApi, MediaApiCountry } from '../common/consts'; + +/** Parameters for an app search request. */ +export type AppSearchRequest = { + /** The term to search for. */ + searchTerm: string; + + /** + * The platform(s) for which to fetch details about the found apps. Will fetch details for all platforms if this + * parameter isn't specified. + */ + platforms?: Platforms; + + /** Which country's App Store to use. */ + country: Country; + /** The language in which to fetch the app details. */ + language: AllowedLanguagesPerCountryInMediaApi[Country]; +}; + +export const appSearchApiUrl = ( + request: AppSearchRequest +) => + // The `limit` parameter is hard-coded because other values result in 403 errors, see: https://github.com/tweaselORG/parse-tunes/issues/12#issuecomment-2122503518 + `https://tools.applemediaservices.com/api/apple-media/apps/${request.country}/search.json?types=apps&term=${ + request.searchTerm + }&l=${request.language}&limit=25&platform=${request.platforms?.[0] || 'web'}&additionalPlatforms=${ + request.platforms ? request.platforms?.slice(1).join(',') : 'iphone,appletv,ipad,mac,watch' + }`; + +/** + * The attributes that are returned in the app search response. + * + * These are currently not configurable. + */ +export type AppSearchReturnedAttribute = + | 'supportsArcade' + | 'familyShareEnabledDate' + | 'isFirstPartyHideableApp' + | 'contentRatingsBySystem' + | 'deviceFamilies' + | 'chartPositions' + | 'url' + | 'usesLocationBackgroundMode' + | 'userRating' + | 'name' + | 'genreDisplayName' + | 'isPreorder' + | 'isIOSBinaryMacOSCompatible' + | 'artistName' + | 'reviewsRestricted' + | 'sellerLabel' + | 'hasEula' + | 'seller' + | 'copyright' + | 'minimumMacOSVersion' + | 'isStandaloneWithCompanionForWatchOS' + | 'isAppleWatchSupported' + | 'is32bitOnly' + | 'hasSafariExtension' + | 'languageList' + | 'requiresGameController' + | 'requiredCapabilities' + | 'offers' + | 'supportedLocales' + | 'requires32bit' + | 'isSiriSupported' + | 'isGameCenterEnabled' + | 'releaseDate' + | 'minimumOSVersion' + | 'hasInAppPurchases' + | 'bundleId' + | 'hasMessagesExtension' + | 'supportsGameController' + | 'artwork' + | 'hasFamilyShareableInAppPurchases' + | 'isStandaloneForWatchOS' + | 'isHiddenFromSpringboard' + | 'isDeliveredInIOSAppForWatchOS' + | 'hasPrivacyPolicyText' + | 'editorialArtwork' + | 'supportsPassbook' + | 'requirementsString' + | 'externalVersionId'; + +/** + * The response from the app search API. + * + * Note: There is no publicly available documentation for the API responses. The types were extrapolated from a few + * tested responses. They may not be 100 % accurate. + */ +export type AppSearchResponse = UnionToIntersection< + AppDetailsResponseFragmentPerAttribute[AppSearchReturnedAttribute] +>[]; + +/** + * Search for apps on the the App Store. You can request a lot of different information about the app. The `attributes` + * parameter specifies which attributes to fetch. See {@link appDetailsAvailableAttributes} for a list of all available + * attributes. + * + * @param request The request parameters. + * + * @returns The app details, typed according to the attributes you specified. + */ +export async function searchApps( + request: AppSearchRequest +): Promise> { + const res = await fetch(appSearchApiUrl(request), { method: 'GET' }) + .then((response) => response.text()) + .then((r) => JSON.parse(r.trim())); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return res.apps.data.map((a: any) => a.attributes); +} diff --git a/src/top-charts.ts b/src/endpoints/top-charts.ts similarity index 97% rename from src/top-charts.ts rename to src/endpoints/top-charts.ts index d5661f9..4be0cdb 100644 --- a/src/top-charts.ts +++ b/src/endpoints/top-charts.ts @@ -1,5 +1,5 @@ import fetch from 'cross-fetch'; -import type { Genre, StorefrontCountry } from './common/consts'; +import type { Genre, StorefrontCountry } from '../common/consts'; /** * The App Store top charts that can be fetched. diff --git a/src/index.ts b/src/index.ts index 241a9fa..b10f4bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,5 @@ -export * from './app-details'; +export * from './common/app-meta'; export * from './common/consts'; -export * from './top-charts'; +export * from './endpoints/app-details'; +export * from './endpoints/search'; +export * from './endpoints/top-charts';