// ==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== // @downloadURL https://update.greasyfork.cloud/scripts/529449/%E6%96%87%E6%9C%AC%E7%BD%91%E9%A1%B5%E8%87%AA%E7%94%B1%E5%A4%8D%E5%88%B6-Markdown.user.js // @updateURL https://update.greasyfork.cloud/scripts/529449/%E6%96%87%E6%9C%AC%E7%BD%91%E9%A1%B5%E8%87%AA%E7%94%B1%E5%A4%8D%E5%88%B6-Markdown.meta.js // ==/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
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 hasheader = `| ${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 }); })();