careermate 0.0.14 → 0.0.15

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/README.md CHANGED
@@ -35,9 +35,9 @@
35
35
  - **AI와 대화로 모든 작업** — MCP 도구로 온보딩·프로필·이력서·자소서·공고·핏 분석·지원 상태·면접 준비·전문가 플레이북·AI 티 안 나는 글쓰기까지 처리.
36
36
  - **지원 상태 8단계 관리** — `draft`(작성 중) · `planned`(지원 예정) · `applied`(지원 완료) · `document_passed`(서류 합격) · `interview`(면접 진행) · `final_passed`(최종 합격) · `rejected`(불합격) · `on_hold`(보류). `document_passed` 이상에서는 면접 준비를 다음 행동으로 제안합니다.
37
37
  - **자기소개서 버전 관리** — 공고별로 자소서 버전을 쌓고 타임라인으로 비교, 파일로 내보내기.
38
- - **공고 검색·가져오기** — 키워드(직무·기술)나 직무 분류로 원티드·점핏·리멤버·사람인 같은 공개 채용 사이트에서 공고를 바로 가져와, 마음에 드는 것만 저장. 영업·회계·간호·생산직 등 모든 직군의 분류표가 내장돼 있어, 내 직무를 검색 조건으로 저장해 두면 "오늘 올라온 공고 보여줘" 한마디로 새 공고만 볼 수 있습니다(여러 사이트에 함께 올라온 공고는 한 줄로 합침). 키 발급·설치 없이 동작하며, 공개 공고를 *가져오기만* 합니다(봇 차단 우회·자동 대량수집 없음, 보내는 건 검색어·직무 조건뿐).
38
+ - **공고 검색·가져오기** - 키워드(직무·기술)나 직무 분류로 원티드·점핏·리멤버·사람인 같은 공개 채용 사이트에서 공고를 바로 가져와, 마음에 드는 것만 저장. 영업·회계·간호·생산직 등 모든 직군의 분류표가 내장돼 있어, 내 직무를 검색 조건으로 저장해 두면 "오늘 올라온 공고 보여줘" 한마디로 새 공고만 볼 수 있습니다(여러 사이트에 함께 올라온 공고는 한 줄로 합침). 키 발급·설치 없이 동작하며, 공개 공고를 *가져오기만* 합니다(봇 차단 우회·자동 대량수집 없음, 보내는 건 검색어·직무 조건뿐). 경력 연차를 말하면 그 연차가 경력 범위에 들어가는 공고만 남깁니다(5년이면 "3~10년"은 남기고 "8~20년"은 뺌). 잡코리아는 robots.txt 제약 때문에 넣지 않았습니다. 대시보드의 **공고 찾기** 화면에서도 같은 검색을 하고 클릭 한 번으로 저장할 수 있습니다.
39
39
  - **채용공고 파싱** — 붙여넣거나 가져온 공고 텍스트를 구조화해 저장.
40
- - **로컬 대시보드** — 프레임워크·CDN 없는 바닐라 JS로 만든 7페이지 웹 화면(다크모드 지원).
40
+ - **로컬 대시보드** - 프레임워크·CDN 없는 바닐라 JS로 만든 8페이지 웹 화면(다크모드 지원).
41
41
  - **데이터 내보내기/삭제** — Settings 페이지에서 직접.
42
42
 
43
43
  > **예: 공고 검색** — "백엔드 공고 찾아줘"라고 하면 AI가 공개 사이트에서 공고를 모아 이렇게 보여주고, 고른 것만 저장합니다(직무를 저장해 뒀다면 "오늘 올라온 공고 보여줘"만으로도 됩니다):
@@ -134,12 +134,13 @@ AI 클라이언트에 연결된 상태라면 "대시보드 열어줘"만으로
134
134
 
135
135
  ---
136
136
 
137
- ## 대시보드 7페이지
137
+ ## 대시보드 8페이지
138
138
 
139
139
  | 페이지 | 설명 |
140
140
  | --- | --- |
141
141
  | **Home** | 현재 상태·다음 할 일 한눈에. |
142
142
  | **Profile** | 프로필, 경력(experiences), 프로젝트, 스킬 관리. |
143
+ | **공고 찾기** | 원티드·점핏·리멤버·사람인을 검색어·직무·경력 연차·오늘 게시로 찾고, 클릭 한 번으로 저장. |
143
144
  | **Jobs** | 저장한 채용공고 목록과 상세(공고 내용·핏 분석). |
144
145
  | **Applications** | 지원 현황을 8단계 상태로 보는 칸반 보드. |
145
146
  | **Documents** | 자기소개서 버전 타임라인과 이력서. |
@@ -317,9 +318,9 @@ You can open the dashboard at any time to see your stored data. For a step-by-st
317
318
  - **Everything through conversation with AI** — MCP tools cover onboarding, profile, resumes, cover letters, job postings, fit analysis, application status, interview prep, expert playbooks, and human-sounding writing.
318
319
  - **8-stage application status** — `draft` · `planned` · `applied` · `document_passed` · `interview` · `final_passed` · `rejected` · `on_hold`. Interview prep is suggested at `document_passed` or later.
319
320
  - **Cover-letter versioning** — Stack versions per posting, compare on a timeline, export to a file.
320
- - **Job search & import** — Pull postings by keyword (role/skill) or job category straight from public job boards (Wanted, Jumpit, Remember, Saramin) and save only the ones you like. Category tables for every occupation (sales, accounting, nursing, manufacturing...) are built in: save your role as a search condition and "show me today's new postings" returns only new ones, with the same posting from several boards folded into one row. No API key or setup; it only *fetches* public postings (no bot-protection bypass, no bulk harvesting; only the search keyword and job category are sent).
321
+ - **Job search & import** - Pull postings by keyword (role/skill) or job category straight from public job boards (Wanted, Jumpit, Remember, Saramin) and save only the ones you like. Category tables for every occupation (sales, accounting, nursing, manufacturing...) are built in: save your role as a search condition and "show me today's new postings" returns only new ones, with the same posting from several boards folded into one row. No API key or setup; it only *fetches* public postings (no bot-protection bypass, no bulk harvesting; only the search keyword and job category are sent). Give your years of experience and it keeps only postings whose experience range covers them (5 years keeps "3-10 yrs" and drops "8-20 yrs"). JobKorea is left out because of its robots.txt. The dashboard's **Find jobs** screen runs the same search and saves a posting in one click.
321
322
  - **Job-posting parsing** — Turn pasted or imported posting text into structured records.
322
- - **Local dashboard** — A 7-page web UI built with framework-free, CDN-free vanilla JS (dark mode supported).
323
+ - **Local dashboard** - An 8-page web UI built with framework-free, CDN-free vanilla JS (dark mode supported).
323
324
  - **Export / delete your data** — Directly from the Settings page.
324
325
 
325
326
  > **Example: job search** — Ask "find backend jobs" and the AI gathers postings from public boards, presents them like this, and saves only the ones you pick (with a saved role, "show me today's new postings" is enough):
@@ -416,12 +417,13 @@ More in [`docs/FAQ.md`](docs/FAQ.md). For **security issues**, use the private p
416
417
 
417
418
  ---
418
419
 
419
- ## The 7 dashboard pages
420
+ ## The 8 dashboard pages
420
421
 
421
422
  | Page | Description |
422
423
  | --- | --- |
423
424
  | **Home** | Current status and next steps at a glance. |
424
425
  | **Profile** | Manage profile, experiences, projects, and skills. |
426
+ | **Find jobs** | Search Wanted, Jumpit, Remember and Saramin by keyword, job category, years of experience or "posted today", and save a posting in one click. |
425
427
  | **Jobs** | List and detail of saved postings (posting content + fit analysis). |
426
428
  | **Applications** | A kanban board showing your pipeline across the 8 statuses. |
427
429
  | **Documents** | Cover-letter version timeline and resumes. |
package/dist/doctor.mjs CHANGED
@@ -86991,7 +86991,7 @@ var ApplicationContextSchema = external_exports.object({
86991
86991
  });
86992
86992
 
86993
86993
  // packages/shared/src/build-info.ts
86994
- var APP_VERSION = true ? "0.0.14" : "0.0.0-dev";
86994
+ var APP_VERSION = true ? "0.0.15" : "0.0.0-dev";
86995
86995
  var BUNDLED = true;
86996
86996
 
86997
86997
  // packages/shared/src/index.ts
@@ -89101,11 +89101,16 @@ function isRememberCategory(value) {
89101
89101
  return !!subs && (sub === void 0 || subs.includes(sub));
89102
89102
  }
89103
89103
  var saraminCodes;
89104
+ var saraminIndex = () => {
89105
+ if (!saraminCodes) {
89106
+ saraminCodes = /* @__PURE__ */ new Map();
89107
+ for (const kinds of Object.values(SARAMIN_CATEGORIES))
89108
+ for (const list of Object.values(kinds)) for (const [c, n] of list) if (!saraminCodes.has(c)) saraminCodes.set(c, n);
89109
+ }
89110
+ return saraminCodes;
89111
+ };
89104
89112
  function isSaraminCategory(code) {
89105
- saraminCodes ??= new Set(
89106
- Object.values(SARAMIN_CATEGORIES).flatMap((kinds) => Object.values(kinds).flatMap((list) => list.map(([c]) => c)))
89107
- );
89108
- return saraminCodes.has(code);
89113
+ return saraminIndex().has(code);
89109
89114
  }
89110
89115
 
89111
89116
  // packages/core/src/jobsearch.ts
package/dist/mcp.mjs CHANGED
@@ -103447,7 +103447,7 @@ var ApplicationContextSchema = external_exports.object({
103447
103447
  });
103448
103448
 
103449
103449
  // packages/shared/src/build-info.ts
103450
- var APP_VERSION = true ? "0.0.14" : "0.0.0-dev";
103450
+ var APP_VERSION = true ? "0.0.15" : "0.0.0-dev";
103451
103451
  var BUNDLED = true;
103452
103452
 
103453
103453
  // packages/shared/src/index.ts
@@ -105557,11 +105557,16 @@ function isRememberCategory(value) {
105557
105557
  return !!subs && (sub === void 0 || subs.includes(sub));
105558
105558
  }
105559
105559
  var saraminCodes;
105560
+ var saraminIndex = () => {
105561
+ if (!saraminCodes) {
105562
+ saraminCodes = /* @__PURE__ */ new Map();
105563
+ for (const kinds of Object.values(SARAMIN_CATEGORIES))
105564
+ for (const list of Object.values(kinds)) for (const [c, n] of list) if (!saraminCodes.has(c)) saraminCodes.set(c, n);
105565
+ }
105566
+ return saraminCodes;
105567
+ };
105560
105568
  function isSaraminCategory(code) {
105561
- saraminCodes ??= new Set(
105562
- Object.values(SARAMIN_CATEGORIES).flatMap((kinds) => Object.values(kinds).flatMap((list) => list.map(([c]) => c)))
105563
- );
105564
- return saraminCodes.has(code);
105569
+ return saraminIndex().has(code);
105565
105570
  }
105566
105571
 
105567
105572
  // packages/core/src/jobsearch.ts
@@ -9,6 +9,7 @@ import { t, onLangChange } from '/i18n.js';
9
9
  const ROUTES = [
10
10
  { id: 'home', icon: 'home', module: '/pages/home.js' },
11
11
  { id: 'profile', icon: 'user', module: '/pages/profile.js' },
12
+ { id: 'search', icon: 'search', module: '/pages/search.js' },
12
13
  { id: 'jobs', icon: 'briefcase', module: '/pages/jobs.js', countKey: 'jobs' },
13
14
  { id: 'applications', icon: 'layers', module: '/pages/applications.js', countKey: 'active_applications' },
14
15
  { id: 'documents', icon: 'file', module: '/pages/documents.js', countKey: 'cover_letters' },
@@ -3,6 +3,7 @@ export default {
3
3
  'nav.home': 'Home',
4
4
  'nav.profile': 'Profile',
5
5
  'nav.jobs': 'Postings',
6
+ 'nav.search': 'Find jobs',
6
7
  'nav.applications': 'Applications',
7
8
  'nav.documents': 'Documents',
8
9
  'nav.interview': 'Interview prep',
@@ -0,0 +1,43 @@
1
+ // Find jobs (Wanted, Jumpit, Remember, Saramin search).
2
+ export default {
3
+ 'search.desc': 'Search public postings on Wanted, Jumpit, Remember and Saramin at once. Only the keyword and job conditions leave this computer.',
4
+ 'search.cond.title': 'Search conditions',
5
+ 'search.keyword': 'Keyword',
6
+ 'search.keyword.ph': 'e.g. backend, finance, nurse',
7
+ 'search.keyword.hint': 'Wanted and Jumpit are searched by keyword only.',
8
+ 'search.cat': 'Job category',
9
+ 'search.cat.ph': 'Find by job name (e.g. accounting, frontend)',
10
+ 'search.cat.find': 'Find',
11
+ 'search.cat.hint': 'Remember and Saramin are searched by the categories you pick. Click to add or remove.',
12
+ 'search.cat.none': 'No matching category. Try another name.',
13
+ 'search.cat.remember': 'Remember',
14
+ 'search.cat.saramin': 'Saramin',
15
+ 'search.years': 'My experience (years)',
16
+ 'search.years.ph': 'Leave blank for any',
17
+ 'search.years.hint': 'Keeps postings whose experience range includes this number. Use 0 for new grads.',
18
+ 'search.date': 'Posting date',
19
+ 'search.today': 'Posted today only',
20
+ 'search.today.hint': 'Wanted and Jumpit do not give a posting date, so CareerMate uses the day it first saw each posting.',
21
+ 'search.run': 'Search',
22
+ 'search.save.prefs': 'Save these conditions',
23
+ 'search.saved.prefs': 'Search conditions saved.',
24
+ 'search.need': 'Enter a keyword or pick a job category.',
25
+ 'search.results.title': 'Results',
26
+ 'search.results.count': '{{n}} postings',
27
+ 'search.results.filtered': '{{n}} dropped by your conditions',
28
+ 'search.results.empty': 'No postings match.',
29
+ 'search.results.emptyBody': 'Try another keyword or category, or loosen the experience and today filters.',
30
+ 'search.results.idle': 'Set conditions and press Search to see postings here.',
31
+ 'search.provider.fail': '{{site}} did not respond',
32
+ 'search.posted': 'Posted {{d}}',
33
+ 'search.deadline': 'Due {{d}}',
34
+ 'search.alsoOn': 'Also on',
35
+ 'search.save': 'Save',
36
+ 'search.savedBadge': 'Saved',
37
+ 'search.savedToast': 'Saved to Postings.',
38
+ 'search.sort.note': 'Newest postings first. Wanted and Jumpit postings have no posting date and come after.',
39
+ 'search.site.wanted': 'Wanted',
40
+ 'search.site.jumpit': 'Jumpit',
41
+ 'search.site.remember': 'Remember',
42
+ 'search.site.saramin': 'Saramin',
43
+ };
@@ -10,8 +10,9 @@ import documents from './en/documents.js';
10
10
  import profile from './en/profile.js';
11
11
  import interview from './en/interview.js';
12
12
  import applications from './en/applications.js';
13
+ import search from './en/search.js';
13
14
 
14
15
  export default {
15
16
  ...common, ...nav, ...status, ...settings,
16
- ...home, ...jobs, ...documents, ...profile, ...interview, ...applications,
17
+ ...home, ...jobs, ...documents, ...profile, ...interview, ...applications, ...search,
17
18
  };
@@ -3,6 +3,7 @@ export default {
3
3
  'nav.home': '홈',
4
4
  'nav.profile': '프로필',
5
5
  'nav.jobs': '채용공고',
6
+ 'nav.search': '공고 찾기',
6
7
  'nav.applications': '지원 현황',
7
8
  'nav.documents': '문서',
8
9
  'nav.interview': '면접 준비',
@@ -0,0 +1,43 @@
1
+ // 공고 찾기(원티드·점핏·리멤버·사람인 검색).
2
+ export default {
3
+ 'search.desc': '원티드·점핏·리멤버·사람인의 공개 공고를 한 번에 찾습니다. 밖으로 나가는 것은 검색어와 직무 조건뿐입니다.',
4
+ 'search.cond.title': '검색 조건',
5
+ 'search.keyword': '검색어',
6
+ 'search.keyword.ph': '예: 백엔드, 재무, 간호사',
7
+ 'search.keyword.hint': '원티드·점핏은 검색어로만 찾습니다.',
8
+ 'search.cat': '직무',
9
+ 'search.cat.ph': '직무 이름으로 찾기 (예: 회계, 프론트엔드)',
10
+ 'search.cat.find': '찾기',
11
+ 'search.cat.hint': '리멤버·사람인은 고른 직무로 찾습니다. 눌러서 고르거나 뺍니다.',
12
+ 'search.cat.none': '맞는 직무를 찾지 못했습니다. 다른 이름으로 찾아 보세요.',
13
+ 'search.cat.remember': '리멤버',
14
+ 'search.cat.saramin': '사람인',
15
+ 'search.years': '내 경력(년)',
16
+ 'search.years.ph': '비우면 상관없음',
17
+ 'search.years.hint': '공고의 경력 범위에 이 연차가 들어가는 것만 남깁니다. 신입은 0.',
18
+ 'search.date': '게시일',
19
+ 'search.today': '오늘 올라온 공고만',
20
+ 'search.today.hint': '원티드·점핏은 게시일을 주지 않아 CareerMate가 처음 본 날로 거릅니다.',
21
+ 'search.run': '검색',
22
+ 'search.save.prefs': '이 조건 저장',
23
+ 'search.saved.prefs': '검색 조건을 저장했습니다.',
24
+ 'search.need': '검색어나 직무를 넣어 주세요.',
25
+ 'search.results.title': '검색 결과',
26
+ 'search.results.count': '{{n}}건',
27
+ 'search.results.filtered': '조건에 맞지 않아 {{n}}건 제외',
28
+ 'search.results.empty': '조건에 맞는 공고가 없습니다.',
29
+ 'search.results.emptyBody': '검색어나 직무를 바꾸거나, 경력·오늘 조건을 풀어 보세요.',
30
+ 'search.results.idle': '조건을 넣고 검색을 누르면 공고가 여기에 나옵니다.',
31
+ 'search.provider.fail': '{{site}} 응답 실패',
32
+ 'search.posted': '게시 {{d}}',
33
+ 'search.deadline': '마감 {{d}}',
34
+ 'search.alsoOn': '함께 올라온 곳',
35
+ 'search.save': '저장',
36
+ 'search.savedBadge': '저장됨',
37
+ 'search.savedToast': '채용공고에 저장했습니다.',
38
+ 'search.sort.note': '게시일이 최신인 공고부터 보입니다. 게시일이 없는 원티드·점핏 공고는 뒤에 붙습니다.',
39
+ 'search.site.wanted': '원티드',
40
+ 'search.site.jumpit': '점핏',
41
+ 'search.site.remember': '리멤버',
42
+ 'search.site.saramin': '사람인',
43
+ };
@@ -11,8 +11,9 @@ import documents from './ko/documents.js';
11
11
  import profile from './ko/profile.js';
12
12
  import interview from './ko/interview.js';
13
13
  import applications from './ko/applications.js';
14
+ import search from './ko/search.js';
14
15
 
15
16
  export default {
16
17
  ...common, ...nav, ...status, ...settings,
17
- ...home, ...jobs, ...documents, ...profile, ...interview, ...applications,
18
+ ...home, ...jobs, ...documents, ...profile, ...interview, ...applications, ...search,
18
19
  };
@@ -0,0 +1,208 @@
1
+ // Find jobs (#/search): the same board search as the search_jobs MCP tool:
2
+ // keyword + Remember/Saramin categories, optional years and "today only",
3
+ // saved conditions, and one-click save into the jobs list.
4
+ import { el, get, post, put, Card, Btn, Field, Input, EmptyState, Spinner, icon, mount, clear, toastOk, toastError } from '/lib.js';
5
+ import { t } from '/i18n.js';
6
+
7
+ const site = (s) => t('search.site.' + s);
8
+ // Stored job "source" stays one fixed label whatever the UI language (the schema lists 사람인/원티드...).
9
+ const SOURCE_LABEL = { wanted: '원티드', jumpit: '점핏', remember: '리멤버', saramin: '사람인' };
10
+
11
+ export async function render(ctx) {
12
+ ctx.setTitle(t('nav.search'));
13
+
14
+ const state = { remember: [], saramin: [], names: new Map() };
15
+ try {
16
+ const { preferences: p, saramin_names } = await get('/api/job-search/preferences');
17
+ state.remember = [...p.remember_categories];
18
+ state.saramin = [...p.saramin_categories];
19
+ state.keyword = p.keyword;
20
+ for (const [c, n] of Object.entries(saramin_names || {})) state.names.set(c, n);
21
+ } catch { /* first run: nothing saved */ }
22
+
23
+ const keyword = Input({ id: 'search-keyword', placeholder: t('search.keyword.ph'), value: state.keyword || '' });
24
+ const years = Input({ id: 'search-years', type: 'number', min: '0', max: '40', inputMode: 'numeric', placeholder: t('search.years.ph') });
25
+ const today = el('input', { id: 'search-today', type: 'checkbox' });
26
+ const catQuery = Input({ id: 'search-cat', placeholder: t('search.cat.ph') });
27
+ const suggestBox = el('div', { class: 'stack-2' });
28
+ const selectedBox = el('div', { class: 'chips' });
29
+ const results = el('div', {}, el('p', { class: 'muted', style: { margin: 0 } }, t('search.results.idle')));
30
+
31
+ const label = (kind, v) => (kind === 'saramin' ? state.names.get(v) || v : v.replace('>', ' > '));
32
+
33
+ function toggle(kind, v) {
34
+ const list = state[kind];
35
+ const i = list.indexOf(v);
36
+ if (i >= 0) list.splice(i, 1); else list.push(v);
37
+ paintSelected();
38
+ suggestBox.querySelectorAll('[data-cat]').forEach((b) => b.classList.toggle('is-selected', state[b.dataset.kind].includes(b.dataset.cat)));
39
+ }
40
+
41
+ function catButton(kind, value, text) {
42
+ const b = el('button', {
43
+ type: 'button',
44
+ class: `btn btn--sm${state[kind].includes(value) ? ' is-selected' : ''}`,
45
+ dataset: { cat: value, kind },
46
+ onClick: () => toggle(kind, value),
47
+ }, text);
48
+ return b;
49
+ }
50
+
51
+ function paintSelected() {
52
+ const items = [
53
+ ...state.remember.map((v) => ['remember', v]),
54
+ ...state.saramin.map((v) => ['saramin', v]),
55
+ ];
56
+ mount(selectedBox, ...items.map(([kind, v]) =>
57
+ el('button', { type: 'button', class: 'chip chip--accent', title: t('common.delete'), onClick: () => toggle(kind, v) },
58
+ `${site(kind)} · ${label(kind, v)} ×`)));
59
+ selectedBox.hidden = !items.length;
60
+ }
61
+
62
+ async function findCategories() {
63
+ const q = catQuery.value.trim();
64
+ if (!q) return;
65
+ try {
66
+ const r = await get(`/api/job-search/categories?q=${encodeURIComponent(q)}`);
67
+ for (const s of r.saramin) state.names.set(s.code, s.name);
68
+ if (!r.remember.length && !r.saramin.length) {
69
+ mount(suggestBox, el('p', { class: 'muted text-sm', style: { margin: 0 } }, t('search.cat.none')));
70
+ return;
71
+ }
72
+ mount(suggestBox,
73
+ r.remember.length ? el('div', { class: 'flex wrap gap-2' },
74
+ el('span', { class: 'muted text-sm' }, t('search.cat.remember')),
75
+ ...r.remember.slice(0, 8).map((v) => catButton('remember', v, v.replace('>', ' > ')))) : null,
76
+ r.saramin.length ? el('div', { class: 'flex wrap gap-2' },
77
+ el('span', { class: 'muted text-sm' }, t('search.cat.saramin')),
78
+ ...r.saramin.slice(0, 8).map((s) => catButton('saramin', s.code, s.name))) : null);
79
+ } catch (e) { toastError(e); }
80
+ }
81
+ catQuery.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); findCategories(); } });
82
+
83
+ async function savePrefs() {
84
+ try {
85
+ await put('/api/job-search/preferences', {
86
+ keyword: keyword.value.trim(),
87
+ remember_categories: state.remember,
88
+ saramin_categories: state.saramin,
89
+ });
90
+ toastOk(t('search.saved.prefs'));
91
+ } catch (e) { toastError(e); }
92
+ }
93
+
94
+ async function saveJob(r, cell) {
95
+ if (cell.dataset.busy) return; // double-click would log the save twice
96
+ cell.dataset.busy = '1';
97
+ try {
98
+ const { job } = await post('/api/jobs', {
99
+ company: r.company,
100
+ position: r.position,
101
+ url: r.url,
102
+ ...(r.location ? { location: r.location } : {}),
103
+ ...(r.deadline ? { deadline: r.deadline } : {}),
104
+ ...(r.keywords?.length ? { keywords: r.keywords.slice(0, 30) } : {}),
105
+ source: SOURCE_LABEL[r.source] || r.source,
106
+ });
107
+ r.saved_job_id = job.id;
108
+ mount(cell, savedBadge(job.id));
109
+ toastOk(t('search.savedToast'));
110
+ ctx.refreshNav();
111
+ } catch (e) {
112
+ delete cell.dataset.busy;
113
+ toastError(e);
114
+ }
115
+ }
116
+
117
+ const savedBadge = (id) => el('a', { class: 'badge badge--final_passed', href: `#/jobs/${id}` }, icon('check'), t('search.savedBadge'));
118
+
119
+ function row(r) {
120
+ const trail = el('div', { class: 'flex gap-2' });
121
+ if (r.saved_job_id) trail.append(savedBadge(r.saved_job_id));
122
+ else trail.append(Btn(t('search.save'), { icon: 'plus', sm: true, onClick: () => saveJob(r, trail) }));
123
+ const meta = [
124
+ r.company,
125
+ r.experience,
126
+ r.location,
127
+ r.posted_at && t('search.posted', { d: r.posted_at }),
128
+ r.deadline && t('search.deadline', { d: r.deadline }),
129
+ ].filter(Boolean).join(' · ');
130
+ return el('div', { class: 'list-row' },
131
+ el('div', { class: 'list-row__lead' }, el('span', { class: 'badge' }, site(r.source))),
132
+ el('div', { class: 'list-row__main' },
133
+ el('div', { class: 'list-row__title' },
134
+ el('a', { href: r.url, target: '_blank', rel: 'noopener noreferrer', title: r.position }, r.position)),
135
+ el('div', { class: 'list-row__sub', title: meta }, meta),
136
+ r.also_on?.length ? el('div', { class: 'list-row__sub' }, `${t('search.alsoOn')}: `,
137
+ ...r.also_on.flatMap((a, i) => [i ? ', ' : '', el('a', { href: a.url, target: '_blank', rel: 'noopener noreferrer' }, site(a.source))])) : null),
138
+ el('div', { class: 'list-row__trail' }, trail));
139
+ }
140
+
141
+ let busy = false;
142
+ async function run() {
143
+ if (busy) return;
144
+ const kw = keyword.value.trim();
145
+ if (!kw && !state.remember.length && !state.saramin.length) { toastError(new Error(t('search.need'))); return; }
146
+ const y = years.value.trim();
147
+ if (y !== '' && !(Number.isInteger(Number(y)) && Number(y) >= 0 && Number(y) <= 40)) { toastError(new Error(t('search.years.hint'))); years.focus(); return; }
148
+ busy = true;
149
+ runBtn.disabled = true;
150
+ mount(results, Spinner());
151
+ try {
152
+ const res = await post('/api/job-search', {
153
+ keyword: kw,
154
+ remember_categories: state.remember,
155
+ saramin_categories: state.saramin,
156
+ // A date filter drops most of a page, so ask for more then (matches the core default of 50).
157
+ limit: today.checked ? 50 : 30,
158
+ ...(y !== '' ? { years: Number(y) } : {}),
159
+ ...(today.checked ? { posted_since: 'today' } : {}),
160
+ });
161
+ // Dated postings newest first; boards without a date keep their order after them.
162
+ const list = res.results.map((r, i) => ({ ...r, i }))
163
+ .sort((a, b) => (b.posted_at ?? '').localeCompare(a.posted_at ?? '') || a.i - b.i);
164
+ const failed = res.providers.filter((p) => !p.ok);
165
+ const head = el('div', { class: 'flex wrap gap-2 mb-3' },
166
+ el('span', { class: 'badge badge--accent tnum' }, t('search.results.count', { n: list.length })),
167
+ res.filtered_out ? el('span', { class: 'badge tnum' }, t('search.results.filtered', { n: res.filtered_out })) : null,
168
+ ...failed.map((p) => el('span', { class: 'badge badge--rejected', title: p.error || '' }, t('search.provider.fail', { site: site(p.source) }))));
169
+ mount(results,
170
+ head,
171
+ list.length
172
+ ? el('div', {}, el('p', { class: 'muted text-sm', style: { margin: '0 0 4px' } }, t('search.sort.note')), ...list.map(row))
173
+ : EmptyState({ iconName: 'search', title: t('search.results.empty'), body: t('search.results.emptyBody') }));
174
+ } catch (e) {
175
+ clear(results);
176
+ results.append(el('p', { class: 'muted', style: { margin: 0 } }, e instanceof Error ? e.message : String(e)));
177
+ toastError(e);
178
+ } finally {
179
+ busy = false;
180
+ runBtn.disabled = false;
181
+ }
182
+ }
183
+
184
+ const runBtn = Btn(t('search.run'), { icon: 'search', variant: 'primary', onClick: run });
185
+ keyword.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); run(); } });
186
+ years.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); run(); } });
187
+
188
+ paintSelected();
189
+
190
+ const form = Card({
191
+ title: t('search.cond.title'),
192
+ body: el('div', {},
193
+ Field(t('search.keyword'), keyword, t('search.keyword.hint')),
194
+ Field(t('search.cat'), el('div', { class: 'stack-2' },
195
+ el('div', { class: 'flex gap-2' }, catQuery, Btn(t('search.cat.find'), { onClick: findCategories })),
196
+ suggestBox,
197
+ selectedBox), t('search.cat.hint')),
198
+ el('div', { class: 'grid grid--2' },
199
+ Field(t('search.years'), years, t('search.years.hint')),
200
+ Field(t('search.date'), el('label', { class: 'flex gap-2', for: 'search-today', style: { cursor: 'pointer', minHeight: '36px' } }, today, t('search.today')), t('search.today.hint'))),
201
+ el('div', { class: 'flex wrap gap-2' }, runBtn, Btn(t('search.save.prefs'), { icon: 'check', variant: 'ghost', onClick: savePrefs }))),
202
+ });
203
+
204
+ mount(ctx.view, el('div', { class: 'stack-4' },
205
+ el('p', { class: 'muted', style: { marginTop: 0 } }, t('search.desc')),
206
+ form,
207
+ Card({ title: t('search.results.title'), body: results })));
208
+ }
package/dist/web.mjs CHANGED
@@ -2106,7 +2106,7 @@ var init_some = __esm({
2106
2106
  });
2107
2107
 
2108
2108
  // node_modules/underscore/modules/contains.js
2109
- function contains(obj2, item, fromIndex, guard) {
2109
+ function contains2(obj2, item, fromIndex, guard) {
2110
2110
  if (!isArrayLike_default(obj2)) obj2 = values(obj2);
2111
2111
  if (typeof fromIndex != "number" || guard) fromIndex = 0;
2112
2112
  return indexOf_default(obj2, item, fromIndex) >= 0;
@@ -2485,7 +2485,7 @@ var init_omit = __esm({
2485
2485
  } else {
2486
2486
  keys2 = map4(flatten(keys2, false, false), String);
2487
2487
  iteratee2 = function(value, key) {
2488
- return !contains(keys2, key);
2488
+ return !contains2(keys2, key);
2489
2489
  };
2490
2490
  }
2491
2491
  return pick_default(obj2, iteratee2, context);
@@ -2575,7 +2575,7 @@ var init_difference = __esm({
2575
2575
  difference_default = restArguments(function(array2, rest2) {
2576
2576
  rest2 = flatten(rest2, true, true);
2577
2577
  return filter(array2, function(value) {
2578
- return !contains(rest2, value);
2578
+ return !contains2(rest2, value);
2579
2579
  });
2580
2580
  });
2581
2581
  }
@@ -2610,11 +2610,11 @@ function uniq(array2, isSorted, iteratee2, context) {
2610
2610
  if (!i || seen !== computed) result2.push(value);
2611
2611
  seen = computed;
2612
2612
  } else if (iteratee2) {
2613
- if (!contains(seen, computed)) {
2613
+ if (!contains2(seen, computed)) {
2614
2614
  seen.push(computed);
2615
2615
  result2.push(value);
2616
2616
  }
2617
- } else if (!contains(result2, value)) {
2617
+ } else if (!contains2(result2, value)) {
2618
2618
  result2.push(value);
2619
2619
  }
2620
2620
  }
@@ -2650,10 +2650,10 @@ function intersection2(array2) {
2650
2650
  var argsLength = arguments.length;
2651
2651
  for (var i = 0, length = getLength_default(array2); i < length; i++) {
2652
2652
  var item = array2[i];
2653
- if (contains(result2, item)) continue;
2653
+ if (contains2(result2, item)) continue;
2654
2654
  var j;
2655
2655
  for (j = 1; j < argsLength; j++) {
2656
- if (!contains(arguments[j], item)) break;
2656
+ if (!contains2(arguments[j], item)) break;
2657
2657
  }
2658
2658
  if (j === argsLength) result2.push(item);
2659
2659
  }
@@ -2841,7 +2841,7 @@ __export(modules_exports, {
2841
2841
  compact: () => compact,
2842
2842
  compose: () => compose,
2843
2843
  constant: () => constant,
2844
- contains: () => contains,
2844
+ contains: () => contains2,
2845
2845
  countBy: () => countBy_default,
2846
2846
  create: () => create,
2847
2847
  debounce: () => debounce,
@@ -2874,8 +2874,8 @@ __export(modules_exports, {
2874
2874
  has: () => has2,
2875
2875
  head: () => first,
2876
2876
  identity: () => identity,
2877
- include: () => contains,
2878
- includes: () => contains,
2877
+ include: () => contains2,
2878
+ includes: () => contains2,
2879
2879
  indexBy: () => indexBy_default,
2880
2880
  indexOf: () => indexOf_default,
2881
2881
  initial: () => initial,
@@ -3136,7 +3136,7 @@ __export(index_all_exports, {
3136
3136
  compact: () => compact,
3137
3137
  compose: () => compose,
3138
3138
  constant: () => constant,
3139
- contains: () => contains,
3139
+ contains: () => contains2,
3140
3140
  countBy: () => countBy_default,
3141
3141
  create: () => create,
3142
3142
  debounce: () => debounce,
@@ -3169,8 +3169,8 @@ __export(index_all_exports, {
3169
3169
  has: () => has2,
3170
3170
  head: () => first,
3171
3171
  identity: () => identity,
3172
- include: () => contains,
3173
- includes: () => contains,
3172
+ include: () => contains2,
3173
+ includes: () => contains2,
3174
3174
  indexBy: () => indexBy_default,
3175
3175
  indexOf: () => indexOf_default,
3176
3176
  initial: () => initial,
@@ -40162,6 +40162,52 @@ function ensureTables() {
40162
40162
  );
40163
40163
  `);
40164
40164
  }
40165
+ var FORGET_AFTER_DAYS = 120;
40166
+ function markJobsSeen(items, today) {
40167
+ const seen = /* @__PURE__ */ new Map();
40168
+ if (!items.length) return seen;
40169
+ const db = getDb();
40170
+ const upsert = db.prepare(
40171
+ `INSERT INTO job_search_seen (source, external_id, first_seen_at, last_seen_at) VALUES (?, ?, ?, ?)
40172
+ ON CONFLICT(source, external_id) DO UPDATE SET last_seen_at = excluded.last_seen_at
40173
+ RETURNING first_seen_at`
40174
+ );
40175
+ tx(() => {
40176
+ for (const it of items) {
40177
+ const row = upsert.get(it.source, it.external_id, today, today);
40178
+ seen.set(`${it.source}:${it.external_id}`, row?.first_seen_at ?? today);
40179
+ }
40180
+ db.prepare(`DELETE FROM job_search_seen WHERE last_seen_at < date(?, ?)`).run(today, `-${FORGET_AFTER_DAYS} days`);
40181
+ });
40182
+ return seen;
40183
+ }
40184
+ function parseList2(value) {
40185
+ try {
40186
+ const parsed = JSON.parse(String(value));
40187
+ return Array.isArray(parsed) ? parsed.filter((v) => typeof v === "string") : [];
40188
+ } catch {
40189
+ return [];
40190
+ }
40191
+ }
40192
+ function getJobSearchPreferences() {
40193
+ const row = getDb().prepare(`SELECT * FROM job_search_preferences WHERE id = 1`).get();
40194
+ return {
40195
+ keyword: typeof row?.keyword === "string" ? row.keyword : "",
40196
+ remember_categories: parseList2(row?.remember_categories ?? "[]"),
40197
+ saramin_categories: parseList2(row?.saramin_categories ?? "[]"),
40198
+ updated_at: typeof row?.updated_at === "string" ? row.updated_at : null
40199
+ };
40200
+ }
40201
+ function setJobSearchPreferences(prefs) {
40202
+ const next = { ...getJobSearchPreferences(), ...prefs, updated_at: (/* @__PURE__ */ new Date()).toISOString() };
40203
+ getDb().prepare(
40204
+ `INSERT INTO job_search_preferences (id, keyword, remember_categories, saramin_categories, updated_at)
40205
+ VALUES (1, ?, ?, ?, ?)
40206
+ ON CONFLICT(id) DO UPDATE SET keyword = excluded.keyword, remember_categories = excluded.remember_categories,
40207
+ saramin_categories = excluded.saramin_categories, updated_at = excluded.updated_at`
40208
+ ).run(next.keyword, JSON.stringify(next.remember_categories), JSON.stringify(next.saramin_categories), next.updated_at);
40209
+ return next;
40210
+ }
40165
40211
  ensureTables();
40166
40212
 
40167
40213
  // packages/shared/src/errors.ts
@@ -51551,13 +51597,13 @@ function _promise(Class2, innerType) {
51551
51597
  }
51552
51598
  // @__NO_SIDE_EFFECTS__
51553
51599
  function _custom(Class2, fn, _params) {
51554
- const norm2 = normalizeParams(_params);
51555
- norm2.abort ?? (norm2.abort = true);
51600
+ const norm3 = normalizeParams(_params);
51601
+ norm3.abort ?? (norm3.abort = true);
51556
51602
  const schema = new Class2({
51557
51603
  type: "custom",
51558
51604
  check: "custom",
51559
51605
  fn,
51560
- ...norm2
51606
+ ...norm3
51561
51607
  });
51562
51608
  return schema;
51563
51609
  }
@@ -55283,7 +55329,7 @@ var ApplicationContextSchema = external_exports.object({
55283
55329
  });
55284
55330
 
55285
55331
  // packages/shared/src/build-info.ts
55286
- var APP_VERSION = true ? "0.0.14" : "0.0.0-dev";
55332
+ var APP_VERSION = true ? "0.0.15" : "0.0.0-dev";
55287
55333
  var BUNDLED = true;
55288
55334
 
55289
55335
  // packages/shared/src/index.ts
@@ -56529,8 +56575,8 @@ function profileCompleteness(p) {
56529
56575
  experienceRepo.list().length > 0,
56530
56576
  skillRepo.list().length > 0
56531
56577
  ];
56532
- const score = Math.round(checks.filter(Boolean).length / checks.length * 100);
56533
- return score;
56578
+ const score2 = Math.round(checks.filter(Boolean).length / checks.length * 100);
56579
+ return score2;
56534
56580
  }
56535
56581
  function getOnboardingStatus() {
56536
56582
  const profile = profileRepo.get();
@@ -56886,7 +56932,7 @@ function styleSignals(text) {
56886
56932
  }
56887
56933
  var DISCLAIMER = "\uC774 \uC810\uAC80\uC740 LLM \uC5C6\uC774 \uC140 \uC218 \uC788\uB294 \uAC83\uB9CC \uBD05\uB2C8\uB2E4(\uC22B\uC790 \uCD9C\uCC98\xB7\uBB38\uCCB4 \uC2E0\uD638). \uD1B5\uACFC=\uD569\uACA9\uC774 \uC544\uB2C8\uBA70, \uC0AC\uC2E4\xB7\uB3D9\uBB38\uC11C\uB2F5\xB7\uC758\uBBF8 \uD310\uB2E8\uC740 \uB2F9\uC2E0(AI)\uC774 \uC9C1\uC811 \uD574\uC57C \uD569\uB2C8\uB2E4.";
56888
56934
  function lintArtifact(kind, text, corpus, opts = {}) {
56889
- const norm2 = normalizeText(text);
56935
+ const norm3 = normalizeText(text);
56890
56936
  const corpusAvailable = (corpus.documents + corpus.structured).trim().length > 0;
56891
56937
  const provenance = analyzeProvenance(text, corpus);
56892
56938
  const signals = styleSignals(text);
@@ -56910,7 +56956,7 @@ function lintArtifact(kind, text, corpus, opts = {}) {
56910
56956
  }
56911
56957
  return {
56912
56958
  kind,
56913
- charCount: [...norm2].length,
56959
+ charCount: [...norm3].length,
56914
56960
  charCounts: countChars(text),
56915
56961
  signals,
56916
56962
  provenance,
@@ -57403,6 +57449,54 @@ var SARAMIN_CATEGORIES = {
57403
57449
  };
57404
57450
 
57405
57451
  // packages/core/src/job-categories.ts
57452
+ var norm2 = (s) => s.toLowerCase().replace(/[\s·/(),&.\-]/g, "");
57453
+ function contains(hayRaw, needle) {
57454
+ if (/^[a-z0-9]{1,3}$/.test(needle)) return new RegExp(`(?<![a-z0-9])${needle}(?![a-z0-9])`).test(hayRaw.toLowerCase());
57455
+ return norm2(hayRaw).includes(needle);
57456
+ }
57457
+ function score(name, terms) {
57458
+ const n = norm2(name);
57459
+ if (n.length < 2) return 0;
57460
+ let best = 0;
57461
+ for (const term of terms) {
57462
+ const t = norm2(term);
57463
+ if (n === t) return 3;
57464
+ if (contains(name, t)) best = Math.max(best, 2);
57465
+ else if (contains(term, n)) best = Math.max(best, 1);
57466
+ for (const word of term.split(/\s+/).map(norm2)) {
57467
+ if (word.length < 2 || word === t) continue;
57468
+ if (word === n) best = Math.max(best, 1);
57469
+ else if (contains(name, word)) best = Math.max(best, 0.5);
57470
+ }
57471
+ }
57472
+ return best;
57473
+ }
57474
+ function findJobCategories(query, limit = 20) {
57475
+ const terms = (query ?? "").split(/[,\n]/).map((t) => t.trim()).filter((t) => norm2(t).length >= 2);
57476
+ const remember2 = [];
57477
+ for (const [group2, subs] of Object.entries(REMEMBER_CATEGORIES)) {
57478
+ const gs = score(group2, terms);
57479
+ if (gs) remember2.push({ v: group2, s: gs - 0.5 });
57480
+ for (const sub of subs) {
57481
+ const s = score(sub, terms);
57482
+ if (s) remember2.push({ v: `${group2}>${sub}`, s });
57483
+ }
57484
+ }
57485
+ const saramin2 = [];
57486
+ for (const [group2, kinds] of Object.entries(SARAMIN_CATEGORIES)) {
57487
+ for (const [kind, list] of Object.entries(kinds)) {
57488
+ for (const [code, name] of list) {
57489
+ const s = score(name, terms) - (kind === "\uC9C1\uBB34\xB7\uC9C1\uC5C5" ? 0 : 0.25);
57490
+ if (s > 0) saramin2.push({ code, name, group: group2, kind, s });
57491
+ }
57492
+ }
57493
+ }
57494
+ return {
57495
+ query,
57496
+ remember: remember2.sort((a, b) => b.s - a.s).slice(0, limit).map((r) => r.v),
57497
+ saramin: saramin2.sort((a, b) => b.s - a.s).slice(0, limit).map(({ s: _s, ...c }) => c)
57498
+ };
57499
+ }
57406
57500
  function isRememberCategory(value) {
57407
57501
  const parts = value.split(">").map((s) => s.trim());
57408
57502
  if (parts.length > 2) return false;
@@ -57411,11 +57505,19 @@ function isRememberCategory(value) {
57411
57505
  return !!subs && (sub === void 0 || subs.includes(sub));
57412
57506
  }
57413
57507
  var saraminCodes;
57508
+ var saraminIndex = () => {
57509
+ if (!saraminCodes) {
57510
+ saraminCodes = /* @__PURE__ */ new Map();
57511
+ for (const kinds of Object.values(SARAMIN_CATEGORIES))
57512
+ for (const list of Object.values(kinds)) for (const [c, n] of list) if (!saraminCodes.has(c)) saraminCodes.set(c, n);
57513
+ }
57514
+ return saraminCodes;
57515
+ };
57414
57516
  function isSaraminCategory(code) {
57415
- saraminCodes ??= new Set(
57416
- Object.values(SARAMIN_CATEGORIES).flatMap((kinds) => Object.values(kinds).flatMap((list) => list.map(([c]) => c)))
57417
- );
57418
- return saraminCodes.has(code);
57517
+ return saraminIndex().has(code);
57518
+ }
57519
+ function saraminCategoryName(code) {
57520
+ return saraminIndex().get(code);
57419
57521
  }
57420
57522
 
57421
57523
  // packages/core/src/jobsearch.ts
@@ -57663,6 +57765,118 @@ var saramin = {
57663
57765
  };
57664
57766
  var PROVIDERS = [wanted, jumpit, remember, saramin];
57665
57767
  var JOB_SEARCH_SOURCES = PROVIDERS.map((p) => p.id);
57768
+ function providerError(e) {
57769
+ if (e instanceof external_exports.ZodError) {
57770
+ const first2 = e.issues[0];
57771
+ return {
57772
+ status: "degraded",
57773
+ error: `provider response changed${first2 ? ` (${first2.path.join(".") || "root"})` : ""}`
57774
+ };
57775
+ }
57776
+ const message = e instanceof Error ? e.message : String(e);
57777
+ return { status: "unavailable", error: message };
57778
+ }
57779
+ async function searchJobs(query, opts = {}) {
57780
+ const q = (query ?? "").trim();
57781
+ const limit = Math.min(100, Math.max(1, opts.limit ?? (opts.postedSince ? 50 : 10)));
57782
+ const timeoutMs = Math.min(3e4, Math.max(1e3, opts.timeoutMs ?? 1e4));
57783
+ const chosen = (opts.sources?.length ? PROVIDERS.filter((p) => opts.sources.includes(p.id)) : PROVIDERS).filter(
57784
+ (p) => q || p.categoryOption && opts[p.categoryOption]?.length
57785
+ );
57786
+ if (!chosen.length) return { query: q, results: [], providers: [] };
57787
+ const settled = await Promise.all(
57788
+ chosen.map(async (p) => {
57789
+ const checked_at = (/* @__PURE__ */ new Date()).toISOString();
57790
+ try {
57791
+ const results2 = await p.search(q, limit, timeoutMs, opts);
57792
+ return {
57793
+ outcome: {
57794
+ source: p.id,
57795
+ label: p.label,
57796
+ ok: true,
57797
+ status: "healthy",
57798
+ count: results2.length,
57799
+ checked_at
57800
+ },
57801
+ results: results2
57802
+ };
57803
+ } catch (e) {
57804
+ const failure = providerError(e);
57805
+ return {
57806
+ outcome: {
57807
+ source: p.id,
57808
+ label: p.label,
57809
+ ok: false,
57810
+ status: failure.status,
57811
+ count: 0,
57812
+ checked_at,
57813
+ error: failure.error
57814
+ },
57815
+ results: []
57816
+ };
57817
+ }
57818
+ })
57819
+ );
57820
+ const today = opts.today ?? (/* @__PURE__ */ new Date()).toLocaleDateString("sv-SE");
57821
+ let results = settled.flatMap((s) => s.results);
57822
+ if (opts.trackSeen !== false) {
57823
+ const seen = markJobsSeen(results, today);
57824
+ for (const r of results) r.first_seen_at = seen.get(`${r.source}:${r.external_id}`);
57825
+ }
57826
+ results = dedupeAcrossBoards(results);
57827
+ let filtered_out;
57828
+ const years = opts.years;
57829
+ if (opts.postedSince || years != null) {
57830
+ const kept = results.filter((r) => {
57831
+ if (opts.postedSince && (r.posted_at ?? r.first_seen_at ?? today) < opts.postedSince) return false;
57832
+ const range2 = years != null ? experienceRange(r.experience) : null;
57833
+ return !range2 || range2[0] <= years && years <= range2[1];
57834
+ });
57835
+ filtered_out = results.length - kept.length;
57836
+ results = kept;
57837
+ }
57838
+ return {
57839
+ query: q,
57840
+ results,
57841
+ providers: settled.map((s) => s.outcome),
57842
+ ...filtered_out !== void 0 ? { filtered_out } : {}
57843
+ };
57844
+ }
57845
+ function experienceRange(text) {
57846
+ const t = (text ?? "").replace(/\s/g, "");
57847
+ if (!t) return null;
57848
+ if (/무관/.test(t)) return [0, Infinity];
57849
+ const m = t.match(/(\d+)?~(\d+)년/);
57850
+ if (m) return [m[1] ? +m[1] : 0, +m[2]];
57851
+ const min2 = t.match(/(\d+)년(이상|↑)/);
57852
+ if (min2) return [/신입/.test(t) ? 0 : +min2[1], Infinity];
57853
+ if (/신입/.test(t)) return /경력/.test(t) ? [0, Infinity] : [0, 0];
57854
+ return null;
57855
+ }
57856
+ var normCompany = (s) => s.replace(/\([^)]*\)|㈜|주식회사/g, "").toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
57857
+ var normTitle = (title, company) => {
57858
+ const c = normCompany(company);
57859
+ return title.replace(/\[([^\]]*)\]/g, (tag, inner) => {
57860
+ const n = normCompany(inner);
57861
+ return n && c && (c.includes(n) || n.includes(c)) ? "" : tag;
57862
+ }).toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
57863
+ };
57864
+ function dedupeAcrossBoards(results) {
57865
+ const byKey = /* @__PURE__ */ new Map();
57866
+ const out = [];
57867
+ for (const r of results) {
57868
+ const key = `${normCompany(r.company)}|${normTitle(r.position, r.company)}`;
57869
+ const first2 = byKey.get(key);
57870
+ if (first2 && first2.source !== r.source) {
57871
+ (first2.also_on ??= []).push({ source: r.source, url: r.url });
57872
+ if (!first2.posted_at && r.posted_at) first2.posted_at = r.posted_at;
57873
+ continue;
57874
+ }
57875
+ byKey.set(key, r);
57876
+ out.push(r);
57877
+ }
57878
+ return out;
57879
+ }
57666
57880
 
57667
57881
  // packages/core/src/offers.ts
57668
57882
  function withJob(offer, job) {
@@ -77553,6 +77767,82 @@ function registerDiagnosticRoutes(router2) {
77553
77767
  });
77554
77768
  }
77555
77769
 
77770
+ // apps/web/src/job-search-routes.ts
77771
+ var isYmd = (s) => /^\d{4}-\d{2}-\d{2}$/.test(s) && !Number.isNaN(Date.parse(s)) && new Date(s).toISOString().startsWith(s);
77772
+ var Categories = {
77773
+ remember_categories: external_exports.array(external_exports.string().max(100)).max(20).optional(),
77774
+ saramin_categories: external_exports.array(external_exports.string().regex(/^\d+$/)).max(20).optional()
77775
+ };
77776
+ var SearchSchema = external_exports.object({
77777
+ keyword: external_exports.string().max(100).optional(),
77778
+ ...Categories,
77779
+ sources: external_exports.array(external_exports.enum([...JOB_SEARCH_SOURCES])).optional(),
77780
+ limit: external_exports.number().int().min(1).max(100).optional(),
77781
+ posted_since: external_exports.string().refine((s) => s === "today" || isYmd(s)).optional(),
77782
+ years: external_exports.number().int().min(0).max(40).optional()
77783
+ });
77784
+ var PrefsSchema = external_exports.object({ keyword: external_exports.string().max(100).optional(), ...Categories });
77785
+ function assertKnownCategories(remember2 = [], saramin2 = []) {
77786
+ const bad = [...remember2.filter((c) => !isRememberCategory(c)), ...saramin2.filter((c) => !isSaraminCategory(c))];
77787
+ if (bad.length) throw new HttpError(400, `\uB9AC\uBA64\uBC84\xB7\uC0AC\uB78C\uC778 \uBD84\uB958\uD45C\uC5D0 \uC5C6\uB294 \uAC12\uC785\uB2C8\uB2E4: ${bad.join(", ")}`);
77788
+ }
77789
+ function registerJobSearchRoutes(router2) {
77790
+ router2.get("/api/job-search/categories", (ctx) => findJobCategories(ctx.query.get("q") ?? "", 20));
77791
+ router2.get("/api/job-search/preferences", () => {
77792
+ const preferences = getJobSearchPreferences();
77793
+ const saramin_names = Object.fromEntries(preferences.saramin_categories.map((c) => [c, saraminCategoryName(c) ?? c]));
77794
+ return { preferences, saramin_names };
77795
+ });
77796
+ router2.put("/api/job-search/preferences", async (ctx) => {
77797
+ const input = await readJsonBody(ctx.req, PrefsSchema);
77798
+ assertKnownCategories(input.remember_categories, input.saramin_categories);
77799
+ return {
77800
+ preferences: setJobSearchPreferences({
77801
+ ...input.keyword !== void 0 ? { keyword: input.keyword.trim() } : {},
77802
+ ...input.remember_categories ? { remember_categories: input.remember_categories } : {},
77803
+ ...input.saramin_categories ? { saramin_categories: input.saramin_categories } : {}
77804
+ })
77805
+ };
77806
+ });
77807
+ router2.post("/api/job-search", async (ctx) => {
77808
+ const input = await readJsonBody(ctx.req, SearchSchema);
77809
+ let keyword = (input.keyword ?? "").trim();
77810
+ let remember2 = input.remember_categories ?? [];
77811
+ let saramin2 = input.saramin_categories ?? [];
77812
+ let usedSaved = false;
77813
+ if (!keyword && !remember2.length && !saramin2.length) {
77814
+ const saved = getJobSearchPreferences();
77815
+ ({ keyword, remember_categories: remember2, saramin_categories: saramin2 } = saved);
77816
+ usedSaved = true;
77817
+ if (!keyword && !remember2.length && !saramin2.length) {
77818
+ throw new HttpError(400, "\uAC80\uC0C9\uC5B4\uB098 \uC9C1\uBB34\uB97C \uB123\uC5B4 \uC8FC\uC138\uC694.");
77819
+ }
77820
+ }
77821
+ assertKnownCategories(remember2, saramin2);
77822
+ const today = (/* @__PURE__ */ new Date()).toLocaleDateString("sv-SE");
77823
+ const res = await searchJobs(keyword, {
77824
+ sources: input.sources,
77825
+ limit: input.limit,
77826
+ rememberCategories: remember2,
77827
+ saraminCategories: saramin2,
77828
+ postedSince: input.posted_since === "today" ? today : input.posted_since,
77829
+ years: input.years,
77830
+ today
77831
+ });
77832
+ const savedByUrl = new Map(jobRepo.list().filter((j) => j.url).map((j) => [j.url, j.id]));
77833
+ const results = res.results.map((r) => ({
77834
+ ...r,
77835
+ saved_job_id: savedByUrl.get(r.url) ?? r.also_on?.map((a) => savedByUrl.get(a.url)).find(Boolean) ?? null
77836
+ }));
77837
+ return {
77838
+ ...res,
77839
+ results,
77840
+ used_saved_preferences: usedSaved,
77841
+ conditions: { keyword, remember_categories: remember2, saramin_categories: saramin2, years: input.years ?? null }
77842
+ };
77843
+ });
77844
+ }
77845
+
77556
77846
  // apps/web/src/server.ts
77557
77847
  var __dirname2 = path6.dirname(fileURLToPath2(import.meta.url));
77558
77848
  var WEB_ROOT = BUNDLED ? path6.resolve(__dirname2, "public") : path6.resolve(__dirname2, "..", "public");
@@ -77560,6 +77850,7 @@ var LANDING_URL = "https://careermate.life/";
77560
77850
  var router = new Router();
77561
77851
  registerApiRoutes(router);
77562
77852
  registerDiagnosticRoutes(router);
77853
+ registerJobSearchRoutes(router);
77563
77854
  function createServer() {
77564
77855
  return http.createServer(async (req, res) => {
77565
77856
  try {
package/docs/FAQ.md CHANGED
@@ -79,6 +79,30 @@ UTF-8로 다시 저장하거나 내용을 붙여넣어 주세요.
79
79
 
80
80
  ---
81
81
 
82
+ ## 공고 찾기
83
+
84
+ ### 채용공고도 찾아주나요?
85
+ 네. AI에게 "백엔드 공고 찾아줘", "경리 공고 찾아줘"처럼 말하면 **원티드·점핏·리멤버·사람인**의
86
+ 공개 공고를 모아 보여줍니다. 로그인이나 API 키는 필요 없고, 밖으로 나가는 것은 검색어와 직무
87
+ 조건뿐입니다(프로필·이력서는 보내지 않음). 잡코리아는 robots.txt 제약 때문에 넣지 않았습니다.
88
+ 개발 직군만이 아니라 모든 직군에서 됩니다. 직무 이름을 각 사이트의 분류로 바꾸는 표가 내장돼
89
+ 있습니다(리멤버 26개 그룹·315개 세부 직무, 사람인 21개 분야·2,178개 코드). 여러 사이트에 함께
90
+ 올라온 공고는 한 줄로 합칩니다. 고른 공고만 저장되고, 저장한 공고는 적합도 분석과 자소서로 이어집니다.
91
+ 대시보드의 **공고 찾기** 화면에서도 같은 검색을 하고 클릭 한 번으로 저장할 수 있습니다.
92
+
93
+ ### 오늘 올라온 공고만 볼 수 있나요?
94
+ 자주 보는 직무를 검색 조건으로 한 번 저장해 두면 **"오늘 올라온 내 직무 공고 보여줘"** 만으로
95
+ 새 공고를 봅니다. 리멤버·사람인은 사이트 게시일로 거릅니다. 원티드·점핏은 게시일을 주지 않아
96
+ CareerMate가 처음 본 날로 거르므로, **첫 검색에서는 전부 새 공고로 보입니다.**
97
+
98
+ ### 경력 연차로 거를 수 있나요?
99
+ 네. "경력 5년인데 프론트엔드 공고 찾아줘"처럼 연차를 말하면 경력 범위에 그 연차가 들어가는 공고만
100
+ 남깁니다. 5년이면 "3~10년"은 남고 "8~20년"은 빠집니다. 경력 표기를 읽을 수 없는 공고는 빼지 않고
101
+ 남겨 둡니다. 2026-11-04에 프론트엔드·경력 5년으로 검색했을 때 합친 뒤 173건 중 123건이 남고
102
+ 50건이 연차 조건으로 빠졌습니다.
103
+
104
+ ---
105
+
82
106
  ## 공고 분석 / 자소서
83
107
 
84
108
  ### "공고 URL은 어떻게 넣어요?"
@@ -56,6 +56,36 @@
56
56
 
57
57
  ---
58
58
 
59
+ ## A-1. 공고 찾기 (지원할 공고가 아직 없을 때)
60
+
61
+ 사용자가 "공고 찾아줘", "오늘 올라온 내 직무 공고 보여줘"라고 하면 아래 순서로 진행합니다. 원티드·점핏·리멤버·사람인 네 곳의 공개 공고를 로그인·API 키 없이 가져옵니다. 밖으로 나가는 것은 검색어와 직무 조건뿐이고 사용자 데이터는 보내지 않습니다. 잡코리아는 robots.txt 제약으로 포함하지 않습니다.
62
+
63
+ ### 1. 직무 분류 찾기 - `find_job_categories`
64
+
65
+ - 개발 직군만이 아니라 모든 직군에 씁니다. "경리", "간호사", "재무", "백엔드" 같은 직무 이름을 리멤버 분류("상위>하위")와 사람인 숫자 코드로 바꿔 줍니다.
66
+ - 내장된 분류표(리멤버 26개 그룹·315개 세부 직무, 사람인 21개 분야·2,178개 코드)에서 찾으므로 외부 통신이 없습니다. 사용자가 직무를 말하지 않았으면 프로필의 희망 직무를 씁니다.
67
+ - 결과는 관련도순입니다. 다 넣지 말고 사용자 직무에 맞는 것만 고릅니다.
68
+
69
+ ### 2. (자주 볼 직무라면) 검색 조건 저장 - `set_job_search_preferences`
70
+
71
+ - 어떤 직무로 저장할지 사용자에게 확인한 뒤 저장합니다. 이후 "오늘 올라온 내 직무 공고 보여줘"는 `search_jobs`를 조건 없이 `posted_since: "today"`로 부르면 됩니다.
72
+
73
+ ### 3. 공고 검색 - `search_jobs`
74
+
75
+ - `keyword` 또는 1단계에서 고른 직무 조건을 넣습니다. 직무 조건만 주면 직무 조건을 지원하는 리멤버·사람인만 검색합니다.
76
+ - `posted_since`("today" 또는 YYYY-MM-DD): 리멤버·사람인은 사이트 게시일로 거릅니다. 원티드·점핏은 게시일을 주지 않아 CareerMate가 처음 본 날로 거르므로, 첫 검색에서는 전부 새 공고로 보인다고 사용자에게 알려 주세요.
77
+ - `years`: 사용자 경력 연차입니다. 공고의 경력 범위에 이 연차가 들어가는 것만 남깁니다(5년이면 "3~10년"은 남고 "8~20년"은 빠짐). 경력 표기를 읽을 수 없는 공고는 남깁니다.
78
+ - `limit`: 사이트별 건수입니다. 기본 10건, `posted_since`가 있으면 50건, 최대 100건입니다.
79
+ - 여러 사이트에 함께 올라온 같은 공고는 한 줄로 합치고, 나머지 링크는 `also_on`에 둡니다.
80
+ - 예(2026-11-04 실측): 프론트엔드·경력 5년은 합친 뒤 173건 중 123건이 남고 50건이 연차 조건으로 빠졌습니다. 재무는 153건, 백엔드·경력 3년은 95건이 남았습니다.
81
+
82
+ ### 4. 고른 공고만 저장 - `save_job_posting`
83
+
84
+ - 결과를 번호 목록으로 보여주고 "어떤 공고를 저장할까요?"라고 묻습니다. 사용자가 고른 공고만 `save_job_posting`으로 저장하고, 그다음은 B(공고 분석)로 이어갑니다.
85
+ - 대시보드의 "공고 찾기" 화면에서도 같은 검색을 하고 클릭 한 번으로 저장할 수 있다고 안내해도 됩니다.
86
+
87
+ ---
88
+
59
89
  ## B. 공고 분석하기 (핵심 흐름)
60
90
 
61
91
  사용자가 공고 링크나 내용을 주면 아래 순서로 진행합니다.
package/docs/WORKFLOWS.md CHANGED
@@ -234,6 +234,30 @@ LinkedIn 프로필과 포트폴리오 같은 자기표현 자산을 직무에
234
234
 
235
235
  ---
236
236
 
237
+ ## 공고 찾기 (analyze_job 앞 단계)
238
+
239
+ 공고 찾기는 별도 워크플로우로 정의돼 있지 않다. 사용자에게 지원할 공고가 아직 없을 때 `analyze_job` 앞에서 쓴다.
240
+
241
+ **트리거:** "백엔드 공고 찾아줘 / 경리 공고 있어? / 오늘 올라온 내 직무 공고 보여줘"처럼 공고를 찾아 달라고 할 때.
242
+
243
+ **단계:**
244
+
245
+ 1. `find_job_categories`로 사용자 직무(말하지 않았으면 프로필의 희망 직무)를 리멤버 분류와 사람인 코드로 바꾼다. 모든 직군을 다루며, 내장 분류표를 쓰므로 외부 통신이 없다.
246
+ 2. 자주 볼 직무라고 사용자가 확인하면 `set_job_search_preferences`로 저장한다. 이후 "오늘 올라온 공고"는 `search_jobs`를 조건 없이 `posted_since: "today"`로 부른다.
247
+ 3. `search_jobs`로 원티드·점핏·리멤버·사람인의 공개 공고를 가져온다. 경력 연차를 알면 `years`로 경력 범위에 맞지 않는 공고를 거른다. 원티드·점핏은 게시일을 주지 않아 CareerMate가 처음 본 날로 거르므로, 첫 검색에서는 전부 새 공고로 보인다고 알린다.
248
+ 4. 결과를 번호 목록으로 보여주고, 사용자가 고른 공고만 `save_job_posting`으로 저장한 뒤 `analyze_job`으로 이어간다.
249
+
250
+ ```
251
+ find_job_categories
252
+ → set_job_search_preferences # 자주 볼 직무일 때, 사용자 확인 후
253
+ → search_jobs # posted_since·years는 필요할 때
254
+ → (사용자가 공고 선택)
255
+ → save_job_posting # 고른 공고만
256
+ → analyze_job
257
+ ```
258
+
259
+ ---
260
+
237
261
  ## 워크플로우 간 연결
238
262
 
239
263
  워크플로우는 독립적이지 않고 자연스럽게 이어진다.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "careermate",
3
- "version": "0.0.14",
3
+ "version": "0.0.15",
4
4
  "type": "module",
5
5
  "description": "CareerMate — an MCP-first, local-first career workspace. Your career database and documents stay on your machine; optional discovery and update features may use the internet without sending stored career data.",
6
6
  "license": "MIT",
@@ -95,12 +95,23 @@ export function isRememberCategory(value: string): boolean {
95
95
  return !!subs && (sub === undefined || subs.includes(sub));
96
96
  }
97
97
 
98
- let saraminCodes: Set<string> | undefined;
98
+ let saraminCodes: Map<string, string> | undefined;
99
+ // First name wins if a code repeats, so the 직무·직업 name (listed first) is kept.
100
+ const saraminIndex = () => {
101
+ if (!saraminCodes) {
102
+ saraminCodes = new Map();
103
+ for (const kinds of Object.values(SARAMIN_CATEGORIES))
104
+ for (const list of Object.values(kinds)) for (const [c, n] of list) if (!saraminCodes.has(c)) saraminCodes.set(c, n);
105
+ }
106
+ return saraminCodes;
107
+ };
99
108
 
100
109
  /** A cat_kewd code from the snapshot. Unknown codes are rejected rather than risk an unfiltered list. */
101
110
  export function isSaraminCategory(code: string): boolean {
102
- saraminCodes ??= new Set(
103
- Object.values(SARAMIN_CATEGORIES).flatMap((kinds) => Object.values(kinds).flatMap((list) => list.map(([c]) => c))),
104
- );
105
- return saraminCodes.has(code);
111
+ return saraminIndex().has(code);
112
+ }
113
+
114
+ /** Display name of a Saramin code ("322" -> "경리"), or undefined if unknown. */
115
+ export function saraminCategoryName(code: string): string | undefined {
116
+ return saraminIndex().get(code);
106
117
  }