job-pro 1.0.0 → 1.0.2
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 +158 -0
- package/dist/index.js +66 -8
- package/package.json +1 -1
package/dist/apply.js
CHANGED
|
@@ -202,6 +202,164 @@ export function formatStaged(s) {
|
|
|
202
202
|
function truncate(s, n) {
|
|
203
203
|
return s.length > n ? s.slice(0, n - 1) + "…" : s;
|
|
204
204
|
}
|
|
205
|
+
export function buildFormTemplate(schema, profile) {
|
|
206
|
+
const out = [];
|
|
207
|
+
for (const q of schema.questions) {
|
|
208
|
+
for (const f of q.fields) {
|
|
209
|
+
const resolved = resolveAnswer(f, profile);
|
|
210
|
+
out.push({
|
|
211
|
+
name: f.name,
|
|
212
|
+
type: f.type,
|
|
213
|
+
required: q.required,
|
|
214
|
+
label: q.label,
|
|
215
|
+
description: q.description,
|
|
216
|
+
options: f.values && f.values.length > 0 ? f.values : undefined,
|
|
217
|
+
value: resolved.value,
|
|
218
|
+
unanswered_reason: resolved.value ? undefined : resolved.reason || undefined,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
source: schema.source,
|
|
224
|
+
post_id: schema.post_id,
|
|
225
|
+
job_title: schema.job_title,
|
|
226
|
+
apply_url: schema.apply_url,
|
|
227
|
+
submit_kind: schema.submit_kind,
|
|
228
|
+
fields: out,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Walk an ApplyFormSchema and prompt for each unanswered required field
|
|
233
|
+
* on stdin (via readline). Returns the new overrides as a flat
|
|
234
|
+
* `{ name: value }` map ready to merge into profile.custom.
|
|
235
|
+
*
|
|
236
|
+
* Behaviour:
|
|
237
|
+
* - Fields already resolved from profile (name/email/phone/resume/etc.)
|
|
238
|
+
* are skipped silently.
|
|
239
|
+
* - For `*_select` field types, options are presented as a numbered
|
|
240
|
+
* list — user can type the index or the literal value.
|
|
241
|
+
* - User can hit Enter to skip a non-required field.
|
|
242
|
+
* - User can type `q` / Ctrl-D to abort; we return what we've got so far.
|
|
243
|
+
*
|
|
244
|
+
* This function intentionally lives in apply.ts (not index.ts) so it
|
|
245
|
+
* stays unit-testable and so a future TUI can swap it out.
|
|
246
|
+
*/
|
|
247
|
+
export async function promptUnansweredFields(schema, profile, io) {
|
|
248
|
+
const overrides = {};
|
|
249
|
+
for (const q of schema.questions) {
|
|
250
|
+
// Only prompt for the primary field of each question. Secondary
|
|
251
|
+
// alternates (e.g. `resume_text` alongside `resume`) get the same
|
|
252
|
+
// resolution as the primary and don't need a separate prompt.
|
|
253
|
+
const f = q.fields[0];
|
|
254
|
+
if (!f)
|
|
255
|
+
continue;
|
|
256
|
+
const resolved = resolveAnswer(f, profile);
|
|
257
|
+
if (resolved.value)
|
|
258
|
+
continue; // already filled
|
|
259
|
+
if (!q.required)
|
|
260
|
+
continue; // skip optional fields entirely
|
|
261
|
+
while (true) {
|
|
262
|
+
// Build the prompt.
|
|
263
|
+
const lines = [];
|
|
264
|
+
lines.push(`\n${q.label} (required) [${f.name}]`);
|
|
265
|
+
if (q.description)
|
|
266
|
+
lines.push(` ${q.description}`);
|
|
267
|
+
if (f.values && f.values.length > 0) {
|
|
268
|
+
lines.push(" Options:");
|
|
269
|
+
f.values.forEach((opt, i) => {
|
|
270
|
+
const label = opt.label && opt.label !== opt.value ? `${opt.value} — ${opt.label}` : opt.value;
|
|
271
|
+
lines.push(` [${i + 1}] ${label}`);
|
|
272
|
+
});
|
|
273
|
+
lines.push(" Enter number or value:");
|
|
274
|
+
}
|
|
275
|
+
else if (f.type === "input_file") {
|
|
276
|
+
lines.push(" Enter absolute file path:");
|
|
277
|
+
}
|
|
278
|
+
else if (f.type === "textarea") {
|
|
279
|
+
lines.push(" Enter text (single line; \\n for newlines):");
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
lines.push(" Enter value:");
|
|
283
|
+
}
|
|
284
|
+
lines.push("> ");
|
|
285
|
+
io.write(lines.join("\n"));
|
|
286
|
+
const answer = await io.read();
|
|
287
|
+
if (answer === null) {
|
|
288
|
+
// Ctrl-D / EOF — bail with what we have.
|
|
289
|
+
return overrides;
|
|
290
|
+
}
|
|
291
|
+
const trimmed = answer.trim();
|
|
292
|
+
if (trimmed === "q")
|
|
293
|
+
return overrides;
|
|
294
|
+
if (!trimmed) {
|
|
295
|
+
// Empty input for a required field — re-prompt unless user wants to skip.
|
|
296
|
+
io.write(" (required — type a value, `q` to abort, or `skip` to leave blank)\n");
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (trimmed === "skip")
|
|
300
|
+
break;
|
|
301
|
+
let resolvedAnswer = trimmed;
|
|
302
|
+
if (f.values && f.values.length > 0) {
|
|
303
|
+
const asIdx = Number.parseInt(trimmed, 10);
|
|
304
|
+
if (Number.isFinite(asIdx) && asIdx >= 1 && asIdx <= f.values.length) {
|
|
305
|
+
// Coerce — Greenhouse sometimes ships numeric values that JSON.parse
|
|
306
|
+
// hands back as numbers, breaking .replace below.
|
|
307
|
+
resolvedAnswer = String(f.values[asIdx - 1].value ?? "");
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
overrides[f.name] = resolvedAnswer.replace(/\\n/g, "\n");
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return overrides;
|
|
315
|
+
}
|
|
316
|
+
/** Merge a `{ field_name: value }` map into the profile's custom overrides. */
|
|
317
|
+
export function applyFormFile(profile, formFilePath) {
|
|
318
|
+
if (!existsSync(formFilePath)) {
|
|
319
|
+
return { ok: false, message: `form file not found: ${formFilePath}` };
|
|
320
|
+
}
|
|
321
|
+
let raw;
|
|
322
|
+
try {
|
|
323
|
+
raw = readFileSync(formFilePath, "utf8");
|
|
324
|
+
}
|
|
325
|
+
catch (err) {
|
|
326
|
+
return { ok: false, message: `read ${formFilePath} failed: ${err instanceof Error ? err.message : err}` };
|
|
327
|
+
}
|
|
328
|
+
let parsed;
|
|
329
|
+
try {
|
|
330
|
+
parsed = JSON.parse(raw);
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
return { ok: false, message: `${formFilePath} is not valid JSON: ${err instanceof Error ? err.message : err}` };
|
|
334
|
+
}
|
|
335
|
+
// Accept either:
|
|
336
|
+
// (a) a flat { name: value } map, or
|
|
337
|
+
// (b) the full FormTemplate shape (fields:[{ name, value }, …])
|
|
338
|
+
const overrides = {};
|
|
339
|
+
if (parsed && typeof parsed === "object" && Array.isArray(parsed.fields)) {
|
|
340
|
+
for (const f of parsed.fields) {
|
|
341
|
+
if (typeof f.name === "string" && typeof f.value === "string" && f.value.length > 0) {
|
|
342
|
+
overrides[f.name] = f.value;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
else if (parsed && typeof parsed === "object") {
|
|
347
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
348
|
+
if (typeof v === "string" && v.length > 0)
|
|
349
|
+
overrides[k] = v;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
return { ok: false, message: "form file must be a JSON object or FormTemplate" };
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
ok: true,
|
|
357
|
+
profile: {
|
|
358
|
+
...profile,
|
|
359
|
+
custom: { ...(profile.custom ?? {}), ...overrides },
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
}
|
|
205
363
|
export function buildBespokeApplySchema(cfg) {
|
|
206
364
|
const standard = [
|
|
207
365
|
{ label: "Name", required: true, fields: [{ name: "name", type: "input_text" }] },
|
package/dist/index.js
CHANGED
|
@@ -50,11 +50,12 @@ import * as geely from "./geely.js";
|
|
|
50
50
|
import * as webank from "./webank.js";
|
|
51
51
|
import * as horizonrobotics from "./horizonrobotics.js";
|
|
52
52
|
import * as cambricon from "./cambricon.js";
|
|
53
|
-
import { loadProfile, loadSession, profileTemplate, stageApplication, submitApplication, executeFeishu3Step, executeMokaApply, executeBeisenWecruit, executeBeisenITalent, executeCdpRealBrowser, formatStaged, } from "./apply.js";
|
|
53
|
+
import { loadProfile, loadSession, profileTemplate, stageApplication, submitApplication, executeFeishu3Step, executeMokaApply, executeBeisenWecruit, executeBeisenITalent, executeCdpRealBrowser, buildFormTemplate, applyFormFile, promptUnansweredFields, formatStaged, } from "./apply.js";
|
|
54
|
+
import { createInterface } from "node:readline";
|
|
54
55
|
import { memoryList, memoryGet, memorySet, memoryEvent, memoryClear, } from "./memory.js";
|
|
55
56
|
import { writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
56
57
|
import { dirname } from "node:path";
|
|
57
|
-
const VERSION = "1.0.
|
|
58
|
+
const VERSION = "1.0.2";
|
|
58
59
|
const COMPANIES = [
|
|
59
60
|
{ key: "tencent", family: "Bespoke", source: "join.qq.com", label: "Tencent / 腾讯" },
|
|
60
61
|
{ key: "bytedance", family: "Bespoke", source: "jobs.bytedance.com", label: "ByteDance / 字节跳动" },
|
|
@@ -152,8 +153,11 @@ VERBS (same surface for every company)
|
|
|
152
153
|
pass "-" to read resume from stdin
|
|
153
154
|
resume-check <resume-text-or--> structural sanity check on a resume
|
|
154
155
|
apply <post_id> stage an application (Phase 2 dry-run)
|
|
155
|
-
--
|
|
156
|
-
|
|
156
|
+
--print-form emit a fillable JSON template
|
|
157
|
+
--form-file <path> merge per-job answers
|
|
158
|
+
--interactive prompt for unanswered fields
|
|
159
|
+
--debug-submit-to <url> verify wire format
|
|
160
|
+
--really-submit actually fire (env-gated)
|
|
157
161
|
memory list | get <k> | set k=v | event <kind> [payload] | clear
|
|
158
162
|
|
|
159
163
|
OUTPUT
|
|
@@ -403,9 +407,13 @@ async function runCompany(adapter, company, rawArgs) {
|
|
|
403
407
|
if (verb === "apply") {
|
|
404
408
|
const postId = args[0];
|
|
405
409
|
if (!postId)
|
|
406
|
-
die(`usage: job-pro ${company} apply <post_id> [--dry-run | --debug-submit-to <url> | --really-submit]`);
|
|
410
|
+
die(`usage: job-pro ${company} apply <post_id> [--print-form | --form-file <path>] [--dry-run | --debug-submit-to <url> | --really-submit]`);
|
|
407
411
|
const reallySubmit = args.includes("--really-submit");
|
|
412
|
+
const printForm = args.includes("--print-form");
|
|
413
|
+
const interactive = args.includes("--interactive");
|
|
408
414
|
const { args: aDebug, value: debugUrl } = popFlagValue(args, "--debug-submit-to");
|
|
415
|
+
const { args: _aForm, value: formFilePath } = popFlagValue(aDebug, "--form-file");
|
|
416
|
+
void _aForm;
|
|
409
417
|
const fetchSchema = adapter.fetchApplicationSchema;
|
|
410
418
|
if (typeof fetchSchema !== "function") {
|
|
411
419
|
return emit({
|
|
@@ -437,7 +445,49 @@ async function runCompany(adapter, company, rawArgs) {
|
|
|
437
445
|
hint: `run \`job-pro profile init\` to create a template.`,
|
|
438
446
|
}, compact);
|
|
439
447
|
}
|
|
440
|
-
|
|
448
|
+
// --print-form short-circuits everything else: emit a fillable
|
|
449
|
+
// template specific to this job's schema and exit.
|
|
450
|
+
if (printForm) {
|
|
451
|
+
const template = buildFormTemplate(sr.schema, prof.profile);
|
|
452
|
+
return emit(template, compact);
|
|
453
|
+
}
|
|
454
|
+
// --form-file merges per-job overrides into profile.custom.
|
|
455
|
+
let effectiveProfile = prof.profile;
|
|
456
|
+
if (formFilePath) {
|
|
457
|
+
const merged = applyFormFile(effectiveProfile, formFilePath);
|
|
458
|
+
if (!merged.ok) {
|
|
459
|
+
return emit({
|
|
460
|
+
ok: false,
|
|
461
|
+
source: company,
|
|
462
|
+
post_id: postId,
|
|
463
|
+
message: merged.message,
|
|
464
|
+
}, compact);
|
|
465
|
+
}
|
|
466
|
+
effectiveProfile = merged.profile;
|
|
467
|
+
}
|
|
468
|
+
// --interactive: prompt stdin for each unanswered required field.
|
|
469
|
+
// Skipped in --compact mode (we'd be polluting JSON output with prompts).
|
|
470
|
+
if (interactive && !compact) {
|
|
471
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
472
|
+
const io = {
|
|
473
|
+
write: (s) => process.stdout.write(s),
|
|
474
|
+
read: () => new Promise((resolve) => {
|
|
475
|
+
rl.once("close", () => resolve(null));
|
|
476
|
+
rl.question("", (a) => resolve(a));
|
|
477
|
+
}),
|
|
478
|
+
};
|
|
479
|
+
console.log(`\nInteractive mode — fill the required fields for "${sr.schema.job_title || postId}".`);
|
|
480
|
+
console.log(`Type \`q\` or Ctrl-D to abort. Hit Enter to skip an optional field.`);
|
|
481
|
+
const overrides = await promptUnansweredFields(sr.schema, effectiveProfile, io);
|
|
482
|
+
rl.close();
|
|
483
|
+
// Merge into effectiveProfile.custom for the rest of the flow.
|
|
484
|
+
effectiveProfile = {
|
|
485
|
+
...effectiveProfile,
|
|
486
|
+
custom: { ...(effectiveProfile.custom ?? {}), ...overrides },
|
|
487
|
+
};
|
|
488
|
+
console.log(`\nCollected ${Object.keys(overrides).length} answer(s). Staging now…\n`);
|
|
489
|
+
}
|
|
490
|
+
const staged = stageApplication(sr.schema, effectiveProfile);
|
|
441
491
|
const session = loadSession(company);
|
|
442
492
|
// Mode selection: --debug-submit-to <url> overrides everything.
|
|
443
493
|
if (debugUrl) {
|
|
@@ -572,8 +622,16 @@ async function runCompany(adapter, company, rawArgs) {
|
|
|
572
622
|
console.log(`\nSession captured (~/.jobpro/${company}.session.json): ${session.cookies.length} cookies + ${Object.keys(session.headers).length} auth headers.`);
|
|
573
623
|
}
|
|
574
624
|
if (!staged.ready) {
|
|
575
|
-
console.log(`\nFill the unanswered required fields
|
|
576
|
-
`
|
|
625
|
+
console.log(`\nFill the unanswered required fields. Easiest path:\n` +
|
|
626
|
+
` 1. job-pro ${company} apply ${postId} --print-form > form.json\n` +
|
|
627
|
+
` 2. Edit form.json — set each \`value\` for required fields.\n` +
|
|
628
|
+
` 3. job-pro ${company} apply ${postId} --form-file form.json\n` +
|
|
629
|
+
`Or paste the following into ${profileTemplate().path} under \`custom\`:`);
|
|
630
|
+
// Emit a copy-pasteable JSON snippet listing each unanswered required.
|
|
631
|
+
const snippet = {};
|
|
632
|
+
for (const f of staged.unanswered_required)
|
|
633
|
+
snippet[f.name] = "";
|
|
634
|
+
console.log(JSON.stringify({ custom: snippet }, null, 2));
|
|
577
635
|
}
|
|
578
636
|
else {
|
|
579
637
|
const isAnon = staged.source.startsWith("boards-api.greenhouse.io/") ||
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "job-pro",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
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",
|