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.
Files changed (52) hide show
  1. package/AGENTS.md +3 -2
  2. package/INSTALL.md +6 -6
  3. package/LICENSE +1 -1
  4. package/README.md +18 -16
  5. package/dist/career-os/README.md +2 -2
  6. package/dist/doctor.mjs +17857 -6595
  7. package/dist/mcp.mjs +14545 -3839
  8. package/dist/migrate.mjs +14258 -3709
  9. package/dist/pdf.worker.min.mjs +4 -4
  10. package/dist/public/app.js +3 -18
  11. package/dist/public/i18n/en/documents.js +1 -0
  12. package/dist/public/i18n/en/home.js +8 -1
  13. package/dist/public/i18n/en/jobs.js +2 -0
  14. package/dist/public/i18n/en/nav.js +1 -0
  15. package/dist/public/i18n/en/settings.js +1 -6
  16. package/dist/public/i18n/ko/documents.js +1 -0
  17. package/dist/public/i18n/ko/home.js +8 -1
  18. package/dist/public/i18n/ko/jobs.js +2 -0
  19. package/dist/public/i18n/ko/nav.js +1 -0
  20. package/dist/public/i18n/ko/settings.js +1 -6
  21. package/dist/public/index.html +0 -1
  22. package/dist/public/lib.js +13 -4
  23. package/dist/public/pages/documents.js +27 -10
  24. package/dist/public/pages/home.js +15 -5
  25. package/dist/public/pages/insights.js +156 -0
  26. package/dist/public/pages/jobs.js +21 -3
  27. package/dist/public/pages/settings.js +13 -12
  28. package/dist/public/styles.css +33 -0
  29. package/dist/web.mjs +16890 -5492
  30. package/docs/FAQ.md +3 -4
  31. package/docs/LOCAL_FIRST.md +32 -0
  32. package/docs/MCP_TOOLS.md +6 -3
  33. package/docs/REVIEW_IMPLEMENTATION_PLAN.md +22 -0
  34. package/docs/career-os/README.md +2 -2
  35. package/package.json +7 -12
  36. package/packages/core/src/data-health.ts +161 -0
  37. package/packages/core/src/fit-explain.ts +63 -0
  38. package/packages/core/src/index.ts +4 -0
  39. package/packages/core/src/job-categories.data.ts +140 -0
  40. package/packages/core/src/job-categories.ts +106 -0
  41. package/packages/core/src/jobsearch.ts +374 -66
  42. package/packages/core/src/reuse-insights.ts +126 -0
  43. package/packages/db/src/connection.ts +46 -26
  44. package/packages/db/src/index.ts +3 -0
  45. package/packages/db/src/interview-retrospectives.ts +145 -0
  46. package/packages/db/src/job-search.ts +100 -0
  47. package/packages/db/src/provenance.ts +125 -0
  48. package/packages/db/src/repositories.ts +1 -0
  49. package/packages/mcp-tools/src/tools.ts +115 -11
  50. package/packages/prompts/src/system.ts +21 -64
  51. package/scripts/init.ts +2 -0
  52. 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
- * Scope & boundaries (deliberate):
6
- * - INBOUND only. We fetch publicly visible postings; we send only the search
7
- * keyword, never the user's stored data. (Privacy = no user data leaves; this
8
- * does not violate it.)
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 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
- async function getJson(url: string, timeoutMs: number): Promise<any> {
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
- headers: { accept: 'application/json', 'user-agent': UA, 'accept-language': 'ko,en;q=0.8' },
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
- return await res.json();
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(query: string, limit: number, timeoutMs: number): Promise<JobSearchResult[]>;
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
- /** Wanted (wanted.co.kr) — public v4 jobs JSON, no auth. IT/startup roles. */
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
- const rows: any[] = Array.isArray(j?.data) ? j.data : [];
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?.name ?? '',
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 ?? undefined,
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
- } as JobSearchResult;
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
- const url =
131
- `https://api.jumpit.co.kr/api/positions?sort=rsp_rate&page=1&keyword=${encodeURIComponent(query)}`;
132
- const j = await getJson(url, timeoutMs);
133
- const rows: any[] = Array.isArray(j?.result?.positions) ? j.result.positions : [];
134
- return rows.slice(0, limit).map((p) => ({
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
- position: p.title ?? '',
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: Array.isArray(p.locations) ? p.locations[0] : undefined,
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: Array.isArray(p.techStacks) ? p.techStacks : undefined,
216
+ keywords: p.techStacks,
146
217
  deadline: toYmd(p.closedAt),
147
218
  external_id: String(p.id),
148
- } as JobSearchResult));
219
+ }));
149
220
  },
150
221
  };
151
222
 
152
- const PROVIDERS: Provider[] = [wanted, jumpit];
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
- /** Registered provider ids (key-free, no WAF bypass). */
155
- export const JOB_SEARCH_SOURCES = PROVIDERS.map((p) => p.id);
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(/&quot;/g, '"')
299
+ .replace(/&lt;/g, '<')
300
+ .replace(/&gt;/g, '>')
301
+ .replace(/&nbsp;/g, ' ')
302
+ .replace(/&amp;/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
- * Search key-free public job boards by keyword. Each provider runs independently;
159
- * one failing (network/timeout/shape change) does not sink the others. Results
160
- * are returned for the AI to present — saving is a separate explicit step.
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
- if (!q) return { query: '', results: [], providers: [] };
165
- const limit = Math.min(50, Math.max(1, opts.limit ?? 10));
166
- const timeoutMs = opts.timeoutMs ?? 10000;
167
- const chosen = opts.sources?.length
168
- ? PROVIDERS.filter((p) => opts.sources!.includes(p.id))
169
- : PROVIDERS;
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 { outcome: { source: p.id, ok: true, count: results.length }, results };
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: { source: p.id, ok: false, count: 0, error: e instanceof Error ? e.message : String(e) },
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: settled.flatMap((s) => s.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
+ }