Reputation: 1665
I'm writing a script to delete all of my YouTube comments. Each line of this code works to delete one comment, but when I put it into a loop, I get an Uncaught TypeError: Cannot read property 'click' of undefined
that doesn't come up when I run each line individually. I'm thinking if I can figure out a way to sleep in between the lines of code I could remove the error.
var myList = document.getElementsByClassName("dropdown-trigger style-scope ytd-menu-renderer");
for(i = 0; i < myList.length; i++) {
myList[i].click();
document.getElementsByClassName("style-scope ytd-menu-navigation-item-renderer")[4].click(); //error here
document.getElementById("confirm-button").click();
}
I tried using setTimeout, like this:
var myList = document.getElementsByClassName("dropdown-trigger style-scope ytd-menu-renderer");
for(i=0; i<myList.length; i++) {
myList[i].click();
setTimeout(function(){document.getElementsByClassName("style-scope ytd-menu-navigation-item-renderer")[4].click();}, 1000);
setTimeout(function(){document.getElementById("confirm-button").click();}, 5000);
}
And it returned a number of 279, but no errors, and no comments deleted. What happened?
Upvotes: 0
Views: 2278
Reputation: 1
all below options are a bit "hackish" - but there's no consistent and easy way to get a notification when a click event has been fully processed. You could add your own click event listener to the targeted element, but then, that can be "muted" by stopPropagation ... even then, there's no (easy) way to know when the DOM has finished "repainting" as a result of the click event - you could look at using MutationObserver, I guess
I assume (never do that) that the element targeted by document.getElementsByClassName("style-scope ytd-menu-navigation-item-renderer")[4]
is dynamically added as a result of clicking myList[i].click();
which is why you have the issue. After myList[i].click();
the DOM won't be "updated" for "some time" - so, document.getElementsByClassName("style-scope ytd-menu-navigation-item-renderer")
could easily fail.
A potential issue is if deleting comments changes the number of elements targeted by
document.getElementsByClassName("dropdown-trigger style-scope ytd-menu-renderer");
- asHTMLCollection
s are "live", removing a DOM element will mutate theHTMLCollection
- only the last two code snippets will be immune to this possibility
So, here are four different ways you can do this
option 1 - ES5, just plain ol' callbacks
var DELAY = 0; // try 0, then try increasing values
var myList = document.getElementsByClassName("dropdown-trigger style-scope ytd-menu-renderer");
function confirmClick(callback3) {
document.getElementById("confirm-button").click();
setTimeout(callback3, DELAY);
}
function itemClick(callback2, callback3) {
document.getElementsByClassName("style-scope ytd-menu-navigation-item-renderer")[4].click();
setTimeout(callback2, DELAY, callback3);
}
function listClick(element, callback1, callback2, callback3) {
element.click();
setTimeout(callback1, DELAY, callback2, callback3);
}
function doOne(i) {
listClick(myList[i], itemClick, confirmClick, function() {
++i;
if (i < myList.length) {
doOne(i);
}
});
}
doOne(0);
As you are trying to "chain" multiple asynchronous (sort of) processes together,
Promise
's (seem) to make the code a little less cumbersome
option 2 - Promise, but with nested setTimeout
's -
uses Array#reduce
to chain the clicks together so they process strictly one after the other
This is ugly, really ugly, lets leave pyramid building to dead Egyptians, included to illustrate what I'm trying to achieve
var DELAY = 0; // try 0, then try increasing values
var myList = document.getElementsByClassName("dropdown-trigger style-scope ytd-menu-renderer");
var p = Promise.resolve();
for(i = 0; i < myList.length; i++) {
myList[i].click();
p = p.then(() => new Promise(resolve) => {
setTimeout(() => {
document.getElementsByClassName("style-scope ytd-menu-navigation-item-renderer")[4].click();
setTimeout(() => {
document.getElementById("confirm-button").click();
setTimeout(resolve, DELAY);
}, DELAY);
}, DELAY);
});
}
option 3 - Promise with a helper function - basically the above code, but added a helper function to prevent the pyramid
const DELAY = 0; // try 0, then try increasing values
const clickThenDelay = element => new Promise(resolve => {
element.click();
setTimeout(resolve, DELAY);
});
var myList = document.getElementsByClassName("dropdown-trigger style-scope ytd-menu-renderer");
Array.from(myList).reduce((p, item) => {
return p
.then(() => clickThenDelay(item))
.then(() => clickThenDelay(document.getElementsByClassName("style-scope ytd-menu-navigation-item-renderer")[4]))
.then(() => clickThenDelay(document.getElementById("confirm-button")))
}, Promise.resolve());
option 4 - similar to option 3, but this one adds a click event handler before firing click, which is removed after click has been "handled".
Presumably the last added handler is called last. Not sure if that is guaranteed, however.
Also, this will not work if an earlier handler calls event.stop[Immediate]Propagation()
const DELAY = 0; // try 0, then try increasing values
const clickThenDelay = element => new Promise(resolve => {
const handleClick = () => {
element.removeEventListener('click', handleClick);
setTimeout(resolve, DELAY); // wait for repaint? Perhaps a MutationObserver event could be useful here?
}
element.addEventListener('click', handleClick);
element.click();
});
var myList = document.getElementsByClassName("dropdown-trigger style-scope ytd-menu-renderer");
Array.from(myList).reduce((p, item) => {
return p
.then(clickThenDelay(item))
.then(() => clickThenDelay(document.getElementsByClassName("style-scope ytd-menu-navigation-item-renderer")[4]))
.then(() => clickThenDelay(document.getElementById("confirm-button")))
}, Promise.resolve());
Upvotes: 1
Reputation: 3716
The culprit is that the page hasn't had time to update the DOM since you executed the .click()
function. You need to give it that time, perhaps with requestAnimationFrame
or the less precise setTimeout
. In the solution below (completely untested, made up on the spot), the progression is to do each action (open the dropdown, click delete, confirm) after a pause to give the DOM time to update. Consider:
function deleteAllComments ( ) {
'use strict';
var commentsToDelete, i;
function openNextCommentOptionsDropdown ( ) {
if ( ! (++i < commentsToDelete.length ) ) {
console.log('No more comments to remove.');
return;
}
commentsToDelete[i].click();
setTimeout(activateCommentDeleteButton, 50);
}
function activateCommentDeleteButton ( ) {
var el = document.getElementsByClassName("style-scope ytd-menu-navigation-item-renderer")[4];
if ( ! el ) {
console.warn('No "Delete" button found, at comment #', i, commentsToDelete[i]);
console.log('Stopping delete operation');
return;
}
el.click();
setTimeout(activateCommentDeleteConfirmationButton, 50);
}
function activateCommentDeleteConfirmationButton ( ) {
var el = document.getElementById("confirm-button");
if ( ! el ) {
console.warn('Unable to confirm comment delete action, at comment #', i, commentsToDelete[i]);
console.log('Stopping delete operation');
return;
}
el.click();
setTimeout(openNextCommentOptionsDropdown, 50); // continue larger "loop"
}
commentsToDelete = document.getElementsByClassName("dropdown-trigger style-scope ytd-menu-renderer");
openNextCommentOptionsDropdown(); // start the "loop"
}
There are two big details here:
Giving the DOM time to update in response to the actions the code has initiated (setTimeout(..., 50)
, where 50ms should be more than enough time; decrease as necessary, or just use requestAnimationFrame
.
Defensive coding: before blindly executing functions on returned items, first check that the items actually exist (if ( ! el ) { ... }
)
Upvotes: 1