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.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +227 -0
  3. package/README.zh-CN.md +231 -0
  4. package/SKILL.md +179 -0
  5. package/agents/claude/knowme-careerforge.md +16 -0
  6. package/agents/codex/knowme-careerforge.yaml +15 -0
  7. package/agents/cursor/knowme-careerforge.mdc +12 -0
  8. package/agents/gemini/knowme-careerforge.json +6 -0
  9. package/agents/opencode/skill.yaml +11 -0
  10. package/agents/windsurf/knowme-careerforge.rules +10 -0
  11. package/bin/knowme.js +32 -0
  12. package/dist/cli/src/commands/init.d.ts +10 -0
  13. package/dist/cli/src/commands/init.js +130 -0
  14. package/dist/cli/src/commands/list.d.ts +5 -0
  15. package/dist/cli/src/commands/list.js +92 -0
  16. package/dist/cli/src/index.d.ts +6 -0
  17. package/dist/cli/src/index.js +149 -0
  18. package/dist/scripts/render-pdf.d.ts +11 -0
  19. package/dist/scripts/render-pdf.js +105 -0
  20. package/dist/scripts/validate-ats.d.ts +23 -0
  21. package/dist/scripts/validate-ats.js +169 -0
  22. package/dist/scripts/validate-layout.d.ts +24 -0
  23. package/dist/scripts/validate-layout.js +161 -0
  24. package/package.json +58 -0
  25. package/scripts/analyze-jd.py +108 -0
  26. package/scripts/build-gallery.py +233 -0
  27. package/scripts/build-knowledge.py +81 -0
  28. package/scripts/instantiate-resume.py +83 -0
  29. package/scripts/release.py +101 -0
  30. package/scripts/render-pdf.ts +86 -0
  31. package/scripts/run-all-tests.py +41 -0
  32. package/scripts/search-template.py +147 -0
  33. package/scripts/sync-version.py +57 -0
  34. package/scripts/validate-ats.ts +173 -0
  35. package/scripts/validate-layout.ts +161 -0
  36. package/scripts/validate-resume.py +139 -0
  37. package/skill.json +47 -0
  38. package/src/knowledge/ats-rules.json +37 -0
  39. package/src/knowledge/career/seniority-signals.json +24 -0
  40. package/src/knowledge/index.json +734 -0
  41. package/src/knowledge/industries/enterprise-saas.json +21 -0
  42. package/src/knowledge/industries/fintech.json +21 -0
  43. package/src/knowledge/industries/tech-ai.json +21 -0
  44. package/src/knowledge/layouts.json +59 -0
  45. package/src/knowledge/resume/action-verbs.json +20 -0
  46. package/src/knowledge/resume/fab-model.json +22 -0
  47. package/src/knowledge/roles/ai-agent-engineer.json +52 -0
  48. package/src/knowledge/roles/android-engineer.json +45 -0
  49. package/src/knowledge/roles/architect.json +48 -0
  50. package/src/knowledge/roles/cpp-systems.json +47 -0
  51. package/src/knowledge/roles/frontend.json +49 -0
  52. package/src/knowledge/roles/ios-engineer.json +45 -0
  53. package/src/knowledge/roles/java-backend.json +49 -0
  54. package/src/knowledge/roles/node-fullstack.json +49 -0
  55. package/src/knowledge/roles/product-manager.json +48 -0
  56. package/src/knowledge/skills/skill-matrix.json +29 -0
  57. package/src/knowledge/styles.json +73 -0
  58. package/src/knowledge/templates.json +255 -0
  59. package/src/references/ats.md +17 -0
  60. package/src/references/evidence-first.md +22 -0
  61. package/src/references/pdf.md +40 -0
  62. package/src/references/resume-writing.md +25 -0
  63. package/src/references/template-design.md +57 -0
  64. package/src/templates/classic/README.md +12 -0
  65. package/src/templates/classic/metadata.json +34 -0
  66. package/src/templates/classic/style.css +164 -0
  67. package/src/templates/classic/template.html +133 -0
  68. package/src/templates/common/resume-contract.md +69 -0
  69. package/src/templates/executive/README.md +12 -0
  70. package/src/templates/executive/metadata.json +37 -0
  71. package/src/templates/executive/style.css +235 -0
  72. package/src/templates/executive/template.html +101 -0
  73. package/src/templates/minimal/README.md +12 -0
  74. package/src/templates/minimal/metadata.json +37 -0
  75. package/src/templates/minimal/style.css +219 -0
  76. package/src/templates/minimal/template.html +114 -0
  77. package/src/templates/modern/README.md +12 -0
  78. package/src/templates/modern/metadata.json +38 -0
  79. package/src/templates/modern/style.css +277 -0
  80. package/src/templates/modern/template.html +139 -0
  81. package/src/workflows/analyze-job.md +16 -0
  82. package/src/workflows/define-goal.md +16 -0
  83. package/src/workflows/forge-resume.md +25 -0
  84. package/src/workflows/know-me.md +21 -0
  85. package/src/workflows/position.md +19 -0
  86. 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()