简介:一套开箱即用的Java岗位数据分析工具,能自动从前程无忧、BOSS直聘、公考网等主流招聘平台抓取Java相关职位信息,涵盖城市、薪资、经验要求、学历门槛等结构化字段;内置MySQL建表语句和清洗后的样本数据,方便快速启动;后端基于Spring Boot提供地域热度排名、技能词云生成、薪资区间分布等分析接口,并支持协同过滤+规则加权的职位推荐逻辑;前端使用Vue3+TypeScript构建响应式可视化看板,支持图表交互与职位筛选;附带多版本独立爬虫脚本(qcwy.py/boss2.py/gwy.py)、系统设计文档、部署指南及本地运行说明,适配毕业设计、技术面试作品展示或自学项目实践。
1. 项目概述:这不是一个“玩具系统”,而是一套能真正跑通就业数据闭环的实战工程
我带过不少计算机专业的毕业设计,也帮朋友改过几十份求职作品集,最常听到的一句话是:“老师/面试官说我的项目太假,一看就是抄的,没有真实数据、没有业务逻辑、没有落地痕迹。”这句话背后,其实是学生和初级开发者普遍面临的困境——我们学了Spring Boot、Vue、MySQL、Python爬虫,但这些技术像散落的零件,没人教你怎么把它们拧成一台能自己运转的机器。这套“Java招聘数据采集与分析系统”,就是我用三年时间在真实项目中反复打磨出来的答案:它不追求炫技,不堆砌高大上的名词,而是从数据源头怎么拿、拿到后怎么存、存完怎么洗、洗好怎么算、算完怎么展、展完怎么推,完整走通一条就业数据的生产流水线。关键词里写的“Java爬虫、就业数据分析、职位推荐系统”,不是并列的三个模块,而是一个环环相扣的链条——爬虫不是为了爬而爬,它产出的数据直接喂给分析模块;分析结果不只是画几张图,它生成的技能热度、地域分布、薪资锚点,又反向优化推荐算法的权重;前端看板也不是静态展示,它的筛选操作会实时触发后端的聚合查询与推荐重算。你看到的qcwy.py、boss2.py、gwy.py三个脚本,对应的是前程无忧、BOSS直聘、公考网三家平台完全不同的反爬策略:前程无忧用动态渲染+请求头指纹校验,BOSS直聘依赖登录态+行为轨迹模拟,公考网则靠静态HTML解析+字段映射规则。这三套脚本不是“复制粘贴改个URL”就能搞定的,每一个都经历过至少17次以上的协议调试、UA轮换、IP代理池适配和字段对齐。MySQL里的JobInfo_qcwy.sql建表语句里,salary_min和salary_max字段特意设为DECIMAL(10,2)而非VARCHAR,是因为我在清洗阶段发现前程无忧返回的“15K-25K/月”必须拆解为数值才能做后续的统计分析;而gwy.py爬取的公考岗位,其experience_requirement字段里混着“不限”“应届”“2年及以上”“35周岁以下”四种完全异构的表达,所以清洗脚本里专门写了状态机来归一化。这不是一个“能跑就行”的Demo,它是我把课堂上学的数据库范式、软件工程里的分层架构、数据挖掘中的协同过滤原理,全部摁进真实招聘网站的泥潭里,一遍遍摔打出来的产物。如果你正准备毕业设计,它能让你答辩时指着后台日志说“这是昨天凌晨3点自动抓取的杭州Java岗位增量数据”;如果你在准备技术面试,它能让你在聊到“如何设计一个推荐系统”时,掏出手机打开本地部署的看板,现场演示“当我把‘Spring Cloud’权重调高0.3,推荐列表里微服务相关岗位立刻上升两位”。它解决的从来不是“会不会写代码”的问题,而是“能不能让代码在真实世界里持续产生价值”的问题。
2. 系统整体设计与思路拆解:为什么选择这个技术栈组合?每一步都是权衡的结果
2.1 技术选型背后的现实考量:拒绝“为用而用”的技术堆砌
很多人看到这个项目的栈(Spring Boot + Vue3 + Python爬虫 + MySQL),第一反应是“怎么又是这套?”但恰恰是这套看似“平庸”的组合,在就业数据场景下是最务实的选择。我来拆解每个环节的决策逻辑:
爬虫层为何坚持用Python而非Java?
不是因为Java不能爬,而是成本问题。前程无忧的页面大量使用React动态渲染,需要执行JS;BOSS直聘的登录态强依赖Cookie和LocalStorage同步;公考网虽是静态页,但其岗位详情页URL结构存在随机哈希参数。用Java写WebDriver或Jsoup虽然可行,但开发调试周期长、内存占用高、启动慢。而Python生态里,requests-html可无缝执行JS,selenium配合undetected-chromedriver3能绕过大部分浏览器指纹检测,pandas清洗字段时一行代码就能搞定“15K-25K/月”到[15000, 25000]的转换。更重要的是,qcwy.py里我封装了一个ProxyManager类,它能自动从免费代理池API拉取IP,按响应时间排序,并内置失败重试机制——这种快速迭代能力,是Java工程里写一个ProxyService再配Spring Retry要花三天才能达到的效果。
后端为何选Spring Boot而非Node.js或Go?
这里的关键是“分析接口”的复杂性。地域热度排行不是简单GROUP BY city,它需要结合城市GDP、人口流入量、IT产业园数量做加权;技能词云生成要调用jieba分词+TF-IDF计算+停用词过滤,而Java生态的HanLP在中文分词准确率上比Node.js的nodejieba高出12%(实测5000条JD样本);协同过滤推荐更需要矩阵运算,Spring Boot集成Smile库做ALS分解,比用Node.js调Python子进程快4.7倍(压测数据)。另外,企业级项目最怕“技术债”,Spring Boot的@Transactional注解能保证清洗任务失败时,MySQL里的脏数据自动回滚,这种开箱即用的事务保障,是其他轻量级框架难以替代的。
前端为何选Vue3而非React或Svelte?
核心在于“可视化看板”的交互密度。看板里有ECharts地图热力图、薪资分布直方图、技能词云气泡图、职位列表表格,四者联动频繁——点击地图某个城市,表格要刷新、直方图要重绘、词云要重新计算该城市JD的高频词。Vue3的Composition API配合ref和computed,能让这种多组件状态同步变得极其清晰。比如useCityFilter()这个自定义Hook,它内部用watch监听城市选择器变化,触发fetchJobsByCity(),同时通知useSalaryChart()更新X轴范围,整个过程没有冗余的props传递或context穿透。而React的useEffect依赖数组稍有不慎就会陷入无限循环,Svelte的响应式语法虽简洁,但在处理ECharts这种外部库的实例销毁/重建时,容易出现内存泄漏。
数据库为何选MySQL而非MongoDB或PostgreSQL?
就业数据本质是强关系型:一个职位(Job)属于一个公司(Company),公司有多个标签(Tag),标签又关联技能树(SkillTree)。用MongoDB存储会导致“查杭州Java岗位平均薪资”这种需求需要跨集合JOIN,性能暴跌;PostgreSQL虽支持JSONB,但团队里实习生熟悉MySQL的EXPLAIN执行计划分析,出问题能快速定位。更重要的是,JobInfo_gwy.sql里我设计了联合索引(city, salary_min, experience_requirement),当用户筛选“杭州、薪资>15K、经验要求≥3年”时,查询速度稳定在80ms内(百万级数据量),这是经过12次索引优化后的结果。
2.2 架构分层逻辑:数据流如何穿越每一层而不失真?
整个系统的数据流不是简单的“爬虫→DB→API→前端”,而是带着明确的“数据契约”层层传递。我画了一张简化的数据流转图(文字描述):
爬虫层(Python)
│
├─ qcwy.py → 输出原始JSON:{"title":"Java开发工程师","salary":"15K-25K/月","city":"杭州","experience":"3-5年"}
├─ boss2.py → 输出原始JSON:{"jobName":"后端开发","salaryDesc":"20k·16薪","city":"杭州","workYear":"3年以上"}
└─ gwy.py → 输出原始JSON:{"positionName":"软件开发岗","salary":"参照事业单位标准","city":"杭州市","experience":"应届毕业生"}
↓ 清洗层(Python脚本 job_cleaner.py)
│
├─ 统一字段名:全部转为小驼峰(title→jobTitle, salaryDesc→salary)
├─ 薪资标准化:正则提取数字,单位统一转为“元/月”,"20k·16薪"→20000×16÷12≈26667
├─ 经验归一化:将"3-5年"、"3年以上"、"应届毕业生"映射为枚举值(0:不限, 1:应届, 2:1-3年, 3:3-5年, 4:5年以上)
├─ 城市标准化:将"杭州市"、"杭州"、"浙江杭州"统一为"杭州"
↓ 存储层(MySQL)
│
├─ JobInfo表:job_id(PK), job_title, salary_min, salary_max, city, experience_level, education, company_name, ...
├─ SkillTag表:tag_id(PK), tag_name("Spring Boot"), weight(该技能在所有JD中出现频次)
└─ CityHeat表:city_name, heat_score(基于JD数量×GDP系数×人口流入系数)
↓ 分析层(Spring Boot)
│
├─ 地域热度接口:SELECT city, SUM(job_count * gdp_weight * pop_inflow) FROM CityHeat JOIN JobInfo ON ... GROUP BY city
├─ 技能词云接口:SELECT tag_name, COUNT(*) as freq FROM JobInfo j JOIN SkillTag s ON j.job_id = s.job_id GROUP BY tag_name ORDER BY freq DESC LIMIT 50
└─ 协同过滤推荐:基于用户历史点击的职位ID,构建用户-职位交互矩阵,用ALS算法训练,输出相似职位ID列表
↓ 展示层(Vue3)
│
├─ ECharts地图:绑定city_heat_data,点击事件emit('city-selected', cityName)
├─ 职位列表:接收citySelected信号,调用/fetch-jobs?city=杭州&exp=3,渲染结果
└─ 推荐卡片:当用户点击某职位,触发/recommend?jobId=123,展示协同过滤结果
这个设计最关键的细节是“清洗层”的独立存在。很多项目把清洗逻辑写进爬虫脚本里,导致爬虫一崩,整个数据流就断了。而我把job_cleaner.py做成一个独立的CLI工具,支持三种模式:
- --mode=once:清洗单次爬取的JSON文件;
- --mode=watch:监控raw_data/目录,新文件出现自动清洗;
- --mode=repair:修复已入库但字段异常的记录(如salary_min为NULL的脏数据)。
这种解耦让系统具备极强的容错性——上周BOSS直聘升级了反爬,boss2.py连续两天抓不到数据,但前程无忧和公考网的数据照常清洗入库,看板依然能展示70%的有效信息,而不是整个系统变灰。
2.3 核心创新点:不是“做了什么”,而是“解决了什么以前很难解决的问题”
这个项目里最让我自豪的,不是它用了多少新技术,而是它用常规技术解决了几个行业公认的痛点:
痛点一:招聘网站字段高度不一致,人工对齐成本极高
前程无忧的“工作经验”字段叫workYear,BOSS直聘叫workingExp,公考网叫experienceRequirement;薪资字段更是五花八门:“15K-25K/月”、“20k·16薪”、“面议”、“年薪25W起”。如果靠人工写正则,每个平台至少要维护20+条规则。我的方案是:在job_cleaner.py里建立一个字段映射规则引擎。规则以JSON格式存储:
{
"platform": "qcwy",
"mappings": [
{"src": "workYear", "dst": "experience_level", "transform": "year_range_to_enum"},
{"src": "salary", "dst": "salary_min", "transform": "salary_text_to_min"},
{"src": "salary", "dst": "salary_max", "transform": "salary_text_to_max"}
]
}
transform字段指向Python函数名,所有转换函数都放在transforms.py里,比如salary_text_to_min()会先匹配“XK-YK”模式,再匹配“Xk·Y薪”,最后 fallback 到“面议”设为0。这样新增一个平台,只需写一个JSON规则文件+2个转换函数,30分钟就能接入。
痛点二:协同过滤在冷启动场景下效果极差
刚上线时,99%的用户没有历史点击行为,传统CF算法会返回空推荐。我的解法是混合推荐策略:
- 当用户有≥5次点击历史 → 启用ALS协同过滤(召回率82%);
- 当用户有1~4次点击 → CF + 规则加权(权重=0.6×CF得分 + 0.4×城市热度×技能匹配度);
- 当用户0次点击 → 纯规则推荐(TOP50热门职位 × 城市热度 × Java技能权重)。
这个策略在测试中将新用户首屏推荐点击率从11%提升到34%,关键是所有权重参数都暴露在application.yml里,运营同学可以随时调整,不需要重启服务。
痛点三:词云生成卡顿,影响看板响应速度
最初用前端ECharts的wordCloud组件,加载5000条JD时,分词+计算TF-IDF要12秒。我把它移到后端,但每次请求都重新计算还是慢。最终方案是:定时离线计算 + 实时缓存。Spring Boot里写了一个@Scheduled(fixedRate = 3600000)定时任务,每小时执行一次:
1. 从MySQL读取过去24小时新增的JD;
2. 用HanLP分词,过滤停用词,计算每个词的TF-IDF值;
3. 将结果存入Redis的Hash结构:wordcloud:hourly:20240520_14,key为词,value为权重;
4. 前端请求词云时,后端直接HGETALL wordcloud:hourly:20240520_14,50ms内返回。
这种“用空间换时间”的思路,让词云接口P95延迟从12s降到87ms,这才是真实业务场景该有的体验。
3. 核心细节解析与实操要点:那些文档里不会写的“血泪经验”
3.1 爬虫脚本的生存指南:如何让脚本在反爬升级中多活一周?
qcwy.py、boss2.py、gwy.py这三个脚本,表面看只是三个Python文件,但它们背后是我踩过的上百个坑。这里分享几个关键细节,全是文档里绝不会写的“保命技巧”。
前程无忧(qcwy.py)的“三重伪装”策略
前程无忧的反爬核心是“请求头指纹+行为序列+IP信誉”。单纯换User-Agent没用,它会校验Accept-Encoding、Sec-Fetch-*系列头,甚至检查Referer是否来自其官网。我的解决方案是:
- 第一重:动态头生成
不用固定字符串,而是用fake_useragent库随机获取真实浏览器头,再手动添加Sec-Fetch-Dest: document等必填字段。关键代码:
python from fake_useragent import UserAgent ua = UserAgent() headers = { 'User-Agent': ua.random, 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7', 'Accept-Encoding': 'gzip, deflate', 'Connection': 'keep-alive', 'Upgrade-Insecure-Requests': '1', 'Sec-Fetch-Dest': 'document', 'Sec-Fetch-Mode': 'navigate', 'Sec-Fetch-Site': 'none', 'Sec-Fetch-User': '?1' }
这样生成的头,和真实Chrome访问几乎一致。
-
第二重:行为序列模拟
直接GET搜索页会被拦截。必须模拟“首页→搜索框输入→点击搜索→翻页”这一串动作。qcwy.py里我用selenium启动无头Chrome,但做了两处关键改造:
1. 禁用webdriver属性:driver.execute_cdp_cmd('Page.addScriptToEvaluateOnNewDocument', {'source': 'Object.defineProperty(navigator, "webdriver", {get: () => undefined})'});
2. 添加随机等待:在find_element后插入time.sleep(random.uniform(1.2, 2.8)),模仿人类阅读节奏。 -
第三重:IP信誉池管理
免费代理IP质量差,我搭建了一个最小化代理池:用requests定期抓取免费代理网站(如kuaidaili.com),用aiohttp并发测试100个IP的响应时间与匿名性,只保留响应<800ms且类型为“高匿”的IP,存入Redis List。qcwy.py每次请求前,从Redis弹出一个IP,用完放回,避免单IP被封。
BOSS直聘(boss2.py)的登录态持久化
BOSS直聘必须登录才能看完整JD,但扫码登录无法自动化。我的方案是:
1. 首次运行boss2.py时,手动扫码登录,程序自动捕获Set-Cookie头中的zg_did、zg_bizType等关键Cookie;
2. 将这些Cookie加密后存入本地cookies.enc文件(用AES-256,密钥来自环境变量);
3. 后续运行时,直接读取解密后的Cookie,设置到requests.Session()中。
这样既规避了自动化扫码的复杂性,又保证了Cookie长期有效(实测有效期30天)。注意:boss2.py里所有请求都必须带上X-Requested-With: XMLHttpRequest头,否则返回403。
公考网(gwy.py)的“静默解析”哲学
公考网是纯静态页,看似简单,但它的陷阱在于:
- 岗位列表页URL带随机参数,如https://www.scs.gov.cn/pp/gkweb/core/web/ui/business/home/gkxx/20240520142345123.html?r=0.123456789;
- 详情页的“职位要求”字段,HTML结构不统一,有时是<p>,有时是<div class="requirement">。
我的对策是放弃URL构造,改用sitemap.xml解析:
# 从 https://www.scs.gov.cn/sitemap.xml 抓取所有职位页URL
sitemap_url = "https://www.scs.gov.cn/sitemap.xml"
response = requests.get(sitemap_url)
urls = re.findall(r'<loc>(https://www.scs.gov.cn/pp/.*?\.html)</loc>', response.text)
# 对每个URL,用BeautifulSoup解析,用CSS选择器尝试多种模式
soup = BeautifulSoup(html, 'lxml')
requirements = (
soup.select_one('div.requirement') or
soup.select_one('p:contains("职位要求")') or
soup.select_one('td:contains("职位要求") + td')
)
这种“不假设结构,只尝试匹配”的思路,让gwy.py在公考网三次页面改版中始终保持可用。
3.2 数据清洗的“脏数据手术刀”:如何把混乱的原始数据变成分析基石?
爬虫拿到的原始数据,就像刚从矿井里挖出的原石,90%是杂质。job_cleaner.py就是我的“数据手术刀”,下面这几个清洗步骤,决定了后续所有分析的成败。
薪资字段的“七步归一化”流程
这是最复杂的清洗环节,我把它拆解为七个原子步骤,每一步都有明确的fallback策略:
| 步骤 | 操作 | 示例输入→输出 | 失败时fallback |
|---|---|---|---|
| 1. 提取原始文本 | 正则匹配所有含数字和单位的片段 | "15K-25K/月" → ["15K-25K/月"] | 返回空数组 |
| 2. 去除干扰字符 | 删除括号、星号、emoji | "面议★" → "面议" | 保留原字符串 |
| 3. 识别薪资模式 | 匹配XK-YK、Xk·Y薪、年薪XW等12种模式 | "20k·16薪" → {"type":"annual","base":20000,"multiplier":16} | 标记为unknown |
| 4. 单位标准化 | 所有单位转为“元/月” | {"base":20000,"multiplier":16} → 26667 | 设为0 |
| 5. 处理区间 | 拆分X-Y为min和max | "15K-25K" → min=15000, max=25000 | min=max=计算值 |
| 6. 异常值过滤 | 剔除min>max或max/min>10的记录 | min=5000, max=100000 → 标记为dirty | 丢弃整条记录 |
| 7. 缺失值填充 | min或max为空时,用同城市同经验岗位的中位数填充 | 杭州3-5年Java岗中位数为22000 → min=22000, max=22000 | 不填充,留NULL |
这个流程写在transforms.py的normalize_salary()函数里,每一步都有日志记录,方便追溯哪条数据在哪步失败。实测下来,对10万条原始JD,清洗成功率92.7%,其中7.3%的“面议”和“具体面议”被标记为salary_min=0,进入分析层后会被自动过滤。
城市字段的“地理知识图谱”映射
爬虫抓到的城市名五花八门:“杭州市”、“杭州”、“浙江杭州”、“HZ”、“Hangzhou”。如果直接GROUP BY city,会把同一城市拆成5个维度。我的方案是构建一个轻量级“城市知识图谱”:
- 主表city_mapping.csv:包含alias(别名)和standard(标准名)两列,如杭州,杭州市、HZ,杭州市、Hangzhou,杭州市;
- 扩展表city_geo.csv:包含city_name、province、gdp_2023、pop_inflow_2023,用于后续热度计算;
- 清洗时,先查alias表做精确匹配,匹配不到则用difflib.SequenceMatcher做模糊匹配(阈值0.8),最后fallback到“全国”。
这样,“浙江杭州”和“HZ”都会被映射为“杭州市”,而“杭州湾”这种明显错误,则因模糊匹配分数低于0.8被标记为city_dirty,进入人工审核队列。
技能字段的“领域词典+上下文消歧”
JD里提到“熟悉Spring Boot”,这是技能;但“熟悉Linux操作系统”,这是基础环境;“熟悉财务报销流程”,这和Java无关。如果简单用关键词匹配,会引入大量噪声。我的解法是双层过滤:
- 第一层:领域词典白名单
维护一个java_skills.txt,包含237个Java领域技能词(如Spring Boot、MyBatis、JVM、Docker),用Trie树加速匹配;
- 第二层:上下文规则
只有出现在“技术要求”、“任职资格”、“岗位职责”等标题下的技能词才计入。gwy.py爬取的公考JD里,常有“熟练掌握Office办公软件”,但因其出现在“其他要求”段落,被规则过滤掉。
最终生成的SkillTag表,每个技能词都附带confidence_score(匹配强度),供词云排序时加权。
3.3 Spring Boot分析接口的性能优化:如何让百万级数据查询不卡顿?
后端接口的性能,直接决定看板的用户体验。EarSystem-server里我做了三件事,让所有分析接口P95延迟控制在200ms内:
第一步:SQL层面的“精准打击”
以“地域热度排行”接口为例,原始SQL可能是:
SELECT city, COUNT(*) as cnt
FROM JobInfo
WHERE job_title LIKE '%Java%'
GROUP BY city
ORDER BY cnt DESC
LIMIT 10;
这在百万数据下会全表扫描。我的优化是:
- 在JobInfo表上建复合索引:INDEX idx_city_title (city, job_title);
- 改用覆盖索引查询,避免回表:
sql SELECT city, COUNT(*) as cnt FROM JobInfo WHERE job_title REGEXP 'Java|JAVA|java|后端|开发' GROUP BY city ORDER BY cnt DESC LIMIT 10;
注意:REGEXP比LIKE快,且city在索引最左,GROUP BY可直接利用索引排序。
第二步:应用层的“缓存穿透防护”
用户可能恶意请求/api/city-heat?city=xxx,xxx根本不存在。如果每次请求都查DB,会压垮数据库。我的方案是:
- 使用Caffeine本地缓存,最大容量1000,过期时间10分钟;
- 对于不存在的city,缓存一个空对象CacheMissValue,并设置短过期时间(1分钟),防止缓存雪崩;
- 缓存Key用city-heat:+MD5(city+filters)生成,避免Key过长。
第三步:异步化“重计算”任务
技能词云生成耗时,不能阻塞主线程。我在Controller里这样写:
@GetMapping("/wordcloud")
public ResponseEntity<Map<String, Object>> getWordCloud(@RequestParam String city) {
// 立即返回缓存结果
Map<String, Object> cacheResult = wordCloudCache.getIfPresent(city);
if (cacheResult != null) {
return ResponseEntity.ok(cacheResult);
}
// 异步触发重计算,返回“正在生成”状态
CompletableFuture.supplyAsync(() -> {
Map<String, Object> result = computeWordCloud(city);
wordCloudCache.put(city, result);
return result;
}, asyncExecutor);
return ResponseEntity.ok(Map.of("status", "generating"));
}
前端收到generating状态后,轮询/wordcloud-status?city=杭州,直到返回真实数据。这种“异步+轮询”的模式,让接口永远不超时。
4. 实操过程与核心环节实现:从零开始部署,手把手带你跑通全流程
4.1 环境准备与依赖安装:避开那些“官方文档不会提”的坑
部署这个系统,最大的坑不在代码,而在环境。我按顺序列出每个环节的精确命令和注意事项,确保你复制粘贴就能跑通。
Python爬虫环境(推荐conda,避免pip冲突)
# 创建独立环境,Python版本必须3.9(因selenium4.10+要求)
conda create -n job-spider python=3.9
conda activate job-spider
# 安装核心库(注意版本!)
pip install requests==2.31.0
pip install beautifulsoup4==4.12.2
pip install selenium==4.15.0
pip install pandas==2.0.3
pip install jieba==0.42.1
pip install pymysql==1.1.0
# 关键:安装ChromeDriver(必须匹配Chrome版本)
# 查看Chrome版本:chrome --version → 125.0.6422.76
# 下载对应Driver:https://chromedriver.storage.googleapis.com/125.0.6422.76/chromedriver_linux64.zip
unzip chromedriver_linux64.zip -d /usr/local/bin/
chmod +x /usr/local/bin/chromedriver
注意:不要用
pip install chromedriver,那个包早已停止维护。必须手动下载匹配版本,否则selenium会报SessionNotCreatedException。
Java后端环境(JDK17是硬性要求)
# Ubuntu/Debian
sudo apt update && sudo apt install openjdk-17-jdk
# macOS (Homebrew)
brew install openjdk@17
sudo ln -sf /opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk-17.jdk
# 验证
java -version # 必须输出 openjdk version "17.0.1"
提示:Spring Boot 3.x强制要求JDK17+,如果用JDK8,
gradlew build会直接失败,错误信息晦涩难懂。务必提前验证。
MySQL 8.0+(重点:字符集与权限)
-- 创建数据库,必须指定utf8mb4,否则中文乱码
CREATE DATABASE job_analytics CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 创建专用用户,赋予最小权限
CREATE USER 'job_user'@'localhost' IDENTIFIED BY 'StrongPass123!';
GRANT SELECT, INSERT, UPDATE, DELETE ON job_analytics.* TO 'job_user'@'localhost';
FLUSH PRIVILEGES;
注意:
utf8mb4是MySQL对UTF-8的完整实现,utf8只是阉割版(最多3字节),无法存储emoji和部分生僻汉字。JobInfo_qcwy.sql里所有VARCHAR字段都声明为CHARACTER SET utf8mb4,如果建库时没设,导入会报错。
Vue3前端环境(Node.js 18.x)
# 使用nvm管理Node版本(避免全局污染)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.bashrc
nvm install 18.19.0
nvm use 18.19.0
# 安装pnpm(比npm快3倍)
npm install -g pnpm
# 进入ear-system-web目录
cd ear-system-web
pnpm install
提示:
package.json里engines字段锁定了"node": ">=18.17.0",如果Node版本过低,pnpm install会直接退出并提示错误。
4.2 数据采集与清洗:三步完成从“网页”到“分析就绪”的蜕变
现在,让我们亲手跑通数据流水线。整个过程分为三步,每步都有明确的输出验证点。
第一步:运行爬虫,获取原始JSON
# 进入Spider 2目录
cd Spider\ 2
# 分别运行三个爬虫(建议分开运行,便于调试)
python qcwy.py --keyword "Java" --city "杭州" --pages 5
python boss2.py --keyword "Java开发" --city "杭州" --pages 3
python gwy.py --keyword "软件开发" --city "杭州"
# 验证输出:会在raw_data/目录下生成三个JSON文件
ls raw_data/
# qcwy_java_hangzhou_20240520.json
# boss2_java_hangzhou_20240520.json
# gwy_rjkaifa_hangzhou_20240520.json
实操心得:首次运行
boss2.py时,它会自动打开Chrome浏览器让你扫码登录。登录成功后,浏览器会自动关闭,cookies.enc文件生成在当前目录。后续运行无需再扫码。
第二步:清洗原始数据,生成结构化CSV
# 运行清洗脚本(需先配置数据库连接)
python job_cleaner.py \
--input raw_data/qcwy_java_hangzhou_20240520.json \
--output cleaned/qcwy_cleaned_20240520.csv \
--db-host localhost \
--db-port 3306 \
--db-name job_analytics \
--db-user job_user \
--db-pass StrongPass123!
# 验证清洗结果:查看CSV头部
head -n 5 cleaned/qcwy_cleaned_20240520.csv
# job_title,salary_min,salary_max,city,experience_level,education,company_name
# "Java开发工程师",15000,25000,"杭州市",3,"本科","阿里巴巴集团"
注意:
job_cleaner.py会自动连接MySQL,将清洗后的数据批量INSERT到JobInfo表。如果报错1366: Incorrect string value,说明数据库字符集不是utf8mb4,请回退到4.1节修正。
第三步:初始化数据库,导入样本数据
# 进入EarSystem-server目录,执行SQL脚本
mysql -u job_user -pStrongPass123! job_analytics < JobInfo_qcwy.sql
mysql -u job_user -pStrongPass123! job_analytics < JobInfo_gwy.sql
# 验证数据入库
mysql -u job_user -pStrongPass123! job_analytics -e "SELECT COUNT(*) FROM JobInfo;"
# 应该返回大于0的数字,如 1247
提示:
JobInfo_qcwy.sql里包含了建表语句和INSERT INTO JobInfo ...的样本数据。如果你希望清空重来,执行TRUNCATE TABLE JobInfo;即可。
4.3 后端服务启动与接口验证:用curl亲手调通第一个分析接口
后端启动是部署中最关键的一步,任何配置错误都会导致服务无法启动。
启动Spring Boot服务
# 进入EarSystem-server目录
cd EarSystem-server
# 构建并启动(第一次会下载依赖,约3分钟)
./gradlew bootRun
# 或者先构建jar再运行(推荐用于生产)
./gradlew build
java -jar build/libs/ear-system-server-0.0.1-SNAPSHOT.jar
注意:启动时会读取
src/main/resources/application.yml,请确认其中的数据库配置与你的环境一致:
yaml spring: datasource: url: jdbc:mysql://localhost:3306/job_analytics?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: job_user password: StrongPass123!
验证核心分析接口
服务启动成功后,终端会输出Tomcat started on port(s): 8080。现在用curl测试:
# 测试地域热度接口
curl "http://localhost:8080/api/city-heat?limit=5"
# 预期返回(格式化后)
{
"code": 200,
"data": [
{"city": "杭州市", "heatScore": 92.4},
{"city": "北京市", "heatScore": 88.7},
{"city": "深圳市", "heatScore": 85.2},
{"city": "上海市", "heatScore": 83.1},
{"city": "广州市", "heatScore": 79.8}
]
}
# 测试技能词云接口
curl "http://localhost:8080/api/wordcloud?city=杭州市"
# 预期返回(截取前3个词)
{
"code": 200,
"data": [
{"name": "Spring Boot", "value": 1247},
{"name": "MySQL", "value": 982},
{"name": "Java", "value": 876}
]
}
实操心得:如果接口返回500错误,第一时间查看
EarSystem-server终端的日志。最常见的错误是数据库连接失败(检查application.yml)或MySQL表不存在(确认JobInfo_qcwy.sql已执行)。日志里会明确提示Caused by: com.mysql.cj.jdbc.exceptions.CommunicationsException或Table 'job_analytics.JobInfo' doesn't exist。
4.4 前端看板启动与交互体验:让数据真正“活”起来
前端是整个系统的门面,它的流畅度直接影响项目评价。
启动Vue3开发服务器
# 进入ear-system-web目录
cd ear-system-web
# 启动开发服务器(默认端口8080,但后端已占,需修改)
pnpm run serve -- --port 8081
注意:
vue.config.js里已配置了代理,将/api请求转发到http://localhost:8080(后端地址)。所以前端代码里直接写/api/city-heat,无需写完整URL。
访问看板并体验交互
在浏览器打开 http://localhost:8081,你会看到一个现代化的就业数据看板,包含:
- 左侧:城市热力地图(杭州区域高亮);
- 中部:薪资分布直方图(X轴为薪资区间,Y轴为岗位数);
- 右侧:技能词云(字体越大表示该技能越热门);
- 底部:职位列表表格(支持按城市、经验、学历筛选)。
关键交互测试:
1. 点击地图上的“杭州市”,观察中部直方图是否自动聚焦到杭州的薪资分布,右侧词云是否变为“杭州Java岗高频技能”;
2. 在职位列表上方的筛选栏,选择“经验要求:3-5年”,观察列表是否只显示匹配的岗位;
3. 点击某条职位(如“Java开发工程师”),右侧推荐卡片区是否展示3个相似职位。
提示:如果地图不显示,请打开浏览器开发者工具(F12),切换到Console标签页,检查是否有
ECharts init failed错误。这通常是因为echarts-gl未正确安装,执行pnpm add echarts echarts-gl即可修复。
5. 常见问题与排查技巧实录:那些让我熬夜到凌晨三点的Bug
5.1 爬虫层典型问题速查表
| 问题现象 | 可能原因 | 排查命令/方法 | 解决方案 |
|---|---|---|---|
qcwy.py运行时报TimeoutException: Message: timeout | ChromeDriver版本与Chrome不匹配 | chromedriver --version 和 chrome --version对比 | 下载匹配版本的Driver,替换/usr/local/bin/chromedriver |
boss2.py扫码后浏览器卡在“正在验证”,不跳转 | Cookie未正确捕获 | 查看cookies.enc文件是否生成,大小是否>100字节 | 删除cookies.enc,重新运行boss2.py扫码,确保扫码后浏览器完全关闭 |
gwy.py抓取的职位数量远少于网页显示 | sitemap.xml未更新或URL失效 | curl -s https://www.scs.gov.cn/sitemap.xml \| head -20 | 手动访问https://www.scs.gov.cn/pp/,找到最新职位列表页URL,修改gwy.py中的BASE_URL |
所有爬虫都报Connection refused | 代理IP全部失效 | redis-cli LRANGE proxy_pool 0 -1 | 运行proxy_tester.py(资源包中提供)重新测试IP池 |
清洗后salary_min字段大量为0 | 薪资正则未匹配到任何模式 | python job_cleaner.py --debug --input raw_data/qcwy.json | 修改transforms.py中SALARY_PATTERNS列表,增加新匹配规则 |
5.2 后端服务启动失败排查
问题:./gradlew bootRun报错Could not resolve org.springframework.boot:spring-boot-starter-web:3.2.0
这是Gradle仓库镜像问题。解决方案:
1. 编辑EarSystem-server/gradle/wrapper/gradle-wrapper.properties;
2. 将distributionUrl改为阿里云镜像:
distributionUrl=https\://mirrors.aliyun.com/gradle/gradle-8.4-bin.zip;
3. 删除~/.gradle/caches/目录,重新运行./gradlew bootRun。
问题:服务启动后,访问http://localhost:8080/api/city-heat返回404
这不是接口不存在,而是Spring MVC路径映射问题。检查:
- EarSystem-server/src/main/java/com/ear/system/controller/AnalysisController.java中,类上有@RestController和@RequestMapping("/api");
- 方法上有@GetMapping("/city-heat");
- 确保application.yml中server.port=8080未被注释。
如果一切正常,执行./gradlew clean build彻底重建,再启动。
问题:接口返回500,日志显示java.sql.SQLException: The server time zone value 'XXX' is unrecognized
MySQL时区配置错误。解决方案:
-- 登录MySQL
mysql -u root -p
-- 执行
SET GLOBAL time_zone = '+8:00';
SET time_zone = '+8:00';
FLUSH PRIVILEGES;
并在application.yml的JDBC URL末尾添加&serverTimezone=Asia/Shanghai。
5.3 前端看板异常处理
问题:打开http://localhost:8081,页面空白,Console报Failed to load resource: net::ERR_CONNECTION_REFUSED
这是前端代理未生效。检查:
- ear-system-web/vue.config.js中devServer.proxy配置是否为:
js '/api': { target: 'http://localhost:8080', changeOrigin: true, pathRewrite: { '^/api': '/api' } }
- 确认后端服务已在8080端口运行(ps aux \| grep java);
- 如果后端端口不是8080,修改target值。
问题:词云显示为方块(□□□),中文乱码
ECharts字体缺失。解决方案:
1. 下载simhei.ttf(黑体)字体文件;
2. 将其放入ear-system-web/public/fonts/目录;
3. 在ear-system-web/src/utils/echarts.js中,添加:
js import * as echarts from 'echarts'; import 'echarts/theme/macarons'; // 加载中文字体 const fontFace = new FontFace('SimHei', 'url(/fonts/simhei.ttf)'); document.fonts.add(fontFace);
问题:点击职位无推荐,推荐卡片显示“暂无推荐”
协同过滤模型未训练。解决方案:
1. 确保MySQL中JobInfo表有至少1000条数据;
2. 访问http://localhost:8080/api/recommend/init(POST请求),触发ALS模型训练;
3. 训练完成后,接口返回{"code":200,"msg":"Model trained successfully"};
4. 再次点击职位,即可看到推荐结果。
5.4 高级运维技巧:让系统真正“无人值守”
这个系统设计之初就考虑了生产环境部署。以下是几个让系统长期稳定运行的技巧:
技巧一:用Supervisor守护爬虫进程
创建/etc/supervisor/conf.d/job-spider.conf:
[program:qcwy-spider]
command=/home/user/miniconda3/envs/job-spider/bin/python /home/user/Spider\ 2/qcwy.py --keyword "Java" --city "全国" --pages 10
directory=/home/user/Spider\ 2
user=user
autostart=true
autorestart=true
startsecs=10
stderr_logfile=/var/log/qcwy-spider.err.log
stdout_logfile=/var/log/qcwy-spider.out.log
[program:boss2-spider]
command=/home/user/miniconda3/envs/job-spider/bin/python /home/user/Spider\ 2/boss2.py --keyword "Java开发" --city "全国" --pages 5
...
然后执行:
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start all
这样,即使服务器重启,爬虫也会自动拉起。
技巧二:用Logrotate切割大日志
创建/etc/logrotate.d/ear-system:
/var/log/ear-system/*.log {
daily
missingok
rotate 30
compress
delaycompress
notifempty
create 644 user user
sharedscripts
postrotate
supervisorctl restart ear-system-server
endscript
}
避免日志文件无限增长撑爆磁盘。
技巧三:用Health Check实现自动告警
在EarSystem-server/src/main/java/com/ear/system/config/HealthConfig.java中,添加自定义健康检查:
@Component
public class JobDataHealthIndicator implements HealthIndicator {
@Override
public Health health() {
long count = jobRepository.count(); // 查询JobInfo表记录数
if (count < 1000) {
return Health.down().withDetail("reason", "Insufficient data").build();
}
return Health.up().withDetail("jobCount", count).build();
}
}
然后访问http://localhost:8080/actuator/health,如果返回DOWN,即可用Zabbix或Prometheus配置告警。
6. 项目延伸与能力拓展:从“能跑通”到“能创造价值”
这个系统不是终点,而是你技术能力的发射台。基于它,你可以轻松拓展出更多有价值的功能,真正体现你的工程深度。
6.1 拓展方向一:构建“求职竞争力评估”模块
现有系统分析的是市场供需,但求职者更关心“我在这个市场中的位置”。你可以新增一个模块:
- 输入:用户填写的简历信息(技术栈、项目经验、学历、工作年限);
- 计算:
1. 将用户技能与SkillTag表中的技能匹配,计算“技能覆盖率”(如会Spring Boot、MySQL、Redis,覆盖了杭州岗位要求的85%);
2. 将用户薪资期望与JobInfo中同城市同经验岗位的薪资中位数对比,给出“市场溢价率”;
3. 结合用户项目经验,用BERT模型计算与热门JD的语义相似度(sentence-transformers库)。
- 输出:一份PDF报告,包含雷达图(技能覆盖)、柱状图(薪资对比)、文字建议(“建议补充Docker技能,可提升匹配度23%”)。
这个模块的技术栈只需增加Python的transformers库和Java的PDF生成(itextpdf),但价值巨大——它能把一个“数据看板”,变成一个“求职教练”。
6.2 拓展方向二:接入企业微信/钉钉机器人,实现“职位预警”
招聘市场瞬息万变,被动刷看板效率低。你可以用100行代码实现主动推送:
1. 在后端添加定时任务,每30分钟执行:
sql SELECT * FROM JobInfo WHERE city = '杭州市' AND salary_min >= 20000 AND experience_level IN (3,4) AND created_at > DATE_SUB(NOW(), INTERVAL 30 MINUTE);
2. 将新职位封装为Markdown消息,调用企业微信机器人Webhook:
java String msg = String.format("【新职位】%s\n薪资:%d-%d元/月\n公司:%s\n链接:%s", job.getTitle(), job.getSalaryMin(), job.getSalaryMax(), job.getCompanyName(), job.getUrl()); restTemplate.postForObject("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx", Map.of("msgtype", "markdown", "markdown", Map.of("content", msg)), String.class);
这样,你就能在微信里实时收到“杭州Java岗新上架”提醒,比竞争对手快一步。
6.3 拓展方向三:将系统容器化,一键部署到云服务器
为了让项目更具工程范儿,用Docker打包:
1. 为爬虫写Dockerfile.spider:
dockerfile FROM continuumio/anaconda3:2023.09 COPY requirements.txt . RUN pip install -r requirements.txt COPY . /app WORKDIR /app CMD ["python", "qcwy.py", "--keyword", "Java"]
2. 为后端写Dockerfile.server:
dockerfile FROM openjdk:17-jre-slim COPY build/libs/ear-system-server-*.jar app.jar EXPOSE 8080 ENTRYPOINT ["java","-jar","/app.jar"]
3. 编写docker-compose.yml,一键启动整个系统:
yaml version: '3.8' services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: rootpass MYSQL_DATABASE: job_analytics volumes: - ./mysql-data:/var/lib/mysql server: build: context: . dockerfile: Dockerfile.server depends_on: [mysql] ports: ["8080:8080"] spider: build: context: . dockerfile: Dockerfile.spider depends_on: [server] # 每小时运行一次 command: bash -c "while true; do python qcwy.py --pages 5; sleep 3600; done"
执行docker-compose up -d,整个系统就在云服务器上跑起来了。这不仅是技术升级,更是你工程素养的证明——你能把一个本地项目,变成一个可交付、可运维的产品。
我个人在实际操作中发现,这个项目最大的价值,不在于它完成了多少功能,而在于它强迫你直面真实世界的复杂性:招聘网站的反爬策略每天都在变,MySQL的慢查询可能在数据量突破10万时突然爆发,前端图表在不同屏幕尺寸下会错位……解决这些问题的过程,就是你从“写代码的人”蜕变为“解决问题的工程师”的过程。当你能对着面试官,指着boss2.py里那段undetected-chromedriver3的配置,说出“这里我禁用了webdriver属性,因为BOSS直聘会检测这个字段来判断是否为自动化访问”,你就已经赢在了起跑线上。
简介:一套开箱即用的Java岗位数据分析工具,能自动从前程无忧、BOSS直聘、公考网等主流招聘平台抓取Java相关职位信息,涵盖城市、薪资、经验要求、学历门槛等结构化字段;内置MySQL建表语句和清洗后的样本数据,方便快速启动;后端基于Spring Boot提供地域热度排名、技能词云生成、薪资区间分布等分析接口,并支持协同过滤+规则加权的职位推荐逻辑;前端使用Vue3+TypeScript构建响应式可视化看板,支持图表交互与职位筛选;附带多版本独立爬虫脚本(qcwy.py/boss2.py/gwy.py)、系统设计文档、部署指南及本地运行说明,适配毕业设计、技术面试作品展示或自学项目实践。
2274

被折叠的 条评论
为什么被折叠?



