job-pro 0.8.2 → 0.9.1
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 +272 -0
- package/dist/greenhouse.js +58 -0
- package/dist/hoyoverse.js +1 -0
- package/dist/index.js +106 -1
- package/dist/lever.js +75 -0
- package/dist/weride.js +1 -0
- package/dist/xpeng.js +1 -0
- package/package.json +1 -1
package/dist/apply.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
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, statSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { basename, 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
|
+
}
|
|
167
|
+
export async function submitApplication(staged, target) {
|
|
168
|
+
if (!staged.submit_endpoint) {
|
|
169
|
+
return {
|
|
170
|
+
ok: false,
|
|
171
|
+
posted_to: "",
|
|
172
|
+
message: "no submit_endpoint on staged application — this adapter family doesn't expose a public submission API",
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
if (!staged.ready) {
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
posted_to: "",
|
|
179
|
+
message: `${staged.unanswered_required.length} required field(s) still unanswered; fill them before submitting`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
if (target.kind === "dry-run") {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
posted_to: "dry-run (no network)",
|
|
186
|
+
message: "dry-run requested — no HTTP call fired",
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
const url = target.kind === "debug" ? target.url : staged.submit_endpoint;
|
|
190
|
+
const fd = await buildMultipartForm(staged);
|
|
191
|
+
let response;
|
|
192
|
+
try {
|
|
193
|
+
response = await fetch(url, {
|
|
194
|
+
method: staged.submit_method ?? "POST",
|
|
195
|
+
headers: {
|
|
196
|
+
// Don't set Content-Type — fetch/undici picks the correct
|
|
197
|
+
// multipart/form-data boundary for the FormData instance.
|
|
198
|
+
Accept: "application/json, text/plain, */*",
|
|
199
|
+
"User-Agent": "job-pro/0.9 (https://github.com/HA7CH/job-pro)",
|
|
200
|
+
},
|
|
201
|
+
body: fd,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
return {
|
|
206
|
+
ok: false,
|
|
207
|
+
posted_to: url,
|
|
208
|
+
message: `network error: ${err instanceof Error ? err.message : String(err)}`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
let preview = "";
|
|
212
|
+
try {
|
|
213
|
+
preview = (await response.text()).slice(0, 4000);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
/* binary response is fine */
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
ok: response.ok,
|
|
220
|
+
status: response.status,
|
|
221
|
+
posted_to: url,
|
|
222
|
+
response_preview: preview,
|
|
223
|
+
message: response.ok
|
|
224
|
+
? `submission accepted (HTTP ${response.status})`
|
|
225
|
+
: `upstream rejected: HTTP ${response.status} ${response.statusText}`,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
async function buildMultipartForm(staged) {
|
|
229
|
+
const fd = new FormData();
|
|
230
|
+
for (const field of staged.staged) {
|
|
231
|
+
if (!field.value)
|
|
232
|
+
continue;
|
|
233
|
+
if (field.type === "input_file") {
|
|
234
|
+
// Read the file synchronously — these are resumes, KB-range PDFs.
|
|
235
|
+
// For debug endpoints we still attach the actual file so the
|
|
236
|
+
// multipart wire format matches production exactly.
|
|
237
|
+
let stat;
|
|
238
|
+
try {
|
|
239
|
+
stat = statSync(field.value);
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
throw new Error(`could not stat resume file ${field.value}: ${err instanceof Error ? err.message : err}`);
|
|
243
|
+
}
|
|
244
|
+
if (!stat.isFile()) {
|
|
245
|
+
throw new Error(`resume path is not a file: ${field.value}`);
|
|
246
|
+
}
|
|
247
|
+
const bytes = readFileSync(field.value);
|
|
248
|
+
const filename = basename(field.value);
|
|
249
|
+
// Best-effort content type from extension; ATS-side typically
|
|
250
|
+
// re-detects from magic bytes anyway.
|
|
251
|
+
const ext = filename.toLowerCase().split(".").pop() ?? "";
|
|
252
|
+
const mime = ext === "pdf"
|
|
253
|
+
? "application/pdf"
|
|
254
|
+
: ext === "docx"
|
|
255
|
+
? "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
256
|
+
: ext === "doc"
|
|
257
|
+
? "application/msword"
|
|
258
|
+
: "application/octet-stream";
|
|
259
|
+
// Node 20+ has a global File constructor; for older runtimes, fall
|
|
260
|
+
// back to a Blob. We bumped engines.node >=18 — Blob is universal.
|
|
261
|
+
const FileCtor = globalThis.File;
|
|
262
|
+
const part = typeof FileCtor === "function"
|
|
263
|
+
? new FileCtor([new Uint8Array(bytes)], filename, { type: mime })
|
|
264
|
+
: new Blob([new Uint8Array(bytes)], { type: mime });
|
|
265
|
+
fd.append(field.name, part, filename);
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
fd.append(field.name, field.value);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return fd;
|
|
272
|
+
}
|
package/dist/greenhouse.js
CHANGED
|
@@ -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, submitApplication, formatStaged, } from "./apply.js";
|
|
53
54
|
import { memoryList, memoryGet, memorySet, memoryEvent, memoryClear, } from "./memory.js";
|
|
54
|
-
|
|
55
|
+
import { writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
56
|
+
import { dirname } from "node:path";
|
|
57
|
+
const VERSION = "0.9.1";
|
|
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,73 @@ 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 | --debug-submit-to <url> | --really-submit]`);
|
|
396
|
+
const reallySubmit = args.includes("--really-submit");
|
|
397
|
+
const { args: aDebug, value: debugUrl } = popFlagValue(args, "--debug-submit-to");
|
|
398
|
+
const fetchSchema = adapter.fetchApplicationSchema;
|
|
399
|
+
if (typeof fetchSchema !== "function") {
|
|
400
|
+
return emit({
|
|
401
|
+
ok: false,
|
|
402
|
+
source: company,
|
|
403
|
+
post_id: postId,
|
|
404
|
+
message: `apply: Phase 2 not yet wired for "${company}". Only Greenhouse + Lever ` +
|
|
405
|
+
`boards (xpeng / hoyoverse / weride) expose an application schema today. ` +
|
|
406
|
+
`See docs/auto-apply.md for the rollout plan.`,
|
|
407
|
+
}, compact);
|
|
408
|
+
}
|
|
409
|
+
if (reallySubmit) {
|
|
410
|
+
return emit({
|
|
411
|
+
ok: false,
|
|
412
|
+
source: company,
|
|
413
|
+
post_id: postId,
|
|
414
|
+
message: `--really-submit is intentionally disabled until per-ATS submission flows ` +
|
|
415
|
+
`are validated end-to-end against live boards. Use --debug-submit-to <url> ` +
|
|
416
|
+
`to verify the multipart wire format against your own echo server (e.g. ` +
|
|
417
|
+
`https://httpbin.org/post) without spamming the real ATS.`,
|
|
418
|
+
}, compact);
|
|
419
|
+
}
|
|
420
|
+
const schemaResult = await fetchSchema.call(adapter, postId);
|
|
421
|
+
const sr = schemaResult;
|
|
422
|
+
if (!sr.ok || !sr.schema) {
|
|
423
|
+
return emit({ ok: false, source: company, post_id: postId, message: sr.message ?? "unknown error" }, compact);
|
|
424
|
+
}
|
|
425
|
+
const prof = loadProfile();
|
|
426
|
+
if (!prof.ok) {
|
|
427
|
+
return emit({
|
|
428
|
+
ok: false,
|
|
429
|
+
source: company,
|
|
430
|
+
post_id: postId,
|
|
431
|
+
schema: sr.schema,
|
|
432
|
+
message: prof.message,
|
|
433
|
+
hint: `run \`job-pro profile init\` to create a template.`,
|
|
434
|
+
}, compact);
|
|
435
|
+
}
|
|
436
|
+
const staged = stageApplication(sr.schema, prof.profile);
|
|
437
|
+
// Mode selection: --debug-submit-to <url> overrides dry-run.
|
|
438
|
+
if (debugUrl) {
|
|
439
|
+
const result = await submitApplication(staged, { kind: "debug", url: debugUrl });
|
|
440
|
+
return emit({ mode: "debug-submit", staged, result }, compact);
|
|
441
|
+
}
|
|
442
|
+
// Default: dry-run print, no network.
|
|
443
|
+
if (compact) {
|
|
444
|
+
return emit({ mode: "dry-run", staged }, compact);
|
|
445
|
+
}
|
|
446
|
+
console.log(formatStaged(staged));
|
|
447
|
+
if (!staged.ready) {
|
|
448
|
+
console.log(`\nFill the unanswered required fields in ${profileTemplate().path} ` +
|
|
449
|
+
`(profile.custom.<name> for unknown fields), then re-run.`);
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
console.log(`\nDry-run complete. Use --debug-submit-to https://httpbin.org/post to ` +
|
|
453
|
+
`verify the multipart wire format. --really-submit will be enabled per-ATS ` +
|
|
454
|
+
`once each family's submission flow has been validated end-to-end.`);
|
|
455
|
+
}
|
|
456
|
+
void aDebug; // silence "unused" — `args` flow goes through popFlagValue
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
378
459
|
if (verb === "memory") {
|
|
379
460
|
const [sub, ...subArgs] = args;
|
|
380
461
|
if (!sub)
|
|
@@ -463,6 +544,30 @@ async function main() {
|
|
|
463
544
|
printCompanyList(compact);
|
|
464
545
|
return;
|
|
465
546
|
}
|
|
547
|
+
if (cmd === "profile") {
|
|
548
|
+
const sub = args[1];
|
|
549
|
+
if (sub === "init") {
|
|
550
|
+
const { path, template } = profileTemplate();
|
|
551
|
+
if (existsSync(path) && !args.includes("--force")) {
|
|
552
|
+
console.error(`profile already exists at ${path}; pass --force to overwrite.`);
|
|
553
|
+
process.exit(1);
|
|
554
|
+
}
|
|
555
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
556
|
+
writeFileSync(path, JSON.stringify(template, null, 2) + "\n", "utf8");
|
|
557
|
+
console.log(`Wrote ${path}. Fill in first_name / last_name / email / phone / resume_path before running \`job-pro <co> apply\`.`);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (sub === "show") {
|
|
561
|
+
const r = loadProfile();
|
|
562
|
+
if (!r.ok) {
|
|
563
|
+
console.error(r.message);
|
|
564
|
+
process.exit(1);
|
|
565
|
+
}
|
|
566
|
+
console.log(JSON.stringify(r.profile, null, 2));
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
die(`usage: job-pro profile <init [--force] | show>`);
|
|
570
|
+
}
|
|
466
571
|
const adapter = ADAPTERS[cmd];
|
|
467
572
|
if (adapter) {
|
|
468
573
|
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.
|
|
3
|
+
"version": "0.9.1",
|
|
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",
|