LinkedIn Exact Post Date

Show exact post dates on hover for LinkedIn posts

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

You will need to install an extension such as Tampermonkey to install this script.

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         LinkedIn Exact Post Date
// @namespace    https://github.com/chr1sx
// @version      1.0.1
// @description  Show exact post dates on hover for LinkedIn posts
// @author       chr1sx
// @match        https://www.linkedin.com/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // Function to extract timestamp from LinkedIn post URL
    function extractTimestampFromUrl(postUrl) {
        const match = postUrl.match(/activity[:-](\d+)/);
        if (match) {
            const postId = match[1];
            
            // Convert to binary string
            const binaryStr = BigInt(postId).toString(2);
            
            // Extract first 41 bits
            const first41Bits = binaryStr.substring(0, 41);
            
            // Convert back to decimal to get milliseconds since Unix epoch
            const timestamp = parseInt(first41Bits, 2);
            
            return timestamp;
        }
        return null;
    }

    // Function to format date
    function formatDate(timestamp) {
        const date = new Date(timestamp);
        const options = { 
            year: 'numeric', 
            month: 'short', 
            day: 'numeric',
            hour: '2-digit',
            minute: '2-digit'
        };
        return date.toLocaleString('en-US', options);
    }

    // Function to process time elements
    function processTimeElements() {
        // Look for all types of time/date elements
        const selectors = [
            'time',
            'span.update-components-actor__sub-description',
            'span.comment__duration-since',
            'span.comments-comment-meta__data'
        ];
        
        selectors.forEach(selector => {
            const elements = document.querySelectorAll(selector);
            
            elements.forEach(element => {
                // Skip if already processed
                if (element.dataset.linkedinDateProcessed) return;
                
                const text = element.textContent.trim();
                
                // Extract just the time portion (before the • symbol if present)
                const timeMatch = text.match(/^\s*(\d+[smhdwyo]+)/i);
                if (!timeMatch) return;
                
                const timeText = timeMatch[1];
                
                // Try to find the post URL - prioritize closest parent first
                let postUrl = null;
                
                // Check if we're in a comment - try both logged-in and public HTML structures
                const commentArticle = element.closest('article.comments-comment-entity');
                const commentSection = element.closest('section.comment');
                
                if (commentArticle) {
                    const commentId = commentArticle.getAttribute('data-id');
                    if (commentId) {
                        // Extract comment ID from format: urn:li:comment:(ugcPost:7387159705648852992,7387168140029370368)
                        const match = commentId.match(/,(\d{19})\)/);
                        if (match) {
                            postUrl = `activity:${match[1]}`;
                        }
                    }
                } else if (commentSection) {
                    // For public view, look for semaphore link with comment URN
                    const semaphoreLink = commentSection.querySelector('a[data-semaphore-content-urn*="comment"]');
                    if (semaphoreLink) {
                        const urn = semaphoreLink.getAttribute('data-semaphore-content-urn');
                        const match = urn?.match(/,(\d{19})\)/);
                        if (match) {
                            postUrl = `activity:${match[1]}`;
                        }
                    }
                }
                
                // If not in a comment, check if we're inside any type of reshared/nested post
                if (!postUrl) {
                    const reshareContainer = element.closest('.feed-reshare-content, .update-components-mini-update-v2__reshared-content, .uNprduUKOhHAYMWYDczNkVBuaMsUpsSo');
                    if (reshareContainer) {
                        // Look for the data-attributed-urn on the reshare container
                        const attributedUrn = reshareContainer.getAttribute('data-attributed-urn');
                        if (attributedUrn) {
                            // Extract the post ID from the URN (format: urn:li:ugcPost:7384893926446440448)
                            const match = attributedUrn.match(/(\d{19})/);
                            if (match) {
                                postUrl = `activity:${match[1]}`;
                            }
                        }
                        
                        // If no data-attributed-urn, look for activity link within the reshare container
                        if (!postUrl) {
                            // Try multiple link patterns
                            const link = reshareContainer.querySelector('a[href*="/feed/update/urn:li:activity:"]') ||
                                        reshareContainer.querySelector('a.update-components-mini-update-v2__link-to-details-page');
                            if (link) {
                                const href = link.getAttribute('href');
                                const match = href.match(/activity:(\d{19})/);
                                if (match) {
                                    postUrl = `activity:${match[1]}`;
                                }
                            }
                        }
                    }
                }
                
                // If not in a reshare or no URN found, look for main post URL
                if (!postUrl) {
                    // Check for data-urn on parent elements
                    let parent = element.closest('div[data-urn]');
                    if (parent) {
                        const urn = parent.getAttribute('data-urn');
                        const match = urn?.match(/\d{19}/);
                        if (match) {
                            postUrl = `activity:${match[0]}`;
                        }
                    }
                }
                
                // Check for activity link in parent (but not inside reshare containers)
                if (!postUrl) {
                    const parent = element.closest('article, .feed-shared-update-v2, [data-id]');
                    if (parent && !parent.closest('.feed-reshare-content, .update-components-mini-update-v2__reshared-content, .uNprduUKOhHAYMWYDczNkVBuaMsUpsSo')) {
                        const link = parent.querySelector('a[href*="activity"]');
                        if (link) postUrl = link.href;
                    }
                }
                
                // Use current page URL as fallback
                if (!postUrl) {
                    postUrl = window.location.href;
                }
                
                if (postUrl) {
                    const timestamp = extractTimestampFromUrl(postUrl);
                    if (timestamp) {
                        const exactDate = formatDate(timestamp);
                        
                        // Keep original text, just add tooltip with exact date
                        element.title = exactDate;
                        element.dataset.linkedinDateProcessed = 'true';
                        element.style.cursor = 'help';
                    }
                }
            });
        });
    }

    // Run initially
    setTimeout(processTimeElements, 1000);

    // Watch for DOM changes (LinkedIn loads content dynamically)
    const observer = new MutationObserver(() => {
        processTimeElements();
    });

    observer.observe(document.body, {
        childList: true,
        subtree: true
    });

    // Also run periodically as a fallback
    setInterval(processTimeElements, 2000);
})();