@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
package/dist/feishu.js ADDED
@@ -0,0 +1,577 @@
1
+ // Generic Feishu Recruiting (ATSX) adapter factory.
2
+ //
3
+ // Feishu Recruiting (飞书招聘) is ByteDance's SaaS ATS platform. Multiple companies
4
+ // self-host it at dedicated subdomains:
5
+ //
6
+ // *.jobs.feishu.cn — standard Feishu subdomains (NIO, etc.)
7
+ // *.jobs.f.mioffice.cn — Xiaomi fork (not this adapter)
8
+ // {tenant}.jobs.feishu.cn/{companyId}/ — multi-tenant portals (MiniMax)
9
+ //
10
+ // API surface (identical across all hosts, verified 2026-05):
11
+ // POST https://<host>/api/v1/search/job/posts
12
+ // GET https://<host>/api/v1/config/job/filters/<channel>
13
+ //
14
+ // Portal scoping is controlled by two required headers:
15
+ // portal-channel: the channel slug ("campus", "internship", or company-path like "379481")
16
+ // website-path: same value as portal-channel
17
+ //
18
+ // For NIO (nio.jobs.feishu.cn):
19
+ // host = "nio.jobs.feishu.cn"
20
+ // channel = "campus"
21
+ // apply_url prefix = "https://nio.jobs.feishu.cn/campus/position"
22
+ //
23
+ // For MiniMax (vrfi1sk8a0.jobs.feishu.cn / company path 379481):
24
+ // host = "vrfi1sk8a0.jobs.feishu.cn"
25
+ // channel = "379481" ← company PATH is the portal-channel!
26
+ // apply_url prefix = "https://vrfi1sk8a0.jobs.feishu.cn/379481/position"
27
+ //
28
+ // ---- PositionSummary field mapping (Feishu → canonical) ----
29
+ // post_id ← String(item.id)
30
+ // title ← item.title
31
+ // project ← item.job_category.name (or job_function.name if category null)
32
+ // recruit_label ← item.recruit_type.name
33
+ // bgs ← "" (not exposed in public search)
34
+ // work_cities ← city_list joined " / " (city_info used as fallback)
35
+ // apply_url ← `${applyUrlPrefix}/${id}/detail`
36
+ //
37
+ // ---- Discovery notes (2026-05) ----
38
+ // - "site not exist" (-9000003) → wrong portal-channel header
39
+ // - 400 empty body → tenant subdomain not configured on Feishu backend
40
+ // - NIO: job_category is null; project comes from job_function.name
41
+ // - MiniMax: job_function is null; project comes from job_category.name
42
+ // - Both: city_info is null; city_list always populated
43
+ import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
44
+ export { checkResume };
45
+ // ---------- shared apply-schema helper (re-used by bespoke Feishu adapters) ----------
46
+ //
47
+ // xiaomi.ts / zhipu.ts / iqiyi.ts / agibot.ts / lilith.ts each predate the
48
+ // factory and have their own searchPositions implementations. To give them
49
+ // the same Phase-2 behaviour as factory-using adapters (nio / minimax /
50
+ // baichuan / zerooneai), each can call `buildFeishuApplySchema()` from
51
+ // its own fetchApplicationSchema function.
52
+ /**
53
+ * Wire fetchApplicationSchema for a bespoke Feishu adapter that doesn't use
54
+ * createAdapter. The callback `fetchTitle(id)` is the adapter's own
55
+ * fetchPositionDetail (or any function that returns `{ ok, title }`).
56
+ *
57
+ * Usage:
58
+ * export const fetchApplicationSchema = makeFeishuApplyFn({
59
+ * host: HOST, source: SOURCE, channel: CHANNEL,
60
+ * applyUrlPrefix: APPLY_PREFIX,
61
+ * fetchTitle: (id) => fetchPositionDetail(id),
62
+ * submitKind: "feishu-3-step", // override for lilith → "cdp-real-browser"
63
+ * });
64
+ */
65
+ export function makeFeishuApplyFn(opts) {
66
+ return async function fetchApplicationSchema(postId) {
67
+ const id = (postId ?? "").trim();
68
+ if (!id)
69
+ return { ok: false, source: opts.source, message: "post_id is required" };
70
+ let title = "";
71
+ try {
72
+ const detail = (await opts.fetchTitle(id));
73
+ if (detail?.ok === false) {
74
+ return { ok: false, source: opts.source, message: detail.message ?? "post not found" };
75
+ }
76
+ title = detail?.title ?? "";
77
+ }
78
+ catch {
79
+ // detail call failures aren't fatal for the schema — we can still
80
+ // return what we know.
81
+ }
82
+ const schema = buildFeishuApplySchema({
83
+ host: opts.host,
84
+ source: opts.source,
85
+ channel: opts.channel,
86
+ applyUrlPrefix: opts.applyUrlPrefix,
87
+ postId: id,
88
+ jobTitle: title,
89
+ });
90
+ if (opts.submitKind === "cdp-real-browser") {
91
+ schema.submit_kind = "cdp-real-browser";
92
+ schema.submit_notes =
93
+ "Lilith's Feishu tenant requires a runtime-minted `_signature` token. " +
94
+ "Submission must drive a real browser (puppeteer-core) — staged dry-run " +
95
+ "only for now.";
96
+ }
97
+ return { ok: true, schema };
98
+ };
99
+ }
100
+ export function buildFeishuApplySchema(args) {
101
+ const standard = [
102
+ { label: "Name", required: true, fields: [{ name: "name", type: "input_text" }] },
103
+ { label: "Email", required: true, fields: [{ name: "email", type: "input_text" }] },
104
+ { label: "Phone", required: true, fields: [{ name: "phone", type: "input_text" }] },
105
+ { label: "Resume", required: true, fields: [{ name: "resume", type: "input_file" }] },
106
+ ];
107
+ return {
108
+ source: args.source,
109
+ post_id: args.postId,
110
+ job_title: args.jobTitle,
111
+ apply_url: `${args.applyUrlPrefix}/${encodeURIComponent(args.postId)}/detail`,
112
+ submit_endpoint: `https://${args.host}/api/v1/user/applications`,
113
+ submit_method: "POST",
114
+ submit_kind: "feishu-3-step",
115
+ endpoint_verified: true,
116
+ submit_notes: "Feishu apply is a 3-step token flow: POST /api/v1/attachment/upload/tokens → " +
117
+ "PUT presigned URL on lf-package-cn.feishucdn.com → POST /api/v1/attachment/exchange/tokens → " +
118
+ "POST /api/v1/user/applications with { post_id, attachment_id, applicant_info }. " +
119
+ "Endpoint extracted from atsx-throne/hire-fe-prod/saas-career/4026.f23f1edc.js " +
120
+ "(/user/applications path) and anon-probed → HTTP 405 = real REST route in Feishu's " +
121
+ "routing table (method/csrf requirements differ from anon POST). Requires candidate " +
122
+ "session cookies (capture via extension/, drop under ~/.jobpro/<adapter>.session.json).",
123
+ questions: standard,
124
+ };
125
+ }
126
+ // ---------- createAdapter ----------
127
+ export function createAdapter(cfg) {
128
+ const API_ROOT = `https://${cfg.host}/api/v1`;
129
+ const source = cfg.host;
130
+ function makeHeaders() {
131
+ return {
132
+ "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",
133
+ Accept: "application/json, text/plain, */*",
134
+ "Content-Type": "application/json",
135
+ "portal-channel": cfg.channel,
136
+ "portal-platform": "pc",
137
+ "website-path": cfg.channel,
138
+ Referer: `https://${cfg.host}/${cfg.channel}/position`,
139
+ };
140
+ }
141
+ async function call(path, body) {
142
+ const url = `${API_ROOT}${path}`;
143
+ let response;
144
+ try {
145
+ response = await fetch(url, {
146
+ method: "POST",
147
+ headers: makeHeaders(),
148
+ body: JSON.stringify(body),
149
+ });
150
+ }
151
+ catch (err) {
152
+ return {
153
+ ok: false,
154
+ message: `network error: ${err instanceof Error ? err.message : String(err)}`,
155
+ };
156
+ }
157
+ if (!response.ok) {
158
+ return { ok: false, message: `HTTP ${response.status}: ${response.statusText}` };
159
+ }
160
+ let payload;
161
+ try {
162
+ payload = (await response.json());
163
+ }
164
+ catch (err) {
165
+ return { ok: false, message: `bad JSON: ${err instanceof Error ? err.message : err}` };
166
+ }
167
+ return {
168
+ ok: payload.code === 0,
169
+ data: payload.data,
170
+ message: payload.message || (payload.code === 0 ? "ok" : "upstream error"),
171
+ };
172
+ }
173
+ function summarizePosition(item) {
174
+ const id = String(item.id ?? "");
175
+ const cityList = item.city_list ?? [];
176
+ let work_cities;
177
+ if (cityList.length > 1) {
178
+ work_cities = cityList.map((c) => c.name ?? "").filter(Boolean).join(" / ");
179
+ }
180
+ else {
181
+ work_cities = cityList[0]?.name ?? item.city_info?.name ?? "";
182
+ }
183
+ // NIO: job_category null, job_function has the name.
184
+ // MiniMax: job_function null, job_category has the name.
185
+ const project = item.job_category?.name ??
186
+ item.job_function?.name ??
187
+ "";
188
+ return {
189
+ post_id: id,
190
+ title: item.title ?? "",
191
+ project,
192
+ recruit_label: item.recruit_type?.name ?? "",
193
+ bgs: "",
194
+ work_cities,
195
+ apply_url: id ? `${cfg.applyUrlPrefix}/${encodeURIComponent(id)}/detail` : `https://${cfg.host}/${cfg.channel}/position`,
196
+ };
197
+ }
198
+ const asStringList = (v) => {
199
+ if (v === undefined)
200
+ return undefined;
201
+ const arr = Array.isArray(v) ? v : [v];
202
+ return arr.map(String);
203
+ };
204
+ // ---------- searchPositions ----------
205
+ async function searchPositions(opts = {}) {
206
+ const pageSize = Math.max(1, Math.min(100, opts.pageSize ?? 20));
207
+ const page = Math.max(1, opts.page ?? 1);
208
+ const offset = (page - 1) * pageSize;
209
+ const keyword = (opts.keyword ?? "").trim().slice(0, 60);
210
+ const payload = {
211
+ keyword,
212
+ limit: pageSize,
213
+ offset,
214
+ portal_type: 3,
215
+ portal_entrance: 1,
216
+ language: "zh",
217
+ };
218
+ const recruitmentIdList = asStringList(opts.recruitmentIdList);
219
+ if (recruitmentIdList !== undefined && recruitmentIdList.length > 0) {
220
+ payload.recruitment_id_list = recruitmentIdList;
221
+ }
222
+ const jobCategoryIdList = asStringList(opts.jobCategoryIdList);
223
+ if (jobCategoryIdList?.length) {
224
+ payload.job_category_id_list = jobCategoryIdList;
225
+ }
226
+ const cityIdList = asStringList(opts.cityIdList);
227
+ if (cityIdList?.length) {
228
+ payload.location_code_list = cityIdList;
229
+ }
230
+ const subjectIdList = asStringList(opts.subjectIdList);
231
+ if (subjectIdList?.length) {
232
+ payload.subject_id_list = subjectIdList;
233
+ }
234
+ const response = await call("/search/job/posts", payload);
235
+ if (!response.ok || !response.data) {
236
+ return {
237
+ ok: false,
238
+ message: response.message,
239
+ source,
240
+ query: payload,
241
+ positions: [],
242
+ };
243
+ }
244
+ const rows = response.data.job_post_list ?? [];
245
+ return {
246
+ ok: true,
247
+ source,
248
+ query: payload,
249
+ page,
250
+ page_size: pageSize,
251
+ total: response.data.count ?? rows.length,
252
+ positions: rows.map(summarizePosition),
253
+ };
254
+ }
255
+ // ---------- fetchAllPositions ----------
256
+ async function fetchAllPositions(opts = {}) {
257
+ const pageSize = Math.max(1, Math.min(100, opts.pageSize ?? 100));
258
+ const maxPages = Math.max(1, opts.maxPages ?? 5);
259
+ const bucket = [];
260
+ let total;
261
+ for (let page = 1; page <= maxPages; page++) {
262
+ const result = await searchPositions({ ...opts, page, pageSize });
263
+ if (!result.ok) {
264
+ return {
265
+ ok: false,
266
+ message: result.message,
267
+ source,
268
+ fetched: bucket.length,
269
+ positions: bucket,
270
+ };
271
+ }
272
+ if (total === undefined)
273
+ total = result.total;
274
+ if (!result.positions.length)
275
+ break;
276
+ bucket.push(...result.positions);
277
+ if (total !== undefined && bucket.length >= total)
278
+ break;
279
+ }
280
+ return {
281
+ ok: true,
282
+ source,
283
+ total: total ?? bucket.length,
284
+ fetched: bucket.length,
285
+ positions: bucket,
286
+ };
287
+ }
288
+ // ---------- fetchPositionDetail ----------
289
+ // Feishu has no public per-post detail REST endpoint.
290
+ // Paginate search and filter by id.
291
+ async function fetchPositionDetail(postId) {
292
+ const id = (postId ?? "").trim();
293
+ if (!id)
294
+ return { ok: false, source, message: "post_id is required" };
295
+ const pageSize = 100;
296
+ const maxPages = 5;
297
+ for (let page = 1; page <= maxPages; page++) {
298
+ const offset = (page - 1) * pageSize;
299
+ const payload = {
300
+ keyword: "",
301
+ limit: pageSize,
302
+ offset,
303
+ portal_type: 3,
304
+ portal_entrance: 1,
305
+ language: "zh",
306
+ };
307
+ const response = await call("/search/job/posts", payload);
308
+ if (!response.ok || !response.data)
309
+ break;
310
+ const posts = response.data.job_post_list ?? [];
311
+ const found = posts.find((p) => String(p.id) === id);
312
+ if (found) {
313
+ const summary = summarizePosition(found);
314
+ return {
315
+ ok: true,
316
+ source,
317
+ post_id: id,
318
+ title: found.title ?? "",
319
+ direction: found.sub_title ?? "",
320
+ description: found.description ?? "",
321
+ requirements: found.requirement ?? "",
322
+ work_cities: found.city_list ?? (found.city_info ? [found.city_info] : []),
323
+ apply_url: summary.apply_url,
324
+ };
325
+ }
326
+ if (posts.length < pageSize)
327
+ break;
328
+ }
329
+ return {
330
+ ok: false,
331
+ source,
332
+ post_id: id,
333
+ message: `post ${id} not found in public search results (searched up to ${maxPages * 100} posts)`,
334
+ };
335
+ }
336
+ // ---------- fetchDictionaries ----------
337
+ let _filterCache = null;
338
+ async function fetchDictionaries() {
339
+ if (_filterCache !== null)
340
+ return _filterCache;
341
+ const url = `${API_ROOT}/config/job/filters/${cfg.channel}`;
342
+ let response;
343
+ try {
344
+ response = await fetch(url, { headers: makeHeaders() });
345
+ }
346
+ catch (err) {
347
+ const r = {
348
+ ok: false,
349
+ source,
350
+ message: `network error: ${err instanceof Error ? err.message : String(err)}`,
351
+ };
352
+ _filterCache = r;
353
+ return r;
354
+ }
355
+ if (!response.ok) {
356
+ const r = { ok: false, source, message: `HTTP ${response.status}` };
357
+ _filterCache = r;
358
+ return r;
359
+ }
360
+ let payload;
361
+ try {
362
+ payload = await response.json();
363
+ }
364
+ catch (err) {
365
+ const r = {
366
+ ok: false,
367
+ source,
368
+ message: `bad JSON: ${err instanceof Error ? err.message : String(err)}`,
369
+ };
370
+ _filterCache = r;
371
+ return r;
372
+ }
373
+ if (payload.code !== 0 || !payload.data) {
374
+ const r = {
375
+ ok: false,
376
+ source,
377
+ message: payload.message ?? "upstream error",
378
+ };
379
+ _filterCache = r;
380
+ return r;
381
+ }
382
+ const d = payload.data;
383
+ const jobCategories = (d.job_type_list ?? []).map((cat) => ({
384
+ id: cat.id ?? "",
385
+ name: cat.name ?? "",
386
+ en_name: cat.en_name ?? "",
387
+ depth: cat.depth ?? 1,
388
+ parent_id: cat.parent?.id ?? null,
389
+ }));
390
+ const cities = (d.city_list ?? []).map((c) => ({
391
+ code: c.code ?? "",
392
+ name: c.name ?? "",
393
+ en_name: c.en_name ?? "",
394
+ }));
395
+ const subjects = (d.job_subject_list ?? []).map((s) => ({
396
+ id: s.id ?? "",
397
+ name: s.name?.zh_cn ?? s.name?.i18n ?? "",
398
+ group: s.subject_group_info?.name ?? "",
399
+ }));
400
+ const recruitmentTypes = [
401
+ { id: "201", name: "正式" },
402
+ { id: "202", name: "实习" },
403
+ ];
404
+ const result = {
405
+ ok: true,
406
+ source,
407
+ jobCategories,
408
+ cities,
409
+ subjects,
410
+ recruitmentTypes,
411
+ };
412
+ _filterCache = result;
413
+ return result;
414
+ }
415
+ // ---------- stub notices ----------
416
+ const NOTICES_STUB = {
417
+ ok: false,
418
+ source,
419
+ message: `${cfg.label}: no public notices endpoint`,
420
+ };
421
+ async function listNotices() {
422
+ return NOTICES_STUB;
423
+ }
424
+ async function getNotice(_id) {
425
+ return { ok: false, source, message: `${cfg.label}: no public notices endpoint` };
426
+ }
427
+ async function findNoticesByQuestion(_question, _opts = {}) {
428
+ return { ok: false, source, message: `${cfg.label}: no public notices endpoint` };
429
+ }
430
+ // ---------- matchResume ----------
431
+ async function matchResume(text, opts = {}) {
432
+ const topN = Math.max(1, opts.topN ?? 5);
433
+ const candidates = Math.max(topN, opts.candidates ?? 20);
434
+ const { terms, cities } = extractResumeSignals(text ?? "");
435
+ if (!terms.length) {
436
+ return {
437
+ ok: false,
438
+ source,
439
+ message: "could not extract any technical signals from the text",
440
+ preview: (text ?? "").slice(0, 120),
441
+ };
442
+ }
443
+ const keyword = terms.slice(0, 3).join(" ");
444
+ const list = await searchPositions({ keyword, page: 1, pageSize: 100 });
445
+ if (!list.ok) {
446
+ return { ok: false, source, message: list.message, positions: [] };
447
+ }
448
+ const payload = {
449
+ keyword,
450
+ limit: 100,
451
+ offset: 0,
452
+ portal_type: 3,
453
+ portal_entrance: 1,
454
+ language: "zh",
455
+ };
456
+ const raw = await call("/search/job/posts", payload);
457
+ const rawPosts = raw.ok ? (raw.data?.job_post_list ?? []) : [];
458
+ const rawById = new Map();
459
+ for (const p of rawPosts) {
460
+ rawById.set(String(p.id ?? ""), p);
461
+ }
462
+ const scored = [];
463
+ for (const p of list.positions) {
464
+ const rp = rawById.get(p.post_id);
465
+ const blob = [
466
+ p.title,
467
+ p.project,
468
+ p.recruit_label,
469
+ p.work_cities,
470
+ rp?.description ?? "",
471
+ rp?.requirement ?? "",
472
+ ].join(" ");
473
+ const { score, reasons } = scoreOverlap(blob, terms, cities);
474
+ if (score > 0) {
475
+ scored.push({
476
+ score,
477
+ position: p,
478
+ reasons,
479
+ description: rp?.description,
480
+ requirements: rp?.requirement,
481
+ });
482
+ }
483
+ }
484
+ scored.sort((a, b) => b.score - a.score);
485
+ let shortlist = scored.slice(0, Math.max(topN, candidates));
486
+ if (!shortlist.length) {
487
+ shortlist = list.positions.slice(0, candidates).map((position) => ({
488
+ score: 0,
489
+ position,
490
+ reasons: [],
491
+ description: rawById.get(position.post_id)?.description,
492
+ requirements: rawById.get(position.post_id)?.requirement,
493
+ }));
494
+ }
495
+ const matches = shortlist.slice(0, topN).map((s) => {
496
+ const mr = s.reasons.length > 0
497
+ ? s.reasons.slice(0, 5)
498
+ : ["no specific keyword overlap — surfaced from initial keyword search"];
499
+ return {
500
+ ...s.position,
501
+ description: s.description,
502
+ requirements: s.requirements,
503
+ match_reasons: mr,
504
+ };
505
+ });
506
+ return {
507
+ ok: true,
508
+ source,
509
+ extracted_terms: terms,
510
+ city_preferences: cities,
511
+ matches,
512
+ note: "match_reasons surfaces overlapping keywords, not a probability of getting an interview. " +
513
+ "The only authority on selection is HR.",
514
+ };
515
+ }
516
+ // ---------- fetchApplicationSchema (Phase 2) ----------
517
+ //
518
+ // Feishu's apply funnel is a 3-step token flow, not a single multipart
519
+ // POST. Discovered via JS-bundle inspection of nio.jobs.feishu.cn
520
+ // (lf-package-cn.feishucdn.com/obj/atsx-throne/hire-fe-prod/portal/
521
+ // saas-career/static/js/*.js) — the routes baked into the bundle are:
522
+ //
523
+ // 1. POST {API_ROOT}/attachment/upload/tokens
524
+ // → returns short-lived presigned upload URL + attachment_id
525
+ // 2. PUT <presigned-URL on lf-package-cn.feishucdn.com>
526
+ // → uploads the resume PDF/DOCX bytes directly
527
+ // 3. POST {API_ROOT}/attachment/exchange/tokens
528
+ // → exchanges short-lived id for a permanent attachment_id
529
+ // 4. POST {API_ROOT}/user/delivery/check (pre-flight, optional)
530
+ // 5. POST {API_ROOT}/resume/apply
531
+ // body: { post_id, attachment_id, applicant_info: { name, email,
532
+ // phone, ... }, ... }
533
+ // → returns { code:0, data:{ application_id } } on success
534
+ //
535
+ // The whole flow requires the user to be logged in as a candidate; the
536
+ // session cookie set during login authorizes every call above. Capture
537
+ // via the browser extension (~/.jobpro/<co>.session.json), then a
538
+ // future iteration adds an `executeSubmission` hook that drives the
539
+ // 3-step flow with the captured cookies.
540
+ //
541
+ // For now `fetchApplicationSchema` returns the contact-info schema
542
+ // (sufficient for dry-run staging) plus `submit_kind: "feishu-3-step"`
543
+ // so the dispatcher refuses --really-submit with a useful pointer.
544
+ async function fetchApplicationSchema(postId) {
545
+ const id = (postId ?? "").trim();
546
+ if (!id)
547
+ return { ok: false, source, message: "post_id is required" };
548
+ const detail = await fetchPositionDetail(id);
549
+ const detailAny = detail;
550
+ if (!detailAny.ok) {
551
+ return { ok: false, source, message: detailAny.message ?? "post not found" };
552
+ }
553
+ return {
554
+ ok: true,
555
+ schema: buildFeishuApplySchema({
556
+ host: cfg.host,
557
+ source,
558
+ channel: cfg.channel,
559
+ applyUrlPrefix: cfg.applyUrlPrefix,
560
+ postId: id,
561
+ jobTitle: detailAny.title ?? "",
562
+ }),
563
+ };
564
+ }
565
+ return {
566
+ searchPositions,
567
+ fetchAllPositions,
568
+ fetchPositionDetail,
569
+ fetchDictionaries,
570
+ listNotices,
571
+ getNotice,
572
+ findNoticesByQuestion,
573
+ matchResume,
574
+ checkResume,
575
+ fetchApplicationSchema,
576
+ };
577
+ }
@@ -0,0 +1,24 @@
1
+ // 银河通用 / Galaxy Universal (Galbot) careers — Moka SSR + AES-128-CBC.
2
+ //
3
+ // Portal: https://app.mokahr.com/social-recruitment/yinhetongyong/165929
4
+ // Probed 2026-05; ~121 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: "yinhetongyong",
9
+ label: "Galaxy Universal",
10
+ channels: [
11
+ { siteId: 165929, 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;
package/dist/geely.js ADDED
@@ -0,0 +1,35 @@
1
+ // 吉利汽车 (Geely Auto) careers adapter — Moka SSR + AES-128-CBC pagination.
2
+ //
3
+ // ============================================================
4
+ // API DISCOVERY (probed 2026-05-16)
5
+ //
6
+ // `job.geely.com` is a CNAME that 302-redirects to a Moka tenant:
7
+ // https://app.mokahr.com/social-recruitment/geely/96123/
8
+ //
9
+ // (The `198.18.x` IP that `job.geely.com` resolves to is an Alibaba-Cloud
10
+ // front; the actual upstream is `app.mokahr.com`.) The SSR HTML at that
11
+ // URL embeds the standard Moka `<input id="init-data" value="…">` blob
12
+ // containing the first page of jobs + aesIv for AES-128-CBC pagination.
13
+ //
14
+ // Same factory as `cli/src/moka.ts` (used by megvii / cambricon / etc.).
15
+ // Only the social-recruitment channel is published publicly — no
16
+ // campus-recruitment URL is linked from the Geely corporate site.
17
+ import { createAdapter } from "./moka.js";
18
+ const adapter = createAdapter({
19
+ orgSlug: "geely",
20
+ label: "Geely",
21
+ channels: [
22
+ { siteId: 96123, kind: "social-recruitment", recruitType: "social" },
23
+ ],
24
+ defaultRecruitType: "social",
25
+ });
26
+ export const searchPositions = adapter.searchPositions;
27
+ export const fetchAllPositions = adapter.fetchAllPositions;
28
+ export const fetchPositionDetail = adapter.fetchPositionDetail;
29
+ export const fetchDictionaries = adapter.fetchDictionaries;
30
+ export const listNotices = adapter.listNotices;
31
+ export const getNotice = adapter.getNotice;
32
+ export const findNoticesByQuestion = adapter.findNoticesByQuestion;
33
+ export const matchResume = adapter.matchResume;
34
+ export const checkResume = adapter.checkResume;
35
+ export const fetchApplicationSchema = adapter.fetchApplicationSchema;