Created
December 27, 2022 00:43
-
-
Save volovikariel/c4fd7c9f9dd0ee3fff6fae4aca050705 to your computer and use it in GitHub Desktop.
GMail download attachments to drive
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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