您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds "Copy Link" and "Save to Raindrop.io" buttons to LinkedIn posts, including single post pages.
// ==UserScript== // @name LinkedIn Post Enhancer: Copy Link & Save to Raindrop.io // @namespace http://tampermonkey.net/ // @version 1.0 // @description Adds "Copy Link" and "Save to Raindrop.io" buttons to LinkedIn posts, including single post pages. // @author Gemini assisted by @ProtoPioneer // @match https://www.linkedin.com/feed/* // @match https://www.linkedin.com/posts/* // @icon https://www.google.com/s2/favicons?sz=64&domain=linkedin.com // @grant none // @license MIT // ==/UserScript== (function() { 'use strict'; // SVG icon for the "Copy Link" button (clipboard) const copySVG = ` <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="artdeco-button__icon"> <path d="M16 1H4C2.9 1 2 1.9 2 3v14h2V3h12V1zm3 4H8C6.9 5 6 5.9 6 7v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/> </svg> `; // SVG icon for the "Copied!" state (clipboard with checkmark) const copiedSVG = ` <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#0073B1" class="artdeco-button__icon"> <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/> </svg> `; // SVG icon for the "Save to Raindrop.io" button (simple raindrop/cloud) const raindropSVG = ` <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="artdeco-button__icon"> <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/> </svg> `; // Base URL for the Raindrop.io bookmarklet to add new links const raindropBaseUrl = 'https://app.raindrop.io/add'; /** * Creates a styled button element that matches LinkedIn's social action bar buttons. * @param {string} iconHtml - The SVG HTML string to be used as the button's icon. * @param {string} text - The text label for the button. * @param {function} onClickHandler - The event handler function to execute when the button is clicked. * @returns {HTMLElement} The created HTML button element, wrapped in a span for correct LinkedIn styling. */ function createActionButton(iconHtml, text, onClickHandler) { // Create the wrapper span that LinkedIn uses for its action buttons const buttonSpan = document.createElement('span'); buttonSpan.classList.add( 'feed-shared-social-action-bar__action-button', 'feed-shared-social-action-bar--new-padding' // Maintains consistent padding with other buttons ); buttonSpan.setAttribute('tabindex', '-1'); // Ensures proper tab navigation behavior // Create the actual button element const button = document.createElement('button'); button.classList.add( 'artdeco-button', 'artdeco-button--muted', 'artdeco-button--3', 'artdeco-button--tertiary', 'ember-view', // Mimic ember-view for consistent behavior, though not strictly functional 'social-actions-button', 'flex-wrap' // For flexible content layout within the button ); button.setAttribute('role', 'button'); // Explicitly define role for accessibility button.setAttribute('type', 'button'); // Standard button type button.setAttribute('aria-label', text); // Provide an accessible label for screen readers // Create the inner content for the button (icon and text) const buttonContent = document.createElement('span'); buttonContent.classList.add('artdeco-button__text'); buttonContent.innerHTML = ` <div class="flex-wrap justify-center artdeco-button__text align-items-center"> ${iconHtml} <!-- Inject the SVG icon --> <span aria-hidden="true" class="artdeco-button__text social-action-button__text">${text}</span> </div> `; // Append content to button, and button to its wrapper span button.appendChild(buttonContent); button.addEventListener('click', onClickHandler); // Attach the click handler buttonSpan.appendChild(button); return buttonSpan; } /** * Extracts the canonical LinkedIn post URL from a given post element. * LinkedIn posts have a unique URN (Uniform Resource Name) in their data-id or data-urn attribute. * This URN is used to construct the direct link to the post. * @param {HTMLElement} postElement - The DOM element representing a single LinkedIn post. * @returns {string|null} The full URL of the post, or null if the URN cannot be found. */ function extractLinkedInPostUrl(postElement) { let urn = postElement.getAttribute('data-id') || postElement.getAttribute('data-urn'); if (urn && urn.startsWith('urn:li:activity:')) { const postId = urn.split(':').pop(); return `https://www.linkedin.com/feed/update/urn:li:activity:${postId}/`; } return null; } /** * Extracts a suitable title, author, and content snippet for the LinkedIn post for bookmarking purposes. * @param {HTMLElement} postElement - The DOM element representing a single LinkedIn post. * @returns {{title: string, author: string, description: string}} An object containing the extracted data. */ function extractLinkedInPostDetails(postElement) { let authorName = ''; let postDescription = ''; let postTitle = 'LinkedIn Post'; // Default title // Extract author name const actorNameElement = postElement.querySelector('.update-components-actor__title span[dir="ltr"] span[aria-hidden="true"]'); if (actorNameElement) { authorName = actorNameElement.innerText.trim(); } // Extract main text content/commentary of the post const commentaryElement = postElement.querySelector('.update-components-text span[dir="ltr"]'); if (commentaryElement) { postDescription = commentaryElement.innerText.trim(); } // Construct a more descriptive title for Raindrop.io if (authorName && postDescription) { postTitle = `${authorName} - ${postDescription.substring(0, 70).replace(/\n/g, ' ')}... - LinkedIn`; } else if (authorName) { postTitle = `${authorName}'s LinkedIn Post`; } else if (postDescription) { postTitle = `${postDescription.substring(0, 70).replace(/\n/g, ' ')}... - LinkedIn`; } return { title: postTitle, author: authorName, description: postDescription }; } /** * Adds the custom "Copy Link" and "Save to Raindrop.io" buttons to a specific LinkedIn post. * It ensures buttons are only added once per post by marking the post element. * @param {HTMLElement} postElement - The DOM element of the LinkedIn post to which buttons should be added. */ function addCustomButtons(postElement) { const socialActionBar = postElement.querySelector('.feed-shared-social-action-bar'); // Check if the social action bar exists and if buttons have already been added to this specific post if (socialActionBar && !postElement.dataset.customButtonsAdded) { const postUrl = extractLinkedInPostUrl(postElement); const postDetails = extractLinkedInPostDetails(postElement); // If a URL cannot be extracted, log a warning and do not proceed with adding buttons if (!postUrl) { console.warn('Could not extract LinkedIn post URL for:', postElement); return; } // Mark this post element to indicate that custom buttons have been added, // preventing duplicate buttons on subsequent DOM mutations. postElement.dataset.customButtonsAdded = 'true'; // --- 1. Create and configure the "Copy Link" Button --- const copyButton = createActionButton(copySVG, 'Copy Link', (event) => { event.stopPropagation(); // Stop event propagation to prevent triggering LinkedIn's default click handlers on the parent button area. // Attempt to copy the URL to the clipboard using the modern Clipboard API navigator.clipboard.writeText(postUrl) .then(() => { // Store original icon and text to revert after visual feedback const originalIconHtml = copyButton.querySelector('.artdeco-button__icon').outerHTML; const originalText = copyButton.querySelector('.social-action-button__text').innerText; // Provide visual feedback: change icon to a checkmark and text to "Copied!" copyButton.querySelector('.artdeco-button__icon').outerHTML = copiedSVG; copyButton.querySelector('.social-action-button__text').innerText = 'Copied!'; // Revert the button's appearance back to its original state after a short delay setTimeout(() => { copyButton.querySelector('.artdeco-button__icon').outerHTML = originalIconHtml; copyButton.querySelector('.social-action-button__text').innerText = originalText; }, 1500); // Revert after 1.5 seconds console.log('LinkedIn post link copied:', postUrl); }) .catch(err => { // Fallback for environments where navigator.clipboard.writeText() is not supported or blocked (e.g., some older browsers or stricter iframe policies) console.error('Error copying LinkedIn link with Clipboard API, attempting fallback: ', err); const tempInput = document.createElement('input'); tempInput.value = postUrl; document.body.appendChild(tempInput); tempInput.select(); // Select the text in the input try { document.execCommand('copy'); // Execute the copy command console.log('LinkedIn post link copied (fallback):', postUrl); // Provide visual feedback for fallback success const originalIconHtml = copyButton.querySelector('.artdeco-button__icon').outerHTML; const originalText = copyButton.querySelector('.social-action-button__text').innerText; copyButton.querySelector('.artdeco-button__icon').outerHTML = copiedSVG; copyButton.querySelector('.social-action-button__text').innerText = 'Copied!'; setTimeout(() => { copyButton.querySelector('.artdeco-button__icon').outerHTML = originalIconHtml; copyButton.querySelector('.social-action-button__text').innerText = originalText; }, 1500); } catch (fallbackErr) { console.error('Fallback copy failed: ', fallbackErr); } finally { document.body.removeChild(tempInput); // Clean up the temporary input element } }); }); // --- 2. Create and configure the "Save to Raindrop.io" Button --- // Use dash as separator for author name in tags, and add "author-" prefix const sanitizedAuthorTag = 'author-' + postDetails.author.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '-').toLowerCase(); const tags = `linkedin.com,${sanitizedAuthorTag}`; // Construct the Raindrop.io bookmarklet URL with all extracted details const raindropUrl = `${raindropBaseUrl}?link=${encodeURIComponent(postUrl)}&title=${encodeURIComponent(postDetails.title)}&description=${encodeURIComponent(postDetails.description)}&tags=${encodeURIComponent(tags)}`; const raindropButton = createActionButton(raindropSVG, 'Save to Raindrop', (event) => { event.stopPropagation(); // Stop event propagation // Open the Raindrop.io bookmarking page in a new browser tab/window window.open(raindropUrl, '_blank'); console.log('Opening Raindrop.io for:', postUrl); }); // --- Append the new buttons to the social action bar --- // Find the last existing social action button (e.g., "Enviar" / "Send") const existingButtons = socialActionBar.querySelectorAll('.feed-shared-social-action-bar__action-button'); if (existingButtons.length > 0) { const lastButton = existingButtons[existingButtons.length - 1]; // Insert the new buttons immediately after the last existing button lastButton.after(copyButton); copyButton.after(raindropButton); // Insert Raindrop button after Copy button } else { // Fallback: If for some reason no existing buttons are found, just append to the action bar socialActionBar.appendChild(copyButton); socialActionBar.appendChild(raindropButton); } } } /** * Finds all LinkedIn post elements on the page and adds custom buttons if not already present. * This function now also accounts for the structure of single post pages. */ function processAllLinkedInPosts() { // Select all elements that are identified as LinkedIn posts on the feed page document.querySelectorAll('div[data-id^="urn:li:activity:"]').forEach(postElement => { addCustomButtons(postElement); }); // Additionally, select the main post element if on a single post detail page // The HTML structure on a single post page is slightly different. // It uses `data-urn` on the `feed-shared-update-v2` element itself. const singlePostElement = document.querySelector('.feed-shared-update-v2[data-urn^="urn:li:activity:"]'); if (singlePostElement) { addCustomButtons(singlePostElement); } } /** * Sets up a MutationObserver to continuously watch for new LinkedIn posts being added to the DOM. * LinkedIn dynamically loads content, so this ensures that new posts also get the custom buttons. */ function observeLinkedInFeed() { const observer = new MutationObserver((mutationsList) => { for (const mutation of mutationsList) { if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { mutation.addedNodes.forEach(node => { if (node.nodeType === 1) { // Ensure it's an element node // Check if the added node is directly a LinkedIn feed post if (node.matches('div[data-id^="urn:li:activity:"]')) { addCustomButtons(node); } // Check if the added node is directly a single LinkedIn post on a detail page else if (node.matches('.feed-shared-update-v2[data-urn^="urn:li:activity:"]')) { addCustomButtons(node); } // Also, check for posts nested within the added node's subtree. // This catches cases where a larger container element is added, which then holds posts. node.querySelectorAll('div[data-id^="urn:li:activity:"]').forEach(nestedPostElement => { addCustomButtons(nestedPostElement); }); node.querySelectorAll('.feed-shared-update-v2[data-urn^="urn:li:activity:"]').forEach(nestedSinglePostElement => { addCustomButtons(nestedSinglePostElement); }); } }); } } }); observer.observe(document.body, { childList: true, subtree: true }); // Perform an initial scan on page load to add buttons to any posts // already present in the DOM before dynamic loading occurs. processAllLinkedInPosts(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', observeLinkedInFeed); } else { observeLinkedInFeed(); } })();