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.3
// @description  Show exact post dates on hover for LinkedIn posts
// @author       chr1sx
// @match        https://www.linkedin.com/*
// @grant        none
// @license      MIT
// @icon         https://www.google.com/s2/favicons?domain=linkedin.com&sz=64
// ==/UserScript==

(function() {
    'use strict';

    const TIME_PATTERN = /^\s*\d+\s*(?:s|m|h|d|w|mo|yr|y)\b/i;
    const LINKEDIN_EPOCH = new Date('2003-01-01').getTime();
    const ID_PATTERN = /(?:activity|ugcPost|share)[:-](\d{18,19})/;

    function extractTimestampFromId(id) {
        try {
            const binaryStr = BigInt(id).toString(2);
            const timestamp = parseInt(binaryStr.substring(0, 41), 2);
            if (timestamp > LINKEDIN_EPOCH && timestamp <= Date.now()) return timestamp;
        } catch(e) {}
        return null;
    }

    function extractTimestampFromUrl(url) {
        const m = url.match(ID_PATTERN);
        if (m) return extractTimestampFromId(m[1]);
        return null;
    }

    function formatDate(timestamp) {
        return new Date(timestamp).toLocaleString('en-US', {
            year: 'numeric', month: 'short', day: 'numeric',
            hour: '2-digit', minute: '2-digit'
        });
    }

    function getIdFromTrackingScope(attrValue) {
        try {
            const items = JSON.parse(attrValue);
            let postId = null;
            let commentId = null;
            for (const item of items) {
                const dataBytes = item?.breadcrumb?.content?.data;
                if (!Array.isArray(dataBytes)) continue;
                const inner = String.fromCharCode(...dataBytes);
                if (item.topicName === 'CommentServedEvent') {
                    const m = inner.match(/comment:\(urn:li:activity:\d+,(\d{18,19})\)/);
                    if (m) commentId = m[1];
                } else if (item.topicName === 'FeedUpdateServedEvent') {
                    const m = inner.match(ID_PATTERN);
                    if (m) postId = m[1];
                }
            }
            return commentId || postId;
        } catch(e) {}
        return null;
    }

    function findPostUrl(element) {
        const PLAIN_URN_ATTRS = [
            'data-urn', 'data-attributed-urn', 'data-id',
            'data-semaphore-content-urn', 'data-activity-urn', 'data-entity-urn',
        ];

        let node = element;
        while (node && node !== document.body) {
            const ck = node.getAttribute && (node.getAttribute('componentkey') || '');
            const cm = ck.match(/comment:\([^,]+,(\d{18,19})\)/);
            if (cm) return `activity:${cm[1]}`;
            node = node.parentElement;
        }

        let trackingScopeId = null;
        node = element;

        while (node && node !== document.body) {
            if (!node.getAttribute) { node = node.parentElement; continue; }

            for (const attr of PLAIN_URN_ATTRS) {
                const val = node.getAttribute(attr);
                if (val) {
                    const m = val.match(ID_PATTERN);
                    if (m) return `activity:${m[1]}`;
                }
            }

            if (node.querySelectorAll) {
                const reshareLink = node.querySelector('a[data-view-name="feed-original-share-description"]');
                if (reshareLink) {
                    const hasTrackingScope = !!node.querySelector('[data-view-tracking-scope]');
                    if (!hasTrackingScope) {
                        const m = reshareLink.getAttribute('href').match(ID_PATTERN);
                        if (m) return `activity:${m[1]}`;
                    }
                }

                for (const a of node.querySelectorAll(
                    'a[href*="feed/update"]:not([data-view-name="feed-original-share-description"]),' +
                    'a[href*="activity:"]:not([data-view-name="feed-original-share-description"]),' +
                    'a[href*="ugcPost"]:not([data-view-name="feed-original-share-description"])'
                )) {
                    const m = a.getAttribute('href').match(ID_PATTERN);
                    if (m) return `activity:${m[1]}`;
                }
            }

            if (!trackingScopeId) {
                const scope = node.getAttribute('data-view-tracking-scope');
                if (scope) trackingScopeId = getIdFromTrackingScope(scope);
            }

            node = node.parentElement;
        }

        if (trackingScopeId) return `activity:${trackingScopeId}`;
        return window.location.href;
    }

    function isTimeElement(element) {
        const knownClasses = [
            'update-components-actor__sub-description',
            'comment__duration-since',
            'comments-comment-meta__data'
        ];
        if (knownClasses.some(c => element.classList.contains(c))) return true;
        if (element.tagName === 'TIME') return true;

        const directText = Array.from(element.childNodes)
            .filter(n => n.nodeType === Node.TEXT_NODE)
            .map(n => n.textContent)
            .join('');
        return TIME_PATTERN.test(directText);
    }

    function processTimeElements() {
        document.querySelectorAll('time, p, span').forEach(element => {
            if (element.dataset.linkedinDateProcessed) return;
            if (!isTimeElement(element)) return;

            const postUrl = findPostUrl(element);
            if (!postUrl) return;

            const timestamp = extractTimestampFromUrl(postUrl);
            if (!timestamp) return;

            element.title = formatDate(timestamp);
            element.dataset.linkedinDateProcessed = 'true';
            element.style.cursor = 'help';
        });
    }

    setTimeout(processTimeElements, 1500);
    const observer = new MutationObserver(() => processTimeElements());
    observer.observe(document.body, { childList: true, subtree: true });
    setInterval(processTimeElements, 3000);
})();