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/dist/web.mjs CHANGED
@@ -2106,7 +2106,7 @@ var init_some = __esm({
2106
2106
  });
2107
2107
 
2108
2108
  // node_modules/underscore/modules/contains.js
2109
- function contains(obj2, item, fromIndex, guard) {
2109
+ function contains2(obj2, item, fromIndex, guard) {
2110
2110
  if (!isArrayLike_default(obj2)) obj2 = values(obj2);
2111
2111
  if (typeof fromIndex != "number" || guard) fromIndex = 0;
2112
2112
  return indexOf_default(obj2, item, fromIndex) >= 0;
@@ -2485,7 +2485,7 @@ var init_omit = __esm({
2485
2485
  } else {
2486
2486
  keys2 = map4(flatten(keys2, false, false), String);
2487
2487
  iteratee2 = function(value, key) {
2488
- return !contains(keys2, key);
2488
+ return !contains2(keys2, key);
2489
2489
  };
2490
2490
  }
2491
2491
  return pick_default(obj2, iteratee2, context);
@@ -2575,7 +2575,7 @@ var init_difference = __esm({
2575
2575
  difference_default = restArguments(function(array2, rest2) {
2576
2576
  rest2 = flatten(rest2, true, true);
2577
2577
  return filter(array2, function(value) {
2578
- return !contains(rest2, value);
2578
+ return !contains2(rest2, value);
2579
2579
  });
2580
2580
  });
2581
2581
  }
@@ -2610,11 +2610,11 @@ function uniq(array2, isSorted, iteratee2, context) {
2610
2610
  if (!i || seen !== computed) result2.push(value);
2611
2611
  seen = computed;
2612
2612
  } else if (iteratee2) {
2613
- if (!contains(seen, computed)) {
2613
+ if (!contains2(seen, computed)) {
2614
2614
  seen.push(computed);
2615
2615
  result2.push(value);
2616
2616
  }
2617
- } else if (!contains(result2, value)) {
2617
+ } else if (!contains2(result2, value)) {
2618
2618
  result2.push(value);
2619
2619
  }
2620
2620
  }
@@ -2650,10 +2650,10 @@ function intersection2(array2) {
2650
2650
  var argsLength = arguments.length;
2651
2651
  for (var i = 0, length = getLength_default(array2); i < length; i++) {
2652
2652
  var item = array2[i];
2653
- if (contains(result2, item)) continue;
2653
+ if (contains2(result2, item)) continue;
2654
2654
  var j;
2655
2655
  for (j = 1; j < argsLength; j++) {
2656
- if (!contains(arguments[j], item)) break;
2656
+ if (!contains2(arguments[j], item)) break;
2657
2657
  }
2658
2658
  if (j === argsLength) result2.push(item);
2659
2659
  }
@@ -2841,7 +2841,7 @@ __export(modules_exports, {
2841
2841
  compact: () => compact,
2842
2842
  compose: () => compose,
2843
2843
  constant: () => constant,
2844
- contains: () => contains,
2844
+ contains: () => contains2,
2845
2845
  countBy: () => countBy_default,
2846
2846
  create: () => create,
2847
2847
  debounce: () => debounce,
@@ -2874,8 +2874,8 @@ __export(modules_exports, {
2874
2874
  has: () => has2,
2875
2875
  head: () => first,
2876
2876
  identity: () => identity,
2877
- include: () => contains,
2878
- includes: () => contains,
2877
+ include: () => contains2,
2878
+ includes: () => contains2,
2879
2879
  indexBy: () => indexBy_default,
2880
2880
  indexOf: () => indexOf_default,
2881
2881
  initial: () => initial,
@@ -3136,7 +3136,7 @@ __export(index_all_exports, {
3136
3136
  compact: () => compact,
3137
3137
  compose: () => compose,
3138
3138
  constant: () => constant,
3139
- contains: () => contains,
3139
+ contains: () => contains2,
3140
3140
  countBy: () => countBy_default,
3141
3141
  create: () => create,
3142
3142
  debounce: () => debounce,
@@ -3169,8 +3169,8 @@ __export(index_all_exports, {
3169
3169
  has: () => has2,
3170
3170
  head: () => first,
3171
3171
  identity: () => identity,
3172
- include: () => contains,
3173
- includes: () => contains,
3172
+ include: () => contains2,
3173
+ includes: () => contains2,
3174
3174
  indexBy: () => indexBy_default,
3175
3175
  indexOf: () => indexOf_default,
3176
3176
  initial: () => initial,
@@ -40162,6 +40162,52 @@ function ensureTables() {
40162
40162
  );
40163
40163
  `);
40164
40164
  }
40165
+ var FORGET_AFTER_DAYS = 120;
40166
+ function markJobsSeen(items, today) {
40167
+ const seen = /* @__PURE__ */ new Map();
40168
+ if (!items.length) return seen;
40169
+ const db = getDb();
40170
+ const upsert = db.prepare(
40171
+ `INSERT INTO job_search_seen (source, external_id, first_seen_at, last_seen_at) VALUES (?, ?, ?, ?)
40172
+ ON CONFLICT(source, external_id) DO UPDATE SET last_seen_at = excluded.last_seen_at
40173
+ RETURNING first_seen_at`
40174
+ );
40175
+ tx(() => {
40176
+ for (const it of items) {
40177
+ const row = upsert.get(it.source, it.external_id, today, today);
40178
+ seen.set(`${it.source}:${it.external_id}`, row?.first_seen_at ?? today);
40179
+ }
40180
+ db.prepare(`DELETE FROM job_search_seen WHERE last_seen_at < date(?, ?)`).run(today, `-${FORGET_AFTER_DAYS} days`);
40181
+ });
40182
+ return seen;
40183
+ }
40184
+ function parseList2(value) {
40185
+ try {
40186
+ const parsed = JSON.parse(String(value));
40187
+ return Array.isArray(parsed) ? parsed.filter((v) => typeof v === "string") : [];
40188
+ } catch {
40189
+ return [];
40190
+ }
40191
+ }
40192
+ function getJobSearchPreferences() {
40193
+ const row = getDb().prepare(`SELECT * FROM job_search_preferences WHERE id = 1`).get();
40194
+ return {
40195
+ keyword: typeof row?.keyword === "string" ? row.keyword : "",
40196
+ remember_categories: parseList2(row?.remember_categories ?? "[]"),
40197
+ saramin_categories: parseList2(row?.saramin_categories ?? "[]"),
40198
+ updated_at: typeof row?.updated_at === "string" ? row.updated_at : null
40199
+ };
40200
+ }
40201
+ function setJobSearchPreferences(prefs) {
40202
+ const next = { ...getJobSearchPreferences(), ...prefs, updated_at: (/* @__PURE__ */ new Date()).toISOString() };
40203
+ getDb().prepare(
40204
+ `INSERT INTO job_search_preferences (id, keyword, remember_categories, saramin_categories, updated_at)
40205
+ VALUES (1, ?, ?, ?, ?)
40206
+ ON CONFLICT(id) DO UPDATE SET keyword = excluded.keyword, remember_categories = excluded.remember_categories,
40207
+ saramin_categories = excluded.saramin_categories, updated_at = excluded.updated_at`
40208
+ ).run(next.keyword, JSON.stringify(next.remember_categories), JSON.stringify(next.saramin_categories), next.updated_at);
40209
+ return next;
40210
+ }
40165
40211
  ensureTables();
40166
40212
 
40167
40213
  // packages/shared/src/errors.ts
@@ -51551,13 +51597,13 @@ function _promise(Class2, innerType) {
51551
51597
  }
51552
51598
  // @__NO_SIDE_EFFECTS__
51553
51599
  function _custom(Class2, fn, _params) {
51554
- const norm2 = normalizeParams(_params);
51555
- norm2.abort ?? (norm2.abort = true);
51600
+ const norm3 = normalizeParams(_params);
51601
+ norm3.abort ?? (norm3.abort = true);
51556
51602
  const schema = new Class2({
51557
51603
  type: "custom",
51558
51604
  check: "custom",
51559
51605
  fn,
51560
- ...norm2
51606
+ ...norm3
51561
51607
  });
51562
51608
  return schema;
51563
51609
  }
@@ -55283,7 +55329,7 @@ var ApplicationContextSchema = external_exports.object({
55283
55329
  });
55284
55330
 
55285
55331
  // packages/shared/src/build-info.ts
55286
- var APP_VERSION = true ? "0.0.13" : "0.0.0-dev";
55332
+ var APP_VERSION = true ? "0.0.15" : "0.0.0-dev";
55287
55333
  var BUNDLED = true;
55288
55334
 
55289
55335
  // packages/shared/src/index.ts
@@ -56529,8 +56575,8 @@ function profileCompleteness(p) {
56529
56575
  experienceRepo.list().length > 0,
56530
56576
  skillRepo.list().length > 0
56531
56577
  ];
56532
- const score = Math.round(checks.filter(Boolean).length / checks.length * 100);
56533
- return score;
56578
+ const score2 = Math.round(checks.filter(Boolean).length / checks.length * 100);
56579
+ return score2;
56534
56580
  }
56535
56581
  function getOnboardingStatus() {
56536
56582
  const profile = profileRepo.get();
@@ -56886,7 +56932,7 @@ function styleSignals(text) {
56886
56932
  }
56887
56933
  var DISCLAIMER = "\uC774 \uC810\uAC80\uC740 LLM \uC5C6\uC774 \uC140 \uC218 \uC788\uB294 \uAC83\uB9CC \uBD05\uB2C8\uB2E4(\uC22B\uC790 \uCD9C\uCC98\xB7\uBB38\uCCB4 \uC2E0\uD638). \uD1B5\uACFC=\uD569\uACA9\uC774 \uC544\uB2C8\uBA70, \uC0AC\uC2E4\xB7\uB3D9\uBB38\uC11C\uB2F5\xB7\uC758\uBBF8 \uD310\uB2E8\uC740 \uB2F9\uC2E0(AI)\uC774 \uC9C1\uC811 \uD574\uC57C \uD569\uB2C8\uB2E4.";
56888
56934
  function lintArtifact(kind, text, corpus, opts = {}) {
56889
- const norm2 = normalizeText(text);
56935
+ const norm3 = normalizeText(text);
56890
56936
  const corpusAvailable = (corpus.documents + corpus.structured).trim().length > 0;
56891
56937
  const provenance = analyzeProvenance(text, corpus);
56892
56938
  const signals = styleSignals(text);
@@ -56910,7 +56956,7 @@ function lintArtifact(kind, text, corpus, opts = {}) {
56910
56956
  }
56911
56957
  return {
56912
56958
  kind,
56913
- charCount: [...norm2].length,
56959
+ charCount: [...norm3].length,
56914
56960
  charCounts: countChars(text),
56915
56961
  signals,
56916
56962
  provenance,
@@ -57403,6 +57449,54 @@ var SARAMIN_CATEGORIES = {
57403
57449
  };
57404
57450
 
57405
57451
  // packages/core/src/job-categories.ts
57452
+ var norm2 = (s) => s.toLowerCase().replace(/[\s·/(),&.\-]/g, "");
57453
+ function contains(hayRaw, needle) {
57454
+ if (/^[a-z0-9]{1,3}$/.test(needle)) return new RegExp(`(?<![a-z0-9])${needle}(?![a-z0-9])`).test(hayRaw.toLowerCase());
57455
+ return norm2(hayRaw).includes(needle);
57456
+ }
57457
+ function score(name, terms) {
57458
+ const n = norm2(name);
57459
+ if (n.length < 2) return 0;
57460
+ let best = 0;
57461
+ for (const term of terms) {
57462
+ const t = norm2(term);
57463
+ if (n === t) return 3;
57464
+ if (contains(name, t)) best = Math.max(best, 2);
57465
+ else if (contains(term, n)) best = Math.max(best, 1);
57466
+ for (const word of term.split(/\s+/).map(norm2)) {
57467
+ if (word.length < 2 || word === t) continue;
57468
+ if (word === n) best = Math.max(best, 1);
57469
+ else if (contains(name, word)) best = Math.max(best, 0.5);
57470
+ }
57471
+ }
57472
+ return best;
57473
+ }
57474
+ function findJobCategories(query, limit = 20) {
57475
+ const terms = (query ?? "").split(/[,\n]/).map((t) => t.trim()).filter((t) => norm2(t).length >= 2);
57476
+ const remember2 = [];
57477
+ for (const [group2, subs] of Object.entries(REMEMBER_CATEGORIES)) {
57478
+ const gs = score(group2, terms);
57479
+ if (gs) remember2.push({ v: group2, s: gs - 0.5 });
57480
+ for (const sub of subs) {
57481
+ const s = score(sub, terms);
57482
+ if (s) remember2.push({ v: `${group2}>${sub}`, s });
57483
+ }
57484
+ }
57485
+ const saramin2 = [];
57486
+ for (const [group2, kinds] of Object.entries(SARAMIN_CATEGORIES)) {
57487
+ for (const [kind, list] of Object.entries(kinds)) {
57488
+ for (const [code, name] of list) {
57489
+ const s = score(name, terms) - (kind === "\uC9C1\uBB34\xB7\uC9C1\uC5C5" ? 0 : 0.25);
57490
+ if (s > 0) saramin2.push({ code, name, group: group2, kind, s });
57491
+ }
57492
+ }
57493
+ }
57494
+ return {
57495
+ query,
57496
+ remember: remember2.sort((a, b) => b.s - a.s).slice(0, limit).map((r) => r.v),
57497
+ saramin: saramin2.sort((a, b) => b.s - a.s).slice(0, limit).map(({ s: _s, ...c }) => c)
57498
+ };
57499
+ }
57406
57500
  function isRememberCategory(value) {
57407
57501
  const parts = value.split(">").map((s) => s.trim());
57408
57502
  if (parts.length > 2) return false;
@@ -57411,11 +57505,19 @@ function isRememberCategory(value) {
57411
57505
  return !!subs && (sub === void 0 || subs.includes(sub));
57412
57506
  }
57413
57507
  var saraminCodes;
57508
+ var saraminIndex = () => {
57509
+ if (!saraminCodes) {
57510
+ saraminCodes = /* @__PURE__ */ new Map();
57511
+ for (const kinds of Object.values(SARAMIN_CATEGORIES))
57512
+ for (const list of Object.values(kinds)) for (const [c, n] of list) if (!saraminCodes.has(c)) saraminCodes.set(c, n);
57513
+ }
57514
+ return saraminCodes;
57515
+ };
57414
57516
  function isSaraminCategory(code) {
57415
- saraminCodes ??= new Set(
57416
- Object.values(SARAMIN_CATEGORIES).flatMap((kinds) => Object.values(kinds).flatMap((list) => list.map(([c]) => c)))
57417
- );
57418
- return saraminCodes.has(code);
57517
+ return saraminIndex().has(code);
57518
+ }
57519
+ function saraminCategoryName(code) {
57520
+ return saraminIndex().get(code);
57419
57521
  }
57420
57522
 
57421
57523
  // packages/core/src/jobsearch.ts
@@ -57663,6 +57765,118 @@ var saramin = {
57663
57765
  };
57664
57766
  var PROVIDERS = [wanted, jumpit, remember, saramin];
57665
57767
  var JOB_SEARCH_SOURCES = PROVIDERS.map((p) => p.id);
57768
+ function providerError(e) {
57769
+ if (e instanceof external_exports.ZodError) {
57770
+ const first2 = e.issues[0];
57771
+ return {
57772
+ status: "degraded",
57773
+ error: `provider response changed${first2 ? ` (${first2.path.join(".") || "root"})` : ""}`
57774
+ };
57775
+ }
57776
+ const message = e instanceof Error ? e.message : String(e);
57777
+ return { status: "unavailable", error: message };
57778
+ }
57779
+ async function searchJobs(query, opts = {}) {
57780
+ const q = (query ?? "").trim();
57781
+ const limit = Math.min(100, Math.max(1, opts.limit ?? (opts.postedSince ? 50 : 10)));
57782
+ const timeoutMs = Math.min(3e4, Math.max(1e3, opts.timeoutMs ?? 1e4));
57783
+ const chosen = (opts.sources?.length ? PROVIDERS.filter((p) => opts.sources.includes(p.id)) : PROVIDERS).filter(
57784
+ (p) => q || p.categoryOption && opts[p.categoryOption]?.length
57785
+ );
57786
+ if (!chosen.length) return { query: q, results: [], providers: [] };
57787
+ const settled = await Promise.all(
57788
+ chosen.map(async (p) => {
57789
+ const checked_at = (/* @__PURE__ */ new Date()).toISOString();
57790
+ try {
57791
+ const results2 = await p.search(q, limit, timeoutMs, opts);
57792
+ return {
57793
+ outcome: {
57794
+ source: p.id,
57795
+ label: p.label,
57796
+ ok: true,
57797
+ status: "healthy",
57798
+ count: results2.length,
57799
+ checked_at
57800
+ },
57801
+ results: results2
57802
+ };
57803
+ } catch (e) {
57804
+ const failure = providerError(e);
57805
+ return {
57806
+ outcome: {
57807
+ source: p.id,
57808
+ label: p.label,
57809
+ ok: false,
57810
+ status: failure.status,
57811
+ count: 0,
57812
+ checked_at,
57813
+ error: failure.error
57814
+ },
57815
+ results: []
57816
+ };
57817
+ }
57818
+ })
57819
+ );
57820
+ const today = opts.today ?? (/* @__PURE__ */ new Date()).toLocaleDateString("sv-SE");
57821
+ let results = settled.flatMap((s) => s.results);
57822
+ if (opts.trackSeen !== false) {
57823
+ const seen = markJobsSeen(results, today);
57824
+ for (const r of results) r.first_seen_at = seen.get(`${r.source}:${r.external_id}`);
57825
+ }
57826
+ results = dedupeAcrossBoards(results);
57827
+ let filtered_out;
57828
+ const years = opts.years;
57829
+ if (opts.postedSince || years != null) {
57830
+ const kept = results.filter((r) => {
57831
+ if (opts.postedSince && (r.posted_at ?? r.first_seen_at ?? today) < opts.postedSince) return false;
57832
+ const range2 = years != null ? experienceRange(r.experience) : null;
57833
+ return !range2 || range2[0] <= years && years <= range2[1];
57834
+ });
57835
+ filtered_out = results.length - kept.length;
57836
+ results = kept;
57837
+ }
57838
+ return {
57839
+ query: q,
57840
+ results,
57841
+ providers: settled.map((s) => s.outcome),
57842
+ ...filtered_out !== void 0 ? { filtered_out } : {}
57843
+ };
57844
+ }
57845
+ function experienceRange(text) {
57846
+ const t = (text ?? "").replace(/\s/g, "");
57847
+ if (!t) return null;
57848
+ if (/무관/.test(t)) return [0, Infinity];
57849
+ const m = t.match(/(\d+)?~(\d+)년/);
57850
+ if (m) return [m[1] ? +m[1] : 0, +m[2]];
57851
+ const min2 = t.match(/(\d+)년(이상|↑)/);
57852
+ if (min2) return [/신입/.test(t) ? 0 : +min2[1], Infinity];
57853
+ if (/신입/.test(t)) return /경력/.test(t) ? [0, Infinity] : [0, 0];
57854
+ return null;
57855
+ }
57856
+ var normCompany = (s) => s.replace(/\([^)]*\)|㈜|주식회사/g, "").toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
57857
+ var normTitle = (title, company) => {
57858
+ const c = normCompany(company);
57859
+ return title.replace(/\[([^\]]*)\]/g, (tag, inner) => {
57860
+ const n = normCompany(inner);
57861
+ return n && c && (c.includes(n) || n.includes(c)) ? "" : tag;
57862
+ }).toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
57863
+ };
57864
+ function dedupeAcrossBoards(results) {
57865
+ const byKey = /* @__PURE__ */ new Map();
57866
+ const out = [];
57867
+ for (const r of results) {
57868
+ const key = `${normCompany(r.company)}|${normTitle(r.position, r.company)}`;
57869
+ const first2 = byKey.get(key);
57870
+ if (first2 && first2.source !== r.source) {
57871
+ (first2.also_on ??= []).push({ source: r.source, url: r.url });
57872
+ if (!first2.posted_at && r.posted_at) first2.posted_at = r.posted_at;
57873
+ continue;
57874
+ }
57875
+ byKey.set(key, r);
57876
+ out.push(r);
57877
+ }
57878
+ return out;
57879
+ }
57666
57880
 
57667
57881
  // packages/core/src/offers.ts
57668
57882
  function withJob(offer, job) {
@@ -77553,6 +77767,82 @@ function registerDiagnosticRoutes(router2) {
77553
77767
  });
77554
77768
  }
77555
77769
 
77770
+ // apps/web/src/job-search-routes.ts
77771
+ var isYmd = (s) => /^\d{4}-\d{2}-\d{2}$/.test(s) && !Number.isNaN(Date.parse(s)) && new Date(s).toISOString().startsWith(s);
77772
+ var Categories = {
77773
+ remember_categories: external_exports.array(external_exports.string().max(100)).max(20).optional(),
77774
+ saramin_categories: external_exports.array(external_exports.string().regex(/^\d+$/)).max(20).optional()
77775
+ };
77776
+ var SearchSchema = external_exports.object({
77777
+ keyword: external_exports.string().max(100).optional(),
77778
+ ...Categories,
77779
+ sources: external_exports.array(external_exports.enum([...JOB_SEARCH_SOURCES])).optional(),
77780
+ limit: external_exports.number().int().min(1).max(100).optional(),
77781
+ posted_since: external_exports.string().refine((s) => s === "today" || isYmd(s)).optional(),
77782
+ years: external_exports.number().int().min(0).max(40).optional()
77783
+ });
77784
+ var PrefsSchema = external_exports.object({ keyword: external_exports.string().max(100).optional(), ...Categories });
77785
+ function assertKnownCategories(remember2 = [], saramin2 = []) {
77786
+ const bad = [...remember2.filter((c) => !isRememberCategory(c)), ...saramin2.filter((c) => !isSaraminCategory(c))];
77787
+ if (bad.length) throw new HttpError(400, `\uB9AC\uBA64\uBC84\xB7\uC0AC\uB78C\uC778 \uBD84\uB958\uD45C\uC5D0 \uC5C6\uB294 \uAC12\uC785\uB2C8\uB2E4: ${bad.join(", ")}`);
77788
+ }
77789
+ function registerJobSearchRoutes(router2) {
77790
+ router2.get("/api/job-search/categories", (ctx) => findJobCategories(ctx.query.get("q") ?? "", 20));
77791
+ router2.get("/api/job-search/preferences", () => {
77792
+ const preferences = getJobSearchPreferences();
77793
+ const saramin_names = Object.fromEntries(preferences.saramin_categories.map((c) => [c, saraminCategoryName(c) ?? c]));
77794
+ return { preferences, saramin_names };
77795
+ });
77796
+ router2.put("/api/job-search/preferences", async (ctx) => {
77797
+ const input = await readJsonBody(ctx.req, PrefsSchema);
77798
+ assertKnownCategories(input.remember_categories, input.saramin_categories);
77799
+ return {
77800
+ preferences: setJobSearchPreferences({
77801
+ ...input.keyword !== void 0 ? { keyword: input.keyword.trim() } : {},
77802
+ ...input.remember_categories ? { remember_categories: input.remember_categories } : {},
77803
+ ...input.saramin_categories ? { saramin_categories: input.saramin_categories } : {}
77804
+ })
77805
+ };
77806
+ });
77807
+ router2.post("/api/job-search", async (ctx) => {
77808
+ const input = await readJsonBody(ctx.req, SearchSchema);
77809
+ let keyword = (input.keyword ?? "").trim();
77810
+ let remember2 = input.remember_categories ?? [];
77811
+ let saramin2 = input.saramin_categories ?? [];
77812
+ let usedSaved = false;
77813
+ if (!keyword && !remember2.length && !saramin2.length) {
77814
+ const saved = getJobSearchPreferences();
77815
+ ({ keyword, remember_categories: remember2, saramin_categories: saramin2 } = saved);
77816
+ usedSaved = true;
77817
+ if (!keyword && !remember2.length && !saramin2.length) {
77818
+ throw new HttpError(400, "\uAC80\uC0C9\uC5B4\uB098 \uC9C1\uBB34\uB97C \uB123\uC5B4 \uC8FC\uC138\uC694.");
77819
+ }
77820
+ }
77821
+ assertKnownCategories(remember2, saramin2);
77822
+ const today = (/* @__PURE__ */ new Date()).toLocaleDateString("sv-SE");
77823
+ const res = await searchJobs(keyword, {
77824
+ sources: input.sources,
77825
+ limit: input.limit,
77826
+ rememberCategories: remember2,
77827
+ saraminCategories: saramin2,
77828
+ postedSince: input.posted_since === "today" ? today : input.posted_since,
77829
+ years: input.years,
77830
+ today
77831
+ });
77832
+ const savedByUrl = new Map(jobRepo.list().filter((j) => j.url).map((j) => [j.url, j.id]));
77833
+ const results = res.results.map((r) => ({
77834
+ ...r,
77835
+ saved_job_id: savedByUrl.get(r.url) ?? r.also_on?.map((a) => savedByUrl.get(a.url)).find(Boolean) ?? null
77836
+ }));
77837
+ return {
77838
+ ...res,
77839
+ results,
77840
+ used_saved_preferences: usedSaved,
77841
+ conditions: { keyword, remember_categories: remember2, saramin_categories: saramin2, years: input.years ?? null }
77842
+ };
77843
+ });
77844
+ }
77845
+
77556
77846
  // apps/web/src/server.ts
77557
77847
  var __dirname2 = path6.dirname(fileURLToPath2(import.meta.url));
77558
77848
  var WEB_ROOT = BUNDLED ? path6.resolve(__dirname2, "public") : path6.resolve(__dirname2, "..", "public");
@@ -77560,6 +77850,7 @@ var LANDING_URL = "https://careermate.life/";
77560
77850
  var router = new Router();
77561
77851
  registerApiRoutes(router);
77562
77852
  registerDiagnosticRoutes(router);
77853
+ registerJobSearchRoutes(router);
77563
77854
  function createServer() {
77564
77855
  return http.createServer(async (req, res) => {
77565
77856
  try {
package/docs/FAQ.md CHANGED
@@ -79,6 +79,30 @@ UTF-8로 다시 저장하거나 내용을 붙여넣어 주세요.
79
79
 
80
80
  ---
81
81
 
82
+ ## 공고 찾기
83
+
84
+ ### 채용공고도 찾아주나요?
85
+ 네. AI에게 "백엔드 공고 찾아줘", "경리 공고 찾아줘"처럼 말하면 **원티드·점핏·리멤버·사람인**의
86
+ 공개 공고를 모아 보여줍니다. 로그인이나 API 키는 필요 없고, 밖으로 나가는 것은 검색어와 직무
87
+ 조건뿐입니다(프로필·이력서는 보내지 않음). 잡코리아는 robots.txt 제약 때문에 넣지 않았습니다.
88
+ 개발 직군만이 아니라 모든 직군에서 됩니다. 직무 이름을 각 사이트의 분류로 바꾸는 표가 내장돼
89
+ 있습니다(리멤버 26개 그룹·315개 세부 직무, 사람인 21개 분야·2,178개 코드). 여러 사이트에 함께
90
+ 올라온 공고는 한 줄로 합칩니다. 고른 공고만 저장되고, 저장한 공고는 적합도 분석과 자소서로 이어집니다.
91
+ 대시보드의 **공고 찾기** 화면에서도 같은 검색을 하고 클릭 한 번으로 저장할 수 있습니다.
92
+
93
+ ### 오늘 올라온 공고만 볼 수 있나요?
94
+ 자주 보는 직무를 검색 조건으로 한 번 저장해 두면 **"오늘 올라온 내 직무 공고 보여줘"** 만으로
95
+ 새 공고를 봅니다. 리멤버·사람인은 사이트 게시일로 거릅니다. 원티드·점핏은 게시일을 주지 않아
96
+ CareerMate가 처음 본 날로 거르므로, **첫 검색에서는 전부 새 공고로 보입니다.**
97
+
98
+ ### 경력 연차로 거를 수 있나요?
99
+ 네. "경력 5년인데 프론트엔드 공고 찾아줘"처럼 연차를 말하면 경력 범위에 그 연차가 들어가는 공고만
100
+ 남깁니다. 5년이면 "3~10년"은 남고 "8~20년"은 빠집니다. 경력 표기를 읽을 수 없는 공고는 빼지 않고
101
+ 남겨 둡니다. 2026-11-04에 프론트엔드·경력 5년으로 검색했을 때 합친 뒤 173건 중 123건이 남고
102
+ 50건이 연차 조건으로 빠졌습니다.
103
+
104
+ ---
105
+
82
106
  ## 공고 분석 / 자소서
83
107
 
84
108
  ### "공고 URL은 어떻게 넣어요?"
package/docs/MCP_TOOLS.md CHANGED
@@ -141,7 +141,7 @@ AI 클라이언트가 연결되면 이 도구를 가장 먼저 호출해 상태
141
141
 
142
142
  | 도구 | 목적 | 주요 입력 | 반환 | 언제 호출 | 읽기전용 |
143
143
  | --- | --- | --- | --- | --- | --- |
144
- | `search_jobs` | 키워드나 직무 조건으로 무인증 공개 잡보드(원티드·점핏·리멤버·사람인)에서 공고를 *가져옴*(저장은 안 함). 여러 사이트의 같은 공고는 한 줄로 합침 | `keyword?`, `sources?`, `limit?`(사이트별, 기본 10, posted_since가 있으면 50, 최대 100), `remember_categories?`(["재무·회계·세무>회계결산"]), `saramin_categories?`(["322"]), `posted_since?`("today"·YYYY-MM-DD). keyword와 직무 조건을 모두 비우면 저장된 검색 조건 사용 | `{ query, results[](회사·직무·url·지역·경력·기술·마감·posted_at·first_seen_at·also_on, 사람인은 category_codes), providers[], filtered_out?, conditions, used_saved_preferences }` | "○○ 공고 찾아줘", "오늘 올라온 공고 보여줘". 결과를 보여주고 **고른 것만** save_job_posting. 원티드·점핏은 게시일이 없어 처음 본 날로 거름 | ✅ |
144
+ | `search_jobs` | 키워드나 직무 조건으로 무인증 공개 잡보드(원티드·점핏·리멤버·사람인)에서 공고를 *가져옴*(저장은 안 함). 여러 사이트의 같은 공고는 한 줄로 합침 | `keyword?`, `sources?`, `limit?`(사이트별, 기본 10, posted_since가 있으면 50, 최대 100), `remember_categories?`(["재무·회계·세무>회계결산"]), `saramin_categories?`(["322"]), `posted_since?`("today"·YYYY-MM-DD), `years?`(사용자 경력 연차, 공고 경력 범위에 들어가는 것만). keyword와 직무 조건을 모두 비우면 저장된 검색 조건 사용 | `{ query, results[](회사·직무·url·지역·경력·기술·마감·posted_at·first_seen_at·also_on, 사람인은 category_codes), providers[], filtered_out?, conditions, used_saved_preferences }` | "○○ 공고 찾아줘", "오늘 올라온 공고 보여줘". 결과를 보여주고 **고른 것만** save_job_posting. 원티드·점핏은 게시일이 없어 처음 본 날로 거름 | ✅ |
145
145
  | `find_job_categories` | 직무 이름을 리멤버 분류("상위>하위")와 사람인 직무 코드로 변환(내장 분류표, 외부 통신 없음). 모든 직군 | `query`(쉼표로 여러 개), `limit?`(기본 20) | `{ query, remember[], saramin[](code·name·group·kind) }` 관련도순 | search_jobs·set_job_search_preferences에 직무 조건을 넣기 전 | ✅ |
146
146
  | `get_job_search_preferences` | 저장된 공고 검색 조건 조회 | 없음 | `{ keyword, remember_categories, saramin_categories, updated_at }` | "내 검색 조건 뭐야?" | ✅ |
147
147
  | `set_job_search_preferences` | 자주 볼 공고 검색 조건 저장(준 필드만 갱신). 리멤버 분류는 내장 분류표로 검증 | `keyword?`, `remember_categories?`, `saramin_categories?` | 저장된 조건 | 사용자 직무를 find_job_categories로 찾고 확인받은 뒤 | ✏️ |
@@ -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.13",
3
+ "version": "0.0.15",
4
4
  "type": "module",
5
5
  "description": "CareerMate — an MCP-first, local-first career workspace. Your career database and documents stay on your machine; optional discovery and update features may use the internet without sending stored career data.",
6
6
  "license": "MIT",
@@ -95,12 +95,23 @@ export function isRememberCategory(value: string): boolean {
95
95
  return !!subs && (sub === undefined || subs.includes(sub));
96
96
  }
97
97
 
98
- let saraminCodes: Set<string> | undefined;
98
+ let saraminCodes: Map<string, string> | undefined;
99
+ // First name wins if a code repeats, so the 직무·직업 name (listed first) is kept.
100
+ const saraminIndex = () => {
101
+ if (!saraminCodes) {
102
+ saraminCodes = new Map();
103
+ for (const kinds of Object.values(SARAMIN_CATEGORIES))
104
+ for (const list of Object.values(kinds)) for (const [c, n] of list) if (!saraminCodes.has(c)) saraminCodes.set(c, n);
105
+ }
106
+ return saraminCodes;
107
+ };
99
108
 
100
109
  /** A cat_kewd code from the snapshot. Unknown codes are rejected rather than risk an unfiltered list. */
101
110
  export function isSaraminCategory(code: string): boolean {
102
- saraminCodes ??= new Set(
103
- Object.values(SARAMIN_CATEGORIES).flatMap((kinds) => Object.values(kinds).flatMap((list) => list.map(([c]) => c))),
104
- );
105
- return saraminCodes.has(code);
111
+ return saraminIndex().has(code);
112
+ }
113
+
114
+ /** Display name of a Saramin code ("322" -> "경리"), or undefined if unknown. */
115
+ export function saraminCategoryName(code: string): string | undefined {
116
+ return saraminIndex().get(code);
106
117
  }