项目文件夹

文件
wehub-resource-sync d083df1fdb
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 2s
Web CI / web typecheck + build (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
CodeQL Analysis / Analyze (go) (push) Failing after 16s
chore: import upstream snapshot with attribution
2026-07-13 12:02:43 +08:00

394 行
15 KiB
JavaScript

// @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<Response>}
*/
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<string, SeedCompany>} */
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:
* <a ... href="https://company.com" ... data-company-name="Stripe" ...>
* or <h3 class="...">Stripe</h3> 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<string, SeedCompany>} */
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(/<script[^>]+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: <a href="https://stripe.com" class="...portfolio...">Stripe</a>
// or company names in h3/h4 elements within portfolio sections.
const portfolioAnchorRe = /<a\s+[^>]*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<SeedCompany[]>}
*/
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<SeedCompany[]>}
*/
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<string, { fetch: (opts?: object) => Promise<SeedCompany[]>, label: string }>}
*/
export const SEED_SOURCES = {
yc: {
fetch: fetchYCCompanies,
label: 'Y Combinator Portfolio',
},
a16z: {
fetch: fetchA16zCompanies,
label: 'Andreessen Horowitz (a16z) Portfolio',
},
};