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/cambricon.js CHANGED
@@ -1,64 +1,404 @@
1
- // 寒武纪 (Cambricon) — stub adapter for `job-pro`.
2
- //
3
- // STATUS: stub-only. Cambricon's careers domains do not resolve over public
4
- // DNS, and no third-party ATS tenant (Feishu, Moka, Greenhouse, Lever) is
5
- // provisioned for the company. Recruiting runs through internal channels.
1
+ // 寒武纪 (Cambricon) careers adapter Moka SSR + AES-128-CBC pagination.
6
2
  //
7
3
  // ============================================================
8
- // RECONNAISSANCE RESULTS (probed 2026-05):
9
- //
10
- // https://hr.cambricon.com — 000 (no public DNS / unreachable)
11
- // https://careers.cambricon.com — 000 (no public DNS / unreachable)
12
- // https://campus.cambricon.com — 000 (no public DNS / unreachable)
4
+ // API DISCOVERY (probed 2026-05-16)
13
5
  //
14
- // Feishu ATSX: cambricon.jobs.feishu.cn HTTP 400 (no portal)
15
- // Greenhouse: cambricon — HTTP 404 (no board)
16
- // Lever: cambricon — HTTP 404 (no posting)
17
- // Moka: app.mokahr.com/social-recruitment/cambricon → 302 (unprovisioned)
6
+ // www.cambricon.com (the corporate site) embeds links to Moka tenant URLs
7
+ // in its 加入我们 / careers section. Extracted slugs:
18
8
  //
19
- // Cambricon's official careers blurb on cambricon.com points to the
20
- // public WeChat 寒武纪招聘 official account, which posts openings as
21
- // articles and routes applications to internal HR contacts.
9
+ // /campus-recruitment/cambricon/44201 ← campus + intern (main entry)
10
+ // /recommendation-recruitment/cambricon/42452 (referral channel, overlaps)
11
+ // /recommendation-recruitment/cambricon/46261 (referral channel, overlaps)
22
12
  //
23
- // Conclusion: no unauthenticated public API. Visit the 寒武纪招聘 WeChat
24
- // official account, or send a resume to the careers email listed at
25
- // https://www.cambricon.com/.
13
+ // No /social-recruitment/cambricon/<siteId> URL is published Cambricon
14
+ // only opens 校招 / 实习 publicly through Moka. The campus SSR HTML embeds
15
+ // `<input id="init-data" value="{...}">` containing the full first page of
16
+ // jobs + aesIv for subsequent AES-CBC paginated calls. Same pattern as
17
+ // `cli/src/megvii.ts`; the heavy lifting (htmlDecode, parseInitData,
18
+ // fetchPortalHtml two-fetch cookie dance, decryptMokaEnvelope) is
19
+ // duplicated here for now — a shared `moka.ts` factory is worth refactoring
20
+ // to once we have 6+ Moka tenants (currently megvii/deepseek/galaxyuniversal/
21
+ // stepfun/moonshot/+cambricon = 6 → schedule for next pass).
26
22
  import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
27
- export { checkResume };
28
- const SOURCE = "cambricon.com";
29
- const STUB_MESSAGE = "Cambricon (寒武纪): hr / careers / campus.cambricon.com all fail to resolve over public DNS. " +
30
- "No Greenhouse / Lever / Feishu / Moka tenant provisioned. Recruiting runs through the " +
31
- "WeChat 寒武纪招聘 official account. No unauthenticated public API available.";
32
- export async function searchPositions(_opts = {}) {
33
- return { ok: false, source: SOURCE, message: STUB_MESSAGE, query: {}, positions: [] };
23
+ import { createDecipheriv } from "node:crypto";
24
+ export { checkResume, extractResumeSignals, scoreOverlap };
25
+ const SOURCE = "app.mokahr.com/cambricon";
26
+ const ORG_SLUG = "cambricon";
27
+ const CAMPUS_SITE_ID = 44201;
28
+ const CAMPUS_URL = `https://app.mokahr.com/campus-recruitment/${ORG_SLUG}/${CAMPUS_SITE_ID}`;
29
+ const API_ENDPOINT = "https://app.mokahr.com/api/outer/ats-apply/website/jobs/v2";
30
+ const DEFAULT_HEADERS = {
31
+ "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",
32
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
33
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
34
+ };
35
+ // ---- helpers (duplicated from megvii.ts — slated for moka.ts refactor) ----
36
+ function htmlDecode(s) {
37
+ return s
38
+ .replace(/&quot;/g, '"')
39
+ .replace(/&amp;/g, "&")
40
+ .replace(/&lt;/g, "<")
41
+ .replace(/&gt;/g, ">")
42
+ .replace(/&#x27;/g, "'")
43
+ .replace(/&#39;/g, "'");
44
+ }
45
+ function parseInitData(html) {
46
+ const m = html.match(/<input[^>]*id="init-data"[^>]*value="([^"]+)"/);
47
+ if (!m)
48
+ return null;
49
+ try {
50
+ return JSON.parse(htmlDecode(m[1]));
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
56
+ async function fetchPortalHtml(url) {
57
+ let response;
58
+ try {
59
+ response = await fetch(url, { method: "GET", headers: DEFAULT_HEADERS, redirect: "manual" });
60
+ }
61
+ catch (err) {
62
+ return { ok: false, message: `network error: ${err instanceof Error ? err.message : err}` };
63
+ }
64
+ const cookies = [];
65
+ const headersAny = response.headers;
66
+ if (typeof headersAny.getSetCookie === "function") {
67
+ for (const v of headersAny.getSetCookie.call(response.headers) ?? []) {
68
+ const c = v.split(";")[0];
69
+ if (c)
70
+ cookies.push(c);
71
+ }
72
+ }
73
+ if (cookies.length === 0) {
74
+ const raw = response.headers.get("set-cookie");
75
+ if (raw)
76
+ cookies.push(...raw.split(/,(?=[^;]+=)/).map((c) => c.split(";")[0].trim()));
77
+ }
78
+ const cookieHeader = cookies.join("; ");
79
+ let r2;
80
+ try {
81
+ r2 = await fetch(url, {
82
+ method: "GET",
83
+ headers: { ...DEFAULT_HEADERS, Cookie: cookieHeader },
84
+ redirect: "follow",
85
+ });
86
+ }
87
+ catch (err) {
88
+ return { ok: false, message: `network error: ${err instanceof Error ? err.message : err}` };
89
+ }
90
+ if (!r2.ok)
91
+ return { ok: false, status: r2.status, message: `HTTP ${r2.status}` };
92
+ const html = await r2.text();
93
+ return { ok: true, html, cookieHeader, status: r2.status, message: "ok" };
94
+ }
95
+ function decryptMokaEnvelope(envelope, aesIv) {
96
+ if (!envelope.data || !envelope.necromancer)
97
+ return null;
98
+ try {
99
+ const key = Buffer.from(envelope.necromancer, "utf8");
100
+ const iv = Buffer.from(aesIv, "utf8");
101
+ const decipher = createDecipheriv("aes-128-cbc", key, iv);
102
+ const plain = Buffer.concat([
103
+ decipher.update(Buffer.from(envelope.data, "base64")),
104
+ decipher.final(),
105
+ ]);
106
+ return JSON.parse(plain.toString("utf8"));
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ }
112
+ async function fetchEncryptedPage(pageNum, pageSize, aesIv, cookieHeader) {
113
+ const url = `${API_ENDPOINT}?orgId=${encodeURIComponent(ORG_SLUG)}`;
114
+ const body = {
115
+ orgId: ORG_SLUG,
116
+ siteId: String(CAMPUS_SITE_ID),
117
+ pageNum,
118
+ pageSize,
119
+ needStat: true,
120
+ };
121
+ let response;
122
+ try {
123
+ response = await fetch(url, {
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: CAMPUS_URL,
131
+ Cookie: cookieHeader,
132
+ },
133
+ body: JSON.stringify(body),
134
+ });
135
+ }
136
+ catch (err) {
137
+ return { ok: false, message: `network error: ${err instanceof Error ? err.message : err}` };
138
+ }
139
+ if (!response.ok)
140
+ return { ok: false, message: `HTTP ${response.status}` };
141
+ let envelope;
142
+ try {
143
+ envelope = await response.json();
144
+ }
145
+ catch {
146
+ return { ok: false, message: "bad JSON from upstream" };
147
+ }
148
+ const decoded = decryptMokaEnvelope(envelope, aesIv);
149
+ if (!decoded || decoded.code !== 0 || !decoded.data) {
150
+ return { ok: false, message: decoded?.msg || envelope?.msg || "decrypt or upstream error" };
151
+ }
152
+ return {
153
+ ok: true,
154
+ jobs: decoded.data.jobs ?? [],
155
+ total: decoded.data.jobStats?.total ?? 0,
156
+ message: "ok",
157
+ };
158
+ }
159
+ function buildCityMap(groups) {
160
+ const out = {};
161
+ if (!groups)
162
+ return out;
163
+ for (const g of groups) {
164
+ if (typeof g.cityId === "number" && g.label)
165
+ out[g.cityId] = g.label;
166
+ }
167
+ return out;
168
+ }
169
+ function workCitiesFor(job, cityMap) {
170
+ const cities = (job.locations ?? [])
171
+ .map((l) => {
172
+ if (typeof l.cityId === "number" && cityMap[l.cityId])
173
+ return cityMap[l.cityId];
174
+ return l.country || "";
175
+ })
176
+ .filter((s) => s.length > 0);
177
+ const uniq = [];
178
+ for (const c of cities)
179
+ if (!uniq.includes(c))
180
+ uniq.push(c);
181
+ return uniq.join(" / ");
182
+ }
183
+ function commitmentFor(job) {
184
+ if (typeof job.commitment === "string" && job.commitment.length > 0)
185
+ return job.commitment;
186
+ if (job.hireMode === 1)
187
+ return "全职";
188
+ if (job.hireMode === 2)
189
+ return "实习";
190
+ return "";
34
191
  }
35
- export async function fetchAllPositions(_opts = {}) {
36
- return { ok: false, source: SOURCE, message: STUB_MESSAGE, total: 0, fetched: 0, positions: [] };
192
+ function summarize(job, cityMap) {
193
+ return {
194
+ post_id: String(job.id),
195
+ title: job.title ?? "",
196
+ project: job.zhineng?.name ?? "",
197
+ recruit_label: commitmentFor(job),
198
+ bgs: job.department?.name ?? "",
199
+ work_cities: workCitiesFor(job, cityMap),
200
+ apply_url: `${CAMPUS_URL}#/jobs/${encodeURIComponent(job.id)}`,
201
+ };
202
+ }
203
+ function matchesKeyword(job, kw) {
204
+ if (!kw)
205
+ return true;
206
+ const lc = kw.toLowerCase();
207
+ return ((job.title ?? "").toLowerCase().includes(lc) ||
208
+ (job.zhineng?.name ?? "").toLowerCase().includes(lc) ||
209
+ (job.department?.name ?? "").toLowerCase().includes(lc));
210
+ }
211
+ // ---- searchPositions ----
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(CAMPUS_URL);
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));
256
+ const sliced = filtered.slice(0, pageSize);
257
+ const positions = sliced.map((j) => summarize(j, cityMap));
258
+ return {
259
+ ok: true,
260
+ source: SOURCE,
261
+ query: { keyword, page, pageSize },
262
+ page,
263
+ page_size: pageSize,
264
+ total,
265
+ positions,
266
+ };
267
+ }
268
+ // ---- fetchAllPositions ----
269
+ export async function fetchAllPositions(opts = {}) {
270
+ const pageSize = opts.pageSize ?? 20;
271
+ const maxPages = Math.max(1, opts.maxPages ?? 50);
272
+ const keyword = opts.keyword ?? "";
273
+ const portal = await fetchPortalHtml(CAMPUS_URL);
274
+ if (!portal.ok || !portal.html) {
275
+ return {
276
+ ok: false,
277
+ source: SOURCE,
278
+ message: portal.message,
279
+ total: 0,
280
+ fetched: 0,
281
+ positions: [],
282
+ };
283
+ }
284
+ const init = parseInitData(portal.html);
285
+ if (!init || !init.jobs || !init.jobStats || !init.aesIv) {
286
+ return {
287
+ ok: false,
288
+ source: SOURCE,
289
+ message: "Moka init-data missing required fields",
290
+ total: 0,
291
+ fetched: 0,
292
+ positions: [],
293
+ };
294
+ }
295
+ const cityMap = buildCityMap(init.jobsGroupedByLocation);
296
+ const total = init.jobStats.total ?? 0;
297
+ const collected = [...init.jobs];
298
+ let page = 2;
299
+ while (collected.length < total && page <= maxPages) {
300
+ const more = await fetchEncryptedPage(page, pageSize, init.aesIv, portal.cookieHeader ?? "");
301
+ if (!more.ok || !more.jobs || more.jobs.length === 0)
302
+ break;
303
+ collected.push(...more.jobs);
304
+ page += 1;
305
+ }
306
+ const filtered = collected.filter((j) => matchesKeyword(j, keyword));
307
+ return {
308
+ ok: true,
309
+ source: SOURCE,
310
+ total,
311
+ fetched: filtered.length,
312
+ positions: filtered.map((j) => summarize(j, cityMap)),
313
+ };
37
314
  }
315
+ // ---- fetchPositionDetail ----
38
316
  export async function fetchPositionDetail(postId) {
39
- return { ok: false, source: SOURCE, message: STUB_MESSAGE, post_id: postId };
317
+ return {
318
+ ok: false,
319
+ source: SOURCE,
320
+ message: "Moka detail endpoint requires the same encrypted-session flow; not implemented. " +
321
+ "Use the apply_url deeplink for the full JD.",
322
+ post_id: postId,
323
+ apply_url: `${CAMPUS_URL}#/jobs/${encodeURIComponent(postId)}`,
324
+ };
40
325
  }
326
+ // ---- fetchDictionaries ----
41
327
  export async function fetchDictionaries() {
42
- return { ok: false, source: SOURCE, message: STUB_MESSAGE };
328
+ const portal = await fetchPortalHtml(CAMPUS_URL);
329
+ if (!portal.ok || !portal.html) {
330
+ return { ok: false, source: SOURCE, message: portal.message };
331
+ }
332
+ const init = parseInitData(portal.html);
333
+ if (!init) {
334
+ return { ok: false, source: SOURCE, message: "Moka init-data missing" };
335
+ }
336
+ return {
337
+ ok: true,
338
+ source: SOURCE,
339
+ locations: init.jobsGroupedByLocation ?? [],
340
+ moka_org: { slug: ORG_SLUG, id: CAMPUS_SITE_ID, url: CAMPUS_URL },
341
+ };
43
342
  }
343
+ // ---- notices (no public endpoint) ----
344
+ const NOTICES_STUB_MSG = "Cambricon (寒武纪): no public notices endpoint on Moka tenant";
44
345
  export async function listNotices() {
45
- return { ok: false, source: SOURCE, message: STUB_MESSAGE, notices: [] };
346
+ return {
347
+ ok: false,
348
+ source: SOURCE,
349
+ message: NOTICES_STUB_MSG,
350
+ notices: [],
351
+ };
46
352
  }
47
353
  export async function getNotice(noticeId) {
48
- return { ok: false, source: SOURCE, message: STUB_MESSAGE, notice_id: noticeId };
354
+ return {
355
+ ok: false,
356
+ source: SOURCE,
357
+ message: NOTICES_STUB_MSG,
358
+ notice_id: noticeId,
359
+ };
49
360
  }
50
361
  export async function findNoticesByQuestion(question, _opts = {}) {
51
- return { ok: false, source: SOURCE, question, message: STUB_MESSAGE, matches: [] };
362
+ return {
363
+ ok: false,
364
+ source: SOURCE,
365
+ question,
366
+ message: NOTICES_STUB_MSG,
367
+ matches: [],
368
+ };
52
369
  }
53
- export async function matchResume(text, _opts = {}) {
370
+ // ---- matchResume ----
371
+ export async function matchResume(text, opts = {}) {
54
372
  const { terms, cities } = extractResumeSignals(text ?? "");
373
+ const candidates = Math.max(20, opts.candidates ?? 100);
374
+ const search = await fetchAllPositions({
375
+ pageSize: 20,
376
+ maxPages: Math.ceil(candidates / 15),
377
+ });
378
+ if (!search.ok) {
379
+ return {
380
+ ok: false,
381
+ source: SOURCE,
382
+ extracted_terms: terms,
383
+ city_preferences: cities,
384
+ matches: [],
385
+ message: search.message,
386
+ };
387
+ }
388
+ const topN = Math.max(1, opts.topN ?? 10);
389
+ const scored = search.positions
390
+ .map((p) => ({
391
+ p,
392
+ score: scoreOverlap(`${p.title} ${p.project} ${p.bgs}`, terms, cities).score,
393
+ }))
394
+ .sort((a, b) => b.score - a.score)
395
+ .slice(0, topN)
396
+ .map((x) => x.p);
55
397
  return {
56
- ok: false,
398
+ ok: true,
57
399
  source: SOURCE,
58
400
  extracted_terms: terms,
59
401
  city_preferences: cities,
60
- matches: [],
61
- message: STUB_MESSAGE,
402
+ matches: scored,
62
403
  };
63
404
  }
64
- export { extractResumeSignals, scoreOverlap };