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 +1 -1
- package/README.md +8 -6
- package/dist/doctor.mjs +33 -10
- package/dist/mcp.mjs +33 -10
- 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/MCP_TOOLS.md +1 -1
- 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/packages/core/src/jobsearch.ts +26 -3
- package/packages/mcp-tools/src/tools.ts +10 -2
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/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로 찾고 확인받은 뒤 | ✏️ |
|
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
|
}
|