#!/usr/bin/env node /** * company-funded.mjs - discover recently funded companies for manual review. * * V1 is intentionally narrow: it reads structured public feeds/APIs, writes a * report/JSON candidate list, and never probes arbitrary company websites or * edits portals.yml. */ import { mkdirSync, writeFileSync } from 'fs'; import { isIP } from 'net'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; import { decodeEntities } from './providers/_html-entities.mjs'; import { safeEncodeURIComponent } from './providers/_safe-url.mjs'; import { BROWSER_LIKE_USER_AGENT } from './user-agent.mjs'; import { isMainModule } from './lib/is-main-module.mjs'; import { localToday } from './lib/local-today.mjs'; import { getCareerOpsRoot } from './path-resolver.mjs'; import { flagValue, hasFlag, validateFlags } from './lib/cli-flags.mjs'; const ROOT = dirname(fileURLToPath(import.meta.url)); const DATA_ROOT = getCareerOpsRoot(); const DEFAULT_LIMIT = 20; const DEFAULT_MONTHS = 3; const DEFAULT_SORT = 'date'; const DEFAULT_SOURCES = ['techcrunch', 'prnewswire', 'guardian', 'hn']; const RSS_SOURCES = { techcrunch: [ 'https://techcrunch.com/feed/', 'https://techcrunch.com/category/startups/feed/', 'https://techcrunch.com/tag/funding/feed/', ], prnewswire: [ 'https://www.prnewswire.com/rss/news-releases-list.rss', 'https://www.prnewswire.com/rss/venture-capital-list.rss', ], guardian: ['https://www.theguardian.com/technology/rss'], // Opt-in (not in DEFAULT_SOURCES): public, no-auth RSS checked 2026-10-16. // Tech trade press that regularly carries funding announcements: a 1-month // dry run read 30 items, 8 funding-like, and produced 4 candidates. siliconangle: ['https://siliconangle.com/feed/'], }; const SOURCE_HOSTS = { techcrunch: ['techcrunch.com'], prnewswire: ['prnewswire.com'], guardian: ['theguardian.com'], siliconangle: ['siliconangle.com'], hacker_news: ['hn.algolia.com', 'news.ycombinator.com'], }; const SOURCE_RANK = { techcrunch: 70, prnewswire: 65, guardian: 50, siliconangle: 45, hacker_news: 35, web: 5, }; const GENERIC_NAMES = new Set([ 'ai', 'startup', 'startups', 'company', 'companies', 'founder', 'founders', 'developer', 'developers', 'edtech platform', 'fintech platform', 'healthcare platform', 'security platform', 'techcrunch', 'pr newswire', 'business wire', 'globenewswire', 'crunchbase', 'hacker news', 'valuation', 'valuations', 'bubble', 'fears', 'funding', 'fund', 'round', ]); const USAGE = `Usage: node company-funded.mjs --dry-run node company-funded.mjs --limit 25 --months 6 --sort date node company-funded.mjs --sources techcrunch,prnewswire,guardian,hn --dry-run Options: --limit Max companies in the review list. Default: 20. --months Recent-funding window. Default: 3. --sort Candidate ordering. Default: date. --sources Sources. Default: techcrunch,prnewswire,guardian,hn. Also available: siliconangle. --dry-run Print only; do not write report/JSON files. --json Print machine-readable JSON. --self-test Run offline self-test. `; function usage() { console.log(USAGE); } function parseArgs(argv) { const args = argv.slice(2); const KNOWN_FLAGS = ['--dry-run','--json','--self-test','--help','-h','--limit','--months','--sort','--sources']; const VALUE_FLAGS = ['--limit','--months','--sort','--sources']; validateFlags(args, KNOWN_FLAGS, USAGE, { valueFlags: VALUE_FLAGS, requireOperand: true }); return { limit: parseLimit(flagValue(args, '--limit') ?? String(DEFAULT_LIMIT)), months: parseMonths(flagValue(args, '--months') ?? String(DEFAULT_MONTHS)), sort: parseSort(flagValue(args, '--sort') ?? DEFAULT_SORT), sources: parseSources(flagValue(args, '--sources') ?? DEFAULT_SOURCES.join(',')), dryRun: hasFlag(args, '--dry-run'), json: hasFlag(args, '--json'), selfTest: hasFlag(args, '--self-test'), help: hasFlag(args, '--help') || hasFlag(args, '-h'), }; } function parseLimit(value) { const n = Number(value); if (!Number.isInteger(n) || n <= 0 || n > 100) throw new Error('--limit must be an integer from 1 to 100'); return n; } function parseMonths(value) { const n = Number(value); if (!Number.isInteger(n) || n <= 0 || n > 120) throw new Error('--months must be an integer from 1 to 120'); return n; } function parseSort(value) { const s = String(value || '').trim().toLowerCase(); if (!['date', 'score'].includes(s)) throw new Error('--sort must be date or score'); return s; } function parseSources(value) { const out = String(value || '') .split(',') .map((s) => normalizeSourceName(s.trim())) .filter(Boolean); const unique = [...new Set(out)]; const allowed = new Set(['techcrunch', 'prnewswire', 'guardian', 'siliconangle', 'hn']); for (const source of unique) { if (!allowed.has(source)) throw new Error(`unknown source in --sources: ${source}`); } return unique.length ? unique : [...DEFAULT_SOURCES]; } function normalizeSourceName(value) { const s = String(value || '').toLowerCase().replace(/[_\s]+/g, '-'); if (s === 'hacker-news' || s === 'hackernews' || s === 'hn') return 'hn'; return s.replace(/-/g, ''); } function compact(text) { return String(text || '').replace(/\s+/g, ' ').trim(); } function companySlug(name) { return compact(name) .toLowerCase() .replace(/&/g, ' and ') .replace(/[^a-z0-9]+/g, '') .trim(); } export function matchesDomain(hostname, domain) { const host = String(hostname || '').toLowerCase(); const d = String(domain || '').toLowerCase(); return Boolean(host && d && (host === d || host.endsWith(`.${d}`))); } function allowedSourceDomains(source) { return SOURCE_HOSTS[source === 'hn' ? 'hacker_news' : source] || []; } function parseHttpUrl(raw) { try { const url = new URL(String(raw || '')); if (url.protocol !== 'https:' && url.protocol !== 'http:') return null; return url; } catch { return null; } } function trustedForSource(raw, source) { const url = parseHttpUrl(raw); if (!url) return false; const domains = allowedSourceDomains(source); return domains.some((domain) => matchesDomain(url.hostname, domain)); } function isPrivateIpv4(parts) { const [a, b] = parts; return ( a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 198 && (b === 18 || b === 19)) || a >= 224 ); } function parseIpv4Part(part) { if (/^0x[0-9a-f]+$/i.test(part)) return Number.parseInt(part.slice(2), 16); if (/^0[0-7]+$/.test(part)) return Number.parseInt(part, 8); if (/^\d+$/.test(part)) return Number.parseInt(part, 10); return NaN; } function parseIpv4Hostname(host) { const parts = String(host || '').split('.'); if (parts.length < 1 || parts.length > 4 || parts.some((part) => part === '')) return null; if (!parts.every((part) => /^(?:0x[0-9a-f]+|0[0-7]+|\d+)$/i.test(part))) return null; const nums = parts.map(parseIpv4Part); if (nums.some((num) => !Number.isSafeInteger(num) || num < 0)) return null; const prefix = nums.slice(0, -1); if (prefix.some((num) => num > 255)) return null; const last = nums.at(-1); const remainingBytes = 5 - nums.length; if (last > 256 ** remainingBytes - 1) return null; if (nums.length === 1) return [(last >>> 24) & 255, (last >>> 16) & 255, (last >>> 8) & 255, last & 255]; if (nums.length === 2) return [nums[0], (last >>> 16) & 255, (last >>> 8) & 255, last & 255]; if (nums.length === 3) return [nums[0], nums[1], (last >>> 8) & 255, last & 255]; return nums; } function parseIpv4MappedIpv6(host) { const normalized = String(host || '').toLowerCase(); const dotted = normalized.match(/^(?:0:0:0:0:0:ffff:|::ffff:)(\d{1,3}(?:\.\d{1,3}){3})$/); if (dotted) return parseIpv4Hostname(dotted[1]); const hex = normalized.match(/^(?:0:0:0:0:0:ffff:|::ffff:)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/); if (!hex) return null; const high = Number.parseInt(hex[1], 16); const low = Number.parseInt(hex[2], 16); if (high > 0xffff || low > 0xffff) return null; return [(high >>> 8) & 255, high & 255, (low >>> 8) & 255, low & 255]; } function isPrivateOrInternalHostname(hostname) { const host = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, ''); if (!host) return true; if (host === 'localhost' || host.endsWith('.localhost')) return true; const ipv4 = parseIpv4Hostname(host); if (ipv4) return isPrivateIpv4(ipv4); const version = isIP(host); if (version !== 6) return false; if (host === '::1' || host === '0:0:0:0:0:0:0:1') return true; const mappedIpv4 = parseIpv4MappedIpv6(host); if (mappedIpv4) return isPrivateIpv4(mappedIpv4); const firstHextet = host.split(':')[0]; if (/^f[cd][0-9a-f]{0,2}$/i.test(firstHextet)) return true; const first = Number.parseInt(firstHextet, 16); return Number.isFinite(first) && first >= 0xfe80 && first <= 0xfebf; } function untrustedSourceMeta(url, finalUrl, message, status = 0, contentType = '') { return { url, finalUrl, status, ok: false, contentType, text: '', blocked: false, error: message, }; } async function releaseResponseBody(res) { try { if (res?.bodyUsed) return; await res?.arrayBuffer?.(); } catch { // Best-effort cleanup for rejected responses. } } export function sourceFromUrl(raw, fallback = 'web') { const url = parseHttpUrl(raw); if (!url) return fallback; for (const [source, domains] of Object.entries(SOURCE_HOSTS)) { if (domains.some((domain) => matchesDomain(url.hostname, domain))) return source; } return fallback; } function trustedEvidenceUrl(raw, source) { const url = parseHttpUrl(raw); if (!url) return ''; const derived = sourceFromUrl(url.href, ''); if (!derived) return ''; if (source && source !== 'web' && derived !== source && !(source === 'hn' && derived === 'hacker_news')) return ''; return url.href; } function cdataValue(text) { const match = String(text || '').match(/^\s*\s*$/); return match ? match[1] : text; } function xmlText(text) { return compact(decodeEntities(cdataValue(String(text || '')))); } function stripPublisher(title) { return compact(title) .replace(/\s+\|\s+.*$/i, '') .replace(/\s+-\s+(TechCrunch|Crunchbase|Forbes|Reuters|Bloomberg|BusinessWire|PR Newswire|SiliconANGLE|VentureBeat|The Guardian).*$/i, '') .replace(/^Show HN:\s*/i, '') .replace(/^Ask HN:\s*/i, ''); } function cleanCompanyName(raw) { let s = stripPublisher(xmlText(raw)) .replace(/^["'`]+|["'`]+$/g, '') .replace(/'s$/i, '') .replace(/’s$/i, '') .replace(/\s*\([^)]*\)\s*$/g, '') .replace(/\s*,.*$/g, '') .trim(); const afterStartup = s.match(/\bstartup\s+(.+)$/i); if (afterStartup?.[1]) s = afterStartup[1].trim(); if (/\bstartup$/i.test(s) && /['’]s\b/i.test(s)) return ''; if (/['’]s\b.*\b(company|startup)\b/i.test(s)) return ''; s = s.replace(/\s+in talks to$/i, ''); s = s.replace(/\s+reportedly$/i, ''); s = s.replace(/\s+defies\s+.+$/i, ''); s = s.replace(/^[A-Z][A-Za-z0-9&.-]+-backed\s+/i, ''); s = s.replace(/\b(?:maker|creator)\s+of\s+.+$/i, ''); s = s.replace(/^(?:the\s+)?(?:(?:ai|genai|agentic|coding|developer tools|developer|data|security|fintech|startup|software|robotics|defense|healthcare|biotech|open-source|enterprise|infrastructure|crypto|climate)\s+)+(?:startup|company|platform)?\s+/i, ''); s = s.replace(/^.+\bmaker\s+/i, ''); s = s.replace(/^(?:startup|company|platform)\s+/i, ''); s = compact(s); if (s.length < 2 || s.length > 70) return ''; if (/[<>]/.test(s)) return ''; if (GENERIC_NAMES.has(s.toLowerCase())) return ''; if (/^(ai|genai|agentic|edtech|fintech|security|healthcare|biotech|robotics|climate|crypto|developer tools?)\s+(startup|company|platform)$/i.test(s)) return ''; if (/\b(valuation|valuations|bubble|fears|earnings|fund)\b/i.test(s)) return ''; if (!/[a-z0-9]/i.test(s)) return ''; return s; } export function extractCompanyFromFundingTitle(title) { const t = stripPublisher(xmlText(title)); if (/^(ask hn|tell hn|who is hiring|launch hn)\b/i.test(t)) return ''; const patterns = [ /\b(?:raises?|raised|lands|landed|secures?|secured|closes?|closed|nabs?|nabbed|bags?|bagged)\s+(?:an?\s+|over\s+|more\s+than\s+|up\s+to\s+)?\$[\d.,]+\s*(?:billion|million|bn|[bkmt])?\s+for\s+(?:an?\s+)?(?:ai\s+)?startup\s+(.+?)$/i, /\bstartup\s+(.+?)\s+(?:reportedly\s+)?(?:raises?|raised|lands|landed|secures?|secured|closes?|closed|nabs?|nabbed|bags?|bagged)\b/i, /(?:startup|company|agency|platform)\s+(.+?)\s+hits\s+unicorn\s+status\b.*\braises?\b/i, /^(.+?)\s+(?:raises?|raised|lands|landed|secures?|secured|closes?|closed|nabs?|nabbed|bags?|bagged)\s+(?:an?\s+|over\s+|more\s+than\s+|up\s+to\s+)?\$[\d.,]+\s*(?:billion|million|bn|[bkmt])?\b/i, /^(.+?)\s+(?:raises?|raised|lands|landed|secures?|secured|closes?|closed|nabs?|nabbed|bags?|bagged)\s+(?:an?\s+)?(?:\w+\s+){0,4}(?:funding|round|series\s+[a-h]|seed|pre-seed|investment|financing|valuation)\b/i, /^(.+?)\s+(?:announces?|announced)\s+(?:\$[\d.,]+\s*(?:billion|million|bn|[bkmt])?\s+)?(?:\w+\s+){0,4}(?:funding|round|series\s+[a-h]|seed|pre-seed|financing)\b/i, /^(.+?)\s+(?:gets|got|receives?|received)\s+(?:\$[\d.,]+\s*(?:billion|million|bn|[bkmt])?\s+)?(?:in\s+)?(?:funding|investment|financing)\b/i, /^(.+?)\s+(?:hits?|hit|reaches?|reached)\s+(?:a\s+)?\$[\d.,]+(?:\.\d+)?\s*(?:billion|million|bn|[bkmt])?\s+valuation\b/i, /^(.+?)\s+valued\s+at\s+\$[\d.,]+(?:\.\d+)?\s*(?:billion|million|bn|[bkmt])?\b/i, /^(.+?)\s+emerges? from stealth\b/i, /^(.+?)\s+in\s+talks\s+to\s+raise\b/i, ]; for (const re of patterns) { const match = t.match(re); if (match?.[1]) return cleanCompanyName(match[1]); } return ''; } export function extractFundingDetails(text) { const raw = xmlText(text); const amount = raw.match(/\$[\d,.]+(?:\.\d+)?\s*(?:billion|million|bn|m|b|k)?/i)?.[0] || ''; const round = raw.match(/\b(?:pre-seed|seed|series\s+[a-h]|strategic|venture|growth)\b/i)?.[0] || ''; return { amount: compact(amount), round: round ? round.replace(/\s+/g, ' ').replace(/\bseries\b/i, 'Series') : '', }; } function companyKey(name) { return compact(name).toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); } function diagnostic(source) { return { source, status: 'ok', fetched_items: 0, funding_like_items: 0, candidate_count: 0, blocked: false, errors: [], }; } function markDiagnosticError(diag, message, { blocked = false } = {}) { if (!message) return; diag.errors.push(message); if (blocked) diag.blocked = true; if (diag.status !== 'blocked') diag.status = blocked ? 'blocked' : 'error'; } function detectBlockedContent(text, { status = 200, contentType = '' } = {}) { const raw = String(text || '').slice(0, 20_000); if ([401, 403, 429, 503].includes(Number(status))) return true; if (/text\/html/i.test(contentType) || / controller.abort(), timeoutMs); try { let currentUrl = startUrl; for (let hops = 0; hops <= 5; hops++) { const res = await fetch(currentUrl, { headers: { 'user-agent': BROWSER_LIKE_USER_AGENT, accept: 'application/rss+xml,application/xml,text/xml,text/plain,*/*' }, redirect: 'manual', signal: controller.signal, }); const contentType = res.headers.get('content-type') || ''; if ([301, 302, 303, 307, 308].includes(Number(res.status))) { const location = res.headers.get('location') || ''; if (!location) { await releaseResponseBody(res); return untrustedSourceMeta(startUrl, currentUrl, `redirect without Location: ${currentUrl}`, res.status, contentType); } let nextUrl; try { nextUrl = new URL(location, currentUrl).href; } catch { await releaseResponseBody(res); return untrustedSourceMeta(startUrl, currentUrl, `invalid redirect Location: ${location}`, res.status, contentType); } const nextHost = new URL(nextUrl).hostname; if (isPrivateOrInternalHostname(nextHost)) { await releaseResponseBody(res); return untrustedSourceMeta(startUrl, nextUrl, `internal redirect target rejected: ${nextUrl}`, res.status, contentType); } if (!trustedForSource(nextUrl, source)) { await releaseResponseBody(res); return untrustedSourceMeta(startUrl, nextUrl, `untrusted final source URL: ${nextUrl}`, res.status, contentType); } await releaseResponseBody(res); currentUrl = nextUrl; continue; } const finalUrl = res.url || currentUrl; if (!trustedForSource(finalUrl, source)) { await releaseResponseBody(res); return untrustedSourceMeta(startUrl, finalUrl, `untrusted final source URL: ${finalUrl}`, res.status, contentType); } const text = await res.text().catch(() => ''); return { url: startUrl, finalUrl, status: res.status, ok: res.ok, contentType, text, blocked: detectBlockedContent(text, { status: res.status, contentType }), error: res.ok ? '' : `HTTP ${res.status}`, }; } return untrustedSourceMeta(startUrl, startUrl, 'too many redirects'); } catch (err) { return { url: startUrl, finalUrl: startUrl, status: 0, ok: false, contentType: '', text: '', blocked: false, error: err?.name === 'AbortError' ? 'timeout' : (err?.message || 'fetch failed'), }; } finally { clearTimeout(timer); } } async function fetchJsonMeta(url, { timeoutMs = 12_000, source = '' } = {}) { const meta = await fetchTextMeta(url, { timeoutMs, source }); if (!meta.ok) return { ...meta, json: null }; try { return { ...meta, json: JSON.parse(meta.text) }; } catch (err) { return { ...meta, ok: false, json: null, error: `invalid JSON: ${err.message}` }; } } function tagValue(xml, names) { for (const name of names) { const escaped = name.replace(':', '\\:'); const re = new RegExp(`<${escaped}\\b[^>]*>([\\s\\S]*?)<\\/${escaped}>`, 'i'); const match = String(xml || '').match(re); if (match?.[1]) return xmlText(match[1]); } return ''; } function attrValue(xml, tag, attr) { const re = new RegExp(`<${tag}\\b[^>]*\\b${attr}=["']([^"']+)["'][^>]*>`, 'i'); return xmlText(String(xml || '').match(re)?.[1] || ''); } function categoriesFromXml(xml) { return [...String(xml || '').matchAll(/]*>([\s\S]*?)<\/category>/gi)] .map((match) => xmlText(match[1])) .filter(Boolean); } function parsePublishedDate(value) { const raw = compact(value); if (!raw) return null; const date = new Date(raw); if (Number.isNaN(date.getTime())) return null; return { value: date.toISOString().slice(0, 10), precision: 'day', date, }; } export function parseRssItems(xml, { source = 'web' } = {}) { const out = []; const raw = String(xml || ''); const blocks = [ ...raw.matchAll(/]*>([\s\S]*?)<\/item>/gi), ...raw.matchAll(/]*>([\s\S]*?)<\/entry>/gi), ]; for (const blockMatch of blocks) { const block = blockMatch[1]; const title = tagValue(block, ['title']); const description = tagValue(block, ['description', 'summary', 'content:encoded', 'content']); const link = compact(tagValue(block, ['link'])) || attrValue(block, 'link', 'href'); const observedDate = parsePublishedDate(tagValue(block, ['pubDate', 'published', 'updated', 'dc:date'])); const sourceCompany = tagValue(block, ['dc:contributor', 'dc:creator', 'author', 'source']); const itemSource = sourceFromUrl(link, source); if (!title && !link) continue; out.push({ source: itemSource === 'web' ? source : itemSource, title, url: trustedEvidenceUrl(link, itemSource === 'web' ? source : itemSource), published_at: observedDate?.date?.toISOString() || '', observedDate, text: compact(`${title} ${description}`), categories: categoriesFromXml(block), source_company: sourceCompany, }); } return out; } function hasFundingLanguage(text) { return /\b(funding|funded|raises?|raised|raise|series\s+[a-h]|seed\s+round|pre-seed|venture\s+round|investment|financing|valuation|valued\s+at|lands?|landed|secures?|secured|closes?|closed|nabs?|nabbed|bags?|bagged|emerges?\s+from\s+stealth|in\s+talks\s+to\s+raise)\b/i.test(text); } function isExcludedFundingItem(item) { const text = `${item.title || ''} ${item.text || ''} ${item.categories?.join(' ') || ''}`; const exclusions = [ // (? re.test(text))) return true; return !hasFundingLanguage(text); } function inferredDateFromText(text, now = new Date()) { const raw = String(text || ''); const year = raw.match(/\b(20\d{2})\b/)?.[1]; if (year) { // The year where the user is: the UTC year turns over hours apart from it, // and a year read as the next one dates the item in the future. const currentYear = now.getFullYear(); const date = Number(year) === currentYear ? new Date(now.getTime()) : new Date(`${year}-01-01T00:00:00Z`); return { value: String(year), precision: 'year', date }; } return { value: localToday(now), precision: 'observed', date: now }; } function bestObservedDate(item, now) { if (item.observedDate?.date instanceof Date && !Number.isNaN(item.observedDate.date.getTime())) return item.observedDate; if (item.published_at) { const parsed = parsePublishedDate(item.published_at); if (parsed) return parsed; } return inferredDateFromText(`${item.title || ''} ${item.text || ''}`, now); } function withinMonths(date, months, now) { const cutoff = new Date(now.getTime()); cutoff.setUTCMonth(cutoff.getUTCMonth() - months); return date >= cutoff && date <= now; } function confidenceFor(item, details) { const sourceStrong = ['techcrunch', 'prnewswire', 'guardian'].includes(item.source); const detailStrong = Boolean(details.amount || details.round); if (sourceStrong && detailStrong) return 'high'; if (sourceStrong || detailStrong) return 'medium'; return 'low'; } function candidateFromItem(item, { now }) { if (isExcludedFundingItem(item)) return null; const observed = bestObservedDate(item, now); const details = extractFundingDetails(`${item.title || ''} ${item.text || ''}`); const sourceCompany = item.source === 'prnewswire' ? cleanCompanyName(item.source_company || '') : ''; const company = extractCompanyFromFundingTitle(item.title) || sourceCompany; if (!company) return null; return { company, amount: details.amount, round: details.round, source: item.source || sourceFromUrl(item.url), title: compact(item.title || ''), url: trustedEvidenceUrl(item.url || '', ''), observedDate: observed, confidence: confidenceFor(item, details), }; } export function buildCandidates(items, { months = DEFAULT_MONTHS, sort = DEFAULT_SORT, limit = DEFAULT_LIMIT, now = new Date(), diagnostics = [], } = {}) { const grouped = new Map(); for (const item of items || []) { const candidate = candidateFromItem(item, { now }); if (!candidate) continue; if (!withinMonths(candidate.observedDate.date, months, now)) continue; const key = companyKey(candidate.company); if (!key) continue; const current = grouped.get(key) || { company: candidate.company, amount: '', round: '', funding: { status: 'recent_funding', confidence: 'low', sources: [] }, discovery_score: 0, suggested_action: 'review_company_manually', }; if (!current.amount && candidate.amount) current.amount = candidate.amount; if (!current.round && candidate.round) current.round = candidate.round; const evidence = { source: candidate.source, title: candidate.title, url: candidate.url, observed_date: candidate.observedDate.value, date_precision: candidate.observedDate.precision, }; const evidenceKey = `${evidence.source}\n${evidence.title}\n${evidence.url}\n${evidence.observed_date}`; const duplicateEvidence = current.funding.sources.some((src) => `${src.source}\n${src.title}\n${src.url}\n${src.observed_date}` === evidenceKey); if (!duplicateEvidence) { current.funding.sources.push(evidence); const sourceRank = SOURCE_RANK[candidate.source] || SOURCE_RANK.web; const detailBonus = (candidate.amount ? 10 : 0) + (candidate.round ? 8 : 0); const confidenceBonus = candidate.confidence === 'high' ? 15 : candidate.confidence === 'medium' ? 8 : 0; current.discovery_score += sourceRank + detailBonus + confidenceBonus; current.funding.confidence = bestConfidence(current.funding.confidence, candidate.confidence); } grouped.set(key, current); } const candidates = [...grouped.values()].map((candidate) => { candidate.funding.sources.sort((a, b) => String(b.observed_date || '').localeCompare(String(a.observed_date || ''))); return candidate; }); candidates.sort((a, b) => { if (sort === 'score') return b.discovery_score - a.discovery_score || a.company.localeCompare(b.company); const ad = a.funding.sources[0]?.observed_date || ''; const bd = b.funding.sources[0]?.observed_date || ''; return bd.localeCompare(ad) || b.discovery_score - a.discovery_score || a.company.localeCompare(b.company); }); const finalCandidates = candidates.slice(0, limit); for (const diag of diagnostics) { diag.candidate_count = finalCandidates.filter((c) => c.funding.sources.some((s) => s.source === sourceNameForDiagnostic(diag.source))).length; } return finalCandidates; } function sourceNameForDiagnostic(source) { return source === 'hn' ? 'hacker_news' : source; } function bestConfidence(a, b) { const rank = { low: 1, medium: 2, high: 3 }; return rank[b] > rank[a] ? b : a; } async function fetchRssDiscovery(source, diagnostics) { const diag = diagnostics.find((d) => d.source === source) || diagnostic(source); const out = []; for (const url of RSS_SOURCES[source] || []) { const meta = await fetchTextMeta(url, { source }); if (!meta.ok || meta.blocked) { markDiagnosticError(diag, `${url}: ${meta.error || (meta.blocked ? 'blocked/challenge page' : 'fetch failed')}`, { blocked: meta.blocked }); continue; } const items = parseRssItems(meta.text, { source }); diag.fetched_items += items.length; for (const item of items) { if (isExcludedFundingItem(item)) continue; diag.funding_like_items += 1; out.push(item); } } if (diag.fetched_items === 0 && diag.errors.length === 0) markDiagnosticError(diag, 'no RSS items fetched'); return out; } function hnQueries() { return [ 'AI startup raises funding', 'Series A AI startup', 'developer tools startup funding', 'infrastructure startup raises funding', 'agentic AI raises seed', ]; } async function fetchHnDiscovery({ months = DEFAULT_MONTHS, now = new Date(), diagnostics = [] } = {}) { const diag = diagnostics.find((d) => d.source === 'hn') || diagnostic('hn'); const cutoff = Math.floor(now.getTime() / 1000) - months * 31 * 24 * 60 * 60; const out = []; for (const query of hnQueries()) { const url = `https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(query)}` + `&tags=story&hitsPerPage=50&numericFilters=created_at_i>${cutoff}`; const meta = await fetchJsonMeta(url, { source: 'hacker_news' }); if (!meta.ok || meta.blocked || !meta.json) { markDiagnosticError(diag, `${url}: ${meta.error || (meta.blocked ? 'blocked/challenge page' : 'fetch failed')}`, { blocked: meta.blocked }); continue; } const hits = Array.isArray(meta.json?.hits) ? meta.json.hits : []; diag.fetched_items += hits.length; for (const hit of hits) { const title = compact(hit.title || hit.story_title || ''); if (!title) continue; // A lone surrogate in objectID would throw URIError out of // encodeURIComponent and abort the loop over the remaining hits; fall back // to no synthetic URL (hit.url is tried first anyway). const encodedId = safeEncodeURIComponent(hit.objectID || ''); const fallbackUrl = encodedId === null ? '' : `https://news.ycombinator.com/item?id=${encodedId}`; const itemUrl = trustedEvidenceUrl(hit.url || '', '') || trustedEvidenceUrl(fallbackUrl, 'hacker_news'); const item = { source: 'hacker_news', title: xmlText(title), url: itemUrl, published_at: hit.created_at ? new Date(hit.created_at).toISOString() : '', observedDate: hit.created_at ? { value: hit.created_at.slice(0, 10), precision: 'day', date: new Date(hit.created_at) } : null, text: xmlText(hit.story_text || ''), categories: [], source_company: '', }; if (isExcludedFundingItem(item)) continue; diag.funding_like_items += 1; out.push(item); } } if (diag.fetched_items === 0 && diag.errors.length === 0) markDiagnosticError(diag, 'no HN items fetched'); return out; } async function collectDiscoveryItems(opts, diagnostics) { const requested = new Set(opts.sources || DEFAULT_SOURCES); const hits = []; for (const source of ['techcrunch', 'prnewswire', 'guardian', 'siliconangle']) { if (requested.has(source)) hits.push(...await fetchRssDiscovery(source, diagnostics)); } if (requested.has('hn')) { hits.push(...await fetchHnDiscovery({ months: opts.months, now: opts.now, diagnostics })); } return hits; } function markdownText(value) { return String(value || '') .replace(/\\/g, '\\\\') .replace(/&/g, '&') .replace(//g, '>') .replace(/\|/g, '\\|') .replace(/\[/g, '\\[') .replace(/\]/g, '\\]') .replace(/\r?\n/g, ' '); } function safeReportUrl(value) { const url = parseHttpUrl(value); if (!url) return ''; return url.href.replace(/[<>\s]/g, encodeURIComponent); } export function renderReport(result) { const lines = []; lines.push(`# Funded Company Discovery - ${markdownText(result.generated_at)}`); lines.push(''); lines.push('Review-first output. No companies were added to portals.yml and no company websites were probed.'); lines.push(''); lines.push(`Window: ${result.window_months} months`); lines.push(`Sort: ${markdownText(result.sort)}`); lines.push(`Sources: ${result.sources.map(markdownText).join(', ')}`); lines.push(`Candidates: ${result.companies.length}`); lines.push(''); lines.push('## Source Health'); lines.push(''); lines.push('| Source | Status | Fetched | Funding-like | Candidates | Notes |'); lines.push('|--------|--------|---------|--------------|------------|-------|'); for (const diag of result.diagnostics) { lines.push(`| ${markdownText(diag.source)} | ${markdownText(diag.status)} | ${diag.fetched_items} | ${diag.funding_like_items} | ${diag.candidate_count} | ${markdownText(diag.errors.join('; '))} |`); } lines.push(''); lines.push('| # | Company | Funding | Date | Source | Action |'); lines.push('|---|---------|---------|------|--------|--------|'); result.companies.forEach((candidate, idx) => { const src = candidate.funding.sources?.[0] || {}; const funding = [candidate.round, candidate.amount, candidate.funding.status].filter(Boolean).join(' / '); lines.push(`| ${idx + 1} | ${markdownText(candidate.company)} | ${markdownText(funding)} | ${markdownText(src.observed_date || '')} | ${markdownText(src.source || '')} | ${markdownText(candidate.suggested_action)} |`); }); lines.push(''); for (const candidate of result.companies) { lines.push(`## ${markdownText(candidate.company)}`); lines.push(''); lines.push(`- Funding signal: ${markdownText(candidate.funding.status)} (${markdownText(candidate.funding.confidence)})`); if (candidate.round || candidate.amount) lines.push(`- Round/amount: ${markdownText([candidate.round, candidate.amount].filter(Boolean).join(', '))}`); lines.push(`- Suggested action: ${markdownText(candidate.suggested_action)}`); lines.push('- Evidence:'); for (const src of candidate.funding.sources.slice(0, 3)) { const url = safeReportUrl(src.url); const suffix = url ? ` - ${url}` : ''; lines.push(` - ${markdownText(src.source)}${src.observed_date ? `, ${markdownText(src.observed_date)}` : ''}: ${markdownText(src.title)}${suffix}`); } lines.push(''); } return `${lines.join('\n')}\n`; } function writeArtifacts(result) { const outDir = join(DATA_ROOT, 'output'); const reportDir = join(DATA_ROOT, 'reports'); mkdirSync(outDir, { recursive: true }); mkdirSync(reportDir, { recursive: true }); const jsonPath = join(outDir, `funded-companies-${result.generated_at}.json`); const reportPath = join(reportDir, `funded-companies-${result.generated_at}.md`); writeFileSync(jsonPath, `${JSON.stringify(result, null, 2)}\n`, 'utf-8'); writeFileSync(reportPath, renderReport(result), 'utf-8'); return { jsonPath, reportPath }; } function printHuman(result) { console.log(`Funded company discovery - ${result.generated_at}`); console.log(`Window: ${result.window_months} months`); console.log(`Sort: ${result.sort}`); console.log(`Sources: ${result.sources.join(', ')}`); console.log(`Candidates: ${result.companies.length}`); if (result.artifacts) { console.log(`JSON: ${result.artifacts.jsonPath}`); console.log(`Report: ${result.artifacts.reportPath}`); } const unhealthy = result.diagnostics.filter((d) => d.status !== 'ok' || d.blocked || d.fetched_items === 0); if (unhealthy.length || result.companies.length === 0) { console.log(''); console.log('Source health:'); for (const diag of result.diagnostics) { const note = diag.errors.length ? ` - ${diag.errors.join('; ')}` : ''; console.log(` ${diag.source}: ${diag.status}, fetched ${diag.fetched_items}, funding-like ${diag.funding_like_items}, candidates ${diag.candidate_count}${note}`); } } console.log(''); for (const [idx, candidate] of result.companies.entries()) { const src = candidate.funding.sources?.[0] || {}; console.log(`${idx + 1}. ${candidate.company}`); console.log(` Funding: ${[candidate.round, candidate.amount, candidate.funding.status].filter(Boolean).join(' / ')} (${candidate.funding.confidence})${src.observed_date ? `, ${src.observed_date}` : ''}`); console.log(` Source: ${src.source || 'n/a'} - ${src.title || 'n/a'}`); console.log(` Evidence: ${src.url || 'n/a'}`); console.log(` Action: ${candidate.suggested_action}`); } } export async function discoverFundedCompanies(opts = {}) { const months = opts.months || DEFAULT_MONTHS; // The moment the window is measured back from; one value for the whole run, // copied so a caller changing its own Date cannot move it mid-run. const now = opts.now instanceof Date ? new Date(opts.now.getTime()) : new Date(); const sources = (opts.sources || DEFAULT_SOURCES).map(normalizeSourceName); const diagnostics = [...new Set(sources)].map(diagnostic); const hits = Array.isArray(opts.discoveryItems) ? opts.discoveryItems : await collectDiscoveryItems({ ...opts, months, sources, now }, diagnostics); const companies = buildCandidates(hits, { months, sort: opts.sort || DEFAULT_SORT, limit: opts.limit || DEFAULT_LIMIT, now, diagnostics, }); return { generated_at: localToday(now), window_months: months, sort: opts.sort || DEFAULT_SORT, dry_run: Boolean(opts.dryRun), sources, diagnostics, companies, }; } async function selfTest() { const examples = [ ['Prime Intellect raises $130M Series A', 'Prime Intellect'], ['AI coding startup Cursor maker Anysphere raises Series C funding', 'Anysphere'], ['Ex-DeepMind David Silver Raises $1.1B for AI Startup Ineffable', 'Ineffable'], ['Travis Kalanick’s robotics company raises $1.7B, led by a16z', ''], ['AI startup valuations raise bubble fears as funding surges', ''], ["Yann LeCun's AI startup raises $1B seed round", ''], ['Acme closes $25M Series A round', 'Acme'], ['Ask HN: Who is hiring?', ''], ]; for (const [title, expected] of examples) { const got = extractCompanyFromFundingTitle(title); if (got !== expected) throw new Error(`extractCompanyFromFundingTitle(${JSON.stringify(title)}) = ${JSON.stringify(got)}, expected ${JSON.stringify(expected)}`); } const details = extractFundingDetails('Acme closes $25M Series A round'); if (details.amount !== '$25M' || details.round !== 'Series A') { throw new Error(`extractFundingDetails failed: ${JSON.stringify(details)}`); } const candidates = buildCandidates([ { source: 'techcrunch', title: 'Acme raises $25M Series B', url: 'https://techcrunch.com/acme', observedDate: { value: '2026-06-10', precision: 'day', date: new Date('2026-06-10T00:00:00Z') }, text: 'Acme raises $25M Series B funding.', categories: ['Startups'], }, { source: 'hacker_news', title: 'Startup raises seed funding in March 2026', url: 'https://news.ycombinator.com/item?id=2', observedDate: { value: '2026-03-10', precision: 'day', date: new Date('2026-03-10T00:00:00Z') }, text: '', categories: [], }, ], { months: 3, now: new Date('2026-07-20T00:00:00Z') }); if (candidates.length !== 1 || candidates[0].company !== 'Acme') { throw new Error(`buildCandidates failed: ${JSON.stringify(candidates)}`); } console.log('company-funded self-test OK'); } async function main() { const opts = parseArgs(process.argv); if (opts.help) { usage(); return; } if (opts.selfTest) { await selfTest(); return; } const result = await discoverFundedCompanies(opts); if (!opts.dryRun) result.artifacts = writeArtifacts(result); if (opts.json) console.log(JSON.stringify(result, null, 2)); else printHuman(result); } if (isMainModule(import.meta.url)) { main().catch((err) => { console.error(`company-funded: ${err.message}`); process.exit(1); }); }