job-pro 0.9.7 → 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 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. */
@@ -904,3 +905,184 @@ export async function executeBeisenITalent(staged, session, target) {
904
905
  steps,
905
906
  };
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, executeMokaApply, executeBeisenWecruit, executeBeisenITalent, 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.7";
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 / 字节跳动" },
@@ -448,7 +448,8 @@ async function runCompany(adapter, company, rawArgs) {
448
448
  kindForDebug === "moka-aes" ? executeMokaApply :
449
449
  kindForDebug === "beisen-wecruit" ? executeBeisenWecruit :
450
450
  kindForDebug === "beisen-italent" ? executeBeisenITalent :
451
- null;
451
+ kindForDebug === "cdp-real-browser" ? executeCdpRealBrowser :
452
+ null;
452
453
  if (debugExecutor) {
453
454
  const result = await debugExecutor(staged, session, { kind: "debug", url: debugUrl });
454
455
  return emit({ mode: "debug-submit", staged, submit_kind: kindForDebug, result }, compact);
@@ -510,7 +511,8 @@ async function runCompany(adapter, company, rawArgs) {
510
511
  kind === "moka-aes" ? executeMokaApply :
511
512
  kind === "beisen-wecruit" ? executeBeisenWecruit :
512
513
  kind === "beisen-italent" ? executeBeisenITalent :
513
- null;
514
+ kind === "cdp-real-browser" ? executeCdpRealBrowser :
515
+ null;
514
516
  if (familyExecutor) {
515
517
  if (!session) {
516
518
  return emit({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "job-pro",
3
- "version": "0.9.7",
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",