-
-
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 | |
| */ |
@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 🙏🏼


@schima Unfortunately your attachment got deleted. Can you please add it again?