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