job-pro 0.8.1 → 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,82 +50,84 @@ 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.1";
55
+ import { writeFileSync, mkdirSync, existsSync } from "node:fs";
56
+ import { dirname } from "node:path";
57
+ const VERSION = "0.9.0";
58
+ const COMPANIES = [
59
+ { key: "tencent", family: "Bespoke", source: "join.qq.com", label: "Tencent / 腾讯" },
60
+ { key: "bytedance", family: "Bespoke", source: "jobs.bytedance.com", label: "ByteDance / 字节跳动" },
61
+ { key: "alibaba", family: "Bespoke", source: "campus-talent.alibaba.com", label: "Alibaba / 阿里巴巴" },
62
+ { key: "meituan", family: "Bespoke", source: "zhaopin.meituan.com", label: "Meituan / 美团" },
63
+ { key: "xiaohongshu", family: "Bespoke", source: "job.xiaohongshu.com", label: "Xiaohongshu / 小红书" },
64
+ { key: "jd", family: "Bespoke", source: "campus.jd.com", label: "JD / 京东" },
65
+ { key: "kuaishou", family: "Bespoke", source: "campus.kuaishou.cn", label: "Kuaishou / 快手" },
66
+ { key: "baidu", family: "Bespoke", source: "talent.baidu.com", label: "Baidu / 百度" },
67
+ { key: "netease", family: "Bespoke", source: "hr.163.com", label: "NetEase / 网易" },
68
+ { key: "didi", family: "Bespoke", source: "talent.didiglobal.com", label: "Didi / 滴滴" },
69
+ { key: "bilibili", family: "Bespoke", source: "jobs.bilibili.com", label: "Bilibili / 哔哩哔哩" },
70
+ { key: "pdd", family: "Bespoke", source: "careers.pinduoduo.com", label: "PDD / 拼多多" },
71
+ { key: "huawei", family: "Bespoke", source: "career.huawei.com", label: "Huawei / 华为" },
72
+ { key: "weibo", family: "Bespoke", source: "career.sina.com.cn", label: "Weibo / 微博" },
73
+ { key: "mihoyo", family: "Bespoke", source: "ats.openout.mihoyo.com", label: "miHoYo / 米哈游" },
74
+ { key: "pingan", family: "Bespoke", source: "campus.pingan.com", label: "Ping An / 平安" },
75
+ { key: "trip", family: "Bespoke", source: "careers.ctrip.com", label: "Trip.com / 携程" },
76
+ { key: "unitree", family: "Bespoke", source: "www.unitree.com", label: "Unitree / 宇树科技" },
77
+ { key: "byd", family: "Bespoke", source: "job.byd.com", label: "BYD / 比亚迪" },
78
+ { key: "antgroup", family: "Bespoke", source: "hrcareersweb.antgroup.com", label: "Ant Group / 蚂蚁集团" },
79
+ { key: "liauto", family: "Bespoke", source: "www.lixiang.com", label: "Li Auto / 理想汽车" },
80
+ { key: "sf", family: "Bespoke", source: "campus.sf-express.com", label: "SF Express / 顺丰" },
81
+ { key: "oppo", family: "Bespoke", source: "careers.oppo.com", label: "OPPO" },
82
+ { key: "xiaomi", family: "Feishu", source: "xiaomi.jobs.f.mioffice.cn", label: "Xiaomi / 小米" },
83
+ { key: "nio", family: "Feishu", source: "nio.jobs.feishu.cn", label: "NIO / 蔚来" },
84
+ { key: "minimax", family: "Feishu", source: "vrfi1sk8a0.jobs.feishu.cn", label: "MiniMax" },
85
+ { key: "moonshot", family: "Moka", source: "app.mokahr.com/moonshot", label: "Moonshot / 月之暗面" },
86
+ { key: "zhipu", family: "Feishu", source: "zhipu-ai.jobs.feishu.cn", label: "Zhipu / 智谱AI" },
87
+ { key: "iqiyi", family: "Feishu", source: "careers.iqiyi.com", label: "iQIYI / 爱奇艺" },
88
+ { key: "agibot", family: "Feishu", source: "agirobot.jobs.feishu.cn", label: "Agibot / 智元机器人" },
89
+ { key: "lilith", family: "Feishu", source: "lilithgames.jobs.feishu.cn", label: "Lilith Games / 莉莉丝 — needs local Chrome" },
90
+ { key: "zerooneai", family: "Feishu", source: "01ai.jobs.feishu.cn", label: "01.AI / 零一万物" },
91
+ { key: "baichuan", family: "Feishu", source: "cq6qe6bvfr6.jobs.feishu.cn", label: "Baichuan / 百川智能" },
92
+ { key: "sensetime", family: "Beisen Wecruit", source: "hr.sensetime.com", label: "SenseTime / 商汤" },
93
+ { key: "horizonrobotics", family: "Beisen Wecruit", source: "wecruit.hotjob.cn", label: "Horizon Robotics / 地平线" },
94
+ { key: "vivo", family: "Beisen iTalent", source: "vivo.zhiye.com", label: "vivo" },
95
+ { key: "iflytek", family: "Beisen iTalent", source: "iflytek.zhiye.com", label: "iFlytek / 科大讯飞" },
96
+ { key: "megvii", family: "Moka", source: "app.mokahr.com/megviihr", label: "Megvii / 旷视" },
97
+ { key: "deepseek", family: "Moka", source: "app.mokahr.com/high-flyer", label: "DeepSeek / 深度求索" },
98
+ { key: "galaxyuniversal", family: "Moka", source: "app.mokahr.com/yinhetongyong", label: "Galaxy Universal / 银河通用" },
99
+ { key: "stepfun", family: "Moka", source: "app.mokahr.com/step", label: "StepFun / 阶跃星辰" },
100
+ { key: "cambricon", family: "Moka", source: "app.mokahr.com/cambricon", label: "Cambricon / 寒武纪" },
101
+ { key: "geely", family: "Moka", source: "app.mokahr.com/geely", label: "Geely / 吉利" },
102
+ { key: "xpeng", family: "Greenhouse / Lever (intl arm)", source: "boards.greenhouse.io/xpengmotors", label: "XPeng / 小鹏汽车 — US AI" },
103
+ { key: "weride", family: "Greenhouse / Lever (intl arm)", source: "jobs.lever.co/weride", label: "WeRide / 文远知行 — US / 广州" },
104
+ { key: "hoyoverse", family: "Greenhouse / Lever (intl arm)", source: "boards.greenhouse.io/hoyoverse", label: "HoYoverse / 米哈游国际" },
105
+ { key: "hikvision", family: "Liepin (third-party)", source: "api-c.liepin.com", label: "Hikvision / 海康威视" },
106
+ { key: "cicc", family: "Liepin (third-party)", source: "api-c.liepin.com", label: "CICC / 中金" },
107
+ { key: "cainiao", family: "Liepin (third-party)", source: "api-c.liepin.com", label: "Cainiao / 菜鸟" },
108
+ { key: "webank", family: "Liepin (third-party)", source: "api-c.liepin.com", label: "WeBank / 微众银行" },
109
+ ];
55
110
  const HELP = `
56
111
  job-pro — query Chinese big-tech campus recruiting from your terminal
57
112
  (job.ha7ch.com)
58
113
 
59
114
  USAGE
60
115
  job-pro <company> <verb> [options]
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
61
119
  job-pro --version
62
120
  job-pro help
63
121
 
64
- COMPANIES (50 all live; see job.ha7ch.com for the full matrix)
122
+ 50 companies, all live. Run \`job-pro list\` for the full table grouped
123
+ by ATS family (Bespoke / Feishu / Beisen Wecruit / Beisen iTalent / Moka
124
+ / Greenhouse-Lever / Liepin). Coverage summary at job.ha7ch.com.
65
125
 
66
- Bespoke per-company API
67
- tencent join.qq.com (Tencent / 腾讯)
68
- bytedance jobs.bytedance.com (ByteDance / 字节跳动)
69
- alibaba campus-talent.alibaba.com (Alibaba / 阿里巴巴)
70
- meituan zhaopin.meituan.com (Meituan / 美团)
71
- xiaohongshu job.xiaohongshu.com (Xiaohongshu / 小红书)
72
- jd campus.jd.com (JD / 京东)
73
- kuaishou campus.kuaishou.cn (Kuaishou / 快手)
74
- baidu talent.baidu.com (Baidu / 百度)
75
- netease hr.163.com (NetEase / 网易)
76
- didi talent.didiglobal.com (Didi / 滴滴)
77
- bilibili jobs.bilibili.com (Bilibili / 哔哩哔哩)
78
- pdd careers.pinduoduo.com (PDD / 拼多多)
79
- huawei career.huawei.com (Huawei / 华为)
80
- weibo career.sina.com.cn (Weibo / 微博)
81
- mihoyo ats.openout.mihoyo.com (miHoYo / 米哈游)
82
- pingan campus.pingan.com (Ping An / 平安)
83
- trip careers.ctrip.com (Trip.com / 携程)
84
- unitree www.unitree.com (Unitree / 宇树科技)
85
- byd job.byd.com (BYD / 比亚迪)
86
- antgroup hrcareersweb.antgroup.com (Ant Group / 蚂蚁集团)
87
- liauto www.lixiang.com (Li Auto / 理想汽车)
88
- zerooneai 01ai.jobs.feishu.cn (01.AI / 零一万物)
89
- baichuan cq6qe6bvfr6.jobs.feishu.cn (Baichuan / 百川智能)
90
- sf campus.sf-express.com (SF Express / 顺丰)
91
- oppo careers.oppo.com (OPPO)
92
-
93
- Feishu Recruiting (ATSX)
94
- xiaomi xiaomi.jobs.f.mioffice.cn (Xiaomi / 小米)
95
- nio nio.jobs.feishu.cn (NIO / 蔚来)
96
- minimax vrfi1sk8a0.jobs.feishu.cn (MiniMax)
97
- moonshot moonshot.jobs.feishu.cn (Moonshot / 月之暗面)
98
- zhipu zhipu-ai.jobs.feishu.cn (Zhipu / 智谱AI)
99
- iqiyi careers.iqiyi.com (iQIYI / 爱奇艺)
100
- agibot agirobot.jobs.feishu.cn (Agibot / 智元机器人)
101
- lilith lilithgames.jobs.feishu.cn (Lilith Games / 莉莉丝 — needs local Chrome)
102
-
103
- Beisen Wecruit
104
- sensetime hr.sensetime.com (SenseTime / 商汤)
105
- horizonrobotics wecruit.hotjob.cn (Horizon Robotics / 地平线)
106
-
107
- Beisen iTalent (zhiye)
108
- vivo vivo.zhiye.com (vivo)
109
- iflytek iflytek.zhiye.com (iFlytek / 科大讯飞)
110
-
111
- Moka (app.mokahr.com)
112
- megvii app.mokahr.com/megviihr (Megvii / 旷视)
113
- deepseek app.mokahr.com/high-flyer (DeepSeek / 深度求索)
114
- galaxyuniversal app.mokahr.com/yinhetongyong (Galaxy Universal / 银河通用)
115
- stepfun app.mokahr.com/step (StepFun / 阶跃星辰)
116
- cambricon app.mokahr.com/cambricon (Cambricon / 寒武纪)
117
- geely app.mokahr.com/geely (Geely / 吉利)
118
-
119
- Greenhouse / Lever (international arm only)
120
- xpeng boards.greenhouse.io/xpengmotors (XPeng / 小鹏汽车 — US AI)
121
- weride jobs.lever.co/weride (WeRide / 文远知行 — US / 广州)
122
- hoyoverse boards.greenhouse.io/hoyoverse (HoYoverse / 米哈游国际)
123
-
124
- Third-party (Liepin aggregator — no canonical public feed exists)
125
- hikvision api-c.liepin.com (Hikvision / 海康威视)
126
- cicc api-c.liepin.com (CICC / 中金)
127
- cainiao api-c.liepin.com (Cainiao / 菜鸟)
128
- webank api-c.liepin.com (WeBank / 微众银行)
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.
129
131
 
130
132
  VERBS (same surface for every company)
131
133
  search <kw> search openings (free text)
@@ -138,6 +140,9 @@ VERBS (same surface for every company)
138
140
  match <resume-text-or--> rank jobs by overlap with resume text
139
141
  pass "-" to read resume from stdin
140
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.
141
146
  memory list | get <k> | set k=v | event <kind> [payload] | clear
142
147
 
143
148
  OUTPUT
@@ -384,6 +389,63 @@ async function runCompany(adapter, company, rawArgs) {
384
389
  const text = readResumeArg(args[0]);
385
390
  return emit(adapter.checkResume(text), compact);
386
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
+ }
387
449
  if (verb === "memory") {
388
450
  const [sub, ...subArgs] = args;
389
451
  if (!sub)
@@ -409,6 +471,53 @@ async function runCompany(adapter, company, rawArgs) {
409
471
  }
410
472
  die(`unknown verb: ${verb}. Try \`job-pro help\`.`);
411
473
  }
474
+ function printCompanyList(compact) {
475
+ // Validate the directory still matches the ADAPTERS map. If a company
476
+ // appears in only one place, treat it as a bug.
477
+ const adapterKeys = new Set(Object.keys(ADAPTERS));
478
+ const dirKeys = new Set(COMPANIES.map((c) => c.key));
479
+ const missingInDir = [...adapterKeys].filter((k) => !dirKeys.has(k));
480
+ const missingInAdapters = [...dirKeys].filter((k) => !adapterKeys.has(k));
481
+ if (missingInDir.length || missingInAdapters.length) {
482
+ console.error("INTERNAL: COMPANIES directory diverged from ADAPTERS map.\n" +
483
+ (missingInDir.length ? ` missing from directory: ${missingInDir.join(", ")}\n` : "") +
484
+ (missingInAdapters.length ? ` missing from adapters: ${missingInAdapters.join(", ")}\n` : ""));
485
+ }
486
+ if (compact) {
487
+ // Machine-readable: emit a JSON array of { key, family, source, label }.
488
+ console.log(JSON.stringify(COMPANIES));
489
+ return;
490
+ }
491
+ // Human-readable: group by family, fixed-width left column.
492
+ const byFamily = new Map();
493
+ for (const c of COMPANIES) {
494
+ if (!byFamily.has(c.family))
495
+ byFamily.set(c.family, []);
496
+ byFamily.get(c.family).push(c);
497
+ }
498
+ const order = [
499
+ "Bespoke",
500
+ "Feishu",
501
+ "Beisen Wecruit",
502
+ "Beisen iTalent",
503
+ "Moka",
504
+ "Greenhouse / Lever (intl arm)",
505
+ "Liepin (third-party)",
506
+ ];
507
+ const keyWidth = Math.max(...COMPANIES.map((c) => c.key.length));
508
+ const srcWidth = Math.max(...COMPANIES.map((c) => c.source.length));
509
+ console.log(`job-pro — 50 companies, all live. ATS-family breakdown:`);
510
+ for (const family of order) {
511
+ const entries = byFamily.get(family);
512
+ if (!entries)
513
+ continue;
514
+ console.log(`\n${family} (${entries.length})`);
515
+ for (const c of entries) {
516
+ console.log(` ${c.key.padEnd(keyWidth)} ${c.source.padEnd(srcWidth)} ${c.label}`);
517
+ }
518
+ }
519
+ console.log(`\nTotal: ${COMPANIES.length}. Run \`job-pro <key> search "…"\` against any of them.`);
520
+ }
412
521
  async function main() {
413
522
  const args = process.argv.slice(2);
414
523
  const cmd = args[0];
@@ -420,12 +529,42 @@ async function main() {
420
529
  console.log(VERSION);
421
530
  return;
422
531
  }
532
+ if (cmd === "list" || cmd === "companies") {
533
+ const compact = args.includes("--compact");
534
+ printCompanyList(compact);
535
+ return;
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
+ }
423
561
  const adapter = ADAPTERS[cmd];
424
562
  if (adapter) {
425
563
  await runCompany(adapter, cmd, args.slice(1));
426
564
  return;
427
565
  }
428
- die(`unknown company: ${cmd}. Supported: ${Object.keys(ADAPTERS).join(", ")}. Try \`job-pro help\`.`);
566
+ die(`unknown company: ${cmd}. Try \`job-pro list\` for the full list, ` +
567
+ `or \`job-pro help\` for usage.`);
429
568
  }
430
569
  main().catch((err) => {
431
570
  console.error("Error:", err instanceof Error ? err.message : err);
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.1",
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",