careermate 0.0.10 → 0.0.13
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 +597 -59
- package/dist/mcp.mjs +624 -60
- package/dist/migrate.mjs +23 -6
- package/dist/public/app.js +1 -7
- package/dist/public/i18n/en/settings.js +1 -6
- 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 +18 -5
- package/dist/public/pages/home.js +3 -1
- package/dist/public/pages/settings.js +13 -12
- package/dist/public/styles.css +30 -0
- package/dist/web.mjs +332 -17
- package/docs/FAQ.md +3 -4
- package/docs/LOCAL_FIRST.md +1 -1
- package/docs/MCP_TOOLS.md +6 -3
- package/docs/career-os/README.md +2 -2
- package/package.json +1 -8
- package/packages/core/src/index.ts +1 -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 +292 -21
- package/packages/db/src/index.ts +1 -0
- package/packages/db/src/interview-retrospectives.ts +2 -6
- package/packages/db/src/job-search.ts +100 -0
- package/packages/db/src/repositories.ts +1 -0
- package/packages/mcp-tools/src/tools.ts +115 -11
- package/scripts/init.ts +2 -0
|
@@ -2,14 +2,17 @@
|
|
|
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
|
-
* CareerMate is local-first: only the search keyword
|
|
6
|
-
* profile, résumé, application, and document data are never sent to
|
|
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).
|
|
7
8
|
* Provider endpoints are unofficial and therefore validated at runtime; a shape
|
|
8
9
|
* change degrades only that provider instead of silently returning corrupt rows.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import { z } from 'zod';
|
|
12
13
|
import { APP_VERSION } from '@careermate/shared';
|
|
14
|
+
import { markJobsSeen } from '@careermate/db';
|
|
15
|
+
import { isRememberCategory, isSaraminCategory } from './job-categories.ts';
|
|
13
16
|
|
|
14
17
|
export interface JobSearchResult {
|
|
15
18
|
source: string;
|
|
@@ -21,6 +24,14 @@ export interface JobSearchResult {
|
|
|
21
24
|
keywords?: string[];
|
|
22
25
|
deadline?: string;
|
|
23
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 }[];
|
|
24
35
|
}
|
|
25
36
|
|
|
26
37
|
export type ProviderStatus = 'healthy' | 'degraded' | 'unavailable';
|
|
@@ -39,12 +50,24 @@ export interface JobSearchResponse {
|
|
|
39
50
|
query: string;
|
|
40
51
|
results: JobSearchResult[];
|
|
41
52
|
providers: ProviderOutcome[];
|
|
53
|
+
/** Results dropped by `postedSince`. */
|
|
54
|
+
filtered_out?: number;
|
|
42
55
|
}
|
|
43
56
|
|
|
44
57
|
export interface JobSearchOptions {
|
|
45
58
|
sources?: string[];
|
|
46
59
|
limit?: number;
|
|
47
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;
|
|
48
71
|
}
|
|
49
72
|
|
|
50
73
|
const UA = `careermate/${APP_VERSION} (+https://careermate.life)`;
|
|
@@ -55,18 +78,41 @@ function toYmd(v: unknown): string | undefined {
|
|
|
55
78
|
return m ? m[1] : undefined;
|
|
56
79
|
}
|
|
57
80
|
|
|
58
|
-
|
|
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> {
|
|
59
97
|
const ctrl = new AbortController();
|
|
60
98
|
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
61
99
|
try {
|
|
62
100
|
const res = await fetch(url, {
|
|
63
|
-
|
|
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),
|
|
64
110
|
signal: ctrl.signal,
|
|
65
111
|
});
|
|
66
112
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
67
113
|
const type = res.headers.get('content-type') ?? '';
|
|
68
|
-
if (!type.includes('json')) throw new Error(`unexpected content-type: ${type || 'unknown'}`);
|
|
69
|
-
return await res.
|
|
114
|
+
if (!init.anyType && !type.includes('json')) throw new Error(`unexpected content-type: ${type || 'unknown'}`);
|
|
115
|
+
return JSON.parse(await res.text());
|
|
70
116
|
} finally {
|
|
71
117
|
clearTimeout(timer);
|
|
72
118
|
}
|
|
@@ -75,7 +121,9 @@ async function getJson(url: string, timeoutMs: number): Promise<unknown> {
|
|
|
75
121
|
interface Provider {
|
|
76
122
|
id: string;
|
|
77
123
|
label: string;
|
|
78
|
-
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[]>;
|
|
79
127
|
}
|
|
80
128
|
|
|
81
129
|
const wantedSchema = z.object({
|
|
@@ -122,9 +170,12 @@ const wanted: Provider = {
|
|
|
122
170
|
`&job_sort=job.latest_order&limit=${limit}&offset=0&query=${encodeURIComponent(query)}`;
|
|
123
171
|
const j = wantedSchema.parse(await getJson(url, timeoutMs));
|
|
124
172
|
return j.data.map((r) => {
|
|
173
|
+
// Wanted uses 100 for "no upper bound".
|
|
125
174
|
const annual =
|
|
126
175
|
r.annual_from || r.annual_to
|
|
127
|
-
?
|
|
176
|
+
? r.annual_to == null || r.annual_to >= 100
|
|
177
|
+
? `${r.annual_from ?? 0}년 이상`
|
|
178
|
+
: `${r.annual_from ?? 0}~${r.annual_to}년`
|
|
128
179
|
: undefined;
|
|
129
180
|
return {
|
|
130
181
|
source: 'wanted',
|
|
@@ -144,13 +195,20 @@ const jumpit: Provider = {
|
|
|
144
195
|
id: 'jumpit',
|
|
145
196
|
label: '점핏',
|
|
146
197
|
async search(query, limit, timeoutMs) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
198
|
+
// Fixed page size of 16; keep paging until limit or a short page.
|
|
199
|
+
const positions: z.infer<typeof jumpitSchema>['result']['positions'] = [];
|
|
200
|
+
for (let page = 1; positions.length < limit && page <= Math.ceil(limit / 16); page++) {
|
|
201
|
+
const url =
|
|
202
|
+
`https://jumpit-api.saramin.co.kr/api/positions?sort=relation&page=${page}&keyword=${encodeURIComponent(query)}`;
|
|
203
|
+
const got = jumpitSchema.parse(await getJson(url, timeoutMs)).result.positions;
|
|
204
|
+
positions.push(...got);
|
|
205
|
+
if (got.length < 16) break;
|
|
206
|
+
}
|
|
207
|
+
return positions.slice(0, limit).map((p) => ({
|
|
151
208
|
source: 'jumpit',
|
|
152
209
|
company: p.companyName,
|
|
153
|
-
|
|
210
|
+
// Keyword searches come back with highlight markup, e.g. "<span>백엔드</span> 개발자".
|
|
211
|
+
position: p.title.replace(/<[^>]*>/g, ''),
|
|
154
212
|
url: `https://www.jumpit.co.kr/position/${p.id}`,
|
|
155
213
|
location: p.locations?.[0],
|
|
156
214
|
experience: p.newcomer
|
|
@@ -165,7 +223,166 @@ const jumpit: Provider = {
|
|
|
165
223
|
},
|
|
166
224
|
};
|
|
167
225
|
|
|
168
|
-
const
|
|
226
|
+
const rememberSchema = z.object({
|
|
227
|
+
data: z.array(
|
|
228
|
+
z.object({
|
|
229
|
+
id: z.union([z.string(), z.number()]),
|
|
230
|
+
title: z.string().min(1),
|
|
231
|
+
organization: z.object({ name: z.string().min(1) }).passthrough(),
|
|
232
|
+
min_experience: z.number().nullable().optional(),
|
|
233
|
+
max_experience: z.number().nullable().optional(),
|
|
234
|
+
ends_at: z.string().nullable().optional(),
|
|
235
|
+
starts_at: z.string().nullable().optional(),
|
|
236
|
+
addresses: z
|
|
237
|
+
.array(z.object({ address_level1: z.string().nullable().optional(), address_level2: z.string().nullable().optional() }).passthrough())
|
|
238
|
+
.nullable()
|
|
239
|
+
.optional(),
|
|
240
|
+
job_categories: z.array(z.object({ level2: z.string().nullable().optional() }).passthrough()).nullable().optional(),
|
|
241
|
+
}).passthrough(),
|
|
242
|
+
),
|
|
243
|
+
}).passthrough();
|
|
244
|
+
|
|
245
|
+
const remember: Provider = {
|
|
246
|
+
id: 'remember',
|
|
247
|
+
label: '리멤버',
|
|
248
|
+
categoryOption: 'rememberCategories',
|
|
249
|
+
async search(query, limit, timeoutMs, opts) {
|
|
250
|
+
const cats = opts.rememberCategories ?? [];
|
|
251
|
+
// Remember ignores names it doesn't know and returns everything, so reject them up front.
|
|
252
|
+
const unknown = cats.filter((c) => !isRememberCategory(c));
|
|
253
|
+
if (unknown.length) throw new Error(`unknown Remember category: ${unknown.join(', ')} (use find_job_categories)`);
|
|
254
|
+
const search: Record<string, unknown> = {
|
|
255
|
+
keywords: query ? [query] : [],
|
|
256
|
+
organization_type: 'all',
|
|
257
|
+
application_type: 'all',
|
|
258
|
+
};
|
|
259
|
+
if (cats.length) {
|
|
260
|
+
search.job_category_names = cats.map((c) => {
|
|
261
|
+
const [level1, level2] = c.split('>').map((s) => s.trim());
|
|
262
|
+
return level2 ? { level1, level2 } : { level1 };
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
// Remember rejects per > 50 ("최대 요청 수를 초과"), so larger limits take a second page.
|
|
266
|
+
const per = Math.min(limit, 50);
|
|
267
|
+
const pages = await Promise.all(
|
|
268
|
+
Array.from({ length: Math.ceil(limit / per) }, (_, i) =>
|
|
269
|
+
getJson('https://career-api.rememberapp.co.kr/job_postings/search', timeoutMs, {
|
|
270
|
+
body: { search, sort: 'starts_at_desc', page: i + 1, per },
|
|
271
|
+
}).then((r) => rememberSchema.parse(r).data),
|
|
272
|
+
),
|
|
273
|
+
);
|
|
274
|
+
return pages.flat().slice(0, limit).map((d) => {
|
|
275
|
+
const addr = d.addresses?.[0];
|
|
276
|
+
const min = d.min_experience;
|
|
277
|
+
const max = d.max_experience;
|
|
278
|
+
return {
|
|
279
|
+
source: 'remember',
|
|
280
|
+
company: d.organization.name,
|
|
281
|
+
position: d.title,
|
|
282
|
+
url: `https://career.rememberapp.co.kr/job/posting/${d.id}`,
|
|
283
|
+
location: addr ? [addr.address_level1, addr.address_level2].filter(Boolean).join(' ') || undefined : undefined,
|
|
284
|
+
experience:
|
|
285
|
+
min != null && max != null ? `${min}~${max}년` : min != null ? `${min}년 이상` : max != null ? `~${max}년` : undefined,
|
|
286
|
+
keywords: d.job_categories?.map((c) => c.level2).filter((s): s is string => !!s),
|
|
287
|
+
deadline: toYmd(d.ends_at),
|
|
288
|
+
external_id: String(d.id),
|
|
289
|
+
posted_at: kstYmd(d.starts_at),
|
|
290
|
+
};
|
|
291
|
+
});
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const saraminListSchema = z.object({ innerHTML: z.string() }).passthrough();
|
|
296
|
+
|
|
297
|
+
function decodeHtml(s: string): string {
|
|
298
|
+
return s
|
|
299
|
+
.replace(/<[^>]*>/g, ' ')
|
|
300
|
+
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
|
|
301
|
+
.replace(/"/g, '"')
|
|
302
|
+
.replace(/</g, '<')
|
|
303
|
+
.replace(/>/g, '>')
|
|
304
|
+
.replace(/ /g, ' ')
|
|
305
|
+
.replace(/&/g, '&')
|
|
306
|
+
.replace(/\s+/g, ' ')
|
|
307
|
+
.trim();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** "~ 12/04(금)" → YYYY-MM-DD; a month earlier than today's rolls into next year. Other labels (상시채용 etc.) → undefined. */
|
|
311
|
+
function saraminDeadline(text: string, now = new Date()): string | undefined {
|
|
312
|
+
const m = text.match(/(\d{1,2})\/(\d{1,2})/);
|
|
313
|
+
if (!m) return undefined;
|
|
314
|
+
const month = Number(m[1]);
|
|
315
|
+
const year = now.getFullYear() + (month < now.getMonth() + 1 ? 1 : 0);
|
|
316
|
+
return `${year}-${m[1].padStart(2, '0')}-${m[2].padStart(2, '0')}`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Parse the HTML fragment of Saramin's search list (`get-recruit-list` → innerHTML).
|
|
321
|
+
* Items missing an id, title or company are dropped; an empty result from a
|
|
322
|
+
* non-empty page means the markup changed, which the caller reports as degraded.
|
|
323
|
+
*/
|
|
324
|
+
export function parseSaraminList(html: string, now = new Date()): JobSearchResult[] {
|
|
325
|
+
const out: JobSearchResult[] = [];
|
|
326
|
+
for (const chunk of html.split('<div class="item_recruit"').slice(1)) {
|
|
327
|
+
const id = chunk.match(/^\s*value="(\d+)"/)?.[1];
|
|
328
|
+
const title = chunk.match(/class="job_tit">\s*<a[^>]*\btitle="([^"]*)"/)?.[1];
|
|
329
|
+
const company = chunk.match(/class="corp_name">\s*<a[^>]*>([\s\S]*?)<\/a>/)?.[1];
|
|
330
|
+
if (!id || !title || !company) continue;
|
|
331
|
+
const cond = chunk.match(/class="job_condition">([\s\S]*?)<\/div>/)?.[1] ?? '';
|
|
332
|
+
const spans = [...cond.matchAll(/<span>([\s\S]*?)<\/span>/g)].map((m) => decodeHtml(m[1]));
|
|
333
|
+
const sector = chunk.match(/class="job_sector">([\s\S]*?)(?:<span class="job_day"|<\/div>)/)?.[1] ?? '';
|
|
334
|
+
const links = [...sector.matchAll(/cat_kewd=(\d+)"\s*>([^<]*)<\/a>/g)];
|
|
335
|
+
// "등록일 26/11/04" is the posting date; "수정일 ..." is only an edit, so it doesn't count.
|
|
336
|
+
const reg = chunk.match(/class="job_day">\s*등록일\s*(\d{2})\/(\d{2})\/(\d{2})/);
|
|
337
|
+
out.push({
|
|
338
|
+
source: 'saramin',
|
|
339
|
+
company: decodeHtml(company),
|
|
340
|
+
position: decodeHtml(title),
|
|
341
|
+
url: `https://www.saramin.co.kr/zf_user/jobs/relay/view?rec_idx=${id}`,
|
|
342
|
+
location: spans[0] || undefined,
|
|
343
|
+
experience: spans[1] || undefined,
|
|
344
|
+
keywords: links.length ? links.map((m) => decodeHtml(m[2])) : undefined,
|
|
345
|
+
deadline: saraminDeadline(chunk.match(/class="date">([^<]*)</)?.[1] ?? '', now),
|
|
346
|
+
external_id: id,
|
|
347
|
+
category_codes: links.length ? links.map((m) => m[1]) : undefined,
|
|
348
|
+
posted_at: reg ? `20${reg[1]}-${reg[2]}-${reg[3]}` : undefined,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
return out;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const saramin: Provider = {
|
|
355
|
+
id: 'saramin',
|
|
356
|
+
label: '사람인',
|
|
357
|
+
categoryOption: 'saraminCategories',
|
|
358
|
+
async search(query, limit, timeoutMs, opts) {
|
|
359
|
+
const params = new URLSearchParams({
|
|
360
|
+
searchword: query,
|
|
361
|
+
recruitPage: '1',
|
|
362
|
+
// Relevance reads better for a keyword, but "new since" needs newest first.
|
|
363
|
+
recruitSort: query && !opts.postedSince ? 'relation' : 'reg_dt',
|
|
364
|
+
recruitPageCount: String(limit),
|
|
365
|
+
mainSearch: 'n',
|
|
366
|
+
});
|
|
367
|
+
const cats = opts.saraminCategories ?? [];
|
|
368
|
+
const unknown = cats.filter((c) => !isSaraminCategory(c));
|
|
369
|
+
if (unknown.length) throw new Error(`unknown Saramin category code: ${unknown.join(', ')} (use find_job_categories)`);
|
|
370
|
+
if (cats.length) params.set('cat_kewd', cats.join(','));
|
|
371
|
+
const j = saraminListSchema.parse(
|
|
372
|
+
await getJson(`https://www.saramin.co.kr/zf_user/search/get-recruit-list?${params}`, timeoutMs, {
|
|
373
|
+
headers: { 'x-requested-with': 'XMLHttpRequest' },
|
|
374
|
+
anyType: true,
|
|
375
|
+
}),
|
|
376
|
+
);
|
|
377
|
+
const rows = parseSaraminList(j.innerHTML);
|
|
378
|
+
if (!rows.length && j.innerHTML.includes('item_recruit')) {
|
|
379
|
+
throw new z.ZodError([{ code: 'custom', path: ['innerHTML'], message: 'item markup changed' }]);
|
|
380
|
+
}
|
|
381
|
+
return rows.slice(0, limit);
|
|
382
|
+
},
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const PROVIDERS: Provider[] = [wanted, jumpit, remember, saramin];
|
|
169
386
|
export const JOB_SEARCH_SOURCES = PROVIDERS.map((p) => p.id);
|
|
170
387
|
|
|
171
388
|
function providerError(e: unknown): { status: ProviderStatus; error: string } {
|
|
@@ -182,18 +399,20 @@ function providerError(e: unknown): { status: ProviderStatus; error: string } {
|
|
|
182
399
|
|
|
183
400
|
export async function searchJobs(query: string, opts: JobSearchOptions = {}): Promise<JobSearchResponse> {
|
|
184
401
|
const q = (query ?? '').trim();
|
|
185
|
-
|
|
186
|
-
const limit = Math.min(
|
|
402
|
+
// A date filter drops most of a page, so fetch more by default when one is set.
|
|
403
|
+
const limit = Math.min(100, Math.max(1, opts.limit ?? (opts.postedSince ? 50 : 10)));
|
|
187
404
|
const timeoutMs = Math.min(30_000, Math.max(1_000, opts.timeoutMs ?? 10_000));
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
405
|
+
// Without a keyword, only providers given their own category filter can search.
|
|
406
|
+
const chosen = (opts.sources?.length ? PROVIDERS.filter((p) => opts.sources!.includes(p.id)) : PROVIDERS).filter(
|
|
407
|
+
(p) => q || (p.categoryOption && opts[p.categoryOption]?.length),
|
|
408
|
+
);
|
|
409
|
+
if (!chosen.length) return { query: q, results: [], providers: [] };
|
|
191
410
|
|
|
192
411
|
const settled = await Promise.all(
|
|
193
412
|
chosen.map(async (p): Promise<{ outcome: ProviderOutcome; results: JobSearchResult[] }> => {
|
|
194
413
|
const checked_at = new Date().toISOString();
|
|
195
414
|
try {
|
|
196
|
-
const results = await p.search(q, limit, timeoutMs);
|
|
415
|
+
const results = await p.search(q, limit, timeoutMs, opts);
|
|
197
416
|
return {
|
|
198
417
|
outcome: {
|
|
199
418
|
source: p.id,
|
|
@@ -223,9 +442,61 @@ export async function searchJobs(query: string, opts: JobSearchOptions = {}): Pr
|
|
|
223
442
|
}),
|
|
224
443
|
);
|
|
225
444
|
|
|
445
|
+
const today = opts.today ?? new Date().toLocaleDateString('sv-SE'); // local YYYY-MM-DD
|
|
446
|
+
let results = settled.flatMap((s) => s.results);
|
|
447
|
+
if (opts.trackSeen !== false) {
|
|
448
|
+
const seen = markJobsSeen(results, today);
|
|
449
|
+
for (const r of results) r.first_seen_at = seen.get(`${r.source}:${r.external_id}`);
|
|
450
|
+
}
|
|
451
|
+
results = dedupeAcrossBoards(results);
|
|
452
|
+
let filtered_out: number | undefined;
|
|
453
|
+
if (opts.postedSince) {
|
|
454
|
+
const kept = results.filter((r) => (r.posted_at ?? r.first_seen_at ?? today) >= opts.postedSince!);
|
|
455
|
+
filtered_out = results.length - kept.length;
|
|
456
|
+
results = kept;
|
|
457
|
+
}
|
|
458
|
+
|
|
226
459
|
return {
|
|
227
460
|
query: q,
|
|
228
|
-
results
|
|
461
|
+
results,
|
|
229
462
|
providers: settled.map((s) => s.outcome),
|
|
463
|
+
...(filtered_out !== undefined ? { filtered_out } : {}),
|
|
230
464
|
};
|
|
231
465
|
}
|
|
466
|
+
|
|
467
|
+
// Any "(...)" goes: "(주)" and brand notes like "힐링페이퍼(강남언니)" vs "(주)힐링페이퍼".
|
|
468
|
+
const normCompany = (s: string) =>
|
|
469
|
+
s.replace(/\([^)]*\)|㈜|주식회사/g, '').toLowerCase().replace(/[^\p{L}\p{N}]/gu, '');
|
|
470
|
+
/** Drop only a "[company]" tag (Remember prefixes titles with it); "[신입]", "[서울]" and the like stay. */
|
|
471
|
+
const normTitle = (title: string, company: string) => {
|
|
472
|
+
const c = normCompany(company);
|
|
473
|
+
return title
|
|
474
|
+
.replace(/\[([^\]]*)\]/g, (tag, inner: string) => {
|
|
475
|
+
const n = normCompany(inner);
|
|
476
|
+
return n && c && (c.includes(n) || n.includes(c)) ? '' : tag;
|
|
477
|
+
})
|
|
478
|
+
.toLowerCase()
|
|
479
|
+
.replace(/[^\p{L}\p{N}]/gu, '');
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Fold the same posting listed on several boards into one row (first board wins, the rest go
|
|
484
|
+
* to `also_on`). Same company and title after normalization; differently worded titles stay
|
|
485
|
+
* separate, which errs on showing a duplicate rather than hiding a real posting.
|
|
486
|
+
*/
|
|
487
|
+
export function dedupeAcrossBoards(results: JobSearchResult[]): JobSearchResult[] {
|
|
488
|
+
const byKey = new Map<string, JobSearchResult>();
|
|
489
|
+
const out: JobSearchResult[] = [];
|
|
490
|
+
for (const r of results) {
|
|
491
|
+
const key = `${normCompany(r.company)}|${normTitle(r.position, r.company)}`;
|
|
492
|
+
const first = byKey.get(key);
|
|
493
|
+
if (first && first.source !== r.source) {
|
|
494
|
+
(first.also_on ??= []).push({ source: r.source, url: r.url });
|
|
495
|
+
if (!first.posted_at && r.posted_at) first.posted_at = r.posted_at;
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
byKey.set(key, r);
|
|
499
|
+
out.push(r);
|
|
500
|
+
}
|
|
501
|
+
return out;
|
|
502
|
+
}
|
package/packages/db/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export * from './connection.ts';
|
|
|
9
9
|
export * from './settings.ts';
|
|
10
10
|
export * from './provenance.ts';
|
|
11
11
|
export * from './interview-retrospectives.ts';
|
|
12
|
+
export * from './job-search.ts';
|
|
12
13
|
export { migrate, MIGRATIONS } from './schema.ts';
|
|
13
14
|
export {
|
|
14
15
|
profileRepo,
|
|
@@ -18,7 +18,7 @@ function ensureTable(): void {
|
|
|
18
18
|
getDb().exec(`
|
|
19
19
|
CREATE TABLE IF NOT EXISTS interview_retrospectives (
|
|
20
20
|
id TEXT PRIMARY KEY,
|
|
21
|
-
job_id TEXT NOT NULL,
|
|
21
|
+
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
|
22
22
|
interview_round TEXT,
|
|
23
23
|
occurred_at TEXT,
|
|
24
24
|
questions TEXT NOT NULL DEFAULT '[]',
|
|
@@ -31,11 +31,7 @@ function ensureTable(): void {
|
|
|
31
31
|
);
|
|
32
32
|
CREATE INDEX IF NOT EXISTS idx_interview_retrospectives_job
|
|
33
33
|
ON interview_retrospectives(job_id, occurred_at DESC, updated_at DESC);
|
|
34
|
-
|
|
35
|
-
AFTER DELETE ON jobs
|
|
36
|
-
BEGIN
|
|
37
|
-
DELETE FROM interview_retrospectives WHERE job_id = OLD.id;
|
|
38
|
-
END;
|
|
34
|
+
DROP TRIGGER IF EXISTS trg_interview_retrospectives_delete_job;
|
|
39
35
|
`);
|
|
40
36
|
}
|
|
41
37
|
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { getDb, tx } from './connection.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Job-search state, in real tables (not `_meta`) so backup, export and reset cover it:
|
|
5
|
+
* - job_search_seen: first/last sighting of each posting search_jobs returned, keyed by
|
|
6
|
+
* (source, external_id). Wanted/Jumpit lists carry no posting date, so "posted since"
|
|
7
|
+
* for them falls back to first_seen_at. Only board ids and dates, never content.
|
|
8
|
+
* - job_search_preferences: the saved conditions search_jobs uses when called without
|
|
9
|
+
* its own keyword/category (single row).
|
|
10
|
+
*/
|
|
11
|
+
function ensureTables(): void {
|
|
12
|
+
getDb().exec(`
|
|
13
|
+
CREATE TABLE IF NOT EXISTS job_search_seen (
|
|
14
|
+
source TEXT NOT NULL,
|
|
15
|
+
external_id TEXT NOT NULL,
|
|
16
|
+
first_seen_at TEXT NOT NULL,
|
|
17
|
+
last_seen_at TEXT NOT NULL,
|
|
18
|
+
PRIMARY KEY (source, external_id)
|
|
19
|
+
);
|
|
20
|
+
CREATE TABLE IF NOT EXISTS job_search_preferences (
|
|
21
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
22
|
+
keyword TEXT NOT NULL DEFAULT '',
|
|
23
|
+
remember_categories TEXT NOT NULL DEFAULT '[]',
|
|
24
|
+
saramin_categories TEXT NOT NULL DEFAULT '[]',
|
|
25
|
+
updated_at TEXT NOT NULL
|
|
26
|
+
);
|
|
27
|
+
`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A posting not returned by any search for this long is forgotten (and counts as new if it reappears). */
|
|
31
|
+
const FORGET_AFTER_DAYS = 120;
|
|
32
|
+
|
|
33
|
+
/** Record the given postings as seen today and return each one's first-seen date (YYYY-MM-DD), keyed "source:external_id". */
|
|
34
|
+
export function markJobsSeen(items: { source: string; external_id: string }[], today: string): Map<string, string> {
|
|
35
|
+
const seen = new Map<string, string>();
|
|
36
|
+
if (!items.length) return seen;
|
|
37
|
+
const db = getDb();
|
|
38
|
+
const upsert = db.prepare(
|
|
39
|
+
`INSERT INTO job_search_seen (source, external_id, first_seen_at, last_seen_at) VALUES (?, ?, ?, ?)
|
|
40
|
+
ON CONFLICT(source, external_id) DO UPDATE SET last_seen_at = excluded.last_seen_at
|
|
41
|
+
RETURNING first_seen_at`,
|
|
42
|
+
);
|
|
43
|
+
tx(() => {
|
|
44
|
+
for (const it of items) {
|
|
45
|
+
const row = upsert.get(it.source, it.external_id, today, today) as { first_seen_at: string } | undefined;
|
|
46
|
+
seen.set(`${it.source}:${it.external_id}`, row?.first_seen_at ?? today);
|
|
47
|
+
}
|
|
48
|
+
db.prepare(`DELETE FROM job_search_seen WHERE last_seen_at < date(?, ?)`).run(today, `-${FORGET_AFTER_DAYS} days`);
|
|
49
|
+
});
|
|
50
|
+
return seen;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface JobSearchPreferences {
|
|
54
|
+
keyword: string;
|
|
55
|
+
remember_categories: string[];
|
|
56
|
+
saramin_categories: string[];
|
|
57
|
+
updated_at: string | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseList(value: unknown): string[] {
|
|
61
|
+
try {
|
|
62
|
+
const parsed = JSON.parse(String(value));
|
|
63
|
+
return Array.isArray(parsed) ? parsed.filter((v) => typeof v === 'string') : [];
|
|
64
|
+
} catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function getJobSearchPreferences(): JobSearchPreferences {
|
|
70
|
+
const row = getDb().prepare(`SELECT * FROM job_search_preferences WHERE id = 1`).get() as
|
|
71
|
+
| Record<string, unknown>
|
|
72
|
+
| undefined;
|
|
73
|
+
return {
|
|
74
|
+
keyword: typeof row?.keyword === 'string' ? row.keyword : '',
|
|
75
|
+
remember_categories: parseList(row?.remember_categories ?? '[]'),
|
|
76
|
+
saramin_categories: parseList(row?.saramin_categories ?? '[]'),
|
|
77
|
+
updated_at: typeof row?.updated_at === 'string' ? row.updated_at : null,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Only the given fields change; an empty string/array clears that field. */
|
|
82
|
+
export function setJobSearchPreferences(
|
|
83
|
+
prefs: Partial<Omit<JobSearchPreferences, 'updated_at'>>,
|
|
84
|
+
): JobSearchPreferences {
|
|
85
|
+
const next = { ...getJobSearchPreferences(), ...prefs, updated_at: new Date().toISOString() };
|
|
86
|
+
getDb()
|
|
87
|
+
.prepare(
|
|
88
|
+
`INSERT INTO job_search_preferences (id, keyword, remember_categories, saramin_categories, updated_at)
|
|
89
|
+
VALUES (1, ?, ?, ?, ?)
|
|
90
|
+
ON CONFLICT(id) DO UPDATE SET keyword = excluded.keyword, remember_categories = excluded.remember_categories,
|
|
91
|
+
saramin_categories = excluded.saramin_categories, updated_at = excluded.updated_at`,
|
|
92
|
+
)
|
|
93
|
+
.run(next.keyword, JSON.stringify(next.remember_categories), JSON.stringify(next.saramin_categories), next.updated_at);
|
|
94
|
+
return next;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Backup import accepts only tables that already exist, so create these when @careermate/db
|
|
98
|
+
// loads (same as provenance.ts): a backup from a machine that has searched must restore on a
|
|
99
|
+
// fresh install that never has.
|
|
100
|
+
ensureTables();
|
|
@@ -758,6 +758,7 @@ export const jobRepo = {
|
|
|
758
758
|
db.prepare(`DELETE FROM fit_analyses WHERE job_id=?`).run(id);
|
|
759
759
|
db.prepare(`DELETE FROM applications WHERE job_id=?`).run(id);
|
|
760
760
|
db.prepare(`DELETE FROM interview_preps WHERE job_id=?`).run(id);
|
|
761
|
+
db.prepare(`DELETE FROM interview_retrospectives WHERE job_id=?`).run(id);
|
|
761
762
|
db.prepare(`DELETE FROM application_timeline_events WHERE job_id=?`).run(id);
|
|
762
763
|
// offers has ON DELETE CASCADE, but delete explicitly like the siblings above
|
|
763
764
|
// (consistent pattern + safe even if PRAGMA foreign_keys is ever off).
|