knowme-careerforge-skill 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +227 -0
- package/README.zh-CN.md +231 -0
- package/SKILL.md +179 -0
- package/agents/claude/knowme-careerforge.md +16 -0
- package/agents/codex/knowme-careerforge.yaml +15 -0
- package/agents/cursor/knowme-careerforge.mdc +12 -0
- package/agents/gemini/knowme-careerforge.json +6 -0
- package/agents/opencode/skill.yaml +11 -0
- package/agents/windsurf/knowme-careerforge.rules +10 -0
- package/bin/knowme.js +32 -0
- package/dist/cli/src/commands/init.d.ts +10 -0
- package/dist/cli/src/commands/init.js +130 -0
- package/dist/cli/src/commands/list.d.ts +5 -0
- package/dist/cli/src/commands/list.js +92 -0
- package/dist/cli/src/index.d.ts +6 -0
- package/dist/cli/src/index.js +149 -0
- package/dist/scripts/render-pdf.d.ts +11 -0
- package/dist/scripts/render-pdf.js +105 -0
- package/dist/scripts/validate-ats.d.ts +23 -0
- package/dist/scripts/validate-ats.js +169 -0
- package/dist/scripts/validate-layout.d.ts +24 -0
- package/dist/scripts/validate-layout.js +161 -0
- package/package.json +58 -0
- package/scripts/analyze-jd.py +108 -0
- package/scripts/build-gallery.py +233 -0
- package/scripts/build-knowledge.py +81 -0
- package/scripts/instantiate-resume.py +83 -0
- package/scripts/release.py +101 -0
- package/scripts/render-pdf.ts +86 -0
- package/scripts/run-all-tests.py +41 -0
- package/scripts/search-template.py +147 -0
- package/scripts/sync-version.py +57 -0
- package/scripts/validate-ats.ts +173 -0
- package/scripts/validate-layout.ts +161 -0
- package/scripts/validate-resume.py +139 -0
- package/skill.json +47 -0
- package/src/knowledge/ats-rules.json +37 -0
- package/src/knowledge/career/seniority-signals.json +24 -0
- package/src/knowledge/index.json +734 -0
- package/src/knowledge/industries/enterprise-saas.json +21 -0
- package/src/knowledge/industries/fintech.json +21 -0
- package/src/knowledge/industries/tech-ai.json +21 -0
- package/src/knowledge/layouts.json +59 -0
- package/src/knowledge/resume/action-verbs.json +20 -0
- package/src/knowledge/resume/fab-model.json +22 -0
- package/src/knowledge/roles/ai-agent-engineer.json +52 -0
- package/src/knowledge/roles/android-engineer.json +45 -0
- package/src/knowledge/roles/architect.json +48 -0
- package/src/knowledge/roles/cpp-systems.json +47 -0
- package/src/knowledge/roles/frontend.json +49 -0
- package/src/knowledge/roles/ios-engineer.json +45 -0
- package/src/knowledge/roles/java-backend.json +49 -0
- package/src/knowledge/roles/node-fullstack.json +49 -0
- package/src/knowledge/roles/product-manager.json +48 -0
- package/src/knowledge/skills/skill-matrix.json +29 -0
- package/src/knowledge/styles.json +73 -0
- package/src/knowledge/templates.json +255 -0
- package/src/references/ats.md +17 -0
- package/src/references/evidence-first.md +22 -0
- package/src/references/pdf.md +40 -0
- package/src/references/resume-writing.md +25 -0
- package/src/references/template-design.md +57 -0
- package/src/templates/classic/README.md +12 -0
- package/src/templates/classic/metadata.json +34 -0
- package/src/templates/classic/style.css +164 -0
- package/src/templates/classic/template.html +133 -0
- package/src/templates/common/resume-contract.md +69 -0
- package/src/templates/executive/README.md +12 -0
- package/src/templates/executive/metadata.json +37 -0
- package/src/templates/executive/style.css +235 -0
- package/src/templates/executive/template.html +101 -0
- package/src/templates/minimal/README.md +12 -0
- package/src/templates/minimal/metadata.json +37 -0
- package/src/templates/minimal/style.css +219 -0
- package/src/templates/minimal/template.html +114 -0
- package/src/templates/modern/README.md +12 -0
- package/src/templates/modern/metadata.json +38 -0
- package/src/templates/modern/style.css +277 -0
- package/src/templates/modern/template.html +139 -0
- package/src/workflows/analyze-job.md +16 -0
- package/src/workflows/define-goal.md +16 -0
- package/src/workflows/forge-resume.md +25 -0
- package/src/workflows/know-me.md +21 -0
- package/src/workflows/position.md +19 -0
- package/src/workflows/review.md +31 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
KnowMe CareerForge — Template Search & Ranking Engine (BM25 + Multi-criteria scoring)
|
|
4
|
+
根据目标岗位、期望版式风格、目标页数与信息密度,加权检索最匹配的 HTML 简历模板。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys, os, json, argparse
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
def load_templates():
|
|
11
|
+
base_dir = Path(__file__).resolve().parent.parent / "src" / "templates"
|
|
12
|
+
templates = []
|
|
13
|
+
if not base_dir.exists():
|
|
14
|
+
return templates
|
|
15
|
+
|
|
16
|
+
for t_dir in sorted(base_dir.iterdir()):
|
|
17
|
+
if not t_dir.is_dir() or t_dir.name == "common":
|
|
18
|
+
continue
|
|
19
|
+
meta_file = t_dir / "metadata.json"
|
|
20
|
+
if meta_file.exists():
|
|
21
|
+
try:
|
|
22
|
+
data = json.loads(meta_file.read_text(encoding="utf-8"))
|
|
23
|
+
data["directory"] = f"src/templates/{t_dir.name}"
|
|
24
|
+
templates.append(data)
|
|
25
|
+
except Exception as e:
|
|
26
|
+
pass
|
|
27
|
+
return templates
|
|
28
|
+
|
|
29
|
+
def calculate_match_score(template, query_role, query_style=None, target_pages=1, density="balanced"):
|
|
30
|
+
score = 0.0
|
|
31
|
+
|
|
32
|
+
# 1. 岗位匹配度 (Role Match - 35%)
|
|
33
|
+
role_score = 0.0
|
|
34
|
+
query_lower = query_role.lower()
|
|
35
|
+
supported = [r.lower() for r in template.get("supportedRoles", [])]
|
|
36
|
+
|
|
37
|
+
for r in supported:
|
|
38
|
+
if r in query_lower or query_lower in r:
|
|
39
|
+
role_score = 1.0
|
|
40
|
+
break
|
|
41
|
+
|
|
42
|
+
if role_score < 0.5:
|
|
43
|
+
cat = template.get("roleCategory", "")
|
|
44
|
+
if "tech" in query_lower or "engineer" in query_lower or "ai" in query_lower or "研发" in query_lower:
|
|
45
|
+
role_score = 0.90 if cat == "engineering-ai" else 0.40
|
|
46
|
+
elif "manage" in query_lower or "lead" in query_lower or "director" in query_lower or "总监" in query_lower or "架构" in query_lower or "产品" in query_lower:
|
|
47
|
+
role_score = 0.90 if cat == "management-product" else 0.50
|
|
48
|
+
else:
|
|
49
|
+
role_score = 0.60
|
|
50
|
+
|
|
51
|
+
score += role_score * 0.35
|
|
52
|
+
|
|
53
|
+
# 2. 风格匹配度 (Style Match - 25%)
|
|
54
|
+
style_score = 0.70
|
|
55
|
+
if query_style:
|
|
56
|
+
q_style_lower = query_style.lower()
|
|
57
|
+
t_style = template.get("style", "").lower()
|
|
58
|
+
t_id = template.get("id", "").lower()
|
|
59
|
+
if q_style_lower in t_style or q_style_lower in t_id:
|
|
60
|
+
style_score = 1.0
|
|
61
|
+
elif "single" in q_style_lower and "single" in t_style:
|
|
62
|
+
style_score = 1.0
|
|
63
|
+
elif "split" in q_style_lower or "double" in q_style_lower or "column" in q_style_lower:
|
|
64
|
+
style_score = 1.0 if "two-column" in t_style else 0.50
|
|
65
|
+
elif "table" in q_style_lower or "grid" in q_style_lower:
|
|
66
|
+
style_score = 1.0 if "table" in t_style else 0.40
|
|
67
|
+
else:
|
|
68
|
+
style_score = 0.50
|
|
69
|
+
score += style_score * 0.25
|
|
70
|
+
|
|
71
|
+
# 3. ATS 兼容评级 (ATS Tier - 20%)
|
|
72
|
+
ats_tier = template.get("atsScoreTier", "tier-1-optimal")
|
|
73
|
+
ats_score = 1.0 if "optimal" in ats_tier or "tier-1" in ats_tier else 0.85
|
|
74
|
+
score += ats_score * 0.20
|
|
75
|
+
|
|
76
|
+
# 4. 页数适配度 (Page Fit - 10%)
|
|
77
|
+
target_p = template.get("layout", {}).get("targetPages", 1)
|
|
78
|
+
max_p = template.get("layout", {}).get("maxPages", 2)
|
|
79
|
+
if target_pages == target_p:
|
|
80
|
+
page_score = 1.0
|
|
81
|
+
elif target_pages <= max_p:
|
|
82
|
+
page_score = 0.80
|
|
83
|
+
else:
|
|
84
|
+
page_score = 0.50
|
|
85
|
+
score += page_score * 0.10
|
|
86
|
+
|
|
87
|
+
# 5. 信息密度偏好 (Density - 10%)
|
|
88
|
+
t_density = template.get("layout", {}).get("density", "balanced")
|
|
89
|
+
density_score = 1.0 if density == t_density else 0.80
|
|
90
|
+
score += density_score * 0.10
|
|
91
|
+
|
|
92
|
+
return round(score * 100, 1)
|
|
93
|
+
|
|
94
|
+
def search_templates(role, style=None, target_pages=1, density="balanced"):
|
|
95
|
+
templates = load_templates()
|
|
96
|
+
if not templates:
|
|
97
|
+
return []
|
|
98
|
+
|
|
99
|
+
results = []
|
|
100
|
+
for t in templates:
|
|
101
|
+
s = calculate_match_score(t, role, style, target_pages, density)
|
|
102
|
+
results.append({
|
|
103
|
+
"score": s,
|
|
104
|
+
"template": t
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
results.sort(key=lambda x: x["score"], reverse=True)
|
|
108
|
+
return results
|
|
109
|
+
|
|
110
|
+
def main():
|
|
111
|
+
parser = argparse.ArgumentParser(description="KnowMe CareerForge — Template Search Engine")
|
|
112
|
+
parser.add_argument("role", nargs="?", default="AI Agent Engineer", help="Target role title (e.g. 'AI Agent Engineer', 'Frontend')")
|
|
113
|
+
parser.add_argument("--style", default=None, help="Style filter: minimal, modern, executive, classic, single-column, two-column, table")
|
|
114
|
+
parser.add_argument("--target-pages", type=int, default=1, help="Expected page count (1 or 2)")
|
|
115
|
+
parser.add_argument("--density", default="balanced", choices=["high", "balanced", "relaxed"], help="Information density")
|
|
116
|
+
parser.add_argument("--json", action="store_true", help="Output results in JSON format")
|
|
117
|
+
|
|
118
|
+
args = parser.parse_args()
|
|
119
|
+
|
|
120
|
+
results = search_templates(args.role, args.style, args.target_pages, args.density)
|
|
121
|
+
|
|
122
|
+
if args.json:
|
|
123
|
+
print(json.dumps(results, ensure_ascii=False, indent=2))
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
print("=" * 80)
|
|
127
|
+
print(f" KnowMe CareerForge — Template Search Results")
|
|
128
|
+
print(f" Target Role : {args.role}")
|
|
129
|
+
print(f" Style Pref : {args.style or 'Any'}")
|
|
130
|
+
print(f" Target Pages: {args.target_pages} | Density: {args.density}")
|
|
131
|
+
print("=" * 80)
|
|
132
|
+
|
|
133
|
+
if not results:
|
|
134
|
+
print("No matching templates found.")
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
for idx, r in enumerate(results, 1):
|
|
138
|
+
t = r["template"]
|
|
139
|
+
print(f"{idx}. [Score: {r['score']}] {t.get('name')} (ID: {t.get('id')})")
|
|
140
|
+
print(f" Category: {t.get('roleCategory')} | Style: {t.get('style')} | ATS Tier: {t.get('atsScoreTier')}")
|
|
141
|
+
print(f" Target Pages: {t.get('layout', {}).get('targetPages')} (Max: {t.get('layout', {}).get('maxPages')}) | Density: {t.get('layout', {}).get('density')}")
|
|
142
|
+
print(f" Customizable Tokens: {', '.join(t.get('customizableTokens', [])[:4])}")
|
|
143
|
+
print(f" Directory: {t.get('directory')}")
|
|
144
|
+
print("-" * 80)
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
main()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
KnowMe CareerForge — Version Synchronization Engine
|
|
4
|
+
将项目根版本号统一同步至 package.json, pyproject.toml, skill.json 与 cli/package.json。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys, os, json, re, argparse
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
def sync_version(new_version: str):
|
|
11
|
+
base_dir = Path(__file__).resolve().parent.parent
|
|
12
|
+
print("==============================================================")
|
|
13
|
+
print(f" KnowMe CareerForge — Synchronizing Version to v{new_version}")
|
|
14
|
+
print("==============================================================")
|
|
15
|
+
|
|
16
|
+
# 1. Update package.json
|
|
17
|
+
pkg_json_path = base_dir / "package.json"
|
|
18
|
+
if pkg_json_path.exists():
|
|
19
|
+
data = json.loads(pkg_json_path.read_text(encoding="utf-8"))
|
|
20
|
+
data["version"] = new_version
|
|
21
|
+
pkg_json_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
22
|
+
print(f"[✓] Synced package.json -> {new_version}")
|
|
23
|
+
|
|
24
|
+
# 2. Update cli/package.json
|
|
25
|
+
cli_pkg_path = base_dir / "cli" / "package.json"
|
|
26
|
+
if cli_pkg_path.exists():
|
|
27
|
+
data = json.loads(cli_pkg_path.read_text(encoding="utf-8"))
|
|
28
|
+
data["version"] = new_version
|
|
29
|
+
cli_pkg_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
30
|
+
print(f"[✓] Synced cli/package.json -> {new_version}")
|
|
31
|
+
|
|
32
|
+
# 3. Update skill.json
|
|
33
|
+
skill_json_path = base_dir / "skill.json"
|
|
34
|
+
if skill_json_path.exists():
|
|
35
|
+
data = json.loads(skill_json_path.read_text(encoding="utf-8"))
|
|
36
|
+
data["version"] = new_version
|
|
37
|
+
skill_json_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
38
|
+
print(f"[✓] Synced skill.json -> {new_version}")
|
|
39
|
+
|
|
40
|
+
# 4. Update pyproject.toml
|
|
41
|
+
pyproj_path = base_dir / "pyproject.toml"
|
|
42
|
+
if pyproj_path.exists():
|
|
43
|
+
text = pyproj_path.read_text(encoding="utf-8")
|
|
44
|
+
new_text = re.sub(r'version\s*=\s*"[^"]+"', f'version = "{new_version}"', text)
|
|
45
|
+
pyproj_path.write_text(new_text, encoding="utf-8")
|
|
46
|
+
print(f"[✓] Synced pyproject.toml -> {new_version}")
|
|
47
|
+
|
|
48
|
+
print("\n[✓] All package versions successfully synchronized!")
|
|
49
|
+
|
|
50
|
+
def main():
|
|
51
|
+
parser = argparse.ArgumentParser(description="Synchronize project version")
|
|
52
|
+
parser.add_argument("version", nargs="?", default="1.0.0", help="New semantic version (e.g. 1.0.1)")
|
|
53
|
+
args = parser.parse_args()
|
|
54
|
+
sync_version(args.version)
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
main()
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env ts-node
|
|
2
|
+
/**
|
|
3
|
+
* KnowMe CareerForge — ATS Text Flow & Heading Hierarchy Inspector
|
|
4
|
+
* 验证 HTML 简历中的关键文本可提取性、标头层级规范与 ATS 机器解析友好度。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { chromium } from 'playwright';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
import * as fs from 'fs';
|
|
10
|
+
|
|
11
|
+
interface AtsValidationResult {
|
|
12
|
+
status: 'PASS' | 'WARN' | 'FAIL';
|
|
13
|
+
candidateName: string | null;
|
|
14
|
+
detectedContacts: {
|
|
15
|
+
phone: string | null;
|
|
16
|
+
email: string | null;
|
|
17
|
+
};
|
|
18
|
+
headingsFound: Array<{
|
|
19
|
+
level: string;
|
|
20
|
+
text: string;
|
|
21
|
+
isStandard: boolean;
|
|
22
|
+
}>;
|
|
23
|
+
totalTextLength: number;
|
|
24
|
+
warnings: string[];
|
|
25
|
+
errors: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const STANDARD_SECTION_HEADERS = [
|
|
29
|
+
'工作经历', '工作经验', 'work experience', 'experience',
|
|
30
|
+
'项目经历', '核心项目', 'projects', 'key projects',
|
|
31
|
+
'专业技能', '核心技能', 'skills', 'technical skills',
|
|
32
|
+
'教育背景', '教育经历', 'education',
|
|
33
|
+
'核心领导力', '重大经历', '资质与证书', '求职意向'
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
export async function validateAts(htmlPath: string): Promise<AtsValidationResult> {
|
|
37
|
+
const absoluteHtml = path.resolve(htmlPath);
|
|
38
|
+
if (!fs.existsSync(absoluteHtml)) {
|
|
39
|
+
throw new Error(`HTML file not found: ${absoluteHtml}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const browser = await chromium.launch({ headless: true });
|
|
43
|
+
const page = await browser.newPage();
|
|
44
|
+
await page.goto(`file://${absoluteHtml}`, { waitUntil: 'networkidle' });
|
|
45
|
+
|
|
46
|
+
const rawData = await page.evaluate(() => {
|
|
47
|
+
const nameEl = document.querySelector('.candidate-name, h1');
|
|
48
|
+
const name = nameEl ? (nameEl.textContent || '').trim() : null;
|
|
49
|
+
|
|
50
|
+
const fullText = (document.body.innerText || '').trim();
|
|
51
|
+
|
|
52
|
+
// 提取电话与邮箱
|
|
53
|
+
const phoneMatch = fullText.match(/(?:(?:\+|00)86)?1[3-9]\d{9}|(?:1[3-9]\d-\d{4}-\d{4})/);
|
|
54
|
+
const emailMatch = fullText.match(/[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+/);
|
|
55
|
+
|
|
56
|
+
const headings = Array.from(document.querySelectorAll('h1, h2, h3, .section-title, .sub-title')).map(h => ({
|
|
57
|
+
level: h.tagName.toLowerCase(),
|
|
58
|
+
text: (h.textContent || '').trim()
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
candidateName: name,
|
|
63
|
+
phone: phoneMatch ? phoneMatch[0] : null,
|
|
64
|
+
email: emailMatch ? emailMatch[0] : null,
|
|
65
|
+
headings,
|
|
66
|
+
fullTextLength: fullText.length
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await browser.close();
|
|
71
|
+
|
|
72
|
+
const warnings: string[] = [];
|
|
73
|
+
const errors: string[] = [];
|
|
74
|
+
|
|
75
|
+
if (!rawData.candidateName) {
|
|
76
|
+
errors.push('Candidate name (.candidate-name or h1) could not be extracted');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!rawData.email) {
|
|
80
|
+
warnings.push('No valid email address pattern detected in text flow');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!rawData.phone) {
|
|
84
|
+
warnings.push('No standard mobile phone pattern detected in text flow');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const validatedHeadings = rawData.headings.map(h => {
|
|
88
|
+
const textClean = h.text.toLowerCase().replace(/[^\w\u4e00-\u9fa5]/g, '');
|
|
89
|
+
const isStd = STANDARD_SECTION_HEADERS.some(s => textClean.includes(s.toLowerCase().replace(/\s+/g, '')));
|
|
90
|
+
return {
|
|
91
|
+
level: h.level,
|
|
92
|
+
text: h.text,
|
|
93
|
+
isStandard: isStd
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const nonStdHeadings = validatedHeadings.filter(h => !h.isStandard && h.level === 'h2');
|
|
98
|
+
if (nonStdHeadings.length > 0) {
|
|
99
|
+
warnings.push(`Non-standard section headings detected: ${nonStdHeadings.map(h => `"${h.text}"`).join(', ')}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const status: 'PASS' | 'WARN' | 'FAIL' = errors.length > 0 ? 'FAIL' : warnings.length > 0 ? 'WARN' : 'PASS';
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
status,
|
|
106
|
+
candidateName: rawData.candidateName,
|
|
107
|
+
detectedContacts: {
|
|
108
|
+
phone: rawData.phone,
|
|
109
|
+
email: rawData.email
|
|
110
|
+
},
|
|
111
|
+
headingsFound: validatedHeadings,
|
|
112
|
+
totalTextLength: rawData.fullTextLength,
|
|
113
|
+
warnings,
|
|
114
|
+
errors
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function main() {
|
|
119
|
+
const args = process.argv.slice(2);
|
|
120
|
+
let htmlPath = 'workspace/resume.html';
|
|
121
|
+
let jsonOutput = false;
|
|
122
|
+
|
|
123
|
+
for (let i = 0; i < args.length; i++) {
|
|
124
|
+
if (args[i] === '--html' || args[i] === '-i') {
|
|
125
|
+
htmlPath = args[i + 1];
|
|
126
|
+
i++;
|
|
127
|
+
} else if (args[i] === '--json') {
|
|
128
|
+
jsonOutput = true;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const result = await validateAts(htmlPath);
|
|
134
|
+
|
|
135
|
+
if (jsonOutput) {
|
|
136
|
+
console.log(JSON.stringify(result, null, 2));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
console.log('======================================================');
|
|
141
|
+
console.log(` KnowMe CareerForge — ATS Compliance QA: [${result.status}]`);
|
|
142
|
+
console.log(` Candidate Name : ${result.candidateName || 'N/A'}`);
|
|
143
|
+
console.log(` Phone Extracted: ${result.detectedContacts.phone || 'None'}`);
|
|
144
|
+
console.log(` Email Extracted: ${result.detectedContacts.email || 'None'}`);
|
|
145
|
+
console.log(` Text Extracted : ${result.totalTextLength} characters`);
|
|
146
|
+
console.log('------------------------------------------------------');
|
|
147
|
+
console.log(' Headings Structure:');
|
|
148
|
+
for (const h of result.headingsFound) {
|
|
149
|
+
const tag = h.isStandard ? '✓' : '?';
|
|
150
|
+
console.log(` [${tag}] <${h.level}> ${h.text}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (result.errors.length > 0) {
|
|
154
|
+
console.log('\n[!] Errors:');
|
|
155
|
+
for (const e of result.errors) console.log(` - ✗ ${e}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (result.warnings.length > 0) {
|
|
159
|
+
console.log('\n[?] Warnings:');
|
|
160
|
+
for (const w of result.warnings) console.log(` - ⚠️ ${w}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
console.log('======================================================');
|
|
164
|
+
} catch (err: unknown) {
|
|
165
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
166
|
+
console.error(`[✗] ATS Validation Failed: ${msg}`);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (require.main === module) {
|
|
172
|
+
main();
|
|
173
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env ts-node
|
|
2
|
+
/**
|
|
3
|
+
* KnowMe CareerForge — Playwright DOM Layout & Overflow Inspector
|
|
4
|
+
* 基于无头浏览器真实渲染计算 DOM 盒模型高度,检测单页/双页物理 A4 溢出与孤行。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { chromium } from 'playwright';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
import * as fs from 'fs';
|
|
10
|
+
|
|
11
|
+
interface LayoutValidationResult {
|
|
12
|
+
status: 'PASS' | 'OVERFLOW';
|
|
13
|
+
expectedPages: number;
|
|
14
|
+
totalPagesFound: number;
|
|
15
|
+
pageDetails: Array<{
|
|
16
|
+
pageIndex: number;
|
|
17
|
+
measuredHeightPx: number;
|
|
18
|
+
allowedHeightPx: number;
|
|
19
|
+
overflowPx: number;
|
|
20
|
+
isOverflow: boolean;
|
|
21
|
+
}>;
|
|
22
|
+
overflowSections: Array<{
|
|
23
|
+
selector: string;
|
|
24
|
+
heightPx: number;
|
|
25
|
+
textPreview: string;
|
|
26
|
+
}>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function validateLayout(htmlPath: string, expectedPages: number = 1): Promise<LayoutValidationResult> {
|
|
30
|
+
const absoluteHtml = path.resolve(htmlPath);
|
|
31
|
+
if (!fs.existsSync(absoluteHtml)) {
|
|
32
|
+
throw new Error(`HTML file not found: ${absoluteHtml}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const browser = await chromium.launch({ headless: true });
|
|
36
|
+
const context = await browser.newContext({
|
|
37
|
+
viewport: { width: 794, height: 1123 }, // A4 at 96 DPI
|
|
38
|
+
deviceScaleFactor: 2
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const page = await context.newPage();
|
|
42
|
+
await page.goto(`file://${absoluteHtml}`, { waitUntil: 'networkidle' });
|
|
43
|
+
await page.evaluateHandle('document.fonts.ready');
|
|
44
|
+
|
|
45
|
+
// A4 标准高度 (297mm in pixels at 96 DPI ~= 1122.5px)
|
|
46
|
+
const A4_HEIGHT_PX = 1122.5;
|
|
47
|
+
|
|
48
|
+
const evaluation = await page.evaluate((allowedHeight) => {
|
|
49
|
+
const pages = Array.from(document.querySelectorAll('.resume-page'));
|
|
50
|
+
const details = pages.map((el, index) => {
|
|
51
|
+
const rect = el.getBoundingClientRect();
|
|
52
|
+
const scrollH = el.scrollHeight;
|
|
53
|
+
const actualH = Math.max(rect.height, scrollH);
|
|
54
|
+
const diff = actualH - allowedHeight;
|
|
55
|
+
return {
|
|
56
|
+
pageIndex: index + 1,
|
|
57
|
+
measuredHeightPx: Math.round(actualH * 10) / 10,
|
|
58
|
+
allowedHeightPx: allowedHeight,
|
|
59
|
+
overflowPx: diff > 2 ? Math.round(diff * 10) / 10 : 0,
|
|
60
|
+
isOverflow: diff > 2
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const overflows: Array<{ selector: string; heightPx: number; textPreview: string }> = [];
|
|
65
|
+
if (details.some(d => d.isOverflow)) {
|
|
66
|
+
const sections = Array.from(document.querySelectorAll('.resume-section, .timeline-item, .milestone-item, .experience-item, tr'));
|
|
67
|
+
for (const s of sections) {
|
|
68
|
+
const r = s.getBoundingClientRect();
|
|
69
|
+
if (r.bottom > allowedHeight) {
|
|
70
|
+
overflows.push({
|
|
71
|
+
selector: s.className || s.tagName.toLowerCase(),
|
|
72
|
+
heightPx: Math.round(r.height),
|
|
73
|
+
textPreview: (s.textContent || '').trim().slice(0, 60)
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
totalPagesFound: pages.length,
|
|
81
|
+
pageDetails: details,
|
|
82
|
+
overflowSections: overflows
|
|
83
|
+
};
|
|
84
|
+
}, A4_HEIGHT_PX);
|
|
85
|
+
|
|
86
|
+
await browser.close();
|
|
87
|
+
|
|
88
|
+
const hasOverflow = evaluation.pageDetails.some(p => p.isOverflow) || evaluation.totalPagesFound > expectedPages;
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
status: hasOverflow ? 'OVERFLOW' : 'PASS',
|
|
92
|
+
expectedPages,
|
|
93
|
+
totalPagesFound: evaluation.totalPagesFound,
|
|
94
|
+
pageDetails: evaluation.pageDetails,
|
|
95
|
+
overflowSections: evaluation.overflowSections
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function main() {
|
|
100
|
+
const args = process.argv.slice(2);
|
|
101
|
+
let htmlPath = 'workspace/resume.html';
|
|
102
|
+
let expectedPages = 1;
|
|
103
|
+
let jsonOutput = false;
|
|
104
|
+
|
|
105
|
+
for (let i = 0; i < args.length; i++) {
|
|
106
|
+
if (args[i] === '--html' || args[i] === '-i') {
|
|
107
|
+
htmlPath = args[i + 1];
|
|
108
|
+
i++;
|
|
109
|
+
} else if (args[i] === '--expected-pages' || args[i] === '-p') {
|
|
110
|
+
expectedPages = parseInt(args[i + 1], 10) || 1;
|
|
111
|
+
i++;
|
|
112
|
+
} else if (args[i] === '--json') {
|
|
113
|
+
jsonOutput = true;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const result = await validateLayout(htmlPath, expectedPages);
|
|
119
|
+
|
|
120
|
+
if (jsonOutput) {
|
|
121
|
+
console.log(JSON.stringify(result, null, 2));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
console.log('======================================================');
|
|
126
|
+
console.log(` KnowMe CareerForge — Layout QA: ${result.status}`);
|
|
127
|
+
console.log(` HTML Path : ${htmlPath}`);
|
|
128
|
+
console.log(` Expected Pages : ${result.expectedPages} | Pages Found: ${result.totalPagesFound}`);
|
|
129
|
+
console.log('------------------------------------------------------');
|
|
130
|
+
|
|
131
|
+
for (const p of result.pageDetails) {
|
|
132
|
+
const statusSymbol = p.isOverflow ? '✗ OVERFLOW' : '✓ FIT';
|
|
133
|
+
console.log(` Page ${p.pageIndex}: ${p.measuredHeightPx}px / ${p.allowedHeightPx}px [${statusSymbol}] (Overflow: ${p.overflowPx}px)`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (result.overflowSections.length > 0) {
|
|
137
|
+
console.log('\n[!] Nodes exceeding A4 physical boundary:');
|
|
138
|
+
for (const o of result.overflowSections) {
|
|
139
|
+
console.log(` - <${o.selector}> (${o.heightPx}px): "${o.textPreview}..."`);
|
|
140
|
+
}
|
|
141
|
+
console.log('\n[Self-Healing Guidance]:');
|
|
142
|
+
console.log(' 1. Reduce --resume-space-section by 1~2pt in workspace/resume.html <style>');
|
|
143
|
+
console.log(' 2. Reduce --resume-font-size-body by 0.2pt');
|
|
144
|
+
console.log(' 3. Condense verbose bullet points');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
console.log('======================================================');
|
|
148
|
+
|
|
149
|
+
if (result.status === 'OVERFLOW') {
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
} catch (err: unknown) {
|
|
153
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
154
|
+
console.error(`[✗] Layout Validation Failed: ${msg}`);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (require.main === module) {
|
|
160
|
+
main();
|
|
161
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
KnowMe CareerForge — Resume Validation Engine (Layout, Schema & ATS Compliance)
|
|
4
|
+
验证 HTML 工作区简历的语义结构、Design Tokens 完整度与单页/双页排版合规性。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys, os, json, argparse, re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
def validate_resume_html(html_path: str, expected_pages: int = 1) -> dict:
|
|
11
|
+
p = Path(html_path)
|
|
12
|
+
if not p.exists():
|
|
13
|
+
# 尝试相对于脚本目录上一级
|
|
14
|
+
alt = Path(__file__).resolve().parent.parent / html_path
|
|
15
|
+
if alt.exists():
|
|
16
|
+
p = alt
|
|
17
|
+
else:
|
|
18
|
+
return {
|
|
19
|
+
"status": "FAIL",
|
|
20
|
+
"file": html_path,
|
|
21
|
+
"expectedPages": expected_pages,
|
|
22
|
+
"checks": {},
|
|
23
|
+
"errors": [f"HTML file not found at: {html_path}"],
|
|
24
|
+
"warnings": []
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
content = p.read_text(encoding="utf-8")
|
|
28
|
+
errors = []
|
|
29
|
+
warnings = []
|
|
30
|
+
checks = {}
|
|
31
|
+
|
|
32
|
+
# 1. 检查语义化容器
|
|
33
|
+
if '<div class="resume-page"' not in content and "<div class='resume-page'" not in content:
|
|
34
|
+
errors.append("Missing root container <div class='resume-page'>")
|
|
35
|
+
else:
|
|
36
|
+
checks["root_container"] = "PASS"
|
|
37
|
+
|
|
38
|
+
# 2. 检查基本信息字段
|
|
39
|
+
if 'candidate-name' not in content:
|
|
40
|
+
errors.append("Missing candidate name element (.candidate-name)")
|
|
41
|
+
else:
|
|
42
|
+
checks["candidate_name"] = "PASS"
|
|
43
|
+
|
|
44
|
+
# 3. 检查 Design Tokens (:root CSS 变量)
|
|
45
|
+
required_tokens = [
|
|
46
|
+
"--resume-page-width",
|
|
47
|
+
"--resume-page-min-height",
|
|
48
|
+
"--resume-font-size-body"
|
|
49
|
+
]
|
|
50
|
+
missing_tokens = []
|
|
51
|
+
for token in required_tokens:
|
|
52
|
+
if token not in content:
|
|
53
|
+
missing_tokens.append(token)
|
|
54
|
+
|
|
55
|
+
if missing_tokens:
|
|
56
|
+
warnings.append(f"Missing recommended CSS Design Tokens: {', '.join(missing_tokens)}")
|
|
57
|
+
else:
|
|
58
|
+
checks["design_tokens"] = "PASS"
|
|
59
|
+
|
|
60
|
+
# 4. 检查打印分页规则
|
|
61
|
+
if "@page" not in content:
|
|
62
|
+
warnings.append("Missing @page print declaration in CSS")
|
|
63
|
+
if "page-break-after" not in content and "break-after" not in content:
|
|
64
|
+
warnings.append("Missing page-break-after CSS rules for multi-page/A4 constraints")
|
|
65
|
+
|
|
66
|
+
# 5. 检查核心内容模块
|
|
67
|
+
sections = ["experience", "projects", "skills", "education"]
|
|
68
|
+
detected_sections = []
|
|
69
|
+
for s in sections:
|
|
70
|
+
if f'id="{s}"' in content or f"id='{s}'" in content or s in content.lower():
|
|
71
|
+
detected_sections.append(s)
|
|
72
|
+
|
|
73
|
+
checks["detected_sections"] = detected_sections
|
|
74
|
+
|
|
75
|
+
# 6. 单页高度预估 (剔除 <style> 后的实际可见文字量)
|
|
76
|
+
body_text_only = re.sub(r'<style.*?>.*?</style>', '', content, flags=re.DOTALL)
|
|
77
|
+
visible_text = re.sub(r'<[^>]+>', '', body_text_only).strip()
|
|
78
|
+
char_count = len(re.sub(r'\s+', ' ', visible_text))
|
|
79
|
+
|
|
80
|
+
density_status = "OPTIMAL"
|
|
81
|
+
if expected_pages == 1:
|
|
82
|
+
if char_count > 1500:
|
|
83
|
+
warnings.append(f"Visible text volume ({char_count} chars) is high for a single-page resume; risk of overflow.")
|
|
84
|
+
density_status = "HIGH_RISK_OVERFLOW"
|
|
85
|
+
elif char_count < 400:
|
|
86
|
+
warnings.append(f"Visible text volume ({char_count} chars) is low for a single-page resume; canvas may appear sparse.")
|
|
87
|
+
density_status = "UNDERFLOW"
|
|
88
|
+
|
|
89
|
+
checks["char_count"] = char_count
|
|
90
|
+
checks["density_status"] = density_status
|
|
91
|
+
|
|
92
|
+
passed = len(errors) == 0
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
"status": "PASS" if passed else "FAIL",
|
|
96
|
+
"file": str(p.resolve()),
|
|
97
|
+
"expectedPages": expected_pages,
|
|
98
|
+
"checks": checks,
|
|
99
|
+
"errors": errors,
|
|
100
|
+
"warnings": warnings
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
def main():
|
|
104
|
+
parser = argparse.ArgumentParser(description="KnowMe CareerForge — Resume Validator")
|
|
105
|
+
parser.add_argument("--html", "-i", default="workspace/resume.html", help="Path to resume HTML (default: workspace/resume.html)")
|
|
106
|
+
parser.add_argument("--expected-pages", "-p", type=int, default=1, help="Expected page count (default: 1)")
|
|
107
|
+
parser.add_argument("--json", action="store_true", help="Output JSON format")
|
|
108
|
+
|
|
109
|
+
args = parser.parse_args()
|
|
110
|
+
|
|
111
|
+
result = validate_resume_html(args.html, args.expected_pages)
|
|
112
|
+
|
|
113
|
+
if args.json:
|
|
114
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
print("=" * 70)
|
|
118
|
+
print(f" KnowMe CareerForge — Resume Validation: {result['status']}")
|
|
119
|
+
print(f" Target File : {result['file']}")
|
|
120
|
+
print(f" Expected Pages : {result['expectedPages']}")
|
|
121
|
+
print(f" Text Density : {result.get('checks', {}).get('density_status', 'N/A')} ({result.get('checks', {}).get('char_count', 0)} visible chars)")
|
|
122
|
+
print(f" Sections Found : {', '.join(result.get('checks', {}).get('detected_sections', []))}")
|
|
123
|
+
|
|
124
|
+
if result.get("errors"):
|
|
125
|
+
print("\n[!] Errors:")
|
|
126
|
+
for e in result["errors"]:
|
|
127
|
+
print(f" - ✗ {e}")
|
|
128
|
+
|
|
129
|
+
if result.get("warnings"):
|
|
130
|
+
print("\n[?] Warnings:")
|
|
131
|
+
for w in result["warnings"]:
|
|
132
|
+
print(f" - ⚠️ {w}")
|
|
133
|
+
|
|
134
|
+
print("=" * 70)
|
|
135
|
+
if result["status"] != "PASS":
|
|
136
|
+
sys.exit(1)
|
|
137
|
+
|
|
138
|
+
if __name__ == "__main__":
|
|
139
|
+
main()
|