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.
- package/README.md +6 -9
- package/README.zh-CN.md +6 -9
- package/SKILL.md +105 -116
- package/agents/claude/knowme-careerforge.md +11 -9
- package/agents/codex/knowme-careerforge.yaml +15 -6
- package/agents/cursor/knowme-careerforge.mdc +12 -7
- package/agents/opencode/skill.yaml +1 -1
- package/agents/windsurf/knowme-careerforge.rules +11 -8
- package/dist/cli/src/commands/init.d.ts +1 -1
- package/dist/cli/src/commands/init.js +46 -10
- package/dist/cli/src/index.js +82 -22
- package/dist/scripts/render-pdf.d.ts +0 -9
- package/dist/scripts/render-pdf.js +5 -68
- package/dist/scripts/rendering/render-pdf.d.ts +11 -0
- package/dist/scripts/rendering/render-pdf.js +109 -0
- package/dist/scripts/validate-ats.d.ts +0 -21
- package/dist/scripts/validate-ats.js +5 -132
- package/dist/scripts/validate-layout.d.ts +0 -22
- package/dist/scripts/validate-layout.js +5 -124
- package/dist/scripts/validation/validate-ats.d.ts +23 -0
- package/dist/scripts/validation/validate-ats.js +173 -0
- package/dist/scripts/validation/validate-layout.d.ts +24 -0
- package/dist/scripts/validation/validate-layout.js +164 -0
- package/package.json +16 -12
- package/scripts/Agent.md +138 -0
- package/scripts/{build-gallery.py → build/build-gallery.py} +128 -99
- package/scripts/{build-knowledge.py → build/build-knowledge.py} +9 -1
- package/scripts/{release.py → build/release.py} +20 -16
- package/scripts/{run-all-tests.py → build/run-all-tests.py} +9 -1
- package/scripts/build/sync-version.py +116 -0
- package/scripts/{analyze-jd.py → evidence/analyze-jd.py} +34 -45
- package/scripts/evidence/extract-evidence.py +348 -0
- package/scripts/pipeline/forge.py +142 -0
- package/scripts/rendering/render-pdf.py +162 -0
- package/scripts/{render-pdf.ts → rendering/render-pdf.ts} +3 -3
- package/scripts/template/instantiate-resume.py +184 -0
- package/scripts/template/search-template.py +350 -0
- package/scripts/{validate-ats.ts → validation/validate-ats.ts} +39 -35
- package/scripts/{validate-layout.ts → validation/validate-layout.ts} +43 -42
- package/scripts/{validate-resume.py → validation/validate-resume.py} +31 -22
- package/skill.json +1 -1
- package/src/knowledge/index.json +2 -10
- package/src/knowledge/resume-schema.json +112 -0
- package/src/knowledge/templates.json +1 -9
- package/src/references/01-evidence-mining.md +41 -0
- package/src/references/02-career-goal.md +38 -0
- package/src/references/03-jd-analysis.md +44 -0
- package/src/references/04-template-selection.md +43 -0
- package/src/references/05-html-canvas-tokens.md +51 -0
- package/src/references/06-qa-and-rendering.md +54 -0
- package/src/templates/classic/style.css +22 -51
- package/src/templates/common/base.css +150 -0
- package/src/templates/executive/style.css +30 -73
- package/src/templates/minimal/style.css +30 -72
- package/src/templates/modern/style.css +32 -72
- package/scripts/instantiate-resume.py +0 -83
- package/scripts/search-template.py +0 -147
- package/scripts/sync-version.py +0 -57
- package/src/references/ats.md +0 -17
- package/src/references/evidence-first.md +0 -22
- package/src/references/pdf.md +0 -40
- package/src/references/resume-writing.md +0 -25
- package/src/references/template-design.md +0 -57
- package/src/workflows/analyze-job.md +0 -16
- package/src/workflows/define-goal.md +0 -16
- package/src/workflows/forge-resume.md +0 -25
- package/src/workflows/know-me.md +0 -21
- package/src/workflows/position.md +0 -19
- 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
|
|
48
|
-
const
|
|
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
|
|
54
|
-
const
|
|
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,
|
|
54
|
+
const headings = Array.from(document.querySelectorAll('h1, h2, h3, h4')).map(h => ({
|
|
57
55
|
level: h.tagName.toLowerCase(),
|
|
58
|
-
text:
|
|
56
|
+
text: h.textContent?.replace(/[\s\n]+/g, ' ').trim() || ''
|
|
59
57
|
}));
|
|
60
58
|
|
|
61
59
|
return {
|
|
62
|
-
candidateName
|
|
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
|
-
|
|
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()
|
|
89
|
-
const
|
|
86
|
+
const textClean = h.text.toLowerCase();
|
|
87
|
+
const isStandard = STANDARD_SECTION_HEADERS.some(sh => textClean.includes(sh.toLowerCase()));
|
|
90
88
|
return {
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
109
|
-
|
|
105
|
+
email: rawData.email,
|
|
106
|
+
phone: rawData.phone
|
|
110
107
|
},
|
|
111
108
|
headingsFound: validatedHeadings,
|
|
112
|
-
totalTextLength: rawData.
|
|
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
|
-
|
|
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 || '
|
|
143
|
-
console.log(` Phone Extracted: ${result.detectedContacts.phone || '
|
|
144
|
-
console.log(` Email Extracted: ${result.detectedContacts.email || '
|
|
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
|
|
150
|
-
console.log(`
|
|
148
|
+
const mark = h.isStandard ? '[✓]' : '[?]';
|
|
149
|
+
console.log(` ${mark} <${h.level}> ${h.text}`);
|
|
151
150
|
}
|
|
152
151
|
|
|
153
|
-
if (result.
|
|
154
|
-
console.log('\n[!]
|
|
155
|
-
for (const
|
|
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.
|
|
159
|
-
console.log('\n[
|
|
160
|
-
for (const
|
|
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
|
|
166
|
-
console.error(`[✗] ATS Validation Failed: ${
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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:
|
|
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((
|
|
51
|
-
const rect =
|
|
52
|
-
const
|
|
53
|
-
const
|
|
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:
|
|
57
|
-
|
|
55
|
+
pageIndex: idx + 1,
|
|
56
|
+
actualHeightPx: actualHeight,
|
|
58
57
|
allowedHeightPx: allowedHeight,
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
isOverflow,
|
|
59
|
+
overflowDeltaPx: isOverflow ? Math.ceil(actualHeight - allowedHeight) : 0
|
|
61
60
|
};
|
|
62
61
|
});
|
|
63
62
|
|
|
64
|
-
const
|
|
63
|
+
const overflowSections: Array<{ selector: string; bottomPx: number; overflowByPx: number }> = [];
|
|
65
64
|
if (details.some(d => d.isOverflow)) {
|
|
66
|
-
const
|
|
67
|
-
for (const
|
|
68
|
-
const
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
selector:
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
133
|
-
console.log(` Page ${p.pageIndex}: ${p.
|
|
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[!]
|
|
138
|
-
for (const
|
|
139
|
-
console.log(` -
|
|
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
|
|
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
|
|
154
|
-
console.error(`[✗] Layout Validation Failed: ${
|
|
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
|
}
|