job-pro 0.7.0 → 0.7.2

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/moonshot.js CHANGED
@@ -1,63 +1,396 @@
1
- // Thin client for Moonshot AI (月之暗面 / Kimi) recruiting portal.
2
- //
3
- // Portal: https://moonshot.jobs.feishu.cn/
4
- // Platform: Feishu Recruiting (ATSX) SaaS — same API surface as nio.ts / minimax.ts.
1
+ // Moonshot AI (月之暗面 / Kimi) recruiting via app.mokahr.com.
5
2
  //
6
3
  // ============================================================
7
- // Endpoint inventory (probed 2026-05):
8
- //
9
- // POST https://moonshot.jobs.feishu.cn/api/v1/search/job/posts
10
- // GET https://moonshot.jobs.feishu.cn/api/v1/config/job/filters/social
4
+ // HOW THIS WORKS (probed 2026-05):
11
5
  //
12
- // Both return HTTP 200 + code:0 unauthenticated. The portal-channel
13
- // and website-path headers must be "social" (the only registered portal).
6
+ // Moonshot's Feishu tenant (moonshot.jobs.feishu.cn) has zero published
7
+ // positions they migrated their listings to Moka. The kimi.com careers
8
+ // page links to two Moka short URLs:
14
9
  //
15
- // ============================================================
16
- // Portal discovery (2026-05):
10
+ // mokahr.com/su/phmkug → social-recruitment/moonshot/148506
11
+ // mokahr.com/su/gblcus campus-recruitment/moonshot/148507
17
12
  //
18
- // moonshot.cn/careers → bare nginx page (no portal)
19
- // kimi.moonshot.cn/careers → 302 kimi.com/careers 302 / (no portal)
20
- // moonshot.jobs.feishu.cn/ → Feishu ATSX, channel "social" ("Kimi社招官网")
21
- // moonshot.jobs.feishu.cn/campus/position → channel "campus" → code:0, count:0
13
+ // This adapter targets the social portal (148506) — the larger of the two
14
+ // (129+ open positions vs ~65 campus) and the one most users want by default.
22
15
  //
23
- // The only active Feishu portal is the "social" channel.
24
- // Moka orgId 148507 (app.mokahr.com/campus-recruitment/moonshot/148507) exists
25
- // but is auth-gated direct API calls return 404.
16
+ // The SSR HTML at the portal URL embeds the first page of jobs in an
17
+ // `<input id="init-data">` blob. `jobStats.total` is the canonical total
18
+ // count. Deeper pages come from POST /api/outer/ats-apply/website/jobs/v2
19
+ // with `?orgId=moonshot` (AES-128-CBC encrypted envelope; key=necromancer,
20
+ // iv=aesIv from init-data). Same mechanism as deepseek.ts.
26
21
  //
27
- // ============================================================
28
- // Current job count (probed 2026-05):
22
+ // CONFIRMED MOKA ORG:
23
+ // slug=moonshot, siteId=148506, mode=social
24
+ // Portal: https://app.mokahr.com/social-recruitment/moonshot/148506
29
25
  //
30
- // social channel: count: 0 (API live, no published positions)
31
- // campus channel: count: 0 (API live, no published positions)
32
- //
33
- // The API is fully functional and returns the correct JSON envelope.
34
- // Moonshot appears to have temporarily unpublished all listings.
35
- // The adapter will return ok:true with an empty positions array until
36
- // they resume publishing; no code changes are needed when that happens.
37
- //
38
- // ============================================================
39
- // PositionSummary field mapping (Feishu → canonical):
40
- // post_id ← String(item.id)
41
- // title ← item.title
42
- // project ← item.job_category?.name ?? item.job_function?.name
43
- // recruit_label ← item.recruit_type?.name
44
- // bgs ← "" (not exposed in public search)
45
- // work_cities ← city_list joined " / " (city_info used as fallback)
46
- // apply_url ← https://moonshot.jobs.feishu.cn/social/${id}/detail
47
- import { createAdapter } from "./feishu.js";
26
+ // PositionSummary field mapping:
27
+ // post_id ← job.id
28
+ // title ← job.title
29
+ // project ← job.zhineng?.name
30
+ // recruit_label job.commitment || hireMode label
31
+ // bgs ← job.department?.name
32
+ // work_cities ← locations[].cityId label via jobsGroupedByLocation
33
+ // apply_url ← portal#/jobs/{id}
48
34
  import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
49
- export { extractResumeSignals, scoreOverlap, checkResume };
50
- const _adapter = createAdapter({
51
- host: "moonshot.jobs.feishu.cn",
52
- channel: "social",
53
- label: "Moonshot AI (Kimi)",
54
- applyUrlPrefix: "https://moonshot.jobs.feishu.cn/social/position",
55
- });
56
- export const searchPositions = _adapter.searchPositions;
57
- export const fetchAllPositions = _adapter.fetchAllPositions;
58
- export const fetchPositionDetail = _adapter.fetchPositionDetail;
59
- export const fetchDictionaries = _adapter.fetchDictionaries;
60
- export const listNotices = _adapter.listNotices;
61
- export const getNotice = _adapter.getNotice;
62
- export const findNoticesByQuestion = _adapter.findNoticesByQuestion;
63
- export const matchResume = _adapter.matchResume;
35
+ import { createDecipheriv } from "node:crypto";
36
+ export { checkResume, extractResumeSignals, scoreOverlap };
37
+ const SOURCE = "app.mokahr.com/moonshot";
38
+ const ORG_SLUG = "moonshot";
39
+ const SITE_ID = 148506;
40
+ const PORTAL_URL = `https://app.mokahr.com/social-recruitment/${ORG_SLUG}/${SITE_ID}`;
41
+ const API_ENDPOINT = "https://app.mokahr.com/api/outer/ats-apply/website/jobs/v2";
42
+ const DEFAULT_HEADERS = {
43
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
44
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
45
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
46
+ };
47
+ function htmlDecode(s) {
48
+ return s
49
+ .replace(/&quot;/g, '"')
50
+ .replace(/&amp;/g, "&")
51
+ .replace(/&lt;/g, "<")
52
+ .replace(/&gt;/g, ">")
53
+ .replace(/&#x27;/g, "'")
54
+ .replace(/&#39;/g, "'");
55
+ }
56
+ function parseInitData(html) {
57
+ const m = html.match(/<input[^>]*id="init-data"[^>]*value="([^"]+)"/);
58
+ if (!m)
59
+ return null;
60
+ try {
61
+ return JSON.parse(htmlDecode(m[1]));
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ async function fetchPortalHtml() {
68
+ let r1;
69
+ try {
70
+ r1 = await fetch(PORTAL_URL, { method: "GET", headers: DEFAULT_HEADERS, redirect: "manual" });
71
+ }
72
+ catch (err) {
73
+ return { ok: false, message: `network error: ${err instanceof Error ? err.message : err}` };
74
+ }
75
+ const cookies = [];
76
+ const headersAny = r1.headers;
77
+ if (typeof headersAny.getSetCookie === "function") {
78
+ for (const v of headersAny.getSetCookie.call(r1.headers) ?? []) {
79
+ const c = v.split(";")[0];
80
+ if (c)
81
+ cookies.push(c);
82
+ }
83
+ }
84
+ if (cookies.length === 0) {
85
+ const raw = r1.headers.get("set-cookie");
86
+ if (raw)
87
+ cookies.push(...raw.split(/,(?=[^;]+=)/).map((c) => c.split(";")[0].trim()));
88
+ }
89
+ const cookieHeader = cookies.join("; ");
90
+ let r2;
91
+ try {
92
+ r2 = await fetch(PORTAL_URL, {
93
+ method: "GET",
94
+ headers: { ...DEFAULT_HEADERS, Cookie: cookieHeader },
95
+ redirect: "follow",
96
+ });
97
+ }
98
+ catch (err) {
99
+ return { ok: false, message: `network error: ${err instanceof Error ? err.message : err}` };
100
+ }
101
+ if (!r2.ok)
102
+ return { ok: false, message: `HTTP ${r2.status}` };
103
+ return { ok: true, html: await r2.text(), cookieHeader, message: "ok" };
104
+ }
105
+ function decryptMoka(envelope, aesIv) {
106
+ if (!envelope.data || !envelope.necromancer)
107
+ return null;
108
+ try {
109
+ const decipher = createDecipheriv("aes-128-cbc", Buffer.from(envelope.necromancer, "utf8"), Buffer.from(aesIv, "utf8"));
110
+ const plain = Buffer.concat([
111
+ decipher.update(Buffer.from(envelope.data, "base64")),
112
+ decipher.final(),
113
+ ]);
114
+ return JSON.parse(plain.toString("utf8"));
115
+ }
116
+ catch {
117
+ return null;
118
+ }
119
+ }
120
+ async function fetchEncryptedPage(pageNum, pageSize, aesIv, cookieHeader) {
121
+ let response;
122
+ try {
123
+ response = await fetch(`${API_ENDPOINT}?orgId=${encodeURIComponent(ORG_SLUG)}`, {
124
+ method: "POST",
125
+ headers: {
126
+ ...DEFAULT_HEADERS,
127
+ Accept: "application/json,*/*",
128
+ "Content-Type": "application/json",
129
+ Origin: "https://app.mokahr.com",
130
+ Referer: PORTAL_URL,
131
+ Cookie: cookieHeader,
132
+ },
133
+ body: JSON.stringify({
134
+ orgId: ORG_SLUG,
135
+ siteId: String(SITE_ID),
136
+ pageNum,
137
+ pageSize,
138
+ needStat: true,
139
+ }),
140
+ });
141
+ }
142
+ catch (err) {
143
+ return { ok: false, message: `network error: ${err instanceof Error ? err.message : err}` };
144
+ }
145
+ if (!response.ok)
146
+ return { ok: false, message: `HTTP ${response.status}` };
147
+ let envelope;
148
+ try {
149
+ envelope = await response.json();
150
+ }
151
+ catch {
152
+ return { ok: false, message: "bad JSON" };
153
+ }
154
+ const decoded = decryptMoka(envelope, aesIv);
155
+ if (!decoded || decoded.code !== 0 || !decoded.data) {
156
+ return { ok: false, message: decoded?.msg || envelope.msg || "decrypt error" };
157
+ }
158
+ return {
159
+ ok: true,
160
+ jobs: decoded.data.jobs ?? [],
161
+ total: decoded.data.jobStats?.total ?? 0,
162
+ message: "ok",
163
+ };
164
+ }
165
+ function buildCityMap(groups) {
166
+ const out = {};
167
+ if (!groups)
168
+ return out;
169
+ for (const g of groups) {
170
+ if (typeof g.cityId === "number" && g.label)
171
+ out[g.cityId] = g.label;
172
+ }
173
+ return out;
174
+ }
175
+ function workCities(job, cityMap) {
176
+ const uniq = [];
177
+ for (const loc of job.locations ?? []) {
178
+ const label = (typeof loc.cityId === "number" && cityMap[loc.cityId]) || loc.country || "";
179
+ if (label && !uniq.includes(label))
180
+ uniq.push(label);
181
+ }
182
+ return uniq.join(" / ");
183
+ }
184
+ function recruitLabel(job) {
185
+ if (job.commitment)
186
+ return job.commitment;
187
+ if (job.hireMode === 1)
188
+ return "全职";
189
+ if (job.hireMode === 2)
190
+ return "实习";
191
+ return "";
192
+ }
193
+ function summarize(job, cityMap) {
194
+ return {
195
+ post_id: String(job.id),
196
+ title: job.title ?? "",
197
+ project: job.zhineng?.name ?? "",
198
+ recruit_label: recruitLabel(job),
199
+ bgs: job.department?.name ?? "",
200
+ work_cities: workCities(job, cityMap),
201
+ apply_url: `${PORTAL_URL}#/jobs/${encodeURIComponent(job.id)}`,
202
+ };
203
+ }
204
+ function matchesKeyword(job, kw) {
205
+ if (!kw)
206
+ return true;
207
+ const lc = kw.toLowerCase();
208
+ return ((job.title ?? "").toLowerCase().includes(lc) ||
209
+ (job.zhineng?.name ?? "").toLowerCase().includes(lc) ||
210
+ (job.department?.name ?? "").toLowerCase().includes(lc));
211
+ }
212
+ export async function searchPositions(opts = {}) {
213
+ const pageSize = opts.pageSize ?? 20;
214
+ const page = opts.page ?? 1;
215
+ const keyword = opts.keyword ?? "";
216
+ const portal = await fetchPortalHtml();
217
+ if (!portal.ok || !portal.html) {
218
+ return {
219
+ ok: false,
220
+ source: SOURCE,
221
+ message: portal.message,
222
+ query: { keyword, page, pageSize },
223
+ positions: [],
224
+ total: 0,
225
+ };
226
+ }
227
+ const init = parseInitData(portal.html);
228
+ if (!init || !init.jobs || !init.jobStats) {
229
+ return {
230
+ ok: false,
231
+ source: SOURCE,
232
+ message: "Moka init-data missing jobs/jobStats",
233
+ query: { keyword, page, pageSize },
234
+ positions: [],
235
+ total: 0,
236
+ };
237
+ }
238
+ const cityMap = buildCityMap(init.jobsGroupedByLocation);
239
+ let jobs = init.jobs;
240
+ const total = init.jobStats.total ?? jobs.length;
241
+ if (page > 1 && init.aesIv && portal.cookieHeader) {
242
+ const more = await fetchEncryptedPage(page, pageSize, init.aesIv, portal.cookieHeader);
243
+ if (!more.ok || !more.jobs) {
244
+ return {
245
+ ok: false,
246
+ source: SOURCE,
247
+ message: `pagination failed: ${more.message}`,
248
+ query: { keyword, page, pageSize },
249
+ positions: [],
250
+ total,
251
+ };
252
+ }
253
+ jobs = more.jobs;
254
+ }
255
+ const filtered = jobs.filter((j) => matchesKeyword(j, keyword)).slice(0, pageSize);
256
+ return {
257
+ ok: true,
258
+ source: SOURCE,
259
+ query: { keyword, page, pageSize },
260
+ page,
261
+ page_size: pageSize,
262
+ total,
263
+ positions: filtered.map((j) => summarize(j, cityMap)),
264
+ };
265
+ }
266
+ export async function fetchAllPositions(opts = {}) {
267
+ const pageSize = opts.pageSize ?? 20;
268
+ const maxPages = Math.max(1, opts.maxPages ?? 50);
269
+ const keyword = opts.keyword ?? "";
270
+ const portal = await fetchPortalHtml();
271
+ if (!portal.ok || !portal.html) {
272
+ return {
273
+ ok: false,
274
+ source: SOURCE,
275
+ message: portal.message,
276
+ total: 0,
277
+ fetched: 0,
278
+ positions: [],
279
+ };
280
+ }
281
+ const init = parseInitData(portal.html);
282
+ if (!init || !init.jobs || !init.jobStats || !init.aesIv) {
283
+ return {
284
+ ok: false,
285
+ source: SOURCE,
286
+ message: "Moka init-data missing required fields",
287
+ total: 0,
288
+ fetched: 0,
289
+ positions: [],
290
+ };
291
+ }
292
+ const cityMap = buildCityMap(init.jobsGroupedByLocation);
293
+ const total = init.jobStats.total ?? 0;
294
+ const collected = [...init.jobs];
295
+ let page = 2;
296
+ while (collected.length < total && page <= maxPages) {
297
+ const more = await fetchEncryptedPage(page, pageSize, init.aesIv, portal.cookieHeader ?? "");
298
+ if (!more.ok || !more.jobs || more.jobs.length === 0)
299
+ break;
300
+ collected.push(...more.jobs);
301
+ page += 1;
302
+ }
303
+ const filtered = collected.filter((j) => matchesKeyword(j, keyword));
304
+ return {
305
+ ok: true,
306
+ source: SOURCE,
307
+ total,
308
+ fetched: filtered.length,
309
+ positions: filtered.map((j) => summarize(j, cityMap)),
310
+ };
311
+ }
312
+ export async function fetchPositionDetail(postId) {
313
+ return {
314
+ ok: false,
315
+ source: SOURCE,
316
+ message: "Moka detail endpoint is also AES-encrypted and not implemented; " +
317
+ "use the apply_url deeplink for the full JD.",
318
+ post_id: postId,
319
+ apply_url: `${PORTAL_URL}#/jobs/${encodeURIComponent(postId)}`,
320
+ };
321
+ }
322
+ export async function fetchDictionaries() {
323
+ const portal = await fetchPortalHtml();
324
+ if (!portal.ok || !portal.html) {
325
+ return { ok: false, source: SOURCE, message: portal.message };
326
+ }
327
+ const init = parseInitData(portal.html);
328
+ if (!init) {
329
+ return { ok: false, source: SOURCE, message: "Moka init-data missing" };
330
+ }
331
+ return {
332
+ ok: true,
333
+ source: SOURCE,
334
+ locations: init.jobsGroupedByLocation ?? [],
335
+ moka_org: { slug: ORG_SLUG, siteId: SITE_ID, url: PORTAL_URL },
336
+ };
337
+ }
338
+ export async function listNotices() {
339
+ return {
340
+ ok: false,
341
+ source: SOURCE,
342
+ message: "Moonshot: no public notices endpoint",
343
+ notices: [],
344
+ };
345
+ }
346
+ export async function getNotice(noticeId) {
347
+ return {
348
+ ok: false,
349
+ source: SOURCE,
350
+ message: "Moonshot: no public notices endpoint",
351
+ notice_id: noticeId,
352
+ };
353
+ }
354
+ export async function findNoticesByQuestion(question, _opts = {}) {
355
+ return {
356
+ ok: false,
357
+ source: SOURCE,
358
+ question,
359
+ message: "Moonshot: no public notices endpoint",
360
+ matches: [],
361
+ };
362
+ }
363
+ export async function matchResume(text, opts = {}) {
364
+ const { terms, cities } = extractResumeSignals(text ?? "");
365
+ const candidates = Math.max(20, opts.candidates ?? 100);
366
+ const all = await fetchAllPositions({
367
+ pageSize: 20,
368
+ maxPages: Math.ceil(candidates / 15),
369
+ });
370
+ if (!all.ok) {
371
+ return {
372
+ ok: false,
373
+ source: SOURCE,
374
+ extracted_terms: terms,
375
+ city_preferences: cities,
376
+ matches: [],
377
+ message: all.message,
378
+ };
379
+ }
380
+ const topN = Math.max(1, opts.topN ?? 10);
381
+ const scored = all.positions
382
+ .map((p) => ({
383
+ p,
384
+ score: scoreOverlap(`${p.title} ${p.project} ${p.bgs}`, terms, cities).score,
385
+ }))
386
+ .sort((a, b) => b.score - a.score)
387
+ .slice(0, topN)
388
+ .map((x) => x.p);
389
+ return {
390
+ ok: true,
391
+ source: SOURCE,
392
+ extracted_terms: terms,
393
+ city_preferences: cities,
394
+ matches: scored,
395
+ };
396
+ }