job-pro 0.8.2 → 0.9.0

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 ADDED
@@ -0,0 +1,166 @@
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, existsSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { join } from "node:path";
19
+ const PROFILE_PATH = process.env.JOB_PRO_PROFILE_PATH ?? join(homedir(), ".jobpro", "profile.json");
20
+ const TEMPLATE = {
21
+ first_name: "",
22
+ last_name: "",
23
+ email: "",
24
+ phone: "",
25
+ resume_path: "",
26
+ cover_letter_text: "",
27
+ custom: {
28
+ // Common Greenhouse / Lever questions:
29
+ // question_<n>: "answer"
30
+ // linkedin_url: "https://www.linkedin.com/in/your-handle",
31
+ // nationality: "China",
32
+ },
33
+ };
34
+ export function loadProfile() {
35
+ if (!existsSync(PROFILE_PATH)) {
36
+ return {
37
+ ok: false,
38
+ message: `profile not found at ${PROFILE_PATH}. Run \`job-pro profile init\` to create a template, ` +
39
+ `or set $JOB_PRO_PROFILE_PATH to override.`,
40
+ };
41
+ }
42
+ let raw;
43
+ try {
44
+ raw = readFileSync(PROFILE_PATH, "utf8");
45
+ }
46
+ catch (err) {
47
+ return { ok: false, message: `could not read ${PROFILE_PATH}: ${err instanceof Error ? err.message : err}` };
48
+ }
49
+ let parsed;
50
+ try {
51
+ parsed = JSON.parse(raw);
52
+ }
53
+ catch (err) {
54
+ return { ok: false, message: `${PROFILE_PATH} is not valid JSON: ${err instanceof Error ? err.message : err}` };
55
+ }
56
+ for (const required of ["first_name", "last_name", "email", "phone"]) {
57
+ if (!parsed[required]) {
58
+ return { ok: false, message: `${PROFILE_PATH}: missing required field "${required}"` };
59
+ }
60
+ }
61
+ return { ok: true, profile: parsed };
62
+ }
63
+ export function profileTemplate() {
64
+ return { path: PROFILE_PATH, template: TEMPLATE };
65
+ }
66
+ /** Fill in known answers from the profile; flag any unanswered required fields. */
67
+ export function stageApplication(schema, profile) {
68
+ const staged = [];
69
+ const unanswered_required = [];
70
+ for (const q of schema.questions) {
71
+ // The "primary" field is the first one; secondary fields are alternate
72
+ // formats (e.g. resume has both `resume` file + `resume_text` textarea).
73
+ const primary = q.fields[0];
74
+ if (!primary)
75
+ continue;
76
+ const filled = resolveAnswer(primary, profile);
77
+ const reason = filled.value || !q.required ? undefined : filled.reason;
78
+ const sf = {
79
+ name: primary.name,
80
+ type: primary.type,
81
+ value: filled.value,
82
+ required: q.required,
83
+ unanswered_reason: reason,
84
+ };
85
+ staged.push(sf);
86
+ if (q.required && !filled.value)
87
+ unanswered_required.push(sf);
88
+ }
89
+ return {
90
+ source: schema.source,
91
+ post_id: schema.post_id,
92
+ job_title: schema.job_title,
93
+ apply_url: schema.apply_url,
94
+ submit_endpoint: schema.submit_endpoint,
95
+ submit_method: schema.submit_method,
96
+ staged,
97
+ unanswered_required,
98
+ ready: unanswered_required.length === 0,
99
+ };
100
+ }
101
+ function resolveAnswer(field, profile) {
102
+ // Hard-coded standard mappings — these names are the canonical
103
+ // Greenhouse field names and are reused by Lever's submission form.
104
+ switch (field.name) {
105
+ case "first_name":
106
+ return { value: profile.first_name ?? "", reason: "profile.first_name missing" };
107
+ case "last_name":
108
+ return { value: profile.last_name ?? "", reason: "profile.last_name missing" };
109
+ case "email":
110
+ return { value: profile.email ?? "", reason: "profile.email missing" };
111
+ case "phone":
112
+ return { value: profile.phone ?? "", reason: "profile.phone missing" };
113
+ case "resume":
114
+ return {
115
+ value: profile.resume_path ?? "",
116
+ reason: "profile.resume_path missing — set to an absolute PDF/DOCX path",
117
+ };
118
+ case "resume_text":
119
+ // Optional companion field — leave empty if user supplies a file.
120
+ return { value: "", reason: "" };
121
+ case "cover_letter":
122
+ return { value: "", reason: "" };
123
+ case "cover_letter_text":
124
+ return { value: profile.cover_letter_text ?? "", reason: "" };
125
+ default:
126
+ // Custom passthroughs — match by question name (e.g. "question_36528765002").
127
+ const v = profile.custom?.[field.name];
128
+ if (typeof v === "string" && v.length > 0)
129
+ return { value: v, reason: "" };
130
+ return {
131
+ value: "",
132
+ reason: `unknown field "${field.name}" — add to profile.custom.${field.name} to auto-fill`,
133
+ };
134
+ }
135
+ }
136
+ // ---------- pretty-print for dry-run ----------
137
+ export function formatStaged(s) {
138
+ const lines = [];
139
+ lines.push(`source: ${s.source}`);
140
+ lines.push(`job: ${s.post_id} — ${s.job_title}`);
141
+ lines.push(`apply_url: ${s.apply_url}`);
142
+ if (s.submit_endpoint) {
143
+ lines.push(`submit: ${s.submit_method ?? "POST"} ${s.submit_endpoint}`);
144
+ }
145
+ lines.push("");
146
+ lines.push(`ready: ${s.ready ? "✓ all required fields filled" : `✗ ${s.unanswered_required.length} required field(s) unfilled`}`);
147
+ lines.push("");
148
+ lines.push("Staged payload:");
149
+ const widthName = Math.max(...s.staged.map((f) => f.name.length));
150
+ const widthType = Math.max(...s.staged.map((f) => f.type.length));
151
+ for (const f of s.staged) {
152
+ const flag = f.required ? "•" : " ";
153
+ const value = f.value
154
+ ? f.type === "input_file"
155
+ ? `<file: ${f.value}>`
156
+ : truncate(f.value, 60)
157
+ : f.unanswered_reason
158
+ ? `<unanswered: ${f.unanswered_reason}>`
159
+ : "<empty>";
160
+ lines.push(` ${flag} ${f.name.padEnd(widthName)} ${f.type.padEnd(widthType)} ${value}`);
161
+ }
162
+ return lines.join("\n");
163
+ }
164
+ function truncate(s, n) {
165
+ return s.length > n ? s.slice(0, n - 1) + "…" : s;
166
+ }
@@ -356,6 +356,63 @@ export function createAdapter(cfg) {
356
356
  "The only authority on selection is HR.",
357
357
  };
358
358
  }
359
+ async function fetchApplicationSchema(postId) {
360
+ const id = (postId ?? "").trim();
361
+ if (!id)
362
+ return { ok: false, source: SOURCE, message: "post_id is required" };
363
+ const url = `${API_ROOT}/jobs/${encodeURIComponent(id)}?questions=true`;
364
+ let response;
365
+ try {
366
+ response = await fetch(url, { headers: HEADERS });
367
+ }
368
+ catch (err) {
369
+ return {
370
+ ok: false,
371
+ source: SOURCE,
372
+ message: `network error: ${err instanceof Error ? err.message : String(err)}`,
373
+ };
374
+ }
375
+ if (!response.ok) {
376
+ return {
377
+ ok: false,
378
+ source: SOURCE,
379
+ message: `HTTP ${response.status}: ${response.statusText}`,
380
+ };
381
+ }
382
+ let job;
383
+ try {
384
+ job = (await response.json());
385
+ }
386
+ catch (err) {
387
+ return {
388
+ ok: false,
389
+ source: SOURCE,
390
+ message: `bad JSON: ${err instanceof Error ? err.message : String(err)}`,
391
+ };
392
+ }
393
+ const questions = (job.questions ?? []).map((q) => ({
394
+ label: q.label ?? "",
395
+ description: q.description ?? null,
396
+ required: q.required ?? false,
397
+ fields: (q.fields ?? []).map((f) => ({
398
+ name: f.name ?? "",
399
+ type: f.type ?? "input_text",
400
+ values: (f.values ?? []).map((v) => ({ value: v.value ?? "", label: v.label ?? "" })),
401
+ })),
402
+ }));
403
+ return {
404
+ ok: true,
405
+ schema: {
406
+ source: SOURCE,
407
+ post_id: id,
408
+ job_title: job.title ?? "",
409
+ apply_url: job.absolute_url ?? `${BOARD_URL}/jobs/${id}`,
410
+ submit_endpoint: `${API_ROOT}/jobs/${encodeURIComponent(id)}`,
411
+ submit_method: "POST",
412
+ questions,
413
+ },
414
+ };
415
+ }
359
416
  return {
360
417
  searchPositions,
361
418
  fetchAllPositions,
@@ -366,5 +423,6 @@ export function createAdapter(cfg) {
366
423
  findNoticesByQuestion,
367
424
  matchResume,
368
425
  checkResume,
426
+ fetchApplicationSchema,
369
427
  };
370
428
  }
package/dist/hoyoverse.js CHANGED
@@ -23,3 +23,4 @@ export const getNotice = adapter.getNotice;
23
23
  export const findNoticesByQuestion = adapter.findNoticesByQuestion;
24
24
  export const matchResume = adapter.matchResume;
25
25
  export const checkResume = adapter.checkResume;
26
+ export const fetchApplicationSchema = adapter.fetchApplicationSchema;
package/dist/index.js CHANGED
@@ -50,8 +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, profileTemplate, stageApplication, formatStaged, } from "./apply.js";
53
54
  import { memoryList, memoryGet, memorySet, memoryEvent, memoryClear, } from "./memory.js";
54
- const VERSION = "0.8.2";
55
+ import { writeFileSync, mkdirSync, existsSync } from "node:fs";
56
+ import { dirname } from "node:path";
57
+ const VERSION = "0.9.0";
55
58
  const COMPANIES = [
56
59
  { key: "tencent", family: "Bespoke", source: "join.qq.com", label: "Tencent / 腾讯" },
57
60
  { key: "bytedance", family: "Bespoke", source: "jobs.bytedance.com", label: "ByteDance / 字节跳动" },
@@ -111,6 +114,8 @@ job-pro — query Chinese big-tech campus recruiting from your terminal
111
114
  USAGE
112
115
  job-pro <company> <verb> [options]
113
116
  job-pro list [--compact] list all 50 companies + source family
117
+ job-pro profile init [--force] write ~/.jobpro/profile.json template
118
+ job-pro profile show print the loaded profile
114
119
  job-pro --version
115
120
  job-pro help
116
121
 
@@ -118,6 +123,12 @@ USAGE
118
123
  by ATS family (Bespoke / Feishu / Beisen Wecruit / Beisen iTalent / Moka
119
124
  / Greenhouse-Lever / Liepin). Coverage summary at job.ha7ch.com.
120
125
 
126
+ PHASE 2 (auto-apply) is in early access. \`job-pro <co> apply <postId>\`
127
+ prints the staged POST in dry-run mode. Today only Greenhouse +
128
+ Lever boards (xpeng / hoyoverse / weride) expose the application
129
+ schema; the rest return a "not yet wired" note. See docs/auto-apply.md
130
+ for the rollout plan.
131
+
121
132
  VERBS (same surface for every company)
122
133
  search <kw> search openings (free text)
123
134
  detail <post_id> show full JD for one job
@@ -129,6 +140,9 @@ VERBS (same surface for every company)
129
140
  match <resume-text-or--> rank jobs by overlap with resume text
130
141
  pass "-" to read resume from stdin
131
142
  resume-check <resume-text-or--> structural sanity check on a resume
143
+ apply <post_id> stage an application (Phase 2 dry-run)
144
+ --really-submit is intentionally disabled
145
+ until per-ATS flows are validated.
132
146
  memory list | get <k> | set k=v | event <kind> [payload] | clear
133
147
 
134
148
  OUTPUT
@@ -375,6 +389,63 @@ async function runCompany(adapter, company, rawArgs) {
375
389
  const text = readResumeArg(args[0]);
376
390
  return emit(adapter.checkResume(text), compact);
377
391
  }
392
+ if (verb === "apply") {
393
+ const postId = args[0];
394
+ if (!postId)
395
+ die(`usage: job-pro ${company} apply <post_id> [--dry-run | --really-submit]`);
396
+ const reallySubmit = args.includes("--really-submit");
397
+ const fetchSchema = adapter.fetchApplicationSchema;
398
+ if (typeof fetchSchema !== "function") {
399
+ return emit({
400
+ ok: false,
401
+ source: company,
402
+ post_id: postId,
403
+ message: `apply: Phase 2 not yet wired for "${company}". Only Greenhouse + Lever ` +
404
+ `boards (xpeng / weride / hoyoverse) expose an application schema today. ` +
405
+ `See docs/auto-apply.md for the rollout plan.`,
406
+ }, compact);
407
+ }
408
+ if (reallySubmit) {
409
+ return emit({
410
+ ok: false,
411
+ source: company,
412
+ post_id: postId,
413
+ message: `--really-submit is intentionally not implemented yet. Phase 2 ships the ` +
414
+ `staging path first so you can see exactly what would be POSTed before any ` +
415
+ `submission actually fires. Re-run without --really-submit for the dry-run.`,
416
+ }, compact);
417
+ }
418
+ const schemaResult = await fetchSchema.call(adapter, postId);
419
+ const sr = schemaResult;
420
+ if (!sr.ok || !sr.schema) {
421
+ return emit({ ok: false, source: company, post_id: postId, message: sr.message ?? "unknown error" }, compact);
422
+ }
423
+ const prof = loadProfile();
424
+ if (!prof.ok) {
425
+ return emit({
426
+ ok: false,
427
+ source: company,
428
+ post_id: postId,
429
+ schema: sr.schema,
430
+ message: prof.message,
431
+ hint: `run \`job-pro profile init\` to create a template.`,
432
+ }, compact);
433
+ }
434
+ const staged = stageApplication(sr.schema, prof.profile);
435
+ if (compact) {
436
+ return emit({ ok: true, staged }, compact);
437
+ }
438
+ console.log(formatStaged(staged));
439
+ if (!staged.ready) {
440
+ console.log(`\nFill the unanswered required fields in ${profileTemplate().path} ` +
441
+ `(profile.custom.<name> for unknown fields), then re-run.`);
442
+ }
443
+ else {
444
+ console.log(`\nDry-run complete. --really-submit will be enabled in a future release ` +
445
+ `once per-ATS submission flows have been validated against live boards.`);
446
+ }
447
+ return;
448
+ }
378
449
  if (verb === "memory") {
379
450
  const [sub, ...subArgs] = args;
380
451
  if (!sub)
@@ -463,6 +534,30 @@ async function main() {
463
534
  printCompanyList(compact);
464
535
  return;
465
536
  }
537
+ if (cmd === "profile") {
538
+ const sub = args[1];
539
+ if (sub === "init") {
540
+ const { path, template } = profileTemplate();
541
+ if (existsSync(path) && !args.includes("--force")) {
542
+ console.error(`profile already exists at ${path}; pass --force to overwrite.`);
543
+ process.exit(1);
544
+ }
545
+ mkdirSync(dirname(path), { recursive: true });
546
+ writeFileSync(path, JSON.stringify(template, null, 2) + "\n", "utf8");
547
+ console.log(`Wrote ${path}. Fill in first_name / last_name / email / phone / resume_path before running \`job-pro <co> apply\`.`);
548
+ return;
549
+ }
550
+ if (sub === "show") {
551
+ const r = loadProfile();
552
+ if (!r.ok) {
553
+ console.error(r.message);
554
+ process.exit(1);
555
+ }
556
+ console.log(JSON.stringify(r.profile, null, 2));
557
+ return;
558
+ }
559
+ die(`usage: job-pro profile <init [--force] | show>`);
560
+ }
466
561
  const adapter = ADAPTERS[cmd];
467
562
  if (adapter) {
468
563
  await runCompany(adapter, cmd, args.slice(1));
package/dist/lever.js CHANGED
@@ -361,6 +361,80 @@ export function createAdapter(cfg) {
361
361
  "The only authority on selection is HR.",
362
362
  };
363
363
  }
364
+ async function fetchApplicationSchema(postId) {
365
+ const id = (postId ?? "").trim();
366
+ if (!id)
367
+ return { ok: false, source: SOURCE, message: "post_id is required" };
368
+ let response;
369
+ try {
370
+ response = await fetch(API_DETAIL(id), { headers: HEADERS });
371
+ }
372
+ catch (err) {
373
+ return {
374
+ ok: false,
375
+ source: SOURCE,
376
+ message: `network error: ${err instanceof Error ? err.message : String(err)}`,
377
+ };
378
+ }
379
+ if (!response.ok) {
380
+ return {
381
+ ok: false,
382
+ source: SOURCE,
383
+ message: `HTTP ${response.status}: ${response.statusText}`,
384
+ };
385
+ }
386
+ let job;
387
+ try {
388
+ job = (await response.json());
389
+ }
390
+ catch (err) {
391
+ return {
392
+ ok: false,
393
+ source: SOURCE,
394
+ message: `bad JSON: ${err instanceof Error ? err.message : String(err)}`,
395
+ };
396
+ }
397
+ // Lever's standard contact-info block.
398
+ const standard = [
399
+ { label: "First Name", required: true, fields: [{ name: "first_name", type: "input_text" }] },
400
+ { label: "Last Name", required: true, fields: [{ name: "last_name", type: "input_text" }] },
401
+ { label: "Email", required: true, fields: [{ name: "email", type: "input_text" }] },
402
+ { label: "Phone", required: true, fields: [{ name: "phone", type: "input_text" }] },
403
+ { label: "Resume", required: true, fields: [{ name: "resume", type: "input_file" }] },
404
+ ];
405
+ // Custom-question fields keyed by their human label so the staging
406
+ // step can match them via profile.custom["…"].
407
+ const custom = (job.customQuestions ?? []).flatMap((cq) => (cq.fields ?? []).map((f) => ({
408
+ label: f.text ?? cq.text ?? "",
409
+ description: cq.description ?? null,
410
+ required: f.required ?? false,
411
+ fields: [
412
+ {
413
+ name: (f.text ?? cq.text ?? "").slice(0, 60).replace(/\s+/g, "_").toLowerCase(),
414
+ type: f.type === "multiple-choice"
415
+ ? "single_select"
416
+ : f.type === "multi-choice"
417
+ ? "multi_select"
418
+ : f.type === "textarea"
419
+ ? "textarea"
420
+ : "input_text",
421
+ values: (f.options ?? []).map((o) => ({ value: o.text ?? "", label: o.text ?? "" })),
422
+ },
423
+ ],
424
+ })));
425
+ return {
426
+ ok: true,
427
+ schema: {
428
+ source: SOURCE,
429
+ post_id: id,
430
+ job_title: job.text ?? "",
431
+ apply_url: job.applyUrl ?? job.hostedUrl ?? `${BOARD_URL}/${id}/apply`,
432
+ submit_endpoint: `${BOARD_URL}/${id}/apply`,
433
+ submit_method: "POST",
434
+ questions: [...standard, ...custom],
435
+ },
436
+ };
437
+ }
364
438
  return {
365
439
  searchPositions,
366
440
  fetchAllPositions,
@@ -371,5 +445,6 @@ export function createAdapter(cfg) {
371
445
  findNoticesByQuestion,
372
446
  matchResume,
373
447
  checkResume,
448
+ fetchApplicationSchema,
374
449
  };
375
450
  }
package/dist/weride.js CHANGED
@@ -26,3 +26,4 @@ export const getNotice = adapter.getNotice;
26
26
  export const findNoticesByQuestion = adapter.findNoticesByQuestion;
27
27
  export const matchResume = adapter.matchResume;
28
28
  export const checkResume = adapter.checkResume;
29
+ export const fetchApplicationSchema = adapter.fetchApplicationSchema;
package/dist/xpeng.js CHANGED
@@ -31,3 +31,4 @@ export const getNotice = adapter.getNotice;
31
31
  export const findNoticesByQuestion = adapter.findNoticesByQuestion;
32
32
  export const matchResume = adapter.matchResume;
33
33
  export const checkResume = adapter.checkResume;
34
+ export const fetchApplicationSchema = adapter.fetchApplicationSchema;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "job-pro",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
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",