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 +8 -6
- package/dist/doctor.mjs +10 -5
- package/dist/mcp.mjs +10 -5
- package/dist/public/app.js +1 -0
- package/dist/public/i18n/en/nav.js +1 -0
- package/dist/public/i18n/en/search.js +43 -0
- package/dist/public/i18n/en.js +2 -1
- package/dist/public/i18n/ko/nav.js +1 -0
- package/dist/public/i18n/ko/search.js +43 -0
- package/dist/public/i18n/ko.js +2 -1
- package/dist/public/pages/search.js +208 -0
- package/dist/web.mjs +316 -25
- package/docs/FAQ.md +24 -0
- package/docs/START_WORKFLOW.md +30 -0
- package/docs/WORKFLOWS.md +24 -0
- package/package.json +1 -1
- package/packages/core/src/job-categories.ts +16 -5
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
|
-
- **로컬 대시보드**
|
|
40
|
+
- **로컬 대시보드** - 프레임워크·CDN 없는 바닐라 JS로 만든 8페이지 웹 화면(다크모드 지원).
|
|
41
41
|
- **데이터 내보내기/삭제** — Settings 페이지에서 직접.
|
|
42
42
|
|
|
43
43
|
> **예: 공고 검색** — "백엔드 공고 찾아줘"라고 하면 AI가 공개 사이트에서 공고를 모아 이렇게 보여주고, 고른 것만 저장합니다(직무를 저장해 뒀다면 "오늘 올라온 공고 보여줘"만으로도 됩니다):
|
|
@@ -134,12 +134,13 @@ AI 클라이언트에 연결된 상태라면 "대시보드 열어줘"만으로
|
|
|
134
134
|
|
|
135
135
|
---
|
|
136
136
|
|
|
137
|
-
## 대시보드
|
|
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**
|
|
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**
|
|
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
package/dist/public/app.js
CHANGED
|
@@ -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' },
|
|
@@ -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
|
+
};
|
package/dist/public/i18n/en.js
CHANGED
|
@@ -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
|
};
|
|
@@ -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
|
+
};
|
package/dist/public/i18n/ko.js
CHANGED
|
@@ -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
|
|
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 !
|
|
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 !
|
|
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 (!
|
|
2613
|
+
if (!contains2(seen, computed)) {
|
|
2614
2614
|
seen.push(computed);
|
|
2615
2615
|
result2.push(value);
|
|
2616
2616
|
}
|
|
2617
|
-
} else if (!
|
|
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 (
|
|
2653
|
+
if (contains2(result2, item)) continue;
|
|
2654
2654
|
var j;
|
|
2655
2655
|
for (j = 1; j < argsLength; j++) {
|
|
2656
|
-
if (!
|
|
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: () =>
|
|
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: () =>
|
|
2878
|
-
includes: () =>
|
|
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: () =>
|
|
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: () =>
|
|
3173
|
-
includes: () =>
|
|
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
|
|
51555
|
-
|
|
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
|
-
...
|
|
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.
|
|
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
|
|
56533
|
-
return
|
|
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
|
|
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: [...
|
|
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
|
-
|
|
57416
|
-
|
|
57417
|
-
|
|
57418
|
-
return
|
|
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은 어떻게 넣어요?"
|
package/docs/START_WORKFLOW.md
CHANGED
|
@@ -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.
|
|
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:
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
}
|