// ==UserScript==
// @name 文本网页自由复制-Markdown
// @namespace http://tampermonkey.net/
// @version 2.0.0
// @description 自由选择网页区域并复制为 Markdown 格式
// @author shenfangda (enhanced by Claude & community input)
// @match *://*/*
// @exclude https://accounts.google.com/*
// @exclude https://*.google.com/sorry/*
// @exclude https://mail.google.com/*
// @exclude /^https?:\/\/localhost[:/]/
// @exclude /^file:\/\//
// @grant GM_setClipboard
// @license MIT
// @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iIzRDQUY1MCIgd2lkdGg9IjQ4cHgiIGhlaWdodD0iNDhweCI+PHBhdGggZD0iTTAgMGgyNHYyNEgwVjB6IiBmaWxsPSJub25lIi8+PHBhdGggZD0iTTIxIDNINWMtMS4xIDAtMiAuOS0yIDJ2MTRjMCAxLjEuOSAyIDIgMmgxNGMxLjEgMCAyLS45IDItMlY1YzAtMS4xLS45LTItMi0yem0tOSAyaDZ2MkgxMlY1em0wIDRoNnYySDEydjloLTJ2LTJIMTBWN2gydjJ6bS03IDRoMlY3SDVWMTFoMlY5em0wIDRoMnYySDV2LTJ6bTEyLTYuNWMyLjQ5IDAgNC41IDIuMDEgNC41IDQuNXM LTIuMDEgNC41LTQuNSA0LjUtNC41LTIuMDEtNC41LTQuNSAyLjAxLTQuNSA0LjUtNC41eiIvPjwvc3ZnPg==
// ==/UserScript==
(function() {
'use strict';
// --- Configuration ---
const BUTTON_TEXT_DEFAULT = 'Copy Markdown';
const BUTTON_TEXT_SELECTING_FREE = 'Selecting Area... (ESC to cancel)';
const BUTTON_TEXT_SELECTING_DIV = 'Click DIV to Copy (ESC to cancel)';
const BUTTON_TEXT_COPIED = 'Copied!';
const BUTTON_TEXT_FAILED = 'Copy Failed!';
const TEMP_MESSAGE_DURATION = 2000; // ms
const DEBUG = false; // Set to true for more verbose logging
// --- Logging ---
const log = (msg) => console.log(`[Markdown-Copy] ${msg}`);
const debugLog = (msg) => DEBUG && console.log(`[Markdown-Copy Debug] ${msg}`);
// --- State ---
let isSelecting = false;
let isDivMode = false;
let startX, startY;
let selectionBox = null;
let highlightedDiv = null;
let copyBtn = null;
let originalButtonText = BUTTON_TEXT_DEFAULT;
let messageTimeout = null;
// --- DOM Ready Check ---
function onDOMReady(callback) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', callback);
} else {
// DOMContentLoaded already fired or interactive/complete
callback();
}
}
// --- Main Initialization ---
function initScript() {
log(`Attempting init on ${window.location.href}`);
// Avoid running in frames or if body/head not present
if (window.self !== window.top) {
log('Script is running in an iframe, aborting.');
return;
}
if (!document.body || !document.head) {
log('Error: document.body or document.head not found. Retrying...');
setTimeout(initScript, 500); // Retry after a short delay
return;
}
log('DOM ready, initializing script.');
// Inject CSS
injectStyles();
// Create and add the button
if (!createButton()) return; // Stop if button creation fails
// Add core event listeners
setupEventListeners();
log('Initialization complete.');
}
// --- CSS Injection ---
function injectStyles() {
const STYLES = `
.markdown-copy-btn {
position: fixed;
top: 15px;
right: 15px;
z-index: 2147483646; /* Max z-index - 1 */
padding: 8px 14px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 13px;
font-family: sans-serif;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
transition: all 0.2s ease-in-out;
line-height: 1.4;
text-align: center;
}
.markdown-copy-btn:hover {
background-color: #45a049;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0,0,0,0.25);
}
.markdown-copy-btn.mc-copied { background-color: #3a8f40; }
.markdown-copy-btn.mc-failed { background-color: #c0392b; }
.markdown-copy-selection-box {
position: absolute;
border: 2px dashed #4CAF50;
background-color: rgba(76, 175, 80, 0.1);
z-index: 2147483645; /* Max z-index - 2 */
pointer-events: none; /* Allow clicks to pass through */
box-sizing: border-box;
}
.markdown-copy-div-highlight {
outline: 2px solid #4CAF50 !important;
background-color: rgba(76, 175, 80, 0.1) !important;
box-shadow: inset 0 0 0 2px rgba(76, 175, 80, 0.5) !important;
transition: all 0.1s ease-in-out;
cursor: pointer;
}
`;
try {
const styleSheet = document.createElement('style');
styleSheet.id = 'markdown-copy-styles';
styleSheet.textContent = STYLES;
document.head.appendChild(styleSheet);
debugLog('Styles injected.');
} catch (error) {
log(`Error injecting styles: ${error.message}`);
}
}
// --- Button Creation ---
function createButton() {
if (document.getElementById('markdown-copy-btn-main')) {
log('Button already exists.');
copyBtn = document.getElementById('markdown-copy-btn-main'); // Ensure reference is set
return true; // Button already exists
}
try {
copyBtn = document.createElement('button');
copyBtn.id = 'markdown-copy-btn-main';
copyBtn.className = 'markdown-copy-btn';
copyBtn.textContent = BUTTON_TEXT_DEFAULT;
originalButtonText = BUTTON_TEXT_DEFAULT; // Store initial text
document.body.appendChild(copyBtn);
debugLog('Button created and added.');
return true;
} catch (error) {
log(`Error creating button: ${error.message}`);
return false;
}
}
// --- Event Listeners Setup ---
function setupEventListeners() {
if (!copyBtn) {
log("Error: Button not found for adding listeners.");
return;
}
// Button click toggles selection modes
copyBtn.addEventListener('click', handleButtonClick);
// Mouse events for free selection
document.addEventListener('mousedown', handleMouseDown, true); // Use capture phase
document.addEventListener('mousemove', handleMouseMove, true);
document.addEventListener('mouseup', handleMouseUp, true);
// Mouse events for DIV selection
document.addEventListener('mouseover', handleMouseOverDiv);
document.addEventListener('click', handleClickDiv, true); // Use capture phase for potential preventDefault
// Keyboard listener for ESC key
document.addEventListener('keydown', handleKeyDown);
debugLog('Event listeners added.');
}
// --- Button Click Logic ---
function handleButtonClick(e) {
e.stopPropagation(); // Prevent triggering other click listeners
if (!isSelecting) {
// Start selection - cycle through modes (Off -> Div -> Free -> Off)
if (!isDivMode) { // Currently Off, switch to Div mode
isSelecting = true;
isDivMode = true;
setButtonState(BUTTON_TEXT_SELECTING_DIV);
document.body.style.cursor = 'pointer';
log('Entered Div Selection Mode.');
}
// Note: We'll implicitly switch from Div to Free in the next click if needed
} else if (isDivMode) {
// Currently in Div mode, switch to Free Select mode
isDivMode = false;
setButtonState(BUTTON_TEXT_SELECTING_FREE);
document.body.style.cursor = 'crosshair';
log('Switched to Free Selection Mode.');
// Remove any lingering div highlight
removeDivHighlight();
} else {
// Currently in Free mode, cancel selection
resetSelectionState();
log('Selection cancelled by button click.');
}
}
// --- Free Selection Handlers ---
function handleMouseDown(e) {
// Only act if in Free Select mode and not clicking the button itself
if (!isSelecting || isDivMode || e.target === copyBtn || copyBtn.contains(e.target)) return;
// Prevent default text selection behavior during drag
e.preventDefault();
e.stopPropagation();
startX = e.clientX + window.scrollX;
startY = e.clientY + window.scrollY;
// Create or reset selection box
if (!selectionBox) {
selectionBox = document.createElement('div');
selectionBox.className = 'markdown-copy-selection-box';
document.body.appendChild(selectionBox);
}
selectionBox.style.left = `${startX}px`;
selectionBox.style.top = `${startY}px`;
selectionBox.style.width = '0px';
selectionBox.style.height = '0px';
selectionBox.style.display = 'block'; // Make sure it's visible
debugLog(`Free selection started at (${startX}, ${startY})`);
}
function handleMouseMove(e) {
if (!isSelecting || isDivMode || !selectionBox || !startX) return; // Need startX to confirm drag started
// No preventDefault here - allows scrolling while dragging if needed
e.stopPropagation();
const currentX = e.clientX + window.scrollX;
const currentY = e.clientY + window.scrollY;
const left = Math.min(startX, currentX);
const top = Math.min(startY, currentY);
const width = Math.abs(currentX - startX);
const height = Math.abs(currentY - startY);
selectionBox.style.left = `${left}px`;
selectionBox.style.top = `${top}px`;
selectionBox.style.width = `${width}px`;
selectionBox.style.height = `${height}px`;
}
function handleMouseUp(e) {
if (!isSelecting || isDivMode || !selectionBox || !startX) return; // Check if a drag was actually happening
e.stopPropagation(); // Important to stop propagation here
const endX = e.clientX + window.scrollX;
const endY = e.clientY + window.scrollY;
const width = Math.abs(endX - startX);
const height = Math.abs(endY - startY);
debugLog(`Free selection ended at (${endX}, ${endY}), Size: ${width}x${height}`);
// Only copy if the box has a reasonable size (prevent accidental clicks)
if (width > 5 && height > 5) {
const markdownContent = getSelectedContentFromArea(startX, startY, endX, endY);
handleCopyAttempt(markdownContent, "Free Selection");
} else {
log("Selection box too small, ignoring.");
}
// Reset state *after* potential copy
resetSelectionState();
}
// --- Div Selection Handlers ---
function handleMouseOverDiv(e) {
if (!isSelecting || !isDivMode || e.target === copyBtn || copyBtn.contains(e.target)) return;
// Find the closest DIV that isn't the button itself or body/html
const target = e.target.closest('div:not(.markdown-copy-btn)');
if (target && target !== document.body && target !== document.documentElement) {
if (highlightedDiv && highlightedDiv !== target) {
removeDivHighlight();
}
if (highlightedDiv !== target) {
highlightedDiv = target;
highlightedDiv.classList.add('markdown-copy-div-highlight');
debugLog(`Highlighting Div: ${target.tagName}#${target.id}.${target.className.split(' ').join('.')}`);
}
} else {
// If hovering over something not in a suitable div, remove highlight
removeDivHighlight();
}
}
function handleClickDiv(e) {
if (!isSelecting || !isDivMode || e.target === copyBtn || copyBtn.contains(e.target)) return;
// Check if the click was on the currently highlighted div
const targetDiv = e.target.closest('.markdown-copy-div-highlight');
if (targetDiv && targetDiv === highlightedDiv) {
// Prevent the click from triggering other actions on the page (like navigation)
e.preventDefault();
e.stopPropagation();
log(`Div clicked: ${targetDiv.tagName}#${targetDiv.id}.${targetDiv.className.split(' ').join('.')}`);
const markdownContent = htmlToMarkdown(targetDiv);
handleCopyAttempt(markdownContent, "Div Selection");
resetSelectionState(); // Reset after successful click/copy
}
// If clicked outside the highlighted div, do nothing, let the click proceed normally
// unless it hits another potential div, handled by mouseover->highlight->next click
}
// --- Content Extraction ---
/**
* Tries to get Markdown content from the center of a selected area.
* This is an approximation and might not capture everything perfectly.
*/
function getSelectedContentFromArea(x1, y1, x2, y2) {
const centerX = window.scrollX + (Math.min(x1, x2) + Math.abs(x1 - x2) / 2 - window.scrollX);
const centerY = window.scrollY + (Math.min(y1, y2) + Math.abs(y1 - y2) / 2 - window.scrollY);
debugLog(`Checking elements at center point (${centerX}, ${centerY})`);
try {
const elements = document.elementsFromPoint(centerX, centerY);
if (!elements || elements.length === 0) {
log("No elements found at center point.");
return '';
}
// Find the most relevant element (skip body, html, overlays, button)
const meaningfulElement = elements.find(el =>
el &&
el.tagName?.toLowerCase() !== 'body' &&
el.tagName?.toLowerCase() !== 'html' &&
!el.classList.contains('markdown-copy-selection-box') &&
!el.classList.contains('markdown-copy-btn') &&
window.getComputedStyle(el).display !== 'none' &&
window.getComputedStyle(el).visibility !== 'hidden' &&
// Prefer elements with some size or specific tags
(el.offsetWidth > 20 || el.offsetHeight > 10 || ['p', 'div', 'article', 'section', 'main', 'ul', 'ol', 'table', 'pre'].includes(el.tagName?.toLowerCase()))
);
if (meaningfulElement) {
log(`Selected element via area center: ${meaningfulElement.tagName}`);
debugLog(meaningfulElement.outerHTML.substring(0, 100) + '...');
return htmlToMarkdown(meaningfulElement);
} else {
log("Could not find a meaningful element at the center point.");
// Fallback: try the top-most element that isn't the script's stuff
const fallbackElement = elements.find(el =>
el &&
!el.classList.contains('markdown-copy-selection-box') &&
!el.classList.contains('markdown-copy-btn'));
if(fallbackElement){
log(`Using fallback element: ${fallbackElement.tagName}`);
return htmlToMarkdown(fallbackElement);
}
}
} catch (error) {
log(`Error in getSelectedContentFromArea: ${error.message}`);
}
return '';
}
// --- HTML to Markdown Conversion --- (Enhanced)
function htmlToMarkdown(element) {
if (!element) return '';
let markdown = '';
// Function to recursively process nodes
function processNode(node, listLevel = 0, listType = '') {
if (node.nodeType === Node.TEXT_NODE) {
// Replace multiple spaces/newlines with single space, unless in <pre>
const parentTag = node.parentNode?.tagName?.toLowerCase();
if (parentTag === 'pre' || node.parentNode?.closest('pre')) {
return node.textContent || ''; // Preserve whitespace in pre
}
let text = node.textContent || '';
text = text.replace(/\s+/g, ' '); // Consolidate whitespace
return text;
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return ''; // Ignore comments, etc.
}
// Ignore script, style, noscript, etc.
if (['script', 'style', 'noscript', 'button', 'textarea', 'input', 'select', 'option'].includes(node.tagName.toLowerCase())) {
return '';
}
// Ignore the script's own elements
if (node.classList.contains('markdown-copy-btn') || node.classList.contains('markdown-copy-selection-box')) {
return '';
}
let prefix = '';
let suffix = '';
let content = '';
const tag = node.tagName.toLowerCase();
const isBlock = window.getComputedStyle(node).display === 'block' || ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'pre', 'blockquote', 'hr', 'table', 'tr'].includes(tag);
// Process children first for most tags
for (const child of node.childNodes) {
content += processNode(child, listLevel + (tag === 'ul' || tag === 'ol' ? 1 : 0), (tag === 'ul' || tag === 'ol' ? tag : listType));
}
content = content.trim(); // Trim internal content
switch (tag) {
case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6':
prefix = '#'.repeat(parseInt(tag[1])) + ' ';
suffix = '\n\n';
break;
case 'p':
// Avoid adding extra newlines if content is empty or already ends with them
if (content) suffix = '\n\n';
break;
case 'strong': case 'b':
if (content) prefix = '**', suffix = '**';
break;
case 'em': case 'i':
if (content) prefix = '*', suffix = '*';
break;
case 'code':
// Handle inline code vs code block (inside pre)
if (node.closest('pre')) {
// Handled by 'pre' case, just return content
prefix = '', suffix = '';
} else {
if (content) prefix = '`', suffix = '`';
}
break;
case 'a':
const href = node.getAttribute('href');
if (content && href) {
// Handle relative URLs
const absoluteHref = new URL(href, window.location.href).href;
prefix = '[';
suffix = `](${absoluteHref})`;
} else {
// If link has no content but has href, just output URL maybe?
// Or just skip it. Let's skip.
prefix = ''; suffix = ''; content = '';
}
break;
case 'img':
const src = node.getAttribute('src');
const alt = node.getAttribute('alt') || '';
if (src) {
const absoluteSrc = new URL(src, window.location.href).href;
// Render as block element
prefix = ``;
suffix = '\n\n';
content = ''; // No content for images
}
break;
case 'ul':
case 'ol':
// Handled by child 'li' elements, add final newline if content exists
if (content) suffix = '\n\n';
else suffix = ''; // Avoid extra space if list empty
prefix = ''; content = ''; // Content aggregation is done in children
// Need to re-process children with list context here
for (const child of node.children) {
if (child.tagName.toLowerCase() === 'li') {
content += processNode(child, listLevel + 1, tag);
}
}
content = content.trimEnd(); // Remove trailing newline from last li
break;
case 'li':
const indent = ' '.repeat(Math.max(0, listLevel - 1));
prefix = indent + (listType === 'ol' ? '1. ' : '- '); // Simple numbering for ol
// Add newline, unless it's the last item handled by parent ul/ol
suffix = '\n';
break;
case 'blockquote':
// Add > prefix to each line
content = content.split('\n').map(line => '> ' + line).join('\n');
prefix = '';
suffix = '\n\n';
break;
case 'pre':
let codeContent = node.textContent || ''; // Get raw text content
let lang = '';
// Try to find language from class="language-..." on pre or inner code
const codeElement = node.querySelector('code[class*="language-"]');
const langClass = codeElement?.className.match(/language-(\S+)/);
if (langClass) {
lang = langClass[1];
} else {
const preLangClass = node.className.match(/language-(\S+)/);
if (preLangClass) lang = preLangClass[1];
}
prefix = '```' + lang + '\n';
suffix = '\n```\n\n';
content = codeContent.trim(); // Trim overall whitespace but preserve internal
break;
case 'hr':
prefix = '---';
suffix = '\n\n';
content = ''; // No content
break;
case 'table':
// Basic table support
let header = '';
let separator = '';
let body = '';
const rows = Array.from(node.querySelectorAll(':scope > thead > tr, :scope > tbody > tr, :scope > tr')); // More robust row finding
let firstRow = true;
for (const row of rows) {
let cols = [];
const cells = Array.from(row.querySelectorAll(':scope > th, :scope > td'));
cols = cells.map(cell => processNode(cell).replace(/\|/g, '\\|').trim()); // Escape pipes
if (firstRow && row.querySelector('th')) { // Assume header if first row has <th>
header = `| ${cols.join(' | ')} |`;
separator = `| ${cols.map(() => '---').join(' | ')} |`;
firstRow = false;
} else {
body += `| ${cols.join(' | ')} |\n`;
}
}
// Assemble table only if we found some structure
if (header && separator && body) {
prefix = header + '\n' + separator + '\n';
content = body.trim();
suffix = '\n\n';
} else if (body) { // Table with no header
prefix = '';
content = body.trim();
suffix = '\n\n';
}
else { // No meaningful table content
prefix = ''; content = ''; suffix = '';
}
break;
case 'br':
// Add double space for line break within paragraphs, or newline otherwise
const parentDisplay = node.parentNode ? window.getComputedStyle(node.parentNode).display : 'block';
if(parentDisplay !== 'block'){
prefix = ' \n'; // Markdown line break
} else {
prefix = '\n'; // Treat as paragraph break if parent is block
}
content = ''; suffix = '';
break;
// Default: block elements add newlines, inline elements don't
case 'div': case 'section': case 'article': case 'main': case 'header': case 'footer': case 'aside':
// Add newlines only if content exists and doesn't already end with plenty
if (content && !content.endsWith('\n\n')) {
suffix = '\n\n';
}
break;
default:
// For other inline elements, just pass content through
// For unrecognized block elements, add spacing if needed
if (isBlock && content && !content.endsWith('\n\n')) {
suffix = '\n\n';
}
break;
}
// Combine prefix, content, suffix. Trim whitespace around the final result for this node.
let result = prefix + content + suffix;
// Add spacing between block elements if needed
if (isBlock && markdown.length > 0 && !markdown.endsWith('\n\n') && !result.startsWith('\n')) {
// Ensure there's a blank line separating block elements
if (!markdown.endsWith('\n')) markdown += '\n';
markdown += '\n';
} else if (!isBlock && markdown.length > 0 && !markdown.endsWith(' ') && !markdown.endsWith('\n') && !result.startsWith(' ') && !result.startsWith('\n')) {
// Add a space between inline elements if needed
markdown += ' ';
}
markdown += result;
return result; // Return the result for recursive calls
} // End of processNode
try {
// Start processing from the root element provided
let rawMd = processNode(element);
// Final cleanup: consolidate multiple blank lines into one
rawMd = rawMd.replace(/\n{3,}/g, '\n\n');
return rawMd.trim(); // Trim final result
} catch (error) {
log(`Error during Markdown conversion: ${error.message}`);
return element.innerText || ''; // Fallback to innerText on error
}
} // End of htmlToMarkdown
// --- Clipboard & UI Feedback ---
function handleCopyAttempt(markdownContent, sourceType) {
if (markdownContent && markdownContent.trim().length > 0) {
try {
GM_setClipboard(markdownContent);
log(`${sourceType}: Markdown copied successfully! (Length: ${markdownContent.length})`);
showTemporaryMessage(BUTTON_TEXT_COPIED, false);
} catch (err) {
log(`${sourceType}: Copy failed: ${err.message}`);
showTemporaryMessage(BUTTON_TEXT_FAILED, true);
console.error("Clipboard copy error:", err);
}
} else {
log(`${sourceType}: No valid content detected to copy.`);
showTemporaryMessage(BUTTON_TEXT_FAILED, true); // Indicate failure if nothing was found
}
}
function showTemporaryMessage(text, isError) {
if (!copyBtn) return;
clearTimeout(messageTimeout); // Clear previous timeout if any
copyBtn.textContent = text;
copyBtn.classList.toggle('mc-copied', !isError);
copyBtn.classList.toggle('mc-failed', isError);
messageTimeout = setTimeout(() => {
setButtonState(BUTTON_TEXT_DEFAULT); // Restore default state
copyBtn.classList.remove('mc-copied', 'mc-failed');
}, TEMP_MESSAGE_DURATION);
}
function setButtonState(text) {
if (!copyBtn) return;
copyBtn.textContent = text;
// Clear temporary states if setting back to a standard message
if (text !== BUTTON_TEXT_COPIED && text !== BUTTON_TEXT_FAILED) {
copyBtn.classList.remove('mc-copied', 'mc-failed');
clearTimeout(messageTimeout); // Clear any pending message reset
}
// Store the original text if setting to default
if(text === BUTTON_TEXT_DEFAULT) {
originalButtonText = text;
}
}
// --- State Management & Cleanup ---
function removeDivHighlight() {
if (highlightedDiv) {
highlightedDiv.classList.remove('markdown-copy-div-highlight');
highlightedDiv = null;
debugLog('Div highlight removed.');
}
}
function removeSelectionBox() {
if (selectionBox) {
selectionBox.style.display = 'none'; // Hide instead of removing immediately
// Consider removing after a short delay if needed:
// setTimeout(() => { if (selectionBox) selectionBox.remove(); selectionBox = null; }, 50);
debugLog('Selection box hidden.');
// Reset start coords to prevent mouseup from triggering after cancellation
startX = null;
startY = null;
}
}
function resetSelectionState() {
isSelecting = false;
isDivMode = false; // Always reset to off state
document.body.style.cursor = 'default';
setButtonState(originalButtonText); // Restore original or default text
removeSelectionBox();
removeDivHighlight();
log('Selection state reset.');
}
function handleKeyDown(e) {
if (e.key === 'Escape' && isSelecting) {
log('Escape key pressed, cancelling selection.');
resetSelectionState();
}
}
// --- Script Entry Point ---
onDOMReady(() => {
// Small delay to let dynamic pages potentially load more content
setTimeout(() => {
try {
initScript();
} catch (err) {
log(`Critical error during script initialization: ${err.message}`);
console.error(err);
}
}, 100); // Wait 100ms after DOM ready
});
})();