job-pro 0.9.2 → 0.9.3

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/agibot.js CHANGED
@@ -388,3 +388,12 @@ export async function matchResume(text, opts = {}) {
388
388
  "The only authority on selection is HR.",
389
389
  };
390
390
  }
391
+ // ---------- Phase 2: fetchApplicationSchema ----------
392
+ import { makeFeishuApplyFn } from "./feishu.js";
393
+ export const fetchApplicationSchema = makeFeishuApplyFn({
394
+ host: "agirobot.jobs.feishu.cn",
395
+ source: "agirobot.jobs.feishu.cn",
396
+ channel: "campus",
397
+ applyUrlPrefix: "https://agirobot.jobs.feishu.cn/campus/position",
398
+ fetchTitle: (id) => fetchPositionDetail(id),
399
+ });
package/dist/apply.js CHANGED
@@ -120,6 +120,8 @@ export function stageApplication(schema, profile) {
120
120
  apply_url: schema.apply_url,
121
121
  submit_endpoint: schema.submit_endpoint,
122
122
  submit_method: schema.submit_method,
123
+ submit_kind: schema.submit_kind,
124
+ submit_notes: schema.submit_notes,
123
125
  staged,
124
126
  unanswered_required,
125
127
  ready: unanswered_required.length === 0,
@@ -133,6 +135,14 @@ function resolveAnswer(field, profile) {
133
135
  return { value: profile.first_name ?? "", reason: "profile.first_name missing" };
134
136
  case "last_name":
135
137
  return { value: profile.last_name ?? "", reason: "profile.last_name missing" };
138
+ case "name":
139
+ // Feishu / Beisen / Moka often use a single `name` field. Compose
140
+ // first + last; gracefully degrade if only one is set.
141
+ const composed = [profile.first_name, profile.last_name].filter(Boolean).join(" ");
142
+ return {
143
+ value: composed || profile.first_name || profile.last_name || "",
144
+ reason: "profile.first_name and last_name both missing",
145
+ };
136
146
  case "email":
137
147
  return { value: profile.email ?? "", reason: "profile.email missing" };
138
148
  case "phone":
package/dist/baichuan.js CHANGED
@@ -46,3 +46,4 @@ export const listNotices = _adapter.listNotices;
46
46
  export const getNotice = _adapter.getNotice;
47
47
  export const findNoticesByQuestion = _adapter.findNoticesByQuestion;
48
48
  export const matchResume = _adapter.matchResume;
49
+ export const fetchApplicationSchema = _adapter.fetchApplicationSchema;
package/dist/feishu.js CHANGED
@@ -42,6 +42,85 @@
42
42
  // - Both: city_info is null; city_list always populated
43
43
  import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
44
44
  export { checkResume };
45
+ // ---------- shared apply-schema helper (re-used by bespoke Feishu adapters) ----------
46
+ //
47
+ // xiaomi.ts / zhipu.ts / iqiyi.ts / agibot.ts / lilith.ts each predate the
48
+ // factory and have their own searchPositions implementations. To give them
49
+ // the same Phase-2 behaviour as factory-using adapters (nio / minimax /
50
+ // baichuan / zerooneai), each can call `buildFeishuApplySchema()` from
51
+ // its own fetchApplicationSchema function.
52
+ /**
53
+ * Wire fetchApplicationSchema for a bespoke Feishu adapter that doesn't use
54
+ * createAdapter. The callback `fetchTitle(id)` is the adapter's own
55
+ * fetchPositionDetail (or any function that returns `{ ok, title }`).
56
+ *
57
+ * Usage:
58
+ * export const fetchApplicationSchema = makeFeishuApplyFn({
59
+ * host: HOST, source: SOURCE, channel: CHANNEL,
60
+ * applyUrlPrefix: APPLY_PREFIX,
61
+ * fetchTitle: (id) => fetchPositionDetail(id),
62
+ * submitKind: "feishu-3-step", // override for lilith → "cdp-real-browser"
63
+ * });
64
+ */
65
+ export function makeFeishuApplyFn(opts) {
66
+ return async function fetchApplicationSchema(postId) {
67
+ const id = (postId ?? "").trim();
68
+ if (!id)
69
+ return { ok: false, source: opts.source, message: "post_id is required" };
70
+ let title = "";
71
+ try {
72
+ const detail = (await opts.fetchTitle(id));
73
+ if (detail?.ok === false) {
74
+ return { ok: false, source: opts.source, message: detail.message ?? "post not found" };
75
+ }
76
+ title = detail?.title ?? "";
77
+ }
78
+ catch {
79
+ // detail call failures aren't fatal for the schema — we can still
80
+ // return what we know.
81
+ }
82
+ const schema = buildFeishuApplySchema({
83
+ host: opts.host,
84
+ source: opts.source,
85
+ channel: opts.channel,
86
+ applyUrlPrefix: opts.applyUrlPrefix,
87
+ postId: id,
88
+ jobTitle: title,
89
+ });
90
+ if (opts.submitKind === "cdp-real-browser") {
91
+ schema.submit_kind = "cdp-real-browser";
92
+ schema.submit_notes =
93
+ "Lilith's Feishu tenant requires a runtime-minted `_signature` token. " +
94
+ "Submission must drive a real browser (puppeteer-core) — staged dry-run " +
95
+ "only for now.";
96
+ }
97
+ return { ok: true, schema };
98
+ };
99
+ }
100
+ export function buildFeishuApplySchema(args) {
101
+ const standard = [
102
+ { label: "Name", required: true, fields: [{ name: "name", type: "input_text" }] },
103
+ { label: "Email", required: true, fields: [{ name: "email", type: "input_text" }] },
104
+ { label: "Phone", required: true, fields: [{ name: "phone", type: "input_text" }] },
105
+ { label: "Resume", required: true, fields: [{ name: "resume", type: "input_file" }] },
106
+ ];
107
+ return {
108
+ source: args.source,
109
+ post_id: args.postId,
110
+ job_title: args.jobTitle,
111
+ apply_url: `${args.applyUrlPrefix}/${encodeURIComponent(args.postId)}/detail`,
112
+ submit_endpoint: `https://${args.host}/api/v1/resume/apply`,
113
+ submit_method: "POST",
114
+ submit_kind: "feishu-3-step",
115
+ submit_notes: "Feishu apply is a 3-step token flow: POST /api/v1/attachment/upload/tokens → " +
116
+ "PUT presigned URL on lf-package-cn.feishucdn.com → POST /api/v1/attachment/exchange/tokens → " +
117
+ "POST /api/v1/resume/apply with { post_id, attachment_id, applicant_info }. " +
118
+ "Requires candidate session cookies (capture via extension/, drop under " +
119
+ "~/.jobpro/<adapter>.session.json). Multi-step submitter wires in next iteration; " +
120
+ "today this schema is dry-run only.",
121
+ questions: standard,
122
+ };
123
+ }
45
124
  // ---------- createAdapter ----------
46
125
  export function createAdapter(cfg) {
47
126
  const API_ROOT = `https://${cfg.host}/api/v1`;
@@ -432,6 +511,55 @@ export function createAdapter(cfg) {
432
511
  "The only authority on selection is HR.",
433
512
  };
434
513
  }
514
+ // ---------- fetchApplicationSchema (Phase 2) ----------
515
+ //
516
+ // Feishu's apply funnel is a 3-step token flow, not a single multipart
517
+ // POST. Discovered via JS-bundle inspection of nio.jobs.feishu.cn
518
+ // (lf-package-cn.feishucdn.com/obj/atsx-throne/hire-fe-prod/portal/
519
+ // saas-career/static/js/*.js) — the routes baked into the bundle are:
520
+ //
521
+ // 1. POST {API_ROOT}/attachment/upload/tokens
522
+ // → returns short-lived presigned upload URL + attachment_id
523
+ // 2. PUT <presigned-URL on lf-package-cn.feishucdn.com>
524
+ // → uploads the resume PDF/DOCX bytes directly
525
+ // 3. POST {API_ROOT}/attachment/exchange/tokens
526
+ // → exchanges short-lived id for a permanent attachment_id
527
+ // 4. POST {API_ROOT}/user/delivery/check (pre-flight, optional)
528
+ // 5. POST {API_ROOT}/resume/apply
529
+ // body: { post_id, attachment_id, applicant_info: { name, email,
530
+ // phone, ... }, ... }
531
+ // → returns { code:0, data:{ application_id } } on success
532
+ //
533
+ // The whole flow requires the user to be logged in as a candidate; the
534
+ // session cookie set during login authorizes every call above. Capture
535
+ // via the browser extension (~/.jobpro/<co>.session.json), then a
536
+ // future iteration adds an `executeSubmission` hook that drives the
537
+ // 3-step flow with the captured cookies.
538
+ //
539
+ // For now `fetchApplicationSchema` returns the contact-info schema
540
+ // (sufficient for dry-run staging) plus `submit_kind: "feishu-3-step"`
541
+ // so the dispatcher refuses --really-submit with a useful pointer.
542
+ async function fetchApplicationSchema(postId) {
543
+ const id = (postId ?? "").trim();
544
+ if (!id)
545
+ return { ok: false, source, message: "post_id is required" };
546
+ const detail = await fetchPositionDetail(id);
547
+ const detailAny = detail;
548
+ if (!detailAny.ok) {
549
+ return { ok: false, source, message: detailAny.message ?? "post not found" };
550
+ }
551
+ return {
552
+ ok: true,
553
+ schema: buildFeishuApplySchema({
554
+ host: cfg.host,
555
+ source,
556
+ channel: cfg.channel,
557
+ applyUrlPrefix: cfg.applyUrlPrefix,
558
+ postId: id,
559
+ jobTitle: detailAny.title ?? "",
560
+ }),
561
+ };
562
+ }
435
563
  return {
436
564
  searchPositions,
437
565
  fetchAllPositions,
@@ -442,5 +570,6 @@ export function createAdapter(cfg) {
442
570
  findNoticesByQuestion,
443
571
  matchResume,
444
572
  checkResume,
573
+ fetchApplicationSchema,
445
574
  };
446
575
  }
@@ -409,6 +409,9 @@ export function createAdapter(cfg) {
409
409
  apply_url: job.absolute_url ?? `${BOARD_URL}/jobs/${id}`,
410
410
  submit_endpoint: `${API_ROOT}/jobs/${encodeURIComponent(id)}`,
411
411
  submit_method: "POST",
412
+ submit_kind: "multipart-anon",
413
+ submit_notes: "Greenhouse Job Board API accepts anonymous multipart/form-data POSTs " +
414
+ "whose field names match the questions[].fields[].name returned here.",
412
415
  questions,
413
416
  },
414
417
  };
package/dist/index.js CHANGED
@@ -54,7 +54,7 @@ import { loadProfile, loadSession, profileTemplate, stageApplication, submitAppl
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.2";
57
+ const VERSION = "0.9.3";
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 / 字节跳动" },
@@ -460,12 +460,32 @@ async function runCompany(adapter, company, rawArgs) {
460
460
  message: `${staged.unanswered_required.length} required field(s) still unanswered; refusing to submit incomplete application`,
461
461
  }, compact);
462
462
  }
463
- // Anon adapters (Greenhouse / Lever) don't need session.json.
464
- // Bespoke / Beisen / Moka / Feishu adapters do — bail with a hint
465
- // pointing at the browser extension.
466
- const isAnonFamily = staged.source.startsWith("boards-api.greenhouse.io/") ||
467
- staged.source.startsWith("api.lever.co/");
468
- if (!isAnonFamily && !session) {
463
+ // Submission flow selection by submit_kind. Only the generic
464
+ // multipart families are wired to actually fire today; everything
465
+ // else gets a useful refusal message.
466
+ const kind = (sr.schema.submit_kind ?? "multipart-anon");
467
+ const isAnonMultipart = kind === "multipart-anon";
468
+ const isSessionMultipart = kind === "multipart-session";
469
+ const isGenericMultipart = isAnonMultipart || isSessionMultipart;
470
+ if (!isGenericMultipart) {
471
+ return emit({
472
+ ok: false,
473
+ source: company,
474
+ post_id: postId,
475
+ mode: "really-submit-blocked",
476
+ staged,
477
+ submit_kind: kind,
478
+ submit_notes: sr.schema.submit_notes,
479
+ message: `submit_kind="${kind}" — this adapter family doesn't yet have an ` +
480
+ `executor wired. The application schema + submit endpoint are ` +
481
+ `documented (see submit_notes), but firing the submission needs a ` +
482
+ `family-specific multi-step flow (token exchange / AES envelope / ` +
483
+ `CDP / etc.). Landing per-family executors is the next iteration of ` +
484
+ `Phase 2. Use --debug-submit-to <url> to inspect what we have today.`,
485
+ }, compact);
486
+ }
487
+ // Non-anon multipart families need session.json.
488
+ if (!isAnonMultipart && !session) {
469
489
  return emit({
470
490
  ok: false,
471
491
  source: company,
@@ -478,7 +498,7 @@ async function runCompany(adapter, company, rawArgs) {
478
498
  }, compact);
479
499
  }
480
500
  const result = await submitApplication(staged, { kind: "upstream" }, { session });
481
- return emit({ mode: "really-submit", staged, session_used: !!session, result }, compact);
501
+ return emit({ mode: "really-submit", staged, submit_kind: kind, session_used: !!session, result }, compact);
482
502
  }
483
503
  // Default: dry-run print, no network.
484
504
  if (compact) {
package/dist/iqiyi.js CHANGED
@@ -483,3 +483,12 @@ export async function matchResume(text, opts = {}) {
483
483
  "The only authority on selection is HR.",
484
484
  };
485
485
  }
486
+ // ---------- Phase 2: fetchApplicationSchema ----------
487
+ import { makeFeishuApplyFn } from "./feishu.js";
488
+ export const fetchApplicationSchema = makeFeishuApplyFn({
489
+ host: "careers.iqiyi.com",
490
+ source: "careers.iqiyi.com",
491
+ channel: "campus",
492
+ applyUrlPrefix: "https://careers.iqiyi.com/campus/position",
493
+ fetchTitle: (id) => fetchPositionDetail(id),
494
+ });
package/dist/lever.js CHANGED
@@ -431,6 +431,10 @@ export function createAdapter(cfg) {
431
431
  apply_url: job.applyUrl ?? job.hostedUrl ?? `${BOARD_URL}/${id}/apply`,
432
432
  submit_endpoint: `${BOARD_URL}/${id}/apply`,
433
433
  submit_method: "POST",
434
+ submit_kind: "multipart-anon",
435
+ submit_notes: "Lever apply-page accepts anonymous multipart/form-data POST whose field " +
436
+ "names match Lever's hosted apply form (standard contact-info + each " +
437
+ "customQuestion's auto-named field).",
434
438
  questions: [...standard, ...custom],
435
439
  },
436
440
  };
package/dist/lilith.js CHANGED
@@ -288,3 +288,13 @@ export async function matchResume(text, opts = {}) {
288
288
  };
289
289
  }
290
290
  export { extractResumeSignals, scoreOverlap };
291
+ // ---------- Phase 2: fetchApplicationSchema ----------
292
+ import { makeFeishuApplyFn } from "./feishu.js";
293
+ export const fetchApplicationSchema = makeFeishuApplyFn({
294
+ host: "lilithgames.jobs.feishu.cn",
295
+ source: "lilithgames.jobs.feishu.cn",
296
+ channel: "career",
297
+ applyUrlPrefix: "https://lilithgames.jobs.feishu.cn/career/position",
298
+ fetchTitle: (id) => fetchPositionDetail(id),
299
+ submitKind: "cdp-real-browser",
300
+ });
package/dist/minimax.js CHANGED
@@ -24,7 +24,7 @@
24
24
  //
25
25
  // apply_url pattern: https://vrfi1sk8a0.jobs.feishu.cn/379481/position/<id>/detail
26
26
  import { createAdapter } from "./feishu.js";
27
- export const { searchPositions, fetchAllPositions, fetchPositionDetail, fetchDictionaries, listNotices, getNotice, findNoticesByQuestion, matchResume, checkResume, } = createAdapter({
27
+ export const { searchPositions, fetchAllPositions, fetchPositionDetail, fetchDictionaries, listNotices, getNotice, findNoticesByQuestion, matchResume, checkResume, fetchApplicationSchema, } = createAdapter({
28
28
  host: "vrfi1sk8a0.jobs.feishu.cn",
29
29
  channel: "379481",
30
30
  label: "MiniMax / MiniMax智能",
package/dist/nio.js CHANGED
@@ -16,7 +16,7 @@
16
16
  //
17
17
  // apply_url pattern: https://nio.jobs.feishu.cn/campus/position/<id>/detail
18
18
  import { createAdapter } from "./feishu.js";
19
- export const { searchPositions, fetchAllPositions, fetchPositionDetail, fetchDictionaries, listNotices, getNotice, findNoticesByQuestion, matchResume, checkResume, } = createAdapter({
19
+ export const { searchPositions, fetchAllPositions, fetchPositionDetail, fetchDictionaries, listNotices, getNotice, findNoticesByQuestion, matchResume, checkResume, fetchApplicationSchema, } = createAdapter({
20
20
  host: "nio.jobs.feishu.cn",
21
21
  channel: "campus",
22
22
  label: "NIO / 蔚来",
package/dist/xiaomi.js CHANGED
@@ -518,3 +518,12 @@ export async function matchResume(text, opts = {}) {
518
518
  "The only authority on selection is HR.",
519
519
  };
520
520
  }
521
+ // ---------- Phase 2: fetchApplicationSchema ----------
522
+ import { makeFeishuApplyFn } from "./feishu.js";
523
+ export const fetchApplicationSchema = makeFeishuApplyFn({
524
+ host: "xiaomi.jobs.f.mioffice.cn",
525
+ source: "xiaomi.jobs.f.mioffice.cn",
526
+ channel: "campus",
527
+ applyUrlPrefix: "https://xiaomi.jobs.f.mioffice.cn/campus/position",
528
+ fetchTitle: (id) => fetchPositionDetail(id),
529
+ });
package/dist/zerooneai.js CHANGED
@@ -39,3 +39,4 @@ export const listNotices = _adapter.listNotices;
39
39
  export const getNotice = _adapter.getNotice;
40
40
  export const findNoticesByQuestion = _adapter.findNoticesByQuestion;
41
41
  export const matchResume = _adapter.matchResume;
42
+ export const fetchApplicationSchema = _adapter.fetchApplicationSchema;
package/dist/zhipu.js CHANGED
@@ -467,3 +467,12 @@ export async function matchResume(text, opts = {}) {
467
467
  "The only authority on selection is HR.",
468
468
  };
469
469
  }
470
+ // ---------- Phase 2: fetchApplicationSchema ----------
471
+ import { makeFeishuApplyFn } from "./feishu.js";
472
+ export const fetchApplicationSchema = makeFeishuApplyFn({
473
+ host: "zhipu-ai.jobs.feishu.cn",
474
+ source: "zhipu-ai.jobs.feishu.cn",
475
+ channel: "index",
476
+ applyUrlPrefix: "https://zhipu-ai.jobs.feishu.cn/index/position",
477
+ fetchTitle: (id) => fetchPositionDetail(id),
478
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "job-pro",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
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",