Any Hackernews Link

Check if current page has been posted to Hacker News

当前为 2025-01-06 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Any Hackernews Link
// @namespace    http://tampermonkey.net/
// @version      0.1
// @description  Check if current page has been posted to Hacker News
// @author       RoCry
// @match        *://*/*
// @grant        GM_xmlhttpRequest
// @connect      hn.algolia.com
// @grant        GM_addStyle
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    /**
     * Configuration
     */
    const CONFIG = {
        // HN API endpoint
        API_URL: 'https://hn.algolia.com/api/v1/search',
        
        // List of domains to ignore
        IGNORED_DOMAINS: [
            'news.ycombinator.com',
            'hn.algolia.com',
            'mail.google.com',
            'gmail.com',
            'outlook.com',
            'yahoo.com',
            'proton.me',
            'localhost',
            'accounts.google.com',
            'drive.google.com',
            'docs.google.com',
            'calendar.google.com',
            'meet.google.com',
            'chat.google.com',
            'web.whatsapp.com',
            'twitter.com/messages',
            'facebook.com/messages',
            'linkedin.com/messaging'
        ],

        // Patterns that indicate a search page
        SEARCH_PATTERNS: [
            '/search',
            '/webhp',
            '/results',
            '?q=',
            '?query=',
            '?search=',
            '?s='
        ],

        // URL parameters to remove during normalization
        TRACKING_PARAMS: [
            'utm_source',
            'utm_medium',
            'utm_campaign',
            'utm_term',
            'utm_content',
            'fbclid',
            'gclid',
            '_ga',
            'ref',
            'source'
        ]
    };

    /**
     * Styles
     */
    const STYLES = `
        @keyframes fadeIn {
            0% { opacity: 0; transform: translateY(10px); }
            100% { opacity: 1; transform: translateY(0); }
        }
        @keyframes pulse {
            0% { opacity: 1; }
            50% { opacity: 0.6; }
            100% { opacity: 1; }
        }
        #hn-float {
            position: fixed;
            bottom: 20px;
            left: 20px;
            z-index: 9999;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
            display: flex;
            align-items: center;
            gap: 12px;
            background: rgba(255, 255, 255, 0.98);
            padding: 8px 12px;
            border-radius: 12px;
            box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.05);
            cursor: pointer;
            transition: all 0.2s ease;
            max-width: 50px;
            overflow: hidden;
            opacity: 0.95;
            height: 40px;
            backdrop-filter: blur(8px);
            -webkit-backdrop-filter: blur(8px);
            animation: fadeIn 0.3s ease forwards;
            will-change: transform, max-width, box-shadow;
            color: #111827;
        }
        #hn-float:hover {
            max-width: 600px;
            opacity: 1;
            transform: translateY(-2px);
            box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.05);
        }
        #hn-float .hn-icon {
            min-width: 24px;
            width: 24px;
            height: 24px;
            background: linear-gradient(135deg, #ff6600, #ff7f33);
            color: white;
            display: flex;
            align-items: center;
            justify-content: center;
            font-weight: bold;
            border-radius: 6px;
            flex-shrink: 0;
            position: relative;
            font-size: 13px;
            text-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);
            transition: transform 0.2s ease;
        }
        #hn-float:hover .hn-icon {
            transform: scale(1.05);
        }
        #hn-float .hn-icon.not-found {
            background: #9ca3af;
        }
        #hn-float .hn-icon.found {
            background: linear-gradient(135deg, #ff6600, #ff7f33);
        }
        #hn-float .hn-icon.loading {
            background: #6b7280;
            animation: pulse 1.5s infinite;
        }
        #hn-float .hn-icon .badge {
            position: absolute;
            top: -6px;
            right: -6px;
            background: linear-gradient(135deg, #3b82f6, #2563eb);
            color: white;
            border-radius: 10px;
            min-width: 18px;
            height: 18px;
            font-size: 11px;
            display: flex;
            align-items: center;
            justify-content: center;
            padding: 0 4px;
            font-weight: 600;
            box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
            border: 2px solid white;
        }
        #hn-float .hn-info {
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
            line-height: 1.4;
            font-size: 13px;
            opacity: 0;
            transition: opacity 0.2s ease;
        }
        #hn-float:hover .hn-info {
            opacity: 1;
        }
        #hn-float .hn-info a {
            color: inherit;
            font-weight: 500;
            text-decoration: none;
        }
        #hn-float .hn-info a:hover {
            text-decoration: underline;
        }
        #hn-float .hn-stats {
            color: #6b7280;
            font-size: 12px;
            margin-top: 2px;
        }
        @media (prefers-color-scheme: dark) {
            #hn-float {
                background: rgba(17, 24, 39, 0.95);
                color: #e5e7eb;
                box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(255, 255, 255, 0.1);
            }
            #hn-float:hover {
                box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.1);
            }
            #hn-float .hn-stats {
                color: #9ca3af;
            }
            #hn-float .hn-icon .badge {
                border-color: rgba(17, 24, 39, 0.95);
            }
        }
    `;

    /**
     * URL Utilities
     */
    const URLUtils = {
        /**
         * Check if a URL should be ignored based on domain or search patterns
         * @param {string} url - URL to check
         * @returns {boolean} - True if URL should be ignored
         */
        shouldIgnoreUrl(url) {
            try {
                const urlObj = new URL(url);
                
                // Check ignored domains
                if (CONFIG.IGNORED_DOMAINS.some(domain => urlObj.hostname.includes(domain))) {
                    return true;
                }

                // Check if it's a search page
                if (CONFIG.SEARCH_PATTERNS.some(pattern => 
                    urlObj.pathname.includes(pattern) || urlObj.search.includes(pattern))) {
                    return true;
                }

                return false;
            } catch (e) {
                console.error('Error checking URL:', e);
                return false;
            }
        },

        /**
         * Normalize URL by removing tracking parameters and standardizing format
         * @param {string} url - URL to normalize
         * @returns {string} - Normalized URL
         */
        normalizeUrl(url) {
            try {
                const urlObj = new URL(url);
                
                // Remove tracking parameters
                CONFIG.TRACKING_PARAMS.forEach(param => urlObj.searchParams.delete(param));
                
                // Remove hash
                urlObj.hash = '';
                
                // Remove trailing slash for consistency
                let normalizedUrl = urlObj.toString();
                if (normalizedUrl.endsWith('/')) {
                    normalizedUrl = normalizedUrl.slice(0, -1);
                }
                
                return normalizedUrl;
            } catch (e) {
                console.error('Error normalizing URL:', e);
                return url;
            }
        },

        /**
         * Compare two URLs for equality after normalization
         * @param {string} url1 - First URL
         * @param {string} url2 - Second URL
         * @returns {boolean} - True if URLs match
         */
        urlsMatch(url1, url2) {
            try {
                const u1 = new URL(this.normalizeUrl(url1));
                const u2 = new URL(this.normalizeUrl(url2));
                
                return u1.hostname.toLowerCase() === u2.hostname.toLowerCase() &&
                       u1.pathname.toLowerCase() === u2.pathname.toLowerCase() &&
                       u1.search === u2.search;
            } catch (e) {
                console.error('Error comparing URLs:', e);
                return false;
            }
        }
    };

    /**
     * UI Component
     */
    const UI = {
        /**
         * Create and append the floating element to the page
         * @returns {HTMLElement} - The created element
         */
        createFloatingElement() {
            const div = document.createElement('div');
            div.id = 'hn-float';
            div.innerHTML = `
                <div class="hn-icon loading">Y</div>
                <div class="hn-info">Checking HN...</div>
            `;
            document.body.appendChild(div);
            return div;
        },

        /**
         * Update the floating element with HN data
         * @param {Object|null} data - HN post data or null if not found
         */
        updateFloatingElement(data) {
            const iconDiv = document.querySelector('#hn-float .hn-icon');
            const infoDiv = document.querySelector('#hn-float .hn-info');
            
            iconDiv.classList.remove('loading');
            
            if (!data) {
                iconDiv.classList.add('not-found');
                iconDiv.classList.remove('found');
                iconDiv.innerHTML = 'Y';
                infoDiv.textContent = 'Not found on HN';
                return;
            }
            
            iconDiv.classList.remove('not-found');
            iconDiv.classList.add('found');
            iconDiv.innerHTML = `Y${data.comments > 0 ? 
                `<span class="badge">${data.comments > 999 ? '999+' : data.comments}</span>` : ''}`;
            
            infoDiv.innerHTML = `
                <div><a href="${data.link}" target="_blank">${data.title}</a></div>
                <div class="hn-stats">
                    ${data.points} points | ${data.comments} comments | ${data.posted}
                </div>
            `;
        }
    };

    /**
     * HackerNews API Handler
     */
    const HNApi = {
        /**
         * Search for a URL on HackerNews
         * @param {string} normalizedUrl - URL to search for
         */
        checkHackerNews(normalizedUrl) {
            const apiUrl = `${CONFIG.API_URL}?query=${encodeURIComponent(normalizedUrl)}&restrictSearchableAttributes=url`;
            
            GM_xmlhttpRequest({
                method: 'GET',
                url: apiUrl,
                onload: (response) => this.handleApiResponse(response, normalizedUrl),
                onerror: (error) => {
                    console.error('Error fetching from HN API:', error);
                    UI.updateFloatingElement(null);
                }
            });
        },

        /**
         * Handle the API response
         * @param {Object} response - API response
         * @param {string} normalizedUrl - Original normalized URL
         */
        handleApiResponse(response, normalizedUrl) {
            try {
                const data = JSON.parse(response.responseText);
                const matchingHits = data.hits.filter(hit => URLUtils.urlsMatch(hit.url, normalizedUrl));
                
                if (matchingHits.length === 0) {
                    console.log('🔍 URL not found on Hacker News');
                    UI.updateFloatingElement(null);
                    return;
                }

                const topHit = matchingHits.sort((a, b) => (b.points || 0) - (a.points || 0))[0];
                const result = {
                    title: topHit.title,
                    points: topHit.points || 0,
                    comments: topHit.num_comments || 0,
                    link: `https://news.ycombinator.com/item?id=${topHit.objectID}`,
                    posted: new Date(topHit.created_at).toLocaleDateString()
                };

                console.log('📰 Found on Hacker News:', result);
                UI.updateFloatingElement(result);
            } catch (e) {
                console.error('Error parsing HN API response:', e);
                UI.updateFloatingElement(null);
            }
        }
    };

    /**
     * Initialize the script
     */
    function init() {
        const currentUrl = window.location.href;
        
        if (URLUtils.shouldIgnoreUrl(currentUrl)) {
            return;
        }

        GM_addStyle(STYLES);
        const normalizedUrl = URLUtils.normalizeUrl(currentUrl);
        console.log('🔗 Normalized URL:', normalizedUrl);
        
        UI.createFloatingElement();
        HNApi.checkHackerNews(normalizedUrl);
    }

    // Start the script
    init();
})();