Greasy Fork

Greasy Fork is available in English.

元素属性复制

右键复制网页元素内容(类名、文本、HTML、Markdown),支持下载为 Markdown,使用 Vue + Element Plus + Turndown 实现。Markdown下载功能目前只做了掘金、CSDN的兼容(有瑕疵),其余网站没特意试过。

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         元素属性复制
// @namespace    http://tampermonkey.net/
// @version      0.0.1
// @description  右键复制网页元素内容(类名、文本、HTML、Markdown),支持下载为 Markdown,使用 Vue + Element Plus + Turndown 实现。Markdown下载功能目前只做了掘金、CSDN的兼容(有瑕疵),其余网站没特意试过。
// @author       石小石Orz
// @match        *://*/*
// @license      MIT
// @require      https://unpkg.com/vue@3/dist/vue.global.js
// @require      https://unpkg.com/turndown/dist/turndown.js
// @resource     ELEMENT_JS https://cdn.jsdelivr.net/npm/element-plus
// @resource     elementPlusCss https://cdn.jsdelivr.net/npm/element-plus/dist/index.css
// @grant        GM_addStyle
// @grant        GM_getResourceText
// @grant        GM_setClipboard
// @grant        GM_registerMenuCommand
// @grant        GM_download
// @grant        unsafeWindow
// @noframes
// ==/UserScript==

(function () {
  'use strict';

  // 添加 Element Plus 样式
  GM_addStyle(GM_getResourceText('elementPlusCss'));
  GM_addStyle(`
    .tm-hover-highlight {
      outline: 2px solid rgba(0, 123, 255, 0.7);
      background-color: rgba(0, 123, 255, 0.1) !important;
      border-radius: 4px;
      transition: all 0.2s ease;
      z-index: 9999;
    }
  `);

  // 加载 Vue 和 Element Plus
  window.Vue = unsafeWindow.Vue = Vue;
  const { createApp, ref, reactive } = Vue;
  const elementPlusJS = GM_getResourceText('ELEMENT_JS');
  eval(elementPlusJS);

  // 插入挂载点
  const container = document.createElement('div');
  container.id = 'copy-helper-app';
  document.body.appendChild(container);

  // Markdown 转换函数
  function htmlToMarkdown(el) {
    const turndownService = new TurndownService({
      headingStyle: 'atx',
      codeBlockStyle: 'fenced',
    });

    // 处理 <pre><code> 为代码块,过滤说明文字
    turndownService.addRule('code-block', {
      filter: 'pre',
      replacement: function (content) {
        const code = (content.match(/`{1,3}([\s\S]*?)`{1,3}/)?.[1] || content).trim();
        return '\n```\n' + code + '\n```\n';
      }
    });

    // 处理 <table>
    turndownService.addRule('table', {
      filter: 'table',
      replacement: function (_, node) {
        let markdown = '';
        const rows = Array.from(node.querySelectorAll('tr'));
        const extractText = (td) => td.textContent.trim().replace(/\|/g, '\\|');
        rows.forEach((row, i) => {
          const cells = Array.from(row.children).map(extractText);
          markdown += '| ' + cells.join(' | ') + ' |\n';
          if (i === 0) markdown += '| ' + cells.map(() => '---').join(' | ') + ' |\n';
        });
        return '\n' + markdown + '\n';
      }
    });

    return turndownService.turndown(el.innerHTML);
  }

  // Vue 应用
  const App = {
    setup() {
      const visible = ref(false);
      const buttons = reactive([]);
      const pos = reactive({ top: 0, left: 0 });

      const setButtonsFor = (el) => {
        buttons.length = 0;
        const className = el.className?.toString().trim();
        const text = el.innerText?.trim();
        const html = el.innerHTML?.trim();
        const fullHtml = el.outerHTML?.trim();

        if (className) buttons.push({ label: '复制类名', content: className });
        if (text) buttons.push({ label: '复制文本', content: text });
        if (html) buttons.push({ label: '复制网页', content: html, type: 'html' });
        if (fullHtml) buttons.push({ label: '复制HTML文本', content: fullHtml, type: 'text' });

        if (html) {
          const md = htmlToMarkdown(el);
          buttons.push({ label: '复制为Markdown', content: md, type: 'text' });
          buttons.push({ label: '下载为Markdown', content: md, type: 'markdown' });
        }
      };

      const copy = ({ content, type }) => {
        if (type === 'markdown') {
          const title = document.title.replace(/[\\/:*?"<>|]/g, '_');
          GM_download({
            url: 'data:text/markdown;charset=utf-8,' + encodeURIComponent(content),
            name: title + '.md',
            saveAs: true
          });
          ElementPlus.ElMessage.success('Markdown 已下载');
        } else {
          GM_setClipboard(content, type || 'text');
          ElementPlus.ElMessage.success('复制成功!');
        }

        visible.value = false;
        deactivate();
      };

      const updatePosition = (x, y) => {
        pos.top = y;
        pos.left = x;
      };

      return { visible, buttons, pos, copy, setButtonsFor, updatePosition };
    },
    template: `
      <div v-if="visible"
           :style="{
             position: 'absolute',
             top: pos.top + 'px',
             left: pos.left + 'px',
             zIndex: 99999,
             display: 'flex',
             flexDirection: 'column',
             alignItems: 'flex-start',
             gap: '5px',
             backgroundColor: '#f2f2f2',
             borderRadius: '5px',
             border: '1px solid #ccc',
             padding: '8px'
           }">
        <el-button
          v-for="btn in buttons"
          size="small"
          :type="btn.type === 'markdown' ? 'primary' : 'info'"
          style="min-width: 140px; margin: 0"
          @click="copy(btn)"
        >
          {{ btn.label }}
        </el-button>
      </div>
    `
  };

  const app = createApp(App);
  app.use(ElementPlus);
  const vm = app.mount('#copy-helper-app');

  let currentElement = null;
  let activated = false;

  const isValidElement = (el) => {
    if (!el || el.nodeType !== 1) return false;
    const rect = el.getBoundingClientRect();
    return !['html', 'body', 'script', 'style'].includes(el.tagName.toLowerCase()) && rect.width >= 30 && rect.height >= 15;
  };

  const findValidTarget = (el) => {
    while (el && el !== document.body) {
      if (isValidElement(el)) return el;
      el = el.parentElement;
    }
    return null;
  };

  const handleMouseMove = (e) => {
    if (!activated) return;
    let el = e.target;
    if (document.querySelector('#copy-helper-app')?.contains(el)) return;

    el = findValidTarget(el);
    if (!el) return;

    if (el !== currentElement) {
      currentElement?.classList.remove('tm-hover-highlight');
      vm.visible = false;
      currentElement = el;
      currentElement.classList.add('tm-hover-highlight');
    }
  };

  const handleContextMenu = (e) => {
    if (!activated) return;
    let el = e.target;
    if (document.querySelector('#copy-helper-app')?.contains(el)) return;

    el = findValidTarget(el);
    if (!el) {
      vm.visible = false;
      return;
    }

    if (el === currentElement) {
      e.preventDefault();
      vm.setButtonsFor(el);
      vm.updatePosition(e.pageX, e.pageY);
      vm.visible = true;
    } else {
      vm.visible = false;
    }
  };

  function activate() {
    if (activated) return;
    activated = true;
    document.addEventListener('mousemove', handleMouseMove, true);
    document.addEventListener('contextmenu', handleContextMenu, true);
    ElementPlus.ElMessage.info('元素复制脚本已启动,右键高亮区域试试!');
  }

  function deactivate() {
    if (!activated) return;
    activated = false;
    document.removeEventListener('mousemove', handleMouseMove, true);
    document.removeEventListener('contextmenu', handleContextMenu, true);
    currentElement?.classList.remove('tm-hover-highlight');
    currentElement = null;
    vm.visible = false;
  }

  GM_registerMenuCommand('启动元素复制脚本', activate);
})();