Any Hackernews Link

Check if current page has been posted to Hacker News

目前为 2025-01-13 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name Any Hackernews Link
  3. // @namespace http://tampermonkey.net/
  4. // @version 0.1.4
  5. // @description Check if current page has been posted to Hacker News
  6. // @author RoCry
  7. // @icon https://news.ycombinator.com/favicon.ico
  8. // @match https://*/*
  9. // @exclude https://news.ycombinator.com/*
  10. // @exclude https://hn.algolia.com/*
  11. // @exclude https://*.google.com/*
  12. // @exclude https://mail.yahoo.com/*
  13. // @exclude https://outlook.com/*
  14. // @exclude https://proton.me/*
  15. // @exclude https://localhost/*
  16. // @exclude https://127.0.0.1/*
  17. // @exclude https://192.168.*.*/*
  18. // @exclude https://10.*.*.*/*
  19. // @exclude https://172.16.*.*/*
  20. // @exclude https://web.whatsapp.com/*
  21. // @exclude https://*.facebook.com/messages/*
  22. // @exclude https://*.twitter.com/messages/*
  23. // @exclude https://*.linkedin.com/messaging/*
  24. // @grant GM_xmlhttpRequest
  25. // @connect hn.algolia.com
  26. // @grant GM_addStyle
  27. // @license MIT
  28. // ==/UserScript==
  29.  
  30. (function() {
  31. 'use strict';
  32.  
  33. /**
  34. * Configuration
  35. */
  36. const CONFIG = {
  37. // HN API endpoint
  38. API_URL: 'https://hn.algolia.com/api/v1/search',
  39. // Additional domains to ignore that couldn't be handled by @exclude
  40. IGNORED_DOMAINS: [
  41. 'gmail.com',
  42. 'accounts.google.com',
  43. 'accounts.youtube.com',
  44. 'signin.',
  45. 'login.',
  46. 'auth.',
  47. 'oauth.',
  48. ],
  49.  
  50. // Patterns that indicate a search page
  51. SEARCH_PATTERNS: [
  52. '/search',
  53. '/webhp',
  54. '/results',
  55. '?q=',
  56. '?query=',
  57. '?search=',
  58. '?s='
  59. ],
  60.  
  61. // URL parameters to remove during normalization
  62. TRACKING_PARAMS: [
  63. 'utm_source',
  64. 'utm_medium',
  65. 'utm_campaign',
  66. 'utm_term',
  67. 'utm_content',
  68. 'fbclid',
  69. 'gclid',
  70. '_ga',
  71. 'ref',
  72. 'source'
  73. ],
  74.  
  75. // Minimum ratio of ASCII characters to consider content as English
  76. MIN_ASCII_RATIO: 0.9,
  77. // Number of characters to check for language detection
  78. CHARS_TO_CHECK: 300
  79. };
  80.  
  81. /**
  82. * Styles
  83. */
  84. const STYLES = `
  85. @keyframes fadeIn {
  86. 0% { opacity: 0; transform: translateY(10px); }
  87. 100% { opacity: 1; transform: translateY(0); }
  88. }
  89. @keyframes pulse {
  90. 0% { opacity: 1; }
  91. 50% { opacity: 0.6; }
  92. 100% { opacity: 1; }
  93. }
  94. #hn-float {
  95. position: fixed;
  96. bottom: 20px;
  97. left: 20px;
  98. z-index: 9999;
  99. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  100. display: flex;
  101. align-items: center;
  102. gap: 12px;
  103. background: rgba(255, 255, 255, 0.98);
  104. padding: 8px 12px;
  105. border-radius: 12px;
  106. box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.05);
  107. cursor: pointer;
  108. transition: all 0.2s ease;
  109. max-width: 50px;
  110. overflow: hidden;
  111. opacity: 0.95;
  112. height: 40px;
  113. backdrop-filter: blur(8px);
  114. -webkit-backdrop-filter: blur(8px);
  115. animation: fadeIn 0.3s ease forwards;
  116. will-change: transform, max-width, box-shadow;
  117. color: #111827;
  118. display: flex;
  119. align-items: center;
  120. height: 40px;
  121. box-sizing: border-box;
  122. }
  123. #hn-float:hover {
  124. max-width: 600px;
  125. opacity: 1;
  126. transform: translateY(-2px);
  127. box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.05);
  128. }
  129. #hn-float .hn-icon {
  130. min-width: 24px;
  131. width: 24px;
  132. height: 24px;
  133. background: linear-gradient(135deg, #ff6600, #ff7f33);
  134. color: white;
  135. display: flex;
  136. align-items: center;
  137. justify-content: center;
  138. font-weight: bold;
  139. border-radius: 6px;
  140. flex-shrink: 0;
  141. position: relative;
  142. font-size: 13px;
  143. text-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);
  144. transition: transform 0.2s ease;
  145. line-height: 1;
  146. padding-bottom: 1px;
  147. }
  148. #hn-float:hover .hn-icon {
  149. transform: scale(1.05);
  150. }
  151. #hn-float .hn-icon.not-found {
  152. background: #9ca3af;
  153. }
  154. #hn-float .hn-icon.found {
  155. background: linear-gradient(135deg, #ff6600, #ff7f33);
  156. }
  157. #hn-float .hn-icon.loading {
  158. background: #6b7280;
  159. animation: pulse 1.5s infinite;
  160. }
  161. #hn-float .hn-icon .badge {
  162. position: absolute;
  163. top: -4px;
  164. right: -4px;
  165. background: linear-gradient(135deg, #3b82f6, #2563eb);
  166. color: white;
  167. border-radius: 8px;
  168. min-width: 14px;
  169. height: 14px;
  170. font-size: 10px;
  171. display: flex;
  172. align-items: center;
  173. justify-content: center;
  174. padding: 0 3px;
  175. font-weight: 600;
  176. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  177. border: 1.5px solid white;
  178. }
  179. #hn-float .hn-info {
  180. white-space: nowrap;
  181. overflow: hidden;
  182. text-overflow: ellipsis;
  183. line-height: 1.4;
  184. font-size: 13px;
  185. opacity: 0;
  186. transition: opacity 0.2s ease;
  187. width: 0;
  188. flex: 0;
  189. }
  190. #hn-float:hover .hn-info {
  191. opacity: 1;
  192. width: auto;
  193. flex: 1;
  194. }
  195. #hn-float .hn-info a {
  196. color: inherit;
  197. font-weight: 500;
  198. text-decoration: none;
  199. }
  200. #hn-float .hn-info a:hover {
  201. text-decoration: underline;
  202. }
  203. #hn-float .hn-stats {
  204. color: #6b7280;
  205. font-size: 12px;
  206. margin-top: 2px;
  207. }
  208. @media (prefers-color-scheme: dark) {
  209. #hn-float {
  210. background: rgba(17, 24, 39, 0.95);
  211. color: #e5e7eb;
  212. box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(255, 255, 255, 0.1);
  213. }
  214. #hn-float:hover {
  215. box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.1);
  216. }
  217. #hn-float .hn-stats {
  218. color: #9ca3af;
  219. }
  220. #hn-float .hn-icon .badge {
  221. border-color: rgba(17, 24, 39, 0.95);
  222. }
  223. }
  224. `;
  225.  
  226. /**
  227. * URL Utilities
  228. */
  229. const URLUtils = {
  230. /**
  231. * Check if a URL should be ignored based on domain or search patterns
  232. * @param {string} url - URL to check
  233. * @returns {boolean} - True if URL should be ignored
  234. */
  235. shouldIgnoreUrl(url) {
  236. try {
  237. const urlObj = new URL(url);
  238. // Check remaining ignored domains
  239. if (CONFIG.IGNORED_DOMAINS.some(domain => urlObj.hostname.includes(domain))) {
  240. return true;
  241. }
  242.  
  243. // Check if it's a search page
  244. if (CONFIG.SEARCH_PATTERNS.some(pattern =>
  245. urlObj.pathname.includes(pattern) || urlObj.search.includes(pattern))) {
  246. return true;
  247. }
  248.  
  249. return false;
  250. } catch (e) {
  251. console.error('Error checking URL:', e);
  252. return false;
  253. }
  254. },
  255.  
  256. /**
  257. * Normalize URL by removing tracking parameters and standardizing format
  258. * @param {string} url - URL to normalize
  259. * @returns {string} - Normalized URL
  260. */
  261. normalizeUrl(url) {
  262. try {
  263. const urlObj = new URL(url);
  264. // Remove tracking parameters
  265. CONFIG.TRACKING_PARAMS.forEach(param => urlObj.searchParams.delete(param));
  266. // Remove tab parameter for all hosts
  267. // https://github.com/HackerNews/API?tab=readme-ov-file -> https://github.com/HackerNews/API
  268. urlObj.searchParams.delete('tab');
  269. // Remove hash
  270. urlObj.hash = '';
  271. // Remove trailing slash for consistency
  272. let normalizedUrl = urlObj.toString();
  273. if (normalizedUrl.endsWith('/')) {
  274. normalizedUrl = normalizedUrl.slice(0, -1);
  275. }
  276. return normalizedUrl;
  277. } catch (e) {
  278. console.error('Error normalizing URL:', e);
  279. return url;
  280. }
  281. },
  282.  
  283. /**
  284. * Compare two URLs for equality after normalization
  285. * @param {string} url1 - First URL
  286. * @param {string} url2 - Second URL
  287. * @returns {boolean} - True if URLs match
  288. */
  289. urlsMatch(url1, url2) {
  290. try {
  291. const u1 = new URL(this.normalizeUrl(url1));
  292. const u2 = new URL(this.normalizeUrl(url2));
  293. return u1.hostname.toLowerCase() === u2.hostname.toLowerCase() &&
  294. u1.pathname.toLowerCase() === u2.pathname.toLowerCase() &&
  295. u1.search === u2.search;
  296. } catch (e) {
  297. console.error('Error comparing URLs:', e);
  298. return false;
  299. }
  300. }
  301. };
  302.  
  303. /**
  304. * Content Utilities
  305. */
  306. const ContentUtils = {
  307. /**
  308. * Check if text is primarily English by checking ASCII ratio
  309. * @param {string} text - Text to analyze
  310. * @returns {boolean} - True if content is likely English
  311. */
  312. isEnglishContent() {
  313. try {
  314. // Get text from title and first paragraph or relevant content
  315. const title = document.title || '';
  316. const firstParagraphs = Array.from(document.getElementsByTagName('p'))
  317. .slice(0, 3)
  318. .map(p => p.textContent)
  319. .join(' ');
  320. const textToAnalyze = (title + ' ' + firstParagraphs)
  321. .slice(0, CONFIG.CHARS_TO_CHECK)
  322. .replace(/\s+/g, ' ')
  323. .trim();
  324.  
  325. if (!textToAnalyze) return true; // If no text found, assume English
  326.  
  327. // Count ASCII characters (excluding spaces and common punctuation)
  328. const asciiChars = textToAnalyze.replace(/[\s\.,\-_'"!?()]/g, '')
  329. .split('')
  330. .filter(char => char.charCodeAt(0) <= 127).length;
  331. const totalChars = textToAnalyze.replace(/[\s\.,\-_'"!?()]/g, '').length;
  332. if (totalChars === 0) return true;
  333. const asciiRatio = asciiChars / totalChars;
  334. console.log('🈂️ ASCII Ratio:', asciiRatio.toFixed(2));
  335. return asciiRatio >= CONFIG.MIN_ASCII_RATIO;
  336. } catch (e) {
  337. console.error('Error checking content language:', e);
  338. return true; // Default to allowing English in case of error
  339. }
  340. }
  341. };
  342.  
  343. /**
  344. * UI Component
  345. */
  346. const UI = {
  347. /**
  348. * Create and append the floating element to the page
  349. * @returns {HTMLElement} - The created element
  350. */
  351. createFloatingElement() {
  352. const div = document.createElement('div');
  353. div.id = 'hn-float';
  354. // Create icon element
  355. const iconDiv = document.createElement('div');
  356. iconDiv.className = 'hn-icon loading';
  357. iconDiv.textContent = 'Y';
  358. // Create info element
  359. const infoDiv = document.createElement('div');
  360. infoDiv.className = 'hn-info';
  361. infoDiv.textContent = 'Checking HN...';
  362. // Append children
  363. div.appendChild(iconDiv);
  364. div.appendChild(infoDiv);
  365. document.body.appendChild(div);
  366. return div;
  367. },
  368.  
  369. /**
  370. * Update the floating element with HN data
  371. * @param {Object|null} data - HN post data or null if not found
  372. */
  373. updateFloatingElement(data) {
  374. const iconDiv = document.querySelector('#hn-float .hn-icon');
  375. const infoDiv = document.querySelector('#hn-float .hn-info');
  376. iconDiv.classList.remove('loading');
  377. if (!data) {
  378. iconDiv.classList.add('not-found');
  379. iconDiv.classList.remove('found');
  380. iconDiv.textContent = 'Y';
  381. infoDiv.textContent = 'Not found on HN';
  382. return;
  383. }
  384. iconDiv.classList.remove('not-found');
  385. iconDiv.classList.add('found');
  386. // Clear existing content
  387. iconDiv.textContent = 'Y';
  388. // Add badge if there are comments
  389. if (data.comments > 0) {
  390. const badge = document.createElement('span');
  391. badge.className = 'badge';
  392. badge.textContent = data.comments > 999 ? '999+' : data.comments.toString();
  393. iconDiv.appendChild(badge);
  394. }
  395. // Clear and rebuild info content
  396. infoDiv.textContent = '';
  397. const titleDiv = document.createElement('div');
  398. const titleLink = document.createElement('a');
  399. titleLink.href = data.link;
  400. titleLink.target = '_blank';
  401. titleLink.textContent = data.title;
  402. titleDiv.appendChild(titleLink);
  403. const statsDiv = document.createElement('div');
  404. statsDiv.className = 'hn-stats';
  405. statsDiv.textContent = `${data.points} points | ${data.comments} comments | ${data.posted}`;
  406. infoDiv.appendChild(titleDiv);
  407. infoDiv.appendChild(statsDiv);
  408. }
  409. };
  410.  
  411. /**
  412. * HackerNews API Handler
  413. */
  414. const HNApi = {
  415. /**
  416. * Search for a URL on HackerNews
  417. * @param {string} normalizedUrl - URL to search for
  418. */
  419. checkHackerNews(normalizedUrl) {
  420. const apiUrl = `${CONFIG.API_URL}?query=${encodeURIComponent(normalizedUrl)}&restrictSearchableAttributes=url`;
  421. GM_xmlhttpRequest({
  422. method: 'GET',
  423. url: apiUrl,
  424. onload: (response) => this.handleApiResponse(response, normalizedUrl),
  425. onerror: (error) => {
  426. console.error('Error fetching from HN API:', error);
  427. UI.updateFloatingElement(null);
  428. }
  429. });
  430. },
  431.  
  432. /**
  433. * Handle the API response
  434. * @param {Object} response - API response
  435. * @param {string} normalizedUrl - Original normalized URL
  436. */
  437. handleApiResponse(response, normalizedUrl) {
  438. try {
  439. const data = JSON.parse(response.responseText);
  440. const matchingHits = data.hits.filter(hit => URLUtils.urlsMatch(hit.url, normalizedUrl));
  441. if (matchingHits.length === 0) {
  442. console.log('🔍 URL not found on Hacker News');
  443. UI.updateFloatingElement(null);
  444. return;
  445. }
  446.  
  447. const topHit = matchingHits.sort((a, b) => (b.points || 0) - (a.points || 0))[0];
  448. const result = {
  449. title: topHit.title,
  450. points: topHit.points || 0,
  451. comments: topHit.num_comments || 0,
  452. link: `https://news.ycombinator.com/item?id=${topHit.objectID}`,
  453. posted: new Date(topHit.created_at).toLocaleDateString()
  454. };
  455.  
  456. console.log('📰 Found on Hacker News:', result);
  457. UI.updateFloatingElement(result);
  458. } catch (e) {
  459. console.error('Error parsing HN API response:', e);
  460. UI.updateFloatingElement(null);
  461. }
  462. }
  463. };
  464.  
  465. /**
  466. * Initialize the script
  467. */
  468. function init() {
  469. // Skip if we're in an iframe
  470. if (window.top !== window.self) {
  471. console.log('📌 Skipping execution in iframe');
  472. return;
  473. }
  474.  
  475. // Skip if document is hidden (like background tabs or invisible frames)
  476. if (document.hidden) {
  477. console.log('📌 Skipping execution in hidden document');
  478. // Add listener for when the tab becomes visible
  479. document.addEventListener('visibilitychange', function onVisible() {
  480. if (!document.hidden) {
  481. init();
  482. document.removeEventListener('visibilitychange', onVisible);
  483. }
  484. });
  485. return;
  486. }
  487.  
  488. const currentUrl = window.location.href;
  489. // Check if the floating element already exists
  490. if (document.getElementById('hn-float')) {
  491. console.log('📌 HN float already exists, skipping');
  492. return;
  493. }
  494.  
  495. if (URLUtils.shouldIgnoreUrl(currentUrl)) {
  496. console.log('🚫 Ignored URL:', currentUrl);
  497. return;
  498. }
  499.  
  500. // Check if content is primarily English
  501. if (!ContentUtils.isEnglishContent()) {
  502. console.log('🈂️ Non-English content detected, skipping');
  503. return;
  504. }
  505.  
  506. GM_addStyle(STYLES);
  507. const normalizedUrl = URLUtils.normalizeUrl(currentUrl);
  508. console.log('🔗 Normalized URL:', normalizedUrl);
  509. UI.createFloatingElement();
  510. HNApi.checkHackerNews(normalizedUrl);
  511. }
  512.  
  513. // Start the script
  514. init();
  515. })();