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
  5. // @description Check if current page has been posted to Hacker News
  6. // @author RoCry
  7. // @match *://*/*
  8. // @grant GM_xmlhttpRequest
  9. // @connect hn.algolia.com
  10. // @grant GM_addStyle
  11. // @license MIT
  12. // ==/UserScript==
  13.  
  14. (function() {
  15. 'use strict';
  16.  
  17. /**
  18. * Configuration
  19. */
  20. const CONFIG = {
  21. // HN API endpoint
  22. API_URL: 'https://hn.algolia.com/api/v1/search',
  23. // List of domains to ignore
  24. IGNORED_DOMAINS: [
  25. 'news.ycombinator.com',
  26. 'hn.algolia.com',
  27. 'mail.google.com',
  28. 'gmail.com',
  29. 'outlook.com',
  30. 'yahoo.com',
  31. 'proton.me',
  32. 'localhost',
  33. 'accounts.google.com',
  34. 'drive.google.com',
  35. 'docs.google.com',
  36. 'calendar.google.com',
  37. 'meet.google.com',
  38. 'chat.google.com',
  39. 'web.whatsapp.com',
  40. 'twitter.com/messages',
  41. 'facebook.com/messages',
  42. 'linkedin.com/messaging'
  43. ],
  44.  
  45. // Patterns that indicate a search page
  46. SEARCH_PATTERNS: [
  47. '/search',
  48. '/webhp',
  49. '/results',
  50. '?q=',
  51. '?query=',
  52. '?search=',
  53. '?s='
  54. ],
  55.  
  56. // URL parameters to remove during normalization
  57. TRACKING_PARAMS: [
  58. 'utm_source',
  59. 'utm_medium',
  60. 'utm_campaign',
  61. 'utm_term',
  62. 'utm_content',
  63. 'fbclid',
  64. 'gclid',
  65. '_ga',
  66. 'ref',
  67. 'source'
  68. ]
  69. };
  70.  
  71. /**
  72. * Styles
  73. */
  74. const STYLES = `
  75. @keyframes fadeIn {
  76. 0% { opacity: 0; transform: translateY(10px); }
  77. 100% { opacity: 1; transform: translateY(0); }
  78. }
  79. @keyframes pulse {
  80. 0% { opacity: 1; }
  81. 50% { opacity: 0.6; }
  82. 100% { opacity: 1; }
  83. }
  84. #hn-float {
  85. position: fixed;
  86. bottom: 20px;
  87. left: 20px;
  88. z-index: 9999;
  89. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  90. display: flex;
  91. align-items: center;
  92. gap: 12px;
  93. background: rgba(255, 255, 255, 0.98);
  94. padding: 8px 12px;
  95. border-radius: 12px;
  96. box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.05);
  97. cursor: pointer;
  98. transition: all 0.2s ease;
  99. max-width: 50px;
  100. overflow: hidden;
  101. opacity: 0.95;
  102. height: 40px;
  103. backdrop-filter: blur(8px);
  104. -webkit-backdrop-filter: blur(8px);
  105. animation: fadeIn 0.3s ease forwards;
  106. will-change: transform, max-width, box-shadow;
  107. color: #111827;
  108. }
  109. #hn-float:hover {
  110. max-width: 600px;
  111. opacity: 1;
  112. transform: translateY(-2px);
  113. box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.05);
  114. }
  115. #hn-float .hn-icon {
  116. min-width: 24px;
  117. width: 24px;
  118. height: 24px;
  119. background: linear-gradient(135deg, #ff6600, #ff7f33);
  120. color: white;
  121. display: flex;
  122. align-items: center;
  123. justify-content: center;
  124. font-weight: bold;
  125. border-radius: 6px;
  126. flex-shrink: 0;
  127. position: relative;
  128. font-size: 13px;
  129. text-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);
  130. transition: transform 0.2s ease;
  131. }
  132. #hn-float:hover .hn-icon {
  133. transform: scale(1.05);
  134. }
  135. #hn-float .hn-icon.not-found {
  136. background: #9ca3af;
  137. }
  138. #hn-float .hn-icon.found {
  139. background: linear-gradient(135deg, #ff6600, #ff7f33);
  140. }
  141. #hn-float .hn-icon.loading {
  142. background: #6b7280;
  143. animation: pulse 1.5s infinite;
  144. }
  145. #hn-float .hn-icon .badge {
  146. position: absolute;
  147. top: -6px;
  148. right: -6px;
  149. background: linear-gradient(135deg, #3b82f6, #2563eb);
  150. color: white;
  151. border-radius: 10px;
  152. min-width: 18px;
  153. height: 18px;
  154. font-size: 11px;
  155. display: flex;
  156. align-items: center;
  157. justify-content: center;
  158. padding: 0 4px;
  159. font-weight: 600;
  160. box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  161. border: 2px solid white;
  162. }
  163. #hn-float .hn-info {
  164. white-space: nowrap;
  165. overflow: hidden;
  166. text-overflow: ellipsis;
  167. line-height: 1.4;
  168. font-size: 13px;
  169. opacity: 0;
  170. transition: opacity 0.2s ease;
  171. }
  172. #hn-float:hover .hn-info {
  173. opacity: 1;
  174. }
  175. #hn-float .hn-info a {
  176. color: inherit;
  177. font-weight: 500;
  178. text-decoration: none;
  179. }
  180. #hn-float .hn-info a:hover {
  181. text-decoration: underline;
  182. }
  183. #hn-float .hn-stats {
  184. color: #6b7280;
  185. font-size: 12px;
  186. margin-top: 2px;
  187. }
  188. @media (prefers-color-scheme: dark) {
  189. #hn-float {
  190. background: rgba(17, 24, 39, 0.95);
  191. color: #e5e7eb;
  192. box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(255, 255, 255, 0.1);
  193. }
  194. #hn-float:hover {
  195. box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.1);
  196. }
  197. #hn-float .hn-stats {
  198. color: #9ca3af;
  199. }
  200. #hn-float .hn-icon .badge {
  201. border-color: rgba(17, 24, 39, 0.95);
  202. }
  203. }
  204. `;
  205.  
  206. /**
  207. * URL Utilities
  208. */
  209. const URLUtils = {
  210. /**
  211. * Check if a URL should be ignored based on domain or search patterns
  212. * @param {string} url - URL to check
  213. * @returns {boolean} - True if URL should be ignored
  214. */
  215. shouldIgnoreUrl(url) {
  216. try {
  217. const urlObj = new URL(url);
  218. // Check ignored domains
  219. if (CONFIG.IGNORED_DOMAINS.some(domain => urlObj.hostname.includes(domain))) {
  220. return true;
  221. }
  222.  
  223. // Check if it's a search page
  224. if (CONFIG.SEARCH_PATTERNS.some(pattern =>
  225. urlObj.pathname.includes(pattern) || urlObj.search.includes(pattern))) {
  226. return true;
  227. }
  228.  
  229. return false;
  230. } catch (e) {
  231. console.error('Error checking URL:', e);
  232. return false;
  233. }
  234. },
  235.  
  236. /**
  237. * Normalize URL by removing tracking parameters and standardizing format
  238. * @param {string} url - URL to normalize
  239. * @returns {string} - Normalized URL
  240. */
  241. normalizeUrl(url) {
  242. try {
  243. const urlObj = new URL(url);
  244. // Remove tracking parameters
  245. CONFIG.TRACKING_PARAMS.forEach(param => urlObj.searchParams.delete(param));
  246. // Remove hash
  247. urlObj.hash = '';
  248. // Remove trailing slash for consistency
  249. let normalizedUrl = urlObj.toString();
  250. if (normalizedUrl.endsWith('/')) {
  251. normalizedUrl = normalizedUrl.slice(0, -1);
  252. }
  253. return normalizedUrl;
  254. } catch (e) {
  255. console.error('Error normalizing URL:', e);
  256. return url;
  257. }
  258. },
  259.  
  260. /**
  261. * Compare two URLs for equality after normalization
  262. * @param {string} url1 - First URL
  263. * @param {string} url2 - Second URL
  264. * @returns {boolean} - True if URLs match
  265. */
  266. urlsMatch(url1, url2) {
  267. try {
  268. const u1 = new URL(this.normalizeUrl(url1));
  269. const u2 = new URL(this.normalizeUrl(url2));
  270. return u1.hostname.toLowerCase() === u2.hostname.toLowerCase() &&
  271. u1.pathname.toLowerCase() === u2.pathname.toLowerCase() &&
  272. u1.search === u2.search;
  273. } catch (e) {
  274. console.error('Error comparing URLs:', e);
  275. return false;
  276. }
  277. }
  278. };
  279.  
  280. /**
  281. * UI Component
  282. */
  283. const UI = {
  284. /**
  285. * Create and append the floating element to the page
  286. * @returns {HTMLElement} - The created element
  287. */
  288. createFloatingElement() {
  289. const div = document.createElement('div');
  290. div.id = 'hn-float';
  291. div.innerHTML = `
  292. <div class="hn-icon loading">Y</div>
  293. <div class="hn-info">Checking HN...</div>
  294. `;
  295. document.body.appendChild(div);
  296. return div;
  297. },
  298.  
  299. /**
  300. * Update the floating element with HN data
  301. * @param {Object|null} data - HN post data or null if not found
  302. */
  303. updateFloatingElement(data) {
  304. const iconDiv = document.querySelector('#hn-float .hn-icon');
  305. const infoDiv = document.querySelector('#hn-float .hn-info');
  306. iconDiv.classList.remove('loading');
  307. if (!data) {
  308. iconDiv.classList.add('not-found');
  309. iconDiv.classList.remove('found');
  310. iconDiv.innerHTML = 'Y';
  311. infoDiv.textContent = 'Not found on HN';
  312. return;
  313. }
  314. iconDiv.classList.remove('not-found');
  315. iconDiv.classList.add('found');
  316. iconDiv.innerHTML = `Y${data.comments > 0 ?
  317. `<span class="badge">${data.comments > 999 ? '999+' : data.comments}</span>` : ''}`;
  318. infoDiv.innerHTML = `
  319. <div><a href="${data.link}" target="_blank">${data.title}</a></div>
  320. <div class="hn-stats">
  321. ${data.points} points | ${data.comments} comments | ${data.posted}
  322. </div>
  323. `;
  324. }
  325. };
  326.  
  327. /**
  328. * HackerNews API Handler
  329. */
  330. const HNApi = {
  331. /**
  332. * Search for a URL on HackerNews
  333. * @param {string} normalizedUrl - URL to search for
  334. */
  335. checkHackerNews(normalizedUrl) {
  336. const apiUrl = `${CONFIG.API_URL}?query=${encodeURIComponent(normalizedUrl)}&restrictSearchableAttributes=url`;
  337. GM_xmlhttpRequest({
  338. method: 'GET',
  339. url: apiUrl,
  340. onload: (response) => this.handleApiResponse(response, normalizedUrl),
  341. onerror: (error) => {
  342. console.error('Error fetching from HN API:', error);
  343. UI.updateFloatingElement(null);
  344. }
  345. });
  346. },
  347.  
  348. /**
  349. * Handle the API response
  350. * @param {Object} response - API response
  351. * @param {string} normalizedUrl - Original normalized URL
  352. */
  353. handleApiResponse(response, normalizedUrl) {
  354. try {
  355. const data = JSON.parse(response.responseText);
  356. const matchingHits = data.hits.filter(hit => URLUtils.urlsMatch(hit.url, normalizedUrl));
  357. if (matchingHits.length === 0) {
  358. console.log('🔍 URL not found on Hacker News');
  359. UI.updateFloatingElement(null);
  360. return;
  361. }
  362.  
  363. const topHit = matchingHits.sort((a, b) => (b.points || 0) - (a.points || 0))[0];
  364. const result = {
  365. title: topHit.title,
  366. points: topHit.points || 0,
  367. comments: topHit.num_comments || 0,
  368. link: `https://news.ycombinator.com/item?id=${topHit.objectID}`,
  369. posted: new Date(topHit.created_at).toLocaleDateString()
  370. };
  371.  
  372. console.log('📰 Found on Hacker News:', result);
  373. UI.updateFloatingElement(result);
  374. } catch (e) {
  375. console.error('Error parsing HN API response:', e);
  376. UI.updateFloatingElement(null);
  377. }
  378. }
  379. };
  380.  
  381. /**
  382. * Initialize the script
  383. */
  384. function init() {
  385. const currentUrl = window.location.href;
  386. if (URLUtils.shouldIgnoreUrl(currentUrl)) {
  387. return;
  388. }
  389.  
  390. GM_addStyle(STYLES);
  391. const normalizedUrl = URLUtils.normalizeUrl(currentUrl);
  392. console.log('🔗 Normalized URL:', normalizedUrl);
  393. UI.createFloatingElement();
  394. HNApi.checkHackerNews(normalizedUrl);
  395. }
  396.  
  397. // Start the script
  398. init();
  399. })();