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