@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.
Files changed (68) hide show
  1. package/dist/adapter.js +17 -0
  2. package/dist/agibot.js +399 -0
  3. package/dist/alibaba.js +509 -0
  4. package/dist/antgroup.js +397 -0
  5. package/dist/apply.js +1373 -0
  6. package/dist/baichuan.js +49 -0
  7. package/dist/baidu.js +452 -0
  8. package/dist/bilibili.js +455 -0
  9. package/dist/byd.js +412 -0
  10. package/dist/bytedance.js +619 -0
  11. package/dist/cainiao.js +56 -0
  12. package/dist/cambricon.js +33 -0
  13. package/dist/cdp.js +237 -0
  14. package/dist/cicc.js +56 -0
  15. package/dist/coverage.js +60 -0
  16. package/dist/deepseek.js +25 -0
  17. package/dist/didi.js +381 -0
  18. package/dist/feishu.js +577 -0
  19. package/dist/galaxyuniversal.js +24 -0
  20. package/dist/geely.js +35 -0
  21. package/dist/greenhouse.js +432 -0
  22. package/dist/hikvision.js +58 -0
  23. package/dist/horizonrobotics.js +46 -0
  24. package/dist/hoyoverse.js +26 -0
  25. package/dist/huawei.js +537 -0
  26. package/dist/iflytek.js +380 -0
  27. package/dist/index.js +1828 -0
  28. package/dist/iqiyi.js +494 -0
  29. package/dist/jd.js +559 -0
  30. package/dist/kuaishou.js +496 -0
  31. package/dist/lever.js +455 -0
  32. package/dist/liauto.js +393 -0
  33. package/dist/liepin.js +357 -0
  34. package/dist/lilith.js +300 -0
  35. package/dist/megvii.js +27 -0
  36. package/dist/meituan.js +633 -0
  37. package/dist/memory.js +76 -0
  38. package/dist/mihoyo.js +308 -0
  39. package/dist/minimax.js +32 -0
  40. package/dist/moka.js +473 -0
  41. package/dist/moonshot.js +24 -0
  42. package/dist/netease.js +424 -0
  43. package/dist/nio.js +24 -0
  44. package/dist/oppo.js +285 -0
  45. package/dist/pdd.js +614 -0
  46. package/dist/pingan.js +493 -0
  47. package/dist/sensetime.js +51 -0
  48. package/dist/sf.js +310 -0
  49. package/dist/stepfun.js +24 -0
  50. package/dist/tencent.js +770 -0
  51. package/dist/trip.js +396 -0
  52. package/dist/unitree.js +418 -0
  53. package/dist/vivo.js +361 -0
  54. package/dist/webank.js +55 -0
  55. package/dist/wecruit.js +438 -0
  56. package/dist/weibo.js +337 -0
  57. package/dist/weride.js +29 -0
  58. package/dist/xiaohongshu.js +480 -0
  59. package/dist/xiaomi.js +529 -0
  60. package/dist/xpeng.js +34 -0
  61. package/dist/zerooneai.js +42 -0
  62. package/dist/zhipu.js +478 -0
  63. package/extension/README.md +79 -0
  64. package/extension/background.js +177 -0
  65. package/extension/manifest.json +55 -0
  66. package/extension/popup.html +37 -0
  67. package/extension/popup.js +54 -0
  68. 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
+ }