careermate 0.0.9 → 0.0.12
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/AGENTS.md +3 -2
- package/INSTALL.md +6 -6
- package/LICENSE +1 -1
- package/README.md +18 -16
- package/dist/career-os/README.md +2 -2
- package/dist/doctor.mjs +17857 -6595
- package/dist/mcp.mjs +14545 -3839
- package/dist/migrate.mjs +14258 -3709
- package/dist/pdf.worker.min.mjs +4 -4
- package/dist/public/app.js +3 -18
- 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/en/settings.js +1 -6
- 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/i18n/ko/settings.js +1 -6
- package/dist/public/index.html +0 -1
- package/dist/public/lib.js +13 -4
- package/dist/public/pages/documents.js +27 -10
- package/dist/public/pages/home.js +15 -5
- package/dist/public/pages/insights.js +156 -0
- package/dist/public/pages/jobs.js +21 -3
- package/dist/public/pages/settings.js +13 -12
- package/dist/public/styles.css +33 -0
- package/dist/web.mjs +16890 -5492
- package/docs/FAQ.md +3 -4
- package/docs/LOCAL_FIRST.md +32 -0
- package/docs/MCP_TOOLS.md +6 -3
- package/docs/REVIEW_IMPLEMENTATION_PLAN.md +22 -0
- package/docs/career-os/README.md +2 -2
- package/package.json +7 -12
- package/packages/core/src/data-health.ts +161 -0
- package/packages/core/src/fit-explain.ts +63 -0
- package/packages/core/src/index.ts +4 -0
- package/packages/core/src/job-categories.data.ts +140 -0
- package/packages/core/src/job-categories.ts +106 -0
- package/packages/core/src/jobsearch.ts +374 -66
- package/packages/core/src/reuse-insights.ts +126 -0
- package/packages/db/src/connection.ts +46 -26
- package/packages/db/src/index.ts +3 -0
- package/packages/db/src/interview-retrospectives.ts +145 -0
- package/packages/db/src/job-search.ts +100 -0
- package/packages/db/src/provenance.ts +125 -0
- package/packages/db/src/repositories.ts +1 -0
- package/packages/mcp-tools/src/tools.ts +115 -11
- package/packages/prompts/src/system.ts +21 -64
- package/scripts/init.ts +2 -0
- package/tsconfig.json +9 -10
|
@@ -2,49 +2,47 @@
|
|
|
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
|
-
*
|
|
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 and category filters leave the
|
|
6
|
+
* machine. Stored profile, résumé, application, and document data are never sent to
|
|
7
|
+
* providers. Locally it remembers which postings it has already seen (board id + date).
|
|
8
|
+
* Provider endpoints are unofficial and therefore validated at runtime; a shape
|
|
9
|
+
* change degrades only that provider instead of silently returning corrupt rows.
|
|
22
10
|
*/
|
|
23
11
|
|
|
12
|
+
import { z } from 'zod';
|
|
24
13
|
import { APP_VERSION } from '@careermate/shared';
|
|
14
|
+
import { markJobsSeen } from '@careermate/db';
|
|
15
|
+
import { isRememberCategory, isSaraminCategory } from './job-categories.ts';
|
|
25
16
|
|
|
26
17
|
export interface JobSearchResult {
|
|
27
|
-
/** Provider id, e.g. 'wanted' | 'jumpit'. */
|
|
28
18
|
source: string;
|
|
29
19
|
company: string;
|
|
30
20
|
position: string;
|
|
31
|
-
/** Canonical posting URL the user can open. */
|
|
32
21
|
url: string;
|
|
33
22
|
location?: string;
|
|
34
|
-
/** Required experience, human-readable (e.g. '신입', '5~20년'). */
|
|
35
23
|
experience?: string;
|
|
36
|
-
/** Tech stack / tags when the provider exposes them. */
|
|
37
24
|
keywords?: string[];
|
|
38
|
-
/** Application deadline as YYYY-MM-DD when known. */
|
|
39
25
|
deadline?: string;
|
|
40
|
-
/** Provider-native id (for dedup / detail fetch). */
|
|
41
26
|
external_id: string;
|
|
27
|
+
/** Saramin only: category codes of this posting, reusable in `saraminCategories`. */
|
|
28
|
+
category_codes?: string[];
|
|
29
|
+
/** Board's own posting date (Remember, Saramin "등록일"); Wanted/Jumpit lists don't carry one. */
|
|
30
|
+
posted_at?: string;
|
|
31
|
+
/** Day CareerMate first returned this posting (YYYY-MM-DD). */
|
|
32
|
+
first_seen_at?: string;
|
|
33
|
+
/** The same posting found on other boards (same company + title). */
|
|
34
|
+
also_on?: { source: string; url: string }[];
|
|
42
35
|
}
|
|
43
36
|
|
|
37
|
+
export type ProviderStatus = 'healthy' | 'degraded' | 'unavailable';
|
|
38
|
+
|
|
44
39
|
export interface ProviderOutcome {
|
|
45
40
|
source: string;
|
|
41
|
+
label: string;
|
|
46
42
|
ok: boolean;
|
|
43
|
+
status: ProviderStatus;
|
|
47
44
|
count: number;
|
|
45
|
+
checked_at: string;
|
|
48
46
|
error?: string;
|
|
49
47
|
}
|
|
50
48
|
|
|
@@ -52,36 +50,69 @@ export interface JobSearchResponse {
|
|
|
52
50
|
query: string;
|
|
53
51
|
results: JobSearchResult[];
|
|
54
52
|
providers: ProviderOutcome[];
|
|
53
|
+
/** Results dropped by `postedSince`. */
|
|
54
|
+
filtered_out?: number;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
export interface JobSearchOptions {
|
|
58
|
-
/** Which providers to query. Defaults to all registered. */
|
|
59
58
|
sources?: string[];
|
|
60
|
-
/** Max results per provider (1–50). Default 10. */
|
|
61
59
|
limit?: number;
|
|
62
|
-
/** Per-request timeout in ms. Default 10000. */
|
|
63
60
|
timeoutMs?: number;
|
|
61
|
+
/** Remember categories: "SW개발>프론트엔드" or a whole group "SW개발" (see findJobCategories). */
|
|
62
|
+
rememberCategories?: string[];
|
|
63
|
+
/** Saramin category codes (cat_kewd), e.g. ["92", "84"]. */
|
|
64
|
+
saraminCategories?: string[];
|
|
65
|
+
/** Keep only postings dated on/after this day (YYYY-MM-DD): posted_at, else first_seen_at. */
|
|
66
|
+
postedSince?: string;
|
|
67
|
+
/** Record first-seen dates in the local DB (default true; tests turn it off). */
|
|
68
|
+
trackSeen?: boolean;
|
|
69
|
+
/** "Today" for first-seen/postedSince, injectable for tests. */
|
|
70
|
+
today?: string;
|
|
64
71
|
}
|
|
65
72
|
|
|
66
73
|
const UA = `careermate/${APP_VERSION} (+https://careermate.life)`;
|
|
67
74
|
|
|
68
|
-
/** Slice an ISO timestamp / date string to YYYY-MM-DD; undefined if not a date. */
|
|
69
75
|
function toYmd(v: unknown): string | undefined {
|
|
70
76
|
if (typeof v !== 'string' || v.length < 10) return undefined;
|
|
71
77
|
const m = v.match(/^(\d{4}-\d{2}-\d{2})/);
|
|
72
78
|
return m ? m[1] : undefined;
|
|
73
79
|
}
|
|
74
80
|
|
|
75
|
-
|
|
81
|
+
/** Calendar day in Korea for a timestamp with an offset ("...+09:00" or "...Z"), so "today" matches the board's day. */
|
|
82
|
+
function kstYmd(v: unknown): string | undefined {
|
|
83
|
+
if (typeof v !== 'string' || !/T.*(Z|[+-]\d{2}:?\d{2})$/.test(v)) return toYmd(v);
|
|
84
|
+
const d = new Date(v);
|
|
85
|
+
return Number.isNaN(d.getTime()) ? undefined : d.toLocaleDateString('sv-SE', { timeZone: 'Asia/Seoul' });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface GetJsonInit {
|
|
89
|
+
/** When set, POST it as a JSON body. */
|
|
90
|
+
body?: unknown;
|
|
91
|
+
headers?: Record<string, string>;
|
|
92
|
+
/** Skip the content-type check (Saramin serves its JSON as text/html). */
|
|
93
|
+
anyType?: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function getJson(url: string, timeoutMs: number, init: GetJsonInit = {}): Promise<unknown> {
|
|
76
97
|
const ctrl = new AbortController();
|
|
77
98
|
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
78
99
|
try {
|
|
79
100
|
const res = await fetch(url, {
|
|
80
|
-
|
|
101
|
+
method: init.body === undefined ? 'GET' : 'POST',
|
|
102
|
+
headers: {
|
|
103
|
+
accept: 'application/json',
|
|
104
|
+
'user-agent': UA,
|
|
105
|
+
'accept-language': 'ko,en;q=0.8',
|
|
106
|
+
...(init.body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
107
|
+
...init.headers,
|
|
108
|
+
},
|
|
109
|
+
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
|
81
110
|
signal: ctrl.signal,
|
|
82
111
|
});
|
|
83
112
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
84
|
-
|
|
113
|
+
const type = res.headers.get('content-type') ?? '';
|
|
114
|
+
if (!init.anyType && !type.includes('json')) throw new Error(`unexpected content-type: ${type || 'unknown'}`);
|
|
115
|
+
return JSON.parse(await res.text());
|
|
85
116
|
} finally {
|
|
86
117
|
clearTimeout(timer);
|
|
87
118
|
}
|
|
@@ -90,10 +121,46 @@ async function getJson(url: string, timeoutMs: number): Promise<any> {
|
|
|
90
121
|
interface Provider {
|
|
91
122
|
id: string;
|
|
92
123
|
label: string;
|
|
93
|
-
search
|
|
124
|
+
/** Option that lets this provider search by category alone, without a keyword. */
|
|
125
|
+
categoryOption?: 'rememberCategories' | 'saraminCategories';
|
|
126
|
+
search(query: string, limit: number, timeoutMs: number, opts: JobSearchOptions): Promise<JobSearchResult[]>;
|
|
94
127
|
}
|
|
95
128
|
|
|
96
|
-
|
|
129
|
+
const wantedSchema = z.object({
|
|
130
|
+
data: z.array(
|
|
131
|
+
z.object({
|
|
132
|
+
id: z.union([z.string(), z.number()]),
|
|
133
|
+
position: z.string().min(1),
|
|
134
|
+
company: z.object({ name: z.string().min(1) }),
|
|
135
|
+
annual_from: z.number().nullable().optional(),
|
|
136
|
+
annual_to: z.number().nullable().optional(),
|
|
137
|
+
due_time: z.string().nullable().optional(),
|
|
138
|
+
address: z
|
|
139
|
+
.object({ location: z.string().optional(), full_location: z.string().optional() })
|
|
140
|
+
.nullable()
|
|
141
|
+
.optional(),
|
|
142
|
+
}).passthrough(),
|
|
143
|
+
),
|
|
144
|
+
}).passthrough();
|
|
145
|
+
|
|
146
|
+
const jumpitSchema = z.object({
|
|
147
|
+
result: z.object({
|
|
148
|
+
positions: z.array(
|
|
149
|
+
z.object({
|
|
150
|
+
id: z.union([z.string(), z.number()]),
|
|
151
|
+
companyName: z.string().min(1),
|
|
152
|
+
title: z.string().min(1),
|
|
153
|
+
locations: z.array(z.string()).optional(),
|
|
154
|
+
newcomer: z.boolean().optional(),
|
|
155
|
+
minCareer: z.number().nullable().optional(),
|
|
156
|
+
maxCareer: z.number().nullable().optional(),
|
|
157
|
+
techStacks: z.array(z.string()).optional(),
|
|
158
|
+
closedAt: z.string().nullable().optional(),
|
|
159
|
+
}).passthrough(),
|
|
160
|
+
),
|
|
161
|
+
}).passthrough(),
|
|
162
|
+
}).passthrough();
|
|
163
|
+
|
|
97
164
|
const wanted: Provider = {
|
|
98
165
|
id: 'wanted',
|
|
99
166
|
label: '원티드',
|
|
@@ -101,90 +168,331 @@ const wanted: Provider = {
|
|
|
101
168
|
const url =
|
|
102
169
|
`https://www.wanted.co.kr/api/v4/jobs?country=kr&locations=all&years=-1` +
|
|
103
170
|
`&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) => {
|
|
171
|
+
const j = wantedSchema.parse(await getJson(url, timeoutMs));
|
|
172
|
+
return j.data.map((r) => {
|
|
107
173
|
const annual =
|
|
108
174
|
r.annual_from || r.annual_to
|
|
109
175
|
? `${r.annual_from ?? 0}~${r.annual_to ?? '?'}년`
|
|
110
176
|
: undefined;
|
|
111
177
|
return {
|
|
112
178
|
source: 'wanted',
|
|
113
|
-
company: r.company
|
|
114
|
-
position: r.position
|
|
179
|
+
company: r.company.name,
|
|
180
|
+
position: r.position,
|
|
115
181
|
url: `https://www.wanted.co.kr/wd/${r.id}`,
|
|
116
|
-
location: r.address?.location ?? r.address?.full_location
|
|
182
|
+
location: r.address?.location ?? r.address?.full_location,
|
|
117
183
|
experience: annual,
|
|
118
184
|
deadline: toYmd(r.due_time),
|
|
119
185
|
external_id: String(r.id),
|
|
120
|
-
}
|
|
186
|
+
};
|
|
121
187
|
});
|
|
122
188
|
},
|
|
123
189
|
};
|
|
124
190
|
|
|
125
|
-
/** Jumpit (jumpit.co.kr) — public positions JSON, no auth. Dev roles, tech stacks. */
|
|
126
191
|
const jumpit: Provider = {
|
|
127
192
|
id: 'jumpit',
|
|
128
193
|
label: '점핏',
|
|
129
194
|
async search(query, limit, timeoutMs) {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
195
|
+
// Fixed page size of 16; keep paging until limit or a short page.
|
|
196
|
+
const positions: z.infer<typeof jumpitSchema>['result']['positions'] = [];
|
|
197
|
+
for (let page = 1; positions.length < limit && page <= Math.ceil(limit / 16); page++) {
|
|
198
|
+
const url =
|
|
199
|
+
`https://jumpit-api.saramin.co.kr/api/positions?sort=rsp_rate&page=${page}&keyword=${encodeURIComponent(query)}`;
|
|
200
|
+
const got = jumpitSchema.parse(await getJson(url, timeoutMs)).result.positions;
|
|
201
|
+
positions.push(...got);
|
|
202
|
+
if (got.length < 16) break;
|
|
203
|
+
}
|
|
204
|
+
return positions.slice(0, limit).map((p) => ({
|
|
135
205
|
source: 'jumpit',
|
|
136
|
-
company: p.companyName
|
|
137
|
-
|
|
206
|
+
company: p.companyName,
|
|
207
|
+
// Keyword searches come back with highlight markup, e.g. "<span>백엔드</span> 개발자".
|
|
208
|
+
position: p.title.replace(/<[^>]*>/g, ''),
|
|
138
209
|
url: `https://www.jumpit.co.kr/position/${p.id}`,
|
|
139
|
-
location:
|
|
210
|
+
location: p.locations?.[0],
|
|
140
211
|
experience: p.newcomer
|
|
141
212
|
? '신입'
|
|
142
213
|
: p.minCareer != null
|
|
143
214
|
? `${p.minCareer}~${p.maxCareer ?? p.minCareer}년`
|
|
144
215
|
: undefined,
|
|
145
|
-
keywords:
|
|
216
|
+
keywords: p.techStacks,
|
|
146
217
|
deadline: toYmd(p.closedAt),
|
|
147
218
|
external_id: String(p.id),
|
|
148
|
-
}
|
|
219
|
+
}));
|
|
149
220
|
},
|
|
150
221
|
};
|
|
151
222
|
|
|
152
|
-
const
|
|
223
|
+
const rememberSchema = z.object({
|
|
224
|
+
data: z.array(
|
|
225
|
+
z.object({
|
|
226
|
+
id: z.union([z.string(), z.number()]),
|
|
227
|
+
title: z.string().min(1),
|
|
228
|
+
organization: z.object({ name: z.string().min(1) }).passthrough(),
|
|
229
|
+
min_experience: z.number().nullable().optional(),
|
|
230
|
+
max_experience: z.number().nullable().optional(),
|
|
231
|
+
ends_at: z.string().nullable().optional(),
|
|
232
|
+
starts_at: z.string().nullable().optional(),
|
|
233
|
+
addresses: z
|
|
234
|
+
.array(z.object({ address_level1: z.string().nullable().optional(), address_level2: z.string().nullable().optional() }).passthrough())
|
|
235
|
+
.nullable()
|
|
236
|
+
.optional(),
|
|
237
|
+
job_categories: z.array(z.object({ level2: z.string().nullable().optional() }).passthrough()).nullable().optional(),
|
|
238
|
+
}).passthrough(),
|
|
239
|
+
),
|
|
240
|
+
}).passthrough();
|
|
153
241
|
|
|
154
|
-
|
|
155
|
-
|
|
242
|
+
const remember: Provider = {
|
|
243
|
+
id: 'remember',
|
|
244
|
+
label: '리멤버',
|
|
245
|
+
categoryOption: 'rememberCategories',
|
|
246
|
+
async search(query, limit, timeoutMs, opts) {
|
|
247
|
+
const cats = opts.rememberCategories ?? [];
|
|
248
|
+
// Remember ignores names it doesn't know and returns everything, so reject them up front.
|
|
249
|
+
const unknown = cats.filter((c) => !isRememberCategory(c));
|
|
250
|
+
if (unknown.length) throw new Error(`unknown Remember category: ${unknown.join(', ')} (use find_job_categories)`);
|
|
251
|
+
const search: Record<string, unknown> = {
|
|
252
|
+
keywords: query ? [query] : [],
|
|
253
|
+
organization_type: 'all',
|
|
254
|
+
application_type: 'all',
|
|
255
|
+
};
|
|
256
|
+
if (cats.length) {
|
|
257
|
+
search.job_category_names = cats.map((c) => {
|
|
258
|
+
const [level1, level2] = c.split('>').map((s) => s.trim());
|
|
259
|
+
return level2 ? { level1, level2 } : { level1 };
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
// Remember rejects per > 50 ("최대 요청 수를 초과"), so larger limits take a second page.
|
|
263
|
+
const per = Math.min(limit, 50);
|
|
264
|
+
const pages = await Promise.all(
|
|
265
|
+
Array.from({ length: Math.ceil(limit / per) }, (_, i) =>
|
|
266
|
+
getJson('https://career-api.rememberapp.co.kr/job_postings/search', timeoutMs, {
|
|
267
|
+
body: { search, sort: 'starts_at_desc', page: i + 1, per },
|
|
268
|
+
}).then((r) => rememberSchema.parse(r).data),
|
|
269
|
+
),
|
|
270
|
+
);
|
|
271
|
+
return pages.flat().slice(0, limit).map((d) => {
|
|
272
|
+
const addr = d.addresses?.[0];
|
|
273
|
+
const min = d.min_experience;
|
|
274
|
+
const max = d.max_experience;
|
|
275
|
+
return {
|
|
276
|
+
source: 'remember',
|
|
277
|
+
company: d.organization.name,
|
|
278
|
+
position: d.title,
|
|
279
|
+
url: `https://career.rememberapp.co.kr/job/posting/${d.id}`,
|
|
280
|
+
location: addr ? [addr.address_level1, addr.address_level2].filter(Boolean).join(' ') || undefined : undefined,
|
|
281
|
+
experience:
|
|
282
|
+
min != null && max != null ? `${min}~${max}년` : min != null ? `${min}년 이상` : max != null ? `~${max}년` : undefined,
|
|
283
|
+
keywords: d.job_categories?.map((c) => c.level2).filter((s): s is string => !!s),
|
|
284
|
+
deadline: toYmd(d.ends_at),
|
|
285
|
+
external_id: String(d.id),
|
|
286
|
+
posted_at: kstYmd(d.starts_at),
|
|
287
|
+
};
|
|
288
|
+
});
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const saraminListSchema = z.object({ innerHTML: z.string() }).passthrough();
|
|
293
|
+
|
|
294
|
+
function decodeHtml(s: string): string {
|
|
295
|
+
return s
|
|
296
|
+
.replace(/<[^>]*>/g, ' ')
|
|
297
|
+
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
|
|
298
|
+
.replace(/"/g, '"')
|
|
299
|
+
.replace(/</g, '<')
|
|
300
|
+
.replace(/>/g, '>')
|
|
301
|
+
.replace(/ /g, ' ')
|
|
302
|
+
.replace(/&/g, '&')
|
|
303
|
+
.replace(/\s+/g, ' ')
|
|
304
|
+
.trim();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** "~ 12/04(금)" → YYYY-MM-DD; a month earlier than today's rolls into next year. Other labels (상시채용 etc.) → undefined. */
|
|
308
|
+
function saraminDeadline(text: string, now = new Date()): string | undefined {
|
|
309
|
+
const m = text.match(/(\d{1,2})\/(\d{1,2})/);
|
|
310
|
+
if (!m) return undefined;
|
|
311
|
+
const month = Number(m[1]);
|
|
312
|
+
const year = now.getFullYear() + (month < now.getMonth() + 1 ? 1 : 0);
|
|
313
|
+
return `${year}-${m[1].padStart(2, '0')}-${m[2].padStart(2, '0')}`;
|
|
314
|
+
}
|
|
156
315
|
|
|
157
316
|
/**
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
317
|
+
* Parse the HTML fragment of Saramin's search list (`get-recruit-list` → innerHTML).
|
|
318
|
+
* Items missing an id, title or company are dropped; an empty result from a
|
|
319
|
+
* non-empty page means the markup changed, which the caller reports as degraded.
|
|
161
320
|
*/
|
|
321
|
+
export function parseSaraminList(html: string, now = new Date()): JobSearchResult[] {
|
|
322
|
+
const out: JobSearchResult[] = [];
|
|
323
|
+
for (const chunk of html.split('<div class="item_recruit"').slice(1)) {
|
|
324
|
+
const id = chunk.match(/^\s*value="(\d+)"/)?.[1];
|
|
325
|
+
const title = chunk.match(/class="job_tit">\s*<a[^>]*\btitle="([^"]*)"/)?.[1];
|
|
326
|
+
const company = chunk.match(/class="corp_name">\s*<a[^>]*>([\s\S]*?)<\/a>/)?.[1];
|
|
327
|
+
if (!id || !title || !company) continue;
|
|
328
|
+
const cond = chunk.match(/class="job_condition">([\s\S]*?)<\/div>/)?.[1] ?? '';
|
|
329
|
+
const spans = [...cond.matchAll(/<span>([\s\S]*?)<\/span>/g)].map((m) => decodeHtml(m[1]));
|
|
330
|
+
const sector = chunk.match(/class="job_sector">([\s\S]*?)(?:<span class="job_day"|<\/div>)/)?.[1] ?? '';
|
|
331
|
+
const links = [...sector.matchAll(/cat_kewd=(\d+)"\s*>([^<]*)<\/a>/g)];
|
|
332
|
+
// "등록일 26/11/04" is the posting date; "수정일 ..." is only an edit, so it doesn't count.
|
|
333
|
+
const reg = chunk.match(/class="job_day">\s*등록일\s*(\d{2})\/(\d{2})\/(\d{2})/);
|
|
334
|
+
out.push({
|
|
335
|
+
source: 'saramin',
|
|
336
|
+
company: decodeHtml(company),
|
|
337
|
+
position: decodeHtml(title),
|
|
338
|
+
url: `https://www.saramin.co.kr/zf_user/jobs/relay/view?rec_idx=${id}`,
|
|
339
|
+
location: spans[0] || undefined,
|
|
340
|
+
experience: spans[1] || undefined,
|
|
341
|
+
keywords: links.length ? links.map((m) => decodeHtml(m[2])) : undefined,
|
|
342
|
+
deadline: saraminDeadline(chunk.match(/class="date">([^<]*)</)?.[1] ?? '', now),
|
|
343
|
+
external_id: id,
|
|
344
|
+
category_codes: links.length ? links.map((m) => m[1]) : undefined,
|
|
345
|
+
posted_at: reg ? `20${reg[1]}-${reg[2]}-${reg[3]}` : undefined,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const saramin: Provider = {
|
|
352
|
+
id: 'saramin',
|
|
353
|
+
label: '사람인',
|
|
354
|
+
categoryOption: 'saraminCategories',
|
|
355
|
+
async search(query, limit, timeoutMs, opts) {
|
|
356
|
+
const params = new URLSearchParams({
|
|
357
|
+
searchword: query,
|
|
358
|
+
recruitPage: '1',
|
|
359
|
+
// Relevance reads better for a keyword, but "new since" needs newest first.
|
|
360
|
+
recruitSort: query && !opts.postedSince ? 'relation' : 'reg_dt',
|
|
361
|
+
recruitPageCount: String(limit),
|
|
362
|
+
mainSearch: 'n',
|
|
363
|
+
});
|
|
364
|
+
const cats = opts.saraminCategories ?? [];
|
|
365
|
+
const unknown = cats.filter((c) => !isSaraminCategory(c));
|
|
366
|
+
if (unknown.length) throw new Error(`unknown Saramin category code: ${unknown.join(', ')} (use find_job_categories)`);
|
|
367
|
+
if (cats.length) params.set('cat_kewd', cats.join(','));
|
|
368
|
+
const j = saraminListSchema.parse(
|
|
369
|
+
await getJson(`https://www.saramin.co.kr/zf_user/search/get-recruit-list?${params}`, timeoutMs, {
|
|
370
|
+
headers: { 'x-requested-with': 'XMLHttpRequest' },
|
|
371
|
+
anyType: true,
|
|
372
|
+
}),
|
|
373
|
+
);
|
|
374
|
+
const rows = parseSaraminList(j.innerHTML);
|
|
375
|
+
if (!rows.length && j.innerHTML.includes('item_recruit')) {
|
|
376
|
+
throw new z.ZodError([{ code: 'custom', path: ['innerHTML'], message: 'item markup changed' }]);
|
|
377
|
+
}
|
|
378
|
+
return rows.slice(0, limit);
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
const PROVIDERS: Provider[] = [wanted, jumpit, remember, saramin];
|
|
383
|
+
export const JOB_SEARCH_SOURCES = PROVIDERS.map((p) => p.id);
|
|
384
|
+
|
|
385
|
+
function providerError(e: unknown): { status: ProviderStatus; error: string } {
|
|
386
|
+
if (e instanceof z.ZodError) {
|
|
387
|
+
const first = e.issues[0];
|
|
388
|
+
return {
|
|
389
|
+
status: 'degraded',
|
|
390
|
+
error: `provider response changed${first ? ` (${first.path.join('.') || 'root'})` : ''}`,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
394
|
+
return { status: 'unavailable', error: message };
|
|
395
|
+
}
|
|
396
|
+
|
|
162
397
|
export async function searchJobs(query: string, opts: JobSearchOptions = {}): Promise<JobSearchResponse> {
|
|
163
398
|
const q = (query ?? '').trim();
|
|
164
|
-
|
|
165
|
-
const limit = Math.min(
|
|
166
|
-
const timeoutMs = opts.timeoutMs ??
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
399
|
+
// A date filter drops most of a page, so fetch more by default when one is set.
|
|
400
|
+
const limit = Math.min(100, Math.max(1, opts.limit ?? (opts.postedSince ? 50 : 10)));
|
|
401
|
+
const timeoutMs = Math.min(30_000, Math.max(1_000, opts.timeoutMs ?? 10_000));
|
|
402
|
+
// Without a keyword, only providers given their own category filter can search.
|
|
403
|
+
const chosen = (opts.sources?.length ? PROVIDERS.filter((p) => opts.sources!.includes(p.id)) : PROVIDERS).filter(
|
|
404
|
+
(p) => q || (p.categoryOption && opts[p.categoryOption]?.length),
|
|
405
|
+
);
|
|
406
|
+
if (!chosen.length) return { query: q, results: [], providers: [] };
|
|
170
407
|
|
|
171
408
|
const settled = await Promise.all(
|
|
172
409
|
chosen.map(async (p): Promise<{ outcome: ProviderOutcome; results: JobSearchResult[] }> => {
|
|
410
|
+
const checked_at = new Date().toISOString();
|
|
173
411
|
try {
|
|
174
|
-
const results = await p.search(q, limit, timeoutMs);
|
|
175
|
-
return {
|
|
412
|
+
const results = await p.search(q, limit, timeoutMs, opts);
|
|
413
|
+
return {
|
|
414
|
+
outcome: {
|
|
415
|
+
source: p.id,
|
|
416
|
+
label: p.label,
|
|
417
|
+
ok: true,
|
|
418
|
+
status: 'healthy',
|
|
419
|
+
count: results.length,
|
|
420
|
+
checked_at,
|
|
421
|
+
},
|
|
422
|
+
results,
|
|
423
|
+
};
|
|
176
424
|
} catch (e) {
|
|
425
|
+
const failure = providerError(e);
|
|
177
426
|
return {
|
|
178
|
-
outcome: {
|
|
427
|
+
outcome: {
|
|
428
|
+
source: p.id,
|
|
429
|
+
label: p.label,
|
|
430
|
+
ok: false,
|
|
431
|
+
status: failure.status,
|
|
432
|
+
count: 0,
|
|
433
|
+
checked_at,
|
|
434
|
+
error: failure.error,
|
|
435
|
+
},
|
|
179
436
|
results: [],
|
|
180
437
|
};
|
|
181
438
|
}
|
|
182
439
|
}),
|
|
183
440
|
);
|
|
184
441
|
|
|
442
|
+
const today = opts.today ?? new Date().toLocaleDateString('sv-SE'); // local YYYY-MM-DD
|
|
443
|
+
let results = settled.flatMap((s) => s.results);
|
|
444
|
+
if (opts.trackSeen !== false) {
|
|
445
|
+
const seen = markJobsSeen(results, today);
|
|
446
|
+
for (const r of results) r.first_seen_at = seen.get(`${r.source}:${r.external_id}`);
|
|
447
|
+
}
|
|
448
|
+
results = dedupeAcrossBoards(results);
|
|
449
|
+
let filtered_out: number | undefined;
|
|
450
|
+
if (opts.postedSince) {
|
|
451
|
+
const kept = results.filter((r) => (r.posted_at ?? r.first_seen_at ?? today) >= opts.postedSince!);
|
|
452
|
+
filtered_out = results.length - kept.length;
|
|
453
|
+
results = kept;
|
|
454
|
+
}
|
|
455
|
+
|
|
185
456
|
return {
|
|
186
457
|
query: q,
|
|
187
|
-
results
|
|
458
|
+
results,
|
|
188
459
|
providers: settled.map((s) => s.outcome),
|
|
460
|
+
...(filtered_out !== undefined ? { filtered_out } : {}),
|
|
189
461
|
};
|
|
190
462
|
}
|
|
463
|
+
|
|
464
|
+
const normCompany = (s: string) =>
|
|
465
|
+
s.replace(/\(주\)|㈜|주식회사|\(유\)|\(재\)/g, '').toLowerCase().replace(/[^\p{L}\p{N}]/gu, '');
|
|
466
|
+
/** Drop only a "[company]" tag (Remember prefixes titles with it); "[신입]", "[서울]" and the like stay. */
|
|
467
|
+
const normTitle = (title: string, company: string) => {
|
|
468
|
+
const c = normCompany(company);
|
|
469
|
+
return title
|
|
470
|
+
.replace(/\[([^\]]*)\]/g, (tag, inner: string) => {
|
|
471
|
+
const n = normCompany(inner);
|
|
472
|
+
return n && c && (c.includes(n) || n.includes(c)) ? '' : tag;
|
|
473
|
+
})
|
|
474
|
+
.toLowerCase()
|
|
475
|
+
.replace(/[^\p{L}\p{N}]/gu, '');
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Fold the same posting listed on several boards into one row (first board wins, the rest go
|
|
480
|
+
* to `also_on`). Same company and title after normalization; differently worded titles stay
|
|
481
|
+
* separate, which errs on showing a duplicate rather than hiding a real posting.
|
|
482
|
+
*/
|
|
483
|
+
export function dedupeAcrossBoards(results: JobSearchResult[]): JobSearchResult[] {
|
|
484
|
+
const byKey = new Map<string, JobSearchResult>();
|
|
485
|
+
const out: JobSearchResult[] = [];
|
|
486
|
+
for (const r of results) {
|
|
487
|
+
const key = `${normCompany(r.company)}|${normTitle(r.position, r.company)}`;
|
|
488
|
+
const first = byKey.get(key);
|
|
489
|
+
if (first && first.source !== r.source) {
|
|
490
|
+
(first.also_on ??= []).push({ source: r.source, url: r.url });
|
|
491
|
+
if (!first.posted_at && r.posted_at) first.posted_at = r.posted_at;
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
byKey.set(key, r);
|
|
495
|
+
out.push(r);
|
|
496
|
+
}
|
|
497
|
+
return out;
|
|
498
|
+
}
|