- // ==UserScript==
- // @name Any Hackernews Link
- // @namespace http://tampermonkey.net/
- // @version 0.1.4
- // @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 tab parameter for all hosts
- // https://github.com/HackerNews/API?tab=readme-ov-file -> https://github.com/HackerNews/API
- urlObj.searchParams.delete('tab');
-
- // 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();
- })();