company-funded.mjs
Rama main · réplica de lectura del repositorio original.
#!/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 <n> Max companies in the review list. Default: 20.
--months <n> Recent-funding window. Default: 3.
--sort <date|score> Candidate ordering. Default: date.
--sources <csv> 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*<!\[CDATA\[([\s\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) || /<html/i.test(raw)) {
return /\b(access denied|captcha|cloudflare|attention required|verify you are human|enable javascript|unusual traffic|temporarily blocked|bot detection|ddos-guard|akamai|perimeterx)\b/i.test(raw);
}
return false;
}
async function fetchTextMeta(url, { timeoutMs = 12_000, source = '' } = {}) {
const startUrl = String(url || '');
if (!trustedForSource(startUrl, source)) return untrustedSourceMeta(startUrl, startUrl, `untrusted source URL: ${startUrl}`);
if (isPrivateOrInternalHostname(new URL(startUrl).hostname)) {
return untrustedSourceMeta(startUrl, startUrl, `internal source URL rejected: ${startUrl}`);
}
const controller = new AbortController();
const timer = setTimeout(() => 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(/<category\b[^>]*>([\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(/<item\b[^>]*>([\s\S]*?)<\/item>/gi),
...raw.matchAll(/<entry\b[^>]*>([\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 = [
// (?<!\w)/(?!\w), not \b: the `cuts N%` alternative ends in "%", and a
// trailing \b can never match after a non-word character — so that
// alternative was dead and layoff headlines that also mention a raise
// ("Acme raises $40M Series A, then cuts 30% of staff") were surfaced as
// funding leads. Same symbol-edge class as the fix in #2227. For every
// other alternative (all of which end in a word character) \b and the
// lookarounds are equivalent, so their behaviour is unchanged.
/(?<!\w)(acquires?|acquired|acquisition|merger|spac|ipo|bankruptcy|layoffs?|earnings|quarterly results)(?!\w)/i,
// A layoff announced in the same headline as a raise is the case #2404
// fixed, but it only covered one spelling — "cuts N%". These reach the
// same reader with the same harm, the tool recommending a company that
// just announced job losses:
// "raises $40M …, then cuts 30 % of staff" (a space before the percent)
// "raises $40M …, then cuts 1,200 jobs" (a count, no percent)
// "raises $40M … and lays off 300 employees" (a different verb)
//
// EVERY form requires a workforce noun, percent included. The percent
// alternative used to sit in the group above with no such requirement, so
// "cuts 30% of cloud costs" — a cost story at a company that may well
// still be hiring — was excluded as if it were a layoff. Scoping it here
// fixes that and makes the two numeric forms consistent, which splitting
// them did not (CodeRabbit review). The optional "of its/the/their"
// carries the spellings the corpus uses ("cuts 15% of its workforce").
/(?<!\w)(cuts?|axes|slashes|eliminates|sheds)\s+\d[\d,.]*\s*(?:%\s*)?(?:of\s+)?(?:its\s+|the\s+|their\s+)?(jobs|roles|positions|staff|employees|workers|workforce|headcount)(?!\w)/i,
// Plural too: "amid workforce reductions" bypassed the singular-only form.
/\b(lays?\s+off|laying\s+off|laid\s+off|job\s+cuts|workforce\s+reductions?|headcount\s+reductions?|staff\s+reductions?)\b/i,
/\b(public offering|registered direct offering|private placement|atm offering|offering of common stock)\b/i,
/\braises?\s+\$[\d,.]+(?:\.\d+)?\s*(?:billion|million|bn|m|b|k)?\s+fund\b/i,
/\b(venture fund|vc fund|investment fund|private equity fund|capital fund|fund ii|fund iii|fund iv|new fund)\b/i,
/\b(grant|grants|scholarship|scholarships|award|awards|donation|donates?)\b/i,
/\b(how to raise|ways to raise|guide to fundraising|startup valuations|bubble fears)\b/i,
];
if (exclusions.some((re) => 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(/\]/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);
});
}