// ==UserScript==
// @name Article Copy Button
// @namespace https://github.com/BobbyWibowo
// @include http://*
// @include https://*
// @grant GM_addStyle
// @grant GM_getValue
// @version 1.1.0
// @author Bobby Wibowo
// @license MIT
// @run-at document-end
// @description 09/02/2025, 16.57.34
// ==/UserScript==
(function () {
'use strict'
const _logTime = () => {
return new Date().toLocaleTimeString([], {
hourCycle: 'h12',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3
})
.replaceAll('.', ':')
.replace(',', '.')
.toLocaleUpperCase();
};
const log = (message, ...args) => {
const prefix = `[${_logTime()}]: `;
if (typeof message === 'string') {
return console.log(prefix + message, ...args);
} else {
return console.log(prefix, message, ...args);
}
};
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: '.breadcrumb .active',
articleSelector: '.reading-content div[class*="text-"]'
},
{
parentSelector: '#chapter',
titleSelector: '.chapter-title',
articleSelector: '.chapter-c'
},
{
whitelistedHosts: [
'www.hoyolab.com'
],
mutationObserver: true, // this option is ignored without whitelistedHosts for performance
parentSelector: '.mhy-article-page',
titleSelector: '.mhy-article-page__title h1',
articleSelector: '.mhy-article-page__content'
},
{
whitelistedHosts: [
'dragontea.ink'
],
mutationObserver: true, // this option is ignored without whitelistedHosts for performance
parentSelector: '.wp-manga-page',
titleSelector: '.breadcrumb .active',
articleSelector: '.reading-content div[class*="text-"]',
AIPromptPrefix: 'Improve readability of this web novel chapter, without any loss:\n\n'
}
];
const ENV = {
MODE: GM_getValue('MODE', 'PROD')
};
log(`MODE =`, ENV.MODE);
let logDebug = () => {};
if (ENV.MODE !== 'PROD') {
logDebug = log;
}
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);
}
`;
let globalStyleAdded = false;
let observedConfigs = [];
let observerInitiated = false;
let triggerQueue = null;
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>
`;
class FunctionQueue {
constructor() {
this.queue = [];
this.running = false;
}
async go() {
if (this.queue.length) {
this.running = true;
const _func = this.queue.shift();
await _func[0](..._func[1]);
this.go();
} else {
this.running = false;
}
}
add(func, ...args) {
this.queue.push([func, [...args]]);
if (!this.running) {
this.go();
}
}
clear() {
this.queue.length = 0;
}
};
const observerFactory = option => {
let options;
if (typeof option === 'function') {
options = {
callback: option,
node: document.getElementsByTagName('body')[0],
option: { childList: true, subtree: true }
};
} else {
options = $.extend({
callback: () => {},
node: document.getElementsByTagName('body')[0],
option: { childList: true, subtree: true }
}, option);
}
const MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver;
const observer = new MutationObserver((mutations, observer) => {
options.callback.call(this, mutations, observer);
});
observer.observe(options.node, options.option);
return observer;
};
const findArticles = (config, source = document.body) => {
let parents = [];
if (source.matches(config.parentSelector)) {
parents = [source];
} else {
// Loop through query results of parents, to support multiple articles at once.
parents = source.querySelectorAll(config.parentSelector);
}
if (!parents.length) {
return null;
}
logDebug(`Found ${parents.length} element(s) matching parent selector: ${config.parentSelector}`);
let _done = 0;
for (const parent of parents) {
const article = parent.querySelector(config.articleSelector);
if (!article) {
continue;
}
// Skip if already processed.
if (article.querySelector('.copy-article-button-container')) {
continue;
}
logDebug(`Found element matching article selector: ${config.articleSelector}`);
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);
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);
}
if (!globalStyleAdded) {
GM_addStyle(GLOBAL_STYLE);
globalStyleAdded = true;
}
article.insertAdjacentElement('afterbegin', copyButtonContainer);
_done++;
}
return _done;
};
const initObserver = config => {
observedConfigs.push(config);
if (!observerInitiated) {
log('Initiating Mutation Observer for this host.');
observerInitiated = true;
triggerQueue = new FunctionQueue();
observerFactory((...args) => {
triggerQueue.add((mutations, observer) => {
for (let i = 0, len = mutations.length; i < len; i++) {
const mutation = mutations[i];
// Whether to change nodes.
if (mutation.type !== 'childList') {
continue;
}
let _observerDone = 0;
for (const config of observedConfigs) {
const done = findArticles(config, mutation.target);
if (done !== null) {
_observerDone += done;
}
}
if (_observerDone > 0) {
log(`Added ${_observerDone} copy button(s) via Mutation Observer.`);
}
}
}, ...args);
});
}
};
let _rootDone = 0;
for (const config of ARTICLES_CONFIG) {
let initMutationObsever = false;
// 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;
}
log(`Host whitelisted for parent selector: ${config.parentSelector}`);
if (config.mutationObserver) {
initMutationObsever = true;
}
}
if (!initMutationObsever && config.mutationObserver) {
log(`Mutation Observer can only be used for config with whitelisted hosts.`);
}
if (initMutationObsever) {
initObserver(config);
} else {
const done = findArticles(config);
if (done !== null) {
_rootDone += done;
}
}
}
if (_rootDone > 0) {
log(`Added ${_rootDone} copy button(s).`);
}
})()