Initial commit
This commit is contained in:
127
skills/jd-resume-tailor/SKILL.md
Executable file
127
skills/jd-resume-tailor/SKILL.md
Executable file
@@ -0,0 +1,127 @@
|
||||
---
|
||||
name: jd-resume-tailor
|
||||
description: 给定一份 JD 和一份现有简历,做"JD 拆解 + 简历定向改写"。拆 JD 抽出硬技能、软技能、加分项;对照简历做 gap 分析;产出针对该岗位重写后的简历,突出相关经验、补齐关键词缺口、并保留候选人真实经历不编造。当用户说"针对这个岗位 / 这家公司改简历""帮我对一下这个 JD""我想投这个职位你看怎么改""把这份简历针对 X 公司优化""做一份定向版简历",或同时给出 JD 文本 + 简历文件时,必须触发本 skill。**请勿用本 skill 做"从零写简历"**——那是 resume-builder 的事。
|
||||
---
|
||||
|
||||
# JD ⇄ Resume Tailor(JD 拆解 + 简历定向改写)
|
||||
|
||||
这个 skill 的边界很窄:**只解决"已有 JD + 已有简历,要改一份命中率最高的版本"**。
|
||||
|
||||
不做的事:
|
||||
- 写新简历(去 `resume-builder`)
|
||||
- 找方向 / 推荐岗位(去 `job-intent-tracker`)
|
||||
- 出面试题(去 `interview-prep`)
|
||||
|
||||
---
|
||||
|
||||
## 何时触发
|
||||
|
||||
强信号:
|
||||
- 用户给了 JD 链接 / JD 文本 + 一份简历 → **必触发**
|
||||
- "针对这个岗位帮我改简历"
|
||||
- "对照一下这个 JD"
|
||||
- "我想投 X 公司的 Y 岗,帮我看简历"
|
||||
- "做一份定向版"
|
||||
|
||||
弱信号(先确认):
|
||||
- 只给了 JD 没有简历 → 问"你的简历方便发我看一下吗?没有的话,我可以先帮你从零做一份(resume-builder)"
|
||||
- 只给了简历说"改简历" → 问"是针对哪个 JD 改?没有 JD 就用 resume-builder 通用优化"
|
||||
|
||||
---
|
||||
|
||||
## 工作流
|
||||
|
||||
### Step 1: 解析 JD
|
||||
|
||||
输入可能是:
|
||||
- 纯文本(用户粘贴)
|
||||
- 链接(**不要**自动 fetch,提醒用户复制 JD 文本进来;若用户授权 fetch,使用 web_fetch)
|
||||
- 截图(用 OCR / 视觉识别,让用户确认抽取结果)
|
||||
- doc/pdf 文件
|
||||
|
||||
调用脚本:
|
||||
|
||||
```bash
|
||||
python scripts/parse_jd.py --jd-file <jd.txt> --out jd_parsed.json
|
||||
```
|
||||
|
||||
脚本会从 JD 抽出:
|
||||
- **硬技能 must-have**("必须" / "要求" / "至少 X 年" 等强信号词后面的技能)
|
||||
- **硬技能 nice-to-have**("加分" / "优先" / "熟悉者优先" 等弱信号)
|
||||
- **软技能信号**(沟通 / 推动 / 跨部门 / 抗压 等)
|
||||
- **职责动词 + 对象**("负责 X" / "搭建 Y" / "推动 Z")
|
||||
- **特殊要求**(出差 / 学历 / 证书 / 语言 / 城市)
|
||||
|
||||
把结果展示给用户,让用户**确认 / 修正抽取是否准确**(关键 must-have 不能漏)。
|
||||
|
||||
### Step 2: 解析简历
|
||||
|
||||
输入:用户上传的简历文件(.pdf / .docx / .md / .txt)。
|
||||
|
||||
调用对应 skill 解析:
|
||||
- pdf → pdf skill
|
||||
- docx → docx skill
|
||||
|
||||
抽出:基本信息、教育、每段工作 / 项目经历的(公司、岗位、时间、职责 bullet)、技能列表。
|
||||
|
||||
### Step 3: Gap 分析
|
||||
|
||||
调用:
|
||||
|
||||
```bash
|
||||
python scripts/jd_gap.py --jd jd_parsed.json --resume resume.txt --out gap.md
|
||||
```
|
||||
|
||||
脚本输出三类清单:
|
||||
|
||||
1. **完美命中**(JD must-have 在简历里有明确证据)
|
||||
2. **隐性命中**(JD 要求 X,简历里有 X 的近义经验,但用词不一样 → 改写时可以"提一下")
|
||||
3. **真缺口**(JD 要求但简历完全没有)
|
||||
|
||||
对"真缺口"分两类:
|
||||
- **可补救**:简历里其实做过类似的事,只是没写出来 → 追问用户"你做过 X 吗?"
|
||||
- **不可补救**:用户确实没做过 → **不能编**,建议用户在 cover letter 或 summary 里诚实说明并强调 transferable skill
|
||||
|
||||
### Step 4: 定向改写
|
||||
|
||||
按以下原则重写简历:
|
||||
|
||||
**a. 重排经历顺序**:与 JD 最相关的工作 / 项目放最前(不改时间真实性,但可以把项目经历拆成两块"相关项目 / 其他项目")
|
||||
|
||||
**b. 重写每条 bullet**:
|
||||
- 把 JD 里的"职责动词"自然嵌入 bullet(如 JD 说"主导 ___ 系统设计",简历里就把"参与"改成"主导"——前提是用户确实主导了)
|
||||
- 数字保留并放大("用户 100 万"是好事,别藏起来)
|
||||
- 补 JD 关键词(如 JD 说"A/B 测试",但简历里写的是"灰度对比",改成"A/B 测试(灰度对比)")
|
||||
|
||||
**c. 重写 Summary**:用 2~3 行总结你为什么是这个岗位的合适人选,**直接对应 JD 的 must-have**
|
||||
|
||||
**d. 调整技能列表**:把 JD 提到的技能移到最前面(前提是真的会)
|
||||
|
||||
**e. 不改的事实**:
|
||||
- 公司名、岗位名、起止时间、学历 —— 一字不改
|
||||
- 项目规模、用户量、收入数据 —— 不能编,只能让用户确认后填准
|
||||
|
||||
### Step 5: 自检 + 报告
|
||||
|
||||
输出三个文件:
|
||||
1. `resume_tailored_<公司>_<岗位>.md`(改写后的简历)
|
||||
2. `gap_analysis.md`(gap 分析报告)
|
||||
3. 聊天里给一个 ATS 命中率对比:"改前 X% → 改后 Y%"
|
||||
|
||||
附:诚实提醒用户**哪些 bullet 是基于现有信息推测改写的**,让用户复核后再投。
|
||||
|
||||
---
|
||||
|
||||
## 反模式(不要做)
|
||||
|
||||
- ❌ 编造经历("加上一段你没做过的项目"——绝对禁止,哪怕用户要求)
|
||||
- ❌ 把 JD 的整段话直接粘进简历(很容易被 HR 一眼识破,且 ATS 反作弊会标记)
|
||||
- ❌ 关键词堆砌(在末尾塞一长串技能词凑命中率,HR 一眼能看出)
|
||||
- ❌ 把"参与"改成"主导"但没有问用户实际角色 → 必须先核实
|
||||
- ❌ 不给用户看 gap,自己默默改 → 用户会失去对简历的"理解"
|
||||
|
||||
## 与其他 skill 的协作
|
||||
|
||||
- 改完后用户说"帮我准备这家公司的面试" → 转 `interview-prep`,把 JD + 改写简历传过去
|
||||
- 用户说"我想知道还能投哪些类似的岗" → 转 `job-intent-tracker`
|
||||
- 用户说"我现在简历不太行,能不能整体重做" → 转 `resume-builder`
|
||||
79
skills/jd-resume-tailor/references/jd_parsing_signals.md
Executable file
79
skills/jd-resume-tailor/references/jd_parsing_signals.md
Executable file
@@ -0,0 +1,79 @@
|
||||
# JD 解析信号词参考
|
||||
|
||||
## must-have 强信号词
|
||||
|
||||
只要 JD 句子里出现这些词,后面的技能 / 经验大概率是硬门槛:
|
||||
|
||||
- 必须 / 必备 / 要求 / 应当
|
||||
- 至少 X 年 / X+ years / 不少于
|
||||
- "需要" / "需具备"
|
||||
- "必要条件" / "硬性要求"
|
||||
- 工科背景 / 985 / 211 / 硕士及以上 / 博士
|
||||
|
||||
英文 JD:
|
||||
- Required / Must have / Mandatory / Essential
|
||||
- Minimum X years
|
||||
- Strong proficiency in / Expert in
|
||||
- Demonstrated experience with
|
||||
|
||||
## nice-to-have 弱信号词
|
||||
|
||||
- 加分 / 优先 / 优先考虑
|
||||
- 熟悉 ___ 者优先
|
||||
- 有 ___ 经验者优先
|
||||
- "了解 ___ 即可"
|
||||
- 加分项
|
||||
|
||||
英文:
|
||||
- Preferred / Nice to have / Plus / Bonus / Desirable
|
||||
- Familiar with / Exposure to
|
||||
- A plus / Would be a plus
|
||||
|
||||
## 职责动词(写在 bullet 里能呼应 JD)
|
||||
|
||||
中文:负责、主导、推动、设计、搭建、优化、规划、迭代、孵化、复盘、运营、管理、协调、执行
|
||||
英文:Lead / Drive / Build / Design / Architect / Develop / Implement / Optimize / Manage / Coordinate / Execute / Own
|
||||
|
||||
## 特殊要求
|
||||
|
||||
- **学历**:本科 / 硕士 / 博士及以上;学校 tier
|
||||
- **工作年限**:X-Y 年(写明 range,不写满则有弹性)
|
||||
- **语言**:英语口语流利 / CET-6 / 雅思 X / 母语
|
||||
- **证书**:CFA / CPA / PMP / AWS Solutions Architect 等
|
||||
- **出差 / 派驻 / 加班**:"接受出差"、"奇偶周末调休"、"项目制 996"
|
||||
- **工作地点**:城市 + 是否 remote / hybrid
|
||||
|
||||
## 反信号(看到这些要警觉)
|
||||
|
||||
- "其他领导交办的任务" → 工作边界模糊
|
||||
- "良好的抗压能力" → 加班多
|
||||
- "拥抱变化 / 快速迭代" → 业务方向不稳定
|
||||
- "扁平化沟通 / 没有层级" → 实际可能更乱
|
||||
- "5 险一金 + 节日福利" 写在 JD 显眼位置 → 福利可能就这些
|
||||
|
||||
## 输出格式(Step 1 给用户看的)
|
||||
|
||||
```json
|
||||
{
|
||||
"company": "XX 公司",
|
||||
"position": "XX 岗位",
|
||||
"must_have": [
|
||||
{"item": "5 年以上 C 端产品经验", "evidence": "JD 第 X 行"},
|
||||
{"item": "熟练 SQL", "evidence": "JD 第 Y 行"}
|
||||
],
|
||||
"nice_to_have": [
|
||||
{"item": "海外业务经验", "evidence": "JD 第 Z 行"}
|
||||
],
|
||||
"soft_skills": ["跨部门推动", "数据驱动决策"],
|
||||
"responsibilities": [
|
||||
"主导 ___ 业务线产品规划",
|
||||
"通过数据分析驱动迭代"
|
||||
],
|
||||
"special_requirements": {
|
||||
"education": "本科及以上",
|
||||
"years": "5+",
|
||||
"language": "英语口语流利",
|
||||
"location": "上海,可接受短期出差"
|
||||
}
|
||||
}
|
||||
```
|
||||
75
skills/jd-resume-tailor/references/rewrite_principles.md
Executable file
75
skills/jd-resume-tailor/references/rewrite_principles.md
Executable file
@@ -0,0 +1,75 @@
|
||||
# 简历定向改写原则
|
||||
|
||||
## 核心原则
|
||||
|
||||
### 1. 不编造,只重组
|
||||
|
||||
简历里的事实部分(公司、岗位、时间、量化数字)**一字不改**。
|
||||
可以改的:
|
||||
- bullet 的措辞和顺序
|
||||
- 强调的角度(同一段经历,不同 JD 强调不同侧面)
|
||||
- 项目分类("相关项目"放前,"其他项目"放后)
|
||||
|
||||
### 2. 把 JD 的关键词嵌入到真实经验里,而不是单独贴
|
||||
|
||||
❌ 错:在简历末尾加一行"关键词:A/B 测试、用户增长、SQL、Python"
|
||||
✅ 对:把"我做过的灰度对比"改成"A/B 测试(灰度对比)",让关键词嵌在真实场景里
|
||||
|
||||
### 3. 用 JD 的动词
|
||||
|
||||
JD 说"主导 ___ 系统设计",如果你是这段经历的负责人,把"参与"改成"主导"。
|
||||
|
||||
但**前提是真的主导**——agent 在改写前必须确认:
|
||||
- 这段经历你是 leader 吗?是的话改"主导";不是就保留"参与"或写明"作为 _ 角色 协助 ___"
|
||||
|
||||
### 4. 重排经历顺序
|
||||
|
||||
工作经历(按时间倒序)—— 不可改,否则失实。
|
||||
项目经历 / 实习经历 —— 可以改,把与 JD 强相关的放前面。
|
||||
|
||||
更进一步:可以拆成两个小标题:
|
||||
```
|
||||
【相关项目】(与目标岗位强相关)
|
||||
- ...
|
||||
【其他项目】
|
||||
- ...
|
||||
```
|
||||
|
||||
### 5. Summary 要直接对位 JD must-have
|
||||
|
||||
JD 说"5 年 C 端产品 + SQL + A/B 测试"
|
||||
Summary 改成:"5 年 C 端互联网产品经验,主导过 ___ 增长项目,熟练使用 SQL 与 A/B 测试驱动决策"
|
||||
|
||||
### 6. 技能列表也要倾斜
|
||||
|
||||
JD 说"Python / Spark / Flink",简历技能列表把这三个放前面。
|
||||
不会的不要写。
|
||||
|
||||
### 7. 适度补 nice-to-have
|
||||
|
||||
如果 JD 的 nice-to-have 你**真的有**但简历没写,补上。
|
||||
如果没有,不补——nice-to-have 本来就不是硬门槛,不影响过简历筛选。
|
||||
|
||||
## 改写后的双向校验
|
||||
|
||||
每条改后 bullet 自问:
|
||||
- 这是不是用户真实做过的?
|
||||
- 这条 bullet 在 JD 里能找到对应的关键词 / 职责吗?
|
||||
- 量化数字有保留吗?
|
||||
|
||||
每段经历自问:
|
||||
- 是否覆盖了 JD 的至少 1 条 must-have?
|
||||
- 是否突出了用户在这段经历里"真正的高光"?
|
||||
|
||||
整份简历自问:
|
||||
- ATS 命中率有提升吗?
|
||||
- HR 看 30 秒能不能 get 到"为什么这个候选人适合这个岗位"?
|
||||
- 有没有让真实经历变形 / 失实?
|
||||
|
||||
## 当用户与 JD 严重不匹配时
|
||||
|
||||
诚实告诉用户:
|
||||
- "JD 里有 N 条 must-have 你的简历完全没有覆盖,这个岗位投递成功率较低"
|
||||
- "建议你考虑:(a) 投匹配度更高的类似岗位 (b) 在 cover letter 里诚实说明 transferable skill (c) 先补技能再投"
|
||||
|
||||
不要为了"看起来匹配"而扭曲简历。
|
||||
177
skills/jd-resume-tailor/scripts/jd_gap.py
Executable file
177
skills/jd-resume-tailor/scripts/jd_gap.py
Executable file
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
jd_gap.py — 把 parse_jd.py 的 JSON 与简历文本做 gap 分析
|
||||
|
||||
用法:
|
||||
python jd_gap.py --jd jd_parsed.json --resume resume.md --out gap.md
|
||||
|
||||
输出 markdown 报告:完美命中 / 隐性命中 / 真缺口 三类,附改写建议。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_resume_text(path: Path) -> str:
|
||||
suffix = path.suffix.lower()
|
||||
if suffix in {".md", ".txt"}:
|
||||
return path.read_text(encoding="utf-8")
|
||||
if suffix == ".docx":
|
||||
try:
|
||||
from docx import Document
|
||||
except ImportError:
|
||||
print(
|
||||
"✗ 缺少 python-docx:pip install python-docx --break-system-packages",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
doc = Document(str(path))
|
||||
return "\n".join(p.text for p in doc.paragraphs)
|
||||
print(f"✗ 暂不支持的格式:{suffix}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def find_evidence(resume_text: str, keyword: str, window: int = 30) -> str | None:
|
||||
"""在简历里找关键词,返回上下文片段;找不到返回 None。"""
|
||||
pattern = re.escape(keyword)
|
||||
m = re.search(pattern, resume_text, flags=re.IGNORECASE)
|
||||
if not m:
|
||||
return None
|
||||
start = max(0, m.start() - window)
|
||||
end = min(len(resume_text), m.end() + window)
|
||||
snippet = resume_text[start:end].replace("\n", " ").strip()
|
||||
return snippet
|
||||
|
||||
|
||||
def fuzzy_hit(resume_text: str, keyword: str) -> str | None:
|
||||
"""模糊命中:取关键词的中文 / 英文核心,做包含匹配。"""
|
||||
# 拿前 2 个字 / 前 5 个字符
|
||||
candidates = []
|
||||
if re.search(r"[一-龥]", keyword):
|
||||
if len(keyword) >= 4:
|
||||
candidates.append(keyword[:2])
|
||||
candidates.append(keyword[-2:])
|
||||
else:
|
||||
if len(keyword) >= 4:
|
||||
candidates.append(keyword[:4].lower())
|
||||
text_low = resume_text.lower()
|
||||
for c in candidates:
|
||||
if c and c in text_low:
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def analyze(jd: dict, resume_text: str) -> dict:
|
||||
perfect, implicit, missing = [], [], []
|
||||
|
||||
# 用 must_have 句子里抽出来的 skills 作为对比项
|
||||
candidates = jd.get("skills_extracted", []) + jd.get("must_have", [])
|
||||
|
||||
seen = set()
|
||||
for c in candidates:
|
||||
# 句子层面太长,截短
|
||||
keyword = c.strip()
|
||||
if len(keyword) > 30:
|
||||
# 从长句子里抽更短的关键词
|
||||
short_tokens = re.findall(
|
||||
r"[A-Za-z][A-Za-z0-9+/.\-_]{1,20}|[一-龥]{2,6}",
|
||||
keyword,
|
||||
)
|
||||
for t in short_tokens:
|
||||
if t.lower() not in seen:
|
||||
seen.add(t.lower())
|
||||
process_one(t, resume_text, perfect, implicit, missing)
|
||||
else:
|
||||
if keyword.lower() not in seen:
|
||||
seen.add(keyword.lower())
|
||||
process_one(keyword, resume_text, perfect, implicit, missing)
|
||||
|
||||
return {"perfect": perfect, "implicit": implicit, "missing": missing}
|
||||
|
||||
|
||||
def process_one(keyword, resume_text, perfect, implicit, missing):
|
||||
ev = find_evidence(resume_text, keyword)
|
||||
if ev:
|
||||
perfect.append({"keyword": keyword, "evidence": ev})
|
||||
return
|
||||
fuzzy = fuzzy_hit(resume_text, keyword)
|
||||
if fuzzy:
|
||||
implicit.append({"keyword": keyword, "fuzzy_match": fuzzy})
|
||||
else:
|
||||
missing.append(keyword)
|
||||
|
||||
|
||||
def render(jd: dict, gap: dict) -> str:
|
||||
lines = ["# JD ⇄ Resume Gap 分析报告", ""]
|
||||
|
||||
spec = jd.get("special_requirements", {})
|
||||
if spec:
|
||||
lines += ["## JD 硬条件", ""]
|
||||
for k, v in spec.items():
|
||||
lines.append(f"- **{k}**:{v}")
|
||||
lines.append("")
|
||||
|
||||
lines += ["## ✅ 完美命中(简历里有明确证据)", ""]
|
||||
if gap["perfect"]:
|
||||
for item in gap["perfect"][:30]:
|
||||
lines.append(f"- **{item['keyword']}** —— 证据:`...{item['evidence']}...`")
|
||||
else:
|
||||
lines.append("(无)")
|
||||
lines.append("")
|
||||
|
||||
lines += ["## 🟡 隐性命中(简历里有近似但用词不同,建议改写时对齐)", ""]
|
||||
if gap["implicit"]:
|
||||
for item in gap["implicit"][:20]:
|
||||
lines.append(f"- **JD 关键词:{item['keyword']}**(简历里出现:`{item['fuzzy_match']}`)")
|
||||
else:
|
||||
lines.append("(无)")
|
||||
lines.append("")
|
||||
|
||||
lines += ["## 🔴 真缺口(简历完全没有,需要确认 / 补充 / 转换叙事)", ""]
|
||||
if gap["missing"]:
|
||||
for kw in gap["missing"][:30]:
|
||||
lines.append(f"- **{kw}**")
|
||||
else:
|
||||
lines.append("(无)")
|
||||
lines.append("")
|
||||
|
||||
lines += [
|
||||
"---",
|
||||
"## 改写建议",
|
||||
"",
|
||||
"1. **完美命中** 的部分保留,但确保措辞与 JD 一致(比如 JD 用『A/B 测试』就别写『AB 实验』)",
|
||||
"2. **隐性命中** 是性价比最高的优化点 —— 把简历里的近义词改成 JD 的措辞",
|
||||
"3. **真缺口** 分两类:",
|
||||
" - 你做过但没写?→ 补到对应经历的 bullet 里",
|
||||
" - 你没做过?→ **不要编造**。可以在 cover letter / Summary 里诚实说明 transferable skill",
|
||||
"4. 把改后的简历再跑一次 ats_check.py 看命中率是否提升",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--jd", required=True, help="parse_jd.py 输出的 json")
|
||||
parser.add_argument("--resume", required=True, help="简历文件 (.md/.txt/.docx)")
|
||||
parser.add_argument("--out", help="输出 markdown 路径")
|
||||
args = parser.parse_args()
|
||||
|
||||
jd = json.loads(Path(args.jd).read_text(encoding="utf-8"))
|
||||
resume_text = load_resume_text(Path(args.resume))
|
||||
gap = analyze(jd, resume_text)
|
||||
report = render(jd, gap)
|
||||
|
||||
if args.out:
|
||||
Path(args.out).write_text(report, encoding="utf-8")
|
||||
print(f"✓ Gap 报告已生成:{args.out}")
|
||||
else:
|
||||
print(report)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
189
skills/jd-resume-tailor/scripts/parse_jd.py
Executable file
189
skills/jd-resume-tailor/scripts/parse_jd.py
Executable file
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
parse_jd.py — 解析 JD 文本,抽取 must-have / nice-to-have / 职责 / 特殊要求
|
||||
|
||||
用法:
|
||||
python parse_jd.py --jd-file jd.txt --out jd_parsed.json
|
||||
python parse_jd.py --jd-text "..." --out jd_parsed.json
|
||||
|
||||
输出 JSON 结构供下一步的 jd_gap.py 使用,也可以直接给用户看。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
MUST_PATTERNS = [
|
||||
r"必须", r"必备", r"必要条件", r"硬性要求", r"应当", r"需要", r"需具备",
|
||||
r"至少\s*\d+\s*年", r"\d+\+?\s*年以上",
|
||||
r"required", r"must\s*have", r"mandatory", r"essential",
|
||||
r"minimum\s+\d+\s+years",
|
||||
]
|
||||
NICE_PATTERNS = [
|
||||
r"加分", r"优先", r"加分项", r"熟悉.+者优先", r"有.+经验者优先",
|
||||
r"preferred", r"nice\s*to\s*have", r"plus", r"bonus", r"desirable",
|
||||
]
|
||||
ACTION_VERBS = [
|
||||
"负责", "主导", "推动", "设计", "搭建", "构建", "优化", "规划", "迭代",
|
||||
"孵化", "复盘", "运营", "管理", "协调", "执行", "驱动", "落地", "重构",
|
||||
"lead", "drive", "build", "design", "architect", "develop", "implement",
|
||||
"optimize", "manage", "coordinate", "execute", "own",
|
||||
]
|
||||
|
||||
|
||||
def split_sentences(text: str) -> list[str]:
|
||||
# 中文按 。!?; 拆,英文按 . ; 拆,并保留 bullet 行
|
||||
raw = re.split(r"[。!?!?;;\n]+", text)
|
||||
return [s.strip(" \t-•·*") for s in raw if s.strip()]
|
||||
|
||||
|
||||
def classify_sentences(sentences: list[str]) -> dict:
|
||||
must, nice, resp, others = [], [], [], []
|
||||
for s in sentences:
|
||||
s_low = s.lower()
|
||||
if any(re.search(p, s_low) for p in NICE_PATTERNS):
|
||||
nice.append(s)
|
||||
elif any(re.search(p, s_low) for p in MUST_PATTERNS):
|
||||
must.append(s)
|
||||
elif any(v in s_low for v in ACTION_VERBS):
|
||||
resp.append(s)
|
||||
else:
|
||||
others.append(s)
|
||||
return {"must": must, "nice": nice, "responsibilities": resp, "others": others}
|
||||
|
||||
|
||||
def extract_special(text: str) -> dict:
|
||||
out: dict[str, str] = {}
|
||||
|
||||
# 学历
|
||||
edu = re.search(r"(本科|硕士|博士|大专)(?:及以上|以上)?", text)
|
||||
if edu:
|
||||
out["education"] = edu.group(0)
|
||||
|
||||
# 工作年限
|
||||
years = re.search(r"(\d+)\s*[-~–到至]\s*(\d+)\s*年|(\d+)\s*\+?\s*年(以上|及以上)?", text)
|
||||
if years:
|
||||
out["years"] = years.group(0)
|
||||
|
||||
# 语言
|
||||
lang_pat = re.search(
|
||||
r"(英语\s*(口语)?\s*(流利|熟练|母语)|CET[-\s]?[46]|雅思\s*\d(\.\d)?|托福\s*\d{2,3}|母语水平|business\s*english)",
|
||||
text,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if lang_pat:
|
||||
out["language"] = lang_pat.group(0)
|
||||
|
||||
# 城市
|
||||
cities = re.findall(
|
||||
r"(北京|上海|广州|深圳|杭州|南京|苏州|成都|武汉|西安|香港|新加坡|remote|hybrid|远程|海外)",
|
||||
text,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if cities:
|
||||
out["location"] = "/".join(sorted(set(c.lower() for c in cities)))
|
||||
|
||||
# 出差 / 加班信号
|
||||
travel = re.search(r"(出差|派驻|常驻|项目制|加班|999|996|大小周)", text)
|
||||
if travel:
|
||||
out["working_style"] = travel.group(0)
|
||||
|
||||
# 证书
|
||||
certs = re.findall(
|
||||
r"(CFA(?:\s*Level\s*[I123]+)?|CPA|FRM|ACCA|PMP|AWS\s*[\w\s]*认证|Azure\s*[\w]*|GCP\s*[\w]*)",
|
||||
text,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if certs:
|
||||
out["certificates"] = "/".join(sorted(set(c.strip() for c in certs)))
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def extract_skills(sentences: list[str]) -> list[str]:
|
||||
"""从所有句子里抽取技能词候选(短词优先,避免抽出整句)。"""
|
||||
text = " ".join(sentences)
|
||||
# 英文技能(CamelCase 或大写开头的词、含 . 或 +/- 的标识)
|
||||
en = re.findall(r"\b[A-Za-z][A-Za-z0-9+/.\-_#]{1,20}\b", text)
|
||||
# 中文 2~5 字常见技能词
|
||||
zh = re.findall(r"[一-龥]{2,5}", text)
|
||||
raw = en + zh
|
||||
|
||||
stop = {
|
||||
# 中文虚词 / 通用动词
|
||||
"公司", "我们", "你将", "团队", "需要", "能够", "具备", "熟悉", "了解",
|
||||
"良好", "优秀", "经验", "能力", "岗位", "职责", "要求", "以上", "相关",
|
||||
"进行", "完成", "负责", "推动", "实现", "提升", "并且", "包括", "以下",
|
||||
"工作", "项目", "业务", "及其", "或者", "或", "与", "及", "的", "了",
|
||||
"并", "对", "在", "等", "以", "等等", "通过", "将", "其", "之", "至",
|
||||
"至上", "本科", "硕士", "博士", "者优先", "根据", "进行", "支持", "参与",
|
||||
"主导", "提供", "建立", "搭建", "设计", "驱动", "决策", "分析", "推动",
|
||||
"迭代", "规划", "运营", "协作", "跨部门", "跨团队", "高级", "资深",
|
||||
"若干", "多种", "多元", "多类",
|
||||
# 英文虚词
|
||||
"and", "the", "with", "for", "of", "or", "to", "be", "as", "an", "is",
|
||||
"are", "in", "on", "at", "by", "all", "you", "we", "us", "our", "your",
|
||||
"a", "an", "this", "that", "these", "those", "it", "its",
|
||||
}
|
||||
# 含数字的"X年""X个"也过滤
|
||||
digit_only = re.compile(r"^\d+$")
|
||||
|
||||
seen = set()
|
||||
out = []
|
||||
for token in raw:
|
||||
key = token.lower()
|
||||
if key in seen or token in stop or len(token) < 2 or digit_only.match(token):
|
||||
continue
|
||||
# 中文 token 不允许全是 stop 词的子串
|
||||
seen.add(key)
|
||||
out.append(token)
|
||||
return out[:60]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
src = parser.add_mutually_exclusive_group(required=True)
|
||||
src.add_argument("--jd-file", help="JD 文本文件路径")
|
||||
src.add_argument("--jd-text", help="直接传 JD 文本")
|
||||
parser.add_argument("--out", help="输出 JSON 路径,缺省打印")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.jd_file:
|
||||
path = Path(args.jd_file).expanduser()
|
||||
if not path.exists():
|
||||
print(f"✗ JD 文件不存在:{path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
text = path.read_text(encoding="utf-8")
|
||||
else:
|
||||
text = args.jd_text
|
||||
|
||||
sentences = split_sentences(text)
|
||||
classified = classify_sentences(sentences)
|
||||
special = extract_special(text)
|
||||
skills = extract_skills(classified["must"] + classified["responsibilities"])
|
||||
|
||||
result = {
|
||||
"must_have": classified["must"],
|
||||
"nice_to_have": classified["nice"],
|
||||
"responsibilities": classified["responsibilities"],
|
||||
"skills_extracted": skills,
|
||||
"special_requirements": special,
|
||||
"raw_sentence_count": len(sentences),
|
||||
}
|
||||
|
||||
payload = json.dumps(result, ensure_ascii=False, indent=2)
|
||||
if args.out:
|
||||
out_path = Path(args.out).expanduser()
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(payload, encoding="utf-8")
|
||||
print(f"✓ JD 解析结果已保存:{out_path}")
|
||||
else:
|
||||
print(payload)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user