Skip to content

Instantly share code, notes, and snippets.

@volovikariel
Created December 27, 2022 00:43
Show Gist options
  • Save volovikariel/c4fd7c9f9dd0ee3fff6fae4aca050705 to your computer and use it in GitHub Desktop.
Save volovikariel/c4fd7c9f9dd0ee3fff6fae4aca050705 to your computer and use it in GitHub Desktop.
GMail download attachments to drive
/**
* Script to paste into GMail to search through an inbox that contains attachments, and download them to your drive if possible.
* Does it in paralell (or concurrently...not too sure which it is...we have different chrome tab, so different threads, so parallel is possible,
* but all the functions being called are in the same file, so it may still be only running concurrently) to speed things up!
*
* Note: Error handling is quite bad, so if an error ever occurs, we simply close all the open pages.
* This is to avoid having a situation where 1 out of 1000 emails has an undownloaded file, good luck tracking that!
* Though frankly, this is probably handle-able through some logging + some retries...not too sure.
*
* Note 2: Maybe not a good idea to actually use this, probably goes against some anti-botting TOS.
* This is purely for educational purposes.
*
* Tweak @param {numMaxWindowsPerPage} to specify how many windows should be opened per inbox page, defaults to 1
* vvvvvvvvvvvvvvvvvvvvvvvvvvvv
*/
const numMaxWindowsPerPage = 1;
const olderEmailButtonSelector = '[role="button"][data-tooltip="Older"]'
const emailMessageSelector = '.adn.ads';
const downloadAllToDriveStartedIndicatorSelected = 'div[role="alertdialog"]';
const emailElementsSelector = 'div[role="main"] table[role="grid"] tr'
const openedWindows = [];
console.log('Link to script >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>');
class PromiseRejectionError extends Error {
constructor(message) {
super(message);
this.name = 'PromiseRejectionError';
}
}
function isClickable(element) {
return element && (!element.hasAttribute('aria-disabled') || element.getAttribute('aria-disabled') === 'false');
}
// Some buttons don't trigger with a simple .click() even in GMail, requiring this...thing
function clickElement(element) {
['mouseover', 'mousedown', 'mouseup', 'click'].forEach(event => element.dispatchEvent(new MouseEvent(event)));
}
function wait(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Wait for an element that to be present in the DOM (for querySelector to return a result)
// If the element is present, return it
// If it isn't present after #ms provided, Promise.reject
async function waitForElement(selector, windowHandler, maxwait = 10_000) {
const element = windowHandler.document.querySelector(selector);
if (element) {
return element;
}
let observer;
let timeout;
return Promise.race([
new Promise((_, reject) => {
timeout = setTimeout(() => reject(new PromiseRejectionError(`Element with selector "${selector}" were not found in "${windowHandler.name}" after ${maxwait}ms`)), maxwait);
}),
new Promise((resolve) => {
observer = new MutationObserver(async () => {
const element = windowHandler.document.querySelector(selector);
if (element) {
resolve(element);
}
});
observer.observe(windowHandler.document.body, {
childList: true,
subtree: true,
});
}),
]).finally(() => {
// Make sure that the references are defined before clearing/disconnecting
if (observer) {
observer.disconnect();
}
if (timeout) {
clearTimeout(timeout);
}
});
}
// Wait for elements to be present in the DOM (for querySelectorAll to return a non-empty result)
// If elements are present, return them
// If none are present after #ms provided, Promise.reject
async function waitForElements(selector, windowHandler, maxwait = 10_000) {
const elements = windowHandler.document.querySelectorAll(selector);
if (elements && elements.length > 0) {
return elements;
}
let observer;
let timeout;
return Promise.race([
new Promise((_, reject) => {
timeout = setTimeout(() => reject(new PromiseRejectionError(`Elements with selector "${selector}" were not found in "${windowHandler.name}" after ${maxwait}ms`)), maxwait);
}),
new Promise((resolve) => {
observer = new MutationObserver(async () => {
const elements = windowHandler.document.querySelectorAll(selector);
if (elements && elements.length > 0) {
resolve(elements);
}
});
observer.observe(windowHandler.document.body, {
childList: true,
subtree: true,
});
}),
]).finally(() => {
// Make sure that the references are defined before clearing/disconnecting
if (observer) {
observer.disconnect();
}
if (timeout) {
clearTimeout(timeout);
}
});
}
// Wait for an element that to be present in the DOM (for querySelector to return a result)
// If the element is present, return it
// If it isn't present after #ms provided, Promise.reject
async function waitForElementDeath(selector, windowHandler, maxwait = 10_000) {
const elements = windowHandler.document.querySelector(selector);
if (!elements) {
return true;
}
let observer;
let timeout;
return Promise.race([
new Promise((_, reject) => {
timeout = setTimeout(() => reject(new PromiseRejectionError(`Element(s) with selector "${selector}" did not disappear in "${windowHandler.name}" after ${maxwait}ms`)), maxwait);
}),
new Promise((resolve) => {
observer = new MutationObserver(async () => {
const elements = windowHandler.document.querySelector(selector);
if (!elements) {
resolve(true);
}
});
observer.observe(windowHandler.document.body, {
childList: true,
subtree: true,
});
}),
]).finally(() => {
// Make sure that the references are defined before clearing/disconnecting
if (observer) {
observer.disconnect();
}
if (timeout) {
clearTimeout(timeout);
}
});
}
async function getEmailElementsIfExist(windowHandler, startIdx, endIdx) {
const emailElements = await waitForElements(emailElementsSelector, windowHandler, 30_000);
return [...emailElements].slice(startIdx, endIdx);
}
/**
* @param {*} startIdx Start (Inclusive) index of the email to be processed in all the emails on the given pageNum
* @param {*} endIdx End (Exclusive) index of the emails to be processed
*/
async function processEmails(url, startIdx, endIdx, pageNum) {
const childWindowName = `${pageNum} [${startIdx}-${endIdx})`
const childWindow = window.open(url, childWindowName);
if (childWindow == null) {
throw new Error('Disable popup blocker to run script');
}
openedWindows.push(childWindow);
childWindow.addEventListener('DOMContentLoaded', async () => {
const emailElements = await getEmailElementsIfExist(childWindow, startIdx, endIdx);
let lastDataMessageId;
for (const emailElement of emailElements) {
// Click the email element...
emailElement.click()
// Wait for the previous email to disappear...
// Note that if lastDataMessageId is undefined, it works as well
await waitForElementDeath(`.adn.ads[data-message-id="${lastDataMessageId}"]`, childWindow);
// Mark the new email message as the last one (for future comparisons)
const emailMessage = await waitForElement(emailMessageSelector, childWindow);
lastDataMessageId = emailMessage.getAttribute('data-message-id');
const downloadAllToDriveElements = [...childWindow.document.querySelectorAll('[aria-label="Add all to Drive"]')];
await Promise.all(
downloadAllToDriveElements.map(downloadAllToDriveElement =>
waitForElement(`div[id="${downloadAllToDriveElement.id}"][aria-label="Add all to Drive"][aria-disabled="false"]`, childWindow, 2_000)
.then(_ => {
// Click the download all to drive element
clickElement(downloadAllToDriveElement);
// Wait for a popup that indicates the download started
return waitForElement(`div[id="${downloadAllToDriveElement.id}"] + ${downloadAllToDriveStartedIndicatorSelected}`, childWindow);
})
.then(_ => {
// Wait for the indicator to disappear
return waitForElementDeath(`div[id="${element.id}"] + ${downloadAllToDriveStartedIndicatorSelected}`, childWindow)
})
.catch((error) => {
if (error instanceof PromiseRejectionError) {
// If no download all to drive element is disabled, that's okay
// If no animation is played...that's a problem, not sure how to handle that yet though.
} else {
console.log("Received error that wasn't a PromiseRejectionError while waiting on downloadAllToDriveElement related promises");
throw error;
}
})
)
)
}
console.log(`Finished processing "${childWindowName}"`)
childWindow.close();
}, false);
}
(async () => {
let haveMoreEmails;
let pageNum = 0;
try {
do {
pageNum += 1;
// window.location.href's value changes as the page changes, to keep track of the previous page URL, we create a string out of it
const currentPageURL = window.location.href + "";
const emailElements = await getEmailElementsIfExist(window);
const numEmails = emailElements.length;
const numDivisions = Math.min(numMaxWindowsPerPage, numEmails);
const divisionSize = Math.floor(numEmails / numDivisions);
const remainder = numEmails % divisionSize;
console.log(`Opening ${numEmails} emails on page#${pageNum}`);
const processEmailTasks = []
for (let divisionNum = 0; divisionNum < numDivisions; ++divisionNum) {
const startIdx = divisionNum * divisionSize;
const endIdx = (divisionNum + 1) * divisionSize
processEmailTasks.push(processEmails(currentPageURL, startIdx, endIdx, pageNum));
}
if (remainder !== 0) {
const startIdx = numEmails - remainder;
const endIdx = numEmails;
processEmailTasks.push(processEmails(currentPageURL, startIdx, endIdx, pageNum));
}
Promise.all(processEmailTasks);
const olderEmailButton = await waitForElement(olderEmailButtonSelector, window);
// The button is grayed out/unclickable when there is no next page
haveMoreEmails = isClickable(olderEmailButton)
if (haveMoreEmails) {
clickElement(olderEmailButton);
await waitForElementDeath(`[id="${olderEmailButton.id}"]${olderEmailButtonSelector}`, window)
}
} while (haveMoreEmails);
} catch (error) {
// Force close all the windows if an error is thrown
openedWindows.forEach(window => window.close());
throw error;
}
})()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment