knowme-careerforge-skill 0.0.1 → 0.0.4

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 (69) hide show
  1. package/README.md +6 -9
  2. package/README.zh-CN.md +6 -9
  3. package/SKILL.md +105 -116
  4. package/agents/claude/knowme-careerforge.md +11 -9
  5. package/agents/codex/knowme-careerforge.yaml +15 -6
  6. package/agents/cursor/knowme-careerforge.mdc +12 -7
  7. package/agents/opencode/skill.yaml +1 -1
  8. package/agents/windsurf/knowme-careerforge.rules +11 -8
  9. package/dist/cli/src/commands/init.d.ts +1 -1
  10. package/dist/cli/src/commands/init.js +46 -10
  11. package/dist/cli/src/index.js +82 -22
  12. package/dist/scripts/render-pdf.d.ts +0 -9
  13. package/dist/scripts/render-pdf.js +5 -68
  14. package/dist/scripts/rendering/render-pdf.d.ts +11 -0
  15. package/dist/scripts/rendering/render-pdf.js +109 -0
  16. package/dist/scripts/validate-ats.d.ts +0 -21
  17. package/dist/scripts/validate-ats.js +5 -132
  18. package/dist/scripts/validate-layout.d.ts +0 -22
  19. package/dist/scripts/validate-layout.js +5 -124
  20. package/dist/scripts/validation/validate-ats.d.ts +23 -0
  21. package/dist/scripts/validation/validate-ats.js +173 -0
  22. package/dist/scripts/validation/validate-layout.d.ts +24 -0
  23. package/dist/scripts/validation/validate-layout.js +164 -0
  24. package/package.json +16 -12
  25. package/scripts/Agent.md +138 -0
  26. package/scripts/{build-gallery.py → build/build-gallery.py} +128 -99
  27. package/scripts/{build-knowledge.py → build/build-knowledge.py} +9 -1
  28. package/scripts/{release.py → build/release.py} +20 -16
  29. package/scripts/{run-all-tests.py → build/run-all-tests.py} +9 -1
  30. package/scripts/build/sync-version.py +116 -0
  31. package/scripts/{analyze-jd.py → evidence/analyze-jd.py} +34 -45
  32. package/scripts/evidence/extract-evidence.py +348 -0
  33. package/scripts/pipeline/forge.py +142 -0
  34. package/scripts/rendering/render-pdf.py +162 -0
  35. package/scripts/{render-pdf.ts → rendering/render-pdf.ts} +3 -3
  36. package/scripts/template/instantiate-resume.py +184 -0
  37. package/scripts/template/search-template.py +350 -0
  38. package/scripts/{validate-ats.ts → validation/validate-ats.ts} +39 -35
  39. package/scripts/{validate-layout.ts → validation/validate-layout.ts} +43 -42
  40. package/scripts/{validate-resume.py → validation/validate-resume.py} +31 -22
  41. package/skill.json +1 -1
  42. package/src/knowledge/index.json +2 -10
  43. package/src/knowledge/resume-schema.json +112 -0
  44. package/src/knowledge/templates.json +1 -9
  45. package/src/references/01-evidence-mining.md +41 -0
  46. package/src/references/02-career-goal.md +38 -0
  47. package/src/references/03-jd-analysis.md +44 -0
  48. package/src/references/04-template-selection.md +43 -0
  49. package/src/references/05-html-canvas-tokens.md +51 -0
  50. package/src/references/06-qa-and-rendering.md +54 -0
  51. package/src/templates/classic/style.css +22 -51
  52. package/src/templates/common/base.css +150 -0
  53. package/src/templates/executive/style.css +30 -73
  54. package/src/templates/minimal/style.css +30 -72
  55. package/src/templates/modern/style.css +32 -72
  56. package/scripts/instantiate-resume.py +0 -83
  57. package/scripts/search-template.py +0 -147
  58. package/scripts/sync-version.py +0 -57
  59. package/src/references/ats.md +0 -17
  60. package/src/references/evidence-first.md +0 -22
  61. package/src/references/pdf.md +0 -40
  62. package/src/references/resume-writing.md +0 -25
  63. package/src/references/template-design.md +0 -57
  64. package/src/workflows/analyze-job.md +0 -16
  65. package/src/workflows/define-goal.md +0 -16
  66. package/src/workflows/forge-resume.md +0 -25
  67. package/src/workflows/know-me.md +0 -21
  68. package/src/workflows/position.md +0 -19
  69. package/src/workflows/review.md +0 -31
@@ -0,0 +1,350 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ KnowMe CareerForge — Template Search & Ranking Engine
4
+ Architecture: Deep Module with Pluggable Scorer Strategies (Weighted Rules + BM25 Text Search)
5
+
6
+ Design Contract:
7
+ - BaseTemplateScorer: Abstract strategy interface
8
+ - WeightedRuleScorer: Multi-criteria rule-based weighted scorer (Role 35%, Style 25%, ATS 20%, Page 10%, Density 10%)
9
+ - BM25TextScorer: Pure-Python BM25 keyword retrieval across template text documents
10
+ - HybridTemplateScorer: Weighted blend (70% Rule Score + 30% BM25 Text Score)
11
+ - search_templates(): Public facade interface
12
+ """
13
+
14
+ import sys, os, json, argparse, math, re
15
+ from pathlib import Path
16
+ from dataclasses import dataclass, field
17
+ from typing import List, Dict, Any, Optional
18
+ from abc import ABC, abstractmethod
19
+
20
+
21
+ def get_project_root() -> Path:
22
+ curr = Path(__file__).resolve().parent
23
+ for _ in range(5):
24
+ if (curr / "SKILL.md").exists() or (curr / "package.json").exists() or (curr / "src" / "templates").exists():
25
+ return curr
26
+ curr = curr.parent
27
+ return Path.cwd()
28
+
29
+
30
+ @dataclass
31
+ class SearchQuery:
32
+ role: str
33
+ style: Optional[str] = None
34
+ target_pages: int = 1
35
+ density: str = "balanced"
36
+ keywords: Optional[str] = None
37
+ engine: str = "hybrid" # "weighted" | "bm25" | "hybrid"
38
+
39
+
40
+ def load_templates() -> List[Dict[str, Any]]:
41
+ root_dir = get_project_root()
42
+ base_dir = root_dir / "src" / "templates"
43
+ templates = []
44
+ if not base_dir.exists():
45
+ return templates
46
+
47
+ for t_dir in sorted(base_dir.iterdir()):
48
+ if not t_dir.is_dir() or t_dir.name == "common":
49
+ continue
50
+ meta_file = t_dir / "metadata.json"
51
+ if meta_file.exists():
52
+ try:
53
+ data = json.loads(meta_file.read_text(encoding="utf-8"))
54
+ data["_directory"] = str(t_dir.relative_to(root_dir))
55
+ templates.append(data)
56
+ except Exception:
57
+ pass
58
+ return templates
59
+
60
+
61
+ # ==============================================================================
62
+ # 1. SCORER STRATEGY INTERFACE & IMPLEMENTATIONS
63
+ # ==============================================================================
64
+
65
+ class BaseTemplateScorer(ABC):
66
+ """Abstract interface for template scoring algorithms."""
67
+
68
+ @abstractmethod
69
+ def score(self, template: Dict[str, Any], query: SearchQuery) -> float:
70
+ """Return match score normalized between 0.0 and 100.0."""
71
+ pass
72
+
73
+
74
+ class WeightedRuleScorer(BaseTemplateScorer):
75
+ """
76
+ Rule-based weighted scoring engine:
77
+ - Role Match: 35%
78
+ - Style Match: 25%
79
+ - ATS Tier: 20%
80
+ - Page Fit: 10%
81
+ - Density Fit: 10%
82
+ """
83
+
84
+ def __init__(self, weights: Optional[Dict[str, float]] = None):
85
+ self.weights = weights or {
86
+ "role": 0.35,
87
+ "style": 0.25,
88
+ "ats": 0.20,
89
+ "page": 0.10,
90
+ "density": 0.10
91
+ }
92
+
93
+ def score(self, template: Dict[str, Any], query: SearchQuery) -> float:
94
+ score = 0.0
95
+
96
+ # 1. Role Match
97
+ role_score = 0.0
98
+ query_lower = query.role.lower()
99
+ supported = [r.lower() for r in template.get("supportedRoles", [])]
100
+
101
+ for r in supported:
102
+ if r in query_lower or query_lower in r:
103
+ role_score = 1.0
104
+ break
105
+
106
+ if role_score < 0.5:
107
+ cat = template.get("roleCategory", "")
108
+ if any(k in query_lower for k in ["ai", "agent", "llm", "frontend", "backend", "fullstack", "devops", "engineer"]) and "engineering" in cat:
109
+ role_score = 0.85
110
+ elif any(k in query_lower for k in ["manager", "product", "lead", "director", "architect", "cto"]) and "management" in cat:
111
+ role_score = 0.85
112
+ elif any(k in query_lower for k in ["bank", "fintech", "state", "corp", "government"]) and "corporate" in cat:
113
+ role_score = 0.85
114
+ else:
115
+ role_score = 0.60
116
+
117
+ score += role_score * self.weights["role"]
118
+
119
+ # 2. Style Match
120
+ style_score = 0.70
121
+ if query.style:
122
+ q_style_lower = query.style.lower()
123
+ t_style = template.get("style", "").lower()
124
+ t_tone = template.get("visualStyle", {}).get("tone", "").lower()
125
+
126
+ if q_style_lower in t_style or q_style_lower in t_tone:
127
+ style_score = 1.0
128
+ elif any(k in t_style for k in q_style_lower.split("-")):
129
+ style_score = 0.85
130
+ else:
131
+ style_score = 0.50
132
+ score += style_score * self.weights["style"]
133
+
134
+ # 3. ATS Tier
135
+ ats_tier = template.get("atsScoreTier", "tier-1-optimal")
136
+ ats_score = 1.0 if "optimal" in ats_tier or "tier-1" in ats_tier else 0.85
137
+ score += ats_score * self.weights["ats"]
138
+
139
+ # 4. Page Fit
140
+ target_p = template.get("layout", {}).get("targetPages", 1)
141
+ max_p = template.get("layout", {}).get("maxPages", 2)
142
+ if query.target_pages == target_p:
143
+ page_score = 1.0
144
+ elif query.target_pages <= max_p:
145
+ page_score = 0.80
146
+ else:
147
+ page_score = 0.50
148
+ score += page_score * self.weights["page"]
149
+
150
+ # 5. Density Preference
151
+ t_density = template.get("layout", {}).get("density", "balanced")
152
+ density_score = 1.0 if query.density == t_density else 0.80
153
+ score += density_score * self.weights["density"]
154
+
155
+ return round(score * 100, 1)
156
+
157
+
158
+ class BM25TextScorer(BaseTemplateScorer):
159
+ """
160
+ Pure Python BM25 Text Scorer across template metadata documents.
161
+ Constructs a virtual document per template: (name, category, style, supportedRoles, tone).
162
+ """
163
+
164
+ def __init__(self, templates: Optional[List[Dict[str, Any]]] = None, k1: float = 1.5, b: float = 0.75):
165
+ self.k1 = k1
166
+ self.b = b
167
+ self.templates = templates or load_templates()
168
+ self.docs = [self._extract_doc(t) for t in self.templates]
169
+ self.avg_dl = sum(len(d) for d in self.docs) / max(len(self.docs), 1)
170
+ self.doc_freqs = self._calc_doc_freqs()
171
+
172
+ def _extract_doc(self, template: Dict[str, Any]) -> List[str]:
173
+ text_parts = [
174
+ template.get("name", ""),
175
+ template.get("style", ""),
176
+ template.get("roleCategory", ""),
177
+ template.get("visualStyle", {}).get("tone", ""),
178
+ " ".join(template.get("supportedRoles", []))
179
+ ]
180
+ combined = " ".join(text_parts).lower()
181
+ # Tokenize words & Chinese characters
182
+ tokens = re.findall(r'[a-zA-Z0-9_\-]+|[\u4e00-\u9fa5]', combined)
183
+ return tokens
184
+
185
+ def _calc_doc_freqs(self) -> Dict[str, int]:
186
+ freqs: Dict[str, int] = {}
187
+ for doc in self.docs:
188
+ for word in set(doc):
189
+ freqs[word] = freqs.get(word, 0) + 1
190
+ return freqs
191
+
192
+ def score(self, template: Dict[str, Any], query: SearchQuery) -> float:
193
+ doc = self._extract_doc(template)
194
+ doc_len = len(doc)
195
+ if doc_len == 0:
196
+ return 50.0
197
+
198
+ query_text = f"{query.role} {query.style or ''} {query.keywords or ''}".lower()
199
+ q_tokens = re.findall(r'[a-zA-Z0-9_\-]+|[\u4e00-\u9fa5]', query_text)
200
+ if not q_tokens:
201
+ return 70.0
202
+
203
+ n_docs = len(self.docs)
204
+ score = 0.0
205
+
206
+ for term in q_tokens:
207
+ df = self.doc_freqs.get(term, 0)
208
+ if df == 0:
209
+ continue
210
+ # Standard BM25 IDF with smoothing
211
+ idf = math.log(1 + (n_docs - df + 0.5) / (df + 0.5))
212
+ tf = doc.count(term)
213
+ numerator = tf * (self.k1 + 1)
214
+ denominator = tf + self.k1 * (1 - self.b + self.b * (doc_len / max(self.avg_dl, 1)))
215
+ score += idf * (numerator / max(denominator, 0.001))
216
+
217
+ # Normalize score into roughly 50.0 ~ 100.0 range
218
+ normalized = 50.0 + min(score * 15.0, 50.0)
219
+ return round(normalized, 1)
220
+
221
+
222
+ class HybridTemplateScorer(BaseTemplateScorer):
223
+ """
224
+ Weighted combination of rule-based constraints and BM25 text relevance.
225
+ Default: 70% Weighted Rules + 30% BM25 Relevance.
226
+ """
227
+
228
+ def __init__(self, rule_weight: float = 0.70, bm25_weight: float = 0.30):
229
+ self.rule_scorer = WeightedRuleScorer()
230
+ self.bm25_scorer = BM25TextScorer()
231
+ self.rule_weight = rule_weight
232
+ self.bm25_weight = bm25_weight
233
+
234
+ def score(self, template: Dict[str, Any], query: SearchQuery) -> float:
235
+ rule_score = self.rule_scorer.score(template, query)
236
+ bm25_score = self.bm25_scorer.score(template, query)
237
+ blended = (rule_score * self.rule_weight) + (bm25_score * self.bm25_weight)
238
+ return round(blended, 1)
239
+
240
+
241
+ # ==============================================================================
242
+ # 2. PUBLIC FACADE INTERFACE (DEEP MODULE)
243
+ # ==============================================================================
244
+
245
+ def search_templates(
246
+ role: str,
247
+ style: Optional[str] = None,
248
+ target_pages: int = 1,
249
+ density: str = "balanced",
250
+ keywords: Optional[str] = None,
251
+ engine: str = "hybrid"
252
+ ) -> List[Dict[str, Any]]:
253
+ """
254
+ Search and rank HTML resume templates.
255
+
256
+ Args:
257
+ role: Target job title or direction (e.g., 'AI Agent Engineer', 'Tech Lead')
258
+ style: Preferred visual/layout style (e.g., 'single-column-minimal', 'two-column-split')
259
+ target_pages: Expected page count (1 or 2)
260
+ density: Information density preference ('high', 'balanced', 'spacious')
261
+ keywords: Optional JD keywords for text matching
262
+ engine: Scoring engine ('hybrid' | 'weighted' | 'bm25')
263
+
264
+ Returns:
265
+ List of matching templates sorted by matchScore descending.
266
+ """
267
+ query = SearchQuery(
268
+ role=role,
269
+ style=style,
270
+ target_pages=target_pages,
271
+ density=density,
272
+ keywords=keywords,
273
+ engine=engine
274
+ )
275
+
276
+ templates = load_templates()
277
+ if not templates:
278
+ return []
279
+
280
+ # Select Scorer Strategy
281
+ scorer: BaseTemplateScorer
282
+ if engine == "bm25":
283
+ scorer = BM25TextScorer(templates)
284
+ elif engine == "weighted":
285
+ scorer = WeightedRuleScorer()
286
+ else:
287
+ scorer = HybridTemplateScorer()
288
+
289
+ results = []
290
+ for t in templates:
291
+ t_copy = dict(t)
292
+ score_val = scorer.score(t, query)
293
+ t_copy["matchScore"] = score_val
294
+ t_copy["score"] = score_val
295
+ t_copy["template"] = t
296
+ results.append(t_copy)
297
+
298
+ results.sort(key=lambda x: x.get("matchScore", 0.0), reverse=True)
299
+ return results
300
+ def main():
301
+ parser = argparse.ArgumentParser(description="KnowMe CareerForge — Template Search Engine")
302
+ parser.add_argument("role", nargs="?", default=None, help="Target role or job title (e.g. 'AI Agent Engineer')")
303
+ parser.add_argument("--role", "-r", dest="named_role", default=None, help="Target role (named option)")
304
+ parser.add_argument("--style", "-s", default=None, help="Style preference (minimal, modern, executive, classic)")
305
+ parser.add_argument("--target-pages", "-p", type=int, default=1, help="Target pages (1 or 2)")
306
+ parser.add_argument("--density", "-d", default="balanced", choices=["high", "balanced", "spacious"], help="Density")
307
+ parser.add_argument("--keywords", "-k", default=None, help="Additional JD keywords")
308
+ parser.add_argument("--engine", "-e", default="hybrid", choices=["hybrid", "weighted", "bm25"], help="Scoring engine")
309
+ parser.add_argument("--json", action="store_true", help="Output raw JSON array")
310
+
311
+ args = parser.parse_args()
312
+ target_role = args.named_role or args.role
313
+ if not target_role:
314
+ parser.error("the following arguments are required: role (or --role / -r)")
315
+
316
+ results = search_templates(
317
+ role=target_role,
318
+ style=args.style,
319
+ target_pages=args.target_pages,
320
+ density=args.density,
321
+ keywords=args.keywords,
322
+ engine=args.engine
323
+ )
324
+
325
+ if args.json:
326
+ print(json.dumps(results, ensure_ascii=False, indent=2))
327
+ return
328
+
329
+ print("=" * 80)
330
+ print(f" KnowMe CareerForge — Template Search Engine Results")
331
+ print(f" Query Role : {target_role}")
332
+ print(f" Style Filter : {args.style or 'Any'}")
333
+ print(f" Engine : {args.engine.upper()}")
334
+ print(f" Matches : {len(results)} template(s) found")
335
+ print("=" * 80)
336
+
337
+ for idx, r in enumerate(results, 1):
338
+ custom_tokens = r.get("customizableTokens", [])
339
+ tokens_preview = ", ".join(custom_tokens[:4]) + ("..." if len(custom_tokens) > 4 else "")
340
+ print(f"\n{idx}. [{r.get('matchScore')} pts] {r.get('name')} (ID: {r.get('id')})")
341
+ print(f" Category: {r.get('roleCategory')} | Style: {r.get('style')} | ATS Tier: {r.get('atsScoreTier')}")
342
+ print(f" Target Pages: {r.get('layout', {}).get('targetPages', 1)} | Density: {r.get('layout', {}).get('density')}")
343
+ print(f" Directory: {r.get('_directory')}")
344
+ if tokens_preview:
345
+ print(f" Tokens : {tokens_preview}")
346
+ print("-" * 80)
347
+
348
+
349
+ if __name__ == "__main__":
350
+ main()
@@ -12,8 +12,8 @@ interface AtsValidationResult {
12
12
  status: 'PASS' | 'WARN' | 'FAIL';
13
13
  candidateName: string | null;
14
14
  detectedContacts: {
15
- phone: string | null;
16
15
  email: string | null;
16
+ phone: string | null;
17
17
  };
18
18
  headingsFound: Array<{
19
19
  level: string;
@@ -44,26 +44,24 @@ export async function validateAts(htmlPath: string): Promise<AtsValidationResult
44
44
  await page.goto(`file://${absoluteHtml}`, { waitUntil: 'networkidle' });
45
45
 
46
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();
47
+ const nameEl = document.querySelector('.candidate-name') || document.querySelector('h1');
48
+ const candidateName = nameEl ? nameEl.textContent?.trim() || null : null;
51
49
 
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-.]+/);
50
+ const bodyText = document.body.innerText || '';
51
+ const emailMatch = bodyText.match(/[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+/);
52
+ const phoneMatch = bodyText.match(/1[3-9]\d{9}|\+?\d{1,4}[-\s]?\d{7,11}/);
55
53
 
56
- const headings = Array.from(document.querySelectorAll('h1, h2, h3, .section-title, .sub-title')).map(h => ({
54
+ const headings = Array.from(document.querySelectorAll('h1, h2, h3, h4')).map(h => ({
57
55
  level: h.tagName.toLowerCase(),
58
- text: (h.textContent || '').trim()
56
+ text: h.textContent?.replace(/[\s\n]+/g, ' ').trim() || ''
59
57
  }));
60
58
 
61
59
  return {
62
- candidateName: name,
63
- phone: phoneMatch ? phoneMatch[0] : null,
60
+ candidateName,
64
61
  email: emailMatch ? emailMatch[0] : null,
62
+ phone: phoneMatch ? phoneMatch[0] : null,
65
63
  headings,
66
- fullTextLength: fullText.length
64
+ totalTextLength: bodyText.trim().length
67
65
  };
68
66
  });
69
67
 
@@ -85,12 +83,11 @@ export async function validateAts(htmlPath: string): Promise<AtsValidationResult
85
83
  }
86
84
 
87
85
  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, '')));
86
+ const textClean = h.text.toLowerCase();
87
+ const isStandard = STANDARD_SECTION_HEADERS.some(sh => textClean.includes(sh.toLowerCase()));
90
88
  return {
91
- level: h.level,
92
- text: h.text,
93
- isStandard: isStd
89
+ ...h,
90
+ isStandard
94
91
  };
95
92
  });
96
93
 
@@ -105,11 +102,11 @@ export async function validateAts(htmlPath: string): Promise<AtsValidationResult
105
102
  status,
106
103
  candidateName: rawData.candidateName,
107
104
  detectedContacts: {
108
- phone: rawData.phone,
109
- email: rawData.email
105
+ email: rawData.email,
106
+ phone: rawData.phone
110
107
  },
111
108
  headingsFound: validatedHeadings,
112
- totalTextLength: rawData.fullTextLength,
109
+ totalTextLength: rawData.totalTextLength,
113
110
  warnings,
114
111
  errors
115
112
  };
@@ -126,6 +123,8 @@ async function main() {
126
123
  i++;
127
124
  } else if (args[i] === '--json') {
128
125
  jsonOutput = true;
126
+ } else if (!args[i].startsWith('-')) {
127
+ htmlPath = args[i];
129
128
  }
130
129
  }
131
130
 
@@ -134,36 +133,41 @@ async function main() {
134
133
 
135
134
  if (jsonOutput) {
136
135
  console.log(JSON.stringify(result, null, 2));
137
- return;
136
+ process.exit(result.status === 'FAIL' ? 1 : 0);
138
137
  }
139
138
 
140
139
  console.log('======================================================');
141
140
  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'}`);
141
+ console.log(` Candidate Name : ${result.candidateName || 'NOT FOUND'}`);
142
+ console.log(` Phone Extracted: ${result.detectedContacts.phone || 'NOT DETECTED'}`);
143
+ console.log(` Email Extracted: ${result.detectedContacts.email || 'NOT DETECTED'}`);
145
144
  console.log(` Text Extracted : ${result.totalTextLength} characters`);
146
145
  console.log('------------------------------------------------------');
147
146
  console.log(' Headings Structure:');
148
147
  for (const h of result.headingsFound) {
149
- const tag = h.isStandard ? '✓' : '?';
150
- console.log(` [${tag}] <${h.level}> ${h.text}`);
148
+ const mark = h.isStandard ? '[✓]' : '[?]';
149
+ console.log(` ${mark} <${h.level}> ${h.text}`);
151
150
  }
152
151
 
153
- if (result.errors.length > 0) {
154
- console.log('\n[!] Errors:');
155
- for (const e of result.errors) console.log(` - ✗ ${e}`);
152
+ if (result.warnings.length > 0) {
153
+ console.log('\n[!] Warnings:');
154
+ for (const w of result.warnings) {
155
+ console.log(` - ⚠️ ${w}`);
156
+ }
156
157
  }
157
158
 
158
- if (result.warnings.length > 0) {
159
- console.log('\n[?] Warnings:');
160
- for (const w of result.warnings) console.log(` - ⚠️ ${w}`);
159
+ if (result.errors.length > 0) {
160
+ console.log('\n[✗] Errors:');
161
+ for (const e of result.errors) {
162
+ console.log(` - ✗ ${e}`);
163
+ }
161
164
  }
162
165
 
163
166
  console.log('======================================================');
167
+ process.exit(result.status === 'FAIL' ? 1 : 0);
164
168
  } catch (err: unknown) {
165
- const msg = err instanceof Error ? err.message : String(err);
166
- console.error(`[✗] ATS Validation Failed: ${msg}`);
169
+ const errorMsg = err instanceof Error ? err.message : String(err);
170
+ console.error(`[✗] ATS Validation Failed: ${errorMsg}`);
167
171
  process.exit(1);
168
172
  }
169
173
  }
@@ -14,15 +14,15 @@ interface LayoutValidationResult {
14
14
  totalPagesFound: number;
15
15
  pageDetails: Array<{
16
16
  pageIndex: number;
17
- measuredHeightPx: number;
17
+ actualHeightPx: number;
18
18
  allowedHeightPx: number;
19
- overflowPx: number;
20
19
  isOverflow: boolean;
20
+ overflowDeltaPx: number;
21
21
  }>;
22
22
  overflowSections: Array<{
23
23
  selector: string;
24
- heightPx: number;
25
- textPreview: string;
24
+ bottomPx: number;
25
+ overflowByPx: number;
26
26
  }>;
27
27
  }
28
28
 
@@ -35,7 +35,7 @@ export async function validateLayout(htmlPath: string, expectedPages: number = 1
35
35
  const browser = await chromium.launch({ headless: true });
36
36
  const context = await browser.newContext({
37
37
  viewport: { width: 794, height: 1123 }, // A4 at 96 DPI
38
- deviceScaleFactor: 2
38
+ deviceScaleFactor: 1
39
39
  });
40
40
 
41
41
  const page = await context.newPage();
@@ -47,39 +47,44 @@ export async function validateLayout(htmlPath: string, expectedPages: number = 1
47
47
 
48
48
  const evaluation = await page.evaluate((allowedHeight) => {
49
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;
50
+ const details = pages.map((p, idx) => {
51
+ const rect = p.getBoundingClientRect();
52
+ const actualHeight = Math.ceil(rect.height);
53
+ const isOverflow = actualHeight > allowedHeight + 2; // 2px 浮点容差
55
54
  return {
56
- pageIndex: index + 1,
57
- measuredHeightPx: Math.round(actualH * 10) / 10,
55
+ pageIndex: idx + 1,
56
+ actualHeightPx: actualHeight,
58
57
  allowedHeightPx: allowedHeight,
59
- overflowPx: diff > 2 ? Math.round(diff * 10) / 10 : 0,
60
- isOverflow: diff > 2
58
+ isOverflow,
59
+ overflowDeltaPx: isOverflow ? Math.ceil(actualHeight - allowedHeight) : 0
61
60
  };
62
61
  });
63
62
 
64
- const overflows: Array<{ selector: string; heightPx: number; textPreview: string }> = [];
63
+ const overflowSections: Array<{ selector: string; bottomPx: number; overflowByPx: number }> = [];
65
64
  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)
65
+ const items = Array.from(document.querySelectorAll('.experience-item, .project-item, .skills-content, .resume-section'));
66
+ for (const item of items) {
67
+ const rect = item.getBoundingClientRect();
68
+ if (rect.bottom > allowedHeight) {
69
+ overflowSections.push({
70
+ selector: item.className ? `.${item.className.split(' ').join('.')}` : item.tagName.toLowerCase(),
71
+ bottomPx: Math.ceil(rect.bottom),
72
+ overflowByPx: Math.ceil(rect.bottom - allowedHeight)
74
73
  });
75
74
  }
76
75
  }
77
76
  }
78
77
 
79
78
  return {
80
- totalPagesFound: pages.length,
81
- pageDetails: details,
82
- overflowSections: overflows
79
+ totalPagesFound: pages.length || 1,
80
+ pageDetails: details.length ? details : [{
81
+ pageIndex: 1,
82
+ actualHeightPx: Math.ceil(document.body.getBoundingClientRect().height),
83
+ allowedHeightPx: allowedHeight,
84
+ isOverflow: document.body.getBoundingClientRect().height > allowedHeight + 2,
85
+ overflowDeltaPx: Math.max(0, Math.ceil(document.body.getBoundingClientRect().height - allowedHeight))
86
+ }],
87
+ overflowSections
83
88
  };
84
89
  }, A4_HEIGHT_PX);
85
90
 
@@ -111,6 +116,8 @@ async function main() {
111
116
  i++;
112
117
  } else if (args[i] === '--json') {
113
118
  jsonOutput = true;
119
+ } else if (!args[i].startsWith('-')) {
120
+ htmlPath = args[i];
114
121
  }
115
122
  }
116
123
 
@@ -119,7 +126,7 @@ async function main() {
119
126
 
120
127
  if (jsonOutput) {
121
128
  console.log(JSON.stringify(result, null, 2));
122
- return;
129
+ process.exit(result.status === 'PASS' ? 0 : 1);
123
130
  }
124
131
 
125
132
  console.log('======================================================');
@@ -129,29 +136,23 @@ async function main() {
129
136
  console.log('------------------------------------------------------');
130
137
 
131
138
  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)`);
139
+ const fitText = p.isOverflow ? `[✗ OVERFLOW by ${p.overflowDeltaPx}px]` : `[✓ FIT] (Overflow: ${p.overflowDeltaPx}px)`;
140
+ console.log(` Page ${p.pageIndex}: ${p.actualHeightPx}px / ${p.allowedHeightPx}px ${fitText}`);
134
141
  }
135
142
 
136
143
  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}..."`);
144
+ console.log('\n[!] Offending Overflow Elements:');
145
+ for (const el of result.overflowSections.slice(0, 3)) {
146
+ console.log(` - Element "${el.selector}" exceeds A4 baseline by ${el.overflowByPx}px (Bottom: ${el.bottomPx}px)`);
140
147
  }
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');
148
+ console.log('\n💡 Recommendation: Reduce --resume-space-section by 1~2pt or reduce --resume-font-size-body by 0.2pt in <style>.');
145
149
  }
146
150
 
147
151
  console.log('======================================================');
148
-
149
- if (result.status === 'OVERFLOW') {
150
- process.exit(1);
151
- }
152
+ process.exit(result.status === 'PASS' ? 0 : 1);
152
153
  } catch (err: unknown) {
153
- const msg = err instanceof Error ? err.message : String(err);
154
- console.error(`[✗] Layout Validation Failed: ${msg}`);
154
+ const errorMsg = err instanceof Error ? err.message : String(err);
155
+ console.error(`[✗] Layout Validation Failed: ${errorMsg}`);
155
156
  process.exit(1);
156
157
  }
157
158
  }