Skip to content

Instantly share code, notes, and snippets.

@schl3ck
Last active November 23, 2025 20:03
Show Gist options
  • Select an option

  • Save schl3ck/b8e21e362f1646c8680e53073e7c95a9 to your computer and use it in GitHub Desktop.

Select an option

Save schl3ck/b8e21e362f1646c8680e53073e7c95a9 to your computer and use it in GitHub Desktop.
iOS Scriptable script to track the price and in-app purchases of your favourite apps
// 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>&nbsp;` : ""
}${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]} -&gt; ${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>&nbsp;` : ""
}${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
*/
@emidblol
Copy link

I got the whole timr a error

@emidblol
Copy link

I got the whole timr a error
He cant find the config file

@schl3ck
Copy link
Author

schl3ck commented Aug 25, 2021

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.

@emidblol
Copy link

No it’s not that it doesn’t find appwatcher.json

@schl3ck
Copy link
Author

schl3ck commented Aug 25, 2021 via email

@emidblol
Copy link

emidblol commented Aug 25, 2021 via email

@schl3ck
Copy link
Author

schl3ck commented Aug 31, 2021

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.

@emidblol
Copy link

emidblol commented Sep 2, 2021 via email

@nelsondani1
Copy link

@online
Copy link

online commented Jul 31, 2023

Please help resolve this error:

Exception Occurred
Error on line 231:20: TypeError:
undefined is not an object (evaluating
'res.data[0]')

@schl3ck
Copy link
Author

schl3ck commented Aug 1, 2023

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?

@online
Copy link

online commented Aug 1, 2023

@schl3ck Hello. Thank you for your reply. Today I don't see this error anymore, everything is working fine.

@schl3ck
Copy link
Author

schl3ck commented Aug 1, 2023

@online Nice! Thank you for using the script!

@thecatfix
Copy link

Thank you!!!!

@nelsondani1
Copy link

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?

@nelsondani1
Copy link


@schima
Copy link

schima commented Nov 15, 2025

@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.

@schl3ck
Copy link
Author

schl3ck commented Nov 15, 2025

@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.

@schl3ck
Copy link
Author

schl3ck commented Nov 16, 2025

@schima I've updated the script. Please try the new version and report back if it works for you. Thank you!

@schima
Copy link

schima commented Nov 16, 2025 via email

@schl3ck
Copy link
Author

schl3ck commented Nov 16, 2025

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

@schima
Copy link

schima commented Nov 16, 2025

Hello @schl3ck

No worries. I hope this attachment comes thru this time.

IMG_4717

@schima
Copy link

schima commented Nov 19, 2025 via email

@schl3ck
Copy link
Author

schl3ck commented Nov 19, 2025

@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
Copy link

schima commented Nov 19, 2025

@schl3ck

I appreciate your efforts. Thank YOU. I wish you have a great day today ☺️
I sent the new link to the app and ran it. It gives the following screen

image

@schl3ck
Copy link
Author

schl3ck commented Nov 19, 2025

@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 👍

@schl3ck
Copy link
Author

schl3ck commented Nov 22, 2025

@schima I've updated the script. I hope your issue is now resolved!

@schima
Copy link

schima commented Nov 22, 2025

@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 😁

@schl3ck
Copy link
Author

schl3ck commented Nov 23, 2025

@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 😅

@schima
Copy link

schima commented Nov 23, 2025

No worries. Thanks for fix 🙏🏼

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment