careermate 0.0.13 → 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/AGENTS.md CHANGED
@@ -16,7 +16,7 @@ CareerMate는 사용자 PC에만 데이터를 두는 **로컬 커리어 작업
16
16
  - **공고 분석·자소서 작성 전에는 반드시 `get_application_context`를 먼저 호출하라.** 한 번의 호출로 프로필·대표 이력서·경력·스킬·기존 자소서·대상 공고·선호 문체·강조 포인트를 모두 돌려준다. 맥락 없이 작성하지 마라.
17
17
  - **결과는 다시 CareerMate에 저장하라.** 분석 → `save_fit_analysis`, 자소서 → `save_cover_letter_version`, 경력기술서 → `add_resume`(`kind=career_description`), 공고 → `save_job_posting`, 면접 자료 → `save_interview_prep`. 커리어 데이터는 흩어진 파일이 아니라 CareerMate 안에 둔다. 임의의 로컬 파일 생성은 최소화하라(사용자가 파일을 원하면 자소서는 `export_cover_letter`, 이력서·경력기술서는 `export_resume`, 프로필은 `export_profile`로 `~/.careermate/exports`에 내보낸다).
18
18
  - **여러 문서를 한 번에 요청받아도 각 문서는 각자의 저장 도구로 따로 저장하라.** "경력기술서랑 자기소개서 둘 다 써줘"처럼 한 턴에 여러 문서를 만들었더라도, 한 저장 호출의 본문(content)에 다른 문서를 이어붙이지 마라 — 경력기술서는 `add_resume`(`kind=career_description`), 자기소개서는 `save_cover_letter_version`으로 **각각 따로** 저장한다(작성은 한 답변에 둘 다 보여줘도 무방하나, 저장은 문서별 도구로 분리). 한 문서 본문에 다른 문서가 섞이면 대시보드에 통째로 붙어 등록된다.
19
- - **공고 찾기는 사용자 직무 기준으로.** 직무 조건은 짐작하지 말고 `find_job_categories`로 사용자 직무(말하지 않았으면 프로필의 희망 직무)에 맞는 리멤버 분류·사람인 코드를 찾는다. 사용자가 자주 볼 직무라고 확인하면 `set_job_search_preferences`로 저장해 두고, 이후 "오늘 올라온 공고 보여줘"는 `search_jobs`를 조건 없이 `posted_since: "today"`로 부른다. 원티드·점핏은 게시일을 주지 않아 CareerMate가 처음 본 날로 거르므로 첫 검색에선 전부 새 공고로 보인다는 점을 알려라. 개발 직군만이 아니라 모든 직군에 같은 흐름을 쓴다.
19
+ - **공고 찾기는 사용자 직무 기준으로.** 직무 조건은 짐작하지 말고 `find_job_categories`로 사용자 직무(말하지 않았으면 프로필의 희망 직무)에 맞는 리멤버 분류·사람인 코드를 찾는다. 사용자가 자주 볼 직무라고 확인하면 `set_job_search_preferences`로 저장해 두고, 이후 "오늘 올라온 공고 보여줘"는 `search_jobs`를 조건 없이 `posted_since: "today"`로 부른다. 원티드·점핏은 게시일을 주지 않아 CareerMate가 처음 본 날로 거르므로 첫 검색에선 전부 새 공고로 보인다는 점을 알려라. 사용자가 "5년차"처럼 경력을 말하면 `years`(신입은 0)로 거른다. 개발 직군만이 아니라 모든 직군에 같은 흐름을 쓴다.
20
20
  - **도구 응답은 사람 말로 바꿔라.** 응답에 `user_message`가 있으면 그 내용을 우선 활용하되 **사용자의 언어로 옮겨** 전달하고(한국어 원문을 그대로 복사하지 않는다), `suggested_next_action`은 선택지로 제안한다. `id`, `job_id`, `cover_letter_id`, `workflow_id`, JSON 원문, 도구 이름은 내부 작업용으로만 쓰고 사용자에게 그대로 복사하지 않는다.
21
21
  - **공고 분석 뒤에는 자기소개서를 곧장 쓰지 말고 선택지를 보여줘라.** 적합도 분석을 저장하고 쉬운 말로 설명한 뒤 "이 공고에 맞춘 자기소개서를 써드릴까요?"라고 묻고, `1. 네, 이 공고에 맞춘 자기소개서를 써줘` / `2. 아니요, 분석만 저장해줘`처럼 고르게 한다. 사용자가 1번을 고른 경우에만 자소서 작성·저장 흐름으로 이어간다. **1번(쓰기)을 고르면 작성 전에, 회사가 지정한 문항·양식·글자수 제한이 있으면 먼저 달라고 요청하라** — 한국 자소서는 문항형(회사별 항목·글자수)이 많아 문항·양식이 결과 질을 좌우한다. 공고·맥락에 없으면 사용자에게 문항 텍스트·제출 양식·글자수(상·하한, 공백포함 여부)를 묻고, 끝내 없으면 일반 양식으로 진행한다고 알린다. 받은 문항·양식·글자수는 작성에 반영하고 가능하면 공고에 함께 저장한다.
22
22
  - **표준 워크플로우를 따르라(핵심 6 + 선택 1)**: onboarding, analyze_job, write_cover_letter, write_career_description, manage_application_status, prepare_interview — 그리고 사용자가 LinkedIn·포트폴리오 같은 자기표현 자산을 요청하면 `build_personal_brand`. 순서가 헷갈리면 `get_workflow_guide`를 호출한다.
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.13" : "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
@@ -89413,8 +89418,13 @@ async function searchJobs(query, opts = {}) {
89413
89418
  }
89414
89419
  results = dedupeAcrossBoards(results);
89415
89420
  let filtered_out;
89416
- if (opts.postedSince) {
89417
- const kept = results.filter((r) => (r.posted_at ?? r.first_seen_at ?? today) >= opts.postedSince);
89421
+ const years = opts.years;
89422
+ if (opts.postedSince || years != null) {
89423
+ const kept = results.filter((r) => {
89424
+ if (opts.postedSince && (r.posted_at ?? r.first_seen_at ?? today) < opts.postedSince) return false;
89425
+ const range2 = years != null ? experienceRange(r.experience) : null;
89426
+ return !range2 || range2[0] <= years && years <= range2[1];
89427
+ });
89418
89428
  filtered_out = results.length - kept.length;
89419
89429
  results = kept;
89420
89430
  }
@@ -89425,6 +89435,17 @@ async function searchJobs(query, opts = {}) {
89425
89435
  ...filtered_out !== void 0 ? { filtered_out } : {}
89426
89436
  };
89427
89437
  }
89438
+ function experienceRange(text) {
89439
+ const t = (text ?? "").replace(/\s/g, "");
89440
+ if (!t) return null;
89441
+ if (/무관/.test(t)) return [0, Infinity];
89442
+ const m = t.match(/(\d+)?~(\d+)년/);
89443
+ if (m) return [m[1] ? +m[1] : 0, +m[2]];
89444
+ const min2 = t.match(/(\d+)년(이상|↑)/);
89445
+ if (min2) return [/신입/.test(t) ? 0 : +min2[1], Infinity];
89446
+ if (/신입/.test(t)) return /경력/.test(t) ? [0, Infinity] : [0, 0];
89447
+ return null;
89448
+ }
89428
89449
  var normCompany = (s) => s.replace(/\([^)]*\)|㈜|주식회사/g, "").toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
89429
89450
  var normTitle = (title, company) => {
89430
89451
  const c = normCompany(company);
@@ -110103,7 +110124,8 @@ ${retro_prompt}` : "");
110103
110124
  limit: external_exports.number().optional().describe("\uC0AC\uC774\uD2B8\uBCC4 \uCD5C\uB300 \uAC74\uC218(\uAE30\uBCF8 10, posted_since\uAC00 \uC788\uC73C\uBA74 50, \uCD5C\uB300 100). \uC624\uB298 \uACF5\uACE0\uAC00 limit\uB9CC\uD07C \uAF49 \uCC28\uC11C \uB3CC\uC544\uC624\uBA74 \uB354 \uC788\uC744 \uC218 \uC788\uC73C\uB2C8 \uB298\uB824\uC11C \uB2E4\uC2DC \uAC80\uC0C9"),
110104
110125
  remember_categories: external_exports.array(external_exports.string()).max(20).optional().describe('\uB9AC\uBA64\uBC84 \uC9C1\uBB34(find_job_categories \uACB0\uACFC): "\uC0C1\uC704>\uD558\uC704" \uB610\uB294 \uC0C1\uC704\uB9CC. \uC608: ["\uC7AC\uBB34\xB7\uD68C\uACC4\xB7\uC138\uBB34>\uD68C\uACC4\uACB0\uC0B0"]'),
110105
110126
  saramin_categories: external_exports.array(external_exports.string().regex(/^\d+$/)).max(20).optional().describe('\uC0AC\uB78C\uC778 \uC9C1\uBB34 \uCF54\uB4DC(find_job_categories \uACB0\uACFC). \uC608: ["322"]'),
110106
- posted_since: external_exports.string().refine((s) => s === "today" || isYmd(s), '"today" \uB610\uB294 \uC2E4\uC81C \uB0A0\uC9DC(YYYY-MM-DD)').optional().describe('"today" \uB610\uB294 YYYY-MM-DD. \uADF8\uB0A0 \uC774\uD6C4 \uACF5\uACE0\uB9CC')
110127
+ posted_since: external_exports.string().refine((s) => s === "today" || isYmd(s), '"today" \uB610\uB294 \uC2E4\uC81C \uB0A0\uC9DC(YYYY-MM-DD)').optional().describe('"today" \uB610\uB294 YYYY-MM-DD. \uADF8\uB0A0 \uC774\uD6C4 \uACF5\uACE0\uB9CC'),
110128
+ years: external_exports.number().int().min(0).max(40).optional().describe('\uC0AC\uC6A9\uC790 \uACBD\uB825 \uC5F0\uCC28(\uC2E0\uC785 0). \uACF5\uACE0\uC758 \uACBD\uB825 \uBC94\uC704\uC5D0 \uC774 \uC5F0\uCC28\uAC00 \uB4E4\uC5B4\uAC00\uB294 \uAC83\uB9CC \uB0A8\uAE40. "5\uB144\uCC28"\uBA74 5. \uC0AC\uC6A9\uC790\uAC00 \uB9D0\uD558\uC9C0 \uC54A\uC558\uC73C\uBA74 \uD504\uB85C\uD544 \uACBD\uB825\uC73C\uB85C \uACC4\uC0B0\uD574 \uB123\uB418 \uD655\uC778. \uACBD\uB825 \uD45C\uAE30\uB97C \uC77D\uC744 \uC218 \uC5C6\uB294 \uACF5\uACE0\uB294 \uB0A8\uAE40')
110107
110129
  },
110108
110130
  // 공고를 가져오기만 한다. 처음 본 날짜 기록은 사용자 데이터가 아닌 로컬 캐시라 readOnly로 둔다.
110109
110131
  readOnly: true,
@@ -110129,18 +110151,19 @@ ${retro_prompt}` : "");
110129
110151
  rememberCategories,
110130
110152
  saraminCategories,
110131
110153
  postedSince: args.posted_since === "today" ? today : args.posted_since,
110154
+ years: args.years,
110132
110155
  today
110133
110156
  });
110134
110157
  const failed = res.providers.filter((p) => !p.ok);
110135
110158
  const okCount = res.results.length;
110136
110159
  const parts = [`'${res.query || "\uC9C1\uBB34 \uC870\uAC74"}' \uAC80\uC0C9 \uACB0\uACFC ${okCount}\uAC74${usedSaved ? "(\uC800\uC7A5\uB41C \uAC80\uC0C9 \uC870\uAC74 \uC0AC\uC6A9)" : ""}`];
110137
- if (res.filtered_out) parts.push(`(\uAC8C\uC2DC\uC77C \uAE30\uC900 ${res.filtered_out}\uAC74 \uC81C\uC678)`);
110160
+ if (res.filtered_out) parts.push(`(\uAC8C\uC2DC\uC77C\xB7\uACBD\uB825 \uC870\uAC74\uC73C\uB85C ${res.filtered_out}\uAC74 \uC81C\uC678)`);
110138
110161
  if (failed.length) parts.push(`(\uC77C\uBD80 \uC0AC\uC774\uD2B8 \uC751\uB2F5 \uC2E4\uD328: ${failed.map((f) => `${f.source}: ${f.error ?? f.status}`).join("; ")})`);
110139
110162
  if (okCount === 0) parts.push("- \uD0A4\uC6CC\uB4DC\uB098 \uC9C1\uBB34 \uC870\uAC74\uC744 \uBC14\uAFB8\uAC70\uB098, \uACF5\uACE0 URL/\uBCF8\uBB38\uC744 \uC8FC\uC2DC\uBA74 \uC9C1\uC811 \uC800\uC7A5\uD574 \uB4DC\uB9B4\uAC8C\uC694.");
110140
110163
  return ok(parts.join(" "), {
110141
110164
  ...res,
110142
110165
  used_saved_preferences: usedSaved,
110143
- conditions: { keyword, remember_categories: rememberCategories, saramin_categories: saraminCategories }
110166
+ conditions: { keyword, remember_categories: rememberCategories, saramin_categories: saraminCategories, years: args.years }
110144
110167
  });
110145
110168
  }
110146
110169
  },
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.13" : "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
@@ -105869,8 +105874,13 @@ async function searchJobs(query, opts = {}) {
105869
105874
  }
105870
105875
  results = dedupeAcrossBoards(results);
105871
105876
  let filtered_out;
105872
- if (opts.postedSince) {
105873
- const kept = results.filter((r) => (r.posted_at ?? r.first_seen_at ?? today) >= opts.postedSince);
105877
+ const years = opts.years;
105878
+ if (opts.postedSince || years != null) {
105879
+ const kept = results.filter((r) => {
105880
+ if (opts.postedSince && (r.posted_at ?? r.first_seen_at ?? today) < opts.postedSince) return false;
105881
+ const range2 = years != null ? experienceRange(r.experience) : null;
105882
+ return !range2 || range2[0] <= years && years <= range2[1];
105883
+ });
105874
105884
  filtered_out = results.length - kept.length;
105875
105885
  results = kept;
105876
105886
  }
@@ -105881,6 +105891,17 @@ async function searchJobs(query, opts = {}) {
105881
105891
  ...filtered_out !== void 0 ? { filtered_out } : {}
105882
105892
  };
105883
105893
  }
105894
+ function experienceRange(text) {
105895
+ const t = (text ?? "").replace(/\s/g, "");
105896
+ if (!t) return null;
105897
+ if (/무관/.test(t)) return [0, Infinity];
105898
+ const m = t.match(/(\d+)?~(\d+)년/);
105899
+ if (m) return [m[1] ? +m[1] : 0, +m[2]];
105900
+ const min2 = t.match(/(\d+)년(이상|↑)/);
105901
+ if (min2) return [/신입/.test(t) ? 0 : +min2[1], Infinity];
105902
+ if (/신입/.test(t)) return /경력/.test(t) ? [0, Infinity] : [0, 0];
105903
+ return null;
105904
+ }
105884
105905
  var normCompany = (s) => s.replace(/\([^)]*\)|㈜|주식회사/g, "").toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
105885
105906
  var normTitle = (title, company) => {
105886
105907
  const c = normCompany(company);
@@ -126571,7 +126592,8 @@ ${retro_prompt}` : "");
126571
126592
  limit: external_exports.number().optional().describe("\uC0AC\uC774\uD2B8\uBCC4 \uCD5C\uB300 \uAC74\uC218(\uAE30\uBCF8 10, posted_since\uAC00 \uC788\uC73C\uBA74 50, \uCD5C\uB300 100). \uC624\uB298 \uACF5\uACE0\uAC00 limit\uB9CC\uD07C \uAF49 \uCC28\uC11C \uB3CC\uC544\uC624\uBA74 \uB354 \uC788\uC744 \uC218 \uC788\uC73C\uB2C8 \uB298\uB824\uC11C \uB2E4\uC2DC \uAC80\uC0C9"),
126572
126593
  remember_categories: external_exports.array(external_exports.string()).max(20).optional().describe('\uB9AC\uBA64\uBC84 \uC9C1\uBB34(find_job_categories \uACB0\uACFC): "\uC0C1\uC704>\uD558\uC704" \uB610\uB294 \uC0C1\uC704\uB9CC. \uC608: ["\uC7AC\uBB34\xB7\uD68C\uACC4\xB7\uC138\uBB34>\uD68C\uACC4\uACB0\uC0B0"]'),
126573
126594
  saramin_categories: external_exports.array(external_exports.string().regex(/^\d+$/)).max(20).optional().describe('\uC0AC\uB78C\uC778 \uC9C1\uBB34 \uCF54\uB4DC(find_job_categories \uACB0\uACFC). \uC608: ["322"]'),
126574
- posted_since: external_exports.string().refine((s) => s === "today" || isYmd(s), '"today" \uB610\uB294 \uC2E4\uC81C \uB0A0\uC9DC(YYYY-MM-DD)').optional().describe('"today" \uB610\uB294 YYYY-MM-DD. \uADF8\uB0A0 \uC774\uD6C4 \uACF5\uACE0\uB9CC')
126595
+ posted_since: external_exports.string().refine((s) => s === "today" || isYmd(s), '"today" \uB610\uB294 \uC2E4\uC81C \uB0A0\uC9DC(YYYY-MM-DD)').optional().describe('"today" \uB610\uB294 YYYY-MM-DD. \uADF8\uB0A0 \uC774\uD6C4 \uACF5\uACE0\uB9CC'),
126596
+ years: external_exports.number().int().min(0).max(40).optional().describe('\uC0AC\uC6A9\uC790 \uACBD\uB825 \uC5F0\uCC28(\uC2E0\uC785 0). \uACF5\uACE0\uC758 \uACBD\uB825 \uBC94\uC704\uC5D0 \uC774 \uC5F0\uCC28\uAC00 \uB4E4\uC5B4\uAC00\uB294 \uAC83\uB9CC \uB0A8\uAE40. "5\uB144\uCC28"\uBA74 5. \uC0AC\uC6A9\uC790\uAC00 \uB9D0\uD558\uC9C0 \uC54A\uC558\uC73C\uBA74 \uD504\uB85C\uD544 \uACBD\uB825\uC73C\uB85C \uACC4\uC0B0\uD574 \uB123\uB418 \uD655\uC778. \uACBD\uB825 \uD45C\uAE30\uB97C \uC77D\uC744 \uC218 \uC5C6\uB294 \uACF5\uACE0\uB294 \uB0A8\uAE40')
126575
126597
  },
126576
126598
  // 공고를 가져오기만 한다. 처음 본 날짜 기록은 사용자 데이터가 아닌 로컬 캐시라 readOnly로 둔다.
126577
126599
  readOnly: true,
@@ -126597,18 +126619,19 @@ ${retro_prompt}` : "");
126597
126619
  rememberCategories,
126598
126620
  saraminCategories,
126599
126621
  postedSince: args.posted_since === "today" ? today : args.posted_since,
126622
+ years: args.years,
126600
126623
  today
126601
126624
  });
126602
126625
  const failed = res.providers.filter((p) => !p.ok);
126603
126626
  const okCount = res.results.length;
126604
126627
  const parts = [`'${res.query || "\uC9C1\uBB34 \uC870\uAC74"}' \uAC80\uC0C9 \uACB0\uACFC ${okCount}\uAC74${usedSaved ? "(\uC800\uC7A5\uB41C \uAC80\uC0C9 \uC870\uAC74 \uC0AC\uC6A9)" : ""}`];
126605
- if (res.filtered_out) parts.push(`(\uAC8C\uC2DC\uC77C \uAE30\uC900 ${res.filtered_out}\uAC74 \uC81C\uC678)`);
126628
+ if (res.filtered_out) parts.push(`(\uAC8C\uC2DC\uC77C\xB7\uACBD\uB825 \uC870\uAC74\uC73C\uB85C ${res.filtered_out}\uAC74 \uC81C\uC678)`);
126606
126629
  if (failed.length) parts.push(`(\uC77C\uBD80 \uC0AC\uC774\uD2B8 \uC751\uB2F5 \uC2E4\uD328: ${failed.map((f) => `${f.source}: ${f.error ?? f.status}`).join("; ")})`);
126607
126630
  if (okCount === 0) parts.push("- \uD0A4\uC6CC\uB4DC\uB098 \uC9C1\uBB34 \uC870\uAC74\uC744 \uBC14\uAFB8\uAC70\uB098, \uACF5\uACE0 URL/\uBCF8\uBB38\uC744 \uC8FC\uC2DC\uBA74 \uC9C1\uC811 \uC800\uC7A5\uD574 \uB4DC\uB9B4\uAC8C\uC694.");
126608
126631
  return ok(parts.join(" "), {
126609
126632
  ...res,
126610
126633
  used_saved_preferences: usedSaved,
126611
- conditions: { keyword, remember_categories: rememberCategories, saramin_categories: saraminCategories }
126634
+ conditions: { keyword, remember_categories: rememberCategories, saramin_categories: saraminCategories, years: args.years }
126612
126635
  });
126613
126636
  }
126614
126637
  },
@@ -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
+ }