browse.js
1 /** 2 * Product Hunt category browse — INTERCEPT strategy. 3 * 4 * Navigates to a Product Hunt category page and scrapes the top-rated products. 5 * Shows all-time best products for a category (ranked by review score, not daily votes). 6 */ 7 import { cli, Strategy } from '@jackwener/opencli/registry'; 8 import { CliError } from '@jackwener/opencli/errors'; 9 import { PRODUCTHUNT_CATEGORY_SLUGS } from './utils.js'; 10 cli({ 11 site: 'producthunt', 12 name: 'browse', 13 description: 'Best products in a Product Hunt category', 14 domain: 'www.producthunt.com', 15 strategy: Strategy.INTERCEPT, 16 args: [ 17 { 18 name: 'category', 19 type: 'string', 20 positional: true, 21 required: true, 22 help: `Category slug, e.g. vibe-coding, ai-agents, developer-tools`, 23 }, 24 { name: 'limit', type: 'int', default: 20, help: 'Number of results (max 50)' }, 25 ], 26 columns: ['rank', 'name', 'tagline', 'reviews', 'url'], 27 func: async (page, args) => { 28 const count = Math.min(Number(args.limit) || 20, 50); 29 const slug = String(args.category || '').trim().toLowerCase(); 30 await page.installInterceptor('producthunt.com'); 31 await page.goto(`https://www.producthunt.com/categories/${slug}`); 32 await page.waitForCapture(5); 33 const domItems = await page.evaluate(` 34 (() => { 35 const seen = new Set(); 36 const results = []; 37 38 // Card links: <a class="...flex-col" href="/products/<slug>"> (not review links) 39 const cardLinks = Array.from(document.querySelectorAll('a[href^="/products/"]')).filter(a => { 40 const href = a.getAttribute('href') || ''; 41 const cls = a.className || ''; 42 return cls.includes('flex-col') && !href.includes('/reviews'); 43 }); 44 45 for (const cardLink of cardLinks) { 46 const href = cardLink.getAttribute('href'); 47 if (!href || seen.has(href)) continue; 48 49 // Child 0: div with name (strip "Launched this month/week/year" noise) 50 const nameDiv = cardLink.querySelector('div'); 51 const rawName = nameDiv?.textContent?.trim() || ''; 52 const name = rawName 53 .replace(/\\s*Launched\\s+this\\s+(month|week|year|day)\\s*/gi, '') 54 .replace(/\\s*Featured\\s*/gi, '') 55 .trim(); 56 57 // Child 1: span.text-secondary — tagline 58 const taglineEl = cardLink.querySelector('span.text-secondary, span[class*="text-secondary"]'); 59 const tagline = taglineEl?.textContent?.trim() || ''; 60 61 if (!name) continue; 62 63 // Find reviews count from sibling /reviews link 64 let reviews = ''; 65 let container = cardLink.parentElement; 66 for (let i = 0; i < 5 && container; i++) { 67 const reviewLink = container.querySelector('a[href="' + href + '/reviews"]'); 68 if (reviewLink) { 69 reviews = (reviewLink.textContent?.trim() || '').replace(/\\s*reviews?\\s*/i, '').trim(); 70 break; 71 } 72 container = container.parentElement; 73 } 74 75 seen.add(href); 76 results.push({ 77 name, 78 tagline: tagline.slice(0, 120), 79 reviews: reviews || '0', 80 url: 'https://www.producthunt.com' + href, 81 }); 82 } 83 84 return results; 85 })() 86 `); 87 const items = Array.isArray(domItems) ? domItems : []; 88 if (items.length === 0) { 89 throw new CliError('NO_DATA', `No products found for category "${slug}"`, 'Check the category slug or try: ' + PRODUCTHUNT_CATEGORY_SLUGS.slice(0, 5).join(', ')); 90 } 91 return items.slice(0, count).map((item, i) => ({ 92 rank: i + 1, 93 name: item.name, 94 tagline: item.tagline, 95 reviews: item.reviews, 96 url: item.url, 97 })); 98 }, 99 });