基于Next.js与Supabase构建AI职位聚合平台:架构设计与工程实践
2026/5/26 22:57:54 网站建设 项目流程

1. 项目概述:一个自动更新的AI职位聚合平台

如果你在2026年寻找一份AI工程师的工作,你的日常大概率是这样的:打开十几个浏览器标签页,在Anthropic的Greenhouse页面、OpenAI的招聘网站、DeepMind的职位公告板、Cohere的Lever页面之间来回切换。像LinkedIn、Indeed这样的综合招聘平台确实有AI相关的职位,但它们的筛选功能简直是一场灾难。搜索“AI工程师”,你可能会得到“AI驱动的客服专员”或者“一家业务与AI毫不相干的AI初创公司的工程师”这类结果。这种信息过载与精准度缺失的双重困境,正是我决心构建LLMHire的起点。我的核心目标很简单:打造一个单一页面,自动聚合所有主流AI/ML/LLM公司的每一个相关职位,并且保持实时更新。

这个项目不仅仅是一个工具,它是我对当前AI招聘市场信息碎片化问题的一次技术性回应。通过自动化爬取和智能分类,我希望为求职者提供一个纯净、高效的信息入口。整个系统基于现代Web技术栈构建:Next.js 14(使用App Router)作为前端框架,Supabase(底层是PostgreSQL)处理数据存储,Vercel则承担了部署和定时任务(Cron Jobs)的重任。下面,我将详细拆解从架构设计、核心实现到数据洞察的完整过程。

2. 核心架构与设计思路拆解

2.1 技术栈选型背后的逻辑

选择Next.js 14的App Router,并非盲目追新。对于这样一个内容驱动、需要优秀SEO(方便求职者通过搜索引擎发现)和极快初始加载速度的项目,Next.js的服务端渲染(SSR)和静态生成(SSG)能力是决定性因素。App Router提供的基于文件系统的路由、服务端组件以及流式渲染,使得构建复杂的、数据预取的应用变得异常清晰。例如,职位列表页可以大部分静态生成,而搜索和过滤功能则利用客户端组件实现交互,这种混合模式在性能和体验上取得了很好的平衡。

数据层选择Supabase,看中的是其开箱即用的PostgreSQL数据库、实时订阅功能和简单的REST/GraphQL API。职位数据具有明显的结构化特征(公司、职位、地点、描述等),关系型数据库是最自然的选择。Supabase的pg_cron扩展也能方便地执行数据库内的定时清理任务(如标记过期职位),虽然本项目主要使用Vercel Cron,但这种灵活性为未来扩展留下了空间。

部署在Vercel上几乎是顺理成章的。它与Next.js的无缝集成、全球CDN、以及最重要的——内置的Cron Jobs功能,使得设置定时抓取任务变得轻而易举,无需额外维护一个服务器或复杂的Worker脚本。Vercel的Cron可以可靠地每隔一段时间触发我们的数据更新管道。

2.2 核心挑战:异构ATS系统的统一

项目的技术核心在于对接不同的申请人跟踪系统(ATS)。经过调研,绝大多数科技公司使用三大平台:Greenhouse、Ashby和Lever。它们虽然都提供公开的职位API,但接口规范、认证方式和数据格式迥然不同。我的设计思路是采用“适配器模式”(Adapter Pattern),为每个ATS系统编写一个专用的数据获取器(Fetcher)。

为什么是适配器模式?这保证了系统核心逻辑的稳定性。无论底层对接的是Greenhouse还是Ashby,我的数据处理流水线(去重、分类、存储)都只需要与一个统一的、规范化的数据格式打交道。当未来需要支持新的ATS(如Workable)时,我只需新增一个适配器模块,而不必修改核心业务代码。这种解耦设计极大地提升了系统的可维护性和可扩展性。

3. 核心细节解析与实操要点

3.1 数据获取器的具体实现

每个ATS的Fetcher模块都需要处理网络请求、错误重试和数据清洗。下面以Greenhouse为例,详细说明实现细节:

// adapters/greenhouseFetcher.js import axios from 'axios'; /** * Greenhouse ATS 职位获取器 * @param {string} companySlug - 公司在Greenhouse上的唯一标识符 * @returns {Promise<Array>} - 规范化后的职位列表 */ async function fetchGreenhouseJobs(companySlug) { const url = `https://boards-api.greenhouse.io/v1/boards/${companySlug}/jobs`; try { const response = await axios.get(url, { timeout: 10000, // 10秒超时 headers: { 'User-Agent': 'LLMHireBot/1.0 (compatible; +https://llmhire.com)' } // 友好的User-Agent }); if (response.status !== 200) { throw new Error(`Greenhouse API返回错误状态码: ${response.status}`); } const jobs = response.data.jobs || []; // 核心:数据规范化 return jobs.map(rawJob => ({ // 生成唯一ID,用于后续去重 externalId: `greenhouse_${companySlug}_${rawJob.id}`, title: rawJob.title?.trim() || '', company: rawJob.company?.name || companySlug, location: parseLocation(rawJob.location?.name), // 需要解析地点字符串 department: rawJob.departments?.[0]?.name || 'Uncategorized', // Greenhouse API不直接提供远程信息,需从标题或地点推断 workMode: inferWorkMode(rawJob.title, rawJob.location?.name), description: rawJob.content || '', applyUrl: rawJob.absolute_url, publishedDate: new Date(rawJob.updated_at || rawJob.created_at), // 原始数据保留,以备后续分析或调试 rawData: rawJob, source: 'greenhouse' })); } catch (error) { console.error(`获取Greenhouse公司 ${companySlug} 职位失败:`, error.message); // 实现简单的指数退避重试逻辑 return []; // 本次返回空数组,由上游调度器决定是否重试 } } // 辅助函数:解析复杂的地点字符串,如“Remote - US”或“San Francisco, CA | Hybrid” function parseLocation(locationStr) { if (!locationStr) return 'Not Specified'; // 移除“| Hybrid”等后缀,提取主要地点 return locationStr.split('|')[0].trim(); } // 辅助函数:从职位标题和地点推断工作模式 function inferWorkMode(title, location) { const titleLower = title.toLowerCase(); const locationLower = location?.toLowerCase() || ''; if (locationLower.includes('remote') || titleLower.includes('remote')) { return 'remote'; } else if (locationLower.includes('hybrid') || titleLower.includes('hybrid')) { return 'hybrid'; } else { return 'onsite'; } }

注意:在实际请求ATS的公开API时,务必设置合理的超时(如10秒)和请求间隔,避免对对方服务器造成压力。同时,使用一个清晰的User-Agent头(如包含你的项目名称和网站)是一种良好的网络公民行为,方便对方识别你的爬虫。

Ashby和Lever的适配器结构类似,但需注意:Ashby通常使用POST请求,其请求体和响应格式是自定义的;Lever的API路径和分页方式也略有不同。关键在于为每个平台编写正确的HTTP请求逻辑和对应的数据映射函数。

3.2 自动分类系统的构建与优化

将杂乱的职位标题自动归类为“LLM工程师”、“ML工程师”等,是提升产品可用性的关键。我采用了一个基于规则的模式匹配系统,其核心是一个优先级匹配列表:

// classifiers/roleClassifier.js const ROLE_PATTERNS = [ { category: 'LLM Engineer', keywords: ['llm', 'large language model', 'language model', 'nlp', 'natural language processing', 'generative ai', 'gpt', 'transformer'], priority: 10 // 优先级最高,因为更具体 }, { category: 'AI Research Scientist', keywords: ['research scientist', 'ai research', 'applied scientist'], mustAlsoContain: ['ai', 'machine learning', 'llm'], // 必须同时包含这些词,避免误判 priority: 9 }, { category: 'ML Engineer', keywords: ['machine learning engineer', 'ml engineer', 'ml/ai engineer', 'ai/ml engineer'], priority: 8 }, { category: 'AI Infrastructure Engineer', keywords: ['ml infrastructure', 'ai platform', 'ai infrastructure', 'ml systems', 'model deployment'], priority: 7 }, { category: 'Prompt Engineer', keywords: ['prompt engineer', 'prompting'], priority: 6 // 优先级较低,因为可能被其他更宽泛的类别覆盖 }, // ... 其他类别如 Data Scientist, AI Product Manager 等 ]; function classifyRole(jobTitle, jobDescription = '') { const textToSearch = (jobTitle + ' ' + jobDescription).toLowerCase(); let matchedRole = 'Other AI Role'; // 默认类别 let highestPriority = -1; for (const pattern of ROLE_PATTERNS) { // 检查关键词 const hasKeywords = pattern.keywords.some(keyword => textToSearch.includes(keyword)); // 检查“必须包含”条件(如果存在) const meetsMustContain = !pattern.mustAlsoContain || pattern.mustAlsoContain.some(word => textToSearch.includes(word)); if (hasKeywords && meetsMustContain && pattern.priority > highestPriority) { highestPriority = pattern.priority; matchedRole = pattern.category; } } return matchedRole; }

实操心得:纯关键词匹配的准确率最初只有70%左右。例如,“Software Engineer, ML Platform”可能被错误地归类为“ML Engineer”,而它更可能是“AI Infrastructure Engineer”。我通过以下策略将准确率提升至90%以上:

  1. 引入优先级系统:更具体的角色(如“LLM Engineer”)优先级高于更宽泛的角色(如“ML Engineer”)。
  2. 添加“必须同时包含”规则:对于“Research Scientist”,要求标题或描述中必须同时出现“AI”或“Machine Learning”,以避免将生物、化学领域的研究科学家误判进来。
  3. 结合部门信息:如果ATS提供了部门信息(如“Machine Learning Department”),可以将其作为强有力的分类依据。
  4. 建立人工审核队列:对于置信度低的匹配(如匹配到的关键词非常少),系统会将其标记,放入一个管理后台,供我每周花少量时间集中审核和修正。这些修正后的数据又可以反过来作为训练数据,未来用于优化一个简单的机器学习分类器。

4. 实操过程与核心环节实现

4.1 数据流水线与定时任务

整个系统的数据流由Vercel Cron Job驱动,每4小时执行一次。我将其设计为一个无状态的、幂等的流水线,确保即使某次执行失败,也不会导致数据混乱。

// app/api/cron/update-jobs/route.js (Next.js App Router API Route) import { fetchAllJobs } from '@/lib/fetchers'; import { deduplicateAndStore } from '@/lib/database'; import { updateSearchIndex } from '@/lib/search'; export const maxDuration = 60; // Vercel函数最大执行时间(秒) export const dynamic = 'force-dynamic'; export async function GET(request) { // 简单的Cron验证(可选,增加安全性) const authHeader = request.headers.get('authorization'); if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { return new Response('Unauthorized', { status: 401 }); } console.log('开始执行定时职位更新任务...'); const startTime = Date.now(); try { // 阶段一:获取数据 const allRawJobs = await fetchAllJobs(); // 并行调用所有公司的Fetcher console.log(`从所有公司获取到 ${allRawJobs.length} 个原始职位`); // 阶段二:去重与存储 const { newJobsCount, updatedJobsCount } = await deduplicateAndStore(allRawJobs); console.log(`新增职位: ${newJobsCount}, 更新职位: ${updatedJobsCount}`); // 阶段三:更新搜索索引(例如,使用Algolia或Meilisearch) await updateSearchIndex(); console.log('搜索索引已更新'); // 阶段四:标记过期职位(例如,超过30天未更新的) await markStaleListings(); const duration = ((Date.now() - startTime) / 1000).toFixed(2); return Response.json({ success: true, message: `任务完成,耗时 ${duration} 秒。新增 ${newJobsCount} 个职位。` }); } catch (error) { console.error('定时任务执行失败:', error); // 这里可以集成错误通知,如发送邮件或Slack消息 return Response.json({ success: false, error: error.message }, { status: 500 }); } }

去重逻辑详解:这是保证数据清洁度的关键。我并非简单地根据职位标题去重,因为同一公司可能在不同地点招聘相同标题的职位。我采用的去重键是一个由公司ID规范化后的职位标题规范化后的地点三者生成的哈希值。只有这个哈希值完全相同的职位,才会被视为重复。对于已存在的职位,我会比较publishedDate,如果新抓取的数据更新,则覆盖旧记录。

4.2 前端展示与用户体验优化

前端使用Next.js 14的App Router和服务器组件构建。职位列表页(/jobs)在构建时(或定时重验证时)会直接从Supabase获取数据并静态生成,保证了极快的加载速度。

// app/jobs/page.js import { createClient } from '@/lib/supabase-server'; import JobList from '@/components/JobList'; import SearchAndFilters from '@/components/SearchAndFilters'; export const revalidate = 3600; // 每1小时重新验证并可能生成新页面 export default async function JobsPage({ searchParams }) { const supabase = createClient(); // 初始加载:获取所有活跃职位,按发布日期排序 let query = supabase .from('jobs') .select('*') .eq('is_active', true) .order('published_date', { ascending: false }); // 根据URL查询参数应用过滤(客户端交互后) const { role, location, mode } = searchParams; if (role) query = query.ilike('role_category', `%${role}%`); if (location) query = query.ilike('location', `%${location}%`); if (mode) query = query.eq('work_mode', mode); const { data: initialJobs, error } = await query; if (error) { console.error('Error fetching jobs:', error); } return ( <div className="container mx-auto px-4 py-8"> <h1 className="text-3xl font-bold mb-2">AI & ML 职位聚合</h1> <p className="text-gray-600 mb-8">实时追踪来自 160+ 家顶尖AI公司的职位,自动更新。</p> <SearchAndFilters initialFilters={searchParams} /> <JobList initialJobs={initialJobs || []} /> </div> ); }

搜索与过滤的实现:为了在静态页面上实现动态过滤,我采用了Next.js的useSearchParamsuseRouter钩子。当用户选择过滤器时,前端会更新URL的查询参数,触发页面的软导航(router.push),服务器组件会根据新的searchParams重新获取数据并渲染。这种方式既保持了服务器端渲染的优势,又提供了流畅的客户端交互体验。对于更复杂的全文搜索,我集成了Meilisearch,它提供了即时的、模糊的搜索能力,远超PostgreSQL的ilike查询。

5. 数据洞察与市场分析

运行数周后,系统积累了超过670个活跃职位,每日更新50-100个。这些数据揭示了AI人才市场的一些有趣趋势:

1. “LLM工程师”已成为一个明确的独立角色。与传统的“机器学习工程师”相比,LLM工程师的职位描述更强调对Transformer架构、提示工程、大模型微调(Fine-tuning)、检索增强生成(RAG)以及相关工具链(如LangChain, LlamaIndex)的深入理解。而ML工程师的职位则更侧重于传统的模型开发、特征工程和MLOps。

2. 远程工作机会在减少。尽管早期许多AI公司提供远程岗位,但数据显示,像OpenAI、Anthropic、DeepMind这样的头部实验室,越来越倾向于混合或现场办公模式。这可能与涉及敏感研究、需要高强度协作或使用受限的计算基础设施有关。

3. AI基础设施领域正在蓬勃发展。“AI基础设施工程师”或“ML系统工程师”是增长最快的子类别之一。这反映了行业重点正从纯粹的研究和模型开发,转向如何高效、可靠、规模化地部署和运行这些大模型。相关技能包括分布式系统、GPU集群管理、模型服务化(如使用Triton Inference Server)和成本优化。

4. 提示工程师的热度在变化。纯粹的“提示工程师”职位数量似乎达到了一个峰值,并开始趋于平稳或略有下降。相反,这项技能正被吸收到更广泛的角色中,如“LLM工程师”、“AI产品经理”甚至“解决方案架构师”。这表明提示工程正在从一门独立的“手艺”,转变为AI从业者工具箱中的一项基础技能。

为了更直观地展示这些洞察,我构建了一个简单的内部仪表盘,并考虑将这些分析通过博客分享出去。

角色类别占比 (约)趋势关键技能关键词
LLM工程师25%📈 快速增长Transformer, Fine-tuning, RAG, LangChain, Prompting
机器学习工程师35%→ 保持稳定Python, TensorFlow/PyTorch, MLOps, Feature Engineering
AI研究科学家15%→ 稳定Publications (NeurIPS, ICML), Novel Research, PhD
AI基础设施工程师18%📈 快速增长Distributed Systems, Kubernetes, GPU, Cloud (AWS/GCP/Azure)
提示工程师5%📉 略有下降Prompt Design, LLM Evaluation, Few-shot Learning
其他2%AI Product, Data, Ethics

6. 常见问题与排查技巧实录

在开发和维护LLMHire的过程中,我遇到了不少典型问题,以下是其中一些及其解决方案:

问题一:ATS API限制或封禁。

  • 现象:定时任务突然开始大量失败,返回403或429状态码。
  • 排查:首先检查请求头,确保User-Agent设置得当。查看日志,确认是否因请求频率过高导致。
  • 解决
    1. 增加延迟:在每个公司API请求之间添加随机延迟(如1-3秒),模拟人类浏览行为。
    2. 实现指数退避重试:对于临时性错误(如网络波动、服务器5xx错误),实现重试机制,每次重试前等待时间指数级增加。
    3. 使用IP轮换(如果规模扩大):考虑使用可靠的代理服务池,避免单个IP被限制。
    4. 尊重robots.txt:定期检查目标网站的robots.txt文件,确保你的爬取行为是被允许的。

问题二:职位分类错误率高。

  • 现象:用户反馈某些职位被分错了类别。
  • 排查:在管理后台查看低置信度匹配的队列,分析误判案例的共同模式。
  • 解决
    1. 丰富规则库:针对新的误判模式,添加或调整关键词和规则。例如,发现“AI Safety Researcher”被分到“Other”,就为“AI Safety”创建新规则。
    2. 引入描述文本分析:最初只分析标题,后来加入职位描述的前200个字符进行分析,准确率显著提升。
    3. 建立反馈循环:在网站上添加一个简单的“分类有误?”按钮,收集用户反馈,用于持续优化分类器。

问题三:数据库性能随着数据量增长而下降。

  • 现象:职位数量超过1万条后,列表页加载和复杂过滤查询变慢。
  • 排查:使用Supabase的仪表板或EXPLAIN ANALYZE命令分析慢查询。
  • 解决
    1. 添加索引:为最常用的过滤字段(如role_category,work_mode,company,is_active)和排序字段(published_date)创建数据库索引。
    2. 数据归档:将标记为is_active = false且超过60天的职位移动到历史表,保持主表精简。
    3. 引入专用搜索服务:将全文搜索功能从PostgreSQL的ilike迁移到Meilisearch或Typesense,后者为搜索场景做了极致优化。

问题四:Vercel Cron Job执行时间超过限制。

  • 现象:数据抓取公司数量增加到160+后,单个Cron Job执行时间超过Vercel免费计划的10秒限制(或Pro计划的60秒)。
  • 解决
    1. 并行化抓取:使用Promise.all()Promise.allSettled()并发执行多个公司的数据抓取任务,大幅减少总耗时。
    2. 任务分片:如果并行后仍超时,可以将公司列表分成多个批次,设置多个Cron Job(例如/api/cron/update-group1,/api/cron/update-group2),错峰执行。
    3. 考虑边缘函数或队列:对于超大规模抓取,可以将抓取任务拆解,通过消息队列(如Redis)分发,由多个边缘函数并发处理。

7. 项目演进与未来规划

LLMHire目前已经稳定运行,但仍有不少可以改进和扩展的方向。

短期规划:

  1. 集成更多ATS:正如原文提到的,Workable是下一个目标。许多中型AI初创公司使用Workable。其API的集成方式将与现有模式类似,但需要仔细研究其认证(通常需要API Key)和分页机制。
  2. 薪资数据聚合:这是用户需求最强烈的功能之一。计划通过多种方式获取:a) 从职位描述中提取(如果明确列出);b) 集成第三方薪资数据API(如Levels.fyi的API,如果可用);c) 鼓励用户匿名提交offer数据。展示薪资范围时,会明确标注数据来源和样本量。
  3. 增强搜索与提醒:实现更智能的语义搜索(“找一份需要PyTorch经验的远程LLM工作”),并允许用户创建保存的搜索,当有新职位匹配时通过邮件或Telegram/Bot发送通知。

中期构想:

  1. 技能标签提取:使用NLP技术(如简单的关键词提取或微调一个小模型)从职位描述中自动提取技术栈(如Python, PyTorch, AWS, Kubernetes),让求职者能按技能过滤。
  2. 公司信息聚合:除了职位,聚合公司的基本信息、技术博客、融资情况等,帮助求职者更全面地了解目标公司。
  3. 社区与内容:启动每周AI招聘市场分析简报(Newsletter),分享趋势数据、热门技能和招聘解读。建立一个博客,深入探讨如何准备AI面试、如何构建AI作品集等话题。

长期愿景:将平台从一个单向的信息聚合器,转变为一个双向的AI人才生态连接器。可能的形态包括:基于技能的个性化职位推荐、匿名的候选人-职位匹配度分析、或是与AI教育平台合作,为学习者指明技能提升路径以匹配市场需求。

构建LLMHire的过程,是一个典型的“用技术解决自身痛点”的案例。它始于一个简单的需求,通过合理的架构设计、持续的数据处理和对细节的打磨,最终成为一个有价值的工具。最让我有成就感的,不是技术本身,而是看到它真正帮助到那些在AI浪潮中寻找方向的开发者。如果你也在构建类似的数据驱动型产品,我的核心建议是:从最小可行产品(MVP)开始,尽快让数据流动起来,然后在真实用户的反馈和数据的指引下持续迭代。数据管道中每一个环节的稳定性和可观测性,远比追求功能的复杂更重要。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询