@ha7ch/job-pro 1.0.93
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/adapter.js +17 -0
- package/dist/agibot.js +399 -0
- package/dist/alibaba.js +509 -0
- package/dist/antgroup.js +397 -0
- package/dist/apply.js +1373 -0
- package/dist/baichuan.js +49 -0
- package/dist/baidu.js +452 -0
- package/dist/bilibili.js +455 -0
- package/dist/byd.js +412 -0
- package/dist/bytedance.js +619 -0
- package/dist/cainiao.js +56 -0
- package/dist/cambricon.js +33 -0
- package/dist/cdp.js +237 -0
- package/dist/cicc.js +56 -0
- package/dist/coverage.js +60 -0
- package/dist/deepseek.js +25 -0
- package/dist/didi.js +381 -0
- package/dist/feishu.js +577 -0
- package/dist/galaxyuniversal.js +24 -0
- package/dist/geely.js +35 -0
- package/dist/greenhouse.js +432 -0
- package/dist/hikvision.js +58 -0
- package/dist/horizonrobotics.js +46 -0
- package/dist/hoyoverse.js +26 -0
- package/dist/huawei.js +537 -0
- package/dist/iflytek.js +380 -0
- package/dist/index.js +1828 -0
- package/dist/iqiyi.js +494 -0
- package/dist/jd.js +559 -0
- package/dist/kuaishou.js +496 -0
- package/dist/lever.js +455 -0
- package/dist/liauto.js +393 -0
- package/dist/liepin.js +357 -0
- package/dist/lilith.js +300 -0
- package/dist/megvii.js +27 -0
- package/dist/meituan.js +633 -0
- package/dist/memory.js +76 -0
- package/dist/mihoyo.js +308 -0
- package/dist/minimax.js +32 -0
- package/dist/moka.js +473 -0
- package/dist/moonshot.js +24 -0
- package/dist/netease.js +424 -0
- package/dist/nio.js +24 -0
- package/dist/oppo.js +285 -0
- package/dist/pdd.js +614 -0
- package/dist/pingan.js +493 -0
- package/dist/sensetime.js +51 -0
- package/dist/sf.js +310 -0
- package/dist/stepfun.js +24 -0
- package/dist/tencent.js +770 -0
- package/dist/trip.js +396 -0
- package/dist/unitree.js +418 -0
- package/dist/vivo.js +361 -0
- package/dist/webank.js +55 -0
- package/dist/wecruit.js +438 -0
- package/dist/weibo.js +337 -0
- package/dist/weride.js +29 -0
- package/dist/xiaohongshu.js +480 -0
- package/dist/xiaomi.js +529 -0
- package/dist/xpeng.js +34 -0
- package/dist/zerooneai.js +42 -0
- package/dist/zhipu.js +478 -0
- package/extension/README.md +79 -0
- package/extension/background.js +177 -0
- package/extension/manifest.json +55 -0
- package/extension/popup.html +37 -0
- package/extension/popup.js +54 -0
- package/package.json +61 -0
package/dist/moka.js
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
// Generic Moka (北森外 — `app.mokahr.com` 招聘) adapter factory.
|
|
2
|
+
//
|
|
3
|
+
// Moka is a SaaS ATS used by many Chinese tech companies (Megvii, DeepSeek,
|
|
4
|
+
// Galaxy Universal, StepFun, Cambricon, Geely, …). Each tenant publishes a
|
|
5
|
+
// public portal at one of these URL shapes:
|
|
6
|
+
//
|
|
7
|
+
// https://app.mokahr.com/campus-recruitment/<orgSlug>/<siteId>
|
|
8
|
+
// https://app.mokahr.com/campus_apply/<orgSlug>/<siteId>
|
|
9
|
+
// https://app.mokahr.com/social-recruitment/<orgSlug>/<siteId>
|
|
10
|
+
// https://app.mokahr.com/recommendation-recruitment/<orgSlug>/<siteId>
|
|
11
|
+
//
|
|
12
|
+
// The SSR HTML always embeds an `<input id="init-data" value="<HTML-escaped JSON>">`
|
|
13
|
+
// containing the first page of jobs + an `aesIv` constant. For deeper
|
|
14
|
+
// pagination the SPA POSTs to
|
|
15
|
+
// /api/outer/ats-apply/website/jobs/v2?orgId=<slug>
|
|
16
|
+
// and receives an AES-CBC encrypted envelope `{data, necromancer}`. We
|
|
17
|
+
// decrypt with key=necromancer (utf8) and iv=aesIv (utf8) to obtain the
|
|
18
|
+
// plain JSON page.
|
|
19
|
+
//
|
|
20
|
+
// This factory hides that machinery. Adapters declare `{ orgSlug, channels }`
|
|
21
|
+
// (one channel per public portal URL) and get the eight canonical verbs.
|
|
22
|
+
import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
|
|
23
|
+
import { createDecipheriv } from "node:crypto";
|
|
24
|
+
export { checkResume };
|
|
25
|
+
// ---------- shared headers ----------
|
|
26
|
+
const DEFAULT_HEADERS = {
|
|
27
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
|
|
28
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
29
|
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
30
|
+
};
|
|
31
|
+
const API_ENDPOINT = "https://app.mokahr.com/api/outer/ats-apply/website/jobs/v2";
|
|
32
|
+
// ---------- shared helpers ----------
|
|
33
|
+
function htmlDecode(s) {
|
|
34
|
+
return s
|
|
35
|
+
.replace(/"/g, '"')
|
|
36
|
+
.replace(/&/g, "&")
|
|
37
|
+
.replace(/</g, "<")
|
|
38
|
+
.replace(/>/g, ">")
|
|
39
|
+
.replace(/'/g, "'")
|
|
40
|
+
.replace(/'/g, "'");
|
|
41
|
+
}
|
|
42
|
+
function parseInitData(html) {
|
|
43
|
+
const m = html.match(/<input[^>]*id="init-data"[^>]*value="([^"]+)"/);
|
|
44
|
+
if (!m)
|
|
45
|
+
return null;
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(htmlDecode(m[1]));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function fetchPortalHtml(url) {
|
|
54
|
+
// Moka does a locale-cookie redirect dance: first request returns 302 +
|
|
55
|
+
// Set-Cookie; we capture them, then re-issue.
|
|
56
|
+
let response;
|
|
57
|
+
try {
|
|
58
|
+
response = await fetch(url, { method: "GET", headers: DEFAULT_HEADERS, redirect: "manual" });
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
return { ok: false, message: `network error: ${err instanceof Error ? err.message : err}` };
|
|
62
|
+
}
|
|
63
|
+
const cookies = [];
|
|
64
|
+
const headersAny = response.headers;
|
|
65
|
+
if (typeof headersAny.getSetCookie === "function") {
|
|
66
|
+
for (const v of headersAny.getSetCookie.call(response.headers) ?? []) {
|
|
67
|
+
const c = v.split(";")[0];
|
|
68
|
+
if (c)
|
|
69
|
+
cookies.push(c);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (cookies.length === 0) {
|
|
73
|
+
const raw = response.headers.get("set-cookie");
|
|
74
|
+
if (raw)
|
|
75
|
+
cookies.push(...raw.split(/,(?=[^;]+=)/).map((c) => c.split(";")[0].trim()));
|
|
76
|
+
}
|
|
77
|
+
const cookieHeader = cookies.join("; ");
|
|
78
|
+
let r2;
|
|
79
|
+
try {
|
|
80
|
+
r2 = await fetch(url, {
|
|
81
|
+
method: "GET",
|
|
82
|
+
headers: { ...DEFAULT_HEADERS, Cookie: cookieHeader },
|
|
83
|
+
redirect: "follow",
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
return { ok: false, message: `network error: ${err instanceof Error ? err.message : err}` };
|
|
88
|
+
}
|
|
89
|
+
if (!r2.ok)
|
|
90
|
+
return { ok: false, message: `HTTP ${r2.status}` };
|
|
91
|
+
const html = await r2.text();
|
|
92
|
+
return { ok: true, html, cookieHeader, message: "ok" };
|
|
93
|
+
}
|
|
94
|
+
function decryptMokaEnvelope(envelope, aesIv) {
|
|
95
|
+
if (!envelope.data || !envelope.necromancer)
|
|
96
|
+
return null;
|
|
97
|
+
try {
|
|
98
|
+
const key = Buffer.from(envelope.necromancer, "utf8");
|
|
99
|
+
const iv = Buffer.from(aesIv, "utf8");
|
|
100
|
+
const decipher = createDecipheriv("aes-128-cbc", key, iv);
|
|
101
|
+
const plain = Buffer.concat([
|
|
102
|
+
decipher.update(Buffer.from(envelope.data, "base64")),
|
|
103
|
+
decipher.final(),
|
|
104
|
+
]);
|
|
105
|
+
return JSON.parse(plain.toString("utf8"));
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async function fetchEncryptedPage(orgSlug, siteId, pageNum, pageSize, aesIv, cookieHeader, portalUrl) {
|
|
112
|
+
const url = `${API_ENDPOINT}?orgId=${encodeURIComponent(orgSlug)}`;
|
|
113
|
+
const body = {
|
|
114
|
+
orgId: orgSlug,
|
|
115
|
+
siteId: String(siteId),
|
|
116
|
+
pageNum,
|
|
117
|
+
pageSize,
|
|
118
|
+
needStat: true,
|
|
119
|
+
};
|
|
120
|
+
let response;
|
|
121
|
+
try {
|
|
122
|
+
response = await fetch(url, {
|
|
123
|
+
method: "POST",
|
|
124
|
+
headers: {
|
|
125
|
+
...DEFAULT_HEADERS,
|
|
126
|
+
Accept: "application/json,*/*",
|
|
127
|
+
"Content-Type": "application/json",
|
|
128
|
+
Origin: "https://app.mokahr.com",
|
|
129
|
+
Referer: portalUrl,
|
|
130
|
+
Cookie: cookieHeader,
|
|
131
|
+
},
|
|
132
|
+
body: JSON.stringify(body),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
return { ok: false, message: `network error: ${err instanceof Error ? err.message : err}` };
|
|
137
|
+
}
|
|
138
|
+
if (!response.ok)
|
|
139
|
+
return { ok: false, message: `HTTP ${response.status}` };
|
|
140
|
+
let envelope;
|
|
141
|
+
try {
|
|
142
|
+
envelope = await response.json();
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return { ok: false, message: "bad JSON from upstream" };
|
|
146
|
+
}
|
|
147
|
+
const decoded = decryptMokaEnvelope(envelope, aesIv);
|
|
148
|
+
if (!decoded || decoded.code !== 0 || !decoded.data) {
|
|
149
|
+
return { ok: false, message: decoded?.msg || envelope?.msg || "decrypt or upstream error" };
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
ok: true,
|
|
153
|
+
jobs: decoded.data.jobs ?? [],
|
|
154
|
+
total: decoded.data.jobStats?.total ?? 0,
|
|
155
|
+
message: "ok",
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
function buildCityMap(groups) {
|
|
159
|
+
const out = {};
|
|
160
|
+
if (!groups)
|
|
161
|
+
return out;
|
|
162
|
+
for (const g of groups) {
|
|
163
|
+
if (typeof g.cityId === "number" && g.label)
|
|
164
|
+
out[g.cityId] = g.label;
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
function workCitiesFor(job, cityMap) {
|
|
169
|
+
const cities = (job.locations ?? [])
|
|
170
|
+
.map((l) => {
|
|
171
|
+
if (typeof l.cityId === "number" && cityMap[l.cityId])
|
|
172
|
+
return cityMap[l.cityId];
|
|
173
|
+
return l.country || "";
|
|
174
|
+
})
|
|
175
|
+
.filter((s) => s.length > 0);
|
|
176
|
+
const uniq = [];
|
|
177
|
+
for (const c of cities)
|
|
178
|
+
if (!uniq.includes(c))
|
|
179
|
+
uniq.push(c);
|
|
180
|
+
return uniq.join(" / ");
|
|
181
|
+
}
|
|
182
|
+
function commitmentFor(job) {
|
|
183
|
+
if (typeof job.commitment === "string" && job.commitment.length > 0)
|
|
184
|
+
return job.commitment;
|
|
185
|
+
if (job.hireMode === 1)
|
|
186
|
+
return "全职";
|
|
187
|
+
if (job.hireMode === 2)
|
|
188
|
+
return "实习";
|
|
189
|
+
return "";
|
|
190
|
+
}
|
|
191
|
+
function matchesKeyword(job, kw) {
|
|
192
|
+
if (!kw)
|
|
193
|
+
return true;
|
|
194
|
+
const lc = kw.toLowerCase();
|
|
195
|
+
return ((job.title ?? "").toLowerCase().includes(lc) ||
|
|
196
|
+
(job.zhineng?.name ?? "").toLowerCase().includes(lc) ||
|
|
197
|
+
(job.department?.name ?? "").toLowerCase().includes(lc));
|
|
198
|
+
}
|
|
199
|
+
// ---------- createAdapter ----------
|
|
200
|
+
export function createAdapter(cfg) {
|
|
201
|
+
const SOURCE = `app.mokahr.com/${cfg.orgSlug}`;
|
|
202
|
+
const portalUrl = (ch) => `https://app.mokahr.com/${ch.kind}/${cfg.orgSlug}/${ch.siteId}`;
|
|
203
|
+
function pickChannel(recruitType) {
|
|
204
|
+
const want = recruitType ?? cfg.defaultRecruitType ?? "social";
|
|
205
|
+
return cfg.channels.find((c) => c.recruitType === want) ?? cfg.channels[0];
|
|
206
|
+
}
|
|
207
|
+
function summarize(job, cityMap, ch) {
|
|
208
|
+
return {
|
|
209
|
+
post_id: String(job.id),
|
|
210
|
+
title: job.title ?? "",
|
|
211
|
+
project: job.zhineng?.name ?? "",
|
|
212
|
+
recruit_label: commitmentFor(job),
|
|
213
|
+
bgs: job.department?.name ?? "",
|
|
214
|
+
work_cities: workCitiesFor(job, cityMap),
|
|
215
|
+
apply_url: `${portalUrl(ch)}#/jobs/${encodeURIComponent(job.id)}`,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
async function searchPositions(opts = {}) {
|
|
219
|
+
const ch = pickChannel(opts.recruitType);
|
|
220
|
+
const url = portalUrl(ch);
|
|
221
|
+
const pageSize = opts.pageSize ?? 20;
|
|
222
|
+
const page = opts.page ?? 1;
|
|
223
|
+
const keyword = opts.keyword ?? "";
|
|
224
|
+
const portal = await fetchPortalHtml(url);
|
|
225
|
+
if (!portal.ok || !portal.html) {
|
|
226
|
+
return {
|
|
227
|
+
ok: false,
|
|
228
|
+
source: SOURCE,
|
|
229
|
+
message: portal.message,
|
|
230
|
+
query: { recruitType: ch.recruitType, keyword, page, pageSize },
|
|
231
|
+
positions: [],
|
|
232
|
+
total: 0,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
const init = parseInitData(portal.html);
|
|
236
|
+
if (!init || !init.jobs || !init.jobStats) {
|
|
237
|
+
return {
|
|
238
|
+
ok: false,
|
|
239
|
+
source: SOURCE,
|
|
240
|
+
message: "Moka init-data missing jobs/jobStats",
|
|
241
|
+
query: { recruitType: ch.recruitType, keyword, page, pageSize },
|
|
242
|
+
positions: [],
|
|
243
|
+
total: 0,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
const cityMap = buildCityMap(init.jobsGroupedByLocation);
|
|
247
|
+
let jobs = init.jobs;
|
|
248
|
+
const total = init.jobStats.total ?? jobs.length;
|
|
249
|
+
if (page > 1 && init.aesIv && portal.cookieHeader) {
|
|
250
|
+
const more = await fetchEncryptedPage(cfg.orgSlug, ch.siteId, page, pageSize, init.aesIv, portal.cookieHeader, url);
|
|
251
|
+
if (!more.ok || !more.jobs) {
|
|
252
|
+
return {
|
|
253
|
+
ok: false,
|
|
254
|
+
source: SOURCE,
|
|
255
|
+
message: `pagination failed: ${more.message}`,
|
|
256
|
+
query: { recruitType: ch.recruitType, keyword, page, pageSize },
|
|
257
|
+
positions: [],
|
|
258
|
+
total,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
jobs = more.jobs;
|
|
262
|
+
}
|
|
263
|
+
const filtered = jobs.filter((j) => matchesKeyword(j, keyword));
|
|
264
|
+
const sliced = filtered.slice(0, pageSize);
|
|
265
|
+
return {
|
|
266
|
+
ok: true,
|
|
267
|
+
source: SOURCE,
|
|
268
|
+
query: { recruitType: ch.recruitType, keyword, page, pageSize },
|
|
269
|
+
page,
|
|
270
|
+
page_size: pageSize,
|
|
271
|
+
total,
|
|
272
|
+
positions: sliced.map((j) => summarize(j, cityMap, ch)),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
async function fetchAllPositions(opts = {}) {
|
|
276
|
+
const ch = pickChannel(opts.recruitType);
|
|
277
|
+
const url = portalUrl(ch);
|
|
278
|
+
const pageSize = opts.pageSize ?? 20;
|
|
279
|
+
const maxPages = Math.max(1, opts.maxPages ?? 50);
|
|
280
|
+
const keyword = opts.keyword ?? "";
|
|
281
|
+
const portal = await fetchPortalHtml(url);
|
|
282
|
+
if (!portal.ok || !portal.html) {
|
|
283
|
+
return {
|
|
284
|
+
ok: false,
|
|
285
|
+
source: SOURCE,
|
|
286
|
+
message: portal.message,
|
|
287
|
+
total: 0,
|
|
288
|
+
fetched: 0,
|
|
289
|
+
positions: [],
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
const init = parseInitData(portal.html);
|
|
293
|
+
if (!init || !init.jobs || !init.jobStats || !init.aesIv) {
|
|
294
|
+
return {
|
|
295
|
+
ok: false,
|
|
296
|
+
source: SOURCE,
|
|
297
|
+
message: "Moka init-data missing required fields",
|
|
298
|
+
total: 0,
|
|
299
|
+
fetched: 0,
|
|
300
|
+
positions: [],
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
const cityMap = buildCityMap(init.jobsGroupedByLocation);
|
|
304
|
+
const total = init.jobStats.total ?? 0;
|
|
305
|
+
const collected = [...init.jobs];
|
|
306
|
+
let page = 2;
|
|
307
|
+
while (collected.length < total && page <= maxPages) {
|
|
308
|
+
const more = await fetchEncryptedPage(cfg.orgSlug, ch.siteId, page, pageSize, init.aesIv, portal.cookieHeader ?? "", url);
|
|
309
|
+
if (!more.ok || !more.jobs || more.jobs.length === 0)
|
|
310
|
+
break;
|
|
311
|
+
collected.push(...more.jobs);
|
|
312
|
+
page += 1;
|
|
313
|
+
}
|
|
314
|
+
const filtered = collected.filter((j) => matchesKeyword(j, keyword));
|
|
315
|
+
return {
|
|
316
|
+
ok: true,
|
|
317
|
+
source: SOURCE,
|
|
318
|
+
total,
|
|
319
|
+
fetched: filtered.length,
|
|
320
|
+
positions: filtered.map((j) => summarize(j, cityMap, ch)),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
async function fetchPositionDetail(postId) {
|
|
324
|
+
const ch = pickChannel();
|
|
325
|
+
return {
|
|
326
|
+
ok: false,
|
|
327
|
+
source: SOURCE,
|
|
328
|
+
message: "Moka detail endpoint requires the same encrypted-session flow; not implemented. " +
|
|
329
|
+
"Use the apply_url deeplink for the full JD.",
|
|
330
|
+
post_id: postId,
|
|
331
|
+
apply_url: `${portalUrl(ch)}#/jobs/${encodeURIComponent(postId)}`,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
async function fetchDictionaries() {
|
|
335
|
+
const ch = pickChannel();
|
|
336
|
+
const url = portalUrl(ch);
|
|
337
|
+
const portal = await fetchPortalHtml(url);
|
|
338
|
+
if (!portal.ok || !portal.html) {
|
|
339
|
+
return { ok: false, source: SOURCE, message: portal.message };
|
|
340
|
+
}
|
|
341
|
+
const init = parseInitData(portal.html);
|
|
342
|
+
if (!init)
|
|
343
|
+
return { ok: false, source: SOURCE, message: "Moka init-data missing" };
|
|
344
|
+
return {
|
|
345
|
+
ok: true,
|
|
346
|
+
source: SOURCE,
|
|
347
|
+
locations: init.jobsGroupedByLocation ?? [],
|
|
348
|
+
moka_orgs: cfg.channels.map((c) => ({
|
|
349
|
+
slug: cfg.orgSlug,
|
|
350
|
+
id: c.siteId,
|
|
351
|
+
url: portalUrl(c),
|
|
352
|
+
recruitType: c.recruitType,
|
|
353
|
+
})),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
const NOTICES_MSG = `${cfg.label}: no public notices endpoint on Moka tenant`;
|
|
357
|
+
async function listNotices() {
|
|
358
|
+
return { ok: false, source: SOURCE, message: NOTICES_MSG, notices: [] };
|
|
359
|
+
}
|
|
360
|
+
async function getNotice(noticeId) {
|
|
361
|
+
return { ok: false, source: SOURCE, message: NOTICES_MSG, notice_id: noticeId };
|
|
362
|
+
}
|
|
363
|
+
async function findNoticesByQuestion(question, _opts = {}) {
|
|
364
|
+
return { ok: false, source: SOURCE, question, message: NOTICES_MSG, matches: [] };
|
|
365
|
+
}
|
|
366
|
+
async function matchResume(text, opts = {}) {
|
|
367
|
+
const { terms, cities } = extractResumeSignals(text ?? "");
|
|
368
|
+
const candidates = Math.max(20, opts.candidates ?? 100);
|
|
369
|
+
const search = await fetchAllPositions({
|
|
370
|
+
pageSize: 20,
|
|
371
|
+
maxPages: Math.ceil(candidates / 15),
|
|
372
|
+
});
|
|
373
|
+
if (!search.ok) {
|
|
374
|
+
return {
|
|
375
|
+
ok: false,
|
|
376
|
+
source: SOURCE,
|
|
377
|
+
extracted_terms: terms,
|
|
378
|
+
city_preferences: cities,
|
|
379
|
+
matches: [],
|
|
380
|
+
message: search.message,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
const topN = Math.max(1, opts.topN ?? 10);
|
|
384
|
+
const scored = search.positions
|
|
385
|
+
.map((p) => ({
|
|
386
|
+
p,
|
|
387
|
+
score: scoreOverlap(`${p.title} ${p.project} ${p.bgs}`, terms, cities).score,
|
|
388
|
+
}))
|
|
389
|
+
.sort((a, b) => b.score - a.score)
|
|
390
|
+
.slice(0, topN)
|
|
391
|
+
.map((x) => x.p);
|
|
392
|
+
return {
|
|
393
|
+
ok: true,
|
|
394
|
+
source: SOURCE,
|
|
395
|
+
extracted_terms: terms,
|
|
396
|
+
city_preferences: cities,
|
|
397
|
+
matches: scored,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
// ---------- Phase 2: fetchApplicationSchema ----------
|
|
401
|
+
//
|
|
402
|
+
// Moka apply endpoints discovered in
|
|
403
|
+
// static-ats.mokahr.com/recruitment-web-client/javascripts/recruitmentWeb-*.js
|
|
404
|
+
// (probed 2026-05-16, 4.2 MB bundle):
|
|
405
|
+
//
|
|
406
|
+
// GET /api/get_job_apply_form/?jobId=<uuid>&orgId=<slug>
|
|
407
|
+
// → returns the per-job questions array (subject to org config)
|
|
408
|
+
// POST /api/outer/ats-apply/website/applicant-limit-check
|
|
409
|
+
// → rate-limit / dedupe pre-flight
|
|
410
|
+
// POST /api/outer/ats-apply/website/getValidateConfig
|
|
411
|
+
// → returns whether SMS validation is required
|
|
412
|
+
// POST /api/outer/ats-apply/website/sendApplyValidateSmsCode
|
|
413
|
+
// → send the candidate's phone an SMS code
|
|
414
|
+
// POST /api/outer/ats-apply/website/apply
|
|
415
|
+
// → final submission. Body is AES-128-CBC encrypted with the
|
|
416
|
+
// per-response `necromancer` key + page-level aesIv (same
|
|
417
|
+
// envelope as our existing read-side cli/src/moka.ts decrypt).
|
|
418
|
+
//
|
|
419
|
+
// The whole flow requires the candidate to be logged in via Moka's
|
|
420
|
+
// candidate-portal (email + SMS verification). Cookies for that
|
|
421
|
+
// session are captured by the browser extension and dropped under
|
|
422
|
+
// ~/.jobpro/<adapter>.session.json — see docs/auto-apply.md.
|
|
423
|
+
async function fetchApplicationSchema(postId) {
|
|
424
|
+
const id = (postId ?? "").trim();
|
|
425
|
+
if (!id)
|
|
426
|
+
return { ok: false, source: SOURCE, message: "post_id is required" };
|
|
427
|
+
// Find the job title via our existing search infrastructure.
|
|
428
|
+
const r = await fetchPositionDetail(id);
|
|
429
|
+
const detailAny = r;
|
|
430
|
+
// Standard contact-info questions Moka tenants always require.
|
|
431
|
+
const questions = [
|
|
432
|
+
{ label: "Name", required: true, fields: [{ name: "name", type: "input_text" }] },
|
|
433
|
+
{ label: "Email", required: true, fields: [{ name: "email", type: "input_text" }] },
|
|
434
|
+
{ label: "Phone", required: true, fields: [{ name: "phone", type: "input_text" }] },
|
|
435
|
+
{ label: "Resume", required: true, fields: [{ name: "resume", type: "input_file" }] },
|
|
436
|
+
];
|
|
437
|
+
return {
|
|
438
|
+
ok: true,
|
|
439
|
+
schema: {
|
|
440
|
+
source: SOURCE,
|
|
441
|
+
post_id: id,
|
|
442
|
+
job_title: detailAny.title ?? "",
|
|
443
|
+
apply_url: `${portalUrl(pickChannel())}#/jobs/${encodeURIComponent(id)}`,
|
|
444
|
+
submit_endpoint: "https://app.mokahr.com/api/outer/ats-apply/website/apply",
|
|
445
|
+
submit_method: "POST",
|
|
446
|
+
submit_kind: "moka-aes",
|
|
447
|
+
endpoint_verified: true,
|
|
448
|
+
submit_notes: "Moka apply flow: GET /api/get_job_apply_form (questions) → " +
|
|
449
|
+
"POST /applicant-limit-check (rate-limit) → POST /getValidateConfig + " +
|
|
450
|
+
"/sendApplyValidateSmsCode (if SMS required) → POST /website/apply with " +
|
|
451
|
+
"AES-128-CBC envelope {data, necromancer} (same encryption as the read-side " +
|
|
452
|
+
"list endpoint). Endpoint URL anon-probed (returns the AES envelope rather " +
|
|
453
|
+
"than HTML fallthrough — confirms it's the real route, not a guess). " +
|
|
454
|
+
"Requires candidate session — capture via extension/, drop session.json " +
|
|
455
|
+
"under ~/.jobpro/.",
|
|
456
|
+
questions,
|
|
457
|
+
},
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
return {
|
|
461
|
+
searchPositions,
|
|
462
|
+
fetchAllPositions,
|
|
463
|
+
fetchPositionDetail,
|
|
464
|
+
fetchDictionaries,
|
|
465
|
+
listNotices,
|
|
466
|
+
getNotice,
|
|
467
|
+
findNoticesByQuestion,
|
|
468
|
+
matchResume,
|
|
469
|
+
checkResume,
|
|
470
|
+
fetchApplicationSchema,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
export { extractResumeSignals, scoreOverlap };
|
package/dist/moonshot.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Moonshot AI (月之暗面 / Kimi) careers — Moka SSR + AES-128-CBC.
|
|
2
|
+
//
|
|
3
|
+
// Portal: https://app.mokahr.com/social-recruitment/moonshot/148506
|
|
4
|
+
// Probed 2026-05; ~130 social-hire positions.
|
|
5
|
+
// See cli/src/moka.ts for the shared factory.
|
|
6
|
+
import { createAdapter } from "./moka.js";
|
|
7
|
+
const adapter = createAdapter({
|
|
8
|
+
orgSlug: "moonshot",
|
|
9
|
+
label: "Moonshot AI",
|
|
10
|
+
channels: [
|
|
11
|
+
{ siteId: 148506, kind: "social-recruitment", recruitType: "social" },
|
|
12
|
+
],
|
|
13
|
+
defaultRecruitType: "social",
|
|
14
|
+
});
|
|
15
|
+
export const searchPositions = adapter.searchPositions;
|
|
16
|
+
export const fetchAllPositions = adapter.fetchAllPositions;
|
|
17
|
+
export const fetchPositionDetail = adapter.fetchPositionDetail;
|
|
18
|
+
export const fetchDictionaries = adapter.fetchDictionaries;
|
|
19
|
+
export const listNotices = adapter.listNotices;
|
|
20
|
+
export const getNotice = adapter.getNotice;
|
|
21
|
+
export const findNoticesByQuestion = adapter.findNoticesByQuestion;
|
|
22
|
+
export const matchResume = adapter.matchResume;
|
|
23
|
+
export const checkResume = adapter.checkResume;
|
|
24
|
+
export const fetchApplicationSchema = adapter.fetchApplicationSchema;
|