【油猴脚本】生成纯元素CSS选择器(附开发笔记)

脚本介绍

  1. 用途

常见的CSS选择器:

#gatsby-focus-wrapper > div > div > div.Box-nv15kw-0.Flex-arghxi-0.layout___StyledFlex-sc-1qhwq3g-0.iUtsKT.fNYvIR > div.Box-nv15kw-0.iiMOdu > div > div > div > div:nth-child(8) > div > div

经过脚本转换后的CSS选择器:

body > div > div:nth-child(1) > div > div > div:nth-child(2) > div:nth-child(1) > div > div > div > div:nth-child(8) > div > div

可以发现转换后的CSS选择器只包含元素,这就是这个脚本的目的。

注意:

  1. 准备转换的CSS选择器不能包含找不到实际元素的伪类和伪元素,如:hover、::after等,因为脚本需要用JS的document.querySelector函数在网页中找到对应的元素。只要是正常利用浏览器复制得到的CSS选择器都行。CSS选择器种类参见:https://developer.mozilla.org/zh-CN/docs/Learn/CSS/Building_blocks/Selectors

  1. 网页中iframe里的元素不适用,因为每个嵌入的浏览上下文(embedded browsing context)都有自己的会话历史记录 (session history)和DOM 树。而CSS的作用域是相同的document,因此不能直接用CSS选择器获取到iframe里的元素。参见:

  1. https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/iframe

  1. https://stackoverflow.com/questions/21842373

补充:利用浏览器复制CSS选择器

  1. 首先打开开发者模式

  1. Windows和Linux:ctrl+ shift+i

  1. Mac:command + ⌘ + i

  1. 点击下图标识的图标

  1. 鼠标移动到需要选择的网页元素上,可以发现右边的开发者工具中会出现你选中的元素。

  1. 右键点击开发者工具中出现的元素然后按照图中顺序选择复制selector

  1. 安装地址

脚本发布在greasyfork:https://greasyfork.org/zh-CN/scripts/460714

脚本Github仓库文件地址:https://github.com/coycs/Greasy-Fork/blob/main/%E7%94%9F%E6%88%90%E7%BA%AF%E5%85%83%E7%B4%A0CSS%E9%80%89%E6%8B%A9%E5%99%A8/main.js,如果你只是需要用这个脚本就忽略这个地址,去greasyfork安装就可以用了。

  1. 使用

(1)有一定的编程基础并且不想安装油猴脚本

获取到目标元素的选择器后,作为函数调用参数填入指定位置,复制全部代码粘贴在浏览器的控制台:

const genTagSelector = (selector) => {
  let targetNode = document.querySelector(selector);
  let tagSelector = "";

  while (targetNode.nodeName != "HTML") {
    const parentNode = targetNode.parentNode;
    const childNodes = Array.from(parentNode.childNodes).filter(node => node.nodeName != "#text" && node.nodeName != "#comment");
    const tagName = targetNode.tagName.toLowerCase();
    let nthIndex = 0;

    // 判断父元素下目标元素的标签名是否唯一
    if (childNodes.filter(node => node.tagName.toLowerCase() == tagName).length == 1) {
      if (parentNode.nodeName != "HTML") {
        tagSelector = ` > ${tagName}` + tagSelector;
      } else {
        tagSelector = `html > ${tagName}` + tagSelector;
      }

    } else {
      // 获取nthIndex的序号
      for (let i = 0; i < childNodes.length; i++) {
        if (childNodes[i] === targetNode) {
          nthIndex = i + 1;
          break;
        }
      };

      if (parentNode.nodeName != "HTML") {
        tagSelector = ` > ${tagName}:nth-child(${nthIndex})` + tagSelector;
      } else {
        tagSelector = `html > ${tagName}:nth-child(${nthIndex})` + tagSelector;
      }
      
    }

    targetNode = parentNode;
  }

  return tagSelector;
}

// 函数传入选择器字符串
// 如:genTagSelector("body > div > p")
genTagSelector("")

(2)直接使用油猴脚本

按照图中步骤先点击油猴脚本图标(对应1),然后点击该脚本菜单的按钮“输入选择器”(对应2)。

出现对话框后在输入框中输入想要转换的CSS选择器,然后点击“确定”按钮。

随后出现一个新的对话框,复制对话框中的内容然后点击“确定”按钮关闭对话框。

开发笔记

  1. 开发背景

小小的油猴脚本也需要对于前端基础知识的了解很深入。

开发这个脚本的想法源于自己想要用puppeteer来将https://docs.npmjs.com/的页面转成pdf来仔细阅读做笔记。使用puppeteer的过程中需要用到CSS选择器来需要获取到侧边栏中目录的链接。直接用浏览器复制到的CSS选择器如下:

#gatsby-focus-wrapper > div > div > div.Box-nv15kw-0.Flex-arghxi-0.layout___StyledFlex-sc-1qhwq3g-0.iUtsKT.fNYvIR > div.Box-nv15kw-0.iiMOdu > div > div > div > div:nth-child(8) > div > div > div:nth-child(3) > div

很容易发现没有几个id,class名也有一些生成的内容在里面,我担心这种CSS选择器的稳定性,我不想在下次运行脚本前还有很大的可能性需要重新复制一下CSS选择器。

于是我想网页dom结构一般是稳定的,那么只要选择器不涉及class类和id就满足需要了。我首先想到的是用xpath,因为它也可以直接在浏览器复制,比如:

/html/body/div/div[1]/div/div[1]

但自己对xpath了解不多,简单学习一下发现相比于平常使用的CSS选择器来说要麻烦很多,不符合开发习惯,也容易出错,于是还是选择CSS选择器,xpath的使用可以去这里https://developer.mozilla.org/zh-CN/docs/Web/XPath/Introduction_to_using_XPath_in_JavaScript学习。

既然我不想慢慢分析dom结构写出不涉及class类和id的CSS选择器但又需要用到,那就写一个脚本来辅助生成吧。

  1. 基本思路

主要的思路是先用浏览器复制到元素的选择器,接下来在程序中获取到其父元素,再在父元素中循环用nth-child来判断各个子元素是否是目标元素,这样就可以找到nth-child的index,就这样循环往复直到body元素。这个思路的灵感来自开发其他项目时查询到的stackflow回答。

按这个思路应该可以用递归,最后判断一下元素的父元素是否是body元素就可以了。但是我想到网页结构嵌套可能比较多,使用递归会不会导致效率低和栈溢出呢?考虑后决定使用循环比较稳妥。

根据上面的思路,简单写出了核心的根据CSS选择器生成纯元素CSS选择器的函数:

  const genTagSelector = (selector) => {
    let targetNode = document.querySelector(selector);
    let tagSelector = "";

    while (targetNode.nodeName != "BODY") {

      const parentNode = targetNode.parentNode;
      const tagName = targetNode.tagName.toLowerCase();
      const tagNameNodes = Array.from(parentNode.querySelectorAll(tagName));
      let nthIndex = 0;
      for (let i = 0; i < tagNameNodes.length; i++) {
        if (tagNameNodes[i] === targetNode) {
          nthIndex = i + 1;
          break;
        }
      };
      if (parentNode.nodeName != "BODY") {
        tagSelector = ` > ${tagName}:nth-child(${nthIndex})` + tagSelector;
      } else {
        tagSelector = `body > ${tagName}:nth-child(${nthIndex})` + tagSelector;
      }

      targetNode = parentNode;
    }

    return tagSelector;
  }
  1. 解决问题

  1. :nth-child

初步写出核心函数后简单测试一下:

genTagSelector("#gatsby-focus-wrapper > div > div > div.Box-nv15kw-0.Flex-arghxi-0.layout___StyledFlex-sc-1qhwq3g-0.iUtsKT.fNYvIR > div.Box-nv15kw-0.iiMOdu > div > div > div > div:nth-child(8) > div > div > div:nth-child(3) > div")

输出结果为:

body > div:nth-child(1) > div:nth-child(1) > div:nth-child(1) > div:nth-child(1) > div:nth-child(11) > div:nth-child(1) > div:nth-child(1) > div:nth-child(1) > div:nth-child(1) > div:nth-child(15) > div:nth-child(1) > div:nth-child(1) > div:nth-child(3) > div:nth-child(1)

验证以下发现没有找到目标元素:

经过不断删减选择器测试发现直到这里是有效的:

body > div:nth-child(1) > div:nth-child(1) > div:nth-child(1)

这样也是有效的:

body > div:nth-child(1) > div:nth-child(1) > div:nth-child(1) > div

但这样就无效了:

body > div:nth-child(1) > div:nth-child(1) > div:nth-child(1) > div:nth-child(1)

我对于:nth-child的认知可以以div:nth-child(1)为例,就是选中某个元素下的所有div元素中的排在第一个的元素。

但按照我的思路来看程序应该不会出现这种错误才对,于是我思考是否是自己对于:nth-child的理解不到位呢?

查询了MDN文档https://developer.mozilla.org/zh-CN/docs/Web/CSS/:nth-child发现自己确实存在误解:

:nth-child(an+b) 这个 CSS 伪类首先找到所有当前元素的兄弟元素,然后按照位置先后顺序从 1 开始排序。

结合一个例子可以更好理解:

span:nth-child(1)
表示父元素中子元素为第一的并且名字为 span 的标签被选中

那么以a:nth-child(b)为例,就是先找到a的所有兄弟元素,按照先后排序,然后找到b位置的为a的元素。

这样看来我原来的理解的确是错误的,那么根据正确的理解写CSS选择器来验证一下:

body > div:nth-child(1) > div:nth-child(1) > div:nth-child(1)>div:nth-child(2)

可以发现符合正确理解对应的预期结果。

这时候我又想到了:first-child,如果我用div:first-child能成功获取到目标元素吗?因为按照我对:first-child的理解和:nth-child最初的理解是一致的,就是获取到父元素下的所有div元素中的排在第一个的元素。测试一下:

body > div:nth-child(1) > div:nth-child(1) > div:nth-child(1) > div:first-child

可以发现没有成功获取到,那么说明我的理解有误,怀疑和:nth-child如出一辙。

查询一下MDN文档:https://developer.mozilla.org/zh-CN/docs/Web/CSS/:first-child

也是获取一组兄弟元素,而不是仅仅:first-child前面的元素,以c:first-child为例。那么按照正确理解来解释就是获取到c的所有兄弟元素,按照先后排序,然后获取到第一位置为c的元素。测试验证一下:

body > div:nth-child(1) > div:nth-child(1) > div:nth-child(1) > a:first-child

可以发现成功获取到,验证了自己的想法。

既然已经纠正了对于:nth-child的理解,那修改一下代码:

  const genTagSelector = (selector) => {
    let targetNode = document.querySelector(selector);
    let tagSelector = "";

    while (targetNode.nodeName != "BODY") {
      const parentNode = targetNode.parentNode;
      const childNodes = Array.from(parentNode.childNodes);
      const tagName = targetNode.tagName.toLowerCase();
      let nthIndex = 0;

      for (let i = 0; i < childNodes.length; i++) {
        if (childNodes[i] === targetNode) {
          nthIndex = i + 1;
          break;
        }
      };

      if (parentNode.nodeName != "BODY") {
        tagSelector = ` > ${tagName}:nth-child(${nthIndex})` + tagSelector;
      } else {
        tagSelector = `body > ${tagName}:nth-child(${nthIndex})` + tagSelector;
      }

      targetNode = parentNode;
    }

    return tagSelector;
  }
  1. 节点与nodeName

当我用上面的代码来测试自己简单写的网页时发现又出现了问题。

自己写的网页html代码:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>

<body>
  <div>
    <p>p1</p>
    <p>p2</p>
    <div>div1</div>
    <div>div2</div>
  </div>
</body>

</html>

渲染出的网页:

选择元素测试:

验证结果:

可以发现没有正确找到目标元素,而且:nth-child内的序号都找到了6,但很明显网页元素没有那么多啊,按照上面的思路怎么会错误得这么离谱。

在仔细检查了各个元素的childNodes后,我发现了问题所在。

body内明明只有一个div元素,哪里来的两个text元素?查看这个text元素的属性后我明白了原因:

根据data属性值可以知道是换行符产生了这个节点,根据这个nodeName属性值"#text"查询MDN文档发现这是一个文本节点https://developer.mozilla.org/zh-CN/docs/Web/API/Node/nodeName

https://developer.mozilla.org/zh-CN/docs/Web/API/Node/nodeType

但这不也是一个节点吗,按照我之前的代码来说应该也没有问题啊?

待我仔细再去查看:nth-child的概念后发现了问题:

注意:nth-child找的是元素节点,文本节点不是元素节点,按照我的代码去分析就是找到了所有的节点包括文本节点,但:nth-child找的是元素节点,那么这两个的数量就有可能不一样,又可能一样的情况就是全部是元素节点,比如源代码中body内容都在一行里:

既然二者的数量有可能不一致,那确定的:nth-child的序号就有可能出错,而对我自己写的网页测试就是出现的这样的错误。要解决这个问题就要让确定:nth-child序号时所有的子节点都是元素节点。修改后代码如下:

const genTagSelector = (selector) => {
  let targetNode = document.querySelector(selector);
  let tagSelector = "";

  while (targetNode.nodeName != "BODY") {
    const parentNode = targetNode.parentNode;
    const childNodes = Array.from(parentNode.childNodes).filter(node => node.nodeName != "#text" && node.nodeName != "#comment");
    const tagName = targetNode.tagName.toLowerCase();
    let nthIndex = 0;

    for (let i = 0; i < childNodes.length; i++) {
      if (childNodes[i] === targetNode) {
        nthIndex = i + 1;
        break;
      }
    };

    if (parentNode.nodeName != "BODY") {
      tagSelector = ` > ${tagName}:nth-child(${nthIndex})` + tagSelector;
    } else {
      tagSelector = `body > ${tagName}:nth-child(${nthIndex})` + tagSelector;
    }

    targetNode = parentNode;
  }

  return tagSelector;
}

如果仔细看可以发现代码中还排除了"#comment",也就是注释节点,但我一开始觉得印象里网页中好像没有注释内容,也就忽略了,但在测试出错后才发现确实存在注释节点:

  1. 生成内容较长

使用上面核心函数生成的CSS选择器像这样:

body > div:nth-child(1) > div:nth-child(1) > div:nth-child(1) > div:nth-child(2) > div:nth-child(2) > div:nth-child(1) > div:nth-child(1) > div:nth-child(1) > div:nth-child(1) > div:nth-child(8) > div:nth-child(1) > div:nth-child(2) > div:nth-child(3) > div:nth-child(2)

可以发现除了body,其他部分都带有:nth-child,但分析网页发现有的元素的子元素只有一个,那么就可以直接用这个子元素的标签名选择,而不不需要:nth-child(1)。因此代码就需要判断一下父元素的子元素是否只有一个,优化后的代码如下:

  const genTagSelector = (selector) => {
    let targetNode = document.querySelector(selector);
    let tagSelector = "";
  
    while (targetNode.nodeName != "BODY") {
      const parentNode = targetNode.parentNode;
      const childNodes = Array.from(parentNode.childNodes).filter(node => node.nodeName != "#text" && node.nodeName != "#comment");
      const tagName = targetNode.tagName.toLowerCase();
      let nthIndex = 0;
  
      // 判断父元素下目标元素的标签名是否唯一
      if (childNodes.filter(node => node.tagName.toLowerCase() == tagName).length == 1) {
        if (parentNode.nodeName != "BODY") {
          tagSelector = ` > ${tagName}` + tagSelector;
        } else {
          tagSelector = `body > ${tagName}` + tagSelector;
        }
  
      } else {
        // 获取nth-child的序号
        for (let i = 0; i < childNodes.length; i++) {
          if (childNodes[i] === targetNode) {
            nthIndex = i + 1;
            break;
          }
        };
  
        if (parentNode.nodeName != "BODY") {
          tagSelector = ` > ${tagName}:nth-child(${nthIndex})` + tagSelector;
        } else {
          tagSelector = `body > ${tagName}:nth-child(${nthIndex})` + tagSelector;
        }
        
      }
  
      targetNode = parentNode;
    }
  
    return tagSelector;
  }

简化后的CSS选择器:

body > div > div:nth-child(1) > div > div > div:nth-child(2) > div:nth-child(1) > div > div > div > div:nth-child(8) > div > div > div:nth-child(3) > div

可以发现确实是更简单一点。

  1. 整合油猴

既然核心函数写好了,那么整合进油猴内就主要解决交互使用的问题了,主要考虑的就是怎么将生成后内容复制进剪切板。

一开始的想法是想要在生成后自动复制到剪切板,这样用户用得就比较方便自然。

查询后https://www.zhangxinxu.com/wordpress/2021/10/js-copy-paste-clipboard/发现常用的有两种方法:document.execCommand,navigator.clipboard.writeText。

但查询后发现document.execCommand已经不推荐使用了:

https://developer.mozilla.org/zh-CN/docs/Web/API/Document/execCommand

navigator.clipboard.writeText也存在兼容性和需要在HTTPS的问题:

https://developer.mozilla.org/zh-CN/docs/Web/API/Clipboard

而且无法想象的是safari浏览器会出现什么错误,各种厂商自己的浏览器又会出现什么错误。“能够使用”和“可能出错”哪个更重要?肯定是“能够使用”,那么就要放弃自动复制的思路,选择最原始的方式,直接展示,然后用户自己复制。

我最先想到的是Window.alert方法,查看了一下兼容性,发现很好,那为什么不用呢:

https://caniuse.com/?search=alert

其他内容就是和油猴扩展本身有关的了,内容比较简单,最后脚本的全部代码如下:

// ==UserScript==
// @name         生成纯元素CSS选择器
// @namespace    coycs.com
// @version      1.0.0
// @description  generate a element-only CSS selector
// @author       coycs
// @match        http://*/*
// @match        https://*/*
// @grant        GM_registerMenuCommand
// @run-at       document-end
// @license MIT
// ==/UserScript==

(function () {
  "use strict";

  // 根据选择器生成元素选择器
  const genTagSelector = (selector) => {
    let targetNode = document.querySelector(selector);
    let tagSelector = "";
  
    while (targetNode.nodeName != "BODY") {
      const parentNode = targetNode.parentNode;
      const childNodes = Array.from(parentNode.childNodes).filter(node => node.nodeName != "#text" && node.nodeName != "#comment");
      const tagName = targetNode.tagName.toLowerCase();
      let nthIndex = 0;
  
      // 判断父元素下目标元素的标签名是否唯一
      if (childNodes.filter(node => node.tagName.toLowerCase() == tagName).length == 1) {
        if (parentNode.nodeName != "BODY") {
          tagSelector = ` > ${tagName}` + tagSelector;
        } else {
          tagSelector = `body > ${tagName}` + tagSelector;
        }
  
      } else {
        // 获取nth-child的序号
        for (let i = 0; i < childNodes.length; i++) {
          if (childNodes[i] === targetNode) {
            nthIndex = i + 1;
            break;
          }
        };
  
        if (parentNode.nodeName != "BODY") {
          tagSelector = ` > ${tagName}:nth-child(${nthIndex})` + tagSelector;
        } else {
          tagSelector = `body > ${tagName}:nth-child(${nthIndex})` + tagSelector;
        }
        
      }
  
      targetNode = parentNode;
    }
  
    return tagSelector;
  }
  // 转换选择器
  const tranSelector = () => {
    const promptContent = window.prompt("请将原始的选择器粘贴在下面");
    // 判断粘贴内容是否合格
    if (promptContent === null) {// 点击取消按钮
      return;
    } else if (promptContent === "") {// 输入框内容为空时点击确定
      window.alert("内容为空!");
    } else if (!document.querySelector(promptContent)) {// 选择器无效
      window.alert("请检查选择器是否正确!");
    } else {
      const tagSelector = genTagSelector(promptContent);
      window.alert(tagSelector);
    }
  }

  GM_registerMenuCommand("输入选择器", tranSelector, "t");
})();

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值