// @ts-check /** * seeds/vc-portfolios.mjs — VC portfolio seed fetchers for career-ops. * * Pulls public VC portfolio company lists (Y Combinator, Andreessen Horowitz) * and emits company entries compatible with the existing ATS scan/discovery * path (same shape as tracked_companies entries in portals.yml). * * Design constraints: * - Zero auth — public sources only, no login, no API keys. * - Zero LLM tokens — pure HTTP + JSON / HTML. * - Same SLUG_RE guard used by scan-ats-full.mjs for every slug that reaches * URL interpolation — a tampered or malformed payload can never inject * unexpected characters into a URL. * - `parseSeedEntries()` is a pure, synchronous function (no network) so it * can be unit-tested with inline fixtures without any mocking. * * Typical usage (via scan-ats-full.mjs --seeds flag): * node scan-ats-full.mjs --seeds yc * node scan-ats-full.mjs --seeds yc,a16z --since 7 --dry-run * * Direct usage: * import { fetchYCCompanies, fetchA16zCompanies } from './seeds/vc-portfolios.mjs'; * const companies = await fetchYCCompanies(); */ // ── Constants ──────────────────────────────────────────────────────── /** * Safe charset for slug values that will be interpolated into ATS URLs. * Consistent with the SLUG_RE guard in scan-ats-full.mjs. */ export const SLUG_RE = /^[A-Za-z0-9._-]+$/; const DEFAULT_TIMEOUT_MS = 20_000; const DEFAULT_USER_AGENT = 'Mozilla/5.0 (compatible; career-ops-seeds/1.0)'; /** * YC public company API. * Returns paginated JSON with company objects including name, slug, website. * Documentation: https://www.ycombinator.com/companies (public, no auth needed). */ const YC_API_URL = 'https://api.ycombinator.com/v0.1/companies?page=1&per_page=1000'; /** * a16z public portfolio page. * The portfolio is a publicly accessible HTML page listing all portfolio companies. */ const A16Z_PORTFOLIO_URL = 'https://a16z.com/portfolio/'; // ── HTTP helper (local — avoids importing providers/_http.mjs to keep seeds/ self-contained) ── /** * Minimal fetch wrapper with timeout + user-agent header. * * @param {string} url * @param {{ timeoutMs?: number }} [opts] * @returns {Promise} */ async function fetchWithTimeout(url, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const res = await fetch(url, { headers: { 'user-agent': DEFAULT_USER_AGENT }, signal: controller.signal, }); if (!res.ok) { const snippet = await res.text().catch(() => '').then(t => t.slice(0, 200)); throw new Error(`HTTP ${res.status}${snippet ? ': ' + snippet.replace(/\s+/g, ' ').trim() : ''}`); } return res; } finally { clearTimeout(timer); } } // ── Shared types (JSDoc only — no runtime cost) ────────────────────── /** * A single VC-portfolio company entry — the output unit of both seed fetchers. * * @typedef {object} SeedCompany * @property {string} name Display name, e.g. "Stripe". * @property {string} slug URL-safe slug, validated against SLUG_RE. * @property {string} url Company website URL. * @property {string} [ats] ATS platform if detectable: 'greenhouse' | 'lever' | 'ashby'. * @property {string} [ats_id] ATS board/org slug for URL construction. * @property {string} [source] Which VC list this came from: 'yc' | 'a16z'. * @property {string} [batch] YC batch label, e.g. "W21" (YC only). */ /** * A PortalEntry-compatible object ready to be passed to ATS provider.detect(). * Shape matches the PortalEntry typedef in providers/_types.js. * * @typedef {object} SeedPortalEntry * @property {string} name * @property {string} careers_url Best-effort ATS or website URL. * @property {string} [source] Seed origin ('yc' | 'a16z'). */ // ── Pure parser: YC ────────────────────────────────────────────────── /** * Parse a raw YC API response payload into SeedCompany entries. * * This is the testable unit — pure, no network, no side effects. * The YC API returns a paginated JSON object: * { companies: [{ id, name, slug, website, batch, ... }], ... } * * @param {unknown} payload Parsed JSON from the YC API (or a fixture in tests). * @returns {SeedCompany[]} */ export function parseYCPayload(payload) { if (!payload || typeof payload !== 'object') return []; const raw = /** @type {any} */ (payload); const list = Array.isArray(raw.companies) ? raw.companies : (Array.isArray(raw) ? raw : []); /** @type {Map} */ const seen = new Map(); for (const item of list) { if (!item || typeof item !== 'object') continue; const name = typeof item.name === 'string' ? item.name.trim() : ''; if (!name) continue; // Prefer explicit slug; derive from name as fallback. const rawSlug = typeof item.slug === 'string' ? item.slug.trim() : name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); if (!rawSlug || !SLUG_RE.test(rawSlug)) continue; // Deduplicate by slug. if (seen.has(rawSlug)) continue; const url = typeof item.website === 'string' && item.website.startsWith('http') ? item.website.trim() : (typeof item.url === 'string' && item.url.startsWith('http') ? item.url.trim() : ''); /** @type {SeedCompany} */ const entry = { name, slug: rawSlug, url, source: 'yc', }; if (typeof item.batch === 'string' && item.batch.trim()) { entry.batch = item.batch.trim(); } seen.set(rawSlug, entry); } return [...seen.values()]; } // ── Pure parser: a16z ──────────────────────────────────────────────── /** * Parse raw a16z portfolio HTML into SeedCompany entries. * * This is the testable unit — pure, no network, no side effects. * The a16z portfolio page lists companies in anchor tags with data attributes. * We extract company names and URLs from the HTML without a full DOM parser — * matching patterns like: * * or

Stripe

adjacent to a link * * Strategy: look for JSON-LD structured data first (most reliable), then * fall back to pattern-matching anchor/heading text. * * @param {string} html Raw HTML from the a16z portfolio page (or a fixture in tests). * @returns {SeedCompany[]} */ export function parseA16zPayload(html) { if (typeof html !== 'string' || !html.trim()) return []; /** @type {Map} */ const seen = new Map(); // Strategy 1: JSON-LD embedded in the page (structured data block). // a16z sometimes embeds schema.org/Organization blocks — extract if present. const jsonLdMatches = html.matchAll(/]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi); for (const match of jsonLdMatches) { try { const data = JSON.parse(match[1]); const items = Array.isArray(data) ? data : [data]; for (const item of items) { if (item?.['@type'] === 'Organization' || item?.['@type'] === 'Corporation') { const name = typeof item.name === 'string' ? item.name.trim() : ''; const url = typeof item.url === 'string' ? item.url.trim() : ''; if (!name) continue; const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); if (!slug || !SLUG_RE.test(slug) || seen.has(slug)) continue; seen.set(slug, { name, slug, url, source: 'a16z' }); } } } catch { // Malformed JSON-LD — skip silently. } } // Strategy 2: data-company-name attributes (a16z uses React-rendered data attrs). // Pattern: data-company-name="Stripe" (optionally with data-company-url) const dataAttrRe = /data-company-name=["']([^"']+)["'](?:[^>]*data-company-url=["']([^"']+)["'])?/gi; for (const match of html.matchAll(dataAttrRe)) { const name = match[1]?.trim(); const url = match[2]?.trim() || ''; if (!name) continue; const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); if (!slug || !SLUG_RE.test(slug) || seen.has(slug)) continue; seen.set(slug, { name, slug, url, source: 'a16z' }); } // Strategy 3: Portfolio card anchors / heading text fallback. // Matches patterns like:
Stripe // or company names in h3/h4 elements within portfolio sections. const portfolioAnchorRe = /]*href=["'](https?:\/\/[^"'?\s]+)["'][^>]*class=["'][^"']*(?:portfolio|company|card)[^"']*["'][^>]*>\s*([A-Z][^<]{1,60}?)\s*<\/a>/gi; for (const match of html.matchAll(portfolioAnchorRe)) { const url = match[1]?.trim(); const name = match[2]?.trim().replace(/\s+/g, ' '); if (!name || !url) continue; // Filter out nav/generic link text. if (/^(read more|learn more|visit|see all|view|more|news|blog|press|contact)/i.test(name)) continue; const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); if (!slug || !SLUG_RE.test(slug) || seen.has(slug)) continue; seen.set(slug, { name, slug, url, source: 'a16z' }); } return [...seen.values()]; } // ── Generic pure parser (entry point for test-all.mjs) ─────────────── /** * Parse a raw seed payload (either YC JSON or a16z HTML) into validated * SeedCompany entries. This is the universal testable unit cited in the * issue acceptance criteria. * * @param {unknown} payload JSON object (YC) or HTML string (a16z). * @param {'yc'|'a16z'} source Which VC portfolio this payload came from. * @returns {SeedCompany[]} */ export function parseSeedEntries(payload, source) { if (source === 'a16z') { return parseA16zPayload(typeof payload === 'string' ? payload : ''); } // Default: YC (also used for unknown sources — parse defensively). return parseYCPayload(payload); } // ── toPortalEntry converter ────────────────────────────────────────── /** * Convert a SeedCompany into a PortalEntry-shaped object that ATS provider * detect() can consume directly. * * Resolution order for careers_url: * 1. If `company.ats === 'greenhouse'` and `company.ats_id` is set → Greenhouse board URL. * 2. If `company.ats === 'lever'` and `company.ats_id` is set → Lever URL. * 3. If `company.ats === 'ashby'` and `company.ats_id` is set → Ashby URL. * 4. Derive from slug: try Greenhouse, Lever, Ashby URLs (provider.detect() will * validate at scan time; if none match, the entry is skipped with a warning). * 5. Fallback: company website URL (the ATS may be on a custom subdomain). * * @param {SeedCompany} company * @returns {SeedPortalEntry} */ export function toPortalEntry(company) { let careers_url = ''; // Explicit ATS hint from the YC dataset. const atsId = company.ats_id && SLUG_RE.test(company.ats_id) ? company.ats_id : null; if (atsId) { if (company.ats === 'greenhouse') { careers_url = `https://job-boards.greenhouse.io/${atsId}`; } else if (company.ats === 'lever') { careers_url = `https://jobs.lever.co/${atsId}`; } else if (company.ats === 'ashby') { careers_url = `https://jobs.ashbyhq.com/${atsId}`; } } // No explicit ATS: try Greenhouse by slug (most common for YC companies), then // Lever, then Ashby — provider.detect() will confirm or skip at scan time. if (!careers_url && company.slug && SLUG_RE.test(company.slug)) { // Use a format that greenhouse.mjs detect() can match. careers_url = `https://job-boards.greenhouse.io/${company.slug}`; } // Last resort: company website (ATS may auto-detect from the domain). if (!careers_url) { careers_url = company.url || ''; } return { name: company.name, careers_url, source: company.source, }; } // ── Network fetchers ───────────────────────────────────────────────── /** * Fetch the Y Combinator public company list and return parsed SeedCompany entries. * * Uses the public YC API (no auth, no API key). The response is a JSON object * with a `companies` array. We fetch page 1 with a large per_page to get the * most recent batch; subsequent pages can be fetched if needed (most users want * the latest batch anyway). * * @param {{ timeoutMs?: number, maxPages?: number }} [opts] * @returns {Promise} */ export async function fetchYCCompanies({ timeoutMs = DEFAULT_TIMEOUT_MS, maxPages = 3 } = {}) { /** @type {SeedCompany[]} */ const all = []; const seen = new Set(); for (let page = 1; page <= maxPages; page++) { const url = `https://api.ycombinator.com/v0.1/companies?page=${page}&per_page=1000`; let payload; try { const res = await fetchWithTimeout(url, { timeoutMs }); payload = await res.json(); } catch (err) { if (page === 1) throw new Error(`vc-portfolios: YC API fetch failed — ${err.message}`); break; // Partial data is fine after page 1. } const entries = parseYCPayload(payload); if (entries.length === 0) break; // No more pages. for (const e of entries) { if (!seen.has(e.slug)) { seen.add(e.slug); all.push(e); } } // The YC API pagination: stop when we receive fewer than 1000 companies. const raw = /** @type {any} */ (payload); const batchSize = Array.isArray(raw?.companies) ? raw.companies.length : 0; if (batchSize < 1000) break; } return all; } /** * Fetch the a16z public portfolio page and return parsed SeedCompany entries. * * a16z does not expose a public JSON API, so we fetch the HTML portfolio page * and parse it with `parseA16zPayload()`. * * @param {{ timeoutMs?: number }} [opts] * @returns {Promise} */ export async function fetchA16zCompanies({ timeoutMs = DEFAULT_TIMEOUT_MS } = {}) { let html; try { const res = await fetchWithTimeout(A16Z_PORTFOLIO_URL, { timeoutMs }); html = await res.text(); } catch (err) { throw new Error(`vc-portfolios: a16z portfolio fetch failed — ${err.message}`); } return parseA16zPayload(html); } // ── SEED_SOURCES registry ──────────────────────────────────────────── /** * Registry mapping seed source names to their fetch functions. * Consumed by scan-ats-full.mjs --seeds flag and CLI tooling. * * To add a new VC portfolio: * 1. Add a fetchXyzCompanies() function above. * 2. Add an entry here: { fetch: fetchXyzCompanies, label: 'XYZ Portfolio' } * * @type {Record Promise, label: string }>} */ export const SEED_SOURCES = { yc: { fetch: fetchYCCompanies, label: 'Y Combinator Portfolio', }, a16z: { fetch: fetchA16zCompanies, label: 'Andreessen Horowitz (a16z) Portfolio', }, };