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 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.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
- --really-submit is intentionally disabled
156
- until per-ATS flows are validated.
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
- const staged = stageApplication(sr.schema, prof.profile);
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 in ${profileTemplate().path} ` +
576
- `(profile.custom.<name> for unknown fields), then re-run.`);
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.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",