career-ops VC 组合种子扫描实战:用--seeds把 YC/a16z 投资组合变成招聘职位发现源
【免费下载链接】career-opsOpen-source AI job search: scan job portals, evaluate listings into a structured A-H report with a global 1-5 score, tailor your CV, track applications — runs locally in your AI coding CLI (Claude Code, Codex, OpenCode, Antigravity…)项目地址: https://gitcode.com/GitHub_Trending/ca/career-ops
导读
本文讲解 career-ops 中seeds/模块的设计与使用:它绕过「等公司在 ATS 目录里出现」的被动模式,直接从 Y Combinator、a16z 等顶级 VC 的公开投资组合页拉取公司名单,逐家探测其 Greenhouse / Lever / Ashby 招聘看板,把结果汇入与portals.yml追踪公司完全相同的扫描管线。读完本文,你将掌握--seeds命令行用法、程序化调用 API、数据转换与安全边界,以及如何扩展新的 VC 组合数据源。
为什么需要一套"组合种子"发现路径
scan-ats-full.mjs的常规工作方式是反向扫描公开 ATS 目录:遍历 Greenhouse、Lever、Ashby、Workday 等招聘板聚合目录,从中发现正在招人的公司。这种方式的局限在于——一家公司只有在被目录收录之后才会进入视野。
而seeds/层提供了面向初创公司求职者的高信号起点:不等待公司出现在 ATS 目录里,而是先从知名 VC 的公开投资组合划定候选宇宙。种子公司名单拉取后,会被转换成与portals.yml追踪条目一致的PortalEntry,再走与scan.mjs中追踪公司完全相同的探测流程,因此一次扫描即可覆盖数百家 YC / a16z 被投公司的实时在招岗位。核心文档见 seeds/README.md,实现位于 seeds/vc-portfolios.mjs。
整体数据流如下:
VC portfolio API/page ↓ seeds/vc-portfolios.mjs SeedCompany[] ↓ toPortalEntry() PortalEntry (careers_url set to best-guess ATS URL) ↓ provider.detect() (same as portals.yml companies) ATS provider fetches jobs ↓ title_filter / location_filter / dedup data/pipeline.md核心实现拆解:种子获取器 vc-portfolios.mjs
实现文件顶部注释明确了三条硬性设计约束,是理解整个模块的钥匙(见 seeds/vc-portfolios.mjs):
- 零鉴权:只用公开数据源,不登录、不要 API key;
- 零 LLM 消耗:纯 HTTP + JSON / HTML 解析,不消耗任何模型 token;
- 同一套 slug 守卫:凡是会拼进 URL 的 slug 都经过
SLUG_RE校验,伪造或畸形的 payload 永远无法向 URL 注入意外字符。
数据类型:SeedCompany 与 SeedPortalEntry
两个用 JSDoc 声明的核心类型(seeds/vc-portfolios.mjs):
SeedCompany:种子输出的统一单位,字段包括name(展示名,如 Stripe)、slug(必须通过SLUG_RE的 URL 安全段)、url(官网)、可选的ats('greenhouse' | 'lever' | 'ashby')、ats_id(用于构造招聘板 URL 的组织段)、source(来自哪个 VC 名单)、batch(仅 YC,如W21);SeedPortalEntry:与providers/_types.js中PortalEntrytypedef 形状一致、可直接交给 ATSprovider.detect()消费的对象,只含name、careers_url(尽力而为的 ATS 或官网 URL)和可选的source。
纯解析函数:可测试性的根基
解析逻辑被刻意设计为纯函数(无网络、无副作用),这是整个模块能被 CI 用内联 fixture 无 mock 测试的关键:
parseYCPayload(payload)(seeds/vc-portfolios.mjs):解析 YC API 的分页 JSON,优先取显式slug,缺省时由name小写化派生(name.toLowerCase().replace(/[^a-z0-9]+/g, '-')),并剔除两端连字符;无效 slug、重复 slug 都会被过滤。url只接受http(s)开头的值;parseA16zPayload(html)(seeds/vc-portfolios.mjs):无 DOM 解析器的三层降级策略——① 优先找页内application/ld+json的Organization/Corporation结构化数据块;② 其次匹配 React 渲染的data-company-name/data-company-url属性;③ 最后退回到含portfolio|company|cardclass 的<a>锚文本,并对read more、visit、press等导航性文字做过滤。页面结构变化时逐层优雅降级;parseSeedEntries(payload, source)(seeds/vc-portfolios.mjs):统一入口,按source分发到上述两个解析器,是 issue 验收标准与 test-all.mjs 直接引用的可测试单元。
网络抓取:超时 + 分页走查
fetchWithTimeout()是本地最小实现(刻意不引providers/_http.mjs,保证seeds/自包含):用AbortController实现默认 20 秒超时,并携带 user-agent.mjs 的默认 UA 头;非 2xx 响应会抛出带状态码与前 200 字符摘要的错误(seeds/vc-portfolios.mjs)。
两个抓取函数:
fetchYCCompanies({ timeoutMs, maxPages }):逐页走查 YC API。注意实现细节:请求里带着per_page=1000,但服务端会自行限页,因此走查终点不是自己猜,而是跟随 API 返回的分页信号——若给出整数totalPages则照其停止;若缺失则用parseYCNextPage()解析nextPage字段(可能是一个裸数字、page=N片段或携带page=N的完整 URL),并要求必须前进,否则中断(seeds/vc-portfolios.mjs)。真正的防失控硬顶是导出的YC_MAX_PAGES = 500,任何显式maxPages(哪怕是Infinity)都会被钳制在该上限内,防止某天 API 不再上报分页元数据时无限空转(seeds/vc-portfolios.mjs)。第 1 页抓取失败会直接抛错,第 2 页起失败则容忍部分数据后跳出;fetchA16zCompanies():a16z 无公开 JSON API,直接抓取公开组合页 HTML 交给parseA16zPayload()(seeds/vc-portfolios.mjs)。
SEED_SOURCES 注册表
导出对象把种子源 ID 映射到抓取函数与可读标签(seeds/vc-portfolios.mjs),它是scan-ats-full.mjs --seeds与 CLI 工具消费的唯一入口:
export const SEED_SOURCES = { yc: { fetch: fetchYCCompanies, label: 'Y Combinator Portfolio', }, a16z: { fetch: fetchA16zCompanies, label: 'Andreessen Horowitz (a16z) Portfolio', }, };命令行使用法:通过 scan-ats-full.mjs(推荐)
scan-ats-full.mjs新增了--seeds标志,取值是逗号分隔的注册表键(见 scan-ats-full.mjs):
# 从 Y Combinator 投资组合播种,只看最近 7 天 node scan-ats-full.mjs --seeds yc --since 7 # 同时播种 YC 和 a16z,仅预览(dry-run 不出写结果) node scan-ats-full.mjs --seeds yc,a16z --dry-run # 种子 + 常规 ATS 源混扫 node scan-ats-full.mjs --seeds yc --ats greenhouse,lever --since 5 # npm 快捷命令(定义见 package.json) npm run scan:seeds # yc + a16z npm run scan:yc # 仅 YC几个值得注意的 CLI 语义(均可在 scan-ats-full.mjs 源码中确认):
- 未知种子源会直接报错退出:
Error: unknown seed source(s): xxx. Valid: yc, a16z; --seeds与--ats的默认值联动:当--seeds是唯一的发现标志(未给--ats)时,--ats自动默认为空列表,避免在种子扫描之外还无意间全量走一遍常规 ATS 目录;反过来,不传--seeds时行为不变,默认遍历全部 ATS 源(Object.keys(SOURCES));--limit对种子同样生效:按 slug 截断公司数量,配合--shuffle时会先洗牌再取前 N 家;--since决定职位新鲜度:runSeedScan内部以Date.now() - sinceDays * 86_400_000为截止线,早于它的dated职位被标记stale丢弃;无日期职位默认丢弃,需--include-undated才保留(scan-ats-full.mjs)。
程序化 API
不经过 CLI、在自有脚本里直接集成也可以:
import { fetchYCCompanies, fetchA16zCompanies, toPortalEntry, SEED_SOURCES } from './seeds/vc-portfolios.mjs'; // 抓取 YC 公司 const companies = await fetchYCCompanies(); console.log(companies[0]); // → { name: 'Stripe', slug: 'stripe', url: 'https://stripe.com', source: 'yc', batch: 'W11' } // 转成可交给 ATS provider.detect() 的 PortalEntry const entry = toPortalEntry(companies[0]); // → { name: 'Stripe', careers_url: 'https://job-boards.greenhouse.io/stripe', source: 'yc' } // 遍历注册表 for (const [id, source] of Object.entries(SEED_SOURCES)) { const companies = await source.fetch(); console.log(`${source.label}: ${companies.length} companies`); }toPortalEntry:ATS URL 的猜测优先级
toPortalEntry()(seeds/vc-portfolios.mjs)把SeedCompany转成PortalEntry,careers_url的解析顺序有严格的分层,理解它有助于排查"为什么某公司没被探测到":
- 显式 ATS 提示:当
company.ats与ats_id同时存在且ats_id通过SLUG_RE时,按平台拼出标准看板 URL——Greenhouse 为https://job-boards.greenhouse.io/${atsId}、Lever 为https://jobs.lever.co/${atsId}、Ashby 为https://jobs.ashbyhq.com/${atsId}; - 无提示则按 slug 猜 Greenhouse:
https://job-boards.greenhouse.io/${company.slug}(Greenhouse 是 YC 公司最常用的 ATS,先拿它试); - 兜底回退到公司官网:适用于 ATS 在自定义子域的情况,交由
provider.detect()在扫描时从域名自动识别。
最终careers_url是否有效,要等扫描期 ATS provider 的detect()确认;对不上就跳过并给出告警。对应断言可见 test-all.mjs。
runSeedScan:种子如何汇入既有扫描管线
scan-ats-full.mjs中的runSeedScan(seedId, opts, ctx, seenUrls, label)(scan-ats-full.mjs)承担了从种子名单到职位 offer 的完整链路,其探测顺序固定为:
// ATS providers that can auto-detect from a careers_url, in probe order. // Workday is excluded: its URL format requires a tenant|instance|site triple // that can't be derived from a portfolio slug alone. const SEED_PROVIDERS = [greenhouse, lever, ashby];(见 scan-ats-full.mjs。)Workday 被排除的原因值得注意:其 URL 需要tenant|instance|site三元组,仅凭组合页的 slug 无法推导,因此种子路径不参与 Workday 探测。
逐家公司处理时:
- 用
parallelEach(capped, CONCURRENCY, ...)以受控并发批量探测,并设置了每家公司 5 分钟的公司级超时(COMPANY_TIMEOUT_MS,见 scan-ats-full.mjs)——防止某家公司的 DNS 或 provider 缺陷拖垮整个 worker 槽位,这对动辄上千家公司的扫场至关重要; - 依次调用
greenhouse → lever → ashby的detect(entry),首个命中者胜出并调用其fetch(entry, ctx),与portals.yml中追踪公司走scan.mjs的路径完全一致; - 返回的每个 offer 打上
source = \${seedId}-seed`(如yc-seed),区分于常规扫场的{sourcesKey}-full`; - 去重沿用与主扫场共享的
seenUrls集合与dedupTokenFor()辅助函数;由于 Greenhouse/Lever/Ashby 目前都未定义dedupKey,种子 offer 实际走normalizeUrlForDedup(url)的纯 URL 去重(scan-ats-full.mjs),并同时接受titleFilter/locationFilter/contentFilter与--limit的约束。
最终新增职位与常规源产出汇入同一data/pipeline.md,后续的标题过滤、位置过滤、去重与投递评估全部复用既有链路,这也是该功能接入成本极低的原因。
数据源一览
| Source | URL | Format | Auth |
|---|---|---|---|
| Y Combinator | https://api.ycombinator.com/v0.1/companies | JSON API | 无 |
| a16z | https://a16z.com/portfolio/ | 公开 HTML 页 | 无 |
- YC:
fetchYCCompanies()逐页走查公开 API,按 slug 全程去重;分页跟随服务端返回的totalPages/nextPage信号推进,且被 500 页的YC_MAX_PAGES硬顶钳制(分页契约有独立测试覆盖,见 tests/vc-portfolios-yc-pagination.test.mjs),覆盖各公开 YC batch; - a16z:抓公开组合页,经 JSON-LD →
data-company-name→ 锚文本的三层降级策略解析,页面结构变化时可优雅回退。
安全设计
该模块面对的是不可信的第三方页面内容,因此防御是显式设计而非事后补丁:
- slug 白名单校验:所有进入 URL 拼接的 slug 先过
SLUG_RE = /^[A-Za-z0-9._-]+$/(导出自 seeds/vc-portfolios.mjs),任何含/、空格、!等危险字符的条目在解析阶段即被丢弃——测试用内联 fixture 证明了good-co、also.good_123通过而bad/slash、bad space、bad!bang被拒(test-all.mjs); - 构造出的 ATS URL 再经过既有的
entryOnHost()SSRF 守卫后才到达任何 provider,从根上阻止向内网/非预期主机发起请求; - 零凭据、零无头浏览器、零 LLM 调用:不引入任何新的攻击面与 token 成本,也无需 API key 配置。
扩展更多 VC 组合源
仓库把"加一个新 VC"做成了四步流水线,其中第 1、4 步的可测性约束是硬性的(对应 seeds/README.md 中的指南与 seeds/vc-portfolios.mjs 的注册表注释):
- 写一个
parseXyzPayload(payload)纯函数(不触网——用内联 fixture 即可测试); - 写一个
fetchXyzCompanies(opts?)异步函数,调用公开端点并返回SeedCompany[]; - 注册进
SEED_SOURCES:
export const SEED_SOURCES = { yc: { fetch: fetchYCCompanies, label: 'Y Combinator Portfolio' }, a16z: { fetch: fetchA16zCompanies, label: 'Andreessen Horowitz (a16z) Portfolio' }, // 新增源: sequoia: { fetch: fetchSequoiaCompanies, label: 'Sequoia Portfolio' }, };- 在
test-all.mjs中为你的parseXyzPayload()补测试用例(现有种子相关断言的完整样例位于 test-all.mjs 的 "9b" 小节,覆盖 YC 解析、a16z HTML 解析、parseSeedEntries分发、SLUG_RE 过滤、toPortalEntry三级 fallback、slug 去重与注册表形态)。
测试与验证
除test-all.mjs"9b" 小节的内联 fixture 测试外,tests/vc-portfolios-yc-pagination.test.mjs 单独锁定了分页走查的契约:fetchYCCompanies必须走完 API 上报的每一页;当totalPages缺失时能跟随nextPage(数字、片段、URL 三种形态由parseYCNextPage统一解析);即便传入maxPages: Infinity也绝不超过 500 页硬顶——该上限值被独立锁定为 500,防止有人悄悄抬高它让测试"静默通过"。
适用前提与注意事项
- 种子扫描依赖对第三方公开接口/页面的实时抓取,YC 与 a16z 任何一方改版都可能导致瞬时空结果或报错(YC 首页失败抛错、翻页中断后容忍部分数据;a16z 走三层降级策略),可通过重试或改日再扫缓解;
- 种子产出的 offer
source是yc-seed/a16z-seed,与常规源的-full后缀可明确区分,方便在去重与统计层面追踪来源; - 该路径不覆盖 Workday 看板(URL 无法由 slug 推导),使用 Workday 的公司需经由常规 ATS 目录发现;
npm run scan:seeds/scan:yc只是 package.json 里对scan-ats-full.mjs --seeds的封装,完整参数列表(--verbose、--json、--dry-run、--liveness等)可在 docs/SCRIPTS.md 中继续查阅。
相关先例与来源
VC 组合播种的灵感源自早期 companion 参考项目adityachaudhary99/job-hunt的02-seeds/fetch_yc.py与fetch_a16z.py(原始 issue #1370 中引用)。career-ops 在此基础上重构为纯解析函数 + 注册表 + 共享去重/过滤管线的形态,既保持了独立可测,又无缝接入了既有扫描体系。
【免费下载链接】career-opsOpen-source AI job search: scan job portals, evaluate listings into a structured A-H report with a global 1-5 score, tailor your CV, track applications — runs locally in your AI coding CLI (Claude Code, Codex, OpenCode, Antigravity…)项目地址: https://gitcode.com/GitHub_Trending/ca/career-ops
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考