LinkedIn Exact Post Date

Show exact post dates on hover for LinkedIn posts

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 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);
})();