Any Hackernews Link

Check if current page has been posted to Hacker News

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

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