sparkletown/sparkle

View on GitHub
reporting/fetch-reports.ts

Summary

Maintainability
A
0 mins
Test Coverage
#!/usr/bin/env node -r esm -r ts-node/register

// @debt replace this with the below line when we require node 14+
//   https://nodejs.org/docs/latest-v14.x/api/fs.html#fs_promise_example
// import { readFile, writeFile } from "fs/promises";
import { promises as fsPromises } from "fs";

import puppeteer, { Page } from "puppeteer";

import { makeScriptUsage } from "../scripts/lib/helpers";

const { readFile, writeFile } = fsPromises;

// ---------------------------------------------------------
// Configuration (this is the bit you should edit)
// ---------------------------------------------------------

// Set to the dates one day before and one day after the day of reports to extract (MM/DD/YYYY)
const from = "10/05/2020";
const to = "10/08/2020";

// Zoom has a captcha, so save cookies to avoid logging in too many times.
// Set this to true to log in and save cookies.
// Set to false if cookies are already available.
const newLogin = true;

// The correct URL for accessing the reports is different for admin users who can
// access reports for all accounts under the zoom account. If you are trying to get
// reports from a standalone, non-admin user, set this to false.
const isAdmin = true;

// If the process crashes halfway through, set this to the page it was on to skip
// some pages - and hopefully avoid another crash.
const resumeFromPage = 1;

// If this is set, then the script will attempt to enable the 'Add tracking field to columns -> Event'
// column and then only download .csv reports that match the string defined here.
const desiredEventTrackingFieldValue: string | undefined = undefined;

// Login credentials (only needed if newLogin is true)
const username: string = "";
const password: string = "";

// ---------------------------------------------------------
// HERE THERE BE DRAGONS (edit below here at your own risk)
// ---------------------------------------------------------

const reportPageUrl = isAdmin
  ? `https://zoom.us/account/report/user?from=${from}&to=${to}`
  : `https://zoom.us/account/my/report?from=${from}&to=${to}`;

const loginEmailFieldSelector = "#login-form #email";
const loginPasswordFieldSelector = "#login-form #password";
const loginButtonSelector =
  "#login-form > .form-group > .controls > .signin > .btn";

const numberOfReportsTotalSelector = "#meetingList span[name=totalRecords]";
const addTrackingFieldToColumnsButtonSelector =
  "#meetingList #trackfieldDropdownMenu > button";
const addEventTrackingFieldColumnCheckboxSelector =
  "#meetingList #trackfieldDropdownMenu label[alt=Event] > input[type=checkbox]";
const nextPageLinkSelector =
  "#meetingList > #paginationDivMeeting > div > ul > li:nth-child(2) > a";
const nextPageLinkDisabledSelector =
  "#meetingList > #paginationDivMeeting > div > ul > li:nth-child(2).disabled > a";

const meetingListTableRowsSelector = "#meeting_list > tbody > tr";

const modalExportWithMeetingDataSelector =
  ".modal-dialog #contentDiv #withMeetingHeaderDiv input#withMeetingHeader";
const modalExportButtonSelector =
  ".modal-dialog #contentDiv button#btnExportParticipants";
const modalMeetingInfoSelector = ".modal-dialog #contentDiv #meetingInfo";

const COOKIES_PATH = "./cookies.json";

const CONFIRM_VALUE = "i-have-edited-the-script-config-and-am-sure";

const usage = makeScriptUsage({
  description:
    "Fetch zoom usage reports from the admin console using a headless Chrome browser via puppeteer",
  usageParams: CONFIRM_VALUE,
  exampleParams: CONFIRM_VALUE,
});

const [confirmationCheck] = process.argv.slice(2);
if (confirmationCheck !== CONFIRM_VALUE) {
  usage();
}

if (newLogin && username === "" && password === "") {
  console.error("Error: username/password are required when newLogin=true.");
  process.exit(1);
}

const keypress = async () => {
  process.stdin.setRawMode(true);
  return new Promise((resolve) =>
    process.stdin.once("data", (data) => {
      process.stdin.setRawMode(false);
      resolve(data);
    })
  );
};

const makeHandleLogin = (page: Page) => async (
  username: string,
  password: string
) => {
  // Enter email address
  await page.waitForSelector(loginEmailFieldSelector);
  await page.type(loginEmailFieldSelector, username);

  // Enter password
  await page.type(loginPasswordFieldSelector, password);

  // Click login
  await page.waitForSelector(loginButtonSelector);
  await page.click(loginButtonSelector);

  // Note: the user may have to solve a captcha at this point, so don't timeout while they are doing so
  await page.waitForNavigation({ timeout: 0 });
};

const makeGetNumberOfReportsTotal = (
  page: Page
) => async (): Promise<string> => {
  await page.waitForSelector(numberOfReportsTotalSelector, {
    visible: true,
  });

  const numberOfReportsTotal = await page.$eval(
    numberOfReportsTotalSelector,
    (el) => el.innerHTML
  );
  console.log(`Number of reports total: ${numberOfReportsTotal}`);

  return numberOfReportsTotal;
};

// Log in to zoom, and download all participants reports from the above selected dates.
(async () => {
  const browser = await puppeteer.launch({ headless: false });
  const page = await browser.newPage();

  await page.setViewport({ width: 1400, height: 900 });

  // Setup our helpers
  const handleLogin = makeHandleLogin(page);
  const getNumberOfReportsTotal = makeGetNumberOfReportsTotal(page);

  const waitForVisibleSelector = async (selector: string) =>
    page.waitForSelector(selector, {
      visible: true,
    });

  const navigationPromise = page.waitForNavigation();

  if (!newLogin) {
    console.log("Loading cookies");
    const cookiesString = await readFile(COOKIES_PATH).then((buffer) =>
      buffer.toString()
    );

    const cookies = JSON.parse(cookiesString);
    await page.setCookie(...cookies);
  }

  console.log("Loading reports page");
  await page.goto(reportPageUrl);

  if (newLogin) {
    await handleLogin(username, password);
  }

  console.log("Press any key to continue...");
  await keypress();

  if (newLogin) {
    console.log("Saving cookies");
    const cookies = await page.cookies();
    await writeFile(COOKIES_PATH, JSON.stringify(cookies, null, 2));
  }

  console.log("Beginning export");

  await getNumberOfReportsTotal();

  let onLastPageAndExportedAll = false;
  let pageNum = 1;
  let reportsDownloaded = 0;
  while (!onLastPageAndExportedAll) {
    await page.waitForSelector(meetingListTableRowsSelector);

    const numberOfReportsOnPage = (await page.$$(meetingListTableRowsSelector))
      .length;
    console.log(
      `Number of reports on page ${pageNum}: ${numberOfReportsOnPage}`
    );

    await page.waitFor(2000);

    if (pageNum >= resumeFromPage) {
      let i = 1;
      let shouldDownloadCsv = true;

      if (desiredEventTrackingFieldValue) {
        // Click the 'Add tracking field to columns' button
        await waitForVisibleSelector(addTrackingFieldToColumnsButtonSelector);
        await page.click(addTrackingFieldToColumnsButtonSelector);

        // Select the 'Event' column
        await waitForVisibleSelector(
          addEventTrackingFieldColumnCheckboxSelector
        );
        await page.click(addEventTrackingFieldColumnCheckboxSelector);
      }

      while (i <= numberOfReportsOnPage) {
        console.log(`Page ${pageNum}: row ${i}...`);

        await waitForVisibleSelector(meetingListTableRowsSelector);

        if (desiredEventTrackingFieldValue) {
          // @debt use the id from the 'tracking field column checkbox' to match the correct td[data-column="XXX"] value
          const eventFieldColumnSelector = `#meeting_list > tbody > tr:nth-child(${i}) > td.trackfieldcol`;

          await waitForVisibleSelector(eventFieldColumnSelector);
          const event = await page.$eval(
            eventFieldColumnSelector,
            (el) => el.innerHTML
          );

          // Only download the CSV if the event matches what we want
          shouldDownloadCsv =
            event.trim() === desiredEventTrackingFieldValue.trim();

          console.log(
            "  Event:",
            event,
            `(matchesDesiredEventTrackingField=${shouldDownloadCsv})`
          );
        }

        if (shouldDownloadCsv) {
          console.log(`  Exporting report...`);

          // Click the download link on this row
          const participantsColumnLinkSelector = `#meeting_list > tbody > tr:nth-child(${i}) > .col6 > a`;
          await waitForVisibleSelector(participantsColumnLinkSelector);
          await page.click(participantsColumnLinkSelector);

          // Select the 'export with meeting data' checkbox on the modal
          await waitForVisibleSelector(modalExportWithMeetingDataSelector);
          await page.click(modalExportWithMeetingDataSelector);
          await waitForVisibleSelector(modalMeetingInfoSelector);

          // Click the export button
          await waitForVisibleSelector(modalExportButtonSelector);
          await page.click(modalExportButtonSelector);

          // ?Confirm the file download?
          await page.mouse.click(10, 10);

          await page.waitFor(1000);

          reportsDownloaded += 1;
        }

        i += 1;
      }
    }

    console.log("Moving on to next page...");

    await page.waitForSelector(nextPageLinkSelector);

    await page.click(nextPageLinkSelector);

    await navigationPromise;

    const nextButtonDisabled =
      (await page.$(nextPageLinkDisabledSelector)) !== null;

    onLastPageAndExportedAll = nextButtonDisabled;

    if (onLastPageAndExportedAll) {
      console.log("Looks like that was the last page!");
    }

    pageNum += 1;
  }

  console.log("Reports Downloaded:", reportsDownloaded);

  console.log("Press any key to continue...");
  await keypress();

  await browser.close();
})()
  .catch((error) => {
    console.error(error);
    process.exit(1);
  })
  .finally(() => {
    console.log("Finished");
    process.exit(0);
  });