-
-
Save schl3ck/b8e21e362f1646c8680e53073e7c95a9 to your computer and use it in GitHub Desktop.
| // Variables used by Scriptable. | |
| // These must be at the very top of the file. Do not edit. | |
| // icon-color: blue; icon-glyph: laptop-code; | |
| // share-sheet-inputs: url; | |
| /******************************************* | |
| * * | |
| * /\ * | |
| * / \ _ __ _ __ * | |
| * / /\ \ | '_ \| '_ \ * | |
| * / ____ \| |_) | |_) | * | |
| * /_/ \_\ .__/| .__/ * | |
| * | | | | * | |
| *__ __ _|_| |_| * | |
| *\ \ / / | | | | * | |
| * \ \ /\ / /_ _| |_ ___| |__ ___ _ __ * | |
| * \ \/ \/ / _` | __/ __| '_ \ / _ \ '__|* | |
| * \ /\ / (_| | || (__| | | | __/ | * | |
| * \/ \/ \__,_|\__\___|_| |_|\___|_| * | |
| * * | |
| * Track the price of apps and their * | |
| * in-app purchases * | |
| * * | |
| * - To view the list of apps just run * | |
| * this script. * | |
| * - To add an app, just share the app * | |
| * from the AppStore to Scriptable and * | |
| * choose this script. * | |
| * - To remove an app from the list, check * | |
| * in the result view the checkbox "Remove * | |
| * app" at the top, press "Done" and it * | |
| * will ask you to select the apps to * | |
| * remove. * | |
| * - To reset the changes in prices, check * | |
| * in the result view the checkbox "Reset * | |
| * price changes" at the top and press * | |
| * "Done" * | |
| * * | |
| * This script can also run in * | |
| * notifications. It then will display * | |
| * only the changes. * | |
| * * | |
| * You can also start this script with the * | |
| * URL scheme with a parameter "mode" to * | |
| * directly execute its action. This is * | |
| * useful for the actions inside * | |
| * notifications. It can have the values * | |
| * "view" to only view the apps without * | |
| * polling for any changes, "reset" to * | |
| * delete every recorded change and * | |
| * "remove" to jump to the selection where * | |
| * you can remove the apps * | |
| * * | |
| * ! ! ! ! ! ! ! ! ! * | |
| * This script can only handle apps from * | |
| * the AppStore for now * | |
| * ! ! ! ! ! ! ! ! ! * | |
| * * | |
| * Below this comment is a small * | |
| * configuration section. * | |
| * * | |
| * Scroll to the bottom for the changelog! * | |
| * * | |
| * Made by @schl3ck (Reddit, Automators * | |
| * Talk) in November 2018 * | |
| * * | |
| * Version 1.1.2 * | |
| * * | |
| *******************************************/ | |
| // if true, updates will be tracked | |
| const trackVersion = true; | |
| // if true, in-app purchases will be tracked | |
| const trackIAPs = true; | |
| // if true, starts the in app results view with an overview of the found changes | |
| const defaultToChangesView = true; | |
| // automatically close the progress view and open the changes view when no error has occurred | |
| const autoOpenChangesWhenFinishedLoading = true; | |
| // ======= end of config ======= | |
| // set it to true initially as it gets set to false when at least one app could retrieve its in-app-purchases (even if there are none) | |
| let errorFindingInAppDueToWebsiteChange = | |
| trackIAPs && | |
| "errorFindingInApp" in args.queryParameters && | |
| args.queryParameters.errorFindingInApp === "true"; | |
| const errorFindingInAppTitle = | |
| "Looks like Apple changed their AppStore web appeareance. Tap here to see if there is an update for this script."; | |
| const errorFindingInAppSubtitle = "If there is no update, please try again after some time."; | |
| const forumPostURLOfScript = | |
| "https://talk.automators.fm/t/appwatcher-track-the-price-of-apps-and-their-in-app-purchases/3381"; | |
| let fm = FileManager.iCloud(); | |
| let settingsFilename = "AppWatcher.json"; | |
| let file = fm.joinPath(fm.documentsDirectory(), settingsFilename); | |
| await fm.downloadFileFromiCloud(file); | |
| let apps = fm.readString(file); | |
| let noFile = false; | |
| if (!apps) { | |
| if (config.runsInWidget) { | |
| Script.setWidget( | |
| MessageWidget("No settings file was found. Please run the script in the app first."), | |
| ); | |
| Script.complete(); | |
| return; | |
| } else { | |
| let alert = new Alert(); | |
| alert.title = `The file "${settingsFilename}" was not found in the Scriptable iCloud folder. You can select a file or create a new one`; | |
| alert.addAction("Select from iCloud"); | |
| alert.addAction("Create new file"); | |
| alert.addCancelAction("Cancel"); | |
| let i = await alert.presentSheet(); | |
| switch (i) { | |
| case -1: | |
| Script.complete(); | |
| return; | |
| case 0: | |
| file = (await DocumentPicker.open(["public.json"]))[0]; | |
| apps = fm.readString(file); | |
| break; | |
| case 1: | |
| apps = "[]"; | |
| break; | |
| } | |
| noFile = true; | |
| } | |
| } | |
| apps = JSON.parse(apps); | |
| apps.forEach((app) => { | |
| setUndef(app); | |
| if (!app.inApp) return; | |
| app.inApp.forEach(setUndef); | |
| }); | |
| if (noFile) save(); | |
| function MessageWidget(message) { | |
| let w = new ListWidget(); | |
| w.backgroundColor = Color.dynamic(Color.white(), Color.black()); | |
| w.addSpacer(); | |
| let text = w.addText(message); | |
| text.centerAlignText(); | |
| text.textColor = Color.red(); | |
| text.minimumScaleFactor = 0.5; | |
| w.addSpacer(); | |
| let stack = w.addStack(); | |
| stack.layoutHorizontally(); | |
| stack.addSpacer(); | |
| text = stack.addText("Last update:"); | |
| text.rightAlignText(); | |
| text.textColor = Color.gray(); | |
| text.font = Font.footnote(); | |
| text.lineLimit = 1; | |
| text.minimumScaleFactor = 0.7; | |
| stack.addSpacer(1); | |
| let time = stack.addDate(new Date()); | |
| time.rightAlignText(); | |
| time.applyTimeStyle(); | |
| time.textColor = Color.gray(); | |
| time.font = Font.footnote(); | |
| time.minimumScaleFactor = 0.7; | |
| return w; | |
| } | |
| class noSave { | |
| constructor(data) { | |
| this.data = data; | |
| log(data); | |
| } | |
| toJSON() { | |
| return undefined; | |
| } | |
| toString() { | |
| return this.data.toString(); | |
| } | |
| } | |
| function setUndef(i) { | |
| if (!i.price) return; | |
| if (i.price[0] == null) { | |
| i.price[0] = undefined; | |
| i.formattedPrice[0] = undefined; | |
| } | |
| } | |
| function getInAppPurchases(html, url, id) { | |
| let regex = /<script type="application\/json" id="serialized-server-data">(.+?)(?=<\/script>)/s; | |
| let match = html.match(regex); | |
| if (!match) { | |
| log("Did not find serialized-server-data, " + url); | |
| // probably the website has changed | |
| errorFindingInAppDueToWebsiteChange = errorFindingInAppDueToWebsiteChange && true; | |
| return; | |
| } | |
| let json = JSON.parse(match[1]); | |
| if (!json[0].data.titleOfferDisplayProperties.hasInAppPurchases) { | |
| // the regex found something. It looks like the website hasn't changed yet | |
| errorFindingInAppDueToWebsiteChange = false; | |
| return []; | |
| } | |
| const regexLooksLikePrice = /\d+(?:[ ,.]\d+)*/; | |
| const candidates = json[0].data.shelfMapping.information.items.filter((item) => { | |
| if ( | |
| "items_V3" in item && | |
| Array.isArray(item.items_V3) && | |
| item.items_V3.length > 0 && | |
| item.items_V3[0].$kind === "textPair" && | |
| regexLooksLikePrice.test(item.items_V3[0].trailingText) | |
| ) { | |
| return true; | |
| } | |
| if ( | |
| "items" in item && | |
| Array.isArray(item.items) && | |
| item.items.length > 0 && | |
| "textPairs" in item.items[0] && | |
| Array.isArray(item.items[0].textPairs) && | |
| item.items[0].textPairs.length > 0 && | |
| regexLooksLikePrice.test(item.items[0].textPairs[0][1]) | |
| ) { | |
| return true; | |
| } | |
| return false; | |
| }); | |
| if (candidates.length === 0) { | |
| // probably the website has changed | |
| errorFindingInAppDueToWebsiteChange = errorFindingInAppDueToWebsiteChange && true; | |
| return; | |
| } | |
| if (candidates.length > 1) { | |
| console.log( | |
| "found multiple candidates for in app purchases (choosing first): " + | |
| JSON.stringify(candidates, null, 2), | |
| ); | |
| } | |
| const items = candidates[0].items; | |
| const items_V3 = candidates[0].items_V3; | |
| /** | |
| * Parses the price from a string. | |
| * | |
| * It should be able to parse all these number formats: | |
| * - 780 | |
| * - 6,000 | |
| * - 1,678.00 | |
| * - 1 000,00 | |
| * - 1.199.000 | |
| * @param {string} text | |
| */ | |
| function getPrice(text) { | |
| if (!text) { | |
| return 0; | |
| } | |
| const match = text.match(regexLooksLikePrice); | |
| if (!match) { | |
| throw new Error(`Found no price in text "${text}"`); | |
| } | |
| // we can safely remove any spaces as thousand separator | |
| let number = match[0].replaceAll(" ", ""); | |
| // remove , and . as thousand separator | |
| for (const separator of [",", "\\."]) { | |
| if (new RegExp(`\\d+${separator}\\d{3}`).test(number)) { | |
| number = number.replaceAll(separator[separator.length - 1], ""); | |
| } | |
| } | |
| // any remaining , are floating point separator | |
| number = number.replaceAll(",", "."); | |
| return parseFloat(number); | |
| } | |
| // log("items_V3: " + JSON.stringify(items_V3, null ,2)) | |
| // log("items: " + JSON.stringify(items, null, 2)) | |
| if (items_V3) { | |
| const titleKey = "leadingText"; | |
| const priceKey = "trailingText"; | |
| // we found something. It looks like the website hasn't changed yet | |
| errorFindingInAppDueToWebsiteChange = false; | |
| return items_V3 | |
| .filter((i) => i.$kind === "textPair") | |
| .map((i) => { | |
| return { | |
| id: i[titleKey], | |
| name: i[titleKey], | |
| price: getPrice(i[priceKey]), | |
| formattedPrice: i[priceKey], | |
| }; | |
| }); | |
| } | |
| if (items) { | |
| const titleIndex = 0; | |
| const priceIndex = 1; | |
| // we found something. It looks like the website hasn't changed yet | |
| errorFindingInAppDueToWebsiteChange = false; | |
| return items[0].textPairs.map((i) => { | |
| return { | |
| id: i[titleIndex], | |
| name: i[titleIndex], | |
| price: getPrice(i[priceIndex]), | |
| formattedPrice: i[priceIndex], | |
| }; | |
| }); | |
| } | |
| log("Did not find in-app purchases information, " + url); | |
| errorFindingInAppDueToWebsiteChange = errorFindingInAppDueToWebsiteChange && true; | |
| return; | |
| } | |
| function getColor([a, b]) { | |
| // log(`getColor(${a}, ${b})`) | |
| if (typeof a === "undefined") return ""; | |
| if (a < b) return "table-danger"; | |
| if (b === 0) return "table-success"; | |
| if (a > b) return "table-warning"; | |
| return ""; | |
| } | |
| function save() { | |
| // log(JSON.stringify(apps, null, 4)); | |
| // return; | |
| if (fm.fileExists(file)) fm.remove(file); | |
| fm.writeString( | |
| file, | |
| JSON.stringify( | |
| apps, | |
| (k, v) => { | |
| if (k === "removed") return undefined; | |
| return v; | |
| }, | |
| 0, | |
| ), | |
| ); | |
| log("Saved!"); | |
| } | |
| let addApp = args.urls[0]; | |
| if (addApp) { | |
| if (!/^https?:\/\/(?:itunes|apps)\.apple\.com\/(?:[^/]+\/)?app\//.test(addApp)) { | |
| let a = new Alert(); | |
| a.title = "Not an AppStore app"; | |
| a.message = "I'm sorry, but this script only supports apps from the AppStore for now 😕"; | |
| a.addCancelAction("OK"); | |
| await a.presentAlert(); | |
| return; | |
| } | |
| let id = parseInt(addApp.match(/id(\d+)/)[1]); | |
| let country = addApp.match(/https?:\/\/(?:itunes|apps)\.apple\.com\/([^/]+)/)[1]; | |
| let alert = new Alert(); | |
| if (apps.find((a) => a.id == id)) { | |
| alert.title = "This app is already in the list"; | |
| alert.addCancelAction("OK"); | |
| await alert.presentAlert(); | |
| Script.complete(); | |
| return; | |
| } | |
| apps.push({ id, country, trackViewUrl: addApp }); | |
| save(); | |
| alert.title = "Show all apps?"; | |
| alert.addAction("Yes"); | |
| alert.addCancelAction("No"); | |
| if (-1 === (await alert.presentAlert())) { | |
| Script.complete(); | |
| return; | |
| } | |
| } | |
| if (!apps.length) { | |
| const msg = | |
| "There are no apps in your list. Please add an app by sharing its AppStore URL to this script."; | |
| if (config.runsInWidget) { | |
| Script.setWidget(MessageWidget(msg)); | |
| Script.complete(); | |
| } else { | |
| let a = new Alert(); | |
| a.title = "No apps"; | |
| a.message = msg; | |
| a.addCancelAction("OK"); | |
| await a.presentAlert(); | |
| Script.complete(); | |
| } | |
| return; | |
| } | |
| let launchMode = args.queryParameters.mode; | |
| log("launchMode: " + launchMode); | |
| let wv; | |
| if (config.runsInApp) { | |
| // launchMode = "edit" | |
| } | |
| if (!launchMode) { | |
| log("retrieving prices"); | |
| let ui = config.runsInApp ? new UITable() : null; | |
| let loaded = { | |
| total: apps.length, | |
| successful: 0, | |
| failed: 0, | |
| errors: [], | |
| /** @type {string | null} */ | |
| open: null, | |
| }; | |
| function refreshTable() { | |
| if (!ui) return; | |
| ui.removeAllRows(); | |
| let row, cell; | |
| row = new UITableRow(); | |
| ui.addRow(row); | |
| row.isHeader = true; | |
| row.addText("AppWatcher Loading").centerAligned(); | |
| row = new UITableRow(); | |
| ui.addRow(row); | |
| row.height = 15; | |
| row = new UITableRow(); | |
| ui.addRow(row); | |
| row.addText("" + loaded.successful, "Successful").centerAligned(); | |
| row.addText("" + loaded.failed, "Failed").centerAligned(); | |
| row.addText("" + loaded.total, "Total").centerAligned(); | |
| if (loaded.open) { | |
| row = new UITableRow(); | |
| ui.addRow(row); | |
| row.backgroundColor = Color.green(); | |
| row.addText("Open").centerAligned(); | |
| row.onSelect = () => Safari.open(loaded.open); | |
| } | |
| for (const error of loaded.errors) { | |
| row = new UITableRow(); | |
| ui.addRow(row); | |
| row.addText("" + error); | |
| row.dismissOnSelect = false; | |
| row.onSelect = async () => { | |
| const a = new Alert(); | |
| a.title = "Error"; | |
| a.message = "" + error; | |
| a.addCancelAction("OK"); | |
| a.addAction("Copy"); | |
| if ((await a.presentAlert()) === 0) { | |
| Pasteboard.copyString("" + error); | |
| } | |
| }; | |
| } | |
| ui.reload(); | |
| } | |
| let countries = {}; | |
| apps.forEach((a) => { | |
| countries[a.country] ||= []; | |
| countries[a.country].push(a.id); | |
| }); | |
| log("getting app details"); | |
| let req = Promise.all( | |
| Object.entries(countries).map(([c, ids]) => | |
| new Request(`https://itunes.apple.com/lookup?country=${c}&id=${ids.join(",")}`) | |
| .loadJSON() | |
| .then((a) => a.results), | |
| ), | |
| ); | |
| let json = (await req).reduce((acc, val) => acc.concat(val), []); | |
| // log(JSON.stringify(json, null, 4)) | |
| // return; | |
| log("getting in-app purchases"); | |
| if (trackIAPs) { | |
| ui?.present(); | |
| if (ui) { | |
| refreshTable(); | |
| await new Promise((resolve) => Timer.schedule(100, false, resolve)); | |
| } | |
| await Promise.all( | |
| json.map((i) => { | |
| if (!i.trackViewUrl) log("item: " + JSON.stringify(i, null, 4)); | |
| let req = new Request(i.trackViewUrl); | |
| return req | |
| .loadString() | |
| .then((html) => { | |
| return getInAppPurchases(html, i.trackViewUrl, i.trackId); | |
| }) | |
| .then( | |
| (ia) => { | |
| log("in app for app: " + i.trackViewUrl + "\n" + JSON.stringify(ia, null, 2)); | |
| loaded.successful++; | |
| refreshTable(); | |
| i.inApp = ia; | |
| }, | |
| (err) => { | |
| logError("error by request " + i.trackName + ":\n" + err); | |
| loaded.failed++; | |
| loaded.errors.push( | |
| JSON.stringify( | |
| { | |
| url: i.trackViewUrl, | |
| message: err.message, | |
| line: err.line, | |
| column: err.column, | |
| stack: err.stack, | |
| type: err.type, | |
| name: err.name, | |
| }, | |
| null, | |
| 2, | |
| ), | |
| ); | |
| refreshTable(); | |
| }, | |
| ); | |
| }), | |
| ); | |
| } | |
| json = json.map((app, i) => { | |
| return { | |
| inApp: app.inApp, | |
| price: app.price, | |
| formattedPrice: app.formattedPrice, | |
| name: app.trackName, | |
| icon: app.artworkUrl60, | |
| trackViewUrl: app.trackViewUrl, | |
| id: app.trackId, | |
| version: app.version, | |
| }; | |
| }); | |
| // log(JSON.stringify(json, null, 4)) | |
| apps.forEach((old, i) => { | |
| let app = json.find((a) => a.id == old.id); | |
| old.price ||= [undefined, undefined]; | |
| old.formattedPrice ||= [undefined, undefined]; | |
| old.inApp ||= []; | |
| old.version ||= [undefined, undefined]; | |
| if (!app) { | |
| log("no app found for: " + old.trackViewUrl); | |
| old.removed = true; | |
| // log(typeof old.price[1]); | |
| if (old.price[1] == null) { | |
| old.price[1] = -1; | |
| old.formattedPrice[1] = ""; | |
| } | |
| old.name = | |
| old.name || | |
| new noSave( | |
| (old.trackViewUrl && | |
| decodeURI( | |
| old.trackViewUrl.match( | |
| /https?:\/\/(?:itunes|apps)\.apple\.com\/(?:[^/]+\/){2}([^/]+)\/id/, | |
| )[1], | |
| )) || | |
| "removed", | |
| ); | |
| } else { | |
| old.name = app.name; | |
| old.icon = app.icon; | |
| old.trackViewUrl = app.trackViewUrl; | |
| if (old.price[1] !== app.price) { | |
| old.price.shift(); | |
| old.price.push(app.price); | |
| old.formattedPrice.shift(); | |
| old.formattedPrice.push(app.formattedPrice); | |
| } | |
| if (old.version[1] !== app.version) { | |
| old.version.shift(); | |
| old.version.push(app.version); | |
| } | |
| if (trackIAPs) { | |
| if (!app.inApp) { | |
| log("no in-app found for: " + old.trackViewUrl); | |
| old.removed = true; | |
| return; | |
| } | |
| // log("old.inApp:\n" + JSON.stringify(old.inApp, null, 4)); | |
| old.inApp = app.inApp.map((ia) => { | |
| // let oldia = old.inApp.find((a) => a.id === ia.id); | |
| // log("ia:\n" + JSON.stringify(ia, null, 4)); | |
| let oldia = /*oldia ||*/ old.inApp.find( | |
| (a) => a.name === ia.name && a.price[1] === ia.price, | |
| ); | |
| oldia = oldia || old.inApp.find((a) => a.name === ia.name); | |
| // log("oldia:\n" + JSON.stringify(oldia, null, 4)); | |
| if (oldia && oldia.price[1] !== ia.price) { | |
| oldia.price.shift(); | |
| oldia.price.push(ia.price); | |
| oldia.formattedPrice.shift(); | |
| oldia.formattedPrice.push(ia.formattedPrice); | |
| } else if (!oldia) { | |
| oldia = { | |
| price: [undefined, ia.price], | |
| formattedPrice: [undefined, ia.formattedPrice], | |
| name: ia.name, | |
| id: ia.id, | |
| }; | |
| } | |
| oldia.id = ia.id; | |
| if (oldia.formattedPrice[1] == null || oldia.formattedPrice[1] == oldia.price[1]) { | |
| oldia.formattedPrice[1] = ia.formattedPrice || oldia.price[1]; | |
| } | |
| return oldia; | |
| }); | |
| old.inApp.sort((a, b) => a.price[1] - b.price[1]); | |
| } | |
| } | |
| }); | |
| // await QuickLook.present(JSON.stringify(apps, null, 4)); | |
| apps.sort((a, b) => { | |
| let c = a.price[1] - b.price[1]; | |
| return a.removed ? (b.removed ? c : -1) : b.removed ? 1 : c; | |
| }); | |
| save(); | |
| if (ui && trackIAPs) { | |
| await new Promise((res) => Timer.schedule(100, false, res)); | |
| const url = | |
| URLScheme.forRunningScript() + | |
| "?mode=view&failed=" + | |
| apps | |
| .filter((i) => i.removed) | |
| .map((i) => i.id) | |
| .join(",") + | |
| "&errorFindingInApp=" + | |
| errorFindingInAppDueToWebsiteChange; | |
| // const a = new Alert() | |
| // a.title = "url" | |
| // a.message = url; | |
| // await a.presentAlert() | |
| log(url); | |
| if (!autoOpenChangesWhenFinishedLoading || loaded.errors.length) { | |
| loaded.open = url; | |
| refreshTable(); | |
| } else { | |
| Safari.open(url); | |
| } | |
| return; | |
| } | |
| } // /!launchMode | |
| let changes = apps | |
| .map((app) => { | |
| let a = {}; | |
| Object.entries(app).forEach(([k, v]) => { | |
| a[k] = v; | |
| }); | |
| app = a; | |
| app.inApp = trackIAPs ? app.inApp.filter((ia) => typeof ia.price[0] === "number") : []; | |
| return app; | |
| }) | |
| .filter((app) => { | |
| return ( | |
| app.inApp.length || typeof app.price[0] === "number" || app.removed || app.version[0] != null | |
| ); | |
| }); | |
| // present results | |
| if (config.runsInNotification) { | |
| let ui = new UITable(), | |
| row, | |
| cell; | |
| if (errorFindingInAppDueToWebsiteChange) { | |
| row = new UITableRow(); | |
| row.addText(errorFindingInAppTitle, errorFindingInAppSubtitle).centerAligned(); | |
| row.dismissOnSelect = false; | |
| row.onSelect = () => { | |
| Safari.open(forumPostURLOfScript); | |
| }; | |
| row.height = 110; | |
| row.backgroundColor = Color.orange(); | |
| ui.addRow(row); | |
| } | |
| if (changes.length) { | |
| changes.forEach((app) => { | |
| row = new UITableRow(); | |
| row.height = 60; | |
| row.dismissOnSelect = false; | |
| row.onSelect = () => { | |
| Safari.open(app.trackViewUrl); | |
| }; | |
| cell = row.addImageAtURL(app.icon); | |
| // cell.centerAligned(); | |
| cell.widthWeight = 20; | |
| cell = row.addText(app.name.toString()); | |
| cell.widthWeight = 60; | |
| cell = row.addText( | |
| app.formattedPrice[1], | |
| app.formattedPrice[0] && `was ${app.formattedPrice[0]}`, | |
| ); | |
| cell.rightAligned(); | |
| cell.widthWeight = 30; | |
| if (app.formattedPrice[0]) { | |
| row.backgroundColor = | |
| app.price[1] === 0 | |
| ? Color.green() | |
| : app.price[1] < app.price[0] | |
| ? Color.yellow() | |
| : Color.red(); | |
| } | |
| ui.addRow(row); | |
| if (app.removed) { | |
| row = new UITableRow(); | |
| row | |
| .addText("There was a problem retrieving data for this app. Maybe the app was removed 🙁") | |
| .centerAligned(); | |
| ui.addRow(row); | |
| } | |
| if (trackVersion && app.version[0] != null) { | |
| row = new UITableRow(); | |
| row.addText("New version! " + app.version[0] + " -> " + app.version[1]).centerAligned(); | |
| ui.addRow(row); | |
| } | |
| if (trackIAPs) { | |
| // inApps | |
| app.inApp.forEach((ia) => { | |
| row = new UITableRow(); | |
| row.height = 35; | |
| row.backgroundColor = | |
| ia.price[1] === 0 | |
| ? Color.green() | |
| : ia.price[1] < ia.price[0] | |
| ? Color.yellow() | |
| : Color.red(); | |
| // cell spacer | |
| cell = row.addText(""); | |
| cell.widthWeight = 4; | |
| cell = row.addText(ia.name); | |
| cell.widthWeight = 60; | |
| cell = row.addText(ia.formattedPrice[1], `was ${ia.formattedPrice[0]}`); | |
| cell.rightAligned(); | |
| cell.widthWeight = 30; | |
| ui.addRow(row); | |
| }); | |
| } | |
| // row spacer | |
| row = new UITableRow(); | |
| row.height = 10; | |
| ui.addRow(row); | |
| }); | |
| // remove last spacer | |
| ui.removeRow(row); | |
| } else { | |
| row = new UITableRow(); | |
| row.addText("No changes found").centerAligned(); | |
| ui.addRow(row); | |
| } | |
| ui.present(); | |
| Script.complete(); | |
| return; | |
| } | |
| if (config.runsInWidget) { | |
| const w = MessageWidget("This script does not support running in widgets"); | |
| Script.setWidget(w); | |
| Script.complete(); | |
| return; | |
| } | |
| if (!launchMode || launchMode === "view") { | |
| log("viewing"); | |
| const removed = args.queryParameters.failed?.split(",") || []; | |
| // { | |
| // let a = new Alert() | |
| // a.title = "failed :" | |
| // a.message = `${JSON.stringify(removed)}` | |
| // await a.presentAlert() | |
| // } | |
| changes.push( | |
| ...apps.filter((app) => { | |
| if (removed.includes(app.id)) { | |
| app.removed = true; | |
| return true; | |
| } | |
| return false; | |
| }), | |
| ); | |
| const appToHTML = (app) => { | |
| /* eslint-disable */ | |
| return `<tr class="${getColor(app.price)}"> | |
| <td> | |
| <a href="${app.trackViewUrl}"> | |
| <img src="${app.icon}" width="60" height="60"> | |
| </a> | |
| </td> | |
| <td>${app.name}</td> | |
| <td>${ | |
| typeof app.price[0] !== "undefined" ? `<del>${app.formattedPrice[0]}</del> ` : "" | |
| }${app.formattedPrice[1]}</td> | |
| </tr> | |
| ${ | |
| app.removed | |
| ? `<tr><td colspan="3" class="text-center"> | |
| There was a problem retrieving data for this app.<br> | |
| Maybe it was removed 🙁 | |
| </td></tr>` | |
| : "" | |
| } | |
| ${ | |
| trackVersion && app.version[0] != null | |
| ? `<tr><td colspan="3" class="text-center"> | |
| New version! ${app.version[0]} -> ${app.version[1]} | |
| </td></tr>` | |
| : "" | |
| } | |
| ${ | |
| trackIAPs | |
| ? `<tr><td colspan="3"> | |
| <table class="table table-sm"> | |
| ${app.inApp | |
| .map((ia) => { | |
| return `<tr class="${getColor(ia.price)}"> | |
| <td>${ia.name}</td> | |
| <td>${ | |
| typeof ia.price[0] !== "undefined" ? `<del>${ia.formattedPrice[0]}</del> ` : "" | |
| }${ia.formattedPrice[1]}</td> | |
| </tr>`; | |
| }) | |
| .join("")} | |
| </table> | |
| </td></tr>` | |
| : "" | |
| }`; | |
| /* eslint-enable */ | |
| }; | |
| /* eslint-disable */ | |
| let html = `<html> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="initial-scale=1, width=device-width"> | |
| <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous"> | |
| <style> | |
| .table > tbody > tr:nth-child(odd) > td:last-child, | |
| .table .table td:last-child { | |
| text-align: right; | |
| white-space: nowrap; | |
| } | |
| .table > tbody td { | |
| vertical-align: middle; | |
| } | |
| .table .table { | |
| width: 100% | |
| margin: 0 5px; | |
| padding: 0; | |
| } | |
| .table { | |
| max-width: 100%; | |
| } | |
| .text-center { | |
| text-align: center; | |
| white-space: wrap !important; | |
| } | |
| .controls { | |
| margin: 2px; | |
| } | |
| .btn { | |
| margin: 2px; | |
| text-align: left; | |
| } | |
| .btn span { | |
| position: relative; | |
| top: -4px; | |
| } | |
| .btn span:before { | |
| content: "\\2610"; | |
| position: relative; | |
| left: -5px; | |
| top: 1px; | |
| font: 20pt "Menlo-Regular"; | |
| } | |
| input:checked + .btn span:before { | |
| content: "\\2611"; | |
| } | |
| .message { | |
| padding: 10 0 0; | |
| text-align: center; | |
| font-size: 2em; | |
| } | |
| .no-in-app-found { | |
| background-color: orange; | |
| } | |
| .no-in-app-found .title { | |
| font-size: 0.6em; | |
| } | |
| .no-in-app-found .subtitle { | |
| font-size: 0.45em; | |
| } | |
| a.full-size { | |
| width: 100%; | |
| display: inline-block; | |
| color: black; | |
| } | |
| a.full-size:hover, | |
| a.full-size:active, | |
| a.full-size.active, | |
| a.full-size:focus, | |
| a.full-size.focus { | |
| text-decoration: none; | |
| color: black; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="d-flex flex-wrap justify-content-around controls"> | |
| <input type="checkbox" id="removeApps" hidden> | |
| <label class="btn btn-danger flex-grow-1" for="removeApps"><span>Remove apps</span></label> | |
| <input type="checkbox" id="resetPrices" hidden> | |
| <label class="btn btn-secondary flex-grow-1" for="resetPrices"><span>Reset price changes</span></label> | |
| <input type="checkbox" id="toggleView" hidden ${ | |
| defaultToChangesView ? `checked="checked"` : "" | |
| }> | |
| <label class="btn btn-primary flex-grow-1" for="toggleView"><span>Show only changes</span></label> | |
| </div> | |
| ${ | |
| errorFindingInAppDueToWebsiteChange | |
| ? `<div class="message no-in-app-found"> | |
| <a class="full-size" href="${forumPostURLOfScript}"> | |
| <span class="title">${errorFindingInAppTitle}</span> | |
| <br /> | |
| <span class="subtitle">${errorFindingInAppSubtitle}</span> | |
| </a> | |
| </div>` | |
| : "" | |
| } | |
| ${ | |
| changes.length | |
| ? `<table class="table table-striped" id="changesView"> | |
| <thead> | |
| <tr> | |
| <th>Icon</th> | |
| <th>Name</th> | |
| <th>Price</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| ${changes.map(appToHTML).join("\n")} | |
| </tbody> | |
| </table>` | |
| : `<div id="changesView" class="message">No changes found</div>` | |
| } | |
| <table class="table table-striped" id="fullView"> | |
| <thead> | |
| <tr> | |
| <th>Icon</th> | |
| <th>Name</th> | |
| <th>Price</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| ${apps.map(appToHTML).join("")} | |
| </tbody> | |
| </table> | |
| <script> | |
| let btn = document.getElementById("toggleView"); | |
| let changesView = document.getElementById("changesView"); | |
| let fullView = document.getElementById("fullView"); | |
| function toggleView() { | |
| if (btn.checked) { | |
| changesView.style.display = ""; | |
| fullView.style.display = "none"; | |
| } else { | |
| changesView.style.display = "none"; | |
| fullView.style.display = ""; | |
| } | |
| } | |
| btn.addEventListener("change", toggleView); | |
| toggleView(); | |
| </script> | |
| </body> | |
| </html>`; | |
| /* eslint-enable */ | |
| // Pasteboard.copyString(html); | |
| // await QuickLook.present(html); | |
| wv = new WebView(); | |
| wv.shouldAllowRequest = (req) => { | |
| // log("ShouldAllowRequest url: " + req.url); | |
| if (/^(itms-appss|https?):\/\//.test(req.url)) { | |
| Safari.open(req.url); | |
| return false; | |
| } | |
| return true; | |
| }; | |
| wv.loadHTML(html); | |
| await wv.present(); | |
| launchMode = undefined; | |
| } | |
| let rm = launchMode | |
| ? launchMode === "remove" | |
| : await wv.evaluateJavaScript('document.getElementById("removeApps").checked;'); | |
| let resetPrices = launchMode | |
| ? launchMode === "reset" | |
| : await wv.evaluateJavaScript('document.getElementById("resetPrices").checked;'); | |
| function createRows(apps, ui) { | |
| ui.removeAllRows(); | |
| let row = new UITableRow(); | |
| row.dismissOnSelect = false; | |
| row.addText("Please choose which apps you want to REMOVE from the list"); | |
| row.height = 60; | |
| ui.addRow(row); | |
| apps.forEach((app) => { | |
| row = new UITableRow(); | |
| row.dismissOnSelect = false; | |
| row.onSelect = () => { | |
| app.checked = !app.checked; | |
| createRows(apps, ui); | |
| }; | |
| // checkmark | |
| let cell = UITableCell.text(app.checked ? "\u2714" : ""); | |
| cell.centerAligned(); | |
| cell.widthWeight = 8; | |
| row.addCell(cell); | |
| // icon | |
| cell = UITableCell.imageAtURL(app.icon); | |
| cell.widthWeight = 10; | |
| cell.centerAligned(); | |
| row.addCell(cell); | |
| // name | |
| cell = UITableCell.text(app.name); | |
| cell.widthWeight = 65; | |
| cell.leftAligned(); | |
| row.addCell(cell); | |
| // price | |
| cell = UITableCell.text(app.formattedPrice[1]); | |
| cell.widthWeight = 17; | |
| cell.rightAligned(); | |
| row.addCell(cell); | |
| ui.addRow(row); | |
| }); | |
| ui.reload(); | |
| } | |
| if (rm) { | |
| let ui = new UITable(); | |
| createRows(apps, ui); | |
| await ui.present(); | |
| apps = apps | |
| .filter((app) => !app.checked) | |
| .map((app) => { | |
| delete app.checked; | |
| return app; | |
| }); | |
| } | |
| // ========================================= | |
| // reset section | |
| if (resetPrices) { | |
| apps.forEach((app) => { | |
| app.price[0] = app.formattedPrice[0] = undefined; | |
| app.version[0] = undefined; | |
| app.inApp.forEach((ia) => { | |
| ia.price[0] = ia.formattedPrice[0] = undefined; | |
| }); | |
| }); | |
| log("Prices were reset"); | |
| } | |
| save(); | |
| Script.complete(); | |
| /* | |
| Changelog | |
| v1.1.2 - 2025-11-22 | |
| Fix for different price formats in different countries | |
| Improve in-app purchases matching to no longer find false positive changes | |
| v1.1.1 - 2025-11-16 | |
| Fix for new website data which is different in different languages | |
| v1.1.0 - 2025-11-10 | |
| Add progress view while fetching the apps | |
| Fix for new website layout | |
| v1.0.9 - 2021-09-06 | |
| Fix some problems with urls | |
| Create/copy file if no file was found | |
| Fix some spelling | |
| v1.0.8 - 2021-04-20 | |
| Add option to disable tracking of in-app purchases | |
| Save state more often | |
| v1.0.7 - 2020-10-04 | |
| Fixed error when making too many in-app API requests | |
| v1.0.6 - 2020-03-11 | |
| Added a check when adding a URL to only allow the addition of apps | |
| v1.0.5 - 2019-09-19 | |
| Adapted to the changes of the AppStore website made by Apple. It should find the in-app-purchases again | |
| Added a message if there was a problem fetching the in-app-purchases of all apps (probably due to a new website change) | |
| v1.0.4 - 2019-06-25 | |
| Added tracking of app updates | |
| Added overview of found changes for in app result view | |
| Fixed error while adding a new app, as Apple has changed their API slightly | |
| Fixed bug enabling duplicate apps in the list. If you have duplicates, please remove them manually via the interface | |
| v1.0.3 - 2019-03-05 | |
| Fixed error "can't find variable chooseItems" | |
| v1.0.2 - 2019-01-29 | |
| Fixed not needed inclusion of module "~chooseItems.js" | |
| v1.0.1 - 2019-01-29 | |
| Added ability to reset old prices | |
| Fixed error when an app was removed from the AppStore | |
| v1.0 - 2018-12-02 | |
| Initial Release | |
| */ |
I got the whole timr a error
He cant find the config file
Have you tried adding an app? This should work nonetheless.
While looking through the code, I noticed that it doesn't save the file when selecting Create new file and no app is being added.
No it’s not that it doesn’t find appwatcher.json
To fix the message, share an AppStore app to the script. When the message appears, tap on "Create new file". The message should not appear again.
This is what I meant earlier. Sorry for not being clear.
Please help resolve this error:
Exception Occurred
Error on line 231:20: TypeError:
undefined is not an object (evaluating
'res.data[0]')
Hi,
Unfortunately I can't reproduce the error.
What URL is logged directly before the error message? Is it the same URL all the time?
@schl3ck Hello. Thank you for your reply. Today I don't see this error anymore, everything is working fine.
@online Nice! Thank you for using the script!
Thank you!!!!
Hi, Unfortunately I can't reproduce the error.
What URL is logged directly before the error message? Is it the same URL all the time?
@schl3ck thanks for having this script. App watcher is currently failing in scriptable since iOS 26.1. I have few monitor apps in there and when run it says “looks like Apple changed their AppStore web appearance etc etc”.
I downloaded this latest js zip. Replaced the file. In another instance, replaced the text but same issue persists.
Could you please fix this? This script is very beneficial and great.
@schima Hi, I've looked into it and identified the issue: the information of the in app purchases is not at the same position in the website data for different languages. I'll try to fix it in the next few days.
@schima I've updated the script. Please try the new version and report back if it works for you. Thank you!
@schima Unfortunately your attachment got deleted. Can you please add it again?
@schima Yes, I got it, thank you. I'm currently looking into it but I can't give you a date when I've a fix ready.
Could you please send the link to the app?
@schima Sorry, I meant if you can sent me the link to the 1Blocker app, but I think I've got everything I need in the screenshot 👍
@schima I've updated the script. I hope your issue is now resolved!
@schl3ck yes. I downloaded the file and used it. It looks to be working again 🤗🙏🏼
thank YOU so much. All good for now.
i wish you the best weekend and take care.
If you ever in Vancouver BC Canada area, let me know. I owe you a coffee and lunch 😁
@schima Thank you for your offer. Unfortunately I won't be there in the near future because I've not planned any trip to Canada. I live in Austria therefore I can't just hop in the car and go 😅
No worries. Thanks for fix 🙏🏼


I got the whole timr a error