job-pro 0.9.6 → 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 CHANGED
@@ -567,3 +567,340 @@ function sessionHeaderBag(session, targetHost) {
567
567
  }
568
568
  return out;
569
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, executeFeishu3Step, 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.6";
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 / 字节跳动" },
@@ -444,8 +444,13 @@ async function runCompany(adapter, company, rawArgs) {
444
444
  // Route through the family-specific executor where appropriate so the
445
445
  // user can verify each step's wire format against their echo server.
446
446
  const kindForDebug = sr.schema.submit_kind ?? "multipart-anon";
447
- if (kindForDebug === "feishu-3-step") {
448
- const result = await executeFeishu3Step(staged, session, { kind: "debug", url: debugUrl });
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 });
449
454
  return emit({ mode: "debug-submit", staged, submit_kind: kindForDebug, result }, compact);
450
455
  }
451
456
  const result = await submitApplication(staged, { kind: "debug", url: debugUrl });
@@ -499,7 +504,14 @@ async function runCompany(adapter, company, rawArgs) {
499
504
  `Open apply_url in your browser to start the actual application flow.`,
500
505
  }, compact);
501
506
  }
502
- if (kind === "feishu-3-step") {
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) {
503
515
  if (!session) {
504
516
  return emit({
505
517
  ok: false,
@@ -507,12 +519,13 @@ async function runCompany(adapter, company, rawArgs) {
507
519
  post_id: postId,
508
520
  mode: "really-submit-blocked",
509
521
  staged,
510
- message: `Feishu 3-step submission requires a captured session at ` +
522
+ submit_kind: kind,
523
+ message: `${kind} submission requires a captured session at ` +
511
524
  `~/.jobpro/${company}.session.json. Install extension/ in Chrome, ` +
512
525
  `log in to the careers site, click Export.`,
513
526
  }, compact);
514
527
  }
515
- const result = await executeFeishu3Step(staged, session, { kind: "upstream" });
528
+ const result = await familyExecutor(staged, session, { kind: "upstream" });
516
529
  return emit({ mode: "really-submit", staged, submit_kind: kind, session_used: true, result }, compact);
517
530
  }
518
531
  if (!isGenericMultipart) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "job-pro",
3
- "version": "0.9.6",
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",