Any Hackernews Link

Check if current page has been posted to Hacker News

目前為 2025-01-06 提交的版本,檢視 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Any Hackernews Link
// @namespace    http://tampermonkey.net/
// @version      0.1.3
// @description  Check if current page has been posted to Hacker News
// @author       RoCry
// @icon         https://news.ycombinator.com/favicon.ico
// @match        https://*/*
// @exclude      https://news.ycombinator.com/*
// @exclude      https://hn.algolia.com/*
// @exclude      https://*.google.com/*
// @exclude      https://mail.yahoo.com/*
// @exclude      https://outlook.com/*
// @exclude      https://proton.me/*
// @exclude      https://localhost/*
// @exclude      https://127.0.0.1/*
// @exclude      https://192.168.*.*/*
// @exclude      https://10.*.*.*/*
// @exclude      https://172.16.*.*/*
// @exclude      https://web.whatsapp.com/*
// @exclude      https://*.facebook.com/messages/*
// @exclude      https://*.twitter.com/messages/*
// @exclude      https://*.linkedin.com/messaging/*
// @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',
        
        // Additional domains to ignore that couldn't be handled by @exclude
        IGNORED_DOMAINS: [
            'gmail.com',
            'accounts.google.com',
            'accounts.youtube.com',
            'signin.',
            'login.',
            'auth.',
            'oauth.',
        ],

        // 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'
        ],

        // Minimum ratio of ASCII characters to consider content as English
        MIN_ASCII_RATIO: 0.9,
        
        // Number of characters to check for language detection
        CHARS_TO_CHECK: 300
    };

    /**
     * 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;
            display: flex;
            align-items: center;
            height: 40px;
            box-sizing: border-box;
        }
        #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;
            line-height: 1;
            padding-bottom: 1px;
        }
        #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: -4px;
            right: -4px;
            background: linear-gradient(135deg, #3b82f6, #2563eb);
            color: white;
            border-radius: 8px;
            min-width: 14px;
            height: 14px;
            font-size: 10px;
            display: flex;
            align-items: center;
            justify-content: center;
            padding: 0 3px;
            font-weight: 600;
            box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
            border: 1.5px 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;
            width: 0;
            flex: 0;
        }
        #hn-float:hover .hn-info {
            opacity: 1;
            width: auto;
            flex: 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 remaining 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;
            }
        }
    };

    /**
     * Content Utilities
     */
    const ContentUtils = {
        /**
         * Check if text is primarily English by checking ASCII ratio
         * @param {string} text - Text to analyze
         * @returns {boolean} - True if content is likely English
         */
        isEnglishContent() {
            try {
                // Get text from title and first paragraph or relevant content
                const title = document.title || '';
                const firstParagraphs = Array.from(document.getElementsByTagName('p'))
                    .slice(0, 3)
                    .map(p => p.textContent)
                    .join(' ');
                
                const textToAnalyze = (title + ' ' + firstParagraphs)
                    .slice(0, CONFIG.CHARS_TO_CHECK)
                    .replace(/\s+/g, ' ')
                    .trim();

                if (!textToAnalyze) return true; // If no text found, assume English

                // Count ASCII characters (excluding spaces and common punctuation)
                const asciiChars = textToAnalyze.replace(/[\s\.,\-_'"!?()]/g, '')
                    .split('')
                    .filter(char => char.charCodeAt(0) <= 127).length;
                
                const totalChars = textToAnalyze.replace(/[\s\.,\-_'"!?()]/g, '').length;
                
                if (totalChars === 0) return true;
                
                const asciiRatio = asciiChars / totalChars;
                console.log('🈂️ ASCII Ratio:', asciiRatio.toFixed(2));
                
                return asciiRatio >= CONFIG.MIN_ASCII_RATIO;
            } catch (e) {
                console.error('Error checking content language:', e);
                return true; // Default to allowing English in case of error
            }
        }
    };

    /**
     * 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';
            // Create icon element
            const iconDiv = document.createElement('div');
            iconDiv.className = 'hn-icon loading';
            iconDiv.textContent = 'Y';
            
            // Create info element
            const infoDiv = document.createElement('div');
            infoDiv.className = 'hn-info';
            infoDiv.textContent = 'Checking HN...';
            
            // Append children
            div.appendChild(iconDiv);
            div.appendChild(infoDiv);
            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.textContent = 'Y';
                infoDiv.textContent = 'Not found on HN';
                return;
            }
            
            iconDiv.classList.remove('not-found');
            iconDiv.classList.add('found');
            // Clear existing content
            iconDiv.textContent = 'Y';
            
            // Add badge if there are comments
            if (data.comments > 0) {
                const badge = document.createElement('span');
                badge.className = 'badge';
                badge.textContent = data.comments > 999 ? '999+' : data.comments.toString();
                iconDiv.appendChild(badge);
            }
            
            // Clear and rebuild info content
            infoDiv.textContent = '';
            
            const titleDiv = document.createElement('div');
            const titleLink = document.createElement('a');
            titleLink.href = data.link;
            titleLink.target = '_blank';
            titleLink.textContent = data.title;
            titleDiv.appendChild(titleLink);
            
            const statsDiv = document.createElement('div');
            statsDiv.className = 'hn-stats';
            statsDiv.textContent = `${data.points} points | ${data.comments} comments | ${data.posted}`;
            
            infoDiv.appendChild(titleDiv);
            infoDiv.appendChild(statsDiv);
        }
    };

    /**
     * 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() {
        // Skip if we're in an iframe
        if (window.top !== window.self) {
            console.log('📌 Skipping execution in iframe');
            return;
        }

        // Skip if document is hidden (like background tabs or invisible frames)
        if (document.hidden) {
            console.log('📌 Skipping execution in hidden document');
            // Add listener for when the tab becomes visible
            document.addEventListener('visibilitychange', function onVisible() {
                if (!document.hidden) {
                    init();
                    document.removeEventListener('visibilitychange', onVisible);
                }
            });
            return;
        }

        const currentUrl = window.location.href;
        
        // Check if the floating element already exists
        if (document.getElementById('hn-float')) {
            console.log('📌 HN float already exists, skipping');
            return;
        }

        if (URLUtils.shouldIgnoreUrl(currentUrl)) {
            console.log('🚫 Ignored URL:', currentUrl);
            return;
        }

        // Check if content is primarily English
        if (!ContentUtils.isEnglishContent()) {
            console.log('🈂️ Non-English content detected, skipping');
            return;
        }

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

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