codexus / VelibRideExtract

// ==UserScript==
// @name         VelibRideExtract
// @namespace    https://openuserjs.org//users/codexus
// @version      0.1
// @description  Extract your Velib rides into CSV
// @license      GPL-3.0-or-later
// @author       codexus
// @match        https://www.velib-metropole.fr/fr/private/account*
// @grant        GM_notification
// @grant        window.focus
// @grant        document
// @copyright    2018, codexus (https://openuserjs.org//users/codexus)
// ==/UserScript==

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms))
};

function to_csv({
  list,
  order = null,
  sep = '\t'
}) {
  order = order || Object.keys(list[0]);
  let date_replacer = x => (x) ? x.toISOString().replace('T', ' ').replace(/\.[0-9]{3}Z/g, '') : '';
  let csv = [order.join(sep)];
  for (let o of list) {
    let row = [];
    for (let k of order) {
      if (k.toLowerCase().includes("date")) row.push(date_replacer(o[k]))
      else row.push(o[k])
    };
    csv.push(row.join(sep))
  }
  return csv.join('\n')
}

function write_file({
  name,
  content
}) {
  let a = document.createElement("a");
  a.href = `data:text,${content}`; //content
  a.download = name; //file name
  a.click();
}

function flatten(data) {
  var result = {};

  function recurse(cur, prop) {
    if (Object(cur) !== cur) {
      result[prop] = cur;
    }
    else if (Array.isArray(cur)) {
      for (var i = 0, l = cur.length; i < l; i++)
        recurse(cur[i], prop + "[" + i + "]");
      if (l == 0)
        result[prop] = [];
    }
    else {
      var isEmpty = true;
      for (var p in cur) {
        isEmpty = false;
        recurse(cur[p], prop ? prop + "." + p : p);
      }
      if (isEmpty && prop)
        result[prop] = {};
    }
  }
  recurse(data, "");
  return result;
}

function notify({
  text,
  title,
  timeout,
  onclick,
  image
}) {
  GM_notification({
    text: text,
    title: 'Velib Extract' || title,
    timeout: 3000 || timeout,
    image: 'https://i.stack.imgur.com/geLPT.png' || image,
    onclick: function () {
      console.log("Notice clicked.");
      window.focus();
    } || onclick
  })
}

function clean_ride(ride) {
  ride = flatten(ride)
  for (let key in ride) {
    if (key.toLowerCase().includes('date') && ride[key]) {
      try {
        let d = new Date(ride[key]);
        if (!isNaN(d)) ride[key] = d;
      }
      catch (e) {}
    }
  }
  return ride
}
async function fetch_page({
  limit,
  offset
}) {
  notify({
    text: `Downloading items #${offset}-${limit+offset} (excl.)`
  })
  try {
    let url = `https://www.velib-metropole.fr/webapi/private/getCourseList?limit=${limit}&offset=${offset}`;
    let data = await fetch(url).then(r => r.json());
    if (data.actionStatus.status == 'SUCCESS') return data
    else throw new Error(`Error in downloading page ${limit} ${offset}`);
  }
  catch (err) {
    console.log(err);
    return {}
  };
}

async function fetch_all({
  limit = 10,
  wait = 500,
  max = 0
}) {
  let p1 = await fetch_page({
      limit: limit,
      offset: 0
    }),
    n_rides = p1.paging.totalNumberOfRecords,
    n_pages = Math.ceil(n_rides / limit),
    all_pages = [p1];
  for (let i = 1; i <= Math.min(max, n_pages); i++) {
    all_pages.push(
      sleep(wait * i)
      .then(() => fetch_page({
        limit: limit,
        offset: limit * i
      }))
    );
  }
  let data = await Promise.all(all_pages)
    .then(list => [].concat.apply([],
        list.map(r => r.walletOperations || []))
      .map(clean_ride)
    );
  notify({
    text: `Fetched ${data.length} rides`
  })
  let csv = to_csv({
    list: data,
    sep: ";"
  })
  write_file({
    name: "mes_trajets.csv",
    content: csv
  })
};

fetch_all({
  limit: 100,
  max: 40
});