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.
@@ -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 leaves the machine. Stored
6
- * profile, résumé, application, and document data are never sent to providers.
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
- async function getJson(url: string, timeoutMs: number): Promise<unknown> {
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
- 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),
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.json();
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(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[]>;
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
- ? `${r.annual_from ?? 0}~${r.annual_to ?? '?'}년`
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
- const url =
148
- `https://api.jumpit.co.kr/api/positions?sort=rsp_rate&page=1&keyword=${encodeURIComponent(query)}`;
149
- const j = jumpitSchema.parse(await getJson(url, timeoutMs));
150
- return j.result.positions.slice(0, limit).map((p) => ({
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
- position: p.title,
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 PROVIDERS: Provider[] = [wanted, jumpit];
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(/&quot;/g, '"')
302
+ .replace(/&lt;/g, '<')
303
+ .replace(/&gt;/g, '>')
304
+ .replace(/&nbsp;/g, ' ')
305
+ .replace(/&amp;/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
- if (!q) return { query: '', results: [], providers: [] };
186
- const limit = Math.min(50, Math.max(1, opts.limit ?? 10));
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
- const chosen = opts.sources?.length
189
- ? PROVIDERS.filter((p) => opts.sources!.includes(p.id))
190
- : PROVIDERS;
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: settled.flatMap((s) => s.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
+ }
@@ -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
- CREATE TRIGGER IF NOT EXISTS trg_interview_retrospectives_delete_job
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).