job-pro 0.9.5 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/apply.js CHANGED
@@ -355,3 +355,215 @@ async function buildMultipartForm(staged) {
355
355
  }
356
356
  return fd;
357
357
  }
358
+ /**
359
+ * Feishu Recruiting 3-step submission. Used by every ðŸŸĄ feishu-3-step
360
+ * adapter (xiaomi / nio / minimax / zhipu / iqiyi / agibot / zerooneai /
361
+ * baichuan, and moonshot when wired through the Feishu helper).
362
+ *
363
+ * Steps:
364
+ * 1. POST {host}/api/v1/attachment/upload/tokens
365
+ * body: { filename, file_size }
366
+ * → { code:0, data:{ upload_url, attachment_id, fields:{â€Ķ} } }
367
+ * 2. POST/PUT to data.upload_url (lf-package-cn.feishucdn.com or similar)
368
+ * multipart/form-data with fields[â€Ķ] + file bytes
369
+ * 3. POST {host}/api/v1/resume/apply
370
+ * body: { post_id, attachment_id, applicant_info:{ name, email, phone } }
371
+ * → { code:0, data:{ application_id } }
372
+ *
373
+ * Session.json must contain valid Feishu cookies (typically `_csrf_token`,
374
+ * `lark_oapi_session`, `passport_csrf_token`) for the host.
375
+ */
376
+ export async function executeFeishu3Step(staged, session, target) {
377
+ if (!staged.submit_endpoint) {
378
+ return { ok: false, posted_to: "", message: "no submit_endpoint", steps: [] };
379
+ }
380
+ if (target.kind === "dry-run") {
381
+ return {
382
+ ok: false,
383
+ posted_to: "dry-run (no network)",
384
+ message: "dry-run requested — no HTTP call fired",
385
+ steps: [],
386
+ };
387
+ }
388
+ if (target.kind === "upstream" && !session) {
389
+ return {
390
+ ok: false,
391
+ posted_to: staged.submit_endpoint,
392
+ message: "executeFeishu3Step requires a captured session (~/.jobpro/<adapter>.session.json) " +
393
+ "— Feishu apply endpoints all gate on candidate-session cookies. Install extension/ " +
394
+ "in Chrome, log in to the careers site, click Export.",
395
+ steps: [],
396
+ };
397
+ }
398
+ const submitUrl = new URL(staged.submit_endpoint);
399
+ const host = submitUrl.host;
400
+ const apiRoot = `${submitUrl.protocol}//${host}/api/v1`;
401
+ const debug = target.kind === "debug";
402
+ // Resolve the resume file from staged fields.
403
+ const resumeField = staged.staged.find((f) => f.name === "resume");
404
+ if (!resumeField || !resumeField.value) {
405
+ return { ok: false, posted_to: "", message: "staged.resume missing", steps: [] };
406
+ }
407
+ let resumeBytes;
408
+ try {
409
+ resumeBytes = readFileSync(resumeField.value);
410
+ }
411
+ catch (err) {
412
+ return {
413
+ ok: false,
414
+ posted_to: "",
415
+ message: `could not read resume ${resumeField.value}: ${err instanceof Error ? err.message : err}`,
416
+ steps: [],
417
+ };
418
+ }
419
+ const filename = resumeField.value.split("/").pop() ?? "resume.pdf";
420
+ const fileSize = resumeBytes.length;
421
+ const steps = [];
422
+ const sessionHeaders = sessionHeaderBag(session, host);
423
+ // STEP 1 — upload tokens
424
+ const step1Url = debug ? target.url : `${apiRoot}/attachment/upload/tokens`;
425
+ let step1Resp;
426
+ try {
427
+ step1Resp = await fetch(step1Url, {
428
+ method: "POST",
429
+ headers: {
430
+ ...sessionHeaders,
431
+ "Content-Type": "application/json",
432
+ Accept: "application/json, text/plain, */*",
433
+ },
434
+ body: JSON.stringify({ filename, file_size: fileSize }),
435
+ });
436
+ }
437
+ catch (err) {
438
+ steps.push({ step: "upload-tokens", url: step1Url, status: 0, ok: false, message: String(err) });
439
+ return { ok: false, posted_to: step1Url, message: "step 1 network error", steps };
440
+ }
441
+ const step1Body = await step1Resp.text();
442
+ steps.push({
443
+ step: "upload-tokens",
444
+ url: step1Url,
445
+ status: step1Resp.status,
446
+ ok: step1Resp.ok,
447
+ message: step1Body.slice(0, 200),
448
+ });
449
+ if (!step1Resp.ok) {
450
+ return { ok: false, posted_to: step1Url, status: step1Resp.status, message: "step 1 failed (upload tokens)", steps, response_preview: step1Body.slice(0, 4000) };
451
+ }
452
+ // In debug mode, we don't actually have a presigned URL — short-circuit.
453
+ if (debug) {
454
+ return {
455
+ ok: true,
456
+ posted_to: step1Url,
457
+ status: step1Resp.status,
458
+ message: "debug-submit-to: step 1 fired; steps 2+3 skipped (no real upload URL in echo response)",
459
+ steps,
460
+ response_preview: step1Body.slice(0, 4000),
461
+ };
462
+ }
463
+ let step1Parsed;
464
+ try {
465
+ step1Parsed = JSON.parse(step1Body);
466
+ }
467
+ catch {
468
+ return { ok: false, posted_to: step1Url, message: "step 1 returned non-JSON", steps };
469
+ }
470
+ if (step1Parsed.code !== 0 || !step1Parsed.data?.upload_url) {
471
+ return {
472
+ ok: false,
473
+ posted_to: step1Url,
474
+ message: `step 1 upstream error: ${step1Parsed.message ?? `code=${step1Parsed.code}`}`,
475
+ steps,
476
+ response_preview: step1Body.slice(0, 4000),
477
+ };
478
+ }
479
+ const { upload_url, attachment_id, fields } = step1Parsed.data;
480
+ // STEP 2 — upload resume to presigned URL
481
+ const uploadFd = new FormData();
482
+ for (const [k, v] of Object.entries(fields ?? {}))
483
+ uploadFd.append(k, v);
484
+ const FileCtor = globalThis.File;
485
+ const filePart = typeof FileCtor === "function"
486
+ ? new FileCtor([new Uint8Array(resumeBytes)], filename, { type: "application/pdf" })
487
+ : new Blob([new Uint8Array(resumeBytes)], { type: "application/pdf" });
488
+ uploadFd.append("file", filePart, filename);
489
+ let step2Resp;
490
+ try {
491
+ step2Resp = await fetch(upload_url, { method: "POST", body: uploadFd });
492
+ }
493
+ catch (err) {
494
+ steps.push({ step: "upload-file", url: upload_url, status: 0, ok: false, message: String(err) });
495
+ return { ok: false, posted_to: upload_url, message: "step 2 network error", steps };
496
+ }
497
+ steps.push({
498
+ step: "upload-file",
499
+ url: upload_url,
500
+ status: step2Resp.status,
501
+ ok: step2Resp.ok,
502
+ message: `HTTP ${step2Resp.status}`,
503
+ });
504
+ if (!step2Resp.ok) {
505
+ return { ok: false, posted_to: upload_url, status: step2Resp.status, message: "step 2 failed (upload to CDN)", steps };
506
+ }
507
+ // STEP 3 — resume/apply
508
+ const applicantInfo = {};
509
+ for (const f of staged.staged) {
510
+ if (f.name === "name" || f.name === "email" || f.name === "phone") {
511
+ applicantInfo[f.name] = f.value;
512
+ }
513
+ }
514
+ const step3Body = {
515
+ post_id: staged.post_id,
516
+ attachment_id,
517
+ applicant_info: applicantInfo,
518
+ };
519
+ const step3Url = `${apiRoot}/resume/apply`;
520
+ let step3Resp;
521
+ try {
522
+ step3Resp = await fetch(step3Url, {
523
+ method: "POST",
524
+ headers: {
525
+ ...sessionHeaders,
526
+ "Content-Type": "application/json",
527
+ Accept: "application/json, text/plain, */*",
528
+ },
529
+ body: JSON.stringify(step3Body),
530
+ });
531
+ }
532
+ catch (err) {
533
+ steps.push({ step: "resume-apply", url: step3Url, status: 0, ok: false, message: String(err) });
534
+ return { ok: false, posted_to: step3Url, message: "step 3 network error", steps };
535
+ }
536
+ const step3Text = await step3Resp.text();
537
+ steps.push({
538
+ step: "resume-apply",
539
+ url: step3Url,
540
+ status: step3Resp.status,
541
+ ok: step3Resp.ok,
542
+ message: step3Text.slice(0, 200),
543
+ });
544
+ return {
545
+ ok: step3Resp.ok,
546
+ status: step3Resp.status,
547
+ posted_to: step3Url,
548
+ response_preview: step3Text.slice(0, 4000),
549
+ message: step3Resp.ok ? "Feishu 3-step submission accepted" : `step 3 rejected: HTTP ${step3Resp.status}`,
550
+ steps,
551
+ };
552
+ }
553
+ /** Build the headers bag used by every Feishu/Beisen/Moka step. */
554
+ function sessionHeaderBag(session, targetHost) {
555
+ const out = {
556
+ "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",
557
+ };
558
+ if (!session)
559
+ return out;
560
+ const cookieHeader = serializeCookieHeader(session, targetHost);
561
+ if (cookieHeader)
562
+ out.Cookie = cookieHeader;
563
+ for (const [k, v] of Object.entries(session.headers ?? {})) {
564
+ if (k.toLowerCase() === "cookie" || k.toLowerCase() === "content-type")
565
+ continue;
566
+ out[k] = v;
567
+ }
568
+ return out;
569
+ }
package/dist/index.js CHANGED
@@ -50,11 +50,11 @@ import * as geely from "./geely.js";
50
50
  import * as webank from "./webank.js";
51
51
  import * as horizonrobotics from "./horizonrobotics.js";
52
52
  import * as cambricon from "./cambricon.js";
53
- import { loadProfile, loadSession, profileTemplate, stageApplication, submitApplication, formatStaged, } from "./apply.js";
53
+ import { loadProfile, loadSession, profileTemplate, stageApplication, submitApplication, executeFeishu3Step, formatStaged, } from "./apply.js";
54
54
  import { memoryList, memoryGet, memorySet, memoryEvent, memoryClear, } from "./memory.js";
55
55
  import { writeFileSync, mkdirSync, existsSync } from "node:fs";
56
56
  import { dirname } from "node:path";
57
- const VERSION = "0.9.5";
57
+ const VERSION = "0.9.6";
58
58
  const COMPANIES = [
59
59
  { key: "tencent", family: "Bespoke", source: "join.qq.com", label: "Tencent / č…ūčŪŊ" },
60
60
  { key: "bytedance", family: "Bespoke", source: "jobs.bytedance.com", label: "ByteDance / å­—čŠ‚č·ģåŠĻ" },
@@ -441,8 +441,15 @@ async function runCompany(adapter, company, rawArgs) {
441
441
  const session = loadSession(company);
442
442
  // Mode selection: --debug-submit-to <url> overrides everything.
443
443
  if (debugUrl) {
444
+ // Route through the family-specific executor where appropriate so the
445
+ // user can verify each step's wire format against their echo server.
446
+ const kindForDebug = sr.schema.submit_kind ?? "multipart-anon";
447
+ if (kindForDebug === "feishu-3-step") {
448
+ const result = await executeFeishu3Step(staged, session, { kind: "debug", url: debugUrl });
449
+ return emit({ mode: "debug-submit", staged, submit_kind: kindForDebug, result }, compact);
450
+ }
444
451
  const result = await submitApplication(staged, { kind: "debug", url: debugUrl });
445
- return emit({ mode: "debug-submit", staged, result }, compact);
452
+ return emit({ mode: "debug-submit", staged, submit_kind: kindForDebug, result }, compact);
446
453
  }
447
454
  // --really-submit: actually hit the upstream endpoint. Guarded by both
448
455
  // an env-var attestation and (for non-anon adapters) a session.json.
@@ -492,6 +499,22 @@ async function runCompany(adapter, company, rawArgs) {
492
499
  `Open apply_url in your browser to start the actual application flow.`,
493
500
  }, compact);
494
501
  }
502
+ if (kind === "feishu-3-step") {
503
+ if (!session) {
504
+ return emit({
505
+ ok: false,
506
+ source: company,
507
+ post_id: postId,
508
+ mode: "really-submit-blocked",
509
+ staged,
510
+ message: `Feishu 3-step submission requires a captured session at ` +
511
+ `~/.jobpro/${company}.session.json. Install extension/ in Chrome, ` +
512
+ `log in to the careers site, click Export.`,
513
+ }, compact);
514
+ }
515
+ const result = await executeFeishu3Step(staged, session, { kind: "upstream" });
516
+ return emit({ mode: "really-submit", staged, submit_kind: kind, session_used: true, result }, compact);
517
+ }
495
518
  if (!isGenericMultipart) {
496
519
  return emit({
497
520
  ok: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "job-pro",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
4
  "description": "Query Chinese big-tech campus recruiting from your terminal. 50 companies, all 50 live. 46 via each company's own API; the 4 with no public canonical feed (Hikvision, CICC, Cainiao, WeBank) surfaced via Liepin as a clearly-labeled third-party fallback. No signup, no token, no server.",
5
5
  "homepage": "https://job.ha7ch.com",
6
6
  "repository": "https://github.com/HA7CH/job-pro",