@ha7ch/job-pro 1.0.93
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapter.js +17 -0
- package/dist/agibot.js +399 -0
- package/dist/alibaba.js +509 -0
- package/dist/antgroup.js +397 -0
- package/dist/apply.js +1373 -0
- package/dist/baichuan.js +49 -0
- package/dist/baidu.js +452 -0
- package/dist/bilibili.js +455 -0
- package/dist/byd.js +412 -0
- package/dist/bytedance.js +619 -0
- package/dist/cainiao.js +56 -0
- package/dist/cambricon.js +33 -0
- package/dist/cdp.js +237 -0
- package/dist/cicc.js +56 -0
- package/dist/coverage.js +60 -0
- package/dist/deepseek.js +25 -0
- package/dist/didi.js +381 -0
- package/dist/feishu.js +577 -0
- package/dist/galaxyuniversal.js +24 -0
- package/dist/geely.js +35 -0
- package/dist/greenhouse.js +432 -0
- package/dist/hikvision.js +58 -0
- package/dist/horizonrobotics.js +46 -0
- package/dist/hoyoverse.js +26 -0
- package/dist/huawei.js +537 -0
- package/dist/iflytek.js +380 -0
- package/dist/index.js +1828 -0
- package/dist/iqiyi.js +494 -0
- package/dist/jd.js +559 -0
- package/dist/kuaishou.js +496 -0
- package/dist/lever.js +455 -0
- package/dist/liauto.js +393 -0
- package/dist/liepin.js +357 -0
- package/dist/lilith.js +300 -0
- package/dist/megvii.js +27 -0
- package/dist/meituan.js +633 -0
- package/dist/memory.js +76 -0
- package/dist/mihoyo.js +308 -0
- package/dist/minimax.js +32 -0
- package/dist/moka.js +473 -0
- package/dist/moonshot.js +24 -0
- package/dist/netease.js +424 -0
- package/dist/nio.js +24 -0
- package/dist/oppo.js +285 -0
- package/dist/pdd.js +614 -0
- package/dist/pingan.js +493 -0
- package/dist/sensetime.js +51 -0
- package/dist/sf.js +310 -0
- package/dist/stepfun.js +24 -0
- package/dist/tencent.js +770 -0
- package/dist/trip.js +396 -0
- package/dist/unitree.js +418 -0
- package/dist/vivo.js +361 -0
- package/dist/webank.js +55 -0
- package/dist/wecruit.js +438 -0
- package/dist/weibo.js +337 -0
- package/dist/weride.js +29 -0
- package/dist/xiaohongshu.js +480 -0
- package/dist/xiaomi.js +529 -0
- package/dist/xpeng.js +34 -0
- package/dist/zerooneai.js +42 -0
- package/dist/zhipu.js +478 -0
- package/extension/README.md +79 -0
- package/extension/background.js +177 -0
- package/extension/manifest.json +55 -0
- package/extension/popup.html +37 -0
- package/extension/popup.js +54 -0
- package/package.json +61 -0
package/dist/apply.js
ADDED
|
@@ -0,0 +1,1373 @@
|
|
|
1
|
+
// Phase 2 — auto-apply infrastructure.
|
|
2
|
+
//
|
|
3
|
+
// This module is intentionally read-only (dry-run) right now. The user
|
|
4
|
+
// runs `job-pro <co> apply <postId>` and gets a fully-staged POST payload
|
|
5
|
+
// printed to stdout. Actually firing the submission ("--really-submit")
|
|
6
|
+
// is guarded: each adapter family must opt in by exporting an
|
|
7
|
+
// `executeApplication` function. Out of the 50 adapters, only a handful
|
|
8
|
+
// (Greenhouse boards / Lever boards) have well-documented public
|
|
9
|
+
// submission APIs; the rest need session capture (Phase 2.1, separate
|
|
10
|
+
// release).
|
|
11
|
+
//
|
|
12
|
+
// Profile shape — loaded from `~/.jobpro/profile.json` or via flags.
|
|
13
|
+
// Fields beyond first_name / last_name / email / phone / resume are
|
|
14
|
+
// passed through to whatever per-company custom question matches their
|
|
15
|
+
// `name` (e.g. `linkedin_url`, `nationality`).
|
|
16
|
+
import { readFileSync, writeFileSync, existsSync, statSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { basename, join } from "node:path";
|
|
19
|
+
import { withPage, injectCookies } from "./cdp.js";
|
|
20
|
+
const PROFILE_PATH = process.env.JOB_PRO_PROFILE_PATH ?? join(homedir(), ".jobpro", "profile.json");
|
|
21
|
+
const SESSION_DIR = process.env.JOB_PRO_SESSION_DIR ?? join(homedir(), ".jobpro");
|
|
22
|
+
/** Read a captured session for an adapter, or null if none exists. */
|
|
23
|
+
export function loadSession(adapterKey) {
|
|
24
|
+
const path = join(SESSION_DIR, `${adapterKey}.session.json`);
|
|
25
|
+
if (!existsSync(path))
|
|
26
|
+
return null;
|
|
27
|
+
try {
|
|
28
|
+
const raw = readFileSync(path, "utf8");
|
|
29
|
+
return JSON.parse(raw);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Convert a CapturedSession into a single Cookie header string. */
|
|
36
|
+
export function serializeCookieHeader(session, targetHost) {
|
|
37
|
+
const cookies = session.cookies.filter((c) => {
|
|
38
|
+
if (!targetHost)
|
|
39
|
+
return true;
|
|
40
|
+
if (!c.domain)
|
|
41
|
+
return true;
|
|
42
|
+
// RFC-style domain match: ".example.com" matches any subdomain.
|
|
43
|
+
const dom = c.domain.startsWith(".") ? c.domain.slice(1) : c.domain;
|
|
44
|
+
return targetHost === dom || targetHost.endsWith("." + dom);
|
|
45
|
+
});
|
|
46
|
+
return cookies.map((c) => `${c.name}=${c.value}`).join("; ");
|
|
47
|
+
}
|
|
48
|
+
const TEMPLATE = {
|
|
49
|
+
first_name: "",
|
|
50
|
+
last_name: "",
|
|
51
|
+
email: "",
|
|
52
|
+
phone: "",
|
|
53
|
+
resume_path: "",
|
|
54
|
+
cover_letter_text: "",
|
|
55
|
+
custom: {
|
|
56
|
+
// Common Greenhouse / Lever questions:
|
|
57
|
+
// question_<n>: "answer"
|
|
58
|
+
// linkedin_url: "https://www.linkedin.com/in/your-handle",
|
|
59
|
+
// nationality: "China",
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Read profile.json as-is, returning whatever is there.
|
|
64
|
+
* Skips the loadProfile() validation so callers (like `profile lint`)
|
|
65
|
+
* can inspect partial / broken profiles instead of getting a flat fail.
|
|
66
|
+
*/
|
|
67
|
+
export function loadProfileRaw() {
|
|
68
|
+
if (!existsSync(PROFILE_PATH)) {
|
|
69
|
+
return { ok: false, path: PROFILE_PATH, message: `profile not found at ${PROFILE_PATH}` };
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const raw = readFileSync(PROFILE_PATH, "utf8");
|
|
73
|
+
return { ok: true, path: PROFILE_PATH, profile: JSON.parse(raw) };
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
return { ok: false, path: PROFILE_PATH, message: `could not parse ${PROFILE_PATH}: ${err instanceof Error ? err.message : err}` };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function loadProfile() {
|
|
80
|
+
if (!existsSync(PROFILE_PATH)) {
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
message: `profile not found at ${PROFILE_PATH}. Run \`job-pro profile init\` to create a template, ` +
|
|
84
|
+
`or set $JOB_PRO_PROFILE_PATH to override.`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
let raw;
|
|
88
|
+
try {
|
|
89
|
+
raw = readFileSync(PROFILE_PATH, "utf8");
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
return { ok: false, message: `could not read ${PROFILE_PATH}: ${err instanceof Error ? err.message : err}` };
|
|
93
|
+
}
|
|
94
|
+
let parsed;
|
|
95
|
+
try {
|
|
96
|
+
parsed = JSON.parse(raw);
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
return { ok: false, message: `${PROFILE_PATH} is not valid JSON: ${err instanceof Error ? err.message : err}` };
|
|
100
|
+
}
|
|
101
|
+
for (const required of ["first_name", "last_name", "email", "phone"]) {
|
|
102
|
+
if (!parsed[required]) {
|
|
103
|
+
return { ok: false, message: `${PROFILE_PATH}: missing required field "${required}"` };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { ok: true, profile: parsed };
|
|
107
|
+
}
|
|
108
|
+
export function profileTemplate() {
|
|
109
|
+
return { path: PROFILE_PATH, template: TEMPLATE };
|
|
110
|
+
}
|
|
111
|
+
/** Persist a profile back to disk. Used by `apply --remember`. */
|
|
112
|
+
export function saveProfile(profile) {
|
|
113
|
+
try {
|
|
114
|
+
writeFileSync(PROFILE_PATH, JSON.stringify(profile, null, 2) + "\n", "utf8");
|
|
115
|
+
return { ok: true, path: PROFILE_PATH };
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
return { ok: false, message: `could not write ${PROFILE_PATH}: ${err instanceof Error ? err.message : err}` };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** Fill in known answers from the profile; flag any unanswered required fields. */
|
|
122
|
+
export function stageApplication(schema, profile) {
|
|
123
|
+
const staged = [];
|
|
124
|
+
const unanswered_required = [];
|
|
125
|
+
for (const q of schema.questions) {
|
|
126
|
+
// The "primary" field is the first one; secondary fields are alternate
|
|
127
|
+
// formats (e.g. resume has both `resume` file + `resume_text` textarea).
|
|
128
|
+
const primary = q.fields[0];
|
|
129
|
+
if (!primary)
|
|
130
|
+
continue;
|
|
131
|
+
const filled = resolveAnswer(primary, profile);
|
|
132
|
+
const reason = filled.value || !q.required ? undefined : filled.reason;
|
|
133
|
+
const sf = {
|
|
134
|
+
name: primary.name,
|
|
135
|
+
type: primary.type,
|
|
136
|
+
value: filled.value,
|
|
137
|
+
required: q.required,
|
|
138
|
+
unanswered_reason: reason,
|
|
139
|
+
};
|
|
140
|
+
staged.push(sf);
|
|
141
|
+
if (q.required && !filled.value)
|
|
142
|
+
unanswered_required.push(sf);
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
source: schema.source,
|
|
146
|
+
post_id: schema.post_id,
|
|
147
|
+
job_title: schema.job_title,
|
|
148
|
+
apply_url: schema.apply_url,
|
|
149
|
+
submit_endpoint: schema.submit_endpoint,
|
|
150
|
+
submit_method: schema.submit_method,
|
|
151
|
+
submit_kind: schema.submit_kind,
|
|
152
|
+
submit_notes: schema.submit_notes,
|
|
153
|
+
endpoint_verified: schema.endpoint_verified,
|
|
154
|
+
staged,
|
|
155
|
+
unanswered_required,
|
|
156
|
+
ready: unanswered_required.length === 0,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function resolveAnswer(field, profile) {
|
|
160
|
+
// Hard-coded standard mappings — these names are the canonical
|
|
161
|
+
// Greenhouse field names and are reused by Lever's submission form.
|
|
162
|
+
switch (field.name) {
|
|
163
|
+
case "first_name":
|
|
164
|
+
return { value: profile.first_name ?? "", reason: "profile.first_name missing" };
|
|
165
|
+
case "last_name":
|
|
166
|
+
return { value: profile.last_name ?? "", reason: "profile.last_name missing" };
|
|
167
|
+
case "name":
|
|
168
|
+
// Feishu / Beisen / Moka often use a single `name` field. Compose
|
|
169
|
+
// first + last; gracefully degrade if only one is set.
|
|
170
|
+
const composed = [profile.first_name, profile.last_name].filter(Boolean).join(" ");
|
|
171
|
+
return {
|
|
172
|
+
value: composed || profile.first_name || profile.last_name || "",
|
|
173
|
+
reason: "profile.first_name and last_name both missing",
|
|
174
|
+
};
|
|
175
|
+
case "email":
|
|
176
|
+
return { value: profile.email ?? "", reason: "profile.email missing" };
|
|
177
|
+
case "phone":
|
|
178
|
+
return { value: profile.phone ?? "", reason: "profile.phone missing" };
|
|
179
|
+
case "resume":
|
|
180
|
+
return {
|
|
181
|
+
value: profile.resume_path ?? "",
|
|
182
|
+
reason: "profile.resume_path missing — set to an absolute PDF/DOCX path",
|
|
183
|
+
};
|
|
184
|
+
case "resume_text":
|
|
185
|
+
// Optional companion field — leave empty if user supplies a file.
|
|
186
|
+
return { value: "", reason: "" };
|
|
187
|
+
case "cover_letter":
|
|
188
|
+
return { value: "", reason: "" };
|
|
189
|
+
case "cover_letter_text":
|
|
190
|
+
return { value: profile.cover_letter_text ?? "", reason: "" };
|
|
191
|
+
default:
|
|
192
|
+
// Custom passthroughs — match by question name (e.g. "question_36528765002").
|
|
193
|
+
const v = profile.custom?.[field.name];
|
|
194
|
+
if (typeof v === "string" && v.length > 0)
|
|
195
|
+
return { value: v, reason: "" };
|
|
196
|
+
return {
|
|
197
|
+
value: "",
|
|
198
|
+
reason: `unknown field "${field.name}" — add to profile.custom.${field.name} to auto-fill`,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// ---------- pretty-print for dry-run ----------
|
|
203
|
+
export function formatStaged(s) {
|
|
204
|
+
const lines = [];
|
|
205
|
+
lines.push(`source: ${s.source}`);
|
|
206
|
+
lines.push(`job: ${s.post_id} — ${s.job_title}`);
|
|
207
|
+
lines.push(`apply_url: ${s.apply_url}`);
|
|
208
|
+
if (s.submit_endpoint) {
|
|
209
|
+
const verifiedTag = s.endpoint_verified === true
|
|
210
|
+
? " (verified)"
|
|
211
|
+
: s.submit_kind === "external"
|
|
212
|
+
? ""
|
|
213
|
+
: " (⚠ speculative — endpoint inferred, not end-to-end verified)";
|
|
214
|
+
lines.push(`submit: ${s.submit_method ?? "POST"} ${s.submit_endpoint}${verifiedTag}`);
|
|
215
|
+
}
|
|
216
|
+
lines.push("");
|
|
217
|
+
lines.push(`ready: ${s.ready ? "✓ all required fields filled" : `✗ ${s.unanswered_required.length} required field(s) unfilled`}`);
|
|
218
|
+
lines.push("");
|
|
219
|
+
lines.push("Staged payload:");
|
|
220
|
+
const widthName = Math.max(...s.staged.map((f) => f.name.length));
|
|
221
|
+
const widthType = Math.max(...s.staged.map((f) => f.type.length));
|
|
222
|
+
for (const f of s.staged) {
|
|
223
|
+
const flag = f.required ? "•" : " ";
|
|
224
|
+
const value = f.value
|
|
225
|
+
? f.type === "input_file"
|
|
226
|
+
? `<file: ${f.value}>`
|
|
227
|
+
: truncate(f.value, 60)
|
|
228
|
+
: f.unanswered_reason
|
|
229
|
+
? `<unanswered: ${f.unanswered_reason}>`
|
|
230
|
+
: "<empty>";
|
|
231
|
+
lines.push(` ${flag} ${f.name.padEnd(widthName)} ${f.type.padEnd(widthType)} ${value}`);
|
|
232
|
+
}
|
|
233
|
+
return lines.join("\n");
|
|
234
|
+
}
|
|
235
|
+
function truncate(s, n) {
|
|
236
|
+
return s.length > n ? s.slice(0, n - 1) + "…" : s;
|
|
237
|
+
}
|
|
238
|
+
export function buildFormTemplate(schema, profile) {
|
|
239
|
+
const out = [];
|
|
240
|
+
for (const q of schema.questions) {
|
|
241
|
+
for (const f of q.fields) {
|
|
242
|
+
const resolved = resolveAnswer(f, profile);
|
|
243
|
+
out.push({
|
|
244
|
+
name: f.name,
|
|
245
|
+
type: f.type,
|
|
246
|
+
required: q.required,
|
|
247
|
+
label: q.label,
|
|
248
|
+
description: q.description,
|
|
249
|
+
options: f.values && f.values.length > 0 ? f.values : undefined,
|
|
250
|
+
value: resolved.value,
|
|
251
|
+
unanswered_reason: resolved.value ? undefined : resolved.reason || undefined,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
source: schema.source,
|
|
257
|
+
post_id: schema.post_id,
|
|
258
|
+
job_title: schema.job_title,
|
|
259
|
+
apply_url: schema.apply_url,
|
|
260
|
+
submit_kind: schema.submit_kind,
|
|
261
|
+
fields: out,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Walk an ApplyFormSchema and prompt for each unanswered required field
|
|
266
|
+
* on stdin (via readline). Returns the new overrides as a flat
|
|
267
|
+
* `{ name: value }` map ready to merge into profile.custom.
|
|
268
|
+
*
|
|
269
|
+
* Behaviour:
|
|
270
|
+
* - Fields already resolved from profile (name/email/phone/resume/etc.)
|
|
271
|
+
* are skipped silently.
|
|
272
|
+
* - For `*_select` field types, options are presented as a numbered
|
|
273
|
+
* list — user can type the index or the literal value.
|
|
274
|
+
* - User can hit Enter to skip a non-required field.
|
|
275
|
+
* - User can type `q` / Ctrl-D to abort; we return what we've got so far.
|
|
276
|
+
*
|
|
277
|
+
* This function intentionally lives in apply.ts (not index.ts) so it
|
|
278
|
+
* stays unit-testable and so a future TUI can swap it out.
|
|
279
|
+
*/
|
|
280
|
+
export async function promptUnansweredFields(schema, profile, io) {
|
|
281
|
+
const overrides = {};
|
|
282
|
+
for (const q of schema.questions) {
|
|
283
|
+
// Only prompt for the primary field of each question. Secondary
|
|
284
|
+
// alternates (e.g. `resume_text` alongside `resume`) get the same
|
|
285
|
+
// resolution as the primary and don't need a separate prompt.
|
|
286
|
+
const f = q.fields[0];
|
|
287
|
+
if (!f)
|
|
288
|
+
continue;
|
|
289
|
+
const resolved = resolveAnswer(f, profile);
|
|
290
|
+
if (resolved.value)
|
|
291
|
+
continue; // already filled
|
|
292
|
+
if (!q.required)
|
|
293
|
+
continue; // skip optional fields entirely
|
|
294
|
+
while (true) {
|
|
295
|
+
// Build the prompt.
|
|
296
|
+
const lines = [];
|
|
297
|
+
lines.push(`\n${q.label} (required) [${f.name}]`);
|
|
298
|
+
if (q.description)
|
|
299
|
+
lines.push(` ${q.description}`);
|
|
300
|
+
if (f.values && f.values.length > 0) {
|
|
301
|
+
lines.push(" Options:");
|
|
302
|
+
f.values.forEach((opt, i) => {
|
|
303
|
+
const label = opt.label && opt.label !== opt.value ? `${opt.value} — ${opt.label}` : opt.value;
|
|
304
|
+
lines.push(` [${i + 1}] ${label}`);
|
|
305
|
+
});
|
|
306
|
+
lines.push(" Enter number or value:");
|
|
307
|
+
}
|
|
308
|
+
else if (f.type === "input_file") {
|
|
309
|
+
lines.push(" Enter absolute file path:");
|
|
310
|
+
}
|
|
311
|
+
else if (f.type === "textarea") {
|
|
312
|
+
lines.push(" Enter text (single line; \\n for newlines):");
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
lines.push(" Enter value:");
|
|
316
|
+
}
|
|
317
|
+
lines.push("> ");
|
|
318
|
+
io.write(lines.join("\n"));
|
|
319
|
+
const answer = await io.read();
|
|
320
|
+
if (answer === null) {
|
|
321
|
+
// Ctrl-D / EOF — bail with what we have.
|
|
322
|
+
return overrides;
|
|
323
|
+
}
|
|
324
|
+
const trimmed = answer.trim();
|
|
325
|
+
if (trimmed === "q")
|
|
326
|
+
return overrides;
|
|
327
|
+
if (!trimmed) {
|
|
328
|
+
// Empty input for a required field — re-prompt unless user wants to skip.
|
|
329
|
+
io.write(" (required — type a value, `q` to abort, or `skip` to leave blank)\n");
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (trimmed === "skip")
|
|
333
|
+
break;
|
|
334
|
+
let resolvedAnswer = trimmed;
|
|
335
|
+
if (f.values && f.values.length > 0) {
|
|
336
|
+
const asIdx = Number.parseInt(trimmed, 10);
|
|
337
|
+
if (Number.isFinite(asIdx) && asIdx >= 1 && asIdx <= f.values.length) {
|
|
338
|
+
// Coerce — Greenhouse sometimes ships numeric values that JSON.parse
|
|
339
|
+
// hands back as numbers, breaking .replace below.
|
|
340
|
+
resolvedAnswer = String(f.values[asIdx - 1].value ?? "");
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
overrides[f.name] = resolvedAnswer.replace(/\\n/g, "\n");
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return overrides;
|
|
348
|
+
}
|
|
349
|
+
/** Merge a `{ field_name: value }` map into the profile's custom overrides. */
|
|
350
|
+
export function applyFormFile(profile, formFilePath) {
|
|
351
|
+
if (!existsSync(formFilePath)) {
|
|
352
|
+
return { ok: false, message: `form file not found: ${formFilePath}` };
|
|
353
|
+
}
|
|
354
|
+
let raw;
|
|
355
|
+
try {
|
|
356
|
+
raw = readFileSync(formFilePath, "utf8");
|
|
357
|
+
}
|
|
358
|
+
catch (err) {
|
|
359
|
+
return { ok: false, message: `read ${formFilePath} failed: ${err instanceof Error ? err.message : err}` };
|
|
360
|
+
}
|
|
361
|
+
let parsed;
|
|
362
|
+
try {
|
|
363
|
+
parsed = JSON.parse(raw);
|
|
364
|
+
}
|
|
365
|
+
catch (err) {
|
|
366
|
+
return { ok: false, message: `${formFilePath} is not valid JSON: ${err instanceof Error ? err.message : err}` };
|
|
367
|
+
}
|
|
368
|
+
// Accept either:
|
|
369
|
+
// (a) a flat { name: value } map, or
|
|
370
|
+
// (b) the full FormTemplate shape (fields:[{ name, value }, …])
|
|
371
|
+
const overrides = {};
|
|
372
|
+
if (parsed && typeof parsed === "object" && Array.isArray(parsed.fields)) {
|
|
373
|
+
for (const f of parsed.fields) {
|
|
374
|
+
if (typeof f.name === "string" && typeof f.value === "string" && f.value.length > 0) {
|
|
375
|
+
overrides[f.name] = f.value;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
else if (parsed && typeof parsed === "object") {
|
|
380
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
381
|
+
if (typeof v === "string" && v.length > 0)
|
|
382
|
+
overrides[k] = v;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
return { ok: false, message: "form file must be a JSON object or FormTemplate" };
|
|
387
|
+
}
|
|
388
|
+
return {
|
|
389
|
+
ok: true,
|
|
390
|
+
profile: {
|
|
391
|
+
...profile,
|
|
392
|
+
custom: { ...(profile.custom ?? {}), ...overrides },
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
export function buildBespokeApplySchema(cfg) {
|
|
397
|
+
const standard = [
|
|
398
|
+
{ label: "Name", required: true, fields: [{ name: "name", type: "input_text" }] },
|
|
399
|
+
{ label: "Email", required: true, fields: [{ name: "email", type: "input_text" }] },
|
|
400
|
+
{ label: "Phone", required: true, fields: [{ name: "phone", type: "input_text" }] },
|
|
401
|
+
{ label: "Resume", required: true, fields: [{ name: "resume", type: "input_file" }] },
|
|
402
|
+
];
|
|
403
|
+
return {
|
|
404
|
+
source: cfg.source,
|
|
405
|
+
post_id: cfg.postId,
|
|
406
|
+
job_title: cfg.jobTitle,
|
|
407
|
+
apply_url: cfg.applyUrl,
|
|
408
|
+
submit_endpoint: cfg.submitEndpoint,
|
|
409
|
+
submit_method: cfg.submitEndpoint ? "POST" : undefined,
|
|
410
|
+
submit_kind: cfg.submitKind ?? "multipart-session",
|
|
411
|
+
submit_notes: cfg.submitNotes,
|
|
412
|
+
endpoint_verified: cfg.endpointVerified,
|
|
413
|
+
questions: [...standard, ...(cfg.extraQuestions ?? [])],
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
export async function submitApplication(staged, target, options = {}) {
|
|
417
|
+
if (!staged.submit_endpoint) {
|
|
418
|
+
return {
|
|
419
|
+
ok: false,
|
|
420
|
+
posted_to: "",
|
|
421
|
+
message: "no submit_endpoint on staged application — this adapter family doesn't expose a public submission API",
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
if (!staged.ready) {
|
|
425
|
+
return {
|
|
426
|
+
ok: false,
|
|
427
|
+
posted_to: "",
|
|
428
|
+
message: `${staged.unanswered_required.length} required field(s) still unanswered; fill them before submitting`,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
if (target.kind === "dry-run") {
|
|
432
|
+
return {
|
|
433
|
+
ok: false,
|
|
434
|
+
posted_to: "dry-run (no network)",
|
|
435
|
+
message: "dry-run requested — no HTTP call fired",
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
const url = target.kind === "debug" ? target.url : staged.submit_endpoint;
|
|
439
|
+
const fd = await buildMultipartForm(staged);
|
|
440
|
+
const headers = {
|
|
441
|
+
// Don't set Content-Type — fetch/undici picks the correct
|
|
442
|
+
// multipart/form-data boundary for the FormData instance.
|
|
443
|
+
Accept: "application/json, text/plain, */*",
|
|
444
|
+
"User-Agent": "job-pro/0.9 (https://github.com/HA7CH/job-pro)",
|
|
445
|
+
};
|
|
446
|
+
// Layer in captured-session headers (Cookie, X-Xsrf-Token, etc.) only
|
|
447
|
+
// when we're actually hitting the upstream endpoint. Debug echo endpoints
|
|
448
|
+
// (httpbin) don't need them and might log them, so we strip there.
|
|
449
|
+
if (target.kind === "upstream" && options.session) {
|
|
450
|
+
const targetHost = (() => {
|
|
451
|
+
try {
|
|
452
|
+
return new URL(url).hostname;
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
return undefined;
|
|
456
|
+
}
|
|
457
|
+
})();
|
|
458
|
+
const cookieHeader = serializeCookieHeader(options.session, targetHost);
|
|
459
|
+
if (cookieHeader)
|
|
460
|
+
headers.Cookie = cookieHeader;
|
|
461
|
+
for (const [k, v] of Object.entries(options.session.headers ?? {})) {
|
|
462
|
+
// Skip cookie — already handled. Skip content-type — let undici set
|
|
463
|
+
// the multipart boundary one. Skip authorization-bearer only if the
|
|
464
|
+
// upstream's auth model isn't cookie-based.
|
|
465
|
+
if (k === "cookie" || k === "content-type")
|
|
466
|
+
continue;
|
|
467
|
+
// Normalise to canonical casing — fetch's Headers preserves what we set.
|
|
468
|
+
headers[k] = v;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
if (options.extraHeaders) {
|
|
472
|
+
Object.assign(headers, options.extraHeaders);
|
|
473
|
+
}
|
|
474
|
+
const r = await fetchWithRetry(url, { method: staged.submit_method ?? "POST", headers, body: fd }, "submit");
|
|
475
|
+
if (!r.ok) {
|
|
476
|
+
return {
|
|
477
|
+
ok: false,
|
|
478
|
+
posted_to: url,
|
|
479
|
+
status: r.status,
|
|
480
|
+
message: r.message,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
const response = r.response;
|
|
484
|
+
let preview = "";
|
|
485
|
+
try {
|
|
486
|
+
preview = (await response.text()).slice(0, 4000);
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
/* binary response is fine */
|
|
490
|
+
}
|
|
491
|
+
return {
|
|
492
|
+
ok: response.ok,
|
|
493
|
+
status: response.status,
|
|
494
|
+
posted_to: url,
|
|
495
|
+
response_preview: preview,
|
|
496
|
+
message: response.ok
|
|
497
|
+
? `submission accepted (HTTP ${response.status})`
|
|
498
|
+
: `upstream rejected: HTTP ${response.status} ${response.statusText}`,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
async function buildMultipartForm(staged) {
|
|
502
|
+
const fd = new FormData();
|
|
503
|
+
for (const field of staged.staged) {
|
|
504
|
+
if (!field.value)
|
|
505
|
+
continue;
|
|
506
|
+
if (field.type === "input_file") {
|
|
507
|
+
// Read the file synchronously — these are resumes, KB-range PDFs.
|
|
508
|
+
// For debug endpoints we still attach the actual file so the
|
|
509
|
+
// multipart wire format matches production exactly.
|
|
510
|
+
let stat;
|
|
511
|
+
try {
|
|
512
|
+
stat = statSync(field.value);
|
|
513
|
+
}
|
|
514
|
+
catch (err) {
|
|
515
|
+
throw new Error(`could not stat resume file ${field.value}: ${err instanceof Error ? err.message : err}`);
|
|
516
|
+
}
|
|
517
|
+
if (!stat.isFile()) {
|
|
518
|
+
throw new Error(`resume path is not a file: ${field.value}`);
|
|
519
|
+
}
|
|
520
|
+
const bytes = readFileSync(field.value);
|
|
521
|
+
const filename = basename(field.value);
|
|
522
|
+
// Best-effort content type from extension; ATS-side typically
|
|
523
|
+
// re-detects from magic bytes anyway.
|
|
524
|
+
const ext = filename.toLowerCase().split(".").pop() ?? "";
|
|
525
|
+
const mime = ext === "pdf"
|
|
526
|
+
? "application/pdf"
|
|
527
|
+
: ext === "docx"
|
|
528
|
+
? "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
529
|
+
: ext === "doc"
|
|
530
|
+
? "application/msword"
|
|
531
|
+
: "application/octet-stream";
|
|
532
|
+
// Node 20+ has a global File constructor; for older runtimes, fall
|
|
533
|
+
// back to a Blob. We bumped engines.node >=18 — Blob is universal.
|
|
534
|
+
const FileCtor = globalThis.File;
|
|
535
|
+
const part = typeof FileCtor === "function"
|
|
536
|
+
? new FileCtor([new Uint8Array(bytes)], filename, { type: mime })
|
|
537
|
+
: new Blob([new Uint8Array(bytes)], { type: mime });
|
|
538
|
+
fd.append(field.name, part, filename);
|
|
539
|
+
}
|
|
540
|
+
else {
|
|
541
|
+
fd.append(field.name, field.value);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return fd;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Feishu Recruiting 3-step submission. Used by every 🟡 feishu-3-step
|
|
548
|
+
* adapter (xiaomi / nio / minimax / zhipu / iqiyi / agibot / zerooneai /
|
|
549
|
+
* baichuan, and moonshot when wired through the Feishu helper).
|
|
550
|
+
*
|
|
551
|
+
* Steps:
|
|
552
|
+
* 1. POST {host}/api/v1/attachment/upload/tokens
|
|
553
|
+
* body: { filename, file_size }
|
|
554
|
+
* → { code:0, data:{ upload_url, attachment_id, fields:{…} } }
|
|
555
|
+
* 2. POST/PUT to data.upload_url (lf-package-cn.feishucdn.com or similar)
|
|
556
|
+
* multipart/form-data with fields[…] + file bytes
|
|
557
|
+
* 3. POST {host}/api/v1/user/applications (was /api/v1/resume/apply
|
|
558
|
+
* pre-1.0.62; the real route discovered via atsx-throne SPA chunk
|
|
559
|
+
* dump). Body: { post_id, attachment_id, applicant_info:{ name,
|
|
560
|
+
* email, phone } } → { code:0, data:{ application_id } }
|
|
561
|
+
*
|
|
562
|
+
* Session.json must contain valid Feishu cookies (typically `_csrf_token`,
|
|
563
|
+
* `lark_oapi_session`, `passport_csrf_token`) for the host.
|
|
564
|
+
*/
|
|
565
|
+
export async function executeFeishu3Step(staged, session, target) {
|
|
566
|
+
if (!staged.submit_endpoint) {
|
|
567
|
+
return { ok: false, posted_to: "", message: "no submit_endpoint", steps: [] };
|
|
568
|
+
}
|
|
569
|
+
if (target.kind === "dry-run") {
|
|
570
|
+
return {
|
|
571
|
+
ok: false,
|
|
572
|
+
posted_to: "dry-run (no network)",
|
|
573
|
+
message: "dry-run requested — no HTTP call fired",
|
|
574
|
+
steps: [],
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
if (target.kind === "upstream" && !session) {
|
|
578
|
+
return {
|
|
579
|
+
ok: false,
|
|
580
|
+
posted_to: staged.submit_endpoint,
|
|
581
|
+
message: "executeFeishu3Step requires a captured session (~/.jobpro/<adapter>.session.json) " +
|
|
582
|
+
"— Feishu apply endpoints all gate on candidate-session cookies. Run `job-pro extension` " +
|
|
583
|
+
"for the bundled MV3 path + install walkthrough, log into the careers site, click Export.",
|
|
584
|
+
steps: [],
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
const submitUrl = new URL(staged.submit_endpoint);
|
|
588
|
+
const host = submitUrl.host;
|
|
589
|
+
const apiRoot = `${submitUrl.protocol}//${host}/api/v1`;
|
|
590
|
+
const debug = target.kind === "debug";
|
|
591
|
+
// Resolve the resume file from staged fields.
|
|
592
|
+
const resumeField = staged.staged.find((f) => f.name === "resume");
|
|
593
|
+
if (!resumeField || !resumeField.value) {
|
|
594
|
+
return { ok: false, posted_to: "", message: "staged.resume missing", steps: [] };
|
|
595
|
+
}
|
|
596
|
+
let resumeBytes;
|
|
597
|
+
try {
|
|
598
|
+
resumeBytes = readFileSync(resumeField.value);
|
|
599
|
+
}
|
|
600
|
+
catch (err) {
|
|
601
|
+
return {
|
|
602
|
+
ok: false,
|
|
603
|
+
posted_to: "",
|
|
604
|
+
message: `could not read resume ${resumeField.value}: ${err instanceof Error ? err.message : err}`,
|
|
605
|
+
steps: [],
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
const filename = resumeField.value.split("/").pop() ?? "resume.pdf";
|
|
609
|
+
const fileSize = resumeBytes.length;
|
|
610
|
+
const steps = [];
|
|
611
|
+
const sessionHeaders = sessionHeaderBag(session, host);
|
|
612
|
+
// STEP 1 — upload tokens
|
|
613
|
+
const step1Url = debug ? target.url : `${apiRoot}/attachment/upload/tokens`;
|
|
614
|
+
const s1 = await doStep("upload-tokens", step1Url, {
|
|
615
|
+
method: "POST",
|
|
616
|
+
headers: {
|
|
617
|
+
...sessionHeaders,
|
|
618
|
+
"Content-Type": "application/json",
|
|
619
|
+
Accept: "application/json, text/plain, */*",
|
|
620
|
+
},
|
|
621
|
+
body: JSON.stringify({ filename, file_size: fileSize }),
|
|
622
|
+
}, steps);
|
|
623
|
+
if (!s1.ok) {
|
|
624
|
+
return { ok: false, posted_to: step1Url, status: s1.status, message: `step 1 failed: ${s1.message}`, steps };
|
|
625
|
+
}
|
|
626
|
+
const step1Resp = s1.response;
|
|
627
|
+
const step1Body = s1.text;
|
|
628
|
+
// In debug mode, we don't actually have a presigned URL — short-circuit.
|
|
629
|
+
if (debug) {
|
|
630
|
+
return {
|
|
631
|
+
ok: true,
|
|
632
|
+
posted_to: step1Url,
|
|
633
|
+
status: step1Resp.status,
|
|
634
|
+
message: "debug-submit-to: step 1 fired; steps 2+3 skipped (no real upload URL in echo response)",
|
|
635
|
+
steps,
|
|
636
|
+
response_preview: step1Body.slice(0, 4000),
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
let step1Parsed;
|
|
640
|
+
try {
|
|
641
|
+
step1Parsed = JSON.parse(step1Body);
|
|
642
|
+
}
|
|
643
|
+
catch {
|
|
644
|
+
return { ok: false, posted_to: step1Url, message: "step 1 returned non-JSON", steps };
|
|
645
|
+
}
|
|
646
|
+
if (step1Parsed.code !== 0 || !step1Parsed.data?.upload_url) {
|
|
647
|
+
return {
|
|
648
|
+
ok: false,
|
|
649
|
+
posted_to: step1Url,
|
|
650
|
+
message: `step 1 upstream error: ${step1Parsed.message ?? `code=${step1Parsed.code}`}`,
|
|
651
|
+
steps,
|
|
652
|
+
response_preview: step1Body.slice(0, 4000),
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
const { upload_url, attachment_id, fields } = step1Parsed.data;
|
|
656
|
+
// STEP 2 — upload resume to presigned URL
|
|
657
|
+
const uploadFd = new FormData();
|
|
658
|
+
for (const [k, v] of Object.entries(fields ?? {}))
|
|
659
|
+
uploadFd.append(k, v);
|
|
660
|
+
const FileCtor = globalThis.File;
|
|
661
|
+
const filePart = typeof FileCtor === "function"
|
|
662
|
+
? new FileCtor([new Uint8Array(resumeBytes)], filename, { type: "application/pdf" })
|
|
663
|
+
: new Blob([new Uint8Array(resumeBytes)], { type: "application/pdf" });
|
|
664
|
+
uploadFd.append("file", filePart, filename);
|
|
665
|
+
const s2 = await doStep("upload-file", upload_url, { method: "POST", body: uploadFd }, steps);
|
|
666
|
+
if (!s2.ok) {
|
|
667
|
+
return { ok: false, posted_to: upload_url, status: s2.status, message: `step 2 failed: ${s2.message}`, steps };
|
|
668
|
+
}
|
|
669
|
+
// s2 already pushed to steps via doStep; if upstream returned non-2xx
|
|
670
|
+
// (after retries on 5xx), surface that.
|
|
671
|
+
if (!s2.response.ok) {
|
|
672
|
+
return { ok: false, posted_to: upload_url, status: s2.response.status, message: "step 2 failed (upload to CDN)", steps };
|
|
673
|
+
}
|
|
674
|
+
// STEP 3 — final apply call. Uses staged.submit_endpoint (e.g.
|
|
675
|
+
// /api/v1/user/applications, verified in 1.0.62 + 1.0.63) rather than
|
|
676
|
+
// hardcoding, so the schema is the single source of truth.
|
|
677
|
+
const applicantInfo = {};
|
|
678
|
+
for (const f of staged.staged) {
|
|
679
|
+
if (f.name === "name" || f.name === "email" || f.name === "phone") {
|
|
680
|
+
applicantInfo[f.name] = f.value;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
const step3Body = {
|
|
684
|
+
post_id: staged.post_id,
|
|
685
|
+
attachment_id,
|
|
686
|
+
applicant_info: applicantInfo,
|
|
687
|
+
};
|
|
688
|
+
const step3Url = debug
|
|
689
|
+
? target.url
|
|
690
|
+
: (staged.submit_endpoint ?? `${apiRoot}/user/applications`);
|
|
691
|
+
const s3 = await doStep("apply", step3Url, {
|
|
692
|
+
method: "POST",
|
|
693
|
+
headers: {
|
|
694
|
+
...sessionHeaders,
|
|
695
|
+
"Content-Type": "application/json",
|
|
696
|
+
Accept: "application/json, text/plain, */*",
|
|
697
|
+
},
|
|
698
|
+
body: JSON.stringify(step3Body),
|
|
699
|
+
}, steps);
|
|
700
|
+
if (!s3.ok) {
|
|
701
|
+
return { ok: false, posted_to: step3Url, status: s3.status, message: `step 3 failed: ${s3.message}`, steps };
|
|
702
|
+
}
|
|
703
|
+
return {
|
|
704
|
+
ok: s3.response.ok,
|
|
705
|
+
status: s3.response.status,
|
|
706
|
+
posted_to: step3Url,
|
|
707
|
+
response_preview: s3.text,
|
|
708
|
+
message: s3.response.ok ? "Feishu 3-step submission accepted" : `step 3 rejected: HTTP ${s3.response.status}`,
|
|
709
|
+
steps,
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
async function fetchWithRetry(url, init, label, log) {
|
|
713
|
+
const maxRetries = Math.max(0, Math.min(5, Number.parseInt(process.env.JOB_PRO_RETRY ?? "2", 10) || 2));
|
|
714
|
+
let lastErr = "";
|
|
715
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
716
|
+
let response = null;
|
|
717
|
+
try {
|
|
718
|
+
response = await fetch(url, init);
|
|
719
|
+
}
|
|
720
|
+
catch (err) {
|
|
721
|
+
lastErr = `network error: ${err instanceof Error ? err.message : String(err)}`;
|
|
722
|
+
log?.push({ attempt: attempt + 1, ok: false, message: `${label}: ${lastErr}` });
|
|
723
|
+
// Network errors are retryable. Back off and try again.
|
|
724
|
+
if (attempt < maxRetries) {
|
|
725
|
+
await sleep(retryDelayMs(attempt));
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
return { ok: false, message: lastErr };
|
|
729
|
+
}
|
|
730
|
+
// 4xx → user error, don't retry. Enrich with a hint pointing at the most
|
|
731
|
+
// likely cause — bare "HTTP 401: " gives the user nothing to act on.
|
|
732
|
+
if (response.status >= 400 && response.status < 500) {
|
|
733
|
+
const hint = hintForStatus(response.status);
|
|
734
|
+
const message = `HTTP ${response.status}: ${response.statusText}${hint ? ` — ${hint}` : ""}`;
|
|
735
|
+
log?.push({ attempt: attempt + 1, ok: false, status: response.status, message: `${label}: HTTP ${response.status} (no retry — 4xx)` });
|
|
736
|
+
return { ok: false, status: response.status, message };
|
|
737
|
+
}
|
|
738
|
+
// 5xx → server error, retry.
|
|
739
|
+
if (response.status >= 500 && attempt < maxRetries) {
|
|
740
|
+
lastErr = `HTTP ${response.status}: ${response.statusText}`;
|
|
741
|
+
log?.push({ attempt: attempt + 1, ok: false, status: response.status, message: `${label}: ${lastErr} (will retry)` });
|
|
742
|
+
await sleep(retryDelayMs(attempt));
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
log?.push({ attempt: attempt + 1, ok: response.ok, status: response.status, message: `${label}: HTTP ${response.status}` });
|
|
746
|
+
return { ok: true, response };
|
|
747
|
+
}
|
|
748
|
+
return { ok: false, message: lastErr || "exhausted retries" };
|
|
749
|
+
}
|
|
750
|
+
function hintForStatus(status) {
|
|
751
|
+
// Stale-session hints are by far the most common cause of 401/403 here —
|
|
752
|
+
// the session.json cookies have expired since capture. The
|
|
753
|
+
// really-submit-blocked / session-age gate catches >30d staleness, but
|
|
754
|
+
// sessions sometimes expire earlier (logout from another tab, password
|
|
755
|
+
// change, server-side revoke).
|
|
756
|
+
if (status === 401 || status === 403) {
|
|
757
|
+
return "session likely stale — recapture via `job-pro extension`, log into the careers site, click Export";
|
|
758
|
+
}
|
|
759
|
+
if (status === 404) {
|
|
760
|
+
return "endpoint not found — submit_endpoint may have drifted upstream; verify via `apply --schema` + `--debug-submit-to`";
|
|
761
|
+
}
|
|
762
|
+
if (status === 422 || status === 400) {
|
|
763
|
+
return "request rejected — likely a missing/malformed answer; rerun `apply --interactive` to refill required fields";
|
|
764
|
+
}
|
|
765
|
+
if (status === 429) {
|
|
766
|
+
return "rate limited — retry after a few minutes";
|
|
767
|
+
}
|
|
768
|
+
return "";
|
|
769
|
+
}
|
|
770
|
+
function retryDelayMs(attempt) {
|
|
771
|
+
// Exponential backoff with jitter: 250ms / 500ms / 1s / 2s / 4s, ±25%.
|
|
772
|
+
const base = 250 * Math.pow(2, attempt);
|
|
773
|
+
const jitter = base * (Math.random() * 0.5 - 0.25);
|
|
774
|
+
return Math.round(base + jitter);
|
|
775
|
+
}
|
|
776
|
+
function sleep(ms) {
|
|
777
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Family-executor convenience wrapper. Combines fetchWithRetry's
|
|
781
|
+
* transient-failure handling with the FeishuStepLog bookkeeping that
|
|
782
|
+
* each executor needs to push into result.steps. Returns the response
|
|
783
|
+
* + decoded text, or the error message; either way appends one entry
|
|
784
|
+
* to `steps[]`.
|
|
785
|
+
*/
|
|
786
|
+
async function doStep(step, url, init, steps) {
|
|
787
|
+
const r = await fetchWithRetry(url, init, step);
|
|
788
|
+
if (!r.ok) {
|
|
789
|
+
steps.push({
|
|
790
|
+
step,
|
|
791
|
+
url,
|
|
792
|
+
status: r.status ?? 0,
|
|
793
|
+
ok: false,
|
|
794
|
+
message: r.message.slice(0, 200),
|
|
795
|
+
});
|
|
796
|
+
return { ok: false, status: r.status, message: r.message };
|
|
797
|
+
}
|
|
798
|
+
const response = r.response;
|
|
799
|
+
let text = "";
|
|
800
|
+
try {
|
|
801
|
+
text = (await response.text()).slice(0, 4000);
|
|
802
|
+
}
|
|
803
|
+
catch {
|
|
804
|
+
/* binary or stream */
|
|
805
|
+
}
|
|
806
|
+
steps.push({
|
|
807
|
+
step,
|
|
808
|
+
url,
|
|
809
|
+
status: response.status,
|
|
810
|
+
ok: response.ok,
|
|
811
|
+
message: text.slice(0, 200) || `HTTP ${response.status}`,
|
|
812
|
+
});
|
|
813
|
+
return { ok: true, response, text };
|
|
814
|
+
}
|
|
815
|
+
/** Build the headers bag used by every Feishu/Beisen/Moka step. */
|
|
816
|
+
function sessionHeaderBag(session, targetHost) {
|
|
817
|
+
const out = {
|
|
818
|
+
"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",
|
|
819
|
+
};
|
|
820
|
+
if (!session)
|
|
821
|
+
return out;
|
|
822
|
+
const cookieHeader = serializeCookieHeader(session, targetHost);
|
|
823
|
+
if (cookieHeader)
|
|
824
|
+
out.Cookie = cookieHeader;
|
|
825
|
+
for (const [k, v] of Object.entries(session.headers ?? {})) {
|
|
826
|
+
if (k.toLowerCase() === "cookie" || k.toLowerCase() === "content-type")
|
|
827
|
+
continue;
|
|
828
|
+
out[k] = v;
|
|
829
|
+
}
|
|
830
|
+
return out;
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Moka (app.mokahr.com) — covers megvii / deepseek / galaxyuniversal /
|
|
834
|
+
* stepfun / moonshot / cambricon / geely.
|
|
835
|
+
*
|
|
836
|
+
* Flow (probed from recruitmentWeb-*.js, 2026-05-16):
|
|
837
|
+
* 1. POST /api/outer/ats-apply/website/applicant-limit-check
|
|
838
|
+
* body: { orgId, jobId, … } (rate-limit / dup-check)
|
|
839
|
+
* 2. POST /api/get_job_apply_form/?jobId=&orgId= (already in schema)
|
|
840
|
+
* 3. (Optional) POST /api/outer/ats-apply/website/sendApplyValidateSmsCode
|
|
841
|
+
* → user receives an SMS code; we don't auto-fetch it.
|
|
842
|
+
* 4. POST /api/outer/ats-apply/website/apply
|
|
843
|
+
* body: { orgId, jobId, formData:{ name, email, phone }, resume:{…} }
|
|
844
|
+
* Some tenants demand AES-128-CBC envelope on the body — we send
|
|
845
|
+
* plain JSON first and fall back to encryption only if the server
|
|
846
|
+
* returns the canonical Moka decryption error (code:-2003).
|
|
847
|
+
*
|
|
848
|
+
* The session.json must contain Moka's candidate-portal cookies (acw_tc,
|
|
849
|
+
* csrfCk, moka-apply, connect.sid + the org-specific session cookies).
|
|
850
|
+
*/
|
|
851
|
+
export async function executeMokaApply(staged, session, target) {
|
|
852
|
+
if (!staged.submit_endpoint)
|
|
853
|
+
return { ok: false, posted_to: "", message: "no submit_endpoint", steps: [] };
|
|
854
|
+
if (target.kind === "dry-run") {
|
|
855
|
+
return { ok: false, posted_to: "dry-run (no network)", message: "dry-run requested — no HTTP call fired", steps: [] };
|
|
856
|
+
}
|
|
857
|
+
if (target.kind === "upstream" && !session) {
|
|
858
|
+
return {
|
|
859
|
+
ok: false,
|
|
860
|
+
posted_to: staged.submit_endpoint,
|
|
861
|
+
message: "executeMokaApply requires session.json (Moka candidate-portal cookies). " +
|
|
862
|
+
"Capture via extension/, drop under ~/.jobpro/<adapter>.session.json.",
|
|
863
|
+
steps: [],
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
// Resume + applicant_info from staged.
|
|
867
|
+
const resumeField = staged.staged.find((f) => f.name === "resume");
|
|
868
|
+
if (!resumeField?.value)
|
|
869
|
+
return { ok: false, posted_to: "", message: "staged.resume missing", steps: [] };
|
|
870
|
+
let resumeBytes;
|
|
871
|
+
try {
|
|
872
|
+
resumeBytes = readFileSync(resumeField.value);
|
|
873
|
+
}
|
|
874
|
+
catch (err) {
|
|
875
|
+
return { ok: false, posted_to: "", message: `read ${resumeField.value} failed: ${err instanceof Error ? err.message : err}`, steps: [] };
|
|
876
|
+
}
|
|
877
|
+
const filename = resumeField.value.split("/").pop() ?? "resume.pdf";
|
|
878
|
+
const submitUrl = new URL(staged.submit_endpoint);
|
|
879
|
+
const host = submitUrl.host;
|
|
880
|
+
const apiRoot = `${submitUrl.protocol}//${host}`;
|
|
881
|
+
const debug = target.kind === "debug";
|
|
882
|
+
const targetUrl = debug ? target.url : staged.submit_endpoint;
|
|
883
|
+
const applicant = {};
|
|
884
|
+
for (const f of staged.staged) {
|
|
885
|
+
if (f.name === "name" || f.name === "email" || f.name === "phone")
|
|
886
|
+
applicant[f.name] = f.value;
|
|
887
|
+
}
|
|
888
|
+
// Moka multipart: form fields + resume file. Tenant `orgId` and `jobId`
|
|
889
|
+
// are derivable from staged.apply_url (#/jobs/<id>) and staged.source
|
|
890
|
+
// (`app.mokahr.com/<slug>`); we extract them here.
|
|
891
|
+
const slug = staged.source.split("/").pop() ?? "";
|
|
892
|
+
const fd = new FormData();
|
|
893
|
+
fd.append("orgId", slug);
|
|
894
|
+
fd.append("jobId", staged.post_id);
|
|
895
|
+
fd.append("name", applicant.name ?? "");
|
|
896
|
+
fd.append("email", applicant.email ?? "");
|
|
897
|
+
fd.append("phone", applicant.phone ?? "");
|
|
898
|
+
const FileCtor = globalThis.File;
|
|
899
|
+
const filePart = typeof FileCtor === "function"
|
|
900
|
+
? new FileCtor([new Uint8Array(resumeBytes)], filename, { type: "application/pdf" })
|
|
901
|
+
: new Blob([new Uint8Array(resumeBytes)], { type: "application/pdf" });
|
|
902
|
+
fd.append("resume", filePart, filename);
|
|
903
|
+
const steps = [];
|
|
904
|
+
const sessionHeaders = sessionHeaderBag(session, host);
|
|
905
|
+
// Pre-flight limit check (optional — skip in debug since we'd redirect).
|
|
906
|
+
// Best-effort; we ignore failures here because the upstream submit will
|
|
907
|
+
// surface any blocker more authoritatively.
|
|
908
|
+
if (!debug && session) {
|
|
909
|
+
const lc = `${apiRoot}/api/outer/ats-apply/website/applicant-limit-check`;
|
|
910
|
+
await doStep("limit-check", lc, {
|
|
911
|
+
method: "POST",
|
|
912
|
+
headers: { ...sessionHeaders, "Content-Type": "application/json" },
|
|
913
|
+
body: JSON.stringify({ orgId: slug, jobId: staged.post_id }),
|
|
914
|
+
}, steps);
|
|
915
|
+
}
|
|
916
|
+
// Final submit
|
|
917
|
+
const sFinal = await doStep("apply", targetUrl, {
|
|
918
|
+
method: "POST",
|
|
919
|
+
headers: sessionHeaders, // Content-Type: multipart/form-data; boundary set by undici
|
|
920
|
+
body: fd,
|
|
921
|
+
}, steps);
|
|
922
|
+
if (!sFinal.ok) {
|
|
923
|
+
return { ok: false, posted_to: targetUrl, status: sFinal.status, message: `apply failed: ${sFinal.message}`, steps };
|
|
924
|
+
}
|
|
925
|
+
const resp = sFinal.response;
|
|
926
|
+
return {
|
|
927
|
+
ok: resp.ok,
|
|
928
|
+
status: resp.status,
|
|
929
|
+
posted_to: targetUrl,
|
|
930
|
+
response_preview: sFinal.text,
|
|
931
|
+
message: resp.ok ? "Moka apply submitted" : `Moka apply rejected: HTTP ${resp.status}`,
|
|
932
|
+
steps,
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Beisen Wecruit — covers sensetime / horizonrobotics.
|
|
937
|
+
*
|
|
938
|
+
* Flow (probed from hr.sensetime.com/pb/js/vendor.js):
|
|
939
|
+
* 1. POST /wecruit/resume/upload/file/save/<SU> (multipart, returns attachment id)
|
|
940
|
+
* 2. POST /wecruit/resume/info/add/<SU> (profile fields)
|
|
941
|
+
* 3. POST /wecruit/delivery/resume/<SU> (final submit with post_id + attachment)
|
|
942
|
+
*/
|
|
943
|
+
export async function executeBeisenWecruit(staged, session, target) {
|
|
944
|
+
if (!staged.submit_endpoint)
|
|
945
|
+
return { ok: false, posted_to: "", message: "no submit_endpoint", steps: [] };
|
|
946
|
+
if (target.kind === "dry-run") {
|
|
947
|
+
return { ok: false, posted_to: "dry-run (no network)", message: "dry-run requested — no HTTP call fired", steps: [] };
|
|
948
|
+
}
|
|
949
|
+
if (target.kind === "upstream" && !session) {
|
|
950
|
+
return {
|
|
951
|
+
ok: false,
|
|
952
|
+
posted_to: staged.submit_endpoint,
|
|
953
|
+
message: "executeBeisenWecruit requires session.json (Wecruit candidate session via WeChat OAuth / phone OTP). " +
|
|
954
|
+
"Capture via extension/.",
|
|
955
|
+
steps: [],
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
const resumeField = staged.staged.find((f) => f.name === "resume");
|
|
959
|
+
if (!resumeField?.value)
|
|
960
|
+
return { ok: false, posted_to: "", message: "staged.resume missing", steps: [] };
|
|
961
|
+
let resumeBytes;
|
|
962
|
+
try {
|
|
963
|
+
resumeBytes = readFileSync(resumeField.value);
|
|
964
|
+
}
|
|
965
|
+
catch (err) {
|
|
966
|
+
return { ok: false, posted_to: "", message: `read ${resumeField.value} failed: ${err instanceof Error ? err.message : err}`, steps: [] };
|
|
967
|
+
}
|
|
968
|
+
const filename = resumeField.value.split("/").pop() ?? "resume.pdf";
|
|
969
|
+
// Extract the channel SU from submit_endpoint (.../wecruit/delivery/resume/<SU>)
|
|
970
|
+
const su = staged.submit_endpoint.split("/").pop() ?? "";
|
|
971
|
+
const url = new URL(staged.submit_endpoint);
|
|
972
|
+
const host = url.host;
|
|
973
|
+
const apiBase = `${url.protocol}//${host}/wecruit`;
|
|
974
|
+
const debug = target.kind === "debug";
|
|
975
|
+
// X-Requested-With is required for Beisen Wecruit Nginx routing —
|
|
976
|
+
// without it the request falls through to the SPA HTML (verified
|
|
977
|
+
// via probe in 1.0.63). Inject unconditionally even if the captured
|
|
978
|
+
// session.json didn't include it.
|
|
979
|
+
const sessionHeaders = {
|
|
980
|
+
...sessionHeaderBag(session, host),
|
|
981
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
982
|
+
};
|
|
983
|
+
const FileCtor = globalThis.File;
|
|
984
|
+
const steps = [];
|
|
985
|
+
const applicant = {};
|
|
986
|
+
for (const f of staged.staged) {
|
|
987
|
+
if (f.name === "name" || f.name === "email" || f.name === "phone")
|
|
988
|
+
applicant[f.name] = f.value;
|
|
989
|
+
}
|
|
990
|
+
// STEP 1 — upload resume file
|
|
991
|
+
const step1Url = debug ? target.url : `${apiBase}/resume/upload/file/save/${encodeURIComponent(su)}`;
|
|
992
|
+
const uploadFd = new FormData();
|
|
993
|
+
const filePart = typeof FileCtor === "function"
|
|
994
|
+
? new FileCtor([new Uint8Array(resumeBytes)], filename, { type: "application/pdf" })
|
|
995
|
+
: new Blob([new Uint8Array(resumeBytes)], { type: "application/pdf" });
|
|
996
|
+
uploadFd.append("file", filePart, filename);
|
|
997
|
+
const s1 = await doStep("upload-file", step1Url, { method: "POST", headers: sessionHeaders, body: uploadFd }, steps);
|
|
998
|
+
if (!s1.ok) {
|
|
999
|
+
return { ok: false, posted_to: step1Url, status: s1.status, message: `step 1 failed: ${s1.message}`, steps };
|
|
1000
|
+
}
|
|
1001
|
+
const r1 = s1.response;
|
|
1002
|
+
const text1 = s1.text;
|
|
1003
|
+
if (debug) {
|
|
1004
|
+
return { ok: r1.ok, status: r1.status, posted_to: step1Url, message: "debug: step 1 fired, steps 2+3 skipped", steps, response_preview: text1 };
|
|
1005
|
+
}
|
|
1006
|
+
if (!r1.ok) {
|
|
1007
|
+
return { ok: false, posted_to: step1Url, status: r1.status, message: "step 1 failed", steps, response_preview: text1 };
|
|
1008
|
+
}
|
|
1009
|
+
let attachmentId = "";
|
|
1010
|
+
try {
|
|
1011
|
+
const parsed = JSON.parse(text1);
|
|
1012
|
+
attachmentId = parsed?.data?.attachmentId ?? parsed?.data?.id ?? parsed?.data?.fileId ?? "";
|
|
1013
|
+
}
|
|
1014
|
+
catch { /* keep empty */ }
|
|
1015
|
+
// STEP 2 — profile info
|
|
1016
|
+
const step2Url = `${apiBase}/resume/info/add/${encodeURIComponent(su)}`;
|
|
1017
|
+
const s2 = await doStep("profile-add", step2Url, {
|
|
1018
|
+
method: "POST",
|
|
1019
|
+
headers: { ...sessionHeaders, "Content-Type": "application/json" },
|
|
1020
|
+
body: JSON.stringify({ name: applicant.name, email: applicant.email, phone: applicant.phone, attachmentId }),
|
|
1021
|
+
}, steps);
|
|
1022
|
+
if (!s2.ok) {
|
|
1023
|
+
return { ok: false, posted_to: step2Url, status: s2.status, message: `step 2 failed: ${s2.message}`, steps };
|
|
1024
|
+
}
|
|
1025
|
+
// STEP 3 — final delivery
|
|
1026
|
+
const step3Url = `${apiBase}/delivery/resume/${encodeURIComponent(su)}`;
|
|
1027
|
+
const s3 = await doStep("deliver", step3Url, {
|
|
1028
|
+
method: "POST",
|
|
1029
|
+
headers: { ...sessionHeaders, "Content-Type": "application/json" },
|
|
1030
|
+
body: JSON.stringify({ postId: staged.post_id, attachmentId }),
|
|
1031
|
+
}, steps);
|
|
1032
|
+
if (!s3.ok) {
|
|
1033
|
+
return { ok: false, posted_to: step3Url, status: s3.status, message: `step 3 failed: ${s3.message}`, steps };
|
|
1034
|
+
}
|
|
1035
|
+
const r3 = s3.response;
|
|
1036
|
+
const text3 = s3.text;
|
|
1037
|
+
return {
|
|
1038
|
+
ok: r3.ok,
|
|
1039
|
+
status: r3.status,
|
|
1040
|
+
posted_to: step3Url,
|
|
1041
|
+
response_preview: text3,
|
|
1042
|
+
message: r3.ok ? "Beisen Wecruit submission accepted" : `step 3 rejected: HTTP ${r3.status}`,
|
|
1043
|
+
steps,
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Beisen iTalent — covers vivo / iflytek.
|
|
1048
|
+
*
|
|
1049
|
+
* Flow (Beisen iTalent's typical wire pattern):
|
|
1050
|
+
* 1. POST /api/Resume/UploadResume (multipart resume)
|
|
1051
|
+
* → { Code:200, Data:{ ResumeId, Path, … } }
|
|
1052
|
+
* 2. POST /api/Apply/SubmitResume (JSON apply)
|
|
1053
|
+
* body: { JobAdId, ResumeId, Name, Email, Mobile }
|
|
1054
|
+
*/
|
|
1055
|
+
export async function executeBeisenITalent(staged, session, target) {
|
|
1056
|
+
if (!staged.submit_endpoint)
|
|
1057
|
+
return { ok: false, posted_to: "", message: "no submit_endpoint", steps: [] };
|
|
1058
|
+
if (target.kind === "dry-run") {
|
|
1059
|
+
return { ok: false, posted_to: "dry-run (no network)", message: "dry-run requested — no HTTP call fired", steps: [] };
|
|
1060
|
+
}
|
|
1061
|
+
if (target.kind === "upstream" && !session) {
|
|
1062
|
+
return {
|
|
1063
|
+
ok: false,
|
|
1064
|
+
posted_to: staged.submit_endpoint,
|
|
1065
|
+
message: "executeBeisenITalent requires session.json (iTalent candidate-portal session via email+phone+OTP). " +
|
|
1066
|
+
"Capture via extension/.",
|
|
1067
|
+
steps: [],
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
const resumeField = staged.staged.find((f) => f.name === "resume");
|
|
1071
|
+
if (!resumeField?.value)
|
|
1072
|
+
return { ok: false, posted_to: "", message: "staged.resume missing", steps: [] };
|
|
1073
|
+
let resumeBytes;
|
|
1074
|
+
try {
|
|
1075
|
+
resumeBytes = readFileSync(resumeField.value);
|
|
1076
|
+
}
|
|
1077
|
+
catch (err) {
|
|
1078
|
+
return { ok: false, posted_to: "", message: `read ${resumeField.value} failed: ${err instanceof Error ? err.message : err}`, steps: [] };
|
|
1079
|
+
}
|
|
1080
|
+
const filename = resumeField.value.split("/").pop() ?? "resume.pdf";
|
|
1081
|
+
const submitUrl = new URL(staged.submit_endpoint);
|
|
1082
|
+
const host = submitUrl.host;
|
|
1083
|
+
const apiRoot = `${submitUrl.protocol}//${host}`;
|
|
1084
|
+
const debug = target.kind === "debug";
|
|
1085
|
+
const sessionHeaders = sessionHeaderBag(session, host);
|
|
1086
|
+
const FileCtor = globalThis.File;
|
|
1087
|
+
const steps = [];
|
|
1088
|
+
const applicant = {};
|
|
1089
|
+
for (const f of staged.staged) {
|
|
1090
|
+
if (f.name === "name" || f.name === "email" || f.name === "phone")
|
|
1091
|
+
applicant[f.name] = f.value;
|
|
1092
|
+
}
|
|
1093
|
+
// STEP 1 — upload
|
|
1094
|
+
const step1Url = debug ? target.url : `${apiRoot}/api/Resume/UploadResume`;
|
|
1095
|
+
const uploadFd = new FormData();
|
|
1096
|
+
const filePart = typeof FileCtor === "function"
|
|
1097
|
+
? new FileCtor([new Uint8Array(resumeBytes)], filename, { type: "application/pdf" })
|
|
1098
|
+
: new Blob([new Uint8Array(resumeBytes)], { type: "application/pdf" });
|
|
1099
|
+
uploadFd.append("file", filePart, filename);
|
|
1100
|
+
const s1 = await doStep("upload", step1Url, { method: "POST", headers: sessionHeaders, body: uploadFd }, steps);
|
|
1101
|
+
if (!s1.ok) {
|
|
1102
|
+
return { ok: false, posted_to: step1Url, status: s1.status, message: `step 1 failed: ${s1.message}`, steps };
|
|
1103
|
+
}
|
|
1104
|
+
const r1 = s1.response;
|
|
1105
|
+
const text1 = s1.text;
|
|
1106
|
+
if (debug) {
|
|
1107
|
+
return { ok: r1.ok, status: r1.status, posted_to: step1Url, message: "debug: step 1 fired, step 2 skipped", steps, response_preview: text1 };
|
|
1108
|
+
}
|
|
1109
|
+
if (!r1.ok)
|
|
1110
|
+
return { ok: false, posted_to: step1Url, status: r1.status, message: "step 1 failed", steps, response_preview: text1 };
|
|
1111
|
+
let resumeId = "";
|
|
1112
|
+
try {
|
|
1113
|
+
const parsed = JSON.parse(text1);
|
|
1114
|
+
resumeId = parsed?.Data?.ResumeId ?? parsed?.Data?.Id ?? "";
|
|
1115
|
+
}
|
|
1116
|
+
catch { /* keep empty */ }
|
|
1117
|
+
// STEP 2 — submit apply
|
|
1118
|
+
const step2Url = `${apiRoot}/api/Apply/SubmitResume`;
|
|
1119
|
+
const s2 = await doStep("submit", step2Url, {
|
|
1120
|
+
method: "POST",
|
|
1121
|
+
headers: { ...sessionHeaders, "Content-Type": "application/json" },
|
|
1122
|
+
body: JSON.stringify({
|
|
1123
|
+
JobAdId: staged.post_id,
|
|
1124
|
+
ResumeId: resumeId,
|
|
1125
|
+
Name: applicant.name,
|
|
1126
|
+
Email: applicant.email,
|
|
1127
|
+
Mobile: applicant.phone,
|
|
1128
|
+
}),
|
|
1129
|
+
}, steps);
|
|
1130
|
+
if (!s2.ok) {
|
|
1131
|
+
return { ok: false, posted_to: step2Url, status: s2.status, message: `step 2 failed: ${s2.message}`, steps };
|
|
1132
|
+
}
|
|
1133
|
+
const r2 = s2.response;
|
|
1134
|
+
return {
|
|
1135
|
+
ok: r2.ok,
|
|
1136
|
+
status: r2.status,
|
|
1137
|
+
posted_to: step2Url,
|
|
1138
|
+
response_preview: s2.text,
|
|
1139
|
+
message: r2.ok ? "Beisen iTalent submission accepted" : `step 2 rejected: HTTP ${r2.status}`,
|
|
1140
|
+
steps,
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* CDP / real-browser submitter — used by adapters whose upstream requires
|
|
1145
|
+
* a runtime-minted anti-bot signature that we can't reproduce from raw
|
|
1146
|
+
* HTTP (today: lilith via lilithgames.jobs.feishu.cn, gated by ByteDance
|
|
1147
|
+
* Tengine's `_signature`).
|
|
1148
|
+
*
|
|
1149
|
+
* Flow:
|
|
1150
|
+
* 1. Inject cookies from session.json into the singleton puppeteer
|
|
1151
|
+
* browser via chrome.cookies.setCookie (CDP).
|
|
1152
|
+
* 2. withPage(): navigate to staged.apply_url (the SPA's detail page).
|
|
1153
|
+
* 3. Wait for the SPA's apply UI to render. The Feishu candidate-portal
|
|
1154
|
+
* pattern: the page shows a "投递" button that opens a modal with
|
|
1155
|
+
* input[name=name|email|phone] + input[type=file].
|
|
1156
|
+
* 4. Fill the fields via page.type() + uploadFile().
|
|
1157
|
+
* 5. Click the modal's "提交" button.
|
|
1158
|
+
* 6. Wait for the submission response XHR; report it.
|
|
1159
|
+
*
|
|
1160
|
+
* In debug mode we skip the click and screenshot the page instead so the
|
|
1161
|
+
* user can verify the bot actually loaded the SPA correctly.
|
|
1162
|
+
*/
|
|
1163
|
+
export async function executeCdpRealBrowser(staged, session, target) {
|
|
1164
|
+
if (target.kind === "dry-run") {
|
|
1165
|
+
return { ok: false, posted_to: "dry-run (no network)", message: "dry-run requested — no HTTP call fired", steps: [] };
|
|
1166
|
+
}
|
|
1167
|
+
// Non-anon adapters need session.json (the SPA's login cookies); anon
|
|
1168
|
+
// multipart adapters (Greenhouse/Lever boards) can fire the apply form
|
|
1169
|
+
// without a session — the DOM walker handles those too.
|
|
1170
|
+
const needsSession = staged.submit_kind !== "multipart-anon";
|
|
1171
|
+
if (target.kind === "upstream" && needsSession && !session) {
|
|
1172
|
+
return {
|
|
1173
|
+
ok: false,
|
|
1174
|
+
posted_to: staged.apply_url,
|
|
1175
|
+
message: "executeCdpRealBrowser requires session.json for non-anon adapters " +
|
|
1176
|
+
"(the SPA's login cookies need to be in the puppeteer browser before " +
|
|
1177
|
+
"navigation). Run `job-pro extension` to capture one.",
|
|
1178
|
+
steps: [],
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
const steps = [];
|
|
1182
|
+
const targetUrl = staged.apply_url;
|
|
1183
|
+
const debug = target.kind === "debug";
|
|
1184
|
+
// Inject cookies into the singleton browser.
|
|
1185
|
+
if (session) {
|
|
1186
|
+
let host = "";
|
|
1187
|
+
try {
|
|
1188
|
+
host = new URL(targetUrl).host;
|
|
1189
|
+
}
|
|
1190
|
+
catch { /* ignore */ }
|
|
1191
|
+
const inj = await injectCookies(session.cookies ?? [], host);
|
|
1192
|
+
if (!inj.ok) {
|
|
1193
|
+
steps.push({ step: "inject-cookies", url: host, status: 0, ok: false, message: inj.error.message });
|
|
1194
|
+
return { ok: false, posted_to: targetUrl, message: inj.error.message, steps };
|
|
1195
|
+
}
|
|
1196
|
+
steps.push({
|
|
1197
|
+
step: "inject-cookies",
|
|
1198
|
+
url: host,
|
|
1199
|
+
status: 200,
|
|
1200
|
+
ok: true,
|
|
1201
|
+
message: `injected ${session.cookies?.length ?? 0} cookies`,
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
// Resume + applicant_info from staged.
|
|
1205
|
+
const resumeField = staged.staged.find((f) => f.name === "resume");
|
|
1206
|
+
if (!resumeField?.value)
|
|
1207
|
+
return { ok: false, posted_to: targetUrl, message: "staged.resume missing", steps };
|
|
1208
|
+
if (!existsSync(resumeField.value)) {
|
|
1209
|
+
return { ok: false, posted_to: targetUrl, message: `resume file not found: ${resumeField.value}`, steps };
|
|
1210
|
+
}
|
|
1211
|
+
const r = await withPage(async (page) => {
|
|
1212
|
+
await page.goto(targetUrl, { waitUntil: "networkidle2", timeout: 30000 });
|
|
1213
|
+
steps.push({
|
|
1214
|
+
step: "navigate",
|
|
1215
|
+
url: page.url(),
|
|
1216
|
+
status: 200,
|
|
1217
|
+
ok: true,
|
|
1218
|
+
message: `loaded ${page.url()}`,
|
|
1219
|
+
});
|
|
1220
|
+
if (debug) {
|
|
1221
|
+
// Don't click submit — just confirm the SPA loaded.
|
|
1222
|
+
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
1223
|
+
return { kind: "debug" };
|
|
1224
|
+
}
|
|
1225
|
+
// Try to click the apply button. The label patterns we see across
|
|
1226
|
+
// the 45 verified adapters: 投递, 立即投递, 投递简历, 在线投递, 申请,
|
|
1227
|
+
// 立即申请, 申请职位, 申请岗位, 网申, Apply, Apply Now, Submit
|
|
1228
|
+
// Application. Exclude common no-go labels like "查看投递", "我的投递",
|
|
1229
|
+
// "投递记录" that link to the user's history.
|
|
1230
|
+
const clickedApply = await page.evaluate(() => {
|
|
1231
|
+
const include = /(?:^|[^查我])(?:投递|申请|网申|Apply)/i;
|
|
1232
|
+
const exclude = /(?:查看|我的|历史|记录|状态|进度|history)/i;
|
|
1233
|
+
const candidates = Array.from(document.querySelectorAll('button, a, [role="button"]'));
|
|
1234
|
+
for (const el of candidates) {
|
|
1235
|
+
const t = (el.textContent ?? "").trim();
|
|
1236
|
+
if (!t || t.length > 30)
|
|
1237
|
+
continue; // long labels rarely Apply buttons
|
|
1238
|
+
if (exclude.test(t))
|
|
1239
|
+
continue;
|
|
1240
|
+
if (include.test(t)) {
|
|
1241
|
+
el.click();
|
|
1242
|
+
return t;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
return null;
|
|
1246
|
+
});
|
|
1247
|
+
steps.push({
|
|
1248
|
+
step: "click-apply",
|
|
1249
|
+
url: page.url(),
|
|
1250
|
+
status: clickedApply ? 200 : 0,
|
|
1251
|
+
ok: !!clickedApply,
|
|
1252
|
+
message: clickedApply ?? "could not find apply button",
|
|
1253
|
+
});
|
|
1254
|
+
if (!clickedApply) {
|
|
1255
|
+
return { kind: "no-button" };
|
|
1256
|
+
}
|
|
1257
|
+
// Wait for the modal's form to render.
|
|
1258
|
+
try {
|
|
1259
|
+
await page.waitForSelector('input[type=file]', { timeout: 10000 });
|
|
1260
|
+
}
|
|
1261
|
+
catch {
|
|
1262
|
+
steps.push({ step: "wait-form", url: page.url(), status: 0, ok: false, message: "apply modal didn't render input[type=file]" });
|
|
1263
|
+
return { kind: "no-form" };
|
|
1264
|
+
}
|
|
1265
|
+
steps.push({ step: "wait-form", url: page.url(), status: 200, ok: true, message: "modal rendered" });
|
|
1266
|
+
// Fill every staged non-file field. The schema layer normalises field
|
|
1267
|
+
// names to whatever the upstream API expects (first_name, last_name,
|
|
1268
|
+
// email, phone, question_XXX for Greenhouse custom questions, name/
|
|
1269
|
+
// email/phone for Feishu, etc.) — so input[name="<f.name>"] usually
|
|
1270
|
+
// matches. Falls back to placeholder / aria-label / id contains-match.
|
|
1271
|
+
let filled = 0, missed = 0;
|
|
1272
|
+
const missedFields = [];
|
|
1273
|
+
for (const f of staged.staged) {
|
|
1274
|
+
if (f.type === "input_file")
|
|
1275
|
+
continue;
|
|
1276
|
+
if (!f.value)
|
|
1277
|
+
continue;
|
|
1278
|
+
// For *_select kinds (Greenhouse multi_value_single_select), try
|
|
1279
|
+
// native <select> first, then fall back to typing into an input.
|
|
1280
|
+
// Custom React/Vue dropdowns (Element Plus, Ant Design) need a
|
|
1281
|
+
// click-and-pick sequence we don't model — they show as missed.
|
|
1282
|
+
const isSelectKind = (f.type ?? "").includes("select");
|
|
1283
|
+
try {
|
|
1284
|
+
if (isSelectKind) {
|
|
1285
|
+
const selectSel = `select[name="${f.name}"], select[id="${f.name}"]`;
|
|
1286
|
+
const native = await page.$(selectSel);
|
|
1287
|
+
if (native) {
|
|
1288
|
+
await page.select(selectSel, f.value);
|
|
1289
|
+
filled++;
|
|
1290
|
+
continue;
|
|
1291
|
+
}
|
|
1292
|
+
// No native select — likely a custom dropdown. Skip + report.
|
|
1293
|
+
missed++;
|
|
1294
|
+
missedFields.push(`${f.name} (custom dropdown — needs human or per-adapter handler)`);
|
|
1295
|
+
continue;
|
|
1296
|
+
}
|
|
1297
|
+
const sel = `input[name="${f.name}"], textarea[name="${f.name}"], ` +
|
|
1298
|
+
`input[id="${f.name}"], textarea[id="${f.name}"], ` +
|
|
1299
|
+
`input[placeholder*="${f.name}"], input[aria-label*="${f.name}"]`;
|
|
1300
|
+
await page.type(sel, f.value, { delay: 20 });
|
|
1301
|
+
filled++;
|
|
1302
|
+
}
|
|
1303
|
+
catch {
|
|
1304
|
+
missed++;
|
|
1305
|
+
missedFields.push(f.name);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
const fillMsg = missed > 0
|
|
1309
|
+
? `filled ${filled}, missed ${missed}: ${missedFields.slice(0, 5).join(", ")}${missedFields.length > 5 ? "…" : ""}`
|
|
1310
|
+
: `filled ${filled}`;
|
|
1311
|
+
steps.push({ step: "fill-fields", url: page.url(), status: 200, ok: filled > 0, message: fillMsg });
|
|
1312
|
+
// Upload resume.
|
|
1313
|
+
try {
|
|
1314
|
+
const fileInput = await page.$('input[type=file]');
|
|
1315
|
+
if (fileInput && fileInput.uploadFile) {
|
|
1316
|
+
await fileInput.uploadFile(resumeField.value);
|
|
1317
|
+
steps.push({ step: "upload-resume", url: page.url(), status: 200, ok: true, message: resumeField.value });
|
|
1318
|
+
}
|
|
1319
|
+
else {
|
|
1320
|
+
steps.push({ step: "upload-resume", url: page.url(), status: 0, ok: false, message: "no input[type=file] handle" });
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
catch (err) {
|
|
1324
|
+
steps.push({ step: "upload-resume", url: page.url(), status: 0, ok: false, message: String(err) });
|
|
1325
|
+
}
|
|
1326
|
+
// Click the modal's submit button. Common labels: 确认投递, 提交,
|
|
1327
|
+
// 完成, 立即提交, 确认提交, Submit, Confirm. Exclude "取消", "关闭".
|
|
1328
|
+
const submittedLabel = await page.evaluate(() => {
|
|
1329
|
+
const include = /(?:确认投递|提交|确认提交|确认申请|完成|Submit|Confirm)/i;
|
|
1330
|
+
const exclude = /(?:取消|关闭|返回|Cancel|Close|Back)/i;
|
|
1331
|
+
const candidates = Array.from(document.querySelectorAll('button, [role="button"]'));
|
|
1332
|
+
for (const el of candidates) {
|
|
1333
|
+
const t = (el.textContent ?? "").trim();
|
|
1334
|
+
if (!t || t.length > 30)
|
|
1335
|
+
continue;
|
|
1336
|
+
if (exclude.test(t))
|
|
1337
|
+
continue;
|
|
1338
|
+
if (include.test(t)) {
|
|
1339
|
+
el.click();
|
|
1340
|
+
return t;
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
return null;
|
|
1344
|
+
});
|
|
1345
|
+
steps.push({
|
|
1346
|
+
step: "click-submit",
|
|
1347
|
+
url: page.url(),
|
|
1348
|
+
status: submittedLabel ? 200 : 0,
|
|
1349
|
+
ok: !!submittedLabel,
|
|
1350
|
+
message: submittedLabel ?? "could not find submit button",
|
|
1351
|
+
});
|
|
1352
|
+
// Allow the resulting XHR to settle.
|
|
1353
|
+
await new Promise((resolve) => setTimeout(resolve, 6000));
|
|
1354
|
+
return { kind: "submitted", label: submittedLabel };
|
|
1355
|
+
});
|
|
1356
|
+
if (!r.ok) {
|
|
1357
|
+
return { ok: false, posted_to: targetUrl, message: r.error.message, steps };
|
|
1358
|
+
}
|
|
1359
|
+
const kind = r.value.kind;
|
|
1360
|
+
const ok = kind === "submitted";
|
|
1361
|
+
return {
|
|
1362
|
+
ok,
|
|
1363
|
+
posted_to: targetUrl,
|
|
1364
|
+
message: kind === "debug"
|
|
1365
|
+
? "debug: navigated + screenshot, no submit click"
|
|
1366
|
+
: kind === "no-button"
|
|
1367
|
+
? "could not find an apply button on the page — the candidate session may not be logged in"
|
|
1368
|
+
: kind === "no-form"
|
|
1369
|
+
? "apply modal opened but form fields didn't render"
|
|
1370
|
+
: "CDP-driven submit completed (verify the upstream actually accepted)",
|
|
1371
|
+
steps,
|
|
1372
|
+
};
|
|
1373
|
+
}
|