AppWatcher - Track the price of apps and their in-app purchases

Hello,

once I made a Shortcut to show the in-app purchases of some apps. As soon as I’ve found Scriptable, I started to transfer the Shortcut to it because it was fetching the information a lot faster than Shortcuts. I’ve also extended it to show the last price, if it has changed.

It is pretty easy to use it:

  • Share an app from the AppStore or with 3D Touch on the icon of the app on the homescreen with this script and it will be added to your personal list of apps that is stored in iCloud
  • Run the script to show the prices of each app
  • To remove an app from the list, run the script, check the checkbox at the top of the result view, click “Done” and a list of your apps with it’s icon, name and current price will be shown. Tap on an item to select it and press “Done” at the top left to remove the selected apps

It can also run in notifications, where it only displays the changes in prices! Just schedule some notifications in the script settings and you’re good to go!

This script can only handle apps from AppStore for now!

To download it, either copy it’s contents into the old script replacing everything, or use my Import Script for Github Gists script

If there is any bug, please report it and I like reading comments that you are using it or you learned something from it :wink:

Edit
I’ve changed the script to use your local AppStore and not the one from Austria.

Another edit
I’ve updated the script to fix an error if an app was removed from the AppStore. Price changes can now also be reset.

Yet another edit
I’ve fixed requiring a not needed module ~ chooseItems.js

Additional edit
Fixed error “can’t find variable chooseItems”

Update

  • Added tracking of updates, has to be enabled
  • Added a summary view in the app that only displays the changes
  • Fixed a bug that prevented adding new apps as Apple has changed their API slightly.
  • Fixed a bug so you could add the same app multiple times

v1.0.6
Added a check to only allow the addition of apps

v1.0.7
Fixed error when making too many in-app API requests

v1.0.8
Add option to disable tracking of in-app purchases
Save data more often to not loose any

10 Likes

So. Stupid question probably, but how do I import this?

I can’t find any import-function in scriptable. I could probably just extract the script-part from the JSON, but I feel like I’m missing something obvious.

For what it’s worth, it looks like it was the original JS file, but with lots of escape sequences added in \n for new line, \t for tab, etc. I had a quick go at some find and replace and then some more manual re-crafting to see if I could get it running. I succeeded to an extent, but I wonder if some of the regex got mangled in the process, or if I’ve missed something (e.g. in the HTML) somewhere? I can run the reworked script (below), but whenever it attempts to create a new file it just sits there waiting. Right now, I can’t be sure if that’s a problem with the original script or my iterated on version to try and get it to run. Unfortunately I don’t have the time left today to sit down and figure it out. This was just a quick attempt at a quck fix as it was.

Expand to reveal script
/********************************************
 *                                          *
 *              \/\\                          *
 *             \/  \\   _ __  _ __            *
 *            \/ \/\\ \\ | '_ \\| '_ \\           *
 *           \/ ____ \\| |_) | |_) |          *
 *          \/_\/    \\_\\ .__\/| .__\/           *
 *                   | |   | |              *
 * __          __   _|_|   |_|              *
 * \\ \\        \/ \/  | |     | |              *
 *  \\ \\  \/\\  \/ \/_ _| |_ ___| |__   ___ _ __ *
 *   \\ \\\/  \\\/ \/ _` | __\/ __| '_ \\ \/ _ \\ '__|*
 *    \\  \/\\  \/ (_| | || (__| | | |  __\/ |   *
 *     \\\/  \\\/ \\__,_|\\__\\___|_| |_|\\___|_|   *
 *                                          *
\*    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   *
 *   at the top, press "Done" and it will   *
 *  ask you to select the apps to remove.   *
 *                                          *
 *   Made by @schl3ck (Reddit, Automators   *
 *          Talk) in November 2018          *
 *                                          *
 ********************************************/


let fm = FileManager.iCloud();
let settingsFilename = "AppWatcher.json";
let file = fm.joinPath(fm.documentsDirectory(), settingsFilename);
let apps = fm.readString(file);
if (!apps) {
    let alert = new Alert();
    alert.title = `The file "${settingsFilename}" was not found in the Scriptable iCloud folder or is not downloaded from iCloud. If you know that it exists, you can select it, otherwise you have to 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 = "[]";
    }
}
apps = JSON.parse(apps);
apps.forEach(app => {
    setUndef(app);
    if (!app.inApp) return;
    app.inApp.forEach(setUndef);
});


function setUndef(i) {
    if (!i.price) return;
    if (i.price[0] == null) {
        i.price[0] = undefined;
        i.formattedPrice[0] = undefined;
    }
}

function getInAppPurchases(html) {
    let regex = /<script type=\"fastboot\\\shoebox\" id=\"shoebox-ember-data-store\">(\{\"data\":.*?\})<\/script>/s;
    let json = JSON.parse(html.match(regex)[1]);
    let result = [];
    
    for (let i of json.included) {
        if (!i.type.includes("in-app"))
            continue;
        let price = parseFloat(i.attributes.price.replace(",", ".")) || 0;
        let id = i.id;
        let name = i.attributes.name;
        let formattedPrice = i.attributes.price;
        result.push({name, price, formattedPrice});
    }
    result.sort((a, b) => a.price - b.price);
    return result.filter((a, i) => i === 0 || a.name !== result[i-1].name);
}

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() {
    if (fm.fileExists(file))
        fm.remove(file);
fm.writeString(file, JSON.stringify(apps, null, 0));
    log("Saved!");
}

let addApp = args.urls[0];
if (addApp) {
    let id = parseInt(addApp.match(/id(\d+)/)[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});
    alert.title = "Show all apps?";
    alert.addAction("Yes");
    alert.addCancelAction("No");
    if (-1 === await alert.presentAlert()) {
        save();
        Script.complete();
        return;
    }
}

if (!apps.length) {
    let a = new Alert();
    a.title = "No apps";
    a.message = "There are no apps in your list. Please add an app by sharing its AppStore URL to this script.";
    a.addCancelAction("OK");
    await a.presentAlert();
    Script.complete();
    return;
}


let req = new Request("https:/bitunes.apple.com/lookup?country=de&id=" + apps.map(app => app.id).join(","));
// req = new Request(url);
let json = (await req.loadJSON()).results;
// QuickLook.present(json);
// return;

let inApp = await Promise.all(json.map(i => {
        let req = new Request(i.trackViewUrl);
        return req.loadString().then(html => getInAppPurchases(html));
    }));
apps = json.map((app, i) => {
    return {
        inApp: inApp[i],
        price: app.price,
        formattedPrice: app.formattedPrice,
        name: app.trackName,
        icon: app.artworkUrl60
    };
}).map((app, i) => {
    apps[i].name = app.name;
    apps[i].icon = app.icon;
    apps[i].price = apps[i].price || [undefined, undefined];
    apps[i].formattedPrice = apps[i].formattedPrice || [undefined, undefined];
    if (apps[i].price[1] !== app.price) {
        apps[i].price.shift();
        apps[i].price.push(app.price);
        apps[i].formattedPrice.shift();
        apps[i].formattedPrice.push(app.formattedPrice);
    }
    apps[i].inApp = apps[i].inApp || [];
    apps[i].inApp = app.inApp.map((ia) => {
        let old = apps[i].inApp.find(a => a.name === ia.name && a.price === ia.price[1]);
        old = old || apps[i].inApp.find(a => a.name === ia.name);
        if (old && old.price[1] !== ia.price) {
            old.price.shift();
            old.price.push(ia.price);
            old.formattedPrice.shift();
            old.formattedPrice.push(ia.formattedPrice);
        } else if (!old) {
            old = {
                price: [undefined, ia.price],
                formattedPrice: [undefined, ia.formattedPrice],
                name: ia.name,
                id: ia.id
            };
        }
        return old;
    });
    apps[i].inApp.sort((a, b) => a.price[1] - b.price[1]);
    return apps[i];
});

apps.sort((a, b) => a.price[1] - b.price[1]);
// await QuickLook.present(JSON.stringify(apps, null, 4));

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;
        }
        .btn {
            width: 100%;
            border-radius: 0;
            font-size: 15pt;
            margin: 0;
        }
        .btn span {
            position: relative;
            top: -4px;
        }
        .btn:before {
            content: "\\2610";
            position: relative;
            left: -50px;
            top: -4px;
            font: 25pt "Menlo-Regular";
        }
        input:checked + .btn:before {
            content: "\\2611";
        }
    </style>
</head>
<body>
    <input type="checkbox" id="removeApps" hidden>
    <label class="btn btn-sm btn-warning" for="removeApps"><span>Remove apps</span></label>
    <table class="table table-striped">
    <thead>
        <tr>
            <th>Icon</th>
            <th>Name</th>
            <th>Price</th>
        </tr>
    </thead>
    <tbody>
        ${apps.map(app => {
            return `<tr class="${getColor(app.price)}">
                <td><img src="${app.icon}" width="60" height="60"></td>
                <td>${app.name}</td>
                <td>${typeof app.price[0] !== "undefined" ? `<del>${app.formattedPrice[0]}</del>&nbsp;` : ""}${app.formattedPrice[1]}</td>
            </tr>
            <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>`;
        }).join("")}
    </tbody>
    </table>
</body>
</html>`;

// await QuickLook.present(html);

let wv = new WebView();
wv.loadHTML(html);
await wv.present();
let rm = await wv.evaluateJavaScript(`document.getElementById("removeApps").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 = (number) => {
            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 = 67;
        cell.leftAligned();
        row.addCell(cell);
        
//         price
        cell = UITableCell.text(app.formattedPrice[1]);
        cell.widthWeight = 15;
        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;
        });
}

save();

Script.complete();

But that’s just the value for the script-key, right? Looks to me like this is JSON that is exported from scriptable somehow, with the icon metadata and everything.

If you take a Scriptable script and choose to Save to Files from the Share Sheet, guess what the file format looks like :wink:

The problem is, for this file format, as far as I know, there is no built-in import method to allow you to get the file back into Scriptable.

If you actually look at one of the JS files directly outside of Scriptable, you’ll see that Scriptable only data at the top - just as the comments suggest. The Scriptable UI just filters them out.

I’m sorry guys, it is the exported .scriptable file, but iCloud only shows the contents and not the file… I’ve uploaded it to my OneDrive and updated the link at the top. Download the file and open it with Scriptable. I hope that it works now :crossed_fingers:

That comes back to my earlier comment. How do you open a Scriptable file with Scriptable?

As far as I know, that’s one of the main reasons we’ve all posted scripts to copy and paste rather than files.

Thanks, that works @schl3ck

And on a more general note the script also seems to work. Don’t really have much time to play around with it right now, but it looks like you did a great job!

You can just use open in, there’s a “Copy to Scriptable” action that works :slight_smile:

2 Likes

Thanks :+1: I’d expected an Open In or Import option. I’d never expected a Copy To… Haven’t used those in a while :laughing::

Cool! Thanks for sharing the script :raised_hands:

Really cool! Ive been looking for this kinda app/script for a long time

1 Like

I’ve updated the script with a fix for apps that were removed from the AppStore.

And the changes in price can now also be reset.

For the next update, I’m working on integrating it in notifications. It works, but now I want to extract that notification edit thing to make it as plugin because it looked quite nice :grin:

2 Likes

And I made a mistake in the last update. Link is updated in the initial post!

1 Like

How do I remove an app?

As I wrote in my first post and in the comment at the top in the script :wink:

I’m sorry if you have gotten an error with the latest version. That was my fault. But please write the next time that you got an error and also copy it into your post :wink:

I’ve fixed the error “can’t find variable chooseItems”. Post was updated!

I’m sorry about missing the instructions in the post. That fixed it, I thought I was doing it wrong before. Thanks!

I just wanted to let you all know that the script can also run in notifications! Just schedule one in the script settings and it will only show you the changes in the notification!

1 Like

I’ve updated the script :smile: Mainly to fix a bug that prevented adding new apps. Just check out the first post in this thread!

1 Like

I’ve updated the script to adapt it to the latest changes Apple has made to its AppStore websites (thereby breaking this script).
And if when Apple does it again, it should show a banner with a link to this topic.

1 Like