careermate 0.0.9 → 0.0.10
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/dist/doctor.mjs +17283 -6559
- package/dist/mcp.mjs +13982 -3840
- package/dist/migrate.mjs +14241 -3709
- package/dist/pdf.worker.min.mjs +4 -4
- package/dist/public/app.js +10 -19
- package/dist/public/i18n/en/documents.js +1 -0
- package/dist/public/i18n/en/home.js +8 -1
- package/dist/public/i18n/en/jobs.js +2 -0
- package/dist/public/i18n/en/nav.js +1 -0
- package/dist/public/i18n/ko/documents.js +1 -0
- package/dist/public/i18n/ko/home.js +8 -1
- package/dist/public/i18n/ko/jobs.js +2 -0
- package/dist/public/i18n/ko/nav.js +1 -0
- package/dist/public/pages/documents.js +9 -5
- package/dist/public/pages/home.js +12 -4
- package/dist/public/pages/insights.js +156 -0
- package/dist/public/pages/jobs.js +21 -3
- package/dist/public/styles.css +3 -0
- package/dist/web.mjs +16581 -5498
- package/docs/LOCAL_FIRST.md +32 -0
- package/docs/REVIEW_IMPLEMENTATION_PLAN.md +22 -0
- package/package.json +7 -5
- package/packages/core/src/data-health.ts +161 -0
- package/packages/core/src/fit-explain.ts +63 -0
- package/packages/core/src/index.ts +3 -0
- package/packages/core/src/jobsearch.ts +96 -55
- package/packages/core/src/reuse-insights.ts +126 -0
- package/packages/db/src/connection.ts +46 -26
- package/packages/db/src/index.ts +2 -0
- package/packages/db/src/interview-retrospectives.ts +149 -0
- package/packages/db/src/provenance.ts +125 -0
- package/packages/prompts/src/system.ts +21 -64
- package/tsconfig.json +9 -10
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Local-first data ownership
|
|
2
|
+
|
|
3
|
+
CareerMate uses **local-first** to mean that the primary career workspace is owned by the user and stored on the user's machine.
|
|
4
|
+
|
|
5
|
+
## Stored locally
|
|
6
|
+
|
|
7
|
+
- SQLite database
|
|
8
|
+
- profile, experience, project, and skill records
|
|
9
|
+
- résumés, career descriptions, and cover-letter versions
|
|
10
|
+
- saved job postings, application status, interview preparation, and timeline history
|
|
11
|
+
- backups and exports
|
|
12
|
+
- deterministic verification results
|
|
13
|
+
|
|
14
|
+
These records are not uploaded to a CareerMate account or CareerMate-operated database.
|
|
15
|
+
|
|
16
|
+
## Optional network activity
|
|
17
|
+
|
|
18
|
+
Some features may use the network without changing the local-first model:
|
|
19
|
+
|
|
20
|
+
- checking the npm registry for a newer CareerMate version
|
|
21
|
+
- opening current installation documentation
|
|
22
|
+
- searching supported public job-board endpoints using only the user's search keyword
|
|
23
|
+
- loading the public product website or demo
|
|
24
|
+
- public-site analytics on `careermate.life`
|
|
25
|
+
|
|
26
|
+
CareerMate must not attach locally stored résumé, profile, document, or application data to these requests.
|
|
27
|
+
|
|
28
|
+
## Product language
|
|
29
|
+
|
|
30
|
+
Do not describe CareerMate as fully offline. Prefer:
|
|
31
|
+
|
|
32
|
+
> Your career database and documents stay on your computer. Optional discovery and update features may access the internet without sending your stored career data.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Full Review Implementation Plan
|
|
2
|
+
|
|
3
|
+
This document tracks the repository-wide improvements implemented after the 2026-08 full review.
|
|
4
|
+
|
|
5
|
+
## Product definition
|
|
6
|
+
|
|
7
|
+
CareerMate is local-first: the primary database, career records, generated documents, and core workflows are owned and stored on the user's machine. Optional network-backed features such as install guidance, update checks, analytics, and job-board search do not change that definition.
|
|
8
|
+
|
|
9
|
+
## Delivery groups
|
|
10
|
+
|
|
11
|
+
1. Data safety and lifecycle
|
|
12
|
+
2. Provenance and human verification
|
|
13
|
+
3. Backup, restore, and portability
|
|
14
|
+
4. Provider validation and health
|
|
15
|
+
5. Onboarding and diagnostics
|
|
16
|
+
6. Workflow history and explainability
|
|
17
|
+
7. Fit analysis quality and contradiction detection
|
|
18
|
+
8. Front-end maintainability and accessibility
|
|
19
|
+
9. CI, release, and security hardening
|
|
20
|
+
10. Documentation and SEO
|
|
21
|
+
|
|
22
|
+
Each group should be implemented as an independently reviewable commit or PR with tests and migration coverage where applicable.
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "careermate",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.10",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "CareerMate — an MCP-first, local-first career workspace.
|
|
5
|
+
"description": "CareerMate — an MCP-first, local-first career workspace. Your career database and documents stay on your machine; optional discovery and update features may use the internet without sending stored career data.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "CareerMate",
|
|
8
8
|
"homepage": "https://careermate.life",
|
|
@@ -41,6 +41,8 @@
|
|
|
41
41
|
"INSTALL.md",
|
|
42
42
|
"THIRD_PARTY_NOTICES.md",
|
|
43
43
|
"AGENTS.md",
|
|
44
|
+
"docs/LOCAL_FIRST.md",
|
|
45
|
+
"docs/REVIEW_IMPLEMENTATION_PLAN.md",
|
|
44
46
|
"docs/career-os/README.md",
|
|
45
47
|
"docs/career-os/eop",
|
|
46
48
|
"docs/career-os/knowledge",
|
|
@@ -80,13 +82,13 @@
|
|
|
80
82
|
"mammoth": "^1.12.0",
|
|
81
83
|
"pdfjs-dist": "^6.0.0",
|
|
82
84
|
"tsx": "^4.19.2",
|
|
83
|
-
"zod": "^
|
|
85
|
+
"zod": "^4.4.3"
|
|
84
86
|
},
|
|
85
87
|
"devDependencies": {
|
|
86
88
|
"@playwright/test": "^1.60.0",
|
|
87
|
-
"@types/node": "^
|
|
89
|
+
"@types/node": "^26.1.2",
|
|
88
90
|
"esbuild": "^0.28.1",
|
|
89
91
|
"playwright": "^1.60.0",
|
|
90
|
-
"typescript": "^
|
|
92
|
+
"typescript": "^7.0.2"
|
|
91
93
|
}
|
|
92
94
|
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import {
|
|
2
|
+
documentRepo,
|
|
3
|
+
experienceRepo,
|
|
4
|
+
projectRepo,
|
|
5
|
+
jobRepo,
|
|
6
|
+
fitRepo,
|
|
7
|
+
applicationRepo,
|
|
8
|
+
} from '@careermate/db';
|
|
9
|
+
|
|
10
|
+
export interface DataHealthIssue {
|
|
11
|
+
code: string;
|
|
12
|
+
severity: 'info' | 'warning' | 'error';
|
|
13
|
+
entity_type: string;
|
|
14
|
+
entity_id?: string;
|
|
15
|
+
message: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DataHealthReport {
|
|
19
|
+
score: number;
|
|
20
|
+
checked_at: string;
|
|
21
|
+
counts: Record<string, number>;
|
|
22
|
+
issues: DataHealthIssue[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function monthValue(value: string | null | undefined): number | null {
|
|
26
|
+
if (!value) return null;
|
|
27
|
+
const m = /^(\d{4})-(\d{2})/.exec(value);
|
|
28
|
+
if (!m) return null;
|
|
29
|
+
return Number(m[1]) * 12 + Number(m[2]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalized(value: string | null | undefined): string {
|
|
33
|
+
return (value ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getDataHealthReport(now = new Date()): DataHealthReport {
|
|
37
|
+
const experiences = experienceRepo.list();
|
|
38
|
+
const projects = projectRepo.list();
|
|
39
|
+
const documents = documentRepo.list();
|
|
40
|
+
const jobs = jobRepo.list();
|
|
41
|
+
const issues: DataHealthIssue[] = [];
|
|
42
|
+
|
|
43
|
+
for (const exp of experiences) {
|
|
44
|
+
const start = monthValue(exp.start_date);
|
|
45
|
+
const end = monthValue(exp.end_date);
|
|
46
|
+
if (start != null && end != null && end < start) {
|
|
47
|
+
issues.push({
|
|
48
|
+
code: 'experience_date_reversed',
|
|
49
|
+
severity: 'error',
|
|
50
|
+
entity_type: 'experience',
|
|
51
|
+
entity_id: exp.id,
|
|
52
|
+
message: `${exp.company} 경력의 종료일이 시작일보다 빠릅니다.`,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
if (exp.is_current && exp.end_date) {
|
|
56
|
+
issues.push({
|
|
57
|
+
code: 'current_experience_has_end_date',
|
|
58
|
+
severity: 'warning',
|
|
59
|
+
entity_type: 'experience',
|
|
60
|
+
entity_id: exp.id,
|
|
61
|
+
message: `${exp.company} 경력이 재직 중이지만 종료일이 입력돼 있습니다.`,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (!exp.role) {
|
|
65
|
+
issues.push({
|
|
66
|
+
code: 'experience_missing_role',
|
|
67
|
+
severity: 'warning',
|
|
68
|
+
entity_type: 'experience',
|
|
69
|
+
entity_id: exp.id,
|
|
70
|
+
message: `${exp.company} 경력에 직무/역할이 없습니다.`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const seenProjects = new Map<string, string>();
|
|
76
|
+
for (const project of projects) {
|
|
77
|
+
const key = normalized(project.name);
|
|
78
|
+
const duplicate = seenProjects.get(key);
|
|
79
|
+
if (key && duplicate) {
|
|
80
|
+
issues.push({
|
|
81
|
+
code: 'duplicate_project_name',
|
|
82
|
+
severity: 'warning',
|
|
83
|
+
entity_type: 'project',
|
|
84
|
+
entity_id: project.id,
|
|
85
|
+
message: `같은 이름의 프로젝트가 중복돼 있습니다: ${project.name}`,
|
|
86
|
+
});
|
|
87
|
+
} else if (key) {
|
|
88
|
+
seenProjects.set(key, project.id);
|
|
89
|
+
}
|
|
90
|
+
const start = monthValue(project.start_date);
|
|
91
|
+
const end = monthValue(project.end_date);
|
|
92
|
+
if (start != null && end != null && end < start) {
|
|
93
|
+
issues.push({
|
|
94
|
+
code: 'project_date_reversed',
|
|
95
|
+
severity: 'error',
|
|
96
|
+
entity_type: 'project',
|
|
97
|
+
entity_id: project.id,
|
|
98
|
+
message: `${project.name} 프로젝트의 종료일이 시작일보다 빠릅니다.`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const primaryDocs = documents.filter((doc) => doc.is_primary);
|
|
104
|
+
if (documents.length > 0 && primaryDocs.length === 0) {
|
|
105
|
+
issues.push({
|
|
106
|
+
code: 'missing_primary_document',
|
|
107
|
+
severity: 'warning',
|
|
108
|
+
entity_type: 'document',
|
|
109
|
+
message: '대표 이력서 또는 경력기술서가 지정되지 않았습니다.',
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const nowDate = now.toISOString().slice(0, 10);
|
|
114
|
+
let staleDrafts = 0;
|
|
115
|
+
let jobsWithoutFit = 0;
|
|
116
|
+
for (const job of jobs) {
|
|
117
|
+
if (!fitRepo.getByJob(job.id)) jobsWithoutFit++;
|
|
118
|
+
const app = applicationRepo.getByJob(job.id);
|
|
119
|
+
if (app?.status === 'draft') {
|
|
120
|
+
const updated = String(app.updated_at ?? '').slice(0, 10);
|
|
121
|
+
if (updated && updated < nowDate) staleDrafts++;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (jobsWithoutFit > 0) {
|
|
125
|
+
issues.push({
|
|
126
|
+
code: 'jobs_without_fit_analysis',
|
|
127
|
+
severity: 'info',
|
|
128
|
+
entity_type: 'job',
|
|
129
|
+
message: `적합도 분석이 없는 공고가 ${jobsWithoutFit}개 있습니다.`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
if (staleDrafts > 0) {
|
|
133
|
+
issues.push({
|
|
134
|
+
code: 'stale_draft_applications',
|
|
135
|
+
severity: 'info',
|
|
136
|
+
entity_type: 'application',
|
|
137
|
+
message: `후속 행동을 확인할 임시 지원 건이 ${staleDrafts}개 있습니다.`,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const penalty = issues.reduce((sum, issue) => {
|
|
142
|
+
if (issue.severity === 'error') return sum + 15;
|
|
143
|
+
if (issue.severity === 'warning') return sum + 7;
|
|
144
|
+
return sum + 2;
|
|
145
|
+
}, 0);
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
score: Math.max(0, 100 - penalty),
|
|
149
|
+
checked_at: now.toISOString(),
|
|
150
|
+
counts: {
|
|
151
|
+
experiences: experiences.length,
|
|
152
|
+
projects: projects.length,
|
|
153
|
+
documents: documents.length,
|
|
154
|
+
jobs: jobs.length,
|
|
155
|
+
issues: issues.length,
|
|
156
|
+
errors: issues.filter((issue) => issue.severity === 'error').length,
|
|
157
|
+
warnings: issues.filter((issue) => issue.severity === 'warning').length,
|
|
158
|
+
},
|
|
159
|
+
issues,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { fitRepo, jobRepo } from '@careermate/db';
|
|
2
|
+
|
|
3
|
+
export interface ExplainableFitAnalysis {
|
|
4
|
+
job_id: string;
|
|
5
|
+
overall_score: number | null;
|
|
6
|
+
requirement_coverage: number | null;
|
|
7
|
+
evidence_strength: number;
|
|
8
|
+
matched_requirements: string[];
|
|
9
|
+
missing_requirements: string[];
|
|
10
|
+
strengths: string[];
|
|
11
|
+
risks: string[];
|
|
12
|
+
recommended_actions: string[];
|
|
13
|
+
explanation: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function ratio(matched: number, missing: number): number | null {
|
|
17
|
+
const total = matched + missing;
|
|
18
|
+
return total === 0 ? null : Math.round((matched / total) * 100);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getExplainableFitAnalysis(jobId: string): ExplainableFitAnalysis | null {
|
|
22
|
+
const job = jobRepo.get(jobId);
|
|
23
|
+
const fit = fitRepo.getByJob(jobId);
|
|
24
|
+
if (!job || !fit) return null;
|
|
25
|
+
|
|
26
|
+
const matched = fit.matched_keywords ?? [];
|
|
27
|
+
const missing = fit.missing_keywords ?? [];
|
|
28
|
+
const coverage = ratio(matched.length, missing.length);
|
|
29
|
+
const evidenceItems = (fit.strengths ?? []).filter((item) => item.trim().length > 0);
|
|
30
|
+
const evidenceStrength = Math.min(100, evidenceItems.length * 20 + matched.length * 5);
|
|
31
|
+
const risks = [
|
|
32
|
+
...(fit.gaps ?? []),
|
|
33
|
+
...missing.map((item) => `근거가 부족한 요구사항: ${item}`),
|
|
34
|
+
];
|
|
35
|
+
const actions = fit.recommendations?.length
|
|
36
|
+
? fit.recommendations
|
|
37
|
+
: missing.map((item) => `${item} 요구에 연결할 실제 경험 또는 학습 근거를 보완하세요.`);
|
|
38
|
+
|
|
39
|
+
const explanationParts = [
|
|
40
|
+
coverage == null
|
|
41
|
+
? '요구사항 충족률을 계산할 수 있는 매칭 정보가 아직 없습니다.'
|
|
42
|
+
: `저장된 키워드 기준 요구사항 충족률은 ${coverage}%입니다.`,
|
|
43
|
+
evidenceStrength >= 70
|
|
44
|
+
? '강점과 근거가 비교적 충분합니다.'
|
|
45
|
+
: evidenceStrength >= 40
|
|
46
|
+
? '일부 근거는 있지만 지원 문서에서 더 구체적으로 연결할 필요가 있습니다.'
|
|
47
|
+
: '현재 분석에는 구체적인 경험 근거가 부족합니다.',
|
|
48
|
+
risks.length > 0 ? `확인할 위험 요소가 ${risks.length}개 있습니다.` : '명시된 주요 위험 요소는 없습니다.',
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
job_id: jobId,
|
|
53
|
+
overall_score: fit.score,
|
|
54
|
+
requirement_coverage: coverage,
|
|
55
|
+
evidence_strength: evidenceStrength,
|
|
56
|
+
matched_requirements: matched,
|
|
57
|
+
missing_requirements: missing,
|
|
58
|
+
strengths: fit.strengths ?? [],
|
|
59
|
+
risks,
|
|
60
|
+
recommended_actions: actions,
|
|
61
|
+
explanation: explanationParts.join(' '),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -8,4 +8,7 @@ export * from './services.ts';
|
|
|
8
8
|
export * from './summary.ts';
|
|
9
9
|
export * from './jobsearch.ts';
|
|
10
10
|
export * from './offers.ts';
|
|
11
|
+
export * from './data-health.ts';
|
|
12
|
+
export * from './fit-explain.ts';
|
|
13
|
+
export * from './reuse-insights.ts';
|
|
11
14
|
export * from './verify/index.ts';
|
|
@@ -2,49 +2,36 @@
|
|
|
2
2
|
* Job search import — pull postings from key-free public job-board endpoints by
|
|
3
3
|
* keyword, so the AI can offer real listings the user can save into CareerMate.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* - No WAF bypass, no auth keys. These providers' own frontends call these JSON
|
|
10
|
-
* endpoints unauthenticated; a plain fetch suffices. We do NOT bundle a
|
|
11
|
-
* scraper/anti-bot evasion (that's the legally dangerous part — see
|
|
12
|
-
* .claude/work/research/research_0004). Sites requiring keys (Saramin oapi,
|
|
13
|
-
* 고용24) or that forbid it in robots.txt (JobKorea search) are excluded here.
|
|
14
|
-
* - Honest User-Agent. We identify ourselves as careermate (verified live:
|
|
15
|
-
* both providers answer 200 to it), never as a spoofed browser — "no bot
|
|
16
|
-
* evasion" must include not lying about who is asking.
|
|
17
|
-
* - Read-only w.r.t. the DB. This returns candidates; the AI shows them and the
|
|
18
|
-
* user picks which to save via save_job_posting (no auto-dumping into the DB).
|
|
19
|
-
*
|
|
20
|
-
* Providers are unofficial public endpoints — if a response shape changes, only
|
|
21
|
-
* the affected provider's parser needs updating (others keep working).
|
|
5
|
+
* CareerMate is local-first: only the search keyword leaves the machine. Stored
|
|
6
|
+
* profile, résumé, application, and document data are never sent to providers.
|
|
7
|
+
* Provider endpoints are unofficial and therefore validated at runtime; a shape
|
|
8
|
+
* change degrades only that provider instead of silently returning corrupt rows.
|
|
22
9
|
*/
|
|
23
10
|
|
|
11
|
+
import { z } from 'zod';
|
|
24
12
|
import { APP_VERSION } from '@careermate/shared';
|
|
25
13
|
|
|
26
14
|
export interface JobSearchResult {
|
|
27
|
-
/** Provider id, e.g. 'wanted' | 'jumpit'. */
|
|
28
15
|
source: string;
|
|
29
16
|
company: string;
|
|
30
17
|
position: string;
|
|
31
|
-
/** Canonical posting URL the user can open. */
|
|
32
18
|
url: string;
|
|
33
19
|
location?: string;
|
|
34
|
-
/** Required experience, human-readable (e.g. '신입', '5~20년'). */
|
|
35
20
|
experience?: string;
|
|
36
|
-
/** Tech stack / tags when the provider exposes them. */
|
|
37
21
|
keywords?: string[];
|
|
38
|
-
/** Application deadline as YYYY-MM-DD when known. */
|
|
39
22
|
deadline?: string;
|
|
40
|
-
/** Provider-native id (for dedup / detail fetch). */
|
|
41
23
|
external_id: string;
|
|
42
24
|
}
|
|
43
25
|
|
|
26
|
+
export type ProviderStatus = 'healthy' | 'degraded' | 'unavailable';
|
|
27
|
+
|
|
44
28
|
export interface ProviderOutcome {
|
|
45
29
|
source: string;
|
|
30
|
+
label: string;
|
|
46
31
|
ok: boolean;
|
|
32
|
+
status: ProviderStatus;
|
|
47
33
|
count: number;
|
|
34
|
+
checked_at: string;
|
|
48
35
|
error?: string;
|
|
49
36
|
}
|
|
50
37
|
|
|
@@ -55,24 +42,20 @@ export interface JobSearchResponse {
|
|
|
55
42
|
}
|
|
56
43
|
|
|
57
44
|
export interface JobSearchOptions {
|
|
58
|
-
/** Which providers to query. Defaults to all registered. */
|
|
59
45
|
sources?: string[];
|
|
60
|
-
/** Max results per provider (1–50). Default 10. */
|
|
61
46
|
limit?: number;
|
|
62
|
-
/** Per-request timeout in ms. Default 10000. */
|
|
63
47
|
timeoutMs?: number;
|
|
64
48
|
}
|
|
65
49
|
|
|
66
50
|
const UA = `careermate/${APP_VERSION} (+https://careermate.life)`;
|
|
67
51
|
|
|
68
|
-
/** Slice an ISO timestamp / date string to YYYY-MM-DD; undefined if not a date. */
|
|
69
52
|
function toYmd(v: unknown): string | undefined {
|
|
70
53
|
if (typeof v !== 'string' || v.length < 10) return undefined;
|
|
71
54
|
const m = v.match(/^(\d{4}-\d{2}-\d{2})/);
|
|
72
55
|
return m ? m[1] : undefined;
|
|
73
56
|
}
|
|
74
57
|
|
|
75
|
-
async function getJson(url: string, timeoutMs: number): Promise<
|
|
58
|
+
async function getJson(url: string, timeoutMs: number): Promise<unknown> {
|
|
76
59
|
const ctrl = new AbortController();
|
|
77
60
|
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
78
61
|
try {
|
|
@@ -81,6 +64,8 @@ async function getJson(url: string, timeoutMs: number): Promise<any> {
|
|
|
81
64
|
signal: ctrl.signal,
|
|
82
65
|
});
|
|
83
66
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
67
|
+
const type = res.headers.get('content-type') ?? '';
|
|
68
|
+
if (!type.includes('json')) throw new Error(`unexpected content-type: ${type || 'unknown'}`);
|
|
84
69
|
return await res.json();
|
|
85
70
|
} finally {
|
|
86
71
|
clearTimeout(timer);
|
|
@@ -93,7 +78,41 @@ interface Provider {
|
|
|
93
78
|
search(query: string, limit: number, timeoutMs: number): Promise<JobSearchResult[]>;
|
|
94
79
|
}
|
|
95
80
|
|
|
96
|
-
|
|
81
|
+
const wantedSchema = z.object({
|
|
82
|
+
data: z.array(
|
|
83
|
+
z.object({
|
|
84
|
+
id: z.union([z.string(), z.number()]),
|
|
85
|
+
position: z.string().min(1),
|
|
86
|
+
company: z.object({ name: z.string().min(1) }),
|
|
87
|
+
annual_from: z.number().nullable().optional(),
|
|
88
|
+
annual_to: z.number().nullable().optional(),
|
|
89
|
+
due_time: z.string().nullable().optional(),
|
|
90
|
+
address: z
|
|
91
|
+
.object({ location: z.string().optional(), full_location: z.string().optional() })
|
|
92
|
+
.nullable()
|
|
93
|
+
.optional(),
|
|
94
|
+
}).passthrough(),
|
|
95
|
+
),
|
|
96
|
+
}).passthrough();
|
|
97
|
+
|
|
98
|
+
const jumpitSchema = z.object({
|
|
99
|
+
result: z.object({
|
|
100
|
+
positions: z.array(
|
|
101
|
+
z.object({
|
|
102
|
+
id: z.union([z.string(), z.number()]),
|
|
103
|
+
companyName: z.string().min(1),
|
|
104
|
+
title: z.string().min(1),
|
|
105
|
+
locations: z.array(z.string()).optional(),
|
|
106
|
+
newcomer: z.boolean().optional(),
|
|
107
|
+
minCareer: z.number().nullable().optional(),
|
|
108
|
+
maxCareer: z.number().nullable().optional(),
|
|
109
|
+
techStacks: z.array(z.string()).optional(),
|
|
110
|
+
closedAt: z.string().nullable().optional(),
|
|
111
|
+
}).passthrough(),
|
|
112
|
+
),
|
|
113
|
+
}).passthrough(),
|
|
114
|
+
}).passthrough();
|
|
115
|
+
|
|
97
116
|
const wanted: Provider = {
|
|
98
117
|
id: 'wanted',
|
|
99
118
|
label: '원티드',
|
|
@@ -101,81 +120,103 @@ const wanted: Provider = {
|
|
|
101
120
|
const url =
|
|
102
121
|
`https://www.wanted.co.kr/api/v4/jobs?country=kr&locations=all&years=-1` +
|
|
103
122
|
`&job_sort=job.latest_order&limit=${limit}&offset=0&query=${encodeURIComponent(query)}`;
|
|
104
|
-
const j = await getJson(url, timeoutMs);
|
|
105
|
-
|
|
106
|
-
return rows.map((r) => {
|
|
123
|
+
const j = wantedSchema.parse(await getJson(url, timeoutMs));
|
|
124
|
+
return j.data.map((r) => {
|
|
107
125
|
const annual =
|
|
108
126
|
r.annual_from || r.annual_to
|
|
109
127
|
? `${r.annual_from ?? 0}~${r.annual_to ?? '?'}년`
|
|
110
128
|
: undefined;
|
|
111
129
|
return {
|
|
112
130
|
source: 'wanted',
|
|
113
|
-
company: r.company
|
|
114
|
-
position: r.position
|
|
131
|
+
company: r.company.name,
|
|
132
|
+
position: r.position,
|
|
115
133
|
url: `https://www.wanted.co.kr/wd/${r.id}`,
|
|
116
|
-
location: r.address?.location ?? r.address?.full_location
|
|
134
|
+
location: r.address?.location ?? r.address?.full_location,
|
|
117
135
|
experience: annual,
|
|
118
136
|
deadline: toYmd(r.due_time),
|
|
119
137
|
external_id: String(r.id),
|
|
120
|
-
}
|
|
138
|
+
};
|
|
121
139
|
});
|
|
122
140
|
},
|
|
123
141
|
};
|
|
124
142
|
|
|
125
|
-
/** Jumpit (jumpit.co.kr) — public positions JSON, no auth. Dev roles, tech stacks. */
|
|
126
143
|
const jumpit: Provider = {
|
|
127
144
|
id: 'jumpit',
|
|
128
145
|
label: '점핏',
|
|
129
146
|
async search(query, limit, timeoutMs) {
|
|
130
147
|
const url =
|
|
131
148
|
`https://api.jumpit.co.kr/api/positions?sort=rsp_rate&page=1&keyword=${encodeURIComponent(query)}`;
|
|
132
|
-
const j = await getJson(url, timeoutMs);
|
|
133
|
-
|
|
134
|
-
return rows.slice(0, limit).map((p) => ({
|
|
149
|
+
const j = jumpitSchema.parse(await getJson(url, timeoutMs));
|
|
150
|
+
return j.result.positions.slice(0, limit).map((p) => ({
|
|
135
151
|
source: 'jumpit',
|
|
136
|
-
company: p.companyName
|
|
137
|
-
position: p.title
|
|
152
|
+
company: p.companyName,
|
|
153
|
+
position: p.title,
|
|
138
154
|
url: `https://www.jumpit.co.kr/position/${p.id}`,
|
|
139
|
-
location:
|
|
155
|
+
location: p.locations?.[0],
|
|
140
156
|
experience: p.newcomer
|
|
141
157
|
? '신입'
|
|
142
158
|
: p.minCareer != null
|
|
143
159
|
? `${p.minCareer}~${p.maxCareer ?? p.minCareer}년`
|
|
144
160
|
: undefined,
|
|
145
|
-
keywords:
|
|
161
|
+
keywords: p.techStacks,
|
|
146
162
|
deadline: toYmd(p.closedAt),
|
|
147
163
|
external_id: String(p.id),
|
|
148
|
-
}
|
|
164
|
+
}));
|
|
149
165
|
},
|
|
150
166
|
};
|
|
151
167
|
|
|
152
168
|
const PROVIDERS: Provider[] = [wanted, jumpit];
|
|
153
|
-
|
|
154
|
-
/** Registered provider ids (key-free, no WAF bypass). */
|
|
155
169
|
export const JOB_SEARCH_SOURCES = PROVIDERS.map((p) => p.id);
|
|
156
170
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
171
|
+
function providerError(e: unknown): { status: ProviderStatus; error: string } {
|
|
172
|
+
if (e instanceof z.ZodError) {
|
|
173
|
+
const first = e.issues[0];
|
|
174
|
+
return {
|
|
175
|
+
status: 'degraded',
|
|
176
|
+
error: `provider response changed${first ? ` (${first.path.join('.') || 'root'})` : ''}`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
180
|
+
return { status: 'unavailable', error: message };
|
|
181
|
+
}
|
|
182
|
+
|
|
162
183
|
export async function searchJobs(query: string, opts: JobSearchOptions = {}): Promise<JobSearchResponse> {
|
|
163
184
|
const q = (query ?? '').trim();
|
|
164
185
|
if (!q) return { query: '', results: [], providers: [] };
|
|
165
186
|
const limit = Math.min(50, Math.max(1, opts.limit ?? 10));
|
|
166
|
-
const timeoutMs = opts.timeoutMs ??
|
|
187
|
+
const timeoutMs = Math.min(30_000, Math.max(1_000, opts.timeoutMs ?? 10_000));
|
|
167
188
|
const chosen = opts.sources?.length
|
|
168
189
|
? PROVIDERS.filter((p) => opts.sources!.includes(p.id))
|
|
169
190
|
: PROVIDERS;
|
|
170
191
|
|
|
171
192
|
const settled = await Promise.all(
|
|
172
193
|
chosen.map(async (p): Promise<{ outcome: ProviderOutcome; results: JobSearchResult[] }> => {
|
|
194
|
+
const checked_at = new Date().toISOString();
|
|
173
195
|
try {
|
|
174
196
|
const results = await p.search(q, limit, timeoutMs);
|
|
175
|
-
return {
|
|
197
|
+
return {
|
|
198
|
+
outcome: {
|
|
199
|
+
source: p.id,
|
|
200
|
+
label: p.label,
|
|
201
|
+
ok: true,
|
|
202
|
+
status: 'healthy',
|
|
203
|
+
count: results.length,
|
|
204
|
+
checked_at,
|
|
205
|
+
},
|
|
206
|
+
results,
|
|
207
|
+
};
|
|
176
208
|
} catch (e) {
|
|
209
|
+
const failure = providerError(e);
|
|
177
210
|
return {
|
|
178
|
-
outcome: {
|
|
211
|
+
outcome: {
|
|
212
|
+
source: p.id,
|
|
213
|
+
label: p.label,
|
|
214
|
+
ok: false,
|
|
215
|
+
status: failure.status,
|
|
216
|
+
count: 0,
|
|
217
|
+
checked_at,
|
|
218
|
+
error: failure.error,
|
|
219
|
+
},
|
|
179
220
|
results: [],
|
|
180
221
|
};
|
|
181
222
|
}
|