|
| 1 | +import { Route } from '@/types'; |
| 2 | +import type { Context } from 'hono'; |
| 3 | +import puppeteer from '@/utils/puppeteer'; |
| 4 | +import logger from '@/utils/logger'; |
| 5 | +import { parseDate } from '@/utils/parse-date'; |
| 6 | +import { Browser } from 'rebrowser-puppeteer'; |
| 7 | +import { load } from 'cheerio'; |
| 8 | + |
| 9 | +export const route: Route = { |
| 10 | + name: 'Play Store Update', |
| 11 | + path: '/play/:id/:lang?', |
| 12 | + categories: ['program-update'], |
| 13 | + example: '/google/play/net.dinglisch.android.taskerm', |
| 14 | + parameters: { |
| 15 | + id: 'Package id, can be found in url', |
| 16 | + lang: { |
| 17 | + description: 'language', |
| 18 | + options: [ |
| 19 | + { value: 'en-us', label: 'English' }, |
| 20 | + { value: 'zh-cn', label: '简体中文' }, |
| 21 | + ], |
| 22 | + default: 'en-us', |
| 23 | + }, |
| 24 | + }, |
| 25 | + features: { |
| 26 | + requireConfig: false, |
| 27 | + requirePuppeteer: true, |
| 28 | + antiCrawler: false, |
| 29 | + supportBT: false, |
| 30 | + supportPodcast: false, |
| 31 | + supportScihub: false, |
| 32 | + }, |
| 33 | + radar: [ |
| 34 | + { |
| 35 | + source: ['play.google.com/store/apps/details?id=:id'], |
| 36 | + }, |
| 37 | + ], |
| 38 | + maintainers: ['surwall'], |
| 39 | + handler, |
| 40 | +}; |
| 41 | + |
| 42 | +// check more language codes on https://support.google.com/googleplay/android-developer/table/4419860?hl=en |
| 43 | +const keywords = { |
| 44 | + // find aria-label of the button next to the "About this app" |
| 45 | + aboutThisAppButton: { |
| 46 | + 'en-us': 'See more information on About this app', |
| 47 | + 'zh-cn': '查看“关于此应用”的更多相关信息', |
| 48 | + }, |
| 49 | + whatsNew: { |
| 50 | + 'en-us': 'What’s new', |
| 51 | + 'zh-cn': '新变化', |
| 52 | + }, |
| 53 | + updatedOn: { |
| 54 | + 'en-us': 'Updated on', |
| 55 | + 'zh-cn': '更新日期', |
| 56 | + }, |
| 57 | + // this format is used to parse the date string in the "Updated on" section |
| 58 | + updatedOnFormat: { |
| 59 | + // Jun 23, 2025, 'Jun' is a locale word, we use 'en' as the locale key |
| 60 | + 'en-us': ['MMM D, YYYY', 'en'], |
| 61 | + // 2025年6月23日 |
| 62 | + 'zh-cn': ['YYYY年M月D日'], |
| 63 | + }, |
| 64 | + version: { |
| 65 | + 'en-us': 'Version', |
| 66 | + 'zh-cn': '版本', |
| 67 | + }, |
| 68 | + offeredBy: { |
| 69 | + 'en-us': 'Offered by', |
| 70 | + 'zh-cn': '提供方', |
| 71 | + }, |
| 72 | +}; |
| 73 | + |
| 74 | +async function handler(ctx: Context) { |
| 75 | + const id = ctx.req.param('id'); |
| 76 | + const lang = ctx.req.param('lang') ?? 'en-us'; |
| 77 | + const baseurl = 'https://play.google.com/store/apps'; |
| 78 | + const link = `${baseurl}/details?id=${id}&hl=${lang}`; |
| 79 | + |
| 80 | + let browser: Browser | undefined; |
| 81 | + let htmlContent = ''; |
| 82 | + try { |
| 83 | + browser = await puppeteer(); |
| 84 | + const page = await browser.newPage(); |
| 85 | + page.setRequestInterception(true); |
| 86 | + page.on('request', (req) => { |
| 87 | + if (['image', 'font', 'stylesheet'].includes(req.resourceType())) { |
| 88 | + req.abort(); |
| 89 | + } else { |
| 90 | + req.continue(); |
| 91 | + } |
| 92 | + }); |
| 93 | + |
| 94 | + logger.http(`Requesting ${link}`); |
| 95 | + await page.goto(link, { |
| 96 | + waitUntil: 'domcontentloaded', |
| 97 | + }); |
| 98 | + |
| 99 | + // click "about this app" arrow button |
| 100 | + const aboutThisAppButtonXpath = `::-p-xpath(//button[@aria-label='${keywords.aboutThisAppButton[lang]}'])`; |
| 101 | + await page.click(aboutThisAppButtonXpath); |
| 102 | + |
| 103 | + // waiting for a dialog containing <div class="xxxx">Version</div> |
| 104 | + const versionXpath = `::-p-xpath(//div[text()="${keywords.version[lang]}"]/following-sibling::*[1])`; |
| 105 | + await page.waitForSelector(versionXpath); |
| 106 | + |
| 107 | + htmlContent = await page.content(); |
| 108 | + const $ = load(htmlContent); |
| 109 | + |
| 110 | + const appName = $('span[itemprop=name]').first().text(); |
| 111 | + const appImage = $('img[itemprop=image]').first().attr('src'); |
| 112 | + |
| 113 | + let updatedOnStr: string | undefined; |
| 114 | + let version: string | undefined; |
| 115 | + let offeredBy: string | undefined; |
| 116 | + |
| 117 | + $('div').each(function () { |
| 118 | + if ($(this).text().trim() === keywords.updatedOn[lang]) { |
| 119 | + updatedOnStr = $(this).next().text().trim(); |
| 120 | + } else if ($(this).text().trim() === keywords.version[lang]) { |
| 121 | + version = $(this).next().text().trim(); |
| 122 | + } else if ($(this).text().trim() === keywords.offeredBy[lang]) { |
| 123 | + offeredBy = $(this).next().text().trim(); |
| 124 | + } |
| 125 | + }); |
| 126 | + |
| 127 | + if (!updatedOnStr || !version || !offeredBy) { |
| 128 | + throw new Error('Failed to parse the page'); |
| 129 | + } |
| 130 | + |
| 131 | + const updatedDate = parseDate(updatedOnStr, ...keywords.updatedOnFormat[lang]); |
| 132 | + |
| 133 | + const whatsNew = $('div[itemprop=description]').html(); |
| 134 | + |
| 135 | + const feedContent = ` |
| 136 | + <h2>${keywords.whatsNew[lang]}</h2> |
| 137 | + <p>${whatsNew ?? 'No release notes'}</p> |
| 138 | + `; |
| 139 | + |
| 140 | + return { |
| 141 | + title: appName + ' - Google Play', |
| 142 | + link, |
| 143 | + image: appImage, |
| 144 | + item: [ |
| 145 | + { |
| 146 | + title: formatVersion(version, updatedDate), |
| 147 | + description: feedContent, |
| 148 | + link, |
| 149 | + pubDate: updatedDate, |
| 150 | + guid: formatGuid(version, updatedDate), |
| 151 | + author: offeredBy, |
| 152 | + }, |
| 153 | + ], |
| 154 | + }; |
| 155 | + } finally { |
| 156 | + await browser?.close(); |
| 157 | + } |
| 158 | +} |
| 159 | + |
| 160 | +function formatVersion(version: string, updatedDate: Date) { |
| 161 | + // some apps show version as "Varies with device" |
| 162 | + // https://play.google.com/store/apps/details?id=com.adobe.reader&hl=en-us |
| 163 | + const isVersion = /^\d/.test(version); |
| 164 | + return isVersion ? version : updatedDate.toISOString().slice(0, 10); |
| 165 | +} |
| 166 | + |
| 167 | +function formatGuid(version: string, updatedDate: Date) { |
| 168 | + return updatedDate.getTime().toString() + '-' + version; |
| 169 | +} |
0 commit comments