@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.
Files changed (68) hide show
  1. package/dist/adapter.js +17 -0
  2. package/dist/agibot.js +399 -0
  3. package/dist/alibaba.js +509 -0
  4. package/dist/antgroup.js +397 -0
  5. package/dist/apply.js +1373 -0
  6. package/dist/baichuan.js +49 -0
  7. package/dist/baidu.js +452 -0
  8. package/dist/bilibili.js +455 -0
  9. package/dist/byd.js +412 -0
  10. package/dist/bytedance.js +619 -0
  11. package/dist/cainiao.js +56 -0
  12. package/dist/cambricon.js +33 -0
  13. package/dist/cdp.js +237 -0
  14. package/dist/cicc.js +56 -0
  15. package/dist/coverage.js +60 -0
  16. package/dist/deepseek.js +25 -0
  17. package/dist/didi.js +381 -0
  18. package/dist/feishu.js +577 -0
  19. package/dist/galaxyuniversal.js +24 -0
  20. package/dist/geely.js +35 -0
  21. package/dist/greenhouse.js +432 -0
  22. package/dist/hikvision.js +58 -0
  23. package/dist/horizonrobotics.js +46 -0
  24. package/dist/hoyoverse.js +26 -0
  25. package/dist/huawei.js +537 -0
  26. package/dist/iflytek.js +380 -0
  27. package/dist/index.js +1828 -0
  28. package/dist/iqiyi.js +494 -0
  29. package/dist/jd.js +559 -0
  30. package/dist/kuaishou.js +496 -0
  31. package/dist/lever.js +455 -0
  32. package/dist/liauto.js +393 -0
  33. package/dist/liepin.js +357 -0
  34. package/dist/lilith.js +300 -0
  35. package/dist/megvii.js +27 -0
  36. package/dist/meituan.js +633 -0
  37. package/dist/memory.js +76 -0
  38. package/dist/mihoyo.js +308 -0
  39. package/dist/minimax.js +32 -0
  40. package/dist/moka.js +473 -0
  41. package/dist/moonshot.js +24 -0
  42. package/dist/netease.js +424 -0
  43. package/dist/nio.js +24 -0
  44. package/dist/oppo.js +285 -0
  45. package/dist/pdd.js +614 -0
  46. package/dist/pingan.js +493 -0
  47. package/dist/sensetime.js +51 -0
  48. package/dist/sf.js +310 -0
  49. package/dist/stepfun.js +24 -0
  50. package/dist/tencent.js +770 -0
  51. package/dist/trip.js +396 -0
  52. package/dist/unitree.js +418 -0
  53. package/dist/vivo.js +361 -0
  54. package/dist/webank.js +55 -0
  55. package/dist/wecruit.js +438 -0
  56. package/dist/weibo.js +337 -0
  57. package/dist/weride.js +29 -0
  58. package/dist/xiaohongshu.js +480 -0
  59. package/dist/xiaomi.js +529 -0
  60. package/dist/xpeng.js +34 -0
  61. package/dist/zerooneai.js +42 -0
  62. package/dist/zhipu.js +478 -0
  63. package/extension/README.md +79 -0
  64. package/extension/background.js +177 -0
  65. package/extension/manifest.json +55 -0
  66. package/extension/popup.html +37 -0
  67. package/extension/popup.js +54 -0
  68. package/package.json +61 -0
@@ -0,0 +1,438 @@
1
+ // Generic Beisen Wecruit (北森 招聘云) adapter factory.
2
+ //
3
+ // Beisen Wecruit is one of two Beisen recruitment products we hit:
4
+ // * Beisen iTalent — hosted on `<tenant>.zhiye.com` (covered by vivo.ts /
5
+ // iflytek.ts / oppo.ts; envelope { Code, Data, Count }).
6
+ // * Beisen Wecruit — multi-tenant on `wecruit.hotjob.cn` and customer-owned
7
+ // hosts like `hr.sensetime.com`, `careers.<co>.com`.
8
+ // This module.
9
+ //
10
+ // Wecruit's distinguishing path is `/wecruit/...` at the host root. The
11
+ // public SPA bundles at `/{SU…}/pb/<channel>.html` are red herrings —
12
+ // every POST to that prefix returns nginx `405 Not Allowed`. The actual
13
+ // XHR the SPA fires is:
14
+ //
15
+ // POST https://<host>/wecruit/positionInfo/listPosition/{SU…}
16
+ // ?iSaJAx=isAjax&request_locale=zh_CN&t=<unix-ms>
17
+ //
18
+ // Content-Type: application/x-www-form-urlencoded
19
+ // Body: isFrompb=true&recruitType=<1|2>&pageSize=15&currentPage=1
20
+ //
21
+ // (Yes, form-urlencoded — not JSON — even though the response is JSON.)
22
+ //
23
+ // Response envelope:
24
+ // { data:{ pageForm:{ totalPage, pageSize, pageData:[…], currentPage,
25
+ // dataCount }, positonNum },
26
+ // state:"200", type:"success" }
27
+ //
28
+ // recruitType encoding: 1 = 校园 (campus / 应届 / 实习), 2 = 社招 (experienced).
29
+ // Each tenant has separate `SU…` channel ids per recruit type. See:
30
+ // * `sensetime.ts` — social `SU60fa3bdabef57c1023fc1cbc`
31
+ // * `horizonrobotics.ts` — school `SU6409ef49bef57c635fd390a6`,
32
+ // social `SU64819a4f2f9d2433ba8b043a`
33
+ //
34
+ // Probed 2026-05-16. Apply URL deep-links to the SPA detail route at
35
+ // `/{SU…}/pb/<channel>.html#/postDetail?postId=<postId>`.
36
+ import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
37
+ export { checkResume };
38
+ // ---------- factory ----------
39
+ export function createAdapter(cfg) {
40
+ const SOURCE = cfg.host;
41
+ const SITE_ROOT = `https://${cfg.host}`;
42
+ const detailUrl = (channelId, pagePath, postId) => `${SITE_ROOT}/${encodeURIComponent(channelId)}/pb/${encodeURIComponent(pagePath)}.html#/postDetail?postId=${encodeURIComponent(postId)}`;
43
+ const HEADERS = (channelId, pagePath) => ({
44
+ "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",
45
+ Accept: "application/json, text/plain, */*",
46
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
47
+ "Content-Type": "application/x-www-form-urlencoded",
48
+ Origin: SITE_ROOT,
49
+ Referer: `${SITE_ROOT}/${channelId}/pb/${pagePath}.html`,
50
+ "X-Requested-With": "XMLHttpRequest",
51
+ });
52
+ function urlEncode(form) {
53
+ const parts = [];
54
+ for (const [k, v] of Object.entries(form)) {
55
+ if (v === undefined)
56
+ continue;
57
+ parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
58
+ }
59
+ return parts.join("&");
60
+ }
61
+ async function postChannel(channel, pageNum, pageSize, keyword) {
62
+ const ts = Date.now();
63
+ const url = `${SITE_ROOT}/wecruit/positionInfo/listPosition/${channel.channelId}?iSaJAx=isAjax&request_locale=zh_CN&t=${ts}`;
64
+ const recruitType = channel.recruitType === "social" ? 2 : 1;
65
+ const form = {
66
+ isFrompb: true,
67
+ recruitType,
68
+ pageSize,
69
+ currentPage: pageNum,
70
+ };
71
+ if (keyword)
72
+ form.postName = keyword;
73
+ let response;
74
+ try {
75
+ response = await fetch(url, {
76
+ method: "POST",
77
+ headers: HEADERS(channel.channelId, channel.pagePath),
78
+ body: urlEncode(form),
79
+ });
80
+ }
81
+ catch (err) {
82
+ return { ok: false, message: `network error: ${err instanceof Error ? err.message : String(err)}` };
83
+ }
84
+ if (!response.ok) {
85
+ return { ok: false, message: `HTTP ${response.status}: ${response.statusText}` };
86
+ }
87
+ let payload;
88
+ try {
89
+ payload = (await response.json());
90
+ }
91
+ catch (err) {
92
+ return { ok: false, message: `bad JSON: ${err instanceof Error ? err.message : err}` };
93
+ }
94
+ if (payload.state !== "200" || !payload.data) {
95
+ return { ok: false, message: payload.msg ?? `upstream state=${payload.state}` };
96
+ }
97
+ return { ok: true, pageForm: payload.data.pageForm, message: "ok" };
98
+ }
99
+ function summarize(item, channel) {
100
+ const id = String(item.postId ?? "");
101
+ const labelFromRecruitType = item.recruitmentType ?? (item.recruitType === 2 ? "社招" : item.recruitType === 1 ? "校园" : "");
102
+ return {
103
+ post_id: id,
104
+ title: (item.postName ?? "").trim(),
105
+ project: (item.postTypeName ?? "").trim(),
106
+ recruit_label: labelFromRecruitType,
107
+ bgs: (item.department ?? item.company ?? "").trim(),
108
+ work_cities: (item.workPlaceStr ?? "").trim(),
109
+ apply_url: id ? detailUrl(channel.channelId, channel.pagePath, id) : `${SITE_ROOT}/${channel.channelId}/pb/${channel.pagePath}.html`,
110
+ };
111
+ }
112
+ function channelsForType(t) {
113
+ if (!t || t === "all")
114
+ return cfg.channels;
115
+ return cfg.channels.filter((c) => c.recruitType === t);
116
+ }
117
+ async function searchPositions(opts = {}) {
118
+ const pageSize = Math.max(1, Math.min(50, opts.pageSize ?? 15));
119
+ const page = Math.max(1, opts.page ?? 1);
120
+ const keyword = (opts.keyword ?? "").trim().slice(0, 60);
121
+ const channels = channelsForType(opts.recruitType);
122
+ if (!channels.length) {
123
+ return {
124
+ ok: false,
125
+ source: SOURCE,
126
+ message: `no channels match recruitType=${opts.recruitType ?? "all"}`,
127
+ query: opts,
128
+ positions: [],
129
+ };
130
+ }
131
+ // For single-channel adapters this is one call. For multi-channel
132
+ // (campus+social) we round-robin: we ask each channel for the same
133
+ // page index and merge the resulting positions. Total reflects the
134
+ // sum across channels.
135
+ const positions = [];
136
+ let total = 0;
137
+ let lastMsg = "ok";
138
+ let anyOk = false;
139
+ for (const ch of channels) {
140
+ const r = await postChannel(ch, page, pageSize, keyword);
141
+ if (!r.ok || !r.pageForm) {
142
+ lastMsg = r.message;
143
+ continue;
144
+ }
145
+ anyOk = true;
146
+ total += (r.pageForm.dataCount ?? 0) || (r.pageForm.totalPage ?? 0) * (r.pageForm.pageSize ?? 0);
147
+ for (const p of r.pageForm.pageData ?? [])
148
+ positions.push(summarize(p, ch));
149
+ }
150
+ if (!anyOk) {
151
+ return {
152
+ ok: false,
153
+ source: SOURCE,
154
+ message: lastMsg,
155
+ query: opts,
156
+ positions,
157
+ };
158
+ }
159
+ return {
160
+ ok: true,
161
+ source: SOURCE,
162
+ query: opts,
163
+ page,
164
+ page_size: pageSize,
165
+ total,
166
+ positions,
167
+ };
168
+ }
169
+ async function fetchAllPositions(opts = {}) {
170
+ const pageSize = Math.max(1, Math.min(50, opts.pageSize ?? 15));
171
+ const maxPages = Math.max(1, opts.maxPages ?? 30);
172
+ const keyword = (opts.keyword ?? "").trim().slice(0, 60);
173
+ const channels = channelsForType(opts.recruitType);
174
+ const bucket = [];
175
+ let total = 0;
176
+ let lastMsg = "ok";
177
+ let anyOk = false;
178
+ for (const ch of channels) {
179
+ let chTotal;
180
+ for (let page = 1; page <= maxPages; page++) {
181
+ const r = await postChannel(ch, page, pageSize, keyword);
182
+ if (!r.ok || !r.pageForm) {
183
+ lastMsg = r.message;
184
+ break;
185
+ }
186
+ anyOk = true;
187
+ if (chTotal === undefined) {
188
+ chTotal = (r.pageForm.totalPage ?? 0) * (r.pageForm.pageSize ?? 0) || (r.pageForm.dataCount ?? 0);
189
+ total += chTotal;
190
+ }
191
+ const data = r.pageForm.pageData ?? [];
192
+ if (!data.length)
193
+ break;
194
+ for (const p of data)
195
+ bucket.push(summarize(p, ch));
196
+ if (page >= (r.pageForm.totalPage ?? 0))
197
+ break;
198
+ }
199
+ }
200
+ if (!anyOk) {
201
+ return {
202
+ ok: false,
203
+ source: SOURCE,
204
+ message: lastMsg,
205
+ total: 0,
206
+ fetched: bucket.length,
207
+ positions: bucket,
208
+ };
209
+ }
210
+ return {
211
+ ok: true,
212
+ source: SOURCE,
213
+ total,
214
+ fetched: bucket.length,
215
+ positions: bucket,
216
+ };
217
+ }
218
+ async function fetchPositionDetail(postId) {
219
+ const id = (postId ?? "").trim();
220
+ if (!id)
221
+ return { ok: false, source: SOURCE, message: "post_id is required" };
222
+ // Wecruit's listPosition includes description-light fields only.
223
+ // We scan pages until we find the post.
224
+ const pageSize = 50;
225
+ const maxPages = 20;
226
+ for (const ch of cfg.channels) {
227
+ for (let page = 1; page <= maxPages; page++) {
228
+ const r = await postChannel(ch, page, pageSize, "");
229
+ if (!r.ok || !r.pageForm)
230
+ break;
231
+ const found = (r.pageForm.pageData ?? []).find((p) => p.postId === id);
232
+ if (found) {
233
+ const summary = summarize(found, ch);
234
+ return {
235
+ ok: true,
236
+ source: SOURCE,
237
+ post_id: id,
238
+ title: found.postName ?? "",
239
+ project: summary.project,
240
+ recruit_label: summary.recruit_label,
241
+ company: found.company ?? "",
242
+ department: found.department ?? "",
243
+ work_cities: found.workPlaceStr ?? "",
244
+ recruit_num: found.recruitNumStr ?? "",
245
+ page_views: found.pageViews ?? 0,
246
+ publish_date: found.publishDate ?? found.publishFirstDate ?? "",
247
+ end_date: found.endDate ?? "",
248
+ apply_url: summary.apply_url,
249
+ };
250
+ }
251
+ if (page >= (r.pageForm.totalPage ?? 0))
252
+ break;
253
+ }
254
+ }
255
+ return {
256
+ ok: false,
257
+ source: SOURCE,
258
+ post_id: id,
259
+ message: `post ${id} not found across ${cfg.channels.length} channel(s)`,
260
+ };
261
+ }
262
+ // ---------- fetchDictionaries ----------
263
+ // Synthesize from one page per channel (postTypeName, workPlaceStr, etc.).
264
+ let _dictCache = null;
265
+ async function fetchDictionaries() {
266
+ if (_dictCache !== null)
267
+ return _dictCache;
268
+ const types = new Set();
269
+ const cities = new Set();
270
+ const companies = new Set();
271
+ const channelInfo = [];
272
+ let anyOk = false;
273
+ let lastMsg = "ok";
274
+ for (const ch of cfg.channels) {
275
+ const r = await postChannel(ch, 1, 50, "");
276
+ if (!r.ok || !r.pageForm) {
277
+ lastMsg = r.message;
278
+ continue;
279
+ }
280
+ anyOk = true;
281
+ const total = (r.pageForm.totalPage ?? 0) * (r.pageForm.pageSize ?? 0) || (r.pageForm.dataCount ?? 0);
282
+ channelInfo.push({
283
+ channelId: ch.channelId,
284
+ recruitType: ch.recruitType,
285
+ pagePath: ch.pagePath,
286
+ total,
287
+ });
288
+ for (const p of r.pageForm.pageData ?? []) {
289
+ if (p.postTypeName)
290
+ types.add(p.postTypeName);
291
+ if (p.workPlaceStr)
292
+ cities.add(p.workPlaceStr);
293
+ if (p.company)
294
+ companies.add(p.company);
295
+ }
296
+ }
297
+ if (!anyOk) {
298
+ const r = { ok: false, source: SOURCE, message: lastMsg };
299
+ _dictCache = r;
300
+ return r;
301
+ }
302
+ const result = {
303
+ ok: true,
304
+ source: SOURCE,
305
+ channels: channelInfo,
306
+ post_types: [...types].sort(),
307
+ cities: [...cities].sort(),
308
+ companies: [...companies].sort(),
309
+ };
310
+ _dictCache = result;
311
+ return result;
312
+ }
313
+ // ---------- notices (stub) ----------
314
+ const NOTICES_STUB = {
315
+ ok: false,
316
+ source: SOURCE,
317
+ message: `${cfg.label}: Wecruit tenants have no public notices endpoint`,
318
+ };
319
+ async function listNotices() {
320
+ return { ...NOTICES_STUB, notices: [] };
321
+ }
322
+ async function getNotice(noticeId) {
323
+ return { ...NOTICES_STUB, notice_id: noticeId };
324
+ }
325
+ async function findNoticesByQuestion(question, _opts = {}) {
326
+ return { ...NOTICES_STUB, question, matches: [] };
327
+ }
328
+ // ---------- matchResume ----------
329
+ async function matchResume(text, opts = {}) {
330
+ const topN = Math.max(1, opts.topN ?? 5);
331
+ const candidates = Math.max(topN, opts.candidates ?? 20);
332
+ const { terms, cities } = extractResumeSignals(text ?? "");
333
+ if (!terms.length) {
334
+ return {
335
+ ok: false,
336
+ source: SOURCE,
337
+ message: "could not extract any technical signals from the text",
338
+ preview: (text ?? "").slice(0, 120),
339
+ };
340
+ }
341
+ const keyword = terms.slice(0, 3).join(" ");
342
+ const list = await searchPositions({ keyword, page: 1, pageSize: 50 });
343
+ if (!list.ok) {
344
+ return { ok: false, source: SOURCE, message: list.message, positions: [] };
345
+ }
346
+ const scored = [];
347
+ for (const p of list.positions) {
348
+ const blob = [p.title, p.project, p.recruit_label, p.work_cities, p.bgs].join(" ");
349
+ const { score, reasons } = scoreOverlap(blob, terms, cities);
350
+ if (score > 0)
351
+ scored.push({ score, position: p, reasons });
352
+ }
353
+ scored.sort((a, b) => b.score - a.score);
354
+ let shortlist = scored.slice(0, Math.max(topN, candidates));
355
+ if (!shortlist.length) {
356
+ shortlist = list.positions.slice(0, candidates).map((position) => ({ score: 0, position, reasons: [] }));
357
+ }
358
+ const matches = shortlist.slice(0, topN).map((s) => {
359
+ const mr = s.reasons.length > 0
360
+ ? s.reasons.slice(0, 5)
361
+ : ["no specific keyword overlap — surfaced from initial keyword search"];
362
+ return { ...s.position, match_reasons: mr };
363
+ });
364
+ return {
365
+ ok: true,
366
+ source: SOURCE,
367
+ extracted_terms: terms,
368
+ city_preferences: cities,
369
+ matches,
370
+ note: "match_reasons surfaces overlapping keywords, not a probability of getting an interview. " +
371
+ "The only authority on selection is HR.",
372
+ };
373
+ }
374
+ // ---------- Phase 2: fetchApplicationSchema ----------
375
+ //
376
+ // Beisen Wecruit apply endpoints discovered in
377
+ // hr.sensetime.com/pb/js/vendor.js (probed 2026-05-16, 3.8 MB bundle):
378
+ //
379
+ // POST /wecruit/resume/upload/file/save/<channelId> — upload resume PDF/DOCX
380
+ // POST /wecruit/resume/info/add/<channelId> — create/update profile
381
+ // POST /wecruit/resume/info/get/<channelId> — read existing profile
382
+ // POST /wecruit/delivery/resume/<channelId> — final submission
383
+ //
384
+ // The candidate session is established by Wecruit's WeChat-OAuth or
385
+ // phone-OTP login at /pb/<channel>/login.html. Cookies for that session
386
+ // are captured by the browser extension and dropped under
387
+ // ~/.jobpro/<adapter>.session.json.
388
+ async function fetchApplicationSchema(postId) {
389
+ const id = (postId ?? "").trim();
390
+ if (!id)
391
+ return { ok: false, source: SOURCE, message: "post_id is required" };
392
+ const ch = cfg.channels[0];
393
+ if (!ch)
394
+ return { ok: false, source: SOURCE, message: "no channels configured" };
395
+ const detail = await fetchPositionDetail(id);
396
+ const detailAny = detail;
397
+ const questions = [
398
+ { label: "Name", required: true, fields: [{ name: "name", type: "input_text" }] },
399
+ { label: "Email", required: true, fields: [{ name: "email", type: "input_text" }] },
400
+ { label: "Phone", required: true, fields: [{ name: "phone", type: "input_text" }] },
401
+ { label: "Resume", required: true, fields: [{ name: "resume", type: "input_file" }] },
402
+ ];
403
+ return {
404
+ ok: true,
405
+ schema: {
406
+ source: SOURCE,
407
+ post_id: id,
408
+ job_title: detailAny.title ?? "",
409
+ apply_url: `${SITE_ROOT}/${encodeURIComponent(ch.channelId)}/pb/${ch.pagePath}.html`,
410
+ submit_endpoint: `${SITE_ROOT}/wecruit/delivery/resume/${encodeURIComponent(ch.channelId)}`,
411
+ submit_method: "POST",
412
+ submit_kind: "beisen-wecruit",
413
+ endpoint_verified: true,
414
+ submit_notes: "Beisen Wecruit apply flow: POST /wecruit/resume/upload/file/save/<SU> → " +
415
+ "POST /wecruit/resume/info/add/<SU> → POST /wecruit/delivery/resume/<SU> with " +
416
+ "{ post_id, resume_attachment_id, channel_id }. Endpoint verified by reading " +
417
+ "/pb/js/vendor.js (Beisen Wecruit's vendor bundle) which lists /delivery/resume/, " +
418
+ "/resume/info/add/, /resume/upload/file/save/ etc as quoted paths. Anon-probe with " +
419
+ "X-Requested-With:XMLHttpRequest header → HTTP 200 + {type:\"error\",state:\"809\"," +
420
+ "msg:\"您尚未登录...\"} = real auth gate (without that header, Nginx returns the SPA HTML). " +
421
+ "Requires candidate session (WeChat OAuth or phone OTP via /pb/<channel>/login.html).",
422
+ questions,
423
+ },
424
+ };
425
+ }
426
+ return {
427
+ searchPositions,
428
+ fetchAllPositions,
429
+ fetchPositionDetail,
430
+ fetchDictionaries,
431
+ listNotices,
432
+ getNotice,
433
+ findNoticesByQuestion,
434
+ matchResume,
435
+ checkResume,
436
+ fetchApplicationSchema,
437
+ };
438
+ }