1import { Actor } from 'apify';
2import { log } from 'crawlee';
3import { load } from 'cheerio';
4import { lookup } from 'node:dns/promises';
5import { isIP } from 'node:net';
6import { chromium, type Browser } from 'playwright';
7import { getDomain } from 'tldts';
8import {
9 buildWebsitePagePlan,
10 canonicalWebsiteUrl,
11 clampInteger,
12 DEFAULT_MAX_EMAILS_PER_PRODUCT,
13 DEFAULT_MAX_WEBSITE_PAGES,
14 isProductHuntHost,
15 isPublicHttpUrl,
16 MAX_EMAILS_PER_PRODUCT,
17 MAX_WEBSITE_PAGES,
18 normalizeEmailListVerifyStatus,
19 parseProductHuntSeedUrl,
20 rankEmails,
21 type EmailListVerifyStatus,
22 type WebsitePage,
23} from './enrichment-utils.js';
24import {
25 buildLeadProfile,
26 buildWebsiteSearchQuery,
27 evaluateWebsiteIdentity,
28 isPrivateNetworkAddress,
29 isUsableEmailStatus,
30 matchesProductSearch,
31 normalizeContactFinderResults,
32 parseDataForSeoCandidates,
33 parseSerpApiCandidates,
34 scoreSearchCandidate,
35 splitMakerName,
36 type ContactFinderResult,
37 type SearchCandidate,
38} from './lead-engine.js';
39
40await Actor.init();
41
42type Mode = 'leaderboard' | 'search' | 'topic' | 'urls';
43type LeaderboardPeriod = 'daily' | 'weekly' | 'monthly' | 'yearly';
44type RunOutcome = 'COMPLETE' | 'PARTIAL' | 'VALID_EMPTY' | 'INVALID_INPUT' | 'UPSTREAM_FAILED' | 'CONFIG_ERROR';
45type EmailSource = 'page_scrape' | 'emaillistverify_contact_finder' | 'mixed' | 'none';
46type EmailResult = 'emails_found' | 'no_public_email_found' | 'not_assessed';
47type Coverage = 'complete' | 'partial' | 'none';
48type WebsiteResolutionStatus = 'verified_external' | 'verified_search_match' | 'unresolved' | 'not_available';
49type WebsiteResolutionSource = 'api_website_product_link' | 'input_override' | 'product_hunt_redirect' | 'maker_website' | 'dataforseo_organic' | 'serpapi_organic' | 'none';
50type EmailVerification = {
51 email: string;
52 provider: 'emaillistverify';
53 status: EmailListVerifyStatus;
54 checkedAt: string | null;
55 source: 'public_website' | 'emaillistverify_contact_finder';
56 confidence: 'high' | 'medium' | 'low' | 'unknown' | null;
57};
58
59type DateRange = { postedAfter: string; postedBefore: string };
60type WebsiteOverride = { keys: string[]; websiteUrl: string };
61type EnrichmentResult = {
62 emails: string[];
63 emailSource: EmailSource;
64 result: EmailResult;
65 coverage: Coverage;
66 terminalReason: string;
67 pagesVisited: number;
68 pageUrls: string[];
69 warnings: string[];
70};
71
72type WebsiteResolution = {
73 websiteUrl: string | null;
74 source: WebsiteResolutionSource;
75 status: WebsiteResolutionStatus;
76 confidence: number;
77 signals: string[];
78 providerCostUsd: number;
79 providerAttempts: number;
80 warnings: string[];
81};
82
83type ContactDiscovery = {
84 contacts: ContactFinderResult[];
85 attempts: number;
86 credits: number;
87 warnings: string[];
88};
89
90const PH_API = 'https://api.producthunt.com/v2/api/graphql';
91const RESULT_EVENT = 'apify-default-dataset-item';
92const EMAIL_EVENT = 'email-found';
93const RESULT_PRICE_USD = 0.002;
94const EMAIL_PRICE_USD = 0.04;
95const EMAIL_LIST_VERIFY_API = 'https://api.emaillistverify.com/api/verifyEmail';
96const EMAIL_LIST_VERIFY_CONTACT_API = 'https://api.emaillistverify.com/api/findContact';
97const DATAFORSEO_SEARCH_API = 'https://api.dataforseo.com/v3/serp/google/organic/live/advanced';
98const SERPAPI_SEARCH_API = 'https://serpapi.com/search.json';
99const EMAIL_LIST_VERIFY_MIN_INTERVAL_MS = 225;
100const FAILED_OUTCOMES = new Set<RunOutcome>(['UPSTREAM_FAILED', 'CONFIG_ERROR']);
101
102const SKIP_DOMAINS = new Set([
103 'apps.apple.com', 'play.google.com', 'github.com', 'youtube.com',
104 'twitter.com', 'x.com', 'linkedin.com', 'facebook.com', 'instagram.com',
105 'reddit.com', 'discord.com', 'discord.gg', 'slack.com', 'medium.com',
106 'substack.com', 'producthunt.com', 'amazon.com', 'chrome.google.com',
107 'marketplace.visualstudio.com', 'notion.so', 'figma.com', 'trello.com',
108 'airtable.com',
109]);
110
111const PRODUCT_HUNT_USER_AGENT = 'ProductHunt Scraper Pro/1.3 (authorized API client)';
112const WEBSITE_USER_AGENT = 'Mozilla/5.0 (compatible; ProductHuntScraperPro/1.3; +https://apify.com/khadinakbar/producthunt-scraper-pro)';
113
114let nextEmailListVerifyRequestAt = 0;
115let emailListVerifyCreditsExhausted = false;
116const publicHostCache = new Map<string, Promise<boolean>>();
117
118function isoDate(date: Date): string {
119 return date.toISOString().slice(0, 10);
120}
121
122function getWeekBounds(date: Date): { start: string; end: string } {
123 const start = new Date(date);
124
125 const weekday = date.getUTCDay() || 7;
126 start.setUTCDate(date.getUTCDate() - weekday + 1);
127 const end = new Date(start);
128 end.setUTCDate(start.getUTCDate() + 6);
129 return { start: isoDate(start), end: isoDate(end) };
130}
131
132function getMonthBounds(date: Date): { start: string; end: string } {
133 const year = date.getUTCFullYear();
134 const month = date.getUTCMonth();
135 return {
136 start: isoDate(new Date(Date.UTC(year, month, 1))),
137 end: isoDate(new Date(Date.UTC(year, month + 1, 0))),
138 };
139}
140
141function getYearBounds(date: Date): { start: string; end: string } {
142 const year = date.getUTCFullYear();
143 return { start: `${year}-01-01`, end: `${year}-12-31` };
144}
145
146function validIsoDate(value: string): boolean {
147 if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
148 const parsed = new Date(`${value}T12:00:00Z`);
149 return Number.isFinite(parsed.getTime()) && isoDate(parsed) === value;
150}
151
152function dateRangeForPeriod(period: LeaderboardPeriod, anchorDate: string, endDate = ''): DateRange {
153 const base = new Date(`${anchorDate}T12:00:00Z`);
154 if (period === 'daily') {
155 return {
156 postedAfter: `${anchorDate}T00:00:00+00:00`,
157 postedBefore: `${endDate || anchorDate}T23:59:59+00:00`,
158 };
159 }
160 const bounds = period === 'weekly'
161 ? getWeekBounds(base)
162 : period === 'monthly'
163 ? getMonthBounds(base)
164 : getYearBounds(base);
165 return {
166 postedAfter: `${bounds.start}T00:00:00+00:00`,
167 postedBefore: `${bounds.end}T23:59:59+00:00`,
168 };
169}
170
171function normalizeKey(value: unknown): string | null {
172 if (typeof value !== 'string' || !value.trim()) return null;
173 return value.trim().toLowerCase().replace(/[?#].*$/, '').replace(/\/$/, '');
174}
175
176function parseWebsiteOverrides(value: unknown): WebsiteOverride[] {
177 const overrides: WebsiteOverride[] = [];
178 const add = (keys: unknown[], websiteUrl: unknown): void => {
179 if (typeof websiteUrl !== 'string' || !isPublicHttpUrl(websiteUrl)) return;
180 const canonical = canonicalWebsiteUrl(websiteUrl);
181 if (!canonical || isProductHuntHost(new URL(canonical).hostname)) return;
182 const usableKeys = keys.map(normalizeKey).filter((key): key is string => Boolean(key));
183 if (usableKeys.length > 0) overrides.push({ keys: usableKeys, websiteUrl: canonical });
184 };
185
186 if (Array.isArray(value)) {
187 for (const entry of value) {
188 if (!entry || typeof entry !== 'object') continue;
189 const row = entry as Record<string, unknown>;
190 add([row.productHuntUrl, row.productSlug, row.productName, row.productId], row.websiteUrl);
191 }
192 } else if (value && typeof value === 'object') {
193 for (const [key, websiteUrl] of Object.entries(value as Record<string, unknown>)) add([key], websiteUrl);
194 }
195 return overrides;
196}
197
198function lookupWebsiteOverride(node: Record<string, unknown>, overrides: WebsiteOverride[]): string | null {
199 const candidates = [node.url, node.slug, node.id, node.name]
200 .map(normalizeKey)
201 .filter((value): value is string => Boolean(value));
202 return overrides.find((override) => override.keys.some((key) => candidates.includes(key)))?.websiteUrl ?? null;
203}
204
205function rootDomain(hostname: string): string {
206 const labels = hostname.replace(/^www\./i, '').toLowerCase().split('.');
207 return labels.slice(-2).join('.');
208}
209
210function isAllowedExternalWebsite(url: string): boolean {
211 if (!isPublicHttpUrl(url)) return false;
212 try {
213 const host = new URL(url).hostname.toLowerCase();
214 return !isProductHuntHost(host) && !SKIP_DOMAINS.has(host) && !SKIP_DOMAINS.has(rootDomain(host));
215 } catch {
216 return false;
217 }
218}
219
220async function isSafePublicNetworkUrl(rawUrl: string): Promise<boolean> {
221 if (!isPublicHttpUrl(rawUrl)) return false;
222 let hostname: string;
223 try {
224 hostname = new URL(rawUrl).hostname.toLowerCase().replace(/^\[|\]$/g, '');
225 } catch {
226 return false;
227 }
228 if (isIP(hostname)) return !isPrivateNetworkAddress(hostname);
229
230 let assessment = publicHostCache.get(hostname);
231 if (!assessment) {
232 assessment = lookup(hostname, { all: true, verbatim: true })
233 .then((records) => records.length > 0 && records.every(({ address }) => !isPrivateNetworkAddress(address)))
234 .catch(() => false);
235 publicHostCache.set(hostname, assessment);
236 }
237 return assessment;
238}
239
240
241
242function browserExtractEmails(): string[] {
243 const emailRe = /\b[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\b/g;
244 const obfuscatedRe = /\b[a-zA-Z0-9._%+\-]+\s*(?:\[at\]|\(at\)|\sat\s)\s*[a-zA-Z0-9.\-]+\s*(?:\[dot\]|\(dot\)|\sdot\s)\s*[a-zA-Z]{2,}\b/gi;
245 const collected: string[] = [];
246 const add = (value: unknown): void => {
247 if (typeof value !== 'string') return;
248 const email = value.trim().toLowerCase().replace(/^mailto:/, '').split('?')[0];
249 if (email.includes('@')) collected.push(email);
250 };
251 const walkJson = (value: unknown, depth = 0): void => {
252 if (depth > 8 || value === null || value === undefined) return;
253 if (Array.isArray(value)) {
254 value.forEach((item) => walkJson(item, depth + 1));
255 return;
256 }
257 if (typeof value !== 'object') return;
258 for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
259 if (key.toLowerCase() === 'email') add(child);
260 walkJson(child, depth + 1);
261 }
262 };
263 const decodeCfEmail = (encoded: string): string | null => {
264 if (!/^[0-9a-f]+$/i.test(encoded) || encoded.length < 4) return null;
265 const key = Number.parseInt(encoded.slice(0, 2), 16);
266 let decoded = '';
267 for (let index = 2; index < encoded.length; index += 2) {
268 decoded += String.fromCharCode(Number.parseInt(encoded.slice(index, index + 2), 16) ^ key);
269 }
270 return decoded;
271 };
272
273 document.querySelectorAll('a[href^="mailto:"]').forEach((element) => add((element as HTMLAnchorElement).href));
274 document.querySelectorAll('[data-email]').forEach((element) => add(element.getAttribute('data-email')));
275 document.querySelectorAll('[data-cfemail]').forEach((element) => {
276 const decoded = decodeCfEmail(element.getAttribute('data-cfemail') ?? '');
277 if (decoded) add(decoded);
278 });
279 document.querySelectorAll('script[type="application/ld+json"]').forEach((element) => {
280 try { walkJson(JSON.parse(element.textContent ?? '')); } catch { }
281 });
282
283 const combined = `${document.body?.innerText ?? ''}\n${document.documentElement.innerHTML}`;
284 (combined.match(emailRe) ?? []).forEach(add);
285 for (const match of combined.match(obfuscatedRe) ?? []) {
286 add(match.replace(/\s*(?:\[at\]|\(at\)|\sat\s)\s*/i, '@').replace(/\s*(?:\[dot\]|\(dot\)|\sdot\s)\s*/ig, '.'));
287 }
288 return collected;
289}
290
291
292
293function browserExtractLinkedFallbackPages(origin: string): WebsitePage[] {
294 const matchers: Array<{ kind: Exclude<WebsitePage['kind'], 'homepage'>; priority: number; pattern: RegExp }> = [
295 { kind: 'contact', priority: 0, pattern: /(contact|contact-us|get-in-touch|reach-us)/i },
296 { kind: 'about', priority: 1, pattern: /(about|about-us|company|our-story)/i },
297 { kind: 'team', priority: 2, pattern: /(team|our-team|meet-the-team|people|founders)/i },
298 { kind: 'privacy', priority: 3, pattern: /(privacy|privacy-policy)/i },
299 { kind: 'terms', priority: 4, pattern: /(terms|terms-of-service|terms-and-conditions|conditions)/i },
300 ];
301 const links = new Map<string, WebsitePage>();
302 document.querySelectorAll('a[href]').forEach((element) => {
303 const anchor = element as HTMLAnchorElement;
304 try {
305 const url = new URL(anchor.href);
306 if (url.origin !== origin || !['http:', 'https:'].includes(url.protocol) || url.username || url.password) return;
307 const label = (anchor.innerText || anchor.textContent || '').trim();
308 const matched = matchers.find((candidate) => candidate.pattern.test(`${url.pathname} ${label}`));
309 if (!matched) return;
310 url.hash = '';
311 const clean = url.toString();
312 if (clean === `${origin}/` || clean === origin || links.has(clean)) return;
313 links.set(clean, { url: clean, kind: matched.kind, priority: matched.priority });
314 } catch { }
315 });
316 return [...links.values()].sort((left, right) => left.priority - right.priority);
317}
318
319async function waitForEmailListVerifySlot(): Promise<void> {
320 const scheduledAt = Math.max(Date.now(), nextEmailListVerifyRequestAt);
321 nextEmailListVerifyRequestAt = scheduledAt + EMAIL_LIST_VERIFY_MIN_INTERVAL_MS;
322 const delayMs = scheduledAt - Date.now();
323 if (delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs));
324}
325
326async function verifyEmailsWithEmailListVerify(
327 emails: string[],
328 requested: boolean,
329 apiKey: string,
330): Promise<{ verifications: EmailVerification[]; attempts: number; warnings: string[] }> {
331 if (emails.length === 0) return { verifications: [], attempts: 0, warnings: [] };
332 if (!requested) {
333 return {
334 verifications: emails.map((email) => ({
335 email, provider: 'emaillistverify', status: 'not_requested', checkedAt: null,
336 source: 'public_website', confidence: null,
337 })),
338 attempts: 0,
339 warnings: [],
340 };
341 }
342 if (!apiKey) {
343 return {
344 verifications: emails.map((email) => ({
345 email, provider: 'emaillistverify', status: 'not_configured', checkedAt: null,
346 source: 'public_website', confidence: null,
347 })),
348 attempts: 0,
349 warnings: ['EmailListVerify was requested but no owner-managed API key was configured; public emails were kept unverified.'],
350 };
351 }
352 if (emailListVerifyCreditsExhausted) {
353 return {
354 verifications: emails.map((email) => ({
355 email, provider: 'emaillistverify', status: 'error_credit', checkedAt: null,
356 source: 'public_website', confidence: null,
357 })),
358 attempts: 0,
359 warnings: ['EmailListVerify credits were exhausted earlier in this run; no additional verification requests were sent.'],
360 };
361 }
362
363 const warnings = new Set<string>();
364 const verifications = await Promise.all(emails.map(async (email): Promise<EmailVerification> => {
365 await waitForEmailListVerifySlot();
366 const checkedAt = new Date().toISOString();
367 try {
368 const requestUrl = new URL(EMAIL_LIST_VERIFY_API);
369 requestUrl.searchParams.set('email', email);
370 const response = await fetch(requestUrl, {
371 headers: { 'x-api-key': apiKey, Accept: 'text/plain, application/json;q=0.9' },
372 signal: AbortSignal.timeout(12_000),
373 });
374 if (!response.ok) {
375 warnings.add(`EmailListVerify returned HTTP ${response.status}; affected public emails were retained with provider_error status.`);
376 if (response.status === 402) emailListVerifyCreditsExhausted = true;
377 return {
378 email, provider: 'emaillistverify', status: response.status === 402 ? 'error_credit' : 'provider_error', checkedAt,
379 source: 'public_website', confidence: null,
380 };
381 }
382 const status = normalizeEmailListVerifyStatus(await response.text());
383 if (status === 'provider_error') warnings.add('EmailListVerify returned an undocumented response; affected public emails were retained with provider_error status.');
384 if (status === 'error_credit') {
385 emailListVerifyCreditsExhausted = true;
386 warnings.add('EmailListVerify reported insufficient verification credits; remaining public emails were retained with error_credit status.');
387 }
388 return { email, provider: 'emaillistverify', status, checkedAt, source: 'public_website', confidence: null };
389 } catch {
390 warnings.add('EmailListVerify request failed; affected public emails were retained with provider_error status.');
391 return { email, provider: 'emaillistverify', status: 'provider_error', checkedAt, source: 'public_website', confidence: null };
392 }
393 }));
394 return { verifications, attempts: emails.length, warnings: [...warnings] };
395}
396
397let browser: Browser | null = null;
398
399async function ensureBrowser(): Promise<Browser> {
400 if (!browser) {
401 browser = await chromium.launch({
402 headless: true,
403 args: [
404 '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage',
405 '--disable-gpu', '--no-zygote', '--disable-extensions',
406 ],
407 });
408 log.info('Browser launched for verified external website enrichment.');
409 }
410 return browser;
411}
412
413async function closeBrowser(): Promise<void> {
414 if (!browser) return;
415 await browser.close().catch(() => undefined);
416 browser = null;
417}
418
419function extractStaticEmails(html: string): string[] {
420 const $ = load(html);
421 const collected: string[] = [];
422 const emailRe = /\b[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\b/g;
423 const add = (value: unknown): void => {
424 if (typeof value !== 'string') return;
425 const email = value.trim().toLowerCase().replace(/^mailto:/, '').split('?')[0];
426 if (email.includes('@')) collected.push(email);
427 };
428 const walkJson = (value: unknown, depth = 0): void => {
429 if (depth > 8 || value === null || value === undefined) return;
430 if (Array.isArray(value)) return value.forEach((item) => walkJson(item, depth + 1));
431 if (typeof value !== 'object') return;
432 for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
433 if (key.toLowerCase() === 'email') add(child);
434 walkJson(child, depth + 1);
435 }
436 };
437 $('a[href^="mailto:"]').each((_, element) => add($(element).attr('href')));
438 $('[data-email]').each((_, element) => add($(element).attr('data-email')));
439 $('script[type="application/ld+json"]').each((_, element) => {
440 try { walkJson(JSON.parse($(element).text())); } catch { }
441 });
442 const combined = `${$.text()}\n${html}`;
443 (combined.match(emailRe) ?? []).forEach(add);
444 return collected;
445}
446
447function extractStaticFallbackPages(html: string, origin: string): WebsitePage[] {
448 const $ = load(html);
449 const matchers: Array<{ kind: Exclude<WebsitePage['kind'], 'homepage'>; priority: number; pattern: RegExp }> = [
450 { kind: 'contact', priority: 0, pattern: /(contact|contact-us|get-in-touch|reach-us)/i },
451 { kind: 'about', priority: 1, pattern: /(about|about-us|company|our-story)/i },
452 { kind: 'team', priority: 2, pattern: /(team|our-team|meet-the-team|people|founders)/i },
453 { kind: 'privacy', priority: 3, pattern: /(privacy|privacy-policy)/i },
454 { kind: 'terms', priority: 4, pattern: /(terms|terms-of-service|terms-and-conditions|conditions)/i },
455 ];
456 const candidates = new Map<string, WebsitePage>();
457 $('a[href]').each((_, element) => {
458 try {
459 const url = new URL($(element).attr('href') ?? '', origin);
460 if (url.origin !== origin || !['http:', 'https:'].includes(url.protocol) || url.username || url.password) return;
461 const label = $(element).text().trim();
462 const matched = matchers.find((candidate) => candidate.pattern.test(`${url.pathname} ${label}`));
463 if (!matched) return;
464 url.hash = '';
465 const clean = url.toString();
466 if (clean === `${origin}/` || clean === origin || candidates.has(clean)) return;
467 candidates.set(clean, { url: clean, kind: matched.kind, priority: matched.priority });
468 } catch { }
469 });
470 return [...candidates.values()].sort((left, right) => left.priority - right.priority);
471}
472
473type StaticPage = { ok: true; url: string; html: string } | { ok: false; reason: string };
474
475async function fetchPublicHtml(rawUrl: string, timeoutMs: number): Promise<StaticPage> {
476 let current = canonicalWebsiteUrl(rawUrl);
477 if (!current || !isAllowedExternalWebsite(current)) return { ok: false, reason: 'invalid_or_private_target' };
478 try {
479 for (let hop = 0; hop <= 3; hop += 1) {
480 if (!await isSafePublicNetworkUrl(current)) return { ok: false, reason: 'private_or_unresolvable_target' };
481 const response = await fetch(current, {
482 method: 'GET',
483 redirect: 'manual',
484 headers: { Accept: 'text/html,application/xhtml+xml', 'User-Agent': WEBSITE_USER_AGENT },
485 signal: AbortSignal.timeout(timeoutMs),
486 });
487 if (response.status >= 300 && response.status < 400) {
488 const location = response.headers.get('location');
489 if (!location || hop === 3) return { ok: false, reason: 'redirect_hop_limit' };
490 const next = canonicalWebsiteUrl(new URL(location, current).toString());
491 if (!next || !isAllowedExternalWebsite(next)) return { ok: false, reason: 'redirect_to_private_or_unsupported_target' };
492 if (!await isSafePublicNetworkUrl(next)) return { ok: false, reason: 'redirect_to_private_or_unresolvable_target' };
493 current = next;
494 continue;
495 }
496 if (!response.ok) return { ok: false, reason: `http_${response.status}` };
497 const contentType = response.headers.get('content-type') ?? '';
498 if (!/(?:text\/html|application\/xhtml\+xml)/i.test(contentType)) return { ok: false, reason: 'non_html_response' };
499 const contentLength = Number(response.headers.get('content-length') ?? '0');
500 if (Number.isFinite(contentLength) && contentLength > 1_500_000) return { ok: false, reason: 'response_too_large' };
501 const html = (await response.text()).slice(0, 1_500_000);
502 return { ok: true, url: current, html };
503 }
504 return { ok: false, reason: 'redirect_hop_limit' };
505 } catch (error) {
506 const message = error instanceof Error ? error.message : '';
507 return { ok: false, reason: /timeout|abort/i.test(message) ? 'timeout' : 'network_error' };
508 }
509}
510
511function pageIdentity(html: string): { title: string; description: string; text: string } {
512 const $ = load(html);
513 $('script,style,noscript,svg').remove();
514 return {
515 title: $('title').first().text().trim(),
516 description: $('meta[name="description"]').attr('content')?.trim()
517 || $('meta[property="og:description"]').attr('content')?.trim()
518 || '',
519 text: $('body').text().replace(/\s+/g, ' ').trim().slice(0, 100_000),
520 };
521}
522
523async function resolveProductHuntRedirect(rawUrl: string): Promise<string | null> {
524 let current = canonicalWebsiteUrl(rawUrl);
525 if (!current) return null;
526 try {
527 if (!isProductHuntHost(new URL(current).hostname)) return null;
528 for (let hop = 0; hop < 4; hop += 1) {
529 if (!await isSafePublicNetworkUrl(current)) return null;
530 let response = await fetch(current, {
531 method: 'HEAD',
532 redirect: 'manual',
533 headers: { 'User-Agent': PRODUCT_HUNT_USER_AGENT, Accept: 'text/html,*/*;q=0.5' },
534 signal: AbortSignal.timeout(8_000),
535 });
536 if (response.status === 405) {
537 response = await fetch(current, {
538 method: 'GET',
539 redirect: 'manual',
540 headers: { 'User-Agent': PRODUCT_HUNT_USER_AGENT, Accept: 'text/html,*/*;q=0.5' },
541 signal: AbortSignal.timeout(8_000),
542 });
543 }
544 if (response.status < 300 || response.status >= 400) return null;
545 const location = response.headers.get('location');
546 if (!location) return null;
547 const next = canonicalWebsiteUrl(new URL(location, current).toString());
548 if (!next) return null;
549 if (!isProductHuntHost(new URL(next).hostname)) {
550 return isAllowedExternalWebsite(next) && await isSafePublicNetworkUrl(next) ? next : null;
551 }
552 current = next;
553 }
554 } catch {
555 return null;
556 }
557 return null;
558}
559
560async function verifyWebsiteCandidates(
561 candidates: SearchCandidate[],
562 productName: string,
563 tagline: string,
564): Promise<{ websiteUrl: string; confidence: number; signals: string[] } | null> {
565 const ranked = [...candidates]
566 .map((candidate) => ({ candidate, score: scoreSearchCandidate(candidate, productName, tagline) }))
567 .filter(({ score }) => score >= 34)
568 .sort((left, right) => right.score - left.score || left.candidate.rank - right.candidate.rank)
569 .slice(0, 5);
570
571 for (const { candidate } of ranked) {
572 let homepage: string;
573 try {
574 const candidateUrl = new URL(candidate.url);
575 homepage = `${candidateUrl.origin}/`;
576 } catch {
577 continue;
578 }
579 const fetched = await fetchPublicHtml(homepage, 6_000);
580 if (!fetched.ok) continue;
581 const identity = pageIdentity(fetched.html);
582 const assessment = evaluateWebsiteIdentity({
583 candidate: { ...candidate, url: fetched.url },
584 productName,
585 tagline,
586 pageTitle: identity.title,
587 pageDescription: identity.description,
588 pageText: identity.text,
589 });
590 if (!assessment.accepted) continue;
591 const finalUrl = new URL(fetched.url);
592 return { websiteUrl: `${finalUrl.origin}/`, confidence: assessment.confidence, signals: assessment.signals };
593 }
594 return null;
595}
596
597async function callDataForSeoWebsiteSearch(
598 query: string,
599 login: string,
600 password: string,
601): Promise<{ candidates: SearchCandidate[]; costUsd: number }> {
602 const response = await fetch(DATAFORSEO_SEARCH_API, {
603 method: 'POST',
604 headers: {
605 Authorization: `Basic ${Buffer.from(`${login}:${password}`).toString('base64')}`,
606 'Content-Type': 'application/json',
607 Accept: 'application/json',
608 },
609 body: JSON.stringify([{
610 keyword: query,
611 location_code: 2840,
612 language_code: 'en',
613 device: 'desktop',
614 os: 'windows',
615 depth: 10,
616 group_organic_results: true,
617 }]),
618 signal: AbortSignal.timeout(45_000),
619 });
620 if (!response.ok) throw new Error(`DataForSEO website search returned HTTP ${response.status}.`);
621 const body = await response.json() as Record<string, any>;
622 if (body.status_code && body.status_code !== 20000) {
623 throw new Error(`DataForSEO website search failed with provider code ${body.status_code}.`);
624 }
625 const task = Array.isArray(body.tasks) ? body.tasks[0] : null;
626 if (task?.status_code && task.status_code !== 20000) {
627 throw new Error(`DataForSEO website search task failed with provider code ${task.status_code}.`);
628 }
629 return parseDataForSeoCandidates(body);
630}
631
632async function callSerpApiWebsiteSearch(query: string, apiKey: string): Promise<SearchCandidate[]> {
633 const url = new URL(SERPAPI_SEARCH_API);
634 url.searchParams.set('engine', 'google');
635 url.searchParams.set('q', query);
636 url.searchParams.set('num', '10');
637 url.searchParams.set('gl', 'us');
638 url.searchParams.set('hl', 'en');
639 url.searchParams.set('api_key', apiKey);
640 const response = await fetch(url, { signal: AbortSignal.timeout(30_000), headers: { Accept: 'application/json' } });
641 if (!response.ok) throw new Error(`SerpApi website search returned HTTP ${response.status}.`);
642 const body = await response.json() as Record<string, unknown>;
643 if (body.error) throw new Error('SerpApi website search returned a provider error.');
644 return parseSerpApiCandidates(body);
645}
646
647async function resolveWebsite(input: {
648 productName: string;
649 tagline: string;
650 productHuntRedirect: string;
651 makerWebsiteUrls: string[];
652 dataForSeoLogin: string;
653 dataForSeoPassword: string;
654 serpApiKey: string;
655}): Promise<WebsiteResolution> {
656 const warnings: string[] = [];
657 let providerCostUsd = 0;
658 let providerAttempts = 0;
659 const unresolved = (): WebsiteResolution => ({
660 websiteUrl: null,
661 source: 'none',
662 status: input.productHuntRedirect ? 'unresolved' : 'not_available',
663 confidence: 0,
664 signals: [],
665 providerCostUsd,
666 providerAttempts,
667 warnings,
668 });
669
670 if (input.productHuntRedirect) {
671 const destination = await resolveProductHuntRedirect(input.productHuntRedirect);
672 if (destination) {
673 const verified = await verifyWebsiteCandidates([{
674 url: destination, title: input.productName, snippet: input.tagline, rank: 1,
675 }], input.productName, input.tagline);
676 if (verified) {
677 return {
678 ...verified,
679 source: 'product_hunt_redirect',
680 status: 'verified_external',
681 providerCostUsd,
682 providerAttempts,
683 warnings,
684 };
685 }
686 }
687 }
688
689 const makerCandidates = input.makerWebsiteUrls
690 .filter((url) => isAllowedExternalWebsite(url))
691 .slice(0, 2)
692 .map((url, index): SearchCandidate => ({ url, title: '', snippet: '', rank: index + 1 }));
693 if (makerCandidates.length > 0) {
694 const verified = await verifyWebsiteCandidates(makerCandidates, input.productName, input.tagline);
695 if (verified) {
696 return {
697 ...verified,
698 source: 'maker_website',
699 status: 'verified_external',
700 providerCostUsd,
701 providerAttempts,
702 warnings,
703 };
704 }
705 }
706
707 const query = buildWebsiteSearchQuery(input.productName, input.tagline);
708 if (input.dataForSeoLogin && input.dataForSeoPassword) {
709 providerAttempts += 1;
710 try {
711 const search = await callDataForSeoWebsiteSearch(query, input.dataForSeoLogin, input.dataForSeoPassword);
712 providerCostUsd += search.costUsd;
713 const verified = await verifyWebsiteCandidates(search.candidates, input.productName, input.tagline);
714 if (verified) {
715 return {
716 ...verified,
717 source: 'dataforseo_organic',
718 status: 'verified_search_match',
719 providerCostUsd,
720 providerAttempts,
721 warnings,
722 };
723 }
724 warnings.push('DataForSEO returned no website candidate that passed product-identity verification.');
725 } catch (error) {
726 const message = error instanceof Error ? error.message : 'DataForSEO website search failed.';
727 warnings.push(message.slice(0, 160));
728 }
729 }
730
731 if (input.serpApiKey) {
732 providerAttempts += 1;
733 try {
734 const candidates = await callSerpApiWebsiteSearch(query, input.serpApiKey);
735 const verified = await verifyWebsiteCandidates(candidates, input.productName, input.tagline);
736 if (verified) {
737 return {
738 ...verified,
739 source: 'serpapi_organic',
740 status: 'verified_search_match',
741 providerCostUsd,
742 providerAttempts,
743 warnings,
744 };
745 }
746 warnings.push('SerpApi returned no website candidate that passed product-identity verification.');
747 } catch (error) {
748 const message = error instanceof Error ? error.message : 'SerpApi website search failed.';
749 warnings.push(message.slice(0, 160));
750 }
751 }
752
753 if (!input.dataForSeoLogin && !input.serpApiKey) {
754 warnings.push('No owner-managed website search provider was configured for unresolved Product Hunt links.');
755 }
756 return unresolved();
757}
758
759function companyDomainFromWebsite(websiteUrl: string): string {
760 try {
761 const hostname = new URL(websiteUrl).hostname.toLowerCase().replace(/^www\./, '');
762 return getDomain(hostname, { allowPrivateDomains: true }) ?? hostname;
763 } catch {
764 return '';
765 }
766}
767
768async function requestEmailListVerifyContacts(
769 apiKey: string,
770 body: { domain: string; firstName?: string; lastName?: string },
771): Promise<unknown> {
772 await waitForEmailListVerifySlot();
773 const response = await fetch(EMAIL_LIST_VERIFY_CONTACT_API, {
774 method: 'POST',
775 headers: { 'x-api-key': apiKey, 'Content-Type': 'application/json', Accept: 'application/json' },
776 body: JSON.stringify(body),
777 signal: AbortSignal.timeout(30_000),
778 });
779 if (!response.ok) {
780 if (response.status === 402) emailListVerifyCreditsExhausted = true;
781 throw new Error(`EmailListVerify Contact Finder returned HTTP ${response.status}.`);
782 }
783 return response.json();
784}
785
786async function discoverContacts(
787 websiteUrl: string,
788 makerNames: string[],
789 maxEmails: number,
790 apiKey: string,
791): Promise<ContactDiscovery> {
792 const domain = companyDomainFromWebsite(websiteUrl);
793 if (!domain || maxEmails <= 0) return { contacts: [], attempts: 0, credits: 0, warnings: [] };
794 if (!apiKey) {
795 return {
796 contacts: [], attempts: 0, credits: 0,
797 warnings: ['Contact discovery was requested but the owner-managed EmailListVerify key is not configured.'],
798 };
799 }
800 if (emailListVerifyCreditsExhausted) {
801 return {
802 contacts: [], attempts: 0, credits: 0,
803 warnings: ['EmailListVerify credits were exhausted earlier in this run; contact discovery was skipped.'],
804 };
805 }
806
807 const warnings: string[] = [];
808 let attempts = 0;
809 let credits = 0;
810 let contacts: ContactFinderResult[] = [];
811 const parsedMakers = makerNames.map(splitMakerName).filter(({ firstName, lastName }) => firstName || lastName);
812 const maker = parsedMakers.find(({ firstName, lastName }) => firstName && lastName) ?? parsedMakers[0];
813 if (maker) {
814 attempts += 1;
815 credits += 5;
816 try {
817 const payload = await requestEmailListVerifyContacts(apiKey, { domain, ...maker });
818 contacts = normalizeContactFinderResults(payload, domain, maxEmails);
819 if (contacts.length >= maxEmails) return { contacts, attempts, credits, warnings };
820 } catch (error) {
821 const message = error instanceof Error ? error.message : 'EmailListVerify named contact discovery failed.';
822 warnings.push(message.slice(0, 160));
823 }
824 }
825
826 if (!emailListVerifyCreditsExhausted) {
827 attempts += 1;
828 credits += 10;
829 try {
830 const payload = await requestEmailListVerifyContacts(apiKey, { domain });
831 contacts = normalizeContactFinderResults([
832 ...contacts,
833 ...(Array.isArray(payload) ? payload : []),
834 ], domain, maxEmails);
835 return { contacts, attempts, credits, warnings };
836 } catch (error) {
837 const message = error instanceof Error ? error.message : 'EmailListVerify domain contact discovery failed.';
838 warnings.push(message.slice(0, 160));
839 }
840 }
841 return { contacts, attempts, credits, warnings };
842}
843
844function selectBestEmails(
845 publicVerifications: EmailVerification[],
846 contacts: ContactFinderResult[],
847 maxEmails: number,
848): EmailVerification[] {
849 const contactVerifications: EmailVerification[] = contacts.map((contact) => ({
850 email: contact.email,
851 provider: 'emaillistverify',
852 status: contact.result as EmailListVerifyStatus,
853 checkedAt: new Date().toISOString(),
854 source: 'emaillistverify_contact_finder',
855 confidence: contact.confidence,
856 }));
857 const statusScore: Record<string, number> = {
858 ok: 100, ok_for_all: 70, unknown: 55, antispam_system: 50,
859 not_requested: 48, not_configured: 45, provider_error: 40, error_credit: 38,
860 smtp_protocol: 30, dead_server: 10, email_disabled: 5, invalid_mx: 0,
861 invalid_syntax: 0, disposable: 0, spamtrap: 0,
862 };
863 const best = new Map<string, EmailVerification>();
864 for (const verification of [...publicVerifications, ...contactVerifications]) {
865 const current = best.get(verification.email);
866 const score = (statusScore[verification.status] ?? 20)
867 + (verification.source === 'public_website' ? 3 : 0)
868 + (verification.confidence === 'high' ? 2 : verification.confidence === 'medium' ? 1 : 0);
869 const currentScore = current
870 ? (statusScore[current.status] ?? 20) + (current.source === 'public_website' ? 3 : 0)
871 : -1;
872 if (!current || score > currentScore) best.set(verification.email, verification);
873 }
874 return [...best.values()]
875 .filter(({ status }) => isUsableEmailStatus(status))
876 .sort((left, right) => {
877 const leftScore = (statusScore[left.status] ?? 20) + (left.source === 'public_website' ? 3 : 0);
878 const rightScore = (statusScore[right.status] ?? 20) + (right.source === 'public_website' ? 3 : 0);
879 return rightScore - leftScore;
880 })
881 .slice(0, maxEmails);
882}
883
884async function findEmails(
885 rawWebsiteUrl: string,
886 maxWebsitePages: number,
887 maxEmailsPerProduct: number,
888): Promise<EnrichmentResult> {
889 const websiteUrl = canonicalWebsiteUrl(rawWebsiteUrl);
890 if (!websiteUrl || !isAllowedExternalWebsite(websiteUrl)) {
891 return {
892 emails: [], emailSource: 'none', result: 'not_assessed', coverage: 'none',
893 terminalReason: 'invalid_or_private_target', pagesVisited: 0, pageUrls: [],
894 warnings: ['Website URL was not a permitted public external HTTP(S) target.'],
895 };
896 }
897
898 const initialUrl = new URL(websiteUrl);
899 const warnings: string[] = [];
900 const pageUrls: string[] = [];
901 const addPageUrl = (url: string): void => {
902 const canonical = canonicalWebsiteUrl(url);
903 if (canonical && !pageUrls.includes(canonical) && pageUrls.length < maxWebsitePages) pageUrls.push(canonical);
904 };
905 let canonicalOrigin = initialUrl.origin;
906 let baseDomain = initialUrl.hostname.replace(/^www\./i, '');
907 let emails: string[] = [];
908 let coverage: Coverage = 'complete';
909 let pagePlan: WebsitePage[] = [];
910
911 const staticHomepage = await fetchPublicHtml(`${initialUrl.origin}/`, 4_000);
912 if (staticHomepage.ok) {
913 const finalHomepage = new URL(staticHomepage.url);
914 canonicalOrigin = finalHomepage.origin;
915 baseDomain = finalHomepage.hostname.replace(/^www\./i, '');
916 addPageUrl(staticHomepage.url);
917 emails = rankEmails(extractStaticEmails(staticHomepage.html), baseDomain, maxEmailsPerProduct);
918 pagePlan = buildWebsitePagePlan(`${canonicalOrigin}/`, extractStaticFallbackPages(staticHomepage.html, canonicalOrigin), maxWebsitePages);
919 for (const target of pagePlan.slice(1)) {
920 if (emails.length >= maxEmailsPerProduct) break;
921 const staticPage = await fetchPublicHtml(target.url, 3_000);
922 if (!staticPage.ok) {
923 coverage = 'partial';
924 warnings.push(`${target.kind} static assessment failed: ${staticPage.reason}.`);
925 continue;
926 }
927 const finalPage = new URL(staticPage.url);
928 if (finalPage.origin !== canonicalOrigin) {
929 coverage = 'partial';
930 warnings.push(`${target.kind} redirected off the verified homepage origin; it was not extracted.`);
931 continue;
932 }
933 addPageUrl(staticPage.url);
934 emails = rankEmails([...emails, ...extractStaticEmails(staticPage.html)], baseDomain, maxEmailsPerProduct);
935 }
936 } else {
937 coverage = 'partial';
938 warnings.push(`Homepage static assessment failed: ${staticHomepage.reason}.`);
939 }
940
941 if (emails.length < maxEmailsPerProduct) {
942 const context = await (await ensureBrowser()).newContext({
943 userAgent: WEBSITE_USER_AGENT,
944 locale: 'en-US',
945 viewport: { width: 1280, height: 800 },
946 extraHTTPHeaders: { 'Accept-Language': 'en-US,en;q=0.9' },
947 });
948 await context.route('**/*', async (route) => {
949 if (['image', 'media', 'font'].includes(route.request().resourceType())) return route.abort();
950 const requestUrl = route.request().url();
951 if (!/^(?:data|blob|about):/i.test(requestUrl) && !await isSafePublicNetworkUrl(requestUrl)) return route.abort();
952 return route.continue();
953 });
954 try {
955 const page = await context.newPage();
956 const response = await page.goto(`${canonicalOrigin}/`, { waitUntil: 'domcontentloaded', timeout: 6_000 });
957 const finalHomepage = new URL(page.url());
958 if (!response || finalHomepage.origin !== canonicalOrigin) {
959 coverage = 'partial';
960 warnings.push('Homepage DOM assessment redirected off origin or did not return a response.');
961 } else {
962 addPageUrl(finalHomepage.toString());
963 await page.locator('body').waitFor({ state: 'attached', timeout: 750 }).catch(() => undefined);
964 emails = rankEmails([...emails, ...await page.evaluate(browserExtractEmails)], baseDomain, maxEmailsPerProduct);
965 if (pagePlan.length <= 1) {
966 pagePlan = buildWebsitePagePlan(`${canonicalOrigin}/`, await page.evaluate(browserExtractLinkedFallbackPages, canonicalOrigin), maxWebsitePages);
967 }
968 for (const target of pagePlan.slice(1)) {
969 if (emails.length >= maxEmailsPerProduct) break;
970 try {
971 const fallbackResponse = await page.goto(target.url, { waitUntil: 'domcontentloaded', timeout: 4_500 });
972 const finalFallback = new URL(page.url());
973 if (!fallbackResponse || finalFallback.origin !== canonicalOrigin) {
974 coverage = 'partial';
975 warnings.push(`${target.kind} DOM assessment redirected off origin.`);
976 continue;
977 }
978 addPageUrl(finalFallback.toString());
979 await page.locator('body').waitFor({ state: 'attached', timeout: 750 }).catch(() => undefined);
980 emails = rankEmails([...emails, ...await page.evaluate(browserExtractEmails)], baseDomain, maxEmailsPerProduct);
981 } catch (error) {
982 coverage = 'partial';
983 const message = error instanceof Error ? error.message : 'navigation error';
984 warnings.push(`${target.kind} DOM assessment failed: ${message.slice(0, 90)}.`);
985 }
986 }
987 }
988 } catch (error) {
989 coverage = 'partial';
990 const message = error instanceof Error ? error.message : 'browser error';
991 warnings.push(`Homepage DOM assessment failed: ${message.slice(0, 90)}.`);
992 } finally {
993 await context.close();
994 }
995 }
996
997 const result: EmailResult = emails.length > 0
998 ? 'emails_found'
999 : pageUrls.length > 0 && coverage === 'complete'
1000 ? 'no_public_email_found'
1001 : 'not_assessed';
1002 return {
1003 emails,
1004 emailSource: emails.length > 0 ? 'page_scrape' : 'none',
1005 result,
1006 coverage: pageUrls.length === 0 ? 'none' : coverage,
1007 terminalReason: emails.length >= maxEmailsPerProduct
1008 ? 'max_public_emails_reached'
1009 : result === 'no_public_email_found'
1010 ? 'eligible_pages_exhausted'
1011 : 'eligible_pages_partially_assessed',
1012 pagesVisited: pageUrls.length,
1013 pageUrls,
1014 warnings,
1015 };
1016}
1017
1018const POST_FIELDS = `
1019 id name tagline description votesCount commentsCount url website slug
1020 featuredAt createdAt dailyRank weeklyRank monthlyRank yearlyRank
1021 reviewsCount reviewsRating
1022 thumbnail { url }
1023 productLinks { type url }
1024 topics(first: 10) { edges { node { name } } }
1025 makers { id name username twitterUsername url websiteUrl }
1026`;
1027
1028const POSTS_QUERY = `query GetPosts($first:Int $after:String $order:PostsOrder $featured:Boolean $postedAfter:DateTime $postedBefore:DateTime $topic:String) {
1029 posts(first:$first after:$after order:$order featured:$featured postedAfter:$postedAfter postedBefore:$postedBefore topic:$topic) {
1030 edges { node { ${POST_FIELDS} } }
1031 pageInfo { endCursor hasNextPage }
1032 }
1033}`;
1034
1035const POST_BY_SLUG_QUERY = `query GetPost($slug:String!) { post(slug:$slug) { ${POST_FIELDS} } }`;
1036
1037function toInt(value: unknown): number | null {
1038 if (value === null || value === undefined || value === '') return null;
1039 const parsed = Number.parseInt(String(value), 10);
1040 return Number.isNaN(parsed) ? null : parsed;
1041}
1042
1043function toFloat(value: unknown): number | null {
1044 if (value === null || value === undefined || value === '') return null;
1045 const parsed = Number.parseFloat(String(value));
1046 return Number.isNaN(parsed) ? null : parsed;
1047}
1048
1049function nodeToRecord(node: Record<string, any>, rank: number): Record<string, unknown> {
1050 const makers = (node.makers ?? []).map((maker: Record<string, unknown>) => ({
1051 maker_name: maker.name ?? null,
1052 maker_id: maker.username ?? null,
1053 maker_ph_id: maker.id ?? null,
1054 twitter_url: maker.twitterUsername ? `https://twitter.com/${String(maker.twitterUsername)}` : null,
1055 maker_url: maker.url ?? null,
1056 website_url: maker.websiteUrl ?? null,
1057 }));
1058 const topics = (node.topics?.edges ?? []).map((edge: any) => edge?.node?.name).filter(Boolean);
1059 const productLinks = Array.isArray(node.productLinks) ? node.productLinks : [];
1060 const directWebsite = productLinks.find((link: Record<string, unknown>) => {
1061 const type = String(link.type ?? '').toLowerCase();
1062 return type === 'website' && typeof link.url === 'string' && isAllowedExternalWebsite(link.url);
1063 })?.url as string | undefined;
1064
1065 const websiteUrl = directWebsite ? canonicalWebsiteUrl(directWebsite) : null;
1066 return {
1067 product_name: node.name ? String(node.name).trim() : null,
1068 tagline: node.tagline ?? null,
1069 description: node.description ?? null,
1070 upvote_count: toInt(node.votesCount),
1071 comment_count: toInt(node.commentsCount),
1072 reviews_count: toInt(node.reviewsCount),
1073 reviews_rating: toFloat(node.reviewsRating),
1074 daily_rank: toInt(node.dailyRank) ?? rank,
1075 weekly_rank: toInt(node.weeklyRank),
1076 monthly_rank: toInt(node.monthlyRank),
1077 yearly_rank: toInt(node.yearlyRank),
1078 launch_date: node.featuredAt ? isoDate(new Date(node.featuredAt)) : node.createdAt ? isoDate(new Date(node.createdAt)) : null,
1079 product_hunt_url: node.url ?? (node.slug ? `https://www.producthunt.com/posts/${node.slug}` : null),
1080 product_hunt_website_url: node.website ?? null,
1081 website_url: websiteUrl,
1082 website_resolution_source: websiteUrl ? 'api_website_product_link' satisfies WebsiteResolutionSource : 'none' satisfies WebsiteResolutionSource,
1083 website_resolution_status: websiteUrl
1084 ? 'verified_external' satisfies WebsiteResolutionStatus
1085 : node.website
1086 ? 'unresolved' satisfies WebsiteResolutionStatus
1087 : 'not_available' satisfies WebsiteResolutionStatus,
1088 website_resolution_confidence: websiteUrl ? 100 : 0,
1089 website_resolution_signals: websiteUrl ? ['official_api_product_link'] : [],
1090 website_resolution_provider_cost_usd: 0,
1091 company_domain: websiteUrl ? companyDomainFromWebsite(websiteUrl) : null,
1092 topics,
1093 thumbnail_url: node.thumbnail?.url ?? null,
1094 makers,
1095 featured: Boolean(node.featuredAt),
1096 emails: [] as string[],
1097 verified_emails: [] as string[],
1098 risky_emails: [] as string[],
1099 rejected_emails: [] as string[],
1100 rejected_email_verifications: [] as EmailVerification[],
1101 email_verifications: [] as EmailVerification[],
1102 email_verification_requested: false,
1103 email_source: 'none' as EmailSource,
1104 email_result: 'not_assessed' as EmailResult,
1105 email_coverage: 'none' as Coverage,
1106 email_terminal_reason: 'website_not_available',
1107 email_pages_visited: 0,
1108 email_page_urls: [] as string[],
1109 contact_discovery_requested: false,
1110 contact_discovery_attempts: 0,
1111 contact_discovery_credits: 0,
1112 lead_status: 'unresolved_website',
1113 lead_score: 0,
1114 lead_tier: 'unqualified',
1115 lead_signals: [] as string[],
1116 enrichment_warnings: [] as string[],
1117 scraped_at: new Date().toISOString(),
1118 };
1119}
1120
1121const LEAN_FIELDS = new Set([
1122 'product_name', 'tagline', 'website_url', 'company_domain', 'product_hunt_website_url', 'website_resolution_status',
1123 'website_resolution_source', 'website_resolution_confidence', 'upvote_count', 'daily_rank', 'emails', 'verified_emails',
1124 'risky_emails', 'rejected_emails', 'email_verifications', 'rejected_email_verifications', 'email_verification_requested', 'email_source', 'email_result', 'email_coverage',
1125 'email_pages_visited', 'contact_discovery_attempts', 'lead_status', 'lead_score', 'lead_tier', 'lead_signals',
1126 'topics', 'launch_date', 'product_hunt_url', 'featured', 'scraped_at',
1127]);
1128
1129function applyOutputMode(record: Record<string, unknown>, outputMode: 'full' | 'lean' | 'leads'): Record<string, unknown> | null {
1130 if (outputMode === 'leads' && (!Array.isArray(record.emails) || record.emails.length === 0)) return null;
1131 if (outputMode !== 'lean') return record;
1132 const output: Record<string, unknown> = {};
1133 for (const field of LEAN_FIELDS) output[field] = record[field] ?? null;
1134 return output;
1135}
1136
1137function validateOutputRecord(record: Record<string, unknown>): string | null {
1138 if (typeof record.product_name !== 'string' || !record.product_name.trim()) return 'product_name is missing.';
1139 if (typeof record.product_hunt_url !== 'string' || !record.product_hunt_url.startsWith('https://')) return 'product_hunt_url is missing or invalid.';
1140 if (!Array.isArray(record.emails) || !record.emails.every((email) => typeof email === 'string')) return 'emails must be a string array.';
1141 if (typeof record.scraped_at !== 'string' || Number.isNaN(Date.parse(record.scraped_at))) return 'scraped_at is invalid.';
1142 return null;
1143}
1144
1145async function run(): Promise<void> {
1146 const rawInput = (await Actor.getInput() ?? {}) as Record<string, unknown>;
1147 const mode = (rawInput.mode ?? rawInput.scrapeMode ?? 'leaderboard') as Mode;
1148 const leaderboardPeriod = (rawInput.leaderboardPeriod ?? rawInput.period ?? 'daily') as LeaderboardPeriod;
1149 const startDate = String(rawInput.startDate ?? rawInput.date ?? '');
1150 const endDate = String(rawInput.endDate ?? '');
1151 const lookbackDays = clampInteger(rawInput.lookbackDays, 1, 1, 365);
1152 const outputMode = (rawInput.outputMode ?? 'full') as 'full' | 'lean' | 'leads';
1153 const searchQuery = typeof (rawInput.searchQuery ?? rawInput.query ?? rawInput.q) === 'string'
1154 ? String(rawInput.searchQuery ?? rawInput.query ?? rawInput.q).trim()
1155 : '';
1156 const topic = typeof (rawInput.topic ?? rawInput.topicSlug) === 'string'
1157 ? String(rawInput.topic ?? rawInput.topicSlug).trim()
1158 : '';
1159 const startUrls = Array.isArray(rawInput.startUrls) ? rawInput.startUrls as Array<{ url?: unknown } | string> : [];
1160 const maxResults = clampInteger(rawInput.maxResults ?? rawInput.maxItems ?? rawInput.limit, 100, 1, 20_000);
1161 const maxConcurrency = clampInteger(rawInput.maxConcurrency, 3, 1, 5);
1162 const maxWebsitePages = clampInteger(rawInput.maxWebsitePages, DEFAULT_MAX_WEBSITE_PAGES, 1, MAX_WEBSITE_PAGES);
1163 const maxEmailsPerProduct = clampInteger(rawInput.maxEmailsPerProduct, DEFAULT_MAX_EMAILS_PER_PRODUCT, 1, MAX_EMAILS_PER_PRODUCT);
1164 const includeAllProducts = Boolean(rawInput.includeAllProducts ?? rawInput.allProducts ?? rawInput.includeAll ?? true);
1165 const enrichEmails = Boolean(rawInput.enrichEmails ?? rawInput.findEmails ?? true);
1166 const resolveWebsites = Boolean(rawInput.resolveWebsites ?? true);
1167 const verifyEmails = Boolean(rawInput.verifyEmails ?? rawInput.verifyWithEmailListVerify ?? true);
1168 const findContacts = Boolean(rawInput.findContacts ?? true);
1169 const emailListVerifyApiKey = String(process.env.EMAILLISTVERIFY_API_KEY ?? '').trim();
1170 const phApiToken = String(process.env.PH_API_TOKEN ?? '');
1171 const dataForSeoLogin = String(process.env.DATAFORSEO_LOGIN ?? '').trim();
1172 const dataForSeoPassword = String(process.env.DATAFORSEO_PASSWORD ?? '').trim();
1173 const serpApiKey = String(process.env.SERPAPI_KEY ?? '').trim();
1174 const websiteOverrides = parseWebsiteOverrides(rawInput.websiteUrlOverrides);
1175 const startedAt = new Date().toISOString();
1176 emailListVerifyCreditsExhausted = false;
1177
1178 const diagnostics = {
1179 itemsPushed: 0,
1180 itemsProcessed: 0,
1181 itemsFiltered: 0,
1182 itemsFailed: 0,
1183 invalidRecords: 0,
1184 apiFailures: 0,
1185 websiteResolved: 0,
1186 websiteUnresolved: 0,
1187 websiteResolutionProviderAttempts: 0,
1188 websiteResolutionProviderCostUsd: 0,
1189 emailsFound: 0,
1190 verifiedEmailsFound: 0,
1191 emailVerificationAttempts: 0,
1192 emailVerificationProviderErrors: 0,
1193 contactDiscoveryAttempts: 0,
1194 contactDiscoveryCredits: 0,
1195 contactDiscoveryEmailsFound: 0,
1196 leadTiers: { A: 0, B: 0, C: 0, unqualified: 0 },
1197 resultEvents: 0,
1198 emailEvents: 0,
1199 chargeLimitReached: false,
1200 warnings: [] as string[],
1201 };
1202 let finalised = false;
1203
1204 const paidItemLimit = clampInteger(process.env.ACTOR_MAX_PAID_DATASET_ITEMS, maxResults, 1, maxResults);
1205 const effectiveMaxResults = Math.min(maxResults, paidItemLimit);
1206
1207 const addWarning = (message: string): void => {
1208 if (diagnostics.warnings.length < 100 && !diagnostics.warnings.includes(message)) diagnostics.warnings.push(message);
1209 };
1210
1211 const finalizeRun = async (outcome: RunOutcome, message: string): Promise<never> => {
1212 if (finalised) throw new Error(message);
1213 finalised = true;
1214 const finishedAt = new Date().toISOString();
1215 const summary = {
1216 outcome,
1217 message,
1218 startedAt,
1219 finishedAt,
1220 buildNumber: process.env.ACTOR_BUILD_NUMBER ?? null,
1221 runId: process.env.ACTOR_RUN_ID ?? null,
1222 datasetId: process.env.ACTOR_DEFAULT_DATASET_ID ?? null,
1223 keyValueStoreId: process.env.ACTOR_DEFAULT_KEY_VALUE_STORE_ID ?? null,
1224 input: {
1225 mode, leaderboardPeriod, startDate: startDate || null, endDate: endDate || null,
1226 lookbackDays, maxResults, effectiveMaxResults, includeAllProducts, enrichEmails, resolveWebsites, verifyEmails, findContacts,
1227 emailListVerifyConfigured: Boolean(emailListVerifyApiKey), maxWebsitePages,
1228 websiteSearchConfigured: Boolean((dataForSeoLogin && dataForSeoPassword) || serpApiKey),
1229 maxEmailsPerProduct, outputMode,
1230 },
1231 ...diagnostics,
1232 chargedEventCounts: {
1233 [RESULT_EVENT]: diagnostics.resultEvents,
1234 [EMAIL_EVENT]: diagnostics.emailEvents,
1235 },
1236 };
1237 const output = {
1238 outcome,
1239 message,
1240 itemsPushed: diagnostics.itemsPushed,
1241 itemsFailed: diagnostics.itemsFailed,
1242 invalidRecords: diagnostics.invalidRecords,
1243 chargedEventCounts: summary.chargedEventCounts,
1244 warnings: diagnostics.warnings,
1245 };
1246 await Actor.setValue('OUTPUT', output);
1247 await Actor.setValue('RUN_SUMMARY', summary);
1248 const failed = FAILED_OUTCOMES.has(outcome);
1249 await Actor.setStatusMessage(message, {
1250 isStatusMessageTerminal: true,
1251 level: failed ? 'ERROR' : outcome === 'COMPLETE' ? 'INFO' : 'WARNING',
1252 });
1253 if (failed) {
1254 await Actor.fail(message);
1255 }
1256 await Actor.exit();
1257 throw new Error('Actor terminal call returned unexpectedly.');
1258 };
1259
1260 try {
1261 const modes: Mode[] = ['leaderboard', 'search', 'topic', 'urls'];
1262 const periods: LeaderboardPeriod[] = ['daily', 'weekly', 'monthly', 'yearly'];
1263 const outputModes = ['full', 'lean', 'leads'];
1264 if (!modes.includes(mode)) await finalizeRun('INVALID_INPUT', 'mode must be leaderboard, search, topic, or urls.');
1265 if (!periods.includes(leaderboardPeriod)) await finalizeRun('INVALID_INPUT', 'leaderboardPeriod must be daily, weekly, monthly, or yearly.');
1266 if (!outputModes.includes(outputMode)) await finalizeRun('INVALID_INPUT', 'outputMode must be full, lean, or leads.');
1267 if (!phApiToken) await finalizeRun('CONFIG_ERROR', 'Product Hunt API token is not configured. Set the owner-managed PH_API_TOKEN secret and re-run.');
1268 if (mode === 'search' && !searchQuery) await finalizeRun('INVALID_INPUT', 'searchQuery is required for search mode.');
1269 if (mode === 'topic' && !topic) await finalizeRun('INVALID_INPUT', 'topic is required for topic mode.');
1270 if (mode === 'urls' && startUrls.length === 0) await finalizeRun('INVALID_INPUT', 'startUrls is required for urls mode.');
1271 if (startDate && !validIsoDate(startDate)) await finalizeRun('INVALID_INPUT', 'startDate must be a valid YYYY-MM-DD date.');
1272 if (endDate && !validIsoDate(endDate)) await finalizeRun('INVALID_INPUT', 'endDate must be a valid YYYY-MM-DD date.');
1273 if (startDate && endDate && startDate > endDate) await finalizeRun('INVALID_INPUT', 'endDate must be on or after startDate.');
1274 if (leaderboardPeriod !== 'daily' && endDate) await finalizeRun('INVALID_INPUT', 'endDate is supported only with daily leaderboard ranges.');
1275
1276 const maxPotentialEventCost = effectiveMaxResults * (RESULT_PRICE_USD + (enrichEmails ? maxEmailsPerProduct * EMAIL_PRICE_USD : 0));
1277 const maxPotentialVerificationCredits = enrichEmails && emailListVerifyApiKey
1278 ? effectiveMaxResults * ((verifyEmails ? maxEmailsPerProduct : 0) + (findContacts ? 15 : 0))
1279 : 0;
1280 log.info('ProductHunt Scraper starting', {
1281 mode, leaderboardPeriod, startDate: startDate || '(default)', endDate: endDate || null,
1282 lookbackDays, effectiveMaxResults, includeAllProducts, enrichEmails, resolveWebsites, verifyEmails, findContacts,
1283 emailListVerifyConfigured: Boolean(emailListVerifyApiKey), maxWebsitePages, maxEmailsPerProduct,
1284 websiteSearchProviders: {
1285 dataForSeo: Boolean(dataForSeoLogin && dataForSeoPassword),
1286 serpApiFallback: Boolean(serpApiKey),
1287 },
1288 eventCostCeilingUsd: Number(maxPotentialEventCost.toFixed(3)),
1289 emailListVerifyCreditCeiling: maxPotentialVerificationCredits,
1290 websiteSearchQueryCeiling: resolveWebsites ? effectiveMaxResults * (serpApiKey ? 2 : 1) : 0,
1291 platformUsage: 'Additional compute and proxy usage is billed by Apify separately where applicable.',
1292 });
1293 await Actor.setStatusMessage(`Starting: up to ${effectiveMaxResults} products, ${maxWebsitePages} public website pages/product, and ${maxEmailsPerProduct} public emails/product.`);
1294
1295 const phQuery = async (query: string, variables: Record<string, unknown>, attempt = 0): Promise<any> => {
1296 const response = await fetch(PH_API, {
1297 method: 'POST',
1298 headers: {
1299 'Content-Type': 'application/json',
1300 Accept: 'application/json',
1301 Authorization: `Bearer ${phApiToken}`,
1302 'User-Agent': PRODUCT_HUNT_USER_AGENT,
1303 },
1304 body: JSON.stringify({ query, variables }),
1305 signal: AbortSignal.timeout(30_000),
1306 });
1307 if (response.status === 429 && attempt < 3) {
1308 const retryAfter = Number(response.headers.get('x-rate-limit-reset') ?? response.headers.get('retry-after') ?? 30);
1309 const waitSeconds = Math.min(Math.max(5, Number.isFinite(retryAfter) ? retryAfter : 30), 120);
1310 log.warning(`Product Hunt API rate limited; waiting ${waitSeconds}s before retry ${attempt + 1}/3.`);
1311 await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000));
1312 return phQuery(query, variables, attempt + 1);
1313 }
1314 if (!response.ok) throw new Error(`Product Hunt API returned HTTP ${response.status}.`);
1315 const payload = await response.json() as { data?: unknown; errors?: Array<{ message?: string }> };
1316 if (payload.errors?.length) throw new Error(`Product Hunt GraphQL: ${payload.errors[0]?.message ?? 'unknown error'}`);
1317 return payload.data as any;
1318 };
1319
1320 const seenIds = new Set<string>();
1321 let collectedCandidates = 0;
1322
1323 const pushNode = async (node: Record<string, any>, rank: number): Promise<void> => {
1324 const record = nodeToRecord(node, rank);
1325 record.email_verification_requested = verifyEmails;
1326 const override = lookupWebsiteOverride(node, websiteOverrides);
1327 if (override) {
1328 record.website_url = override;
1329 record.website_resolution_source = 'input_override' satisfies WebsiteResolutionSource;
1330 record.website_resolution_status = 'verified_external' satisfies WebsiteResolutionStatus;
1331 record.website_resolution_confidence = 100;
1332 record.website_resolution_signals = ['approved_input_override'];
1333 }
1334
1335 let websiteUrl = typeof record.website_url === 'string' ? record.website_url : '';
1336 if (!websiteUrl && resolveWebsites) {
1337 const resolution = await resolveWebsite({
1338 productName: String(record.product_name ?? ''),
1339 tagline: String(record.tagline ?? ''),
1340 productHuntRedirect: typeof record.product_hunt_website_url === 'string' ? record.product_hunt_website_url : '',
1341 makerWebsiteUrls: (node.makers ?? [])
1342 .map((maker: Record<string, unknown>) => typeof maker.websiteUrl === 'string' ? maker.websiteUrl : '')
1343 .filter(Boolean),
1344 dataForSeoLogin,
1345 dataForSeoPassword,
1346 serpApiKey,
1347 });
1348 diagnostics.websiteResolutionProviderAttempts += resolution.providerAttempts;
1349 diagnostics.websiteResolutionProviderCostUsd += resolution.providerCostUsd;
1350 resolution.warnings.forEach(addWarning);
1351 record.website_resolution_provider_cost_usd = Number(resolution.providerCostUsd.toFixed(6));
1352 if (resolution.websiteUrl) {
1353 websiteUrl = resolution.websiteUrl;
1354 record.website_url = resolution.websiteUrl;
1355 record.website_resolution_source = resolution.source;
1356 record.website_resolution_status = resolution.status;
1357 record.website_resolution_confidence = resolution.confidence;
1358 record.website_resolution_signals = resolution.signals;
1359 }
1360 }
1361
1362 if (websiteUrl) {
1363 diagnostics.websiteResolved += 1;
1364 record.company_domain = companyDomainFromWebsite(websiteUrl);
1365 } else {
1366 diagnostics.websiteUnresolved += 1;
1367 record.website_resolution_status = record.product_hunt_website_url ? 'unresolved' : 'not_available';
1368 }
1369
1370 let publicEmailCount = 0;
1371 let publicVerifications: EmailVerification[] = [];
1372 if (enrichEmails && websiteUrl) {
1373 const enriched = await findEmails(websiteUrl, maxWebsitePages, maxEmailsPerProduct);
1374 record.emails = enriched.emails;
1375 record.email_source = enriched.emailSource;
1376 record.email_result = enriched.result;
1377 record.email_coverage = enriched.coverage;
1378 record.email_terminal_reason = enriched.terminalReason;
1379 record.email_pages_visited = enriched.pagesVisited;
1380 record.email_page_urls = enriched.pageUrls;
1381 record.enrichment_warnings = enriched.warnings;
1382 publicEmailCount = enriched.emails.length;
1383 enriched.warnings.forEach(addWarning);
1384 log.info('Website enrichment complete', {
1385 product: record.product_name,
1386 emailsFound: enriched.emails.length,
1387 source: enriched.emailSource,
1388 pagesVisited: enriched.pagesVisited,
1389 coverage: enriched.coverage,
1390 terminalReason: enriched.terminalReason,
1391 });
1392 }
1393
1394 const selectedEmails = Array.isArray(record.emails) ? record.emails.filter((email): email is string => typeof email === 'string') : [];
1395 if (selectedEmails.length > 0) {
1396 const verification = await verifyEmailsWithEmailListVerify(selectedEmails, verifyEmails, emailListVerifyApiKey);
1397 publicVerifications = verification.verifications;
1398 diagnostics.emailVerificationAttempts += verification.attempts;
1399 diagnostics.emailVerificationProviderErrors += verification.verifications.filter(({ status }) => status === 'provider_error' || status === 'error_credit').length;
1400 verification.warnings.forEach(addWarning);
1401 log.info('Public email verification complete', {
1402 product: record.product_name,
1403 emailsAssessed: selectedEmails.length,
1404 requested: verifyEmails,
1405 attempts: verification.attempts,
1406 statuses: verification.verifications.map(({ status }) => status),
1407 });
1408 }
1409
1410 let discoveredContacts: ContactFinderResult[] = [];
1411 const deliverablePublicCount = publicVerifications.filter(({ status }) => status === 'ok').length;
1412 if (enrichEmails && findContacts && websiteUrl && deliverablePublicCount < maxEmailsPerProduct) {
1413 record.contact_discovery_requested = true;
1414 const discovery = await discoverContacts(
1415 websiteUrl,
1416 (node.makers ?? []).map((maker: Record<string, unknown>) => String(maker.name ?? '')).filter(Boolean),
1417 Math.max(1, maxEmailsPerProduct - deliverablePublicCount),
1418 emailListVerifyApiKey,
1419 );
1420 discoveredContacts = discovery.contacts;
1421 record.contact_discovery_attempts = discovery.attempts;
1422 record.contact_discovery_credits = discovery.credits;
1423 diagnostics.contactDiscoveryAttempts += discovery.attempts;
1424 diagnostics.contactDiscoveryCredits += discovery.credits;
1425 diagnostics.contactDiscoveryEmailsFound += discovery.contacts.length;
1426 discovery.warnings.forEach(addWarning);
1427 }
1428
1429 const finalVerifications = selectBestEmails(publicVerifications, discoveredContacts, maxEmailsPerProduct);
1430 const rejectedVerifications = publicVerifications.filter(({ status }) => !isUsableEmailStatus(status));
1431 const finalEmails = finalVerifications.map(({ email }) => email);
1432 const publicSelected = finalVerifications.filter(({ source }) => source === 'public_website').length;
1433 const finderSelected = finalVerifications.filter(({ source }) => source === 'emaillistverify_contact_finder').length;
1434 record.emails = finalEmails;
1435 record.verified_emails = finalVerifications.filter(({ status }) => status === 'ok').map(({ email }) => email);
1436 record.risky_emails = finalVerifications.filter(({ status }) => status !== 'ok').map(({ email }) => email);
1437 record.rejected_emails = rejectedVerifications.map(({ email }) => email);
1438 record.email_verifications = finalVerifications;
1439 record.rejected_email_verifications = rejectedVerifications;
1440 record.email_source = publicSelected > 0 && finderSelected > 0
1441 ? 'mixed'
1442 : finderSelected > 0
1443 ? 'emaillistverify_contact_finder'
1444 : publicSelected > 0
1445 ? 'page_scrape'
1446 : 'none';
1447 record.email_result = finalEmails.length > 0 ? 'emails_found' : record.email_result;
1448 if (finderSelected > 0) record.email_terminal_reason = 'contact_finder_enriched';
1449 diagnostics.emailsFound += finalEmails.length;
1450 diagnostics.verifiedEmailsFound += (record.verified_emails as string[]).length;
1451
1452 const lead = buildLeadProfile({
1453 websiteResolved: Boolean(websiteUrl),
1454 websiteConfidence: Number(record.website_resolution_confidence ?? 0),
1455 emailStatuses: finalVerifications.map(({ status }) => status),
1456 publicEmailCount,
1457 upvotes: Number(record.upvote_count ?? 0),
1458 pagesVisited: Number(record.email_pages_visited ?? 0),
1459 });
1460 record.lead_status = lead.leadStatus;
1461 record.lead_score = lead.leadScore;
1462 record.lead_tier = lead.leadTier;
1463 record.lead_signals = lead.signals;
1464 diagnostics.leadTiers[lead.leadTier] += 1;
1465
1466 log.info('Lead enrichment complete', {
1467 product: record.product_name,
1468 websiteResolutionSource: record.website_resolution_source,
1469 websiteResolutionConfidence: record.website_resolution_confidence,
1470 publicEmailsFound: publicEmailCount,
1471 contactFinderEmailsFound: discoveredContacts.length,
1472 finalEmails: finalEmails.length,
1473 verifiedEmails: (record.verified_emails as string[]).length,
1474 leadTier: lead.leadTier,
1475 });
1476
1477 const outputRecord = applyOutputMode(record, outputMode);
1478 if (!outputRecord) {
1479 diagnostics.itemsFiltered += 1;
1480 return;
1481 }
1482 const validationError = validateOutputRecord(outputRecord);
1483 if (validationError) {
1484 diagnostics.invalidRecords += 1;
1485 diagnostics.itemsFailed += 1;
1486 addWarning(`A Product Hunt record was not persisted: ${validationError}`);
1487 return;
1488 }
1489
1490 try {
1491
1492
1493 const primaryResult = await Actor.pushData(outputRecord) as unknown as { chargedCount?: number; eventChargeLimitReached?: boolean };
1494 if (primaryResult.eventChargeLimitReached && (primaryResult.chargedCount ?? 0) === 0) {
1495 diagnostics.chargeLimitReached = true;
1496 addWarning('The event-charge limit was reached before this validated row could be persisted.');
1497 return;
1498 }
1499 diagnostics.itemsPushed += 1;
1500 diagnostics.resultEvents += primaryResult.chargedCount ?? 1;
1501 diagnostics.chargeLimitReached ||= Boolean(primaryResult.eventChargeLimitReached);
1502
1503 const emails = outputRecord.emails as string[];
1504 if (emails.length > 0 && !diagnostics.chargeLimitReached) {
1505 const emailResult = await Actor.charge({ eventName: EMAIL_EVENT, count: emails.length }) as unknown as { chargedCount?: number; eventChargeLimitReached?: boolean };
1506 diagnostics.emailEvents += emailResult.chargedCount ?? emails.length;
1507 diagnostics.chargeLimitReached ||= Boolean(emailResult.eventChargeLimitReached);
1508 if (emailResult.eventChargeLimitReached) addWarning('The event-charge limit was reached after a persisted record; remaining work stopped at the configured cap.');
1509 }
1510 } catch (error) {
1511 diagnostics.itemsFailed += 1;
1512 const message = error instanceof Error ? error.message : 'Unknown dataset-write failure.';
1513 addWarning(`Validated dataset write failed: ${message.slice(0, 120)}`);
1514 log.warning(`Validated dataset write failed for ${String(record.product_name)}: ${message.slice(0, 120)}`);
1515 }
1516 };
1517
1518 const processNodes = async (nodes: Array<Record<string, any>>): Promise<void> => {
1519 const uniqueNodes: Array<Record<string, any>> = [];
1520 for (const node of nodes) {
1521 if (collectedCandidates >= effectiveMaxResults || diagnostics.chargeLimitReached) break;
1522 const id = String(node.id ?? '');
1523 if (!id || seenIds.has(id)) continue;
1524 seenIds.add(id);
1525 uniqueNodes.push(node);
1526 collectedCandidates += 1;
1527 }
1528 for (let index = 0; index < uniqueNodes.length; index += maxConcurrency) {
1529 if (diagnostics.chargeLimitReached) break;
1530 const batch = uniqueNodes.slice(index, index + maxConcurrency);
1531 await Promise.all(batch.map((node, batchIndex) => pushNode(node, diagnostics.itemsProcessed + batchIndex + 1)));
1532 diagnostics.itemsProcessed += batch.length;
1533 }
1534 };
1535
1536 const streamPosts = async (range: DateRange | null, label: string): Promise<void> => {
1537 let cursor: string | null = null;
1538 let pageNumber = 1;
1539 while (collectedCandidates < effectiveMaxResults && !diagnostics.chargeLimitReached) {
1540 const remaining = effectiveMaxResults - collectedCandidates;
1541 const variables: Record<string, unknown> = { first: Math.min(20, remaining), after: cursor, order: 'VOTES' };
1542 if (!includeAllProducts) variables.featured = true;
1543 if (range) {
1544 variables.postedAfter = range.postedAfter;
1545 variables.postedBefore = range.postedBefore;
1546 }
1547 if (mode === 'topic') variables.topic = topic;
1548 try {
1549 const data: any = await phQuery(POSTS_QUERY, variables);
1550 const edges = data?.posts?.edges ?? [];
1551 const pageInfo: { hasNextPage?: boolean; endCursor?: string | null } = data?.posts?.pageInfo ?? {};
1552 if (!edges.length) break;
1553 log.info(`Product Hunt ${label} page ${pageNumber}`, { received: edges.length, processed: diagnostics.itemsProcessed });
1554 const nodes = edges.map((edge: { node?: Record<string, any> }) => edge.node).filter(Boolean);
1555 const selectedNodes = mode === 'search'
1556 ? nodes.filter((node: Record<string, any>) => matchesProductSearch(searchQuery, {
1557 name: node.name,
1558 tagline: node.tagline,
1559 description: node.description,
1560 topics: (node.topics?.edges ?? []).map((edge: any) => edge?.node?.name).filter(Boolean),
1561 }))
1562 : nodes;
1563 await processNodes(selectedNodes);
1564 if (!pageInfo.hasNextPage || !pageInfo.endCursor) break;
1565 cursor = pageInfo.endCursor;
1566 pageNumber += 1;
1567 } catch (error) {
1568 diagnostics.apiFailures += 1;
1569 const message = error instanceof Error ? error.message : 'Unknown Product Hunt API error.';
1570 addWarning(`Product Hunt ${label} query failed: ${message}`);
1571 break;
1572 }
1573 }
1574 };
1575
1576 if (mode === 'urls') {
1577 const rawSeeds = startUrls
1578 .map((entry) => typeof entry === 'string' ? entry : typeof entry?.url === 'string' ? entry.url : '')
1579 .filter(Boolean);
1580 const seeds = rawSeeds.map(parseProductHuntSeedUrl);
1581 const validSeeds = seeds.filter((seed) => seed.kind !== 'invalid');
1582 seeds.filter((seed) => seed.kind === 'invalid').forEach((seed) => addWarning(`Ignored start URL: ${seed.reason}`));
1583 if (validSeeds.length === 0) await finalizeRun('INVALID_INPUT', 'No valid Product Hunt post, product launch, or dated leaderboard URLs were supplied.');
1584
1585 for (const seed of validSeeds) {
1586 if (collectedCandidates >= effectiveMaxResults || diagnostics.chargeLimitReached) break;
1587 if (seed.kind === 'post') {
1588 try {
1589 const data = await phQuery(POST_BY_SLUG_QUERY, { slug: seed.slug });
1590 if (!data?.post) {
1591 addWarning(`No Product Hunt post matched the supplied URL slug: ${seed.slug}.`);
1592 continue;
1593 }
1594 await processNodes([data.post]);
1595 } catch (error) {
1596 diagnostics.apiFailures += 1;
1597 const message = error instanceof Error ? error.message : 'Unknown Product Hunt API error.';
1598 addWarning(`Product Hunt URL lookup failed for ${seed.slug}: ${message}`);
1599 }
1600 } else if (seed.kind === 'leaderboard') {
1601 await streamPosts(dateRangeForPeriod(seed.period, seed.anchorDate), `${seed.period} leaderboard URL`);
1602 }
1603 }
1604 } else {
1605 let range: DateRange | null = null;
1606 if (mode === 'leaderboard') {
1607 if (startDate) {
1608 range = dateRangeForPeriod(leaderboardPeriod, startDate, endDate);
1609 } else if (lookbackDays > 1) {
1610 const end = new Date();
1611 const begin = new Date(end);
1612 begin.setUTCDate(end.getUTCDate() - lookbackDays + 1);
1613 range = { postedAfter: `${isoDate(begin)}T00:00:00+00:00`, postedBefore: `${isoDate(end)}T23:59:59+00:00` };
1614 } else {
1615 range = dateRangeForPeriod(leaderboardPeriod, isoDate(new Date()));
1616 }
1617 }
1618 await streamPosts(range, mode);
1619 }
1620
1621 await closeBrowser();
1622 const hasUsefulRows = diagnostics.itemsPushed > 0;
1623 const outcome: RunOutcome = !hasUsefulRows
1624 ? diagnostics.apiFailures > 0 || diagnostics.itemsFailed > 0 || diagnostics.invalidRecords > 0
1625 ? 'UPSTREAM_FAILED'
1626 : 'VALID_EMPTY'
1627 : diagnostics.apiFailures > 0 || diagnostics.itemsFailed > 0 || (resolveWebsites && diagnostics.websiteUnresolved > 0) || diagnostics.chargeLimitReached
1628 ? 'PARTIAL'
1629 : 'COMPLETE';
1630 const eventCost = diagnostics.resultEvents * RESULT_PRICE_USD + diagnostics.emailEvents * EMAIL_PRICE_USD;
1631 await finalizeRun(outcome, `${outcome}: processed ${diagnostics.itemsProcessed} Product Hunt products, persisted ${diagnostics.itemsPushed} rows, and found ${diagnostics.emailsFound} public email(s). Event charges: $${eventCost.toFixed(3)} plus applicable platform usage.`);
1632 } catch (error) {
1633 await closeBrowser();
1634 if (finalised) return;
1635 const message = error instanceof Error ? error.message : 'Unknown actor failure.';
1636 diagnostics.itemsFailed += 1;
1637 addWarning(`Unhandled actor error: ${message.slice(0, 160)}`);
1638 await finalizeRun('UPSTREAM_FAILED', `The run stopped before completion: ${message.slice(0, 160)}`);
1639 }
1640}
1641
1642await run();