How to import training data to Sports Tracker... with RPA

How to import training data to Sports Tracker… with RPA

When you’re trying to be fit and are an engineer, you must collect as much data as you can from your fitness achievements. Training didn’t happen if you don’t have the data. At least for me this stands. So I have the sports watch and gear and training data synced to several services.

But there’s one fitness tracking data service (Sports Tracker) that don’t have direct integrations to upload data from other services. Nobody wants to fill in training data by hand nowadays. So what to do?

RPA to the rescue

So I already had been playing with Polar Accesslink API and pulling my training data with Logic Apps. Now how do I import data to Sports Tracker when there’s no API for it? (Actually there is API from Suunto but it’s not for personal use).

With RPA (robotic process automation) it is possible to interact with e.g. browser programmatically. So I could create a RPA workflow that logins to my Sports Tracker account and imports a gpx file. Now how do I do that fully automated and with minimum effort?

Some vigorous googling and I stumbled on Anthony Chu’s blog post where he described how to run headless Chromium with Azure Functions using Puppeteer for RPA. Now there’s the solution to my problem.

Preparations

As I mentioned before, I already had the means to pull the training data from Polar account. Polar exports trainings as tcx-files and Sports Tracker imports gpx-files. I already had made xsl-transformation to create gpx from tcx, so that was sorted out too.

RPA process

I traced the process that I wanted to achieve:

  1. Open browser
  2. Load Sports Tracker login page
  3. Click cookie consent button
  4. Fill in username and password
  5. Push Login button
  6. Click Add Workout button
  7. Click Import Gpx button
  8. Upload gpx file
  9. Click Save button
  10. Close browser

Next I created new Azure Function project and started noodling around with NodeJS and Puppeteer. I’m terrible with NodeJS and I spent quite a lot of time figuring out how to make my code working. When I got the Sports Tracker login page opened with Puppeteer it was time to figure out the steps to authenticate and import gpx.

So I started investigating Sports Tracker site with Chrome DevTools to figure out textfields and buttons. Bit by bit I worked with my RPA workflow, taking screenshots to see if the process is going as planned. Hardest part was to figure out how to upload the gpx file. Solution was quite simple though, just save the file to some folder that function has access and load it from there with Puppeteers elementHandle.uploadFile method. Finally I got the RPA workflow working for under 100 rows of code, nice. There might be some unnecessary waits in the code, but I wasn’t too keen to streamline the process to the max.

Full function code here:

const puppeteer = require("puppeteer");
const download = require('download');
const path = require('path');
const fs = require('fs');
const uuid = require('uuid');

module.exports = async function (context, req) {

    const username = process.env['stUser'];
    const password = process.env['stPassword'];
    const loginurl = process.env['stLoginUrl'] || "https://www.sports-tracker.com/login";
    const tmpfilepath = process.env['tmpfilepath'] || __dirname+"/files";
    const gpxUrl = req.query.gpxurl;
    const browser = await puppeteer.launch();
    const uploadStatus = 1;

    try {

        const page = await browser.newPage();
        await page.goto(loginurl, {waitUntil: 'networkidle2'});

        console.info('browser opened');

        await page.click('#onetrust-accept-btn-handler');

        console.info('cookies clicked');

        await page.waitForSelector('input[id=username]');
        await page.$eval('input[id=username]', (el, username) => {
            return el.value = username;
        }, username);

        await page.waitForSelector('input[id=password]');
        await page.$eval('input[id=password]', (el, password) => {
            return el.value = password;
        }, password);

        console.log('login filled');

        await page.waitForTimeout(2000);
        await page.click('.submit');
        await page.waitForTimeout(2000);

        console.info('logged in');

        await page.click('.add-workout');
        await page.waitForTimeout(2000);

        await page.waitForSelector('.import-button');
        await page.click('.import-button');
        await page.waitForTimeout(2000);

        var tmpfilename = uuid.v4();
        const filepath = path.join(tmpfilepath, tmpfilename+'.gpx');
        download(gpxUrl).pipe(fs.createWriteStream(filepath));
        await page.waitForTimeout(10000);

        console.info('exercise downloaded');

        const filename = path.basename(gpxUrl)
        console.info(filename);

        const fileExists = fs.existsSync(filepath);

        if(fileExists) {
            await page.waitForSelector('input[type=file]');
            await page.waitForTimeout(1000);

            const fileDropHandle = await page.$('input[type=file]');
            await fileDropHandle.uploadFile(filepath);

            await page.waitForTimeout(10000);

            console.info('gpx uploaded');

            await page.waitForSelector('button.save-button');
            let savebtn = 'button.save-button';
            await page.evaluate((savebtn) => document.querySelector(savebtn).click(), savebtn);
            await page.waitForTimeout(5000);

            console.info('exercise saved');

            fs.unlinkSync(filepath)
        }

    }
    catch(e) {
        console.error(e.message);
        uploadStatus = 0;
    }

    await browser.close();

    context.res = {
        body: {
            "status": uploadStatus
        },
        headers: {
            "content-type": "application/json"
        }
    };
};

Putting it to practise

To finish things up I deployed my RPA function to Azure, configured the secrets, other config items and modified the Logic App pulling training data from Polar to use the RPA function. Next step was to put on my running shoes and go to get some data to test. Few minor tweaks and the function has worked flawlessly since.

Final thoughts

What surprised me is how easy it was to wind up a RPA environment with Puppeteer and get it running in Azure Function. Have to keep this thing in active memory, since this might come handy in some obscure integration situation.

And if you’re interested the function project can be found in my github for you to use freely: sportstracker-gpx-uploader


comments powered by Disqus