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


#1

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

Download it here!

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.


#2

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.


#3

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();

#4

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.


#5

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.


#6

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:


#7

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.


#8

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:


#9

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::


#10

Cool! Thanks for sharing the script :raised_hands:


#11

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