diff --git a/lib/routes/apple/apps.ts b/lib/routes/apple/apps.ts index 18117b482..fdcba82d6 100644 --- a/lib/routes/apple/apps.ts +++ b/lib/routes/apple/apps.ts @@ -1,7 +1,7 @@ import { Route, ViewType } from '@/types'; -import got from '@/utils/got'; -import { load } from 'cheerio'; +import ofetch from '@/utils/ofetch'; import { parseDate } from '@/utils/parse-date'; +import { appstoreBearerToken } from './utils'; const platformIds = { osx: 'macOS', @@ -64,7 +64,7 @@ export const route: Route = { handler, description: ` ::: tip - For example, the URL of [GarageBand](https://apps.apple.com/us/app/messages/id408709785) in the App Store is \`https://apps.apple.com/us/app/messages/id408709785\`. In this case, the \`App Store Country\` parameter for the route is \`us\`, and the \`App id\` parameter is \`id1146560473\`. So the route should be [\`/apple/apps/update/us/id408709785\`](https://rsshub.app/apple/apps/update/us/id408709785). + For example, the URL of [GarageBand](https://apps.apple.com/us/app/garageband/id408709785) in the App Store is \`https://apps.apple.com/us/app/garageband/id408709785\`. In this case, the \`App Store Country\` parameter for the route is \`us\`, and the \`App id\` parameter is \`id408709785\`. So the route should be [\`/apple/apps/update/us/id408709785\`](https://rsshub.app/apple/apps/update/us/id408709785). :::`, }; @@ -85,12 +85,30 @@ async function handler(ctx) { const rootUrl = 'https://apps.apple.com'; const currentUrl = new URL(`${country}/app/${id}`, rootUrl).href; - const { data: response } = await got(currentUrl); + const bearer = await appstoreBearerToken(); - const $ = load(response); + const response = await ofetch(`https://amp-api-edge.apps.apple.com/v1/catalog/${country}/apps/${id.replace('id', '')}`, { + headers: { + authorization: `Bearer ${bearer}`, + origin: 'https://apps.apple.com', + }, + query: { + platform: 'iphone', + additionalPlatforms: 'appletv,ipad,iphone,mac,realityDevice,watch', + extend: 'accessibility,accessibilityDetails,ageRating,backgroundAssetsInfo,backgroundAssetsInfoWithOptional,customArtwork,customDeepLink,customIconArtwork,customPromotionalText,customScreenshotsByType,customVideoPreviewsByType,description,expectedReleaseDateDisplayFormat,fileSizeByDevice,gameDisplayName,iconArtwork,installSizeByDeviceInBytes,messagesScreenshots,miniGamesDeepLink,minimumOSVersion,privacy,privacyDetails,privacyPolicyUrl,remoteControllerRequirement,requirementsByDeviceFamily,supportURLForLanguage,supportedGameCenterFeatures,supportsFunCamera,supportsSharePlay,versionHistory,websiteUrl', + 'extend[app-events]': 'description,productArtwork,productVideo', + include: 'alternate-apps,app-bundles,customers-also-bought-apps,developer,developer-other-apps,merchandised-in-apps,related-editorial-items,reviews,top-in-apps', + 'include[apps]': 'app-events', + 'availableIn[app-events]': 'future', + 'sparseLimit[apps:customers-also-bought-apps]': 40, + 'sparseLimit[apps:developer-other-apps]': 40, + 'sparseLimit[apps:related-editorial-items]': 40, + 'limit[reviews]': 8, + l: 'en-US', + }, + }); - const appData = JSON.parse(Object.values(JSON.parse($('script#shoebox-media-api-cache-apps').text()))[0]); - const attributes = appData.d[0].attributes; + const attributes = response.data[0].attributes; const appName = attributes.name; const artistName = attributes.artistName; @@ -99,6 +117,7 @@ async function handler(ctx) { let items = []; let title = ''; let description = ''; + let image = ''; if (platformId && Object.hasOwn(platformAttributes, platformId)) { platform = Object.hasOwn(platformIds, platformId) ? platformIds[platformId] : platformId; @@ -108,6 +127,7 @@ async function handler(ctx) { items = platformAttribute.versionHistory; title = `${appName}${platform ? ` for ${platform} ` : ' '}`; description = platformAttribute.description.standard; + image = platformAttribute.iconArtwork?.url?.replace('{w}x{h}{c}.{f}', '3000x3000bb.webp'); } else { title = appName; for (const pid of Object.keys(platformAttributes)) { @@ -119,7 +139,8 @@ async function handler(ctx) { platformId: pid, })), ]; - description += platformAttribute.description.standard; + description = platformAttribute.description.standard; + image = platformAttribute.iconArtwork?.url?.replace('{w}x{h}{c}.{f}', '3000x3000bb.webp'); } } @@ -137,21 +158,13 @@ async function handler(ctx) { }; }); - const icon = new URL('favicon.ico', rootUrl).href; - - ctx.set('json', { - appData, - }); - return { item: items, title: `${title} - Apple App Store`, link: currentUrl, description: description?.replaceAll('\n', ' '), - language: $('html').prop('lang'), - image: $('meta[property="og:image"]').prop('content'), - icon, - logo: icon, + image, + logo: image, subtitle: appName, author: artistName, allowEmpty: true, diff --git a/lib/routes/apple/utils.ts b/lib/routes/apple/utils.ts new file mode 100644 index 000000000..14f88a746 --- /dev/null +++ b/lib/routes/apple/utils.ts @@ -0,0 +1,25 @@ +import ofetch from '@/utils/ofetch'; +import cache from '@/utils/cache'; +import { load } from 'cheerio'; +import { config } from '@/config'; + +// App Store and Podcast use different bearer tokens +export const appstoreBearerToken = () => + cache.tryGet( + 'apple:podcast:bearer', + async () => { + const baseUrl = 'https://apps.apple.com'; + const response = await ofetch(`${baseUrl}/us/iphone/today`); + const $ = load(response); + + const moduleAddress = new URL($('head script[type="module"]').attr('src'), baseUrl).href; + const modulesResponse = await ofetch(moduleAddress, { + parseResponse: (txt) => txt, + }); + const bearerToken = modulesResponse.match(/="(eyJhbGci.*?)"/)[1]; + + return bearerToken as string; + }, + config.cache.contentExpire, + false + ); diff --git a/lib/routes/appstore/in-app-purchase.ts b/lib/routes/appstore/in-app-purchase.ts index 609aee302..d7d4cd27a 100644 --- a/lib/routes/appstore/in-app-purchase.ts +++ b/lib/routes/appstore/in-app-purchase.ts @@ -1,6 +1,7 @@ import { Route } from '@/types'; import ofetch from '@/utils/ofetch'; import { load } from 'cheerio'; +import { appstoreBearerToken } from '@/routes/apple/utils'; export const route: Route = { path: '/iap/:country/:id', @@ -23,14 +24,6 @@ export const route: Route = { handler, }; -const getMediaApiToken = (metaContent) => { - if (!metaContent) { - throw new Error('Empty web experience config meta content'); - } - const config = JSON.parse(decodeURIComponent(metaContent)); - return config.MEDIA_API.token; -}; - async function handler(ctx) { const country = ctx.req.param('country'); const id = ctx.req.param('id'); @@ -39,18 +32,18 @@ async function handler(ctx) { const res = await ofetch(link); const $ = load(res); const lang = $('html').attr('lang'); - const mediaToken = getMediaApiToken($('meta[name="web-experience-app/config/environment"]').attr('content')); + const mediaToken = await appstoreBearerToken(); const apiResponse = await ofetch(`https://amp-api-edge.apps.apple.com/v1/catalog/${country}/apps/${id.replace('id', '')}`, { + headers: { + authorization: `Bearer ${mediaToken}`, + origin: 'https://apps.apple.com', + }, query: { platform: 'web', include: 'merchandised-in-apps,top-in-apps,eula', l: lang, }, - headers: { - authorization: `Bearer ${mediaToken}`, - origin: 'https://apps.apple.com', - }, }); const appData = apiResponse.data[0];