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/adapter.js +17 -0
- package/dist/alibaba.js +2 -2
- package/dist/baichuan.js +38 -123
- package/dist/bilibili.js +43 -0
- package/dist/byd.js +333 -112
- package/dist/cambricon.js +379 -39
- package/dist/deepseek.js +373 -25
- package/dist/galaxyuniversal.js +375 -23
- package/dist/horizonrobotics.js +40 -61
- package/dist/iflytek.js +299 -57
- package/dist/index.js +55 -50
- package/dist/megvii.js +387 -150
- package/dist/mihoyo.js +241 -67
- package/dist/moonshot.js +387 -54
- package/dist/oppo.js +212 -56
- package/dist/pdd.js +278 -129
- package/dist/sensetime.js +47 -183
- package/dist/sf.js +249 -36
- package/dist/stepfun.js +333 -105
- package/dist/vivo.js +291 -41
- package/dist/wecruit.js +385 -0
- package/dist/weibo.js +259 -88
- package/dist/zerooneai.js +40 -37
- package/package.json +3 -2
package/dist/megvii.js
CHANGED
|
@@ -1,169 +1,395 @@
|
|
|
1
|
-
// Thin client for 旷视科技 / Megvii / Face++
|
|
1
|
+
// Thin client for 旷视科技 / Megvii / Face++ recruiting portal at app.mokahr.com.
|
|
2
2
|
//
|
|
3
3
|
// ============================================================
|
|
4
|
-
//
|
|
4
|
+
// HOW THIS WORKS (probed 2026-05):
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
6
|
+
// Moka social-recruitment SSR HTML at
|
|
7
|
+
// https://app.mokahr.com/social-recruitment/megviihr/38641
|
|
8
|
+
// embeds the entire first page of jobs INLINE in a hidden input
|
|
9
|
+
// `<input id="init-data" value="<HTML-escaped JSON>">`. The JSON
|
|
10
|
+
// shape is documented in the call helper below; the important keys are
|
|
11
|
+
// `jobs[]` (first 15 entries) and `jobStats.total` (full count).
|
|
8
12
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// no embedded JSON job data, no fetch/axios calls in HTML.
|
|
12
|
-
// Footer links point to /join_us/campus (校园招聘) and Moka.
|
|
13
|
+
// The same SSR HTML is also emitted for the campus portal at
|
|
14
|
+
// https://app.mokahr.com/campus_apply/megviihr/38642
|
|
13
15
|
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
16
|
+
// For deeper pagination the SPA POSTs to
|
|
17
|
+
// /api/outer/ats-apply/website/jobs/v2?orgId=megviihr
|
|
18
|
+
// with body { orgId, siteId, pageNum, pageSize, needStat:true } and
|
|
19
|
+
// receives an AES-CBC encrypted envelope {data, necromancer}. We
|
|
20
|
+
// decrypt using key=necromancer (raw utf8) and iv=aesIv (raw utf8,
|
|
21
|
+
// served in the SSR HTML as a constant — observed value is the
|
|
22
|
+
// same Moka-wide string across orgs).
|
|
17
23
|
//
|
|
18
|
-
// http://joinus.megvii.com
|
|
19
|
-
// → 302 → https://app.mokahr.com/campus_apply/megviihr/38642
|
|
20
|
-
// (Moka campus portal, orgSlug="megviihr", orgId=38642)
|
|
21
|
-
// The Moka SPA enters a redirect loop without a valid session cookie.
|
|
22
|
-
// Every route under /campus_apply/megviihr/38642 returns:
|
|
23
|
-
// init-data: {"message":"您访问的页面不存在",...}
|
|
24
|
-
//
|
|
25
|
-
// http://zhaopin.megvii.com
|
|
26
|
-
// → 302 → https://app.mokahr.com/social-recruitment/megviihr/38641
|
|
27
|
-
// (Moka social portal, orgSlug="megviihr", orgId=38641)
|
|
28
|
-
//
|
|
29
|
-
// ============================================================
|
|
30
|
-
// MOKA API PROBE RESULTS:
|
|
31
|
-
//
|
|
32
|
-
// All Moka REST API patterns tested (probed 2026-05):
|
|
33
|
-
//
|
|
34
|
-
// GET /api/campus/v1/organizations/megviihr/jobs?pageSize=N
|
|
35
|
-
// GET /api/campus/v1/organizations/megviihr/38642/jobs?pageSize=N
|
|
36
|
-
// GET /api/campus/v1/organizations/megviihr/38642/positions?pageSize=N
|
|
37
|
-
// GET /api/campus/v2/organizations/megviihr/positions?pageSize=N
|
|
38
|
-
// POST /api/campus/v1/jobs/search { orgId:"38642", ... }
|
|
39
|
-
// POST /api/campus/v1/organizations/megviihr/jobs/search
|
|
40
|
-
// GET /api/social/v1/organizations/megviihr/jobs?pageSize=N
|
|
41
|
-
//
|
|
42
|
-
// All return: HTTP 200, body { "message":"您访问的页面不存在","code":-1 }
|
|
43
|
-
//
|
|
44
|
-
// Root cause: Moka ATS requires an active applicant session (cookie-based)
|
|
45
|
-
// for ALL candidate-facing API calls. The session is obtained via:
|
|
46
|
-
// - WeChat OAuth (most common)
|
|
47
|
-
// - Phone OTP login
|
|
48
|
-
// There is no anonymous/public API surface on Moka for job listings.
|
|
49
|
-
// This is consistent with Moka's design as a closed ATS — unlike
|
|
50
|
-
// ByteDance (jobs.bytedance.com) or Tencent (join.qq.com) which expose
|
|
51
|
-
// purpose-built public portals with unauthenticated search APIs.
|
|
52
|
-
//
|
|
53
|
-
// ============================================================
|
|
54
24
|
// CONFIRMED MOKA ORG IDs:
|
|
25
|
+
// Campus (校园招聘): orgSlug=megviihr, siteId=38642
|
|
26
|
+
// URL: https://app.mokahr.com/campus_apply/megviihr/38642
|
|
27
|
+
// Social (社会招聘): orgSlug=megviihr, siteId=38641
|
|
28
|
+
// URL: https://app.mokahr.com/social-recruitment/megviihr/38641
|
|
55
29
|
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
// confirmed. Campus (38642) is a separate org on the same Moka tenant.
|
|
66
|
-
//
|
|
67
|
-
// ============================================================
|
|
68
|
-
// WHY THIS IS A STUB (unauthenticated access is impossible):
|
|
69
|
-
//
|
|
70
|
-
// Megvii outsources all recruiting to Moka ATS, which requires
|
|
71
|
-
// a valid applicant session for every API call. There is no
|
|
72
|
-
// anonymous-accessible job search API at any Megvii domain.
|
|
73
|
-
//
|
|
74
|
-
// Alternatives for job discovery:
|
|
75
|
-
// (a) Apply directly via https://app.mokahr.com/campus_apply/megviihr/38642
|
|
76
|
-
// (requires WeChat login)
|
|
77
|
-
// (b) Monitor third-party boards: 牛客网, 实习僧, boss直聘 for Megvii listings
|
|
78
|
-
// (c) Watch for a future public API migration (Feishu Recruiting / custom portal)
|
|
79
|
-
//
|
|
80
|
-
// ============================================================
|
|
81
|
-
// STUB CONTRACT: All functions return ok:false with STUB_MESSAGE.
|
|
82
|
-
// checkResume is re-exported from tencent.ts (works offline on resume text).
|
|
83
|
-
// When/if Megvii opens a public API, rewrite this file — the export shape
|
|
84
|
-
// is already locked by the PositionSummary interface below.
|
|
85
|
-
//
|
|
86
|
-
// ---- PositionSummary field mapping (Moka → canonical) ----
|
|
87
|
-
// post_id ← job.id (Moka internal job ID)
|
|
88
|
-
// title ← job.name (职位名称)
|
|
89
|
-
// project ← job.departmentName or job.categoryName (部门/职类)
|
|
90
|
-
// recruit_label ← job.recruitTypeName (校园招聘 / 社会招聘 / 实习)
|
|
91
|
-
// bgs ← "" (Moka does not expose BG/事业群 in public search)
|
|
92
|
-
// work_cities ← job.cities joined with " / "
|
|
93
|
-
// apply_url ← https://app.mokahr.com/campus_apply/megviihr/38642#/jobs/{id}
|
|
30
|
+
// PositionSummary field mapping (Moka raw → canonical):
|
|
31
|
+
// post_id ← job.id (UUID, used as positionId in detail deeplink)
|
|
32
|
+
// title ← job.title
|
|
33
|
+
// project ← job.zhineng?.name (职位类别, e.g. "算法类", "职能类")
|
|
34
|
+
// recruit_label ← job.commitment || hireMode-derived label
|
|
35
|
+
// bgs ← job.department?.name (部门)
|
|
36
|
+
// work_cities ← job.locations[].cityId resolved via jobsGroupedByLocation
|
|
37
|
+
// (concatenated with " / "); falls back to job.location.country
|
|
38
|
+
// apply_url ← portal URL + "#/jobs/{id}"
|
|
94
39
|
import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
"
|
|
40
|
+
import { createDecipheriv } from "node:crypto";
|
|
41
|
+
export { checkResume, extractResumeSignals, scoreOverlap };
|
|
42
|
+
const SOURCE = "app.mokahr.com/megviihr";
|
|
43
|
+
const ORG_SLUG = "megviihr";
|
|
44
|
+
const CAMPUS_SITE_ID = 38642;
|
|
45
|
+
const SOCIAL_SITE_ID = 38641;
|
|
46
|
+
const CAMPUS_URL = `https://app.mokahr.com/campus_apply/${ORG_SLUG}/${CAMPUS_SITE_ID}`;
|
|
47
|
+
const SOCIAL_URL = `https://app.mokahr.com/social-recruitment/${ORG_SLUG}/${SOCIAL_SITE_ID}`;
|
|
48
|
+
const API_ENDPOINT = "https://app.mokahr.com/api/outer/ats-apply/website/jobs/v2";
|
|
49
|
+
const DEFAULT_HEADERS = {
|
|
50
|
+
"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",
|
|
51
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
52
|
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
53
|
+
};
|
|
54
|
+
// ---- helpers ----
|
|
55
|
+
/** HTML-decode " / & / < / > / ' */
|
|
56
|
+
function htmlDecode(s) {
|
|
57
|
+
return s
|
|
58
|
+
.replace(/"/g, '"')
|
|
59
|
+
.replace(/&/g, "&")
|
|
60
|
+
.replace(/</g, "<")
|
|
61
|
+
.replace(/>/g, ">")
|
|
62
|
+
.replace(/'/g, "'")
|
|
63
|
+
.replace(/'/g, "'");
|
|
64
|
+
}
|
|
65
|
+
/** Parse the init-data JSON blob out of Moka SSR HTML. */
|
|
66
|
+
function parseInitData(html) {
|
|
67
|
+
const m = html.match(/<input[^>]*id="init-data"[^>]*value="([^"]+)"/);
|
|
68
|
+
if (!m)
|
|
69
|
+
return null;
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(htmlDecode(m[1]));
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Fetch SSR HTML for a Moka portal URL with a fresh cookie jar in-memory. */
|
|
78
|
+
async function fetchPortalHtml(url) {
|
|
79
|
+
// Two-fetch dance: first request bounces with Set-Cookie + 302 to self;
|
|
80
|
+
// we capture cookies and re-issue with them attached.
|
|
81
|
+
let response;
|
|
82
|
+
try {
|
|
83
|
+
response = await fetch(url, { method: "GET", headers: DEFAULT_HEADERS, redirect: "manual" });
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
return { ok: false, message: `network error: ${err instanceof Error ? err.message : err}` };
|
|
87
|
+
}
|
|
88
|
+
const cookies = [];
|
|
89
|
+
// getSetCookie() must be called bound to the Headers object (Node undici brandCheck)
|
|
90
|
+
const headersAny = response.headers;
|
|
91
|
+
if (typeof headersAny.getSetCookie === "function") {
|
|
92
|
+
for (const v of headersAny.getSetCookie.call(response.headers) ?? []) {
|
|
93
|
+
const c = v.split(";")[0];
|
|
94
|
+
if (c)
|
|
95
|
+
cookies.push(c);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Some runtimes only expose combined header
|
|
99
|
+
if (cookies.length === 0) {
|
|
100
|
+
const raw = response.headers.get("set-cookie");
|
|
101
|
+
if (raw)
|
|
102
|
+
cookies.push(...raw.split(/,(?=[^;]+=)/).map((c) => c.split(";")[0].trim()));
|
|
103
|
+
}
|
|
104
|
+
const cookieHeader = cookies.join("; ");
|
|
105
|
+
// Now fetch with cookies (follow redirects automatically)
|
|
106
|
+
let r2;
|
|
107
|
+
try {
|
|
108
|
+
r2 = await fetch(url, {
|
|
109
|
+
method: "GET",
|
|
110
|
+
headers: { ...DEFAULT_HEADERS, Cookie: cookieHeader },
|
|
111
|
+
redirect: "follow",
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
return { ok: false, message: `network error: ${err instanceof Error ? err.message : err}` };
|
|
116
|
+
}
|
|
117
|
+
if (!r2.ok) {
|
|
118
|
+
return { ok: false, status: r2.status, message: `HTTP ${r2.status}` };
|
|
119
|
+
}
|
|
120
|
+
const html = await r2.text();
|
|
121
|
+
return { ok: true, html, cookieHeader, status: r2.status, message: "ok" };
|
|
122
|
+
}
|
|
123
|
+
/** AES-128-CBC decrypt of Moka encrypted job payload. */
|
|
124
|
+
function decryptMokaEnvelope(envelope, aesIv) {
|
|
125
|
+
if (!envelope.data || !envelope.necromancer)
|
|
126
|
+
return null;
|
|
127
|
+
try {
|
|
128
|
+
const key = Buffer.from(envelope.necromancer, "utf8");
|
|
129
|
+
const iv = Buffer.from(aesIv, "utf8");
|
|
130
|
+
const decipher = createDecipheriv("aes-128-cbc", key, iv);
|
|
131
|
+
const plain = Buffer.concat([
|
|
132
|
+
decipher.update(Buffer.from(envelope.data, "base64")),
|
|
133
|
+
decipher.final(),
|
|
134
|
+
]);
|
|
135
|
+
return JSON.parse(plain.toString("utf8"));
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** Fetch a deeper page via the encrypted POST endpoint. */
|
|
142
|
+
async function fetchEncryptedPage(orgSlug, siteId, pageNum, pageSize, aesIv, cookieHeader, portalUrl) {
|
|
143
|
+
const url = `${API_ENDPOINT}?orgId=${encodeURIComponent(orgSlug)}`;
|
|
144
|
+
const body = {
|
|
145
|
+
orgId: orgSlug,
|
|
146
|
+
siteId: String(siteId),
|
|
147
|
+
pageNum,
|
|
148
|
+
pageSize,
|
|
149
|
+
needStat: true,
|
|
150
|
+
};
|
|
151
|
+
let response;
|
|
152
|
+
try {
|
|
153
|
+
response = await fetch(url, {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: {
|
|
156
|
+
...DEFAULT_HEADERS,
|
|
157
|
+
Accept: "application/json,*/*",
|
|
158
|
+
"Content-Type": "application/json",
|
|
159
|
+
Origin: "https://app.mokahr.com",
|
|
160
|
+
Referer: portalUrl,
|
|
161
|
+
Cookie: cookieHeader,
|
|
162
|
+
},
|
|
163
|
+
body: JSON.stringify(body),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
return { ok: false, message: `network error: ${err instanceof Error ? err.message : err}` };
|
|
168
|
+
}
|
|
169
|
+
if (!response.ok)
|
|
170
|
+
return { ok: false, message: `HTTP ${response.status}` };
|
|
171
|
+
let envelope;
|
|
172
|
+
try {
|
|
173
|
+
envelope = await response.json();
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return { ok: false, message: "bad JSON from upstream" };
|
|
177
|
+
}
|
|
178
|
+
const decoded = decryptMokaEnvelope(envelope, aesIv);
|
|
179
|
+
if (!decoded || decoded.code !== 0 || !decoded.data) {
|
|
180
|
+
return { ok: false, message: decoded?.msg || envelope?.msg || "decrypt or upstream error" };
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
ok: true,
|
|
184
|
+
jobs: decoded.data.jobs ?? [],
|
|
185
|
+
total: decoded.data.jobStats?.total ?? 0,
|
|
186
|
+
message: "ok",
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/** Build cityId → city label map from jobsGroupedByLocation. */
|
|
190
|
+
function buildCityMap(groups) {
|
|
191
|
+
const out = {};
|
|
192
|
+
if (!groups)
|
|
193
|
+
return out;
|
|
194
|
+
for (const g of groups) {
|
|
195
|
+
if (typeof g.cityId === "number" && g.label)
|
|
196
|
+
out[g.cityId] = g.label;
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
function workCitiesFor(job, cityMap) {
|
|
201
|
+
const cities = (job.locations ?? [])
|
|
202
|
+
.map((l) => {
|
|
203
|
+
if (typeof l.cityId === "number" && cityMap[l.cityId])
|
|
204
|
+
return cityMap[l.cityId];
|
|
205
|
+
return l.country || "";
|
|
206
|
+
})
|
|
207
|
+
.filter((s) => s.length > 0);
|
|
208
|
+
const uniq = [];
|
|
209
|
+
for (const c of cities)
|
|
210
|
+
if (!uniq.includes(c))
|
|
211
|
+
uniq.push(c);
|
|
212
|
+
return uniq.join(" / ");
|
|
213
|
+
}
|
|
214
|
+
function commitmentFor(job) {
|
|
215
|
+
if (typeof job.commitment === "string" && job.commitment.length > 0)
|
|
216
|
+
return job.commitment;
|
|
217
|
+
if (job.hireMode === 1)
|
|
218
|
+
return "全职";
|
|
219
|
+
if (job.hireMode === 2)
|
|
220
|
+
return "实习";
|
|
221
|
+
return "";
|
|
222
|
+
}
|
|
223
|
+
function summarize(job, cityMap, portalUrl) {
|
|
224
|
+
return {
|
|
225
|
+
post_id: String(job.id),
|
|
226
|
+
title: job.title ?? "",
|
|
227
|
+
project: job.zhineng?.name ?? "",
|
|
228
|
+
recruit_label: commitmentFor(job),
|
|
229
|
+
bgs: job.department?.name ?? "",
|
|
230
|
+
work_cities: workCitiesFor(job, cityMap),
|
|
231
|
+
apply_url: `${portalUrl}#/jobs/${encodeURIComponent(job.id)}`,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
function matchesKeyword(job, kw) {
|
|
235
|
+
if (!kw)
|
|
236
|
+
return true;
|
|
237
|
+
const lc = kw.toLowerCase();
|
|
238
|
+
return ((job.title ?? "").toLowerCase().includes(lc) ||
|
|
239
|
+
(job.zhineng?.name ?? "").toLowerCase().includes(lc) ||
|
|
240
|
+
(job.department?.name ?? "").toLowerCase().includes(lc));
|
|
241
|
+
}
|
|
242
|
+
function portalUrlFor(recruitType) {
|
|
243
|
+
return recruitType === "campus" ? CAMPUS_URL : SOCIAL_URL;
|
|
244
|
+
}
|
|
245
|
+
function siteIdFor(recruitType) {
|
|
246
|
+
return recruitType === "campus" ? CAMPUS_SITE_ID : SOCIAL_SITE_ID;
|
|
247
|
+
}
|
|
106
248
|
// ---- searchPositions ----
|
|
107
|
-
export async function searchPositions(
|
|
108
|
-
const recruitType =
|
|
109
|
-
const
|
|
249
|
+
export async function searchPositions(opts = {}) {
|
|
250
|
+
const recruitType = opts.recruitType ?? "social";
|
|
251
|
+
const portalUrl = portalUrlFor(recruitType);
|
|
252
|
+
const pageSize = opts.pageSize ?? 20;
|
|
253
|
+
const page = opts.page ?? 1;
|
|
254
|
+
const keyword = opts.keyword ?? "";
|
|
255
|
+
const portal = await fetchPortalHtml(portalUrl);
|
|
256
|
+
if (!portal.ok || !portal.html) {
|
|
257
|
+
return {
|
|
258
|
+
ok: false,
|
|
259
|
+
source: SOURCE,
|
|
260
|
+
message: portal.message,
|
|
261
|
+
query: { recruitType, keyword, page, pageSize },
|
|
262
|
+
positions: [],
|
|
263
|
+
total: 0,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
const init = parseInitData(portal.html);
|
|
267
|
+
if (!init || !init.jobs || !init.jobStats) {
|
|
268
|
+
return {
|
|
269
|
+
ok: false,
|
|
270
|
+
source: SOURCE,
|
|
271
|
+
message: "Moka init-data missing jobs/jobStats",
|
|
272
|
+
query: { recruitType, keyword, page, pageSize },
|
|
273
|
+
positions: [],
|
|
274
|
+
total: 0,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
const cityMap = buildCityMap(init.jobsGroupedByLocation);
|
|
278
|
+
let jobs = init.jobs;
|
|
279
|
+
const total = init.jobStats.total ?? jobs.length;
|
|
280
|
+
// If caller requested page > 1, fetch via encrypted POST
|
|
281
|
+
if (page > 1 && init.aesIv && portal.cookieHeader) {
|
|
282
|
+
const more = await fetchEncryptedPage(ORG_SLUG, siteIdFor(recruitType), page, pageSize, init.aesIv, portal.cookieHeader, portalUrl);
|
|
283
|
+
if (!more.ok || !more.jobs) {
|
|
284
|
+
return {
|
|
285
|
+
ok: false,
|
|
286
|
+
source: SOURCE,
|
|
287
|
+
message: `pagination failed: ${more.message}`,
|
|
288
|
+
query: { recruitType, keyword, page, pageSize },
|
|
289
|
+
positions: [],
|
|
290
|
+
total,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
jobs = more.jobs;
|
|
294
|
+
}
|
|
295
|
+
// Client-side keyword filter — Moka server-side keyword on this endpoint
|
|
296
|
+
// is observed to be ignored on first-page SSR, so we filter locally.
|
|
297
|
+
const filtered = jobs.filter((j) => matchesKeyword(j, keyword));
|
|
298
|
+
const sliced = filtered.slice(0, pageSize);
|
|
299
|
+
const positions = sliced.map((j) => summarize(j, cityMap, portalUrl));
|
|
110
300
|
return {
|
|
111
|
-
ok:
|
|
301
|
+
ok: true,
|
|
112
302
|
source: SOURCE,
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
query: {
|
|
119
|
-
orgSlug: "megviihr",
|
|
120
|
-
orgId: recruitType === "social" ? 38641 : 38642,
|
|
121
|
-
recruitType,
|
|
122
|
-
pageSize: _opts.pageSize ?? 20,
|
|
123
|
-
pageIndex: _opts.page ?? 1,
|
|
124
|
-
...(_opts.keyword ? { keyword: _opts.keyword } : {}),
|
|
125
|
-
},
|
|
126
|
-
apply_url: applyUrl,
|
|
127
|
-
positions: [],
|
|
128
|
-
total: 0,
|
|
303
|
+
query: { recruitType, keyword, page, pageSize },
|
|
304
|
+
page,
|
|
305
|
+
page_size: pageSize,
|
|
306
|
+
total,
|
|
307
|
+
positions,
|
|
129
308
|
};
|
|
130
309
|
}
|
|
131
310
|
// ---- fetchAllPositions ----
|
|
132
|
-
export async function fetchAllPositions(
|
|
311
|
+
export async function fetchAllPositions(opts = {}) {
|
|
312
|
+
const recruitType = opts.recruitType ?? "social";
|
|
313
|
+
const portalUrl = portalUrlFor(recruitType);
|
|
314
|
+
const pageSize = opts.pageSize ?? 20;
|
|
315
|
+
const maxPages = Math.max(1, opts.maxPages ?? 50);
|
|
316
|
+
const keyword = opts.keyword ?? "";
|
|
317
|
+
const portal = await fetchPortalHtml(portalUrl);
|
|
318
|
+
if (!portal.ok || !portal.html) {
|
|
319
|
+
return {
|
|
320
|
+
ok: false,
|
|
321
|
+
source: SOURCE,
|
|
322
|
+
message: portal.message,
|
|
323
|
+
total: 0,
|
|
324
|
+
fetched: 0,
|
|
325
|
+
positions: [],
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
const init = parseInitData(portal.html);
|
|
329
|
+
if (!init || !init.jobs || !init.jobStats || !init.aesIv) {
|
|
330
|
+
return {
|
|
331
|
+
ok: false,
|
|
332
|
+
source: SOURCE,
|
|
333
|
+
message: "Moka init-data missing required fields",
|
|
334
|
+
total: 0,
|
|
335
|
+
fetched: 0,
|
|
336
|
+
positions: [],
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
const cityMap = buildCityMap(init.jobsGroupedByLocation);
|
|
340
|
+
const total = init.jobStats.total ?? 0;
|
|
341
|
+
const collected = [...init.jobs];
|
|
342
|
+
// Page 1 came from SSR; for subsequent pages use encrypted POST.
|
|
343
|
+
// SSR returns ~15 per page; we cap with maxPages * pageSize.
|
|
344
|
+
let page = 2;
|
|
345
|
+
while (collected.length < total && page <= maxPages) {
|
|
346
|
+
const more = await fetchEncryptedPage(ORG_SLUG, siteIdFor(recruitType), page, pageSize, init.aesIv, portal.cookieHeader ?? "", portalUrl);
|
|
347
|
+
if (!more.ok || !more.jobs || more.jobs.length === 0)
|
|
348
|
+
break;
|
|
349
|
+
collected.push(...more.jobs);
|
|
350
|
+
page += 1;
|
|
351
|
+
}
|
|
352
|
+
const filtered = collected.filter((j) => matchesKeyword(j, keyword));
|
|
133
353
|
return {
|
|
134
|
-
ok:
|
|
354
|
+
ok: true,
|
|
135
355
|
source: SOURCE,
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
positions: [],
|
|
356
|
+
total,
|
|
357
|
+
fetched: filtered.length,
|
|
358
|
+
positions: filtered.map((j) => summarize(j, cityMap, portalUrl)),
|
|
140
359
|
};
|
|
141
360
|
}
|
|
142
361
|
// ---- fetchPositionDetail ----
|
|
362
|
+
//
|
|
363
|
+
// The Moka detail endpoint /api/outer/ats-apply/website/job is also AES-encrypted
|
|
364
|
+
// and requires a fresh session cookie. For now we return the deeplink + a
|
|
365
|
+
// note — keeping the verb honest rather than fake-successful.
|
|
143
366
|
export async function fetchPositionDetail(postId) {
|
|
144
367
|
return {
|
|
145
368
|
ok: false,
|
|
146
369
|
source: SOURCE,
|
|
147
|
-
message:
|
|
370
|
+
message: "Moka detail endpoint /api/outer/ats-apply/website/job requires the same encrypted-session " +
|
|
371
|
+
"flow; not implemented in this adapter. Use the apply_url deeplink for the full JD.",
|
|
148
372
|
post_id: postId,
|
|
373
|
+
apply_url: `${SOCIAL_URL}#/jobs/${encodeURIComponent(postId)}`,
|
|
149
374
|
};
|
|
150
375
|
}
|
|
151
376
|
// ---- fetchDictionaries ----
|
|
152
|
-
//
|
|
153
|
-
// When Moka session is available, the campus org filter taxonomy would come from:
|
|
154
|
-
// GET https://app.mokahr.com/api/campus/v1/organizations/megviihr/38642/searchConfig
|
|
155
|
-
// (returns: departments, job types, cities, recruit types)
|
|
156
377
|
export async function fetchDictionaries() {
|
|
378
|
+
const portal = await fetchPortalHtml(SOCIAL_URL);
|
|
379
|
+
if (!portal.ok || !portal.html) {
|
|
380
|
+
return { ok: false, source: SOURCE, message: portal.message };
|
|
381
|
+
}
|
|
382
|
+
const init = parseInitData(portal.html);
|
|
383
|
+
if (!init) {
|
|
384
|
+
return { ok: false, source: SOURCE, message: "Moka init-data missing" };
|
|
385
|
+
}
|
|
157
386
|
return {
|
|
158
|
-
ok:
|
|
387
|
+
ok: true,
|
|
159
388
|
source: SOURCE,
|
|
160
|
-
|
|
161
|
-
note: "When Moka session is available: " +
|
|
162
|
-
"GET /api/campus/v1/organizations/megviihr/38642/searchConfig " +
|
|
163
|
-
"returns departments, job types, cities, recruit types.",
|
|
389
|
+
locations: init.jobsGroupedByLocation ?? [],
|
|
164
390
|
moka_orgs: {
|
|
165
|
-
campus: { slug:
|
|
166
|
-
social: { slug:
|
|
391
|
+
campus: { slug: ORG_SLUG, id: CAMPUS_SITE_ID, url: CAMPUS_URL },
|
|
392
|
+
social: { slug: ORG_SLUG, id: SOCIAL_SITE_ID, url: SOCIAL_URL },
|
|
167
393
|
},
|
|
168
394
|
};
|
|
169
395
|
}
|
|
@@ -194,26 +420,37 @@ export async function findNoticesByQuestion(question, _opts = {}) {
|
|
|
194
420
|
};
|
|
195
421
|
}
|
|
196
422
|
// ---- matchResume ----
|
|
197
|
-
|
|
198
|
-
// Because the position search API is inauthenticated-inaccessible via Moka,
|
|
199
|
-
// we cannot retrieve live listings to score against the resume.
|
|
200
|
-
// Return ok:false with the extracted signals so the caller can display
|
|
201
|
-
// what terms were parsed — useful for cross-referencing with other adapters.
|
|
202
|
-
export async function matchResume(text, _opts = {}) {
|
|
423
|
+
export async function matchResume(text, opts = {}) {
|
|
203
424
|
const { terms, cities } = extractResumeSignals(text ?? "");
|
|
425
|
+
const candidates = Math.max(20, opts.candidates ?? 100);
|
|
426
|
+
const search = await fetchAllPositions({
|
|
427
|
+
pageSize: 20,
|
|
428
|
+
maxPages: Math.ceil(candidates / 15),
|
|
429
|
+
});
|
|
430
|
+
if (!search.ok) {
|
|
431
|
+
return {
|
|
432
|
+
ok: false,
|
|
433
|
+
source: SOURCE,
|
|
434
|
+
extracted_terms: terms,
|
|
435
|
+
city_preferences: cities,
|
|
436
|
+
matches: [],
|
|
437
|
+
message: search.message,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
const topN = Math.max(1, opts.topN ?? 10);
|
|
441
|
+
const scored = search.positions
|
|
442
|
+
.map((p) => ({
|
|
443
|
+
p,
|
|
444
|
+
score: scoreOverlap(`${p.title} ${p.project} ${p.bgs}`, terms, cities).score,
|
|
445
|
+
}))
|
|
446
|
+
.sort((a, b) => b.score - a.score)
|
|
447
|
+
.slice(0, topN)
|
|
448
|
+
.map((x) => x.p);
|
|
204
449
|
return {
|
|
205
|
-
ok:
|
|
450
|
+
ok: true,
|
|
206
451
|
source: SOURCE,
|
|
207
452
|
extracted_terms: terms,
|
|
208
453
|
city_preferences: cities,
|
|
209
|
-
matches:
|
|
210
|
-
message: STUB_MESSAGE,
|
|
211
|
-
apply_url: CAMPUS_URL,
|
|
212
|
-
note: "Resume signals extracted successfully. " +
|
|
213
|
-
"To find matching Megvii roles, visit the campus portal directly (requires WeChat login).",
|
|
454
|
+
matches: scored,
|
|
214
455
|
};
|
|
215
456
|
}
|
|
216
|
-
// Explicitly re-export scoreOverlap so callers that import * from megvii get the full toolkit,
|
|
217
|
-
// consistent with bytedance.ts. The function is unused internally (no live search to score
|
|
218
|
-
// against), but keeping the export shape uniform avoids surprises when the adapter is upgraded.
|
|
219
|
-
export { scoreOverlap };
|