LinkedIn Job Info Copier

Adds a button to copy LinkedIn job post information to the clipboard.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         LinkedIn Job Info Copier
// @namespace    http://tampermonkey.net/
// @version      1.1
// @description  Adds a button to copy LinkedIn job post information to the clipboard.
// @author       Lich_Amnesia
// @match        https://www.linkedin.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=linkedin.com
// @grant        GM_setClipboard
// @grant        GM_addStyle
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    const BUTTON_ID = 'copy-job-info-button';
    const BUTTON_TEXT = '📋 Copy Job Info';
    const BUTTON_COPIED_TEXT = '✅ Copied!';

    // --- Helper function to safely get text content ---
    function safeGetText(selector, element = document) {
        try {
            const el = element.querySelector(selector);
            // Prioritize innerText for better representation of rendered text, fallback to textContent
            return el ? (el.innerText || el.textContent || '').trim() : 'N/A';
        } catch (e) {
            console.error(`Error getting text for selector "${selector}":`, e);
            return 'Error';
        }
    }

    // --- Helper function to safely get text from multiple elements ---
     function safeGetAllText(selector, separator = '\n- ', element = document) {
        try {
            const els = element.querySelectorAll(selector);
            if (els.length === 0) return 'N/A';
            return Array.from(els).map(el => (el.innerText || el.textContent || '').trim()).filter(Boolean).join(separator);
        } catch (e) {
            console.error(`Error getting all text for selector "${selector}":`, e);
            return 'Error';
        }
    }

    // --- Function to extract job details ---
    function getJobInfo() {
        const topCardSelector = '.job-details-jobs-unified-top-card__container--two-pane'; // More specific container
        const topCardElement = document.querySelector(topCardSelector);

        if (!topCardElement) {
             console.warn("LinkedIn Job Info Copier: Top card element not found yet.");
             return null; // Element not ready
        }


        const jobTitle = safeGetText('.job-details-jobs-unified-top-card__job-title h1 a', topCardElement) || safeGetText('.job-details-jobs-unified-top-card__job-title h1', topCardElement); // Handle both cases h1>a or just h1
        const companyName = safeGetText('.job-details-jobs-unified-top-card__company-name a', topCardElement);
        const tertiaryInfo = safeGetText('.job-details-jobs-unified-top-card__primary-description-container .job-details-jobs-unified-top-card__tertiary-description-container', topCardElement).replace(/\s*·\s*/g, ' | '); // Location, Posted, Applicants

        // Extract pills (Salary, Hybrid, Full-time etc.)
        const pillsRaw = safeGetAllText('.job-details-preferences-and-skills__pill span.ui-label', topCardElement, ' | ');
        // Attempt to split Salary/Benefits from other pills if structure is consistent
        let salary = 'N/A';
        let detailsPills = [];
        const pillElements = topCardElement.querySelectorAll('.job-details-preferences-and-skills__pill span.ui-label');
        pillElements.forEach(pill => {
            const text = (pill.innerText || pill.textContent || '').trim();
            if (text.includes('$') || text.toLowerCase().includes('salary') || text.toLowerCase().includes('/yr')) {
                salary = text;
            } else if (!text.toLowerCase().includes('skills match')) { // Exclude the 'skills match' pill
                detailsPills.push(text);
            }
        });
        const otherDetails = detailsPills.join(' | ') || 'N/A';


        // Job Description
        const jobDescriptionElement = document.querySelector('#job-details .jobs-box__html-content') || document.querySelector('#job-details'); // Try specific content div first
        let jobDescription = 'N/A';
        if (jobDescriptionElement) {
            // Clone to avoid modifying the original, remove the "About the job" heading if present
            const clone = jobDescriptionElement.cloneNode(true);
            const heading = clone.querySelector('h2');
            if (heading && (heading.innerText || heading.textContent || '').trim().toLowerCase() === 'about the job') {
                heading.remove();
            }
             // Try to get innerText for better formatting, fallback to textContent
            jobDescription = (clone.innerText || clone.textContent || '').trim().replace(/\n{3,}/g, '\n\n'); // Reduce excessive newlines
        }


        const jobUrl = window.location.href;

        // Format the output
        const formattedInfo = `Job Title: ${jobTitle}\n` +
                              `Company: ${companyName}\n` +
                              `Location/Info: ${tertiaryInfo}\n` +
                              `Salary/Compensation: ${salary}\n` +
                              `Type/Mode: ${otherDetails}\n` +
                              `URL: ${jobUrl}\n\n` +
                              `---------------- Job Description ----------------\n` +
                              `${jobDescription}`;

        return formattedInfo;
    }

    // --- Function to copy text to clipboard and provide feedback ---
    function copyInfoToClipboard(button) {
        const jobInfoText = getJobInfo();
        if (!jobInfoText) {
             alert("Could not extract job information. The page structure might have changed or not fully loaded.");
             return;
        }

        GM_setClipboard(jobInfoText, 'text');

        // Provide feedback
        const originalText = button.textContent;
        button.textContent = BUTTON_COPIED_TEXT;
        button.disabled = true;
        button.classList.add('copied');

        setTimeout(() => {
            button.textContent = originalText;
            button.disabled = false;
            button.classList.remove('copied');
        }, 2000); // Revert after 2 seconds
    }

    // --- Function to create and add the button ---
    function addCopyButton() {
         // Check if button already exists
        if (document.getElementById(BUTTON_ID)) {
            return;
        }

        // Find the container for the "Easy Apply" / "Save" buttons within the top card
        // Look for the div containing buttons like '.jobs-apply-button' or '.jobs-save-button'
        const buttonContainer = document.querySelector('.job-details-jobs-unified-top-card__container--two-pane .mt4 .display-flex');

        if (buttonContainer) {
            const copyButton = document.createElement('button');
            copyButton.id = BUTTON_ID;
            copyButton.textContent = BUTTON_TEXT;
            // Try to mimic LinkedIn button styles
            copyButton.className = 'artdeco-button artdeco-button--secondary artdeco-button--3'; // Using secondary style like 'Save'
            copyButton.style.marginLeft = '8px'; // Add some space

            copyButton.addEventListener('click', () => {
                copyInfoToClipboard(copyButton);
            });

            // Insert after the Easy Apply button if it exists, otherwise append
            const applyButton = buttonContainer.querySelector('.jobs-s-apply');
             if (applyButton && applyButton.nextSibling) {
                 buttonContainer.insertBefore(copyButton, applyButton.nextSibling);
             } else if (applyButton) {
                 buttonContainer.appendChild(copyButton); // Append if apply is last
             }
             else {
                // Fallback: just append if structure is unexpected
                 buttonContainer.appendChild(copyButton);
            }
            console.log("LinkedIn Job Info Copier: Button added successfully.");

        } else {
            console.warn("LinkedIn Job Info Copier: Button container not found. Cannot add button.");
        }
    }

    // --- Add custom styles for the button ---
    GM_addStyle(`
        #${BUTTON_ID} {
            /* Add any specific overrides if needed */
            cursor: pointer;
        }
        #${BUTTON_ID}.copied {
            background-color: #dff0d8 !important; /* Light green background */
            color: #3c763d !important; /* Darker green text */
            border-color: #d6e9c6 !important;
        }
    `);

    // --- Use MutationObserver to wait for the job details section to load ---
    // LinkedIn loads content dynamically, so we need to wait.
    const observerTargetSelector = '.job-view-layout.jobs-details'; // Main container for job view
    let observer = null;

    function startObserver() {
        const targetNode = document.querySelector(observerTargetSelector);
        if (!targetNode) {
            // If the main container isn't even there yet, wait a bit longer
             console.log("LinkedIn Job Info Copier: Waiting for main job view container...");
             setTimeout(startObserver, 500);
             return;
        }

        console.log("LinkedIn Job Info Copier: Observer started.");
        observer = new MutationObserver((mutationsList, obs) => {
            // Check if the button container is now available and the button isn't already added
            const buttonContainer = document.querySelector('.job-details-jobs-unified-top-card__container--two-pane .mt4 .display-flex');
             if (buttonContainer && !document.getElementById(BUTTON_ID)) {
                 console.log("LinkedIn Job Info Copier: Target container appeared, adding button.");
                 addCopyButton();
                 // Optional: Could disconnect observer here if the button container won't be removed/re-added
                 // obs.disconnect();
             }
             // Also check if the job description is loaded, sometimes it loads later
             const description = document.querySelector('#job-details .jobs-box__html-content');
              if (description && buttonContainer && !document.getElementById(BUTTON_ID)) {
                   console.log("LinkedIn Job Info Copier: Description appeared, ensuring button exists.");
                   addCopyButton(); // Try adding again just in case
             }
        });

        observer.observe(targetNode, {
            childList: true, // Watch for additions/removals of children
            subtree: true    // Watch descendants as well
        });

         // Initial attempt in case content is already present when script runs
         addCopyButton();
    }

    // --- Start the process ---
    // Use window.onload or setTimeout as a fallback if observer setup fails immediately
     if (document.readyState === 'complete') {
         startObserver();
     } else {
         window.addEventListener('load', startObserver);
     }
     // Extra fallback timeout
      setTimeout(addCopyButton, 3000); // Attempt to add after 3 seconds regardless


})();