ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Seed-2.1-pro-0915与SSE的求职雷达:12分钟生成结构化岗位报告

基于Seed-2.1-pro-0915与SSE的求职雷达:12分钟生成结构化岗位报告

1. 求职信息差这件事,到底卡在哪

找工作最让人抓狂的,不是面试被拒,而是你压根不知道有哪些岗位在招、薪资到底给到多少、哪些信息是真的。我做了几年技术,身边朋友换工作、自己偶尔看机会,发现一个特别普遍的现象:招聘平台刷来刷去就那几家,岗位描述写得云里雾里,薪资范围从“8k到30k”这种离谱区间到“面议”都有,你根本没法判断这个岗位到底值不值得投。

更麻烦的是,很多岗位信息散落在不同渠道,有的是企业官网挂出来的,有的是行业社群转发的,还有的是猎头私下推的。你如果一个个手动去翻、去比对,一天下来眼睛都花了,效率极低。我试过用表格手动整理,结果发现信息更新太快,今天记的明天就过期了,维护成本高得离谱。

所以我就想,能不能做一个“求职雷达”——输入你的目标岗位关键词、城市、经验要求,它自动去抓取、聚合、去重、结构化,最后生成一份可读性强的报告,每条薪资都能点开原始来源验证。这个想法听起来不复杂,但真正落地涉及几个核心问题:数据从哪来、怎么保证实时性、怎么把非结构化文本变成结构化字段、怎么让前端流畅展示流式结果。

我最终选的技术栈是Seed-2.1-pro-0915作为核心推理模型,配合Claude Code做本地开发辅助,用Next.js搭前端,SSE做流式推送,整个链路跑通之后,从输入关键词到出报告,实测12分钟左右。下面我把整个项目的设计思路、核心细节、实操过程和踩过的坑,完整拆一遍。

注意:本文涉及的所有数据抓取和聚合行为,均基于公开可访问的招聘信息页面,遵守目标站点的robots协议,不涉及任何绕过访问限制的操作。实际落地时请根据目标平台的使用条款自行评估合规性。

2. 整体架构设计与技术选型逻辑

2.1 为什么是“雷达”而不是“爬虫”

很多人一听“自动抓岗位”,第一反应就是写个爬虫。但我一开始就否掉了纯爬虫方案,原因有三个。

第一,招聘平台的反爬策略更新极快,你今天写的选择器明天就失效,维护成本会吃掉你所有精力。第二,纯爬虫拿到的是原始HTML,里面夹杂大量广告、推荐位、重复卡片,清洗成本很高。第三,也是最关键的——求职者真正需要的不是“一堆链接”,而是一份经过结构化、去重、按薪资和匹配度排序的报告。

所以我把它定位成“雷达”:不是暴力抓取,而是定向扫描+智能聚合+结构化输出。核心流程是:用户输入目标条件 → 后端调度多个数据源 → 原始数据进入清洗管道 → Seed-2.1-pro-0915 做字段抽取和语义去重 → 结果通过SSE流式推给前端 → 前端逐条渲染成可点击验证的报告。

这个定位决定了技术选型:需要一个推理能力足够强、能处理长文本、能稳定输出结构化JSON的模型,同时前端要能处理流式数据,不能等全部跑完才展示。

2.2 Seed-2.1-pro-0915 在链路里到底干什么

Seed-2.1-pro-0915 在这个项目里承担的是“信息结构化引擎”的角色。原始岗位描述是一段非结构化文本,比如:

“某互联网公司招聘高级后端工程师,负责高并发系统设计,要求5年以上Java经验,熟悉分布式架构,薪资25-40k,工作地点北京海淀。”

这段文本里包含:岗位名称、公司类型、职责关键词、经验要求、技能要求、薪资区间、工作地点。如果我用正则去匹配,规则会写得极其脆弱,稍微换个表述就匹配不到。而 Seed-2.1-pro-0915 的优势在于,它能理解语义,即使表述方式变化,也能稳定抽取出结构化字段。

我实测下来,用 few-shot prompt 的方式,给模型几个输入输出示例,它就能以很高的准确率输出如下JSON:

{ "job_title": "高级后端工程师", "company_type": "互联网", "experience_required": "5年以上", "skills": ["Java", "分布式架构", "高并发"], "salary_min": 25000, "salary_max": 40000, "location": "北京海淀", "source_url": "https://example.com/job/12345" }

这个结构化结果是后续去重、排序、展示的基础。没有这一步,后面全是空谈。

2.3 Next.js + SSE 的前后端分工

前端用 Next.js 是考虑到它既能做SSR提升首屏速度,又能很方便地写API Route做后端代理。SSE(Server-Sent Events)的选择是因为这个场景是单向流式推送——服务端不断把处理好的岗位条目推给前端,前端不需要往回发消息。相比WebSocket,SSE更轻量,浏览器原生支持EventSource,断线重连逻辑也简单。

这里有个细节:SSE 默认是文本流,每条消息以data:开头,以两个换行结尾。我在服务端封装了一个sendEvent函数,把结构化后的岗位对象序列化成JSON字符串推出去,前端收到后直接JSON.parse就能用。

// 服务端 SSE 推送示例 function sendEvent(res, data) { res.write(`data: ${JSON.stringify(data)}\n\n`); }

前端用EventSource监听:

const es = new EventSource('/api/radar/stream?keyword=后端&city=北京'); es.onmessage = (event) => { const job = JSON.parse(event.data); setJobs(prev => [...prev, job]); };

这个链路跑通之后,用户体验就是:输入条件,点开始,报告条目一条一条蹦出来,每条都带薪资和原始链接,点一下就能跳过去验证。

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

3.1 数据源调度与去重策略

数据源我分了三类:公开的招聘信息聚合页、企业官方招聘页、行业社群的公开转发内容。每类数据的抓取频率和解析方式不同。聚合页更新快但重复率高,官方页准确但更新慢,社群内容时效性强但格式混乱。

去重是这里最容易被低估的环节。我一开始用URL去重,发现同一个岗位在不同渠道的URL完全不同,但内容几乎一样。后来改成语义去重:把岗位标题、公司名、薪资区间、地点拼成一个特征串,用 Seed-2.1-pro-0915 生成一个语义指纹,相似度超过阈值的判定为重复,只保留来源最权威的那条。

实操心得:语义去重的阈值不要设太高,我一开始设0.95,结果漏掉了大量“同一岗位不同表述”的情况。后来调到0.85,配合人工抽检,效果比较平衡。

3.2 薪资字段的抽取与归一化

薪资是最容易出问题的字段。原始文本里可能是“25-40k”、“25000-40000”、“25k-40k·13薪”、“面议”、“8k-15k(底薪+绩效)”。我的处理策略是:

  • 能抽取出明确数字区间的,统一归一化为月薪整数范围;
  • 带“·13薪”的,额外记录薪资月数;
  • “面议”的单独标记,不参与薪资排序,但保留展示;
  • 带“底薪+绩效”的,记录备注字段,提醒用户注意构成。

这一步 Seed-2.1-pro-0915 的表现很关键。我试过用纯规则解析,遇到“25k-40k·13薪”这种就歇菜了。模型能理解“·13薪”意味着一年发13个月,从而在归一化时把年薪也算出来。

3.3 SSE 流式接口的封装与异常处理

SSE 看起来简单,但实际写起来有几个坑。第一个坑是空闲超时。如果服务端处理某条数据时间过长,中间没有推送任何消息,连接可能被中间层断开,前端会收到stream disconnected before completion: idle timeout waiting for sse这类错误。我的解决办法是加一个心跳机制,每15秒推送一条注释消息: heartbeat\n\n,保持连接活跃。

第二个坑是错误传播。如果某条数据解析失败,不能让整个流断掉。我的做法是捕获单条异常,推送一条带error字段的消息,前端可以选择跳过或展示提示,主流程继续。

try { const parsed = await extractJobInfo(raw); sendEvent(res, { type: 'job', data: parsed }); } catch (err) { sendEvent(res, { type: 'error', message: '单条解析失败', raw: raw.slice(0, 100) }); }

第三个坑是前端重连后的状态同步。EventSource 默认会自动重连,但重连后之前已经推送的数据不会重发。我的处理是给每条消息带一个递增的seq序号,前端记录已接收的最大序号,重连时通过查询参数带上,服务端从该序号之后继续推。

3.4 Claude Code 在开发过程中的实际用法

Claude Code 我主要用来做两件事:一是快速生成样板代码,比如 Next.js 的 API Route 骨架、SSE 的封装函数;二是排查一些奇怪的运行时错误。它的优势是能直接读项目文件,理解上下文,给出的建议比较贴合实际代码结构。

比如我在写 SSE 心跳逻辑时,一开始不知道res.write在 Next.js 的 Edge Runtime 和 Node Runtime 下行为有差异,Claude Code 直接指出 Edge Runtime 不支持 Node 的res.write,建议我改用ReadableStream。这个提醒省了我至少半天排查时间。

注意:Claude Code 生成的代码不能直接无脑用,尤其是涉及数据抓取和并发控制的部分,一定要自己审一遍。我遇到过它生成的并发请求没有做限流,直接跑会把目标站点打挂的情况。

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

4.1 环境准备与依赖安装

项目基于 Next.js 14 的 App Router 搭建。初始化命令:

npx create-next-app@latest job-radar --typescript --app cd job-radar npm install axios cheerio p-limit

axios用于HTTP请求,cheerio用于服务端解析HTML,p-limit用于控制并发数。Seed-2.1-pro-0915 的调用我封装了一个独立的lib/seed.ts,通过环境变量读取API Key。

// lib/seed.ts const SEED_API = process.env.SEED_API_URL; const SEED_KEY = process.env.SEED_API_KEY; export async function extractJobInfo(rawText: string) { const prompt = `从以下招聘文本中抽取结构化信息,输出JSON:\n${rawText}`; const res = await fetch(SEED_API, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${SEED_KEY}` }, body: JSON.stringify({ model: 'Seed-2.1-pro-0915', messages: [{ role: 'user', content: prompt }], temperature: 0.1 }) }); return res.json(); }

temperature设成0.1是为了让输出更稳定,减少格式漂移。实测温度高了之后,模型偶尔会在JSON外面包一层解释文字,解析起来很麻烦。

4.2 数据抓取与清洗管道

抓取层我用p-limit把并发控制在5以内,避免对目标站点造成压力。每个数据源的抓取函数返回原始文本数组,然后统一进入清洗管道。

清洗管道分三步:

  1. 去广告和推荐位:通过CSS选择器排除带有ad、recommend、hot类名的节点;
  2. 提取正文:优先取article、.job-detail、.job-desc等语义化容器;
  3. 截断超长文本:超过2000字符的文本截断,避免超出模型上下文限制。
import * as cheerio from 'cheerio'; export function cleanHtml(html: string): string { const $ = cheerio.load(html); $('.ad, .recommend, .hot, script, style').remove(); const content = $('article, .job-detail, .job-desc').first().text(); return content.slice(0, 2000).trim(); }

4.3 SSE 接口的完整实现

API Route 放在app/api/radar/stream/route.ts。核心逻辑是:接收查询参数 → 调度抓取 → 逐条清洗 → 调用模型抽取 → 推送结果。

export async function GET(req: Request) { const { searchParams } = new URL(req.url); const keyword = searchParams.get('keyword') || ''; const city = searchParams.get('city') || ''; const lastSeq = parseInt(searchParams.get('lastSeq') || '0'); const stream = new ReadableStream({ async start(controller) { const encoder = new TextEncoder(); let seq = lastSeq; const heartbeat = setInterval(() => { controller.enqueue(encoder.encode(': heartbeat\n\n')); }, 15000); try { const rawList = await fetchAllSources(keyword, city); for (const raw of rawList) { seq++; if (seq <= lastSeq) continue; try { const parsed = await extractJobInfo(raw); const payload = { type: 'job', seq, data: parsed }; controller.enqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`)); } catch (err) { const payload = { type: 'error', seq, message: '解析失败' }; controller.enqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`)); } } } finally { clearInterval(heartbeat); controller.close(); } } }); return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' } }); }

这里用ReadableStream而不是 Node 的res.write,是为了兼容 Next.js 的 Edge Runtime。如果你确定跑在 Node Runtime,用res.write也可以,但要注意设置res.flushHeaders()。

4.4 前端流式渲染与薪资验证交互

前端页面用一个useEffect建立 EventSource 连接,收到消息后更新状态。每条岗位卡片展示:岗位名称、公司类型、经验要求、技能标签、薪资区间、地点,以及一个“查看原始来源”的按钮。

'use client'; import { useEffect, useState } from 'react'; export default function RadarPage() { const [jobs, setJobs] = useState([]); const [status, setStatus] = useState('idle'); useEffect(() => { const es = new EventSource('/api/radar/stream?keyword=后端&city=北京'); setStatus('running'); es.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'job') { setJobs(prev => [...prev, msg.data]); } }; es.onerror = () => { setStatus('error'); es.close(); }; return () => es.close(); }, []); return ( <div> {jobs.map((job, i) => ( <div key={i} className="job-card"> <h3>{job.job_title}</h3> <p>{job.company_type} | {job.experience_required}</p> <p>{job.salary_min} - {job.salary_max}</p> <a href={job.source_url} target="_blank" rel="noopener noreferrer"> 查看原始来源 </a> </div> ))} </div> ); }

薪资验证的交互设计很简单:每条卡片的薪资旁边有一个小图标,点击后弹出原始文本片段,高亮显示薪资相关的那句话。这样用户不用跳转就能快速判断这条薪资是否可信。

4.5 12分钟出报告的完整时间线

我实测了一次完整流程,从点击“开始扫描”到报告生成完毕,时间分布大致如下:

阶段耗时说明
数据源调度与抓取约4分钟并发5,覆盖3类数据源
HTML清洗与去重约1分钟本地处理,CPU密集
模型结构化抽取约5分钟逐条调用,平均每条2-3秒
SSE推送与前端渲染约2分钟流式,用户可边看边等
合计约12分钟视数据量浮动

这个时间是可以接受的,因为前端是流式展示,用户在第5分钟左右就能看到第一批结果,不会干等12分钟。

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

5.1 SSE 连接频繁断开怎么办

这是最常见的问题。表现是前端控制台反复报EventSource connection closed,或者收到stream disconnected before completion: idle timeout waiting for sse。排查顺序如下:

  1. 检查服务端是否设置了心跳。没有心跳的话,中间层(如Nginx、负载均衡)通常会在60秒空闲后断开连接。
  2. 检查Cache-Control是否设为no-cache。如果被缓存,流式推送会失效。
  3. 检查是否有代理层缓冲了响应。某些反向代理默认会缓冲text/event-stream,需要显式关闭缓冲。
  4. 如果跑在 Serverless 环境,注意函数执行时长限制。超过限制会被强制终止,SSE 自然断开。

实操心得:心跳间隔我试过30秒、15秒、10秒,最终定在15秒。太短浪费资源,太长容易被中间层判定为空闲。

5.2 模型输出JSON格式不稳定

Seed-2.1-pro-0915 大部分时候输出很稳定,但偶尔会在JSON前后加解释文字,或者把某个字段名写错。我的处理策略是:

  • 在prompt里明确要求“只输出JSON,不要任何解释”;
  • 用正则提取第一个{到最后一个}之间的内容;
  • 解析失败时重试一次,重试时把错误信息拼进prompt,让模型自我纠正。
function safeParse(text: string) { const match = text.match(/\{[\s\S]*\}/); if (!match) throw new Error('No JSON found'); return JSON.parse(match[0]); }

5.3 薪资抽取的边界情况

我整理了一份常见边界情况和处理方式:

原始表述处理方式备注
25-40k归一化为25000-40000标准情况
25k-40k·13薪归一化月薪,额外记录13薪年薪=月薪×13
面议标记为negotiable不参与排序
8k-15k(底薪+绩效)记录备注,提示构成实际到手可能低于下限
年薪30-50万除以12归一化为月薪注意是否含年终奖

5.4 并发控制与目标站点压力

我一开始没做并发限制,直接Promise.all发了几十个请求,结果目标站点返回429,IP被限流了一段时间。后来用p-limit把并发压到5,并在请求之间加200毫秒延迟,就再没触发过限流。

注意:做这类项目一定要控制请求频率,不仅是为了自己不被封,也是对目标站点的基本尊重。公开信息可以聚合,但不能暴力抓取。

5.5 前端重连后的数据重复

EventSource 自动重连后,服务端会从头开始推,导致前端出现重复数据。解决办法就是前面提到的seq序号机制。前端在onerror里记录当前最大seq,重连时拼到URL上,服务端跳过已推送的序号。

es.onerror = () => { const maxSeq = jobs.length > 0 ? jobs[jobs.length - 1].seq : 0; es.close(); // 重连时带上 lastSeq const newEs = new EventSource(`/api/radar/stream?keyword=后端&city=北京&lastSeq=${maxSeq}`); };

6. 这套方案还能怎么扩展

跑通基础版本之后,我陆续加了一些扩展功能,效果不错,这里一并分享。

第一个扩展是薪资趋势对比。把同一岗位在不同城市的薪资区间聚合起来,生成一个简单的对比视图。用户输入“后端+北京+上海+深圳”,报告里会多一个薪资对比模块,直观看到地域差异。

第二个扩展是技能关键词云。从所有岗位的技能要求里提取高频词,按出现次数排序,生成一个标签列表。这个功能帮用户快速了解目标岗位最看重哪些技能,比逐条看岗位描述高效得多。

第三个扩展是订阅推送。用户设置好关键词和城市后,系统每天定时跑一次,有新岗位就通过邮件或站内通知推给用户。这个功能需要加一个定时任务调度,我用的是 Next.js 的 Route Handler 配合外部 cron 触发,实现起来不复杂。

第四个扩展是报告导出。把结构化后的岗位列表导出成 CSV 或 PDF,方便用户离线查看或分享给朋友。导出功能用papaparse处理CSV,用jspdf处理PDF,都是成熟的库,接入成本很低。

我个人在实际操作中的体会是,这类工具的核心价值不在于技术多复杂,而在于把散落的信息变成可验证、可比较、可行动的报告。12分钟出结果听起来不算快,但相比手动翻几十个页面、记一堆笔记、还未必找得全,效率提升是数量级的。如果你也在看机会,不妨按这个思路搭一个自己的雷达,跑一次就知道值不值了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进