/ clis / producthunt / browse.js
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  });