Generate Javascript to Click Webpage Element (WIP)

Position your mouse over a webpage element and trigger this macro.

It will generate Javascript that will do something with the element and copy it to the clipboard. It will then give you the option to test it out.

If it works as expected, you can then paste it into an Execute a JavaScript in Front Browser action for use in your macros.

Generate JavaScript for Web Element Under Mouse.kmmacros (75 KB)

Macro screenshot

Many thanks to @JuanWayri and @ComplexPoint for their contributions towards the final macro.

ezgif-17b7d811e1306c

5 Likes

Thanks so much for sharing this macro, @noisneil! I love it!

For anyone else who found this macro helpful, here are some changes I experimented with today (v1.5):

href

href

I updated the JavaScript so that the href attribute matches exactly what’s in the element, ensuring that the protocol, domain, and subdomain aren’t included when it's a relative URL.

From:

        return `${selector}[href="${element.href}"]`;

To:

        return `${selector}[href="${element.getAttribute('href')}"]`;
:nth-child

:nth-child

I also fixed an issue in the nth-child calculation. It was filtering siblings by tagName AND className, then using that filtered array's index for nth-child. However, nth-child counts ALL child elements regardless of type or class, but only matches if the element at that position matches the selector. It now use Array.from(parent.children) to get ALL child elements and find the element's position among them. This gives the correct nth-child index since nth-child counts all element siblings.

I added 'data-testid' and 'aria-label' as additional unique attributes for matching.

JavaScript with nth-child fix and some extra attributes

(function() {
    function generateUniqueSelector(element) {
        // If element has a unique ID, use it
        if (element.id) {
            return `#${element.id}`;
        }
        
        let selector = element.tagName.toLowerCase();
        
        // Add classes if they exist
        if (element.className && typeof element.className === 'string') {
            const classes = element.className.trim().split(/\s+/).filter(cls => cls);
            if (classes.length > 0) {
                selector += '.' + classes.join('.');
            }
        }
        
        // Add attributes that might make it unique (href, data attributes, etc.)
                
        // Check other unique attributes
        const uniqueAttrs = ['data-id', 'data-testid', 'aria-label', 'data-attachment-id', 'title', 'alt'];
        for (const attr of uniqueAttrs) {
            if (element.hasAttribute(attr)) {
                return `${selector}[${attr}="${element.getAttribute(attr)}"]`;
            }
        }
        
        if (element.href) {
            // For links, we can use href attribute
            return `${selector}[href="${element.getAttribute('href')}"]`;
        }
           
        
        // If still not unique, use nth-child
        const parent = element.parentElement;
        if (parent) {
            // Get all children of the parent (not filtered by tag or class)
            const allSiblings = Array.from(parent.children);
            
            // Find the position of this element among ALL siblings
            const nthChildIndex = allSiblings.indexOf(element) + 1;
            
            // Check if adding nth-child makes it unique
            const selectorWithNthChild = `${selector}:nth-child(${nthChildIndex})`;
            
            // First check if the current selector alone is unique at this level
            const currentLevelMatches = Array.from(parent.children).filter(child => {
                return child.matches(selector);
            });
            
            if (currentLevelMatches.length > 1) {
                selector = selectorWithNthChild;
            }
            
            // If still not unique in the entire document, build path upwards
            if (document.querySelectorAll(selector).length > 1) {
                return generateUniqueSelector(parent) + ' > ' + selector;
            }
        }
        
        return selector;
    }
    
    const hoveredElements = document.querySelectorAll(':hover');
    const element = hoveredElements[hoveredElements.length - 1];
    if (element) {
        return generateUniqueSelector(element);
    } else {
        return "No element found under cursor";
    }
})();

Additional Optimizations

The version below is further optimized: it minimizes class and attribute usage and evaluates uniqueness at each step through various selector combinations.

JavaScript with further OPTIMIZATIONS v1.4
/**
 * @author JuanWayri
 * @version 1.4
 * @URL: https://forum.keyboardmaestro.com/t/generate-javascript-to-click-webpage-element-wip/41465/2?u=juanwayri
 */

const preferredAttrs = ['data-testid', 'data-id', 'data-cy', 'aria-label'];
const otherAttrs = ['href','name', 'role', 'title', 'type'];

/**
 * Checks if a selector is unique and points to the correct target element.
 */
function isUnique(selector, targetElement) {
    try {
        const elements = document.querySelectorAll(selector);
        return elements.length === 1 && elements[0] === targetElement;
    } catch (e) {
        return false;
    }
}

/**
 * Generates a list of candidate selectors for a single element, ordered by preference.
 */
function getComponentCandidates(el, options = {}) {
    const tagName = el.tagName.toLowerCase();
    const candidates = [];

    // IDs (unless excluded)
    if (!options.excludeIds && el.id) {
        candidates.push(`#${el.id}`);
        candidates.push(`${tagName}#${el.id}`);
    }

    // Preferred attributes (unless excluded)
    if (!options.excludePreferredAttrs) {
        for (const attr of preferredAttrs) {
            if (el.hasAttribute(attr)) {
                candidates.push(`${tagName}[${attr}="${el.getAttribute(attr)}"]`);
            }
        }
    }

    // Classes
    const classes = Array.from(el.classList).map(cls => `.${cls}`);
    if (classes.length > 0) {
        candidates.push(...classes.map(cls => tagName + cls));
        if (classes.length > 1) {
            candidates.push(tagName + classes.join(''));
        }
    }

    // Other attributes (unless excluded)
    if (!options.excludeOtherAttrs) {
        for (const attr of otherAttrs) {
            if (el.hasAttribute(attr)) {
                candidates.push(`${tagName}[${attr}="${el.getAttribute(attr)}"]`);
            }
        }
    }

    candidates.push(tagName);
    return [...new Set(candidates)];
}

/**
 * Gets a stable, fallback selector for an element, typically using :nth-child.
 */
function getStableComponent(el, excludeNthChild = false) {
    const tagName = el.tagName.toLowerCase();
    const parent = el.parentElement;
    if (!parent) return tagName;
    
    const siblings = Array.from(parent.children);
    if (siblings.filter(sibling => sibling.tagName === el.tagName).length === 1) return tagName;
    
    if (excludeNthChild) {
        // Return just the tag name even if it's not unique among siblings
        return tagName;
    }
    
    return `${tagName}:nth-child(${siblings.indexOf(el) + 1})`;
}

/**
 * Takes a known-unique selector and simplifies it through multiple optimization passes.
 */
function simplifySelector(selector, targetElement) {
    const tokens = selector.split(/( > | )/).filter(Boolean);
    const components = tokens.filter((_, i) => i % 2 === 0);

    // PASS 1: Prune intermediate components
    if (components.length > 2) {
        for (let i = components.length - 2; i > 0; i--) {
            const tempTokens = [...tokens];
            tempTokens.splice(i * 2 - 1, 2);
            const testSelector = tempTokens.join('').replace(/ +/g, ' ');

            if (isUnique(testSelector, targetElement)) {
                return simplifySelector(testSelector, targetElement);
            }
        }
    }

    // PASS 2: Soften combinators from '>' to ' '
    for (let i = 0; i < tokens.length; i++) {
        if (tokens[i] === ' > ') {
            const tempTokens = [...tokens];
            tempTokens[i] = ' ';
            const testSelector = tempTokens.join('').replace(/ +/g, ' ');
            if (isUnique(testSelector, targetElement)) {
                return simplifySelector(testSelector, targetElement);
            }
        }
    }

    // PASS 3: Simplify individual components
    for (let i = 0; i < components.length; i++) {
        const part = components[i];
        
        // Remove :nth-child
        if (part.includes(':nth-child')) {
            const simplifiedPart = part.replace(/:nth-child\(\d+\)/, '');
            if (simplifiedPart && simplifiedPart !== part) {
                const tempTokens = [...tokens];
                tempTokens[i * 2] = simplifiedPart;
                if (isUnique(tempTokens.join(''), targetElement)) {
                    return simplifySelector(tempTokens.join(''), targetElement);
                }
            }
        }

        // Remove attributes and classes
        const match = part.match(/^([^.\[:]+)?((?:\.[^.\[:]+)*)((?:\[[^\]]+\])*)((?::.*)*)$/);
        if (!match) continue;

        const [, tagName, classStr, attrStr, pseudoStr] = match;
        const classes = classStr.split('.').filter(Boolean);
        const attributes = (attrStr.match(/\[.*?\]/g) || []);
        
        // Try removing attributes one by one
        if (attributes.length > 0) {
             for (let j = 0; j < attributes.length; j++) {
                 const tempAttrs = [...attributes];
                 tempAttrs.splice(j, 1);
                 const newPart = (tagName || '') + (classes.length > 0 ? '.' + classes.join('.') : '') + tempAttrs.join('') + (pseudoStr || '');
                 const tempTokens = [...tokens];
                 tempTokens[i * 2] = newPart;
                 if (isUnique(tempTokens.join(''), targetElement)) {
                     return simplifySelector(tempTokens.join(''), targetElement);
                 }
             }
        }
        
        // Try removing classes one by one
        if (classes.length > 1) {
             for (let j = 0; j < classes.length; j++) {
                 const tempClasses = [...classes];
                 tempClasses.splice(j, 1);
                 const newPart = (tagName || '') + '.' + tempClasses.join('.') + (attrStr || '') + (pseudoStr || '');
                 const tempTokens = [...tokens];
                 tempTokens[i * 2] = newPart;
                 if (isUnique(tempTokens.join(''), targetElement)) {
                     return simplifySelector(tempTokens.join(''), targetElement);
                 }
             }
        }
    }

    return selector;
}

// Helper functions from @noisneil
function isClickable(el) {
    if (!el) return false;
    const style = window.getComputedStyle(el);
    return ['A', 'BUTTON', 'LABEL', 'INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName) ||
        el.hasAttribute('onclick') ||
        el.hasAttribute('href') ||
        style.cursor === 'pointer' ||
        el.getAttribute('role')?.match(/button|link|tab|checkbox|radio/);
}

function isDecorative(el) {
    if (!el) return false;
    const tag = el.tagName.toLowerCase();
    if (['svg', 'path', 'circle', 'rect', 'g', 'i'].includes(tag)) return true;

    const classList = Array.from(el.classList).map(cls => cls.toLowerCase());
    return classList.some(cls =>
        ['icon', 'svg', 'fa', 'material-icons'].some(keyword => cls.includes(keyword))
    );
}

function getClickableTarget(el) {
    if (!el) return null;

    if (isDecorative(el)) {
        const parent = el.closest('a, button, [role="button"], [onclick], [href]');
        if (parent && isClickable(parent)) return parent;
    }

    if (!isClickable(el)) {
        const parent = el.closest('a, button, [role="button"], [onclick], [href]');
        if (parent && isClickable(parent)) return parent;
    }

    return el;
}

/**
 * Generates a unique selector with specific exclusion options.
 */
function generateUniqueSelector(element, options = {}) {
    if (!element || element.nodeType !== Node.ELEMENT_NODE) return null;
    if (element.tagName === 'BODY') return 'body';
    if (element.tagName === 'HTML') return 'html';

    let path = [];
    let current = element;

    while (current && current.parentElement) {
        const candidates = getComponentCandidates(current, options);
        const suffix = path.length > 0 ? ` > ${path.join(' > ')}` : '';

        for (const candidate of candidates) {
            const testSelector = `${candidate}${suffix}`;
            if (isUnique(testSelector, element)) {
                return simplifySelector(testSelector, element);
            }
        }

        const stablePart = getStableComponent(current, options.excludeNthChild);
        path.unshift(stablePart);
        current = current.parentElement;
    }
    
    return path.join(' > ');
}

/**
 * Generates multiple selector variants and returns them as a comma-separated list.
 */
function generateMultipleSelectors(element) {
  if (!element) {
    return "No element found";
  }

  // Define the different selector generation strategies
  const configurations = [
    {}, // 1. Default selector
    { excludeIds: true }, // 2. Selector excluding IDs
    { excludeOtherAttrs: true }, // 3. Selector excluding "otherAttrs"
    { excludePreferredAttrs: true }, // 4. Selector excluding "preferredAttrs"
    
  ];

  // Use a Set to automatically handle duplicate selectors
  const uniqueSelectors = new Set();

  // Iterate over each configuration to generate a selector
  for (const options of configurations) {
    const selector = generateUniqueSelector(element, options);
    // Add the selector to the Set only if it's a valid, non-empty string
    if (selector) {
      uniqueSelectors.add(selector);
    }
  }

  // Convert the Set back to an array and join into a single string
  return Array.from(uniqueSelectors).join('\n');
}


// --- Execution ---
const hoveredElements = document.querySelectorAll(':hover');
const element = hoveredElements.length > 0 ? hoveredElements[hoveredElements.length - 1] : null;
const target = getClickableTarget(element);

if (target) {
    return generateMultipleSelectors(target);
} else {
    return "No element found under cursor";
}

Generate JavaScript to Click or Extract Data From the Element Under the Mouse

I also added a prompt with a list of unique selectors to choose from, since ID (#) and attribute selectors (like href) aren't always the most convenient option.
Lastly, I added support for additional actions other than "click", such as extracting innerText, getAttribute, and more. This is especially useful when using Keyboard Maestro to gather data from websites for use in other macros and workflows.

Generate JavaScript to Click or Extract Data From the Element Under the Mouse (v1.5).kmmacros (66.4 KB)

Macro Screenshot

5 Likes

Holy moly! My original attempt was a quick-fix response to a request on another thread. I knew it was a bit rudimentary but I'm so glad I posted it now that you've done this incredible work! I think this is going to get a lot of use from KM users. Bravo! :clap:t3::clap:t3::clap:t3::clap:t3:

1 Like

I second that @noisneil - @JuanWayri really did a solid on this macro! I just tested and was faster than a click on image and move over to click script

1 Like

Hi @noisneil,

I'm having problems to create a macro to click on the magnifying glass at the top of this forum pages:

Image-000878

I get this script:

document.querySelector('#search-button > svg').click();

But it doesn't work when I embed it in an action:
Image-000879
Am I doing something wrong here?

Btw, The technique works when I use it for clicking on the magnifying glass at www.proz.com.

Yeah the script still needs some work. The selector you should be using for that element is:

#search-button

Sorry to tell you that this doesn't work either ...

Image-000882

document.querySelector('#search-button').click();

Try this version:

Generate JavaScript For Element Under Mouse.kmmacros (66 KB)

Macro screenshot

1 Like

Thank you. That works.

@JuanWayri, here's another work in progress. I've prettified the interface. Just need to figure out how to run a JS script stored as a text variable. Sure we can use a Switch/Case, but we're better than that! Anyway, here's where I'm up to:

Generate JavaScript (Pretty WIP).kmmacros (76 KB)

Macro screenshot

1 Like

I tend to use this:


(() => {

    const sum2 = new Function("a", "b", "return a + b");
    
    const sum3 = new Function("a", "b", "c", "return a + b + c");

    const sumAny = new Function(
        "return Array.from(arguments).reduce((a, x) => a + x, 0)"
    );
    
    const exNihilo = new Function(
        "return `Any kind of value.`"
    )


    console.log("sum2:", sum2(2, 4));
     
    console.log("sum3:", sum3(2, 4, 8));
    
    console.log("sumAny:", sumAny(1, 2, 3, 4, 5, 6, 7, 8, 9));
    
    console.log(exNihilo())
})()

I'm a bit lost I'm afraid. The prompt outputs a working JS script as Local__FinalScript, as a text string. I need to run that script somehow and return any output.

IIRC, version 1.2 or 1.3 that I posted yesterday addressed that issue by testing the selector and prompting for confirmation if a clicking problem was detected. With that in place, it would have generated the following script:

document.querySelector('#search-button:has(svg.d-icon-search)').click();
1 Like

Starting with an action like:

and choosing Keyboard Maestro > Edit > Copy As > Copy As XML will obtain source (requiring a return keyword before your JS source if the chevron option is for "Modern Syntax".

You can then run the source with (KM Engine).doScript as a JS template with a gap filled, in this kind of pattern:

RUN JS from KM variable in browser.kmmacros (4.4 KB)

2 Likes

Fantastic, thanks!

Here's a working version. I'd prefer a bit of control over how to present the JS result, but switching Window to Variable just broke the xml. Must be something I'm missing.

Generate JavaScript (Pretty WIP).kmmacros (70 KB)

Macro screenshot

2 Likes

I'm still setting up my "new" MacBook Air 15″ and I got this reminder:

You must enable 'Allow JavaScript from Apple Events' in the Developer section of Safari Settings to use 'do JavaScript'.

As a friendly reminder to other users, here's how to do that:


2 Likes

Global vs Local variables may be the issue.

.doScript() invokes its own local context, I think, and can't pass local variable values to a different (outer) context.

A global variable name should work.

2 Likes

In my [v1.4 macro], I added a prompt with a list of unique selector variants to handle cases where default selectors are unhelpful. For example, when #IDs are prioritized but not actually useful (as shown in the screenshot below). @noisneil, maybe you could incorporate something similar?

image

Ugh. Am I being stupid?

const kme = Application("Keyboard Maestro Engine").doScript(
`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<array>
    <dict>
        <key>ActionUID</key>
        <integer>99886502</integer>
        <key>DisplayKind</key>
        <string>Variable</string>
        <key>HonourFailureSettings</key>
        <true/>
        <key>IncludeStdErr</key>
        <false/>
        <key>IncludedVariables</key>
        <array>
            <string>9999</string>
        </array>
        <key>MacroActionType</key>
        <string>ExecuteJavaScript</string>
        <key>Path</key>
        <string></string>
        <key>Text</key>
        <string>return ${kmvar.Local__JSScript}</string>
        <key>TimeOutAbortsMacro</key>
        <true/>
        <key>TrimResults</key>
        <true/>
        <key>TrimResultsNew</key>
        <true/>
        <key>UseModernSyntax</key>
        <true/>
        <key>UseText</key>
        <true/>
        <key>Variable</key>
		<string>DND__JSOutput</string>
    </dict>
</array>
</plist>`)