job-pro 0.9.5 â 0.9.7
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/apply.js +549 -0
- package/dist/index.js +39 -3
- package/package.json +1 -1
package/dist/apply.js
CHANGED
|
@@ -355,3 +355,552 @@ async function buildMultipartForm(staged) {
|
|
|
355
355
|
}
|
|
356
356
|
return fd;
|
|
357
357
|
}
|
|
358
|
+
/**
|
|
359
|
+
* Feishu Recruiting 3-step submission. Used by every ðĄ feishu-3-step
|
|
360
|
+
* adapter (xiaomi / nio / minimax / zhipu / iqiyi / agibot / zerooneai /
|
|
361
|
+
* baichuan, and moonshot when wired through the Feishu helper).
|
|
362
|
+
*
|
|
363
|
+
* Steps:
|
|
364
|
+
* 1. POST {host}/api/v1/attachment/upload/tokens
|
|
365
|
+
* body: { filename, file_size }
|
|
366
|
+
* â { code:0, data:{ upload_url, attachment_id, fields:{âĶ} } }
|
|
367
|
+
* 2. POST/PUT to data.upload_url (lf-package-cn.feishucdn.com or similar)
|
|
368
|
+
* multipart/form-data with fields[âĶ] + file bytes
|
|
369
|
+
* 3. POST {host}/api/v1/resume/apply
|
|
370
|
+
* body: { post_id, attachment_id, applicant_info:{ name, email, phone } }
|
|
371
|
+
* â { code:0, data:{ application_id } }
|
|
372
|
+
*
|
|
373
|
+
* Session.json must contain valid Feishu cookies (typically `_csrf_token`,
|
|
374
|
+
* `lark_oapi_session`, `passport_csrf_token`) for the host.
|
|
375
|
+
*/
|
|
376
|
+
export async function executeFeishu3Step(staged, session, target) {
|
|
377
|
+
if (!staged.submit_endpoint) {
|
|
378
|
+
return { ok: false, posted_to: "", message: "no submit_endpoint", steps: [] };
|
|
379
|
+
}
|
|
380
|
+
if (target.kind === "dry-run") {
|
|
381
|
+
return {
|
|
382
|
+
ok: false,
|
|
383
|
+
posted_to: "dry-run (no network)",
|
|
384
|
+
message: "dry-run requested â no HTTP call fired",
|
|
385
|
+
steps: [],
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
if (target.kind === "upstream" && !session) {
|
|
389
|
+
return {
|
|
390
|
+
ok: false,
|
|
391
|
+
posted_to: staged.submit_endpoint,
|
|
392
|
+
message: "executeFeishu3Step requires a captured session (~/.jobpro/<adapter>.session.json) " +
|
|
393
|
+
"â Feishu apply endpoints all gate on candidate-session cookies. Install extension/ " +
|
|
394
|
+
"in Chrome, log in to the careers site, click Export.",
|
|
395
|
+
steps: [],
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
const submitUrl = new URL(staged.submit_endpoint);
|
|
399
|
+
const host = submitUrl.host;
|
|
400
|
+
const apiRoot = `${submitUrl.protocol}//${host}/api/v1`;
|
|
401
|
+
const debug = target.kind === "debug";
|
|
402
|
+
// Resolve the resume file from staged fields.
|
|
403
|
+
const resumeField = staged.staged.find((f) => f.name === "resume");
|
|
404
|
+
if (!resumeField || !resumeField.value) {
|
|
405
|
+
return { ok: false, posted_to: "", message: "staged.resume missing", steps: [] };
|
|
406
|
+
}
|
|
407
|
+
let resumeBytes;
|
|
408
|
+
try {
|
|
409
|
+
resumeBytes = readFileSync(resumeField.value);
|
|
410
|
+
}
|
|
411
|
+
catch (err) {
|
|
412
|
+
return {
|
|
413
|
+
ok: false,
|
|
414
|
+
posted_to: "",
|
|
415
|
+
message: `could not read resume ${resumeField.value}: ${err instanceof Error ? err.message : err}`,
|
|
416
|
+
steps: [],
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
const filename = resumeField.value.split("/").pop() ?? "resume.pdf";
|
|
420
|
+
const fileSize = resumeBytes.length;
|
|
421
|
+
const steps = [];
|
|
422
|
+
const sessionHeaders = sessionHeaderBag(session, host);
|
|
423
|
+
// STEP 1 â upload tokens
|
|
424
|
+
const step1Url = debug ? target.url : `${apiRoot}/attachment/upload/tokens`;
|
|
425
|
+
let step1Resp;
|
|
426
|
+
try {
|
|
427
|
+
step1Resp = await fetch(step1Url, {
|
|
428
|
+
method: "POST",
|
|
429
|
+
headers: {
|
|
430
|
+
...sessionHeaders,
|
|
431
|
+
"Content-Type": "application/json",
|
|
432
|
+
Accept: "application/json, text/plain, */*",
|
|
433
|
+
},
|
|
434
|
+
body: JSON.stringify({ filename, file_size: fileSize }),
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
catch (err) {
|
|
438
|
+
steps.push({ step: "upload-tokens", url: step1Url, status: 0, ok: false, message: String(err) });
|
|
439
|
+
return { ok: false, posted_to: step1Url, message: "step 1 network error", steps };
|
|
440
|
+
}
|
|
441
|
+
const step1Body = await step1Resp.text();
|
|
442
|
+
steps.push({
|
|
443
|
+
step: "upload-tokens",
|
|
444
|
+
url: step1Url,
|
|
445
|
+
status: step1Resp.status,
|
|
446
|
+
ok: step1Resp.ok,
|
|
447
|
+
message: step1Body.slice(0, 200),
|
|
448
|
+
});
|
|
449
|
+
if (!step1Resp.ok) {
|
|
450
|
+
return { ok: false, posted_to: step1Url, status: step1Resp.status, message: "step 1 failed (upload tokens)", steps, response_preview: step1Body.slice(0, 4000) };
|
|
451
|
+
}
|
|
452
|
+
// In debug mode, we don't actually have a presigned URL â short-circuit.
|
|
453
|
+
if (debug) {
|
|
454
|
+
return {
|
|
455
|
+
ok: true,
|
|
456
|
+
posted_to: step1Url,
|
|
457
|
+
status: step1Resp.status,
|
|
458
|
+
message: "debug-submit-to: step 1 fired; steps 2+3 skipped (no real upload URL in echo response)",
|
|
459
|
+
steps,
|
|
460
|
+
response_preview: step1Body.slice(0, 4000),
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
let step1Parsed;
|
|
464
|
+
try {
|
|
465
|
+
step1Parsed = JSON.parse(step1Body);
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
return { ok: false, posted_to: step1Url, message: "step 1 returned non-JSON", steps };
|
|
469
|
+
}
|
|
470
|
+
if (step1Parsed.code !== 0 || !step1Parsed.data?.upload_url) {
|
|
471
|
+
return {
|
|
472
|
+
ok: false,
|
|
473
|
+
posted_to: step1Url,
|
|
474
|
+
message: `step 1 upstream error: ${step1Parsed.message ?? `code=${step1Parsed.code}`}`,
|
|
475
|
+
steps,
|
|
476
|
+
response_preview: step1Body.slice(0, 4000),
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
const { upload_url, attachment_id, fields } = step1Parsed.data;
|
|
480
|
+
// STEP 2 â upload resume to presigned URL
|
|
481
|
+
const uploadFd = new FormData();
|
|
482
|
+
for (const [k, v] of Object.entries(fields ?? {}))
|
|
483
|
+
uploadFd.append(k, v);
|
|
484
|
+
const FileCtor = globalThis.File;
|
|
485
|
+
const filePart = typeof FileCtor === "function"
|
|
486
|
+
? new FileCtor([new Uint8Array(resumeBytes)], filename, { type: "application/pdf" })
|
|
487
|
+
: new Blob([new Uint8Array(resumeBytes)], { type: "application/pdf" });
|
|
488
|
+
uploadFd.append("file", filePart, filename);
|
|
489
|
+
let step2Resp;
|
|
490
|
+
try {
|
|
491
|
+
step2Resp = await fetch(upload_url, { method: "POST", body: uploadFd });
|
|
492
|
+
}
|
|
493
|
+
catch (err) {
|
|
494
|
+
steps.push({ step: "upload-file", url: upload_url, status: 0, ok: false, message: String(err) });
|
|
495
|
+
return { ok: false, posted_to: upload_url, message: "step 2 network error", steps };
|
|
496
|
+
}
|
|
497
|
+
steps.push({
|
|
498
|
+
step: "upload-file",
|
|
499
|
+
url: upload_url,
|
|
500
|
+
status: step2Resp.status,
|
|
501
|
+
ok: step2Resp.ok,
|
|
502
|
+
message: `HTTP ${step2Resp.status}`,
|
|
503
|
+
});
|
|
504
|
+
if (!step2Resp.ok) {
|
|
505
|
+
return { ok: false, posted_to: upload_url, status: step2Resp.status, message: "step 2 failed (upload to CDN)", steps };
|
|
506
|
+
}
|
|
507
|
+
// STEP 3 â resume/apply
|
|
508
|
+
const applicantInfo = {};
|
|
509
|
+
for (const f of staged.staged) {
|
|
510
|
+
if (f.name === "name" || f.name === "email" || f.name === "phone") {
|
|
511
|
+
applicantInfo[f.name] = f.value;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
const step3Body = {
|
|
515
|
+
post_id: staged.post_id,
|
|
516
|
+
attachment_id,
|
|
517
|
+
applicant_info: applicantInfo,
|
|
518
|
+
};
|
|
519
|
+
const step3Url = `${apiRoot}/resume/apply`;
|
|
520
|
+
let step3Resp;
|
|
521
|
+
try {
|
|
522
|
+
step3Resp = await fetch(step3Url, {
|
|
523
|
+
method: "POST",
|
|
524
|
+
headers: {
|
|
525
|
+
...sessionHeaders,
|
|
526
|
+
"Content-Type": "application/json",
|
|
527
|
+
Accept: "application/json, text/plain, */*",
|
|
528
|
+
},
|
|
529
|
+
body: JSON.stringify(step3Body),
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
catch (err) {
|
|
533
|
+
steps.push({ step: "resume-apply", url: step3Url, status: 0, ok: false, message: String(err) });
|
|
534
|
+
return { ok: false, posted_to: step3Url, message: "step 3 network error", steps };
|
|
535
|
+
}
|
|
536
|
+
const step3Text = await step3Resp.text();
|
|
537
|
+
steps.push({
|
|
538
|
+
step: "resume-apply",
|
|
539
|
+
url: step3Url,
|
|
540
|
+
status: step3Resp.status,
|
|
541
|
+
ok: step3Resp.ok,
|
|
542
|
+
message: step3Text.slice(0, 200),
|
|
543
|
+
});
|
|
544
|
+
return {
|
|
545
|
+
ok: step3Resp.ok,
|
|
546
|
+
status: step3Resp.status,
|
|
547
|
+
posted_to: step3Url,
|
|
548
|
+
response_preview: step3Text.slice(0, 4000),
|
|
549
|
+
message: step3Resp.ok ? "Feishu 3-step submission accepted" : `step 3 rejected: HTTP ${step3Resp.status}`,
|
|
550
|
+
steps,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
/** Build the headers bag used by every Feishu/Beisen/Moka step. */
|
|
554
|
+
function sessionHeaderBag(session, targetHost) {
|
|
555
|
+
const out = {
|
|
556
|
+
"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",
|
|
557
|
+
};
|
|
558
|
+
if (!session)
|
|
559
|
+
return out;
|
|
560
|
+
const cookieHeader = serializeCookieHeader(session, targetHost);
|
|
561
|
+
if (cookieHeader)
|
|
562
|
+
out.Cookie = cookieHeader;
|
|
563
|
+
for (const [k, v] of Object.entries(session.headers ?? {})) {
|
|
564
|
+
if (k.toLowerCase() === "cookie" || k.toLowerCase() === "content-type")
|
|
565
|
+
continue;
|
|
566
|
+
out[k] = v;
|
|
567
|
+
}
|
|
568
|
+
return out;
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Moka (app.mokahr.com) â covers megvii / deepseek / galaxyuniversal /
|
|
572
|
+
* stepfun / moonshot / cambricon / geely.
|
|
573
|
+
*
|
|
574
|
+
* Flow (probed from recruitmentWeb-*.js, 2026-05-16):
|
|
575
|
+
* 1. POST /api/outer/ats-apply/website/applicant-limit-check
|
|
576
|
+
* body: { orgId, jobId, âĶ } (rate-limit / dup-check)
|
|
577
|
+
* 2. POST /api/get_job_apply_form/?jobId=&orgId= (already in schema)
|
|
578
|
+
* 3. (Optional) POST /api/outer/ats-apply/website/sendApplyValidateSmsCode
|
|
579
|
+
* â user receives an SMS code; we don't auto-fetch it.
|
|
580
|
+
* 4. POST /api/outer/ats-apply/website/apply
|
|
581
|
+
* body: { orgId, jobId, formData:{ name, email, phone }, resume:{âĶ} }
|
|
582
|
+
* Some tenants demand AES-128-CBC envelope on the body â we send
|
|
583
|
+
* plain JSON first and fall back to encryption only if the server
|
|
584
|
+
* returns the canonical Moka decryption error (code:-2003).
|
|
585
|
+
*
|
|
586
|
+
* The session.json must contain Moka's candidate-portal cookies (acw_tc,
|
|
587
|
+
* csrfCk, moka-apply, connect.sid + the org-specific session cookies).
|
|
588
|
+
*/
|
|
589
|
+
export async function executeMokaApply(staged, session, target) {
|
|
590
|
+
if (!staged.submit_endpoint)
|
|
591
|
+
return { ok: false, posted_to: "", message: "no submit_endpoint", steps: [] };
|
|
592
|
+
if (target.kind === "dry-run") {
|
|
593
|
+
return { ok: false, posted_to: "dry-run (no network)", message: "dry-run requested â no HTTP call fired", steps: [] };
|
|
594
|
+
}
|
|
595
|
+
if (target.kind === "upstream" && !session) {
|
|
596
|
+
return {
|
|
597
|
+
ok: false,
|
|
598
|
+
posted_to: staged.submit_endpoint,
|
|
599
|
+
message: "executeMokaApply requires session.json (Moka candidate-portal cookies). " +
|
|
600
|
+
"Capture via extension/, drop under ~/.jobpro/<adapter>.session.json.",
|
|
601
|
+
steps: [],
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
// Resume + applicant_info from staged.
|
|
605
|
+
const resumeField = staged.staged.find((f) => f.name === "resume");
|
|
606
|
+
if (!resumeField?.value)
|
|
607
|
+
return { ok: false, posted_to: "", message: "staged.resume missing", steps: [] };
|
|
608
|
+
let resumeBytes;
|
|
609
|
+
try {
|
|
610
|
+
resumeBytes = readFileSync(resumeField.value);
|
|
611
|
+
}
|
|
612
|
+
catch (err) {
|
|
613
|
+
return { ok: false, posted_to: "", message: `read ${resumeField.value} failed: ${err instanceof Error ? err.message : err}`, steps: [] };
|
|
614
|
+
}
|
|
615
|
+
const filename = resumeField.value.split("/").pop() ?? "resume.pdf";
|
|
616
|
+
const submitUrl = new URL(staged.submit_endpoint);
|
|
617
|
+
const host = submitUrl.host;
|
|
618
|
+
const apiRoot = `${submitUrl.protocol}//${host}`;
|
|
619
|
+
const debug = target.kind === "debug";
|
|
620
|
+
const targetUrl = debug ? target.url : staged.submit_endpoint;
|
|
621
|
+
const applicant = {};
|
|
622
|
+
for (const f of staged.staged) {
|
|
623
|
+
if (f.name === "name" || f.name === "email" || f.name === "phone")
|
|
624
|
+
applicant[f.name] = f.value;
|
|
625
|
+
}
|
|
626
|
+
// Moka multipart: form fields + resume file. Tenant `orgId` and `jobId`
|
|
627
|
+
// are derivable from staged.apply_url (#/jobs/<id>) and staged.source
|
|
628
|
+
// (`app.mokahr.com/<slug>`); we extract them here.
|
|
629
|
+
const slug = staged.source.split("/").pop() ?? "";
|
|
630
|
+
const fd = new FormData();
|
|
631
|
+
fd.append("orgId", slug);
|
|
632
|
+
fd.append("jobId", staged.post_id);
|
|
633
|
+
fd.append("name", applicant.name ?? "");
|
|
634
|
+
fd.append("email", applicant.email ?? "");
|
|
635
|
+
fd.append("phone", applicant.phone ?? "");
|
|
636
|
+
const FileCtor = globalThis.File;
|
|
637
|
+
const filePart = typeof FileCtor === "function"
|
|
638
|
+
? new FileCtor([new Uint8Array(resumeBytes)], filename, { type: "application/pdf" })
|
|
639
|
+
: new Blob([new Uint8Array(resumeBytes)], { type: "application/pdf" });
|
|
640
|
+
fd.append("resume", filePart, filename);
|
|
641
|
+
const steps = [];
|
|
642
|
+
const sessionHeaders = sessionHeaderBag(session, host);
|
|
643
|
+
// Pre-flight limit check (optional â skip in debug since we'd redirect)
|
|
644
|
+
if (!debug && session) {
|
|
645
|
+
const lc = `${apiRoot}/api/outer/ats-apply/website/applicant-limit-check`;
|
|
646
|
+
try {
|
|
647
|
+
const resp = await fetch(lc, {
|
|
648
|
+
method: "POST",
|
|
649
|
+
headers: { ...sessionHeaders, "Content-Type": "application/json" },
|
|
650
|
+
body: JSON.stringify({ orgId: slug, jobId: staged.post_id }),
|
|
651
|
+
});
|
|
652
|
+
const txt = (await resp.text()).slice(0, 200);
|
|
653
|
+
steps.push({ step: "limit-check", url: lc, status: resp.status, ok: resp.ok, message: txt });
|
|
654
|
+
}
|
|
655
|
+
catch (err) {
|
|
656
|
+
steps.push({ step: "limit-check", url: lc, status: 0, ok: false, message: String(err) });
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
// Final submit
|
|
660
|
+
let resp;
|
|
661
|
+
try {
|
|
662
|
+
resp = await fetch(targetUrl, {
|
|
663
|
+
method: "POST",
|
|
664
|
+
headers: sessionHeaders, // Content-Type: multipart/form-data; boundary set by undici
|
|
665
|
+
body: fd,
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
catch (err) {
|
|
669
|
+
steps.push({ step: "apply", url: targetUrl, status: 0, ok: false, message: String(err) });
|
|
670
|
+
return { ok: false, posted_to: targetUrl, message: "apply step network error", steps };
|
|
671
|
+
}
|
|
672
|
+
const text = (await resp.text()).slice(0, 4000);
|
|
673
|
+
steps.push({ step: "apply", url: targetUrl, status: resp.status, ok: resp.ok, message: text.slice(0, 200) });
|
|
674
|
+
return {
|
|
675
|
+
ok: resp.ok,
|
|
676
|
+
status: resp.status,
|
|
677
|
+
posted_to: targetUrl,
|
|
678
|
+
response_preview: text,
|
|
679
|
+
message: resp.ok ? "Moka apply submitted" : `Moka apply rejected: HTTP ${resp.status}`,
|
|
680
|
+
steps,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Beisen Wecruit â covers sensetime / horizonrobotics.
|
|
685
|
+
*
|
|
686
|
+
* Flow (probed from hr.sensetime.com/pb/js/vendor.js):
|
|
687
|
+
* 1. POST /wecruit/resume/upload/file/save/<SU> (multipart, returns attachment id)
|
|
688
|
+
* 2. POST /wecruit/resume/info/add/<SU> (profile fields)
|
|
689
|
+
* 3. POST /wecruit/delivery/resume/<SU> (final submit with post_id + attachment)
|
|
690
|
+
*/
|
|
691
|
+
export async function executeBeisenWecruit(staged, session, target) {
|
|
692
|
+
if (!staged.submit_endpoint)
|
|
693
|
+
return { ok: false, posted_to: "", message: "no submit_endpoint", steps: [] };
|
|
694
|
+
if (target.kind === "dry-run") {
|
|
695
|
+
return { ok: false, posted_to: "dry-run (no network)", message: "dry-run requested â no HTTP call fired", steps: [] };
|
|
696
|
+
}
|
|
697
|
+
if (target.kind === "upstream" && !session) {
|
|
698
|
+
return {
|
|
699
|
+
ok: false,
|
|
700
|
+
posted_to: staged.submit_endpoint,
|
|
701
|
+
message: "executeBeisenWecruit requires session.json (Wecruit candidate session via WeChat OAuth / phone OTP). " +
|
|
702
|
+
"Capture via extension/.",
|
|
703
|
+
steps: [],
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
const resumeField = staged.staged.find((f) => f.name === "resume");
|
|
707
|
+
if (!resumeField?.value)
|
|
708
|
+
return { ok: false, posted_to: "", message: "staged.resume missing", steps: [] };
|
|
709
|
+
let resumeBytes;
|
|
710
|
+
try {
|
|
711
|
+
resumeBytes = readFileSync(resumeField.value);
|
|
712
|
+
}
|
|
713
|
+
catch (err) {
|
|
714
|
+
return { ok: false, posted_to: "", message: `read ${resumeField.value} failed: ${err instanceof Error ? err.message : err}`, steps: [] };
|
|
715
|
+
}
|
|
716
|
+
const filename = resumeField.value.split("/").pop() ?? "resume.pdf";
|
|
717
|
+
// Extract the channel SU from submit_endpoint (.../wecruit/delivery/resume/<SU>)
|
|
718
|
+
const su = staged.submit_endpoint.split("/").pop() ?? "";
|
|
719
|
+
const url = new URL(staged.submit_endpoint);
|
|
720
|
+
const host = url.host;
|
|
721
|
+
const apiBase = `${url.protocol}//${host}/wecruit`;
|
|
722
|
+
const debug = target.kind === "debug";
|
|
723
|
+
const sessionHeaders = sessionHeaderBag(session, host);
|
|
724
|
+
const FileCtor = globalThis.File;
|
|
725
|
+
const steps = [];
|
|
726
|
+
const applicant = {};
|
|
727
|
+
for (const f of staged.staged) {
|
|
728
|
+
if (f.name === "name" || f.name === "email" || f.name === "phone")
|
|
729
|
+
applicant[f.name] = f.value;
|
|
730
|
+
}
|
|
731
|
+
// STEP 1 â upload resume file
|
|
732
|
+
const step1Url = debug ? target.url : `${apiBase}/resume/upload/file/save/${encodeURIComponent(su)}`;
|
|
733
|
+
const uploadFd = new FormData();
|
|
734
|
+
const filePart = typeof FileCtor === "function"
|
|
735
|
+
? new FileCtor([new Uint8Array(resumeBytes)], filename, { type: "application/pdf" })
|
|
736
|
+
: new Blob([new Uint8Array(resumeBytes)], { type: "application/pdf" });
|
|
737
|
+
uploadFd.append("file", filePart, filename);
|
|
738
|
+
let r1;
|
|
739
|
+
try {
|
|
740
|
+
r1 = await fetch(step1Url, { method: "POST", headers: sessionHeaders, body: uploadFd });
|
|
741
|
+
}
|
|
742
|
+
catch (err) {
|
|
743
|
+
steps.push({ step: "upload-file", url: step1Url, status: 0, ok: false, message: String(err) });
|
|
744
|
+
return { ok: false, posted_to: step1Url, message: "step 1 network error", steps };
|
|
745
|
+
}
|
|
746
|
+
const text1 = (await r1.text()).slice(0, 2000);
|
|
747
|
+
steps.push({ step: "upload-file", url: step1Url, status: r1.status, ok: r1.ok, message: text1.slice(0, 200) });
|
|
748
|
+
if (debug) {
|
|
749
|
+
return { ok: r1.ok, status: r1.status, posted_to: step1Url, message: "debug: step 1 fired, steps 2+3 skipped", steps, response_preview: text1 };
|
|
750
|
+
}
|
|
751
|
+
if (!r1.ok) {
|
|
752
|
+
return { ok: false, posted_to: step1Url, status: r1.status, message: "step 1 failed", steps, response_preview: text1 };
|
|
753
|
+
}
|
|
754
|
+
let attachmentId = "";
|
|
755
|
+
try {
|
|
756
|
+
const parsed = JSON.parse(text1);
|
|
757
|
+
attachmentId = parsed?.data?.attachmentId ?? parsed?.data?.id ?? parsed?.data?.fileId ?? "";
|
|
758
|
+
}
|
|
759
|
+
catch { /* keep empty */ }
|
|
760
|
+
// STEP 2 â profile info
|
|
761
|
+
const step2Url = `${apiBase}/resume/info/add/${encodeURIComponent(su)}`;
|
|
762
|
+
let r2;
|
|
763
|
+
try {
|
|
764
|
+
r2 = await fetch(step2Url, {
|
|
765
|
+
method: "POST",
|
|
766
|
+
headers: { ...sessionHeaders, "Content-Type": "application/json" },
|
|
767
|
+
body: JSON.stringify({ name: applicant.name, email: applicant.email, phone: applicant.phone, attachmentId }),
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
catch (err) {
|
|
771
|
+
steps.push({ step: "profile-add", url: step2Url, status: 0, ok: false, message: String(err) });
|
|
772
|
+
return { ok: false, posted_to: step2Url, message: "step 2 network error", steps };
|
|
773
|
+
}
|
|
774
|
+
const text2 = (await r2.text()).slice(0, 2000);
|
|
775
|
+
steps.push({ step: "profile-add", url: step2Url, status: r2.status, ok: r2.ok, message: text2.slice(0, 200) });
|
|
776
|
+
// STEP 3 â final delivery
|
|
777
|
+
const step3Url = `${apiBase}/delivery/resume/${encodeURIComponent(su)}`;
|
|
778
|
+
let r3;
|
|
779
|
+
try {
|
|
780
|
+
r3 = await fetch(step3Url, {
|
|
781
|
+
method: "POST",
|
|
782
|
+
headers: { ...sessionHeaders, "Content-Type": "application/json" },
|
|
783
|
+
body: JSON.stringify({ postId: staged.post_id, attachmentId }),
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
catch (err) {
|
|
787
|
+
steps.push({ step: "deliver", url: step3Url, status: 0, ok: false, message: String(err) });
|
|
788
|
+
return { ok: false, posted_to: step3Url, message: "step 3 network error", steps };
|
|
789
|
+
}
|
|
790
|
+
const text3 = (await r3.text()).slice(0, 4000);
|
|
791
|
+
steps.push({ step: "deliver", url: step3Url, status: r3.status, ok: r3.ok, message: text3.slice(0, 200) });
|
|
792
|
+
return {
|
|
793
|
+
ok: r3.ok,
|
|
794
|
+
status: r3.status,
|
|
795
|
+
posted_to: step3Url,
|
|
796
|
+
response_preview: text3,
|
|
797
|
+
message: r3.ok ? "Beisen Wecruit submission accepted" : `step 3 rejected: HTTP ${r3.status}`,
|
|
798
|
+
steps,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Beisen iTalent â covers vivo / iflytek.
|
|
803
|
+
*
|
|
804
|
+
* Flow (Beisen iTalent's typical wire pattern):
|
|
805
|
+
* 1. POST /api/Resume/UploadResume (multipart resume)
|
|
806
|
+
* â { Code:200, Data:{ ResumeId, Path, âĶ } }
|
|
807
|
+
* 2. POST /api/Apply/SubmitResume (JSON apply)
|
|
808
|
+
* body: { JobAdId, ResumeId, Name, Email, Mobile }
|
|
809
|
+
*/
|
|
810
|
+
export async function executeBeisenITalent(staged, session, target) {
|
|
811
|
+
if (!staged.submit_endpoint)
|
|
812
|
+
return { ok: false, posted_to: "", message: "no submit_endpoint", steps: [] };
|
|
813
|
+
if (target.kind === "dry-run") {
|
|
814
|
+
return { ok: false, posted_to: "dry-run (no network)", message: "dry-run requested â no HTTP call fired", steps: [] };
|
|
815
|
+
}
|
|
816
|
+
if (target.kind === "upstream" && !session) {
|
|
817
|
+
return {
|
|
818
|
+
ok: false,
|
|
819
|
+
posted_to: staged.submit_endpoint,
|
|
820
|
+
message: "executeBeisenITalent requires session.json (iTalent candidate-portal session via email+phone+OTP). " +
|
|
821
|
+
"Capture via extension/.",
|
|
822
|
+
steps: [],
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
const resumeField = staged.staged.find((f) => f.name === "resume");
|
|
826
|
+
if (!resumeField?.value)
|
|
827
|
+
return { ok: false, posted_to: "", message: "staged.resume missing", steps: [] };
|
|
828
|
+
let resumeBytes;
|
|
829
|
+
try {
|
|
830
|
+
resumeBytes = readFileSync(resumeField.value);
|
|
831
|
+
}
|
|
832
|
+
catch (err) {
|
|
833
|
+
return { ok: false, posted_to: "", message: `read ${resumeField.value} failed: ${err instanceof Error ? err.message : err}`, steps: [] };
|
|
834
|
+
}
|
|
835
|
+
const filename = resumeField.value.split("/").pop() ?? "resume.pdf";
|
|
836
|
+
const submitUrl = new URL(staged.submit_endpoint);
|
|
837
|
+
const host = submitUrl.host;
|
|
838
|
+
const apiRoot = `${submitUrl.protocol}//${host}`;
|
|
839
|
+
const debug = target.kind === "debug";
|
|
840
|
+
const sessionHeaders = sessionHeaderBag(session, host);
|
|
841
|
+
const FileCtor = globalThis.File;
|
|
842
|
+
const steps = [];
|
|
843
|
+
const applicant = {};
|
|
844
|
+
for (const f of staged.staged) {
|
|
845
|
+
if (f.name === "name" || f.name === "email" || f.name === "phone")
|
|
846
|
+
applicant[f.name] = f.value;
|
|
847
|
+
}
|
|
848
|
+
// STEP 1 â upload
|
|
849
|
+
const step1Url = debug ? target.url : `${apiRoot}/api/Resume/UploadResume`;
|
|
850
|
+
const uploadFd = new FormData();
|
|
851
|
+
const filePart = typeof FileCtor === "function"
|
|
852
|
+
? new FileCtor([new Uint8Array(resumeBytes)], filename, { type: "application/pdf" })
|
|
853
|
+
: new Blob([new Uint8Array(resumeBytes)], { type: "application/pdf" });
|
|
854
|
+
uploadFd.append("file", filePart, filename);
|
|
855
|
+
let r1;
|
|
856
|
+
try {
|
|
857
|
+
r1 = await fetch(step1Url, { method: "POST", headers: sessionHeaders, body: uploadFd });
|
|
858
|
+
}
|
|
859
|
+
catch (err) {
|
|
860
|
+
steps.push({ step: "upload", url: step1Url, status: 0, ok: false, message: String(err) });
|
|
861
|
+
return { ok: false, posted_to: step1Url, message: "step 1 network error", steps };
|
|
862
|
+
}
|
|
863
|
+
const text1 = (await r1.text()).slice(0, 2000);
|
|
864
|
+
steps.push({ step: "upload", url: step1Url, status: r1.status, ok: r1.ok, message: text1.slice(0, 200) });
|
|
865
|
+
if (debug) {
|
|
866
|
+
return { ok: r1.ok, status: r1.status, posted_to: step1Url, message: "debug: step 1 fired, step 2 skipped", steps, response_preview: text1 };
|
|
867
|
+
}
|
|
868
|
+
if (!r1.ok)
|
|
869
|
+
return { ok: false, posted_to: step1Url, status: r1.status, message: "step 1 failed", steps, response_preview: text1 };
|
|
870
|
+
let resumeId = "";
|
|
871
|
+
try {
|
|
872
|
+
const parsed = JSON.parse(text1);
|
|
873
|
+
resumeId = parsed?.Data?.ResumeId ?? parsed?.Data?.Id ?? "";
|
|
874
|
+
}
|
|
875
|
+
catch { /* keep empty */ }
|
|
876
|
+
// STEP 2 â submit apply
|
|
877
|
+
const step2Url = `${apiRoot}/api/Apply/SubmitResume`;
|
|
878
|
+
let r2;
|
|
879
|
+
try {
|
|
880
|
+
r2 = await fetch(step2Url, {
|
|
881
|
+
method: "POST",
|
|
882
|
+
headers: { ...sessionHeaders, "Content-Type": "application/json" },
|
|
883
|
+
body: JSON.stringify({
|
|
884
|
+
JobAdId: staged.post_id,
|
|
885
|
+
ResumeId: resumeId,
|
|
886
|
+
Name: applicant.name,
|
|
887
|
+
Email: applicant.email,
|
|
888
|
+
Mobile: applicant.phone,
|
|
889
|
+
}),
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
catch (err) {
|
|
893
|
+
steps.push({ step: "submit", url: step2Url, status: 0, ok: false, message: String(err) });
|
|
894
|
+
return { ok: false, posted_to: step2Url, message: "step 2 network error", steps };
|
|
895
|
+
}
|
|
896
|
+
const text2 = (await r2.text()).slice(0, 4000);
|
|
897
|
+
steps.push({ step: "submit", url: step2Url, status: r2.status, ok: r2.ok, message: text2.slice(0, 200) });
|
|
898
|
+
return {
|
|
899
|
+
ok: r2.ok,
|
|
900
|
+
status: r2.status,
|
|
901
|
+
posted_to: step2Url,
|
|
902
|
+
response_preview: text2,
|
|
903
|
+
message: r2.ok ? "Beisen iTalent submission accepted" : `step 2 rejected: HTTP ${r2.status}`,
|
|
904
|
+
steps,
|
|
905
|
+
};
|
|
906
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -50,11 +50,11 @@ import * as geely from "./geely.js";
|
|
|
50
50
|
import * as webank from "./webank.js";
|
|
51
51
|
import * as horizonrobotics from "./horizonrobotics.js";
|
|
52
52
|
import * as cambricon from "./cambricon.js";
|
|
53
|
-
import { loadProfile, loadSession, profileTemplate, stageApplication, submitApplication, formatStaged, } from "./apply.js";
|
|
53
|
+
import { loadProfile, loadSession, profileTemplate, stageApplication, submitApplication, executeFeishu3Step, executeMokaApply, executeBeisenWecruit, executeBeisenITalent, formatStaged, } from "./apply.js";
|
|
54
54
|
import { memoryList, memoryGet, memorySet, memoryEvent, memoryClear, } from "./memory.js";
|
|
55
55
|
import { writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
56
56
|
import { dirname } from "node:path";
|
|
57
|
-
const VERSION = "0.9.
|
|
57
|
+
const VERSION = "0.9.7";
|
|
58
58
|
const COMPANIES = [
|
|
59
59
|
{ key: "tencent", family: "Bespoke", source: "join.qq.com", label: "Tencent / č
ūčŪŊ" },
|
|
60
60
|
{ key: "bytedance", family: "Bespoke", source: "jobs.bytedance.com", label: "ByteDance / åčč·ģåĻ" },
|
|
@@ -441,8 +441,20 @@ async function runCompany(adapter, company, rawArgs) {
|
|
|
441
441
|
const session = loadSession(company);
|
|
442
442
|
// Mode selection: --debug-submit-to <url> overrides everything.
|
|
443
443
|
if (debugUrl) {
|
|
444
|
+
// Route through the family-specific executor where appropriate so the
|
|
445
|
+
// user can verify each step's wire format against their echo server.
|
|
446
|
+
const kindForDebug = sr.schema.submit_kind ?? "multipart-anon";
|
|
447
|
+
const debugExecutor = kindForDebug === "feishu-3-step" ? executeFeishu3Step :
|
|
448
|
+
kindForDebug === "moka-aes" ? executeMokaApply :
|
|
449
|
+
kindForDebug === "beisen-wecruit" ? executeBeisenWecruit :
|
|
450
|
+
kindForDebug === "beisen-italent" ? executeBeisenITalent :
|
|
451
|
+
null;
|
|
452
|
+
if (debugExecutor) {
|
|
453
|
+
const result = await debugExecutor(staged, session, { kind: "debug", url: debugUrl });
|
|
454
|
+
return emit({ mode: "debug-submit", staged, submit_kind: kindForDebug, result }, compact);
|
|
455
|
+
}
|
|
444
456
|
const result = await submitApplication(staged, { kind: "debug", url: debugUrl });
|
|
445
|
-
return emit({ mode: "debug-submit", staged, result }, compact);
|
|
457
|
+
return emit({ mode: "debug-submit", staged, submit_kind: kindForDebug, result }, compact);
|
|
446
458
|
}
|
|
447
459
|
// --really-submit: actually hit the upstream endpoint. Guarded by both
|
|
448
460
|
// an env-var attestation and (for non-anon adapters) a session.json.
|
|
@@ -492,6 +504,30 @@ async function runCompany(adapter, company, rawArgs) {
|
|
|
492
504
|
`Open apply_url in your browser to start the actual application flow.`,
|
|
493
505
|
}, compact);
|
|
494
506
|
}
|
|
507
|
+
// Family executors: each takes (staged, session, target) and returns
|
|
508
|
+
// a MultiStepResult. All gate on session.json existing.
|
|
509
|
+
const familyExecutor = kind === "feishu-3-step" ? executeFeishu3Step :
|
|
510
|
+
kind === "moka-aes" ? executeMokaApply :
|
|
511
|
+
kind === "beisen-wecruit" ? executeBeisenWecruit :
|
|
512
|
+
kind === "beisen-italent" ? executeBeisenITalent :
|
|
513
|
+
null;
|
|
514
|
+
if (familyExecutor) {
|
|
515
|
+
if (!session) {
|
|
516
|
+
return emit({
|
|
517
|
+
ok: false,
|
|
518
|
+
source: company,
|
|
519
|
+
post_id: postId,
|
|
520
|
+
mode: "really-submit-blocked",
|
|
521
|
+
staged,
|
|
522
|
+
submit_kind: kind,
|
|
523
|
+
message: `${kind} submission requires a captured session at ` +
|
|
524
|
+
`~/.jobpro/${company}.session.json. Install extension/ in Chrome, ` +
|
|
525
|
+
`log in to the careers site, click Export.`,
|
|
526
|
+
}, compact);
|
|
527
|
+
}
|
|
528
|
+
const result = await familyExecutor(staged, session, { kind: "upstream" });
|
|
529
|
+
return emit({ mode: "really-submit", staged, submit_kind: kind, session_used: true, result }, compact);
|
|
530
|
+
}
|
|
495
531
|
if (!isGenericMultipart) {
|
|
496
532
|
return emit({
|
|
497
533
|
ok: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "job-pro",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.7",
|
|
4
4
|
"description": "Query Chinese big-tech campus recruiting from your terminal. 50 companies, all 50 live. 46 via each company's own API; the 4 with no public canonical feed (Hikvision, CICC, Cainiao, WeBank) surfaced via Liepin as a clearly-labeled third-party fallback. No signup, no token, no server.",
|
|
5
5
|
"homepage": "https://job.ha7ch.com",
|
|
6
6
|
"repository": "https://github.com/HA7CH/job-pro",
|