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