hot.js
1 /** 2 * Product Hunt top posts with vote counts — INTERCEPT strategy. 3 * 4 * Navigates to the Product Hunt homepage and scrapes rendered product cards. 5 */ 6 import { cli, Strategy } from '@jackwener/opencli/registry'; 7 import { CliError } from '@jackwener/opencli/errors'; 8 import { pickVoteCount } from './utils.js'; 9 cli({ 10 site: 'producthunt', 11 name: 'hot', 12 description: "Today's top Product Hunt launches with vote counts", 13 domain: 'www.producthunt.com', 14 strategy: Strategy.INTERCEPT, 15 args: [ 16 { name: 'limit', type: 'int', default: 20, help: 'Number of results (max 50)' }, 17 ], 18 columns: ['rank', 'name', 'votes', 'url'], 19 func: async (page, args) => { 20 const count = Math.min(Number(args.limit) || 20, 50); 21 await page.installInterceptor('producthunt.com'); 22 await page.goto('https://www.producthunt.com'); 23 await page.waitForCapture(5); 24 const domItems = await page.evaluate(` 25 (() => { 26 const seen = new Set(); 27 const results = []; 28 29 const cardLinks = Array.from(document.querySelectorAll('a[href^="/products/"]')).filter((el) => { 30 const href = el.getAttribute('href') || ''; 31 const text = el.textContent?.trim() || ''; 32 return href && !href.includes('/reviews') && text.length > 0 && text.length < 120; 33 }); 34 35 const normalizeName = (text) => text 36 .replace(/^\\d+\\.\\s*/, '') 37 .replace(/\\s*Launched\\s+this\\s+(month|week|year|day)\\s*/gi, '') 38 .replace(/\\s*Featured\\s*/gi, '') 39 .trim(); 40 41 for (const cardLink of cardLinks) { 42 const href = cardLink.getAttribute('href') || ''; 43 if (!href || seen.has(href)) continue; 44 45 let card = cardLink; 46 let node = cardLink.parentElement; 47 for (let i = 0; i < 6 && node; i++) { 48 const hasReviewLink = !!node.querySelector('a[href="' + href + '/reviews"]'); 49 const hasNumericNode = Array.from(node.querySelectorAll('button, [role="button"], p, span, div')) 50 .some((el) => /^\\d+$/.test(el.textContent?.trim() || '')); 51 if (hasReviewLink || hasNumericNode) { 52 card = node; 53 break; 54 } 55 node = node.parentElement; 56 } 57 58 const name = normalizeName(cardLink.textContent?.trim() || ''); 59 if (!name) continue; 60 61 const voteCandidates = Array.from(card.querySelectorAll('button, [role="button"], a, p, span, div')) 62 .map((el) => { 63 const reviewLink = el.closest('a[href="' + href + '/reviews"]'); 64 return { 65 text: el.textContent?.trim() || '', 66 tagName: el.tagName, 67 className: el.className || '', 68 role: el.getAttribute('role') || '', 69 inButton: !!el.closest('button, [role="button"]'), 70 inReviewLink: !!reviewLink, 71 }; 72 }) 73 .filter((candidate) => /^\\d+$/.test(candidate.text)); 74 75 if (voteCandidates.length === 0) continue; 76 77 seen.add(href); 78 results.push({ 79 name, 80 voteCandidates, 81 url: 'https://www.producthunt.com' + href, 82 }); 83 } 84 85 return results; 86 })() 87 `); 88 const items = Array.isArray(domItems) ? domItems : []; 89 if (items.length === 0) { 90 throw new CliError('NO_DATA', 'Could not retrieve Product Hunt top posts', 'Product Hunt may have changed its layout'); 91 } 92 const rankedItems = items 93 .map((item) => ({ 94 name: item.name, 95 url: item.url, 96 votes: pickVoteCount(Array.isArray(item.voteCandidates) ? item.voteCandidates : []), 97 })) 98 .filter((item) => item.name && item.url && item.votes); 99 if (rankedItems.length === 0) { 100 throw new CliError('NO_DATA', 'Could not retrieve Product Hunt vote counts', 'Product Hunt may have changed its vote button structure'); 101 } 102 rankedItems.sort((a, b) => parseInt(b.votes, 10) - parseInt(a.votes, 10)); 103 return rankedItems.slice(0, count).map((item, i) => ({ 104 rank: i + 1, 105 name: item.name, 106 votes: item.votes, 107 url: item.url, 108 })); 109 }, 110 });