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/megvii.js CHANGED
@@ -1,169 +1,395 @@
1
- // Thin client for 旷视科技 / Megvii / Face++ campus-recruiting portal.
1
+ // Thin client for 旷视科技 / Megvii / Face++ recruiting portal at app.mokahr.com.
2
2
  //
3
3
  // ============================================================
4
- // RECONNAISSANCE RESULTS (probed 2026-05):
4
+ // HOW THIS WORKS (probed 2026-05):
5
5
  //
6
- // https://www.megvii.com/careers
7
- // → 302 redirect to https://www.megvii.com/ (marketing homepage)
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
- // https://www.megvii.com/join_us
10
- // → Reachable: SSR marketing page (~118 KB), no public job API,
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
- // https://hr.megvii.com/ → HTTP 404
15
- // https://careers.megvii.com/ → HTTP 404
16
- // https://campus.megvii.com/ → HTTP 404
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
- // Campus (校园招聘): orgSlug=megviihr, orgId=38642
57
- // Entry: http://joinus.megvii.com
58
- // https://app.mokahr.com/campus_apply/megviihr/38642
59
- //
60
- // Social (社会招聘): orgSlug=megviihr, orgId=38641
61
- // Entry: http://zhaopin.megvii.com
62
- // https://app.mokahr.com/social-recruitment/megviihr/38641
63
- //
64
- // Note: The task brief flagged orgId 38641 as "social hires only"
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
- export { checkResume };
96
- const SOURCE = "app.mokahr.com/campus_apply/megviihr/38642";
97
- const CAMPUS_URL = "https://app.mokahr.com/campus_apply/megviihr/38642";
98
- const SOCIAL_URL = "https://app.mokahr.com/social-recruitment/megviihr/38641";
99
- const STUB_MESSAGE = "Megvii (旷视科技): no public job API — all recruiting runs through Moka ATS " +
100
- "(campus orgId=38642 at app.mokahr.com/campus_apply/megviihr/38642, " +
101
- "social orgId=38641 at app.mokahr.com/social-recruitment/megviihr/38641). " +
102
- "Moka requires an active applicant session (WeChat OAuth / phone OTP) for every API call; " +
103
- "all unauthenticated API probes return {code:-1, message:'您访问的页面不存在'}. " +
104
- "hr.megvii.com / careers.megvii.com / campus.megvii.com are all HTTP 404. " +
105
- "Documented in cli/src/megvii.ts header.";
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 &quot; / &amp; / &lt; / &gt; / &#x27; */
56
+ function htmlDecode(s) {
57
+ return s
58
+ .replace(/&quot;/g, '"')
59
+ .replace(/&amp;/g, "&")
60
+ .replace(/&lt;/g, "<")
61
+ .replace(/&gt;/g, ">")
62
+ .replace(/&#x27;/g, "'")
63
+ .replace(/&#39;/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(_opts = {}) {
108
- const recruitType = _opts.recruitType ?? "campus";
109
- const applyUrl = recruitType === "social" ? SOCIAL_URL : CAMPUS_URL;
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: false,
301
+ ok: true,
112
302
  source: SOURCE,
113
- message: STUB_MESSAGE,
114
- // Expose the would-be Moka endpoint so callers can see what we'd target
115
- endpoint: recruitType === "social"
116
- ? `GET https://app.mokahr.com/api/social/v1/organizations/megviihr/jobs?pageSize=${_opts.pageSize ?? 20}&pageIndex=${_opts.page ?? 1}`
117
- : `GET https://app.mokahr.com/api/campus/v1/organizations/megviihr/jobs?pageSize=${_opts.pageSize ?? 20}&pageIndex=${_opts.page ?? 1}`,
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(_opts = {}) {
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: false,
354
+ ok: true,
135
355
  source: SOURCE,
136
- message: STUB_MESSAGE,
137
- total: 0,
138
- fetched: 0,
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: STUB_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: false,
387
+ ok: true,
159
388
  source: SOURCE,
160
- message: STUB_MESSAGE,
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: "megviihr", id: 38642, url: CAMPUS_URL },
166
- social: { slug: "megviihr", id: 38641, url: SOCIAL_URL },
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: false,
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 };