- // ==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();
- })();