Any Hackernews Link

Check if current page has been posted to Hacker News

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

  1. // ==UserScript==
  2. // @name Any Hackernews Link
  3. // @namespace http://tampermonkey.net/
  4. // @version 0.1.3
  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 hash
  267. urlObj.hash = '';
  268. // Remove trailing slash for consistency
  269. let normalizedUrl = urlObj.toString();
  270. if (normalizedUrl.endsWith('/')) {
  271. normalizedUrl = normalizedUrl.slice(0, -1);
  272. }
  273. return normalizedUrl;
  274. } catch (e) {
  275. console.error('Error normalizing URL:', e);
  276. return url;
  277. }
  278. },
  279.  
  280. /**
  281. * Compare two URLs for equality after normalization
  282. * @param {string} url1 - First URL
  283. * @param {string} url2 - Second URL
  284. * @returns {boolean} - True if URLs match
  285. */
  286. urlsMatch(url1, url2) {
  287. try {
  288. const u1 = new URL(this.normalizeUrl(url1));
  289. const u2 = new URL(this.normalizeUrl(url2));
  290. return u1.hostname.toLowerCase() === u2.hostname.toLowerCase() &&
  291. u1.pathname.toLowerCase() === u2.pathname.toLowerCase() &&
  292. u1.search === u2.search;
  293. } catch (e) {
  294. console.error('Error comparing URLs:', e);
  295. return false;
  296. }
  297. }
  298. };
  299.  
  300. /**
  301. * Content Utilities
  302. */
  303. const ContentUtils = {
  304. /**
  305. * Check if text is primarily English by checking ASCII ratio
  306. * @param {string} text - Text to analyze
  307. * @returns {boolean} - True if content is likely English
  308. */
  309. isEnglishContent() {
  310. try {
  311. // Get text from title and first paragraph or relevant content
  312. const title = document.title || '';
  313. const firstParagraphs = Array.from(document.getElementsByTagName('p'))
  314. .slice(0, 3)
  315. .map(p => p.textContent)
  316. .join(' ');
  317. const textToAnalyze = (title + ' ' + firstParagraphs)
  318. .slice(0, CONFIG.CHARS_TO_CHECK)
  319. .replace(/\s+/g, ' ')
  320. .trim();
  321.  
  322. if (!textToAnalyze) return true; // If no text found, assume English
  323.  
  324. // Count ASCII characters (excluding spaces and common punctuation)
  325. const asciiChars = textToAnalyze.replace(/[\s\.,\-_'"!?()]/g, '')
  326. .split('')
  327. .filter(char => char.charCodeAt(0) <= 127).length;
  328. const totalChars = textToAnalyze.replace(/[\s\.,\-_'"!?()]/g, '').length;
  329. if (totalChars === 0) return true;
  330. const asciiRatio = asciiChars / totalChars;
  331. console.log('🈂️ ASCII Ratio:', asciiRatio.toFixed(2));
  332. return asciiRatio >= CONFIG.MIN_ASCII_RATIO;
  333. } catch (e) {
  334. console.error('Error checking content language:', e);
  335. return true; // Default to allowing English in case of error
  336. }
  337. }
  338. };
  339.  
  340. /**
  341. * UI Component
  342. */
  343. const UI = {
  344. /**
  345. * Create and append the floating element to the page
  346. * @returns {HTMLElement} - The created element
  347. */
  348. createFloatingElement() {
  349. const div = document.createElement('div');
  350. div.id = 'hn-float';
  351. // Create icon element
  352. const iconDiv = document.createElement('div');
  353. iconDiv.className = 'hn-icon loading';
  354. iconDiv.textContent = 'Y';
  355. // Create info element
  356. const infoDiv = document.createElement('div');
  357. infoDiv.className = 'hn-info';
  358. infoDiv.textContent = 'Checking HN...';
  359. // Append children
  360. div.appendChild(iconDiv);
  361. div.appendChild(infoDiv);
  362. document.body.appendChild(div);
  363. return div;
  364. },
  365.  
  366. /**
  367. * Update the floating element with HN data
  368. * @param {Object|null} data - HN post data or null if not found
  369. */
  370. updateFloatingElement(data) {
  371. const iconDiv = document.querySelector('#hn-float .hn-icon');
  372. const infoDiv = document.querySelector('#hn-float .hn-info');
  373. iconDiv.classList.remove('loading');
  374. if (!data) {
  375. iconDiv.classList.add('not-found');
  376. iconDiv.classList.remove('found');
  377. iconDiv.textContent = 'Y';
  378. infoDiv.textContent = 'Not found on HN';
  379. return;
  380. }
  381. iconDiv.classList.remove('not-found');
  382. iconDiv.classList.add('found');
  383. // Clear existing content
  384. iconDiv.textContent = 'Y';
  385. // Add badge if there are comments
  386. if (data.comments > 0) {
  387. const badge = document.createElement('span');
  388. badge.className = 'badge';
  389. badge.textContent = data.comments > 999 ? '999+' : data.comments.toString();
  390. iconDiv.appendChild(badge);
  391. }
  392. // Clear and rebuild info content
  393. infoDiv.textContent = '';
  394. const titleDiv = document.createElement('div');
  395. const titleLink = document.createElement('a');
  396. titleLink.href = data.link;
  397. titleLink.target = '_blank';
  398. titleLink.textContent = data.title;
  399. titleDiv.appendChild(titleLink);
  400. const statsDiv = document.createElement('div');
  401. statsDiv.className = 'hn-stats';
  402. statsDiv.textContent = `${data.points} points | ${data.comments} comments | ${data.posted}`;
  403. infoDiv.appendChild(titleDiv);
  404. infoDiv.appendChild(statsDiv);
  405. }
  406. };
  407.  
  408. /**
  409. * HackerNews API Handler
  410. */
  411. const HNApi = {
  412. /**
  413. * Search for a URL on HackerNews
  414. * @param {string} normalizedUrl - URL to search for
  415. */
  416. checkHackerNews(normalizedUrl) {
  417. const apiUrl = `${CONFIG.API_URL}?query=${encodeURIComponent(normalizedUrl)}&restrictSearchableAttributes=url`;
  418. GM_xmlhttpRequest({
  419. method: 'GET',
  420. url: apiUrl,
  421. onload: (response) => this.handleApiResponse(response, normalizedUrl),
  422. onerror: (error) => {
  423. console.error('Error fetching from HN API:', error);
  424. UI.updateFloatingElement(null);
  425. }
  426. });
  427. },
  428.  
  429. /**
  430. * Handle the API response
  431. * @param {Object} response - API response
  432. * @param {string} normalizedUrl - Original normalized URL
  433. */
  434. handleApiResponse(response, normalizedUrl) {
  435. try {
  436. const data = JSON.parse(response.responseText);
  437. const matchingHits = data.hits.filter(hit => URLUtils.urlsMatch(hit.url, normalizedUrl));
  438. if (matchingHits.length === 0) {
  439. console.log('🔍 URL not found on Hacker News');
  440. UI.updateFloatingElement(null);
  441. return;
  442. }
  443.  
  444. const topHit = matchingHits.sort((a, b) => (b.points || 0) - (a.points || 0))[0];
  445. const result = {
  446. title: topHit.title,
  447. points: topHit.points || 0,
  448. comments: topHit.num_comments || 0,
  449. link: `https://news.ycombinator.com/item?id=${topHit.objectID}`,
  450. posted: new Date(topHit.created_at).toLocaleDateString()
  451. };
  452.  
  453. console.log('📰 Found on Hacker News:', result);
  454. UI.updateFloatingElement(result);
  455. } catch (e) {
  456. console.error('Error parsing HN API response:', e);
  457. UI.updateFloatingElement(null);
  458. }
  459. }
  460. };
  461.  
  462. /**
  463. * Initialize the script
  464. */
  465. function init() {
  466. // Skip if we're in an iframe
  467. if (window.top !== window.self) {
  468. console.log('📌 Skipping execution in iframe');
  469. return;
  470. }
  471.  
  472. // Skip if document is hidden (like background tabs or invisible frames)
  473. if (document.hidden) {
  474. console.log('📌 Skipping execution in hidden document');
  475. // Add listener for when the tab becomes visible
  476. document.addEventListener('visibilitychange', function onVisible() {
  477. if (!document.hidden) {
  478. init();
  479. document.removeEventListener('visibilitychange', onVisible);
  480. }
  481. });
  482. return;
  483. }
  484.  
  485. const currentUrl = window.location.href;
  486. // Check if the floating element already exists
  487. if (document.getElementById('hn-float')) {
  488. console.log('📌 HN float already exists, skipping');
  489. return;
  490. }
  491.  
  492. if (URLUtils.shouldIgnoreUrl(currentUrl)) {
  493. console.log('🚫 Ignored URL:', currentUrl);
  494. return;
  495. }
  496.  
  497. // Check if content is primarily English
  498. if (!ContentUtils.isEnglishContent()) {
  499. console.log('🈂️ Non-English content detected, skipping');
  500. return;
  501. }
  502.  
  503. GM_addStyle(STYLES);
  504. const normalizedUrl = URLUtils.normalizeUrl(currentUrl);
  505. console.log('🔗 Normalized URL:', normalizedUrl);
  506. UI.createFloatingElement();
  507. HNApi.checkHackerNews(normalizedUrl);
  508. }
  509.  
  510. // Start the script
  511. init();
  512. })();