35

I got this function to get a cssPath :

var cssPath = function (el) {
  var path = [];

  while (
    (el.nodeName.toLowerCase() != 'html') && 
    (el = el.parentNode) &&
    path.unshift(el.nodeName.toLowerCase() + 
      (el.id ? '#' + el.id : '') + 
      (el.className ? '.' + el.className.replace(/\s+/g, ".") : ''))
  );
  return path.join(" > ");
}
console.log(cssPath(document.getElementsByTagName('a')[123]));

But i got something like this :

html > body > div#div-id > div.site > div.clearfix > ul.choices > li

But to be totally right, it should look like this :

html > body > div#div-id > div.site:nth-child(1) > div.clearfix > ul.choices > li:nth-child(5)

Did someone have any idea to implement it simply in javascript ?

jney
  • 1,862
  • 2
  • 17
  • 21
  • 2
    It should probably be `:eq(1)` or `:nth-child(2)` rather than `[1]` if you want a CSS selector. – Andy E Sep 01 '10 at 16:26
  • Or just give the element an unique ID with JavaScript? I can see why cssPath might be useful as a FireBug plugin or something, but for regular code, introducing ID's is the most effective. – BGerrissen Sep 01 '10 at 16:59
  • In fact, I do believe there's a FireBug plugin that gets a cssPath from an element called FireFinder ;oP – BGerrissen Sep 01 '10 at 17:01
  • Yes you're right Andy. This syntax looks like a bad mix between CSS selector and XPath. I should fix it. – jney Sep 01 '10 at 21:45
  • 1
    Possible duplicate of [Get element's CSS selector (when it doesn't have an id)](http://stackoverflow.com/questions/4588119/get-elements-css-selector-when-it-doesnt-have-an-id) – msangel Dec 29 '15 at 13:25
  • 1
    [simmer.js](https://github.com/gmmorris/simmerjs) looks like a good lib for that purpose. – Tom Pohl Oct 12 '20 at 14:01

8 Answers8

41

The answer above actually has a bug in it — the while loop breaks prematurely when it encounters a non-element node (e.g. a text node) resulting in an incorrect CSS selector.

Here's an improved version that fixes that problem plus:

  • Stops when it encounters the first ancestor element with an id assigned to it
  • Uses nth-of-type() to make the selectors more readable
    var cssPath = function(el) {
        if (!(el instanceof Element)) 
            return;
        var path = [];
        while (el.nodeType === Node.ELEMENT_NODE) {
            var selector = el.nodeName.toLowerCase();
            if (el.id) {
                selector += '#' + el.id;
                path.unshift(selector);
                break;
            } else {
                var sib = el, nth = 1;
                while (sib = sib.previousElementSibling) {
                    if (sib.nodeName.toLowerCase() == selector)
                       nth++;
                }
                if (nth != 1)
                    selector += ":nth-of-type("+nth+")";
            }
            path.unshift(selector);
            el = el.parentNode;
        }
        return path.join(" > ");
     }
Eric
  • 6,563
  • 5
  • 42
  • 66
asselin
  • 1,831
  • 2
  • 13
  • 18
  • `:nth-of-type()` works differently from `:nth-child()` - sometimes it isn't a simple matter of replacing one with the other. – BoltClock Aug 31 '12 at 21:30
  • 3
    `if (nth != 1)` is not good, to have an ultra-specific path you should always use child even if it is 1. – Sych Jan 26 '13 at 04:48
  • @Sych, why? Seems to work fine and adding `nth-of-type` to 'html' would not work for example. – WispyCloud Sep 21 '13 at 07:47
  • 3
    @jtblin, because, for example, `.container span` would catch all span's inside `.container`, but `.container span:nth-of-type(1)` would catch only the first one, and this is probably the intended behavior. – Sych Oct 25 '13 at 21:05
  • 5
    Istead of: `if (nth != 1)` we can use: `if (el.previousElementSibling != null || el.nextElementSibling != null)`. It will be then capable of adding `nth-of-type(1)`, if element is the first element in the set but won't add it if it's the only one. – kremuwa May 28 '15 at 15:31
20

To always get the right element, you will need to use :nth-child() or :nth-of-type() for selectors that do not uniquely identify an element. So try this:

var cssPath = function(el) {
    if (!(el instanceof Element)) return;
    var path = [];
    while (el.nodeType === Node.ELEMENT_NODE) {
        var selector = el.nodeName.toLowerCase();
        if (el.id) {
            selector += '#' + el.id;
        } else {
            var sib = el, nth = 1;
            while (sib.nodeType === Node.ELEMENT_NODE && (sib = sib.previousSibling) && nth++);
            selector += ":nth-child("+nth+")";
        }
        path.unshift(selector);
        el = el.parentNode;
    }
    return path.join(" > ");
}

You could add a routine to check for unique elements in their corresponding context (like TITLE, BASE, CAPTION, etc.).

BoltClock
  • 700,868
  • 160
  • 1,392
  • 1,356
Gumbo
  • 643,351
  • 109
  • 780
  • 844
7

The two other provided answers had a couple of assumptions with browser compatibility that I ran into. Below code will not use nth-child and also has the previousElementSibling check.

function previousElementSibling (element) {
  if (element.previousElementSibling !== 'undefined') {
    return element.previousElementSibling;
  } else {
    // Loop through ignoring anything not an element
    while (element = element.previousSibling) {
      if (element.nodeType === 1) {
        return element;
      }
    }
  }
}
function getPath (element) {
  // False on non-elements
  if (!(element instanceof HTMLElement)) { return false; }
  var path = [];
  while (element.nodeType === Node.ELEMENT_NODE) {
    var selector = element.nodeName;
    if (element.id) { selector += ('#' + element.id); }
    else {
      // Walk backwards until there is no previous sibling
      var sibling = element;
      // Will hold nodeName to join for adjacent selection
      var siblingSelectors = [];
      while (sibling !== null && sibling.nodeType === Node.ELEMENT_NODE) {
        siblingSelectors.unshift(sibling.nodeName);
        sibling = previousElementSibling(sibling);
      }
      // :first-child does not apply to HTML
      if (siblingSelectors[0] !== 'HTML') {
        siblingSelectors[0] = siblingSelectors[0] + ':first-child';
      }
      selector = siblingSelectors.join(' + ');
    }
    path.unshift(selector);
    element = element.parentNode;
  }
  return path.join(' > ');
}
BenM
  • 553
  • 1
  • 6
  • 23
7

Doing a reverse CSS selector lookup is an inherently tricky thing. I've generally come across two types of solutions:

  1. Go up the DOM tree to assemble the selector string out of a combination of element names, classes, and the id or name attribute. The problem with this method is that it can result in selectors that return multiple elements, which won't cut it if we require them to select only one unique element.

  2. Assemble the selector string using nth-child() or nth-of-type(), which can result in very long selectors. In most cases the longer a selector is the higher specificity it has, and the higher the specificity the more likely it will break when the DOM structure changes.

The solution below is an attempt at tackling both of these issues. It is a hybrid approach that outputs a unique CSS selector (i.e., document.querySelectorAll(getUniqueSelector(el)) should always return a one-item array). While the returned selector string is not necessarily the shortest, it is derived with an eye towards CSS selector efficiency while balancing specificity by prioritizing nth-of-type() and nth-child() last.

You can specify what attributes to incorporate into the selector by updating the aAttr array. The minimum browser requirement is IE 9.

function getUniqueSelector(elSrc) {
  if (!(elSrc instanceof Element)) return;
  var sSel,
    aAttr = ['name', 'value', 'title', 'placeholder', 'data-*'], // Common attributes
    aSel = [],
    // Derive selector from element
    getSelector = function(el) {
      // 1. Check ID first
      // NOTE: ID must be unique amongst all IDs in an HTML5 document.
      // https://www.w3.org/TR/html5/dom.html#the-id-attribute
      if (el.id) {
        aSel.unshift('#' + el.id);
        return true;
      }
      aSel.unshift(sSel = el.nodeName.toLowerCase());
      // 2. Try to select by classes
      if (el.className) {
        aSel[0] = sSel += '.' + el.className.trim().replace(/ +/g, '.');
        if (uniqueQuery()) return true;
      }
      // 3. Try to select by classes + attributes
      for (var i=0; i<aAttr.length; ++i) {
        if (aAttr[i]==='data-*') {
          // Build array of data attributes
          var aDataAttr = [].filter.call(el.attributes, function(attr) {
            return attr.name.indexOf('data-')===0;
          });
          for (var j=0; j<aDataAttr.length; ++j) {
            aSel[0] = sSel += '[' + aDataAttr[j].name + '="' + aDataAttr[j].value + '"]';
            if (uniqueQuery()) return true;
          }
        } else if (el[aAttr[i]]) {
          aSel[0] = sSel += '[' + aAttr[i] + '="' + el[aAttr[i]] + '"]';
          if (uniqueQuery()) return true;
        }
      }
      // 4. Try to select by nth-of-type() as a fallback for generic elements
      var elChild = el,
        sChild,
        n = 1;
      while (elChild = elChild.previousElementSibling) {
        if (elChild.nodeName===el.nodeName) ++n;
      }
      aSel[0] = sSel += ':nth-of-type(' + n + ')';
      if (uniqueQuery()) return true;
      // 5. Try to select by nth-child() as a last resort
      elChild = el;
      n = 1;
      while (elChild = elChild.previousElementSibling) ++n;
      aSel[0] = sSel = sSel.replace(/:nth-of-type\(\d+\)/, n>1 ? ':nth-child(' + n + ')' : ':first-child');
      if (uniqueQuery()) return true;
      return false;
    },
    // Test query to see if it returns one element
    uniqueQuery = function() {
      return document.querySelectorAll(aSel.join('>')||null).length===1;
    };
  // Walk up the DOM tree to compile a unique selector
  while (elSrc.parentNode) {
    if (getSelector(elSrc)) return aSel.join(' > ');
    elSrc = elSrc.parentNode;
  }
}
thdoan
  • 18,421
  • 1
  • 62
  • 57
  • 1
    One comment I'd make is that while id attribute should be unique, it is not necessarily static, as some sites use dynamic ids that change between refreshes. – Tom Dec 21 '19 at 07:50
2

I somehow find all the implementations unreadable due to unnecessary mutation. Here I provide mine in ClojureScript and JS:

(defn element? [x]
  (and (not (nil? x))
      (identical? (.-nodeType x) js/Node.ELEMENT_NODE)))

(defn nth-child [el]
  (loop [sib el nth 1]
    (if sib
      (recur (.-previousSibling sib) (inc nth))
      (dec nth))))

(defn element-path
  ([el] (element-path el []))
  ([el path]
  (if (element? el)
    (let [tag (.. el -nodeName (toLowerCase))
          id (and (not (string/blank? (.-id el))) (.-id el))]
      (if id
        (element-path nil (conj path (str "#" id)))
        (element-path
          (.-parentNode el)
          (conj path (str tag ":nth-child(" (nth-child el) ")")))))
    (string/join " > " (reverse path)))))

Javascript:

const isElement = (x) => x && x.nodeType === Node.ELEMENT_NODE;

const nthChild = (el, nth = 1) => {
  if (el) {
    return nthChild(el.previousSibling, nth + 1);
  } else {
    return nth - 1;
  }
};

const elementPath = (el, path = []) => {
  if (isElement(el)) {
    const tag = el.nodeName.toLowerCase(),
          id = (el.id.length != 0 && el.id);
    if (id) {
      return elementPath(
        null, path.concat([`#${id}`]));
    } else {
      return elementPath(
        el.parentNode,
        path.concat([`${tag}:nth-child(${nthChild(el)})`]));
    }
  } else {
    return path.reverse().join(" > ");
  }
};
skrat
  • 5,518
  • 3
  • 32
  • 48
2

There are some js libraries that do exactly this:

I am using the first one and with success so far

fguillen
  • 36,125
  • 23
  • 149
  • 210
1
function cssPath (e, anchor) {
    var selector;

    var parent = e.parentNode, child = e;
    var tagSelector = e.nodeName.toLowerCase();

    while (anchor && parent != anchor || !anchor && parent.nodeType === NodeTypes.ELEMENT_NODE) {
        var cssAttributes = ['id', 'name', 'class', 'type', 'alt', 'title', 'value'];
        var childSelector = tagSelector;
        if (!selector || parent.querySelectorAll (selector).length > 1) {
            for (var i = 0; i < cssAttributes.length; i++) {
                var attr = cssAttributes[i];
                var value = child.getAttribute(attr);
                if (value) {
                    if (attr === 'id') {
                        childSelector = '#' + value;
                    } else if (attr === 'class') {
                        childSelector = childSelector + '.' + value.replace(/\s/g, ".").replace(/\.\./g, ".");
                    } else { 
                        childSelector = childSelector + '[' + attr + '="' + value + '"]';
                    }
                }
            }

            var putativeSelector = selector? childSelector + ' ' + selector: childSelector;             

            if (parent.querySelectorAll (putativeSelector).length > 1) {
                var siblings = parent.querySelectorAll (':scope > ' + tagSelector);
                for (var index = 0; index < siblings.length; index++)
                    if (siblings [index] === child) {
                        childSelector = childSelector + ':nth-of-type(' + (index + 1) + ')';
                        putativeSelector = selector? childSelector + ' ' + selector: childSelector;             
                        break;
                    }
            }

            selector = putativeSelector;
        }
        child = parent;
        parent = parent.parentNode;
    }

    return selector;
};      
user1593165
  • 503
  • 3
  • 6
0

Better late than never: I came to this question and tried to use the selected answer, but in my case, it didn't worked because it wasn't very specific for my case. So I decided to write my own solution - I hope it may help some.

This solution goes like this: tag.class#id[name][type]:nth-child(?), and targeted with >.

function path(e) {
    let a = [];
    while (e.parentNode) {
        let d = [
            e.tagName.toLowerCase(),
            e.hasAttribute("class") ? e.getAttribute("class") : "",
            e.hasAttribute("id") ? e.getAttribute("id") : "",
            e.hasAttribute("name") ? e.getAttribute("name") : "",
            e.hasAttribute("type") ? e.getAttribute("type") : "",       
            0                                                       // nth-child
        ];

        // Trim
        for (let i = 0; i < d.length; i++) d[i] = typeof d[i] == "string" ? d[i].trim() : d[i];

        if (d[1] != "") d[1] = "."+d[1].split(" ").join(".");
        if (d[2] != "") d[2] = "#"+d[2];
        if (d[3] != "") d[3] = '[name="'+d[3]+'"]';
        if (d[4] != "") d[4] = '[type="'+d[4]+'"]';
        // Get child index...
        let s = e;
        while (s) {
            d[5]++;
            s = s.previousElementSibling;
        }
        d[5] = d[5] != "" ? ":nth-child("+d[5]+")" : ":only-child";
        // Build the String
        s = "";
        for (let i = 0; i < d.length; i++) s += d[i];
        a.unshift(s);

        // Go to Parent
        e = e.parentNode;
    }
    return a.join(">");
}

I know it's not that readable (I use it in my messy code), but it will give you the exact element(s) you're looking for. Just try it.