Article Copy Button

09/02/2025, 16.57.34

当前为 2025-02-16 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name        Article Copy Button
// @namespace   https://github.com/BobbyWibowo
// @include     http://*
// @include     https://*
// @grant       GM_addStyle
// @version     1.0.5
// @author      Bobby Wibowo
// @license     MIT
// @run-at      document-end
// @description 09/02/2025, 16.57.34
// ==/UserScript==

(function () {
  'use strict'

  const ARTICLES_CONFIG = [
    {
      whitelistedHosts: [
        'quanben-xiaoshuo.com'
      ],
      parentSelector: '.wrapper .box',
      titleSelector: '.title',
      articleSelector: '#articlebody',
      AIPromptPrefix: 'Display the translation of this web novel chapter, retaining all original text without any loss in translation, and incorporating the remembered glossary:\n\n'
    },
    {
      parentSelector: '.wp-manga-page',
      titleSelector: '.cha-tit h3',
      articleSelector: '.cha-content'
    },
    {
      parentSelector: '#chapter',
      titleSelector: '.chapter-title',
      articleSelector: '.chapter-c'
    }
  ];

  const GLOBAL_STYLE = /*css*/`
  .copy-article-button-container {
    display: flex;
    justify-content: center;
    gap: 4px;
    margin-bottom: 16px;
  }

  .copy-article-button {
    --button-bg: #e5e6eb;
    --button-hover-bg: #d7dbe2;
    --button-text-color: #4e5969;
    --button-hover-text-color: #164de5;
    --button-border-radius: 6px;
    --button-diameter: 24px;
    --button-outline-width: 2px;
    --button-outline-color: #9f9f9f;
    --tooltip-bg: #1d2129;
    --toolptip-border-radius: 4px;
    --tooltip-font-family: JetBrains Mono, Consolas, Menlo, Roboto Mono, monospace;
    --tooltip-font-size: 12px;
    --tootip-text-color: #fff;
    --tooltip-padding-x: 7px;
    --tooltip-padding-y: 7px;
    --tooltip-offset: 8px;
    /* --tooltip-transition-duration: 0.3s; */
  }

  html[data-darkreader-scheme="dark"] .copy-article-button,
  body[class*="text-ui-light"] .copy-article-button,
  main[class*="dark-background"] .copy-article-button {
    --button-bg: #353434;
    --button-hover-bg: #464646;
    --button-text-color: #ccc;
    --button-outline-color: #999;
    --button-hover-text-color: #8bb9fe;
    --tooltip-bg: #f4f3f3;
    --tootip-text-color: #111;
  }

  .copy-article-button {
    box-sizing: border-box;
    width: var(--button-diameter);
    height: var(--button-diameter);
    border-radius: var(--button-border-radius);
    background-color: var(--button-bg);
    color: var(--button-text-color);
    border: none;
    cursor: pointer;
    position: relative;
    outline: var(--button-outline-width) solid transparent;
    transition: all 0.2s ease;
  }

  .tooltip {
    position: absolute;
    opacity: 0;
    left: calc(100% + var(--tooltip-offset));
    top: 50%;
    transform: translateY(-50%);
    white-space: nowrap;
    font: var(--tooltip-font-size) var(--tooltip-font-family);
    color: var(--tootip-text-color);
    background: var(--tooltip-bg);
    padding: var(--tooltip-padding-y) var(--tooltip-padding-x);
    border-radius: var(--toolptip-border-radius);
    pointer-events: none;
    transition: all var(--tooltip-transition-duration) cubic-bezier(0.68, -0.55, 0.265, 1.55);
    z-index: 1;
  }

  .tooltip::before {
    content: attr(data-text-initial);
  }

  .tooltip::after {
    content: "";
    width: var(--tooltip-padding-y);
    height: var(--tooltip-padding-y);
    background: inherit;
    position: absolute;
    top: 50%;
    left: calc(var(--tooltip-padding-y) / 2 * -1);
    transform: translateY(-50%) rotate(45deg);
    z-index: -999;
    pointer-events: none;
  }

  .copy-article-button svg {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
  }

  .checkmark,
  .failedmark {
    display: none;
  }

  .copy-article-button:hover .tooltip,
  .copy-article-button:focus:not(:focus-visible) .tooltip {
    opacity: 1;
    visibility: visible;
  }

  .copy-article-button:focus:not(:focus-visible) .tooltip::before {
    content: attr(data-text-end);
  }
  .copy-article-button.copy-failed:focus:not(:focus-visible) .tooltip::before {
    content: attr(data-text-failed);
  }

  .copy-article-button:focus:not(:focus-visible) .clipboard {
    display: none;
  }

  .copy-article-button:focus:not(:focus-visible) .checkmark {
    display: block;
  }

  .copy-article-button.copy-failed:focus:not(:focus-visible) .checkmark {
    display: none;
  }

  .copy-article-button.copy-failed:focus:not(:focus-visible) .failedmark {
    display: block;
  }

  .copy-article-button:hover,
  .copy-article-button:focus {
    background-color: var(--button-hover-bg);
  }

  .copy-article-button:active {
    outline: var(--button-outline-width) solid var(--button-outline-color);
  }

  .copy-article-button:hover svg {
    color: var(--button-hover-text-color);
  }
  `;

  const log = (message, ...args) => {
    console.log(`[Article Copy Button]: ${message}`, ...args)
  }

  const logError = (message, ...args) => {
    console.error(`[Article Copy Button]: ${message}`, ...args)
  }

  let globalStyleAdded = false;

  function formatArticle({ title, article, AIPromptPrefix = '' }) {
    const header = title ? title.innerText.trim() : null;
    const body = article.innerText.trim();

    const formatted = (header ? `${header}\n\n` : '') + body;

    return AIPromptPrefix + formatted;
  }

  function handleCopyError({ event, title, article, error = new Error() }) {
    logError('Could not copy: ', error);
    event.element.classList.add('copy-failed');
  }

  function copyTextToClipboard({ event, title, article, text }) {
    navigator.clipboard.writeText(text).then(function () {
      log('Article copied to clipboard.\n\n' + text);
    }).catch(function (error) {
      handleCopyError({ event, title, article, error })
    });
  }

  function handleArticleCopyClick({ event, title, article, AIPromptPrefix }) {
    event.stopPropagation();
    try {
      const text = formatArticle({ title, article, AIPromptPrefix });
      copyTextToClipboard({ event, title, article, text });
    } catch (error) {
      handleCopyError({ event, title, article, error })
    }
  }

  const BUTTON_INNER_TEMPLATE = /*html*/`
    <span data-text-initial="Copy to clipboard" data-text-end="Copied" data-text-failed="Copy failed, open the console for details!" class="tooltip"></span>
    <span>
      <svg xml:space="preserve" style="enable-background:new 0 0 512 512" viewBox="0 0 6.35 6.35" y="0" x="0"
        height="14" width="14" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"
        xmlns="http://www.w3.org/2000/svg" class="clipboard">
        <g>
          <path fill="currentColor"
            d="M2.43.265c-.3 0-.548.236-.573.53h-.328a.74.74 0 0 0-.735.734v3.822a.74.74 0 0 0 .735.734H4.82a.74.74 0 0 0 .735-.734V1.529a.74.74 0 0 0-.735-.735h-.328a.58.58 0 0 0-.573-.53zm0 .529h1.49c.032 0 .049.017.049.049v.431c0 .032-.017.049-.049.049H2.43c-.032 0-.05-.017-.05-.049V.843c0-.032.018-.05.05-.05zm-.901.53h.328c.026.292.274.528.573.528h1.49a.58.58 0 0 0 .573-.529h.328a.2.2 0 0 1 .206.206v3.822a.2.2 0 0 1-.206.205H1.53a.2.2 0 0 1-.206-.205V1.529a.2.2 0 0 1 .206-.206z">
          </path>
        </g>
      </svg>
      <svg xml:space="preserve" style="enable-background:new 0 0 512 512" viewBox="0 0 24 24" y="0" x="0" height="14"
        width="14" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" xmlns="http://www.w3.org/2000/svg"
        class="checkmark">
        <g>
          <path data-original="#000000" fill="currentColor"
            d="M9.707 19.121a.997.997 0 0 1-1.414 0l-5.646-5.647a1.5 1.5 0 0 1 0-2.121l.707-.707a1.5 1.5 0 0 1 2.121 0L9 14.171l9.525-9.525a1.5 1.5 0 0 1 2.121 0l.707.707a1.5 1.5 0 0 1 0 2.121z">
          </path>
        </g>
      </svg>
      <svg class="failedmark" xmlns="http://www.w3.org/2000/svg" height="14" width="14" viewBox="0 0 512 512">
        <path fill="#FF473E"
          d="m330.443 256l136.765-136.765c14.058-14.058 14.058-36.85 0-50.908l-23.535-23.535c-14.058-14.058-36.85-14.058-50.908 0L256 181.557L119.235 44.792c-14.058-14.058-36.85-14.058-50.908 0L44.792 68.327c-14.058 14.058-14.058 36.85 0 50.908L181.557 256L44.792 392.765c-14.058 14.058-14.058 36.85 0 50.908l23.535 23.535c14.058 14.058 36.85 14.058 50.908 0L256 330.443l136.765 136.765c14.058 14.058 36.85 14.058 50.908 0l23.535-23.535c14.058-14.058 14.058-36.85 0-50.908z" />
      </svg>
    </span>
  `;

  let i = 0;

  for (const config of ARTICLES_CONFIG) {
    // Skip config if it's whitelisted for specific hosts yet it doesn't match current host
    if (Array.isArray(config.whitelistedHosts)) {
      let hostPassed = false;
      for (const host of config.whitelistedHosts) {
        let _pass = true;
        if (host instanceof RegExp) {
          if (host.test(window.location.hostname)) {
            hostPassed = true;
          }
        } else if (host === window.location.hostname) {
          hostPassed = true;
        }
      }
      if (!hostPassed) {
        continue;
      }
    }

    // Loop through query results of parents, to potentially support multiple articles at once
    // TODO Mutation observer for dynamic content?
    const parents = document.querySelectorAll(config.parentSelector);
    for (const parent of parents) {
      const article = parent.querySelector(config.articleSelector);
      if (!article) {
        continue;
      }

      const title = parent.querySelector(config.titleSelector);

      const copyButtonContainer = document.createElement('div');
      copyButtonContainer.className = 'copy-article-button-container';

      const copyButton = document.createElement('button');
      copyButton.className = 'copy-article-button';
      copyButton.innerHTML = BUTTON_INNER_TEMPLATE;
      copyButton.addEventListener('click', event => handleArticleCopyClick({ event, title, article }));
      copyButtonContainer.appendChild(copyButton);
      i++;

      if (config.AIPromptPrefix) {
        const copyButtonAI = document.createElement('button');
        copyButtonAI.className = 'copy-article-button';
        copyButtonAI.innerHTML = BUTTON_INNER_TEMPLATE;
        copyButtonAI.querySelector(':first-child').dataset.textInitial += ' (with AI prompt prefix)';
        copyButtonAI.addEventListener('click', event => handleArticleCopyClick({ event, title, article, AIPromptPrefix: config.AIPromptPrefix }));
        copyButtonContainer.appendChild(copyButtonAI);
        i++;
      }

      if (!globalStyleAdded) {
        GM_addStyle(GLOBAL_STYLE);
        globalStyleAdded = true;
      }

      article.insertAdjacentElement('afterbegin', copyButtonContainer);
    }
  }

  if (i > 0) {
    log(`Added ${i} copy button(s).`);
  }
})()