job-pro 1.0.1 → 1.0.3

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
@@ -228,6 +228,91 @@ export function buildFormTemplate(schema, profile) {
228
228
  fields: out,
229
229
  };
230
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
+ }
231
316
  /** Merge a `{ field_name: value }` map into the profile's custom overrides. */
232
317
  export function applyFormFile(profile, formFilePath) {
233
318
  if (!existsSync(formFilePath)) {
package/dist/index.js CHANGED
@@ -50,11 +50,17 @@ 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, buildFormTemplate, applyFormFile, 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
- import { writeFileSync, mkdirSync, existsSync } from "node:fs";
56
- import { dirname } from "node:path";
57
- const VERSION = "1.0.1";
56
+ import { writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from "node:fs";
57
+ import { dirname, join } from "node:path";
58
+ import { homedir } from "node:os";
59
+ import { createRequire as require_createRequire } from "node:module";
60
+ function require_module() {
61
+ return { createRequire: require_createRequire };
62
+ }
63
+ const VERSION = "1.0.3";
58
64
  const COMPANIES = [
59
65
  { key: "tencent", family: "Bespoke", source: "join.qq.com", label: "Tencent / 腾讯" },
60
66
  { key: "bytedance", family: "Bespoke", source: "jobs.bytedance.com", label: "ByteDance / 字节跳动" },
@@ -114,6 +120,7 @@ job-pro — query Chinese big-tech campus recruiting from your terminal
114
120
  USAGE
115
121
  job-pro <company> <verb> [options]
116
122
  job-pro list [--compact] list all 50 companies + source family
123
+ job-pro status [--compact] survey profile / sessions / memory / chrome
117
124
  job-pro profile init [--force] write ~/.jobpro/profile.json template
118
125
  job-pro profile show print the loaded profile
119
126
  job-pro --version
@@ -154,6 +161,7 @@ VERBS (same surface for every company)
154
161
  apply <post_id> stage an application (Phase 2 dry-run)
155
162
  --print-form emit a fillable JSON template
156
163
  --form-file <path> merge per-job answers
164
+ --interactive prompt for unanswered fields
157
165
  --debug-submit-to <url> verify wire format
158
166
  --really-submit actually fire (env-gated)
159
167
  memory list | get <k> | set k=v | event <kind> [payload] | clear
@@ -408,6 +416,7 @@ async function runCompany(adapter, company, rawArgs) {
408
416
  die(`usage: job-pro ${company} apply <post_id> [--print-form | --form-file <path>] [--dry-run | --debug-submit-to <url> | --really-submit]`);
409
417
  const reallySubmit = args.includes("--really-submit");
410
418
  const printForm = args.includes("--print-form");
419
+ const interactive = args.includes("--interactive");
411
420
  const { args: aDebug, value: debugUrl } = popFlagValue(args, "--debug-submit-to");
412
421
  const { args: _aForm, value: formFilePath } = popFlagValue(aDebug, "--form-file");
413
422
  void _aForm;
@@ -462,6 +471,28 @@ async function runCompany(adapter, company, rawArgs) {
462
471
  }
463
472
  effectiveProfile = merged.profile;
464
473
  }
474
+ // --interactive: prompt stdin for each unanswered required field.
475
+ // Skipped in --compact mode (we'd be polluting JSON output with prompts).
476
+ if (interactive && !compact) {
477
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
478
+ const io = {
479
+ write: (s) => process.stdout.write(s),
480
+ read: () => new Promise((resolve) => {
481
+ rl.once("close", () => resolve(null));
482
+ rl.question("", (a) => resolve(a));
483
+ }),
484
+ };
485
+ console.log(`\nInteractive mode — fill the required fields for "${sr.schema.job_title || postId}".`);
486
+ console.log(`Type \`q\` or Ctrl-D to abort. Hit Enter to skip an optional field.`);
487
+ const overrides = await promptUnansweredFields(sr.schema, effectiveProfile, io);
488
+ rl.close();
489
+ // Merge into effectiveProfile.custom for the rest of the flow.
490
+ effectiveProfile = {
491
+ ...effectiveProfile,
492
+ custom: { ...(effectiveProfile.custom ?? {}), ...overrides },
493
+ };
494
+ console.log(`\nCollected ${Object.keys(overrides).length} answer(s). Staging now…\n`);
495
+ }
465
496
  const staged = stageApplication(sr.schema, effectiveProfile);
466
497
  const session = loadSession(company);
467
498
  // Mode selection: --debug-submit-to <url> overrides everything.
@@ -646,6 +677,185 @@ async function runCompany(adapter, company, rawArgs) {
646
677
  }
647
678
  die(`unknown verb: ${verb}. Try \`job-pro help\`.`);
648
679
  }
680
+ function buildStatusReport() {
681
+ const homeDir = process.env.JOBPRO_HOME ?? join(homedir(), ".jobpro");
682
+ const profilePath = process.env.JOB_PRO_PROFILE_PATH ?? join(homeDir, "profile.json");
683
+ const sessionDir = process.env.JOB_PRO_SESSION_DIR ?? homeDir;
684
+ // Profile state.
685
+ const filled = [];
686
+ const missing = [];
687
+ let customKeys = 0;
688
+ let profileExists = false;
689
+ if (existsSync(profilePath)) {
690
+ profileExists = true;
691
+ try {
692
+ const p = JSON.parse(readFileSync(profilePath, "utf8"));
693
+ for (const key of ["first_name", "last_name", "email", "phone", "resume_path"]) {
694
+ const v = p[key];
695
+ if (typeof v === "string" && v.length > 0)
696
+ filled.push(key);
697
+ else
698
+ missing.push(key);
699
+ }
700
+ customKeys = p.custom && typeof p.custom === "object" ? Object.keys(p.custom).length : 0;
701
+ }
702
+ catch {
703
+ missing.push("(profile JSON is malformed)");
704
+ }
705
+ }
706
+ else {
707
+ missing.push("first_name", "last_name", "email", "phone", "resume_path");
708
+ }
709
+ // Captured sessions in ~/.jobpro/*.session.json
710
+ const sessions = [];
711
+ if (existsSync(sessionDir)) {
712
+ try {
713
+ for (const f of readdirSync(sessionDir)) {
714
+ if (!f.endsWith(".session.json"))
715
+ continue;
716
+ const adapter = f.slice(0, -".session.json".length);
717
+ const full = join(sessionDir, f);
718
+ const stat = statSync(full);
719
+ const age = (Date.now() - stat.mtimeMs) / (24 * 3600 * 1000);
720
+ let host;
721
+ let cookieCount = 0;
722
+ let headerCount = 0;
723
+ let capturedAt;
724
+ try {
725
+ const j = JSON.parse(readFileSync(full, "utf8"));
726
+ host = j.host;
727
+ cookieCount = Array.isArray(j.cookies) ? j.cookies.length : 0;
728
+ headerCount = j.headers ? Object.keys(j.headers).length : 0;
729
+ capturedAt = j.exported_at;
730
+ }
731
+ catch {
732
+ /* malformed — still surface the file */
733
+ }
734
+ sessions.push({
735
+ adapter,
736
+ path: full,
737
+ host,
738
+ captured_at: capturedAt,
739
+ age_days: Math.round(age * 10) / 10,
740
+ cookies: cookieCount,
741
+ headers: headerCount,
742
+ });
743
+ }
744
+ }
745
+ catch {
746
+ /* ignore */
747
+ }
748
+ }
749
+ // Memory snapshot.
750
+ const memSummary = {
751
+ field_keys: [],
752
+ recent_events: [],
753
+ total_events: 0,
754
+ };
755
+ try {
756
+ const memList = memoryList();
757
+ if (memList?.path)
758
+ memSummary.path = memList.path;
759
+ if (memList?.fields)
760
+ memSummary.field_keys = Object.keys(memList.fields);
761
+ if (Array.isArray(memList?.events)) {
762
+ memSummary.total_events = memList.events.length;
763
+ memSummary.recent_events = memList.events.slice(-5).reverse();
764
+ }
765
+ }
766
+ catch {
767
+ /* ignore */
768
+ }
769
+ // Chrome / puppeteer-core availability.
770
+ const CHROME_CANDIDATES = [
771
+ process.env.JOB_PRO_CHROME,
772
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
773
+ "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
774
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
775
+ "/usr/bin/google-chrome",
776
+ "/usr/bin/google-chrome-stable",
777
+ "/usr/bin/chromium",
778
+ "/usr/bin/chromium-browser",
779
+ ].filter((p) => typeof p === "string" && p.length > 0);
780
+ const chromePath = CHROME_CANDIDATES.find((p) => existsSync(p));
781
+ // puppeteer-core is a runtime dep, but a user could have done --omit=optional
782
+ // or be running from a fresh checkout. Probe via createRequire because
783
+ // we're an ESM module without a CJS `require`.
784
+ let hasPuppeteer = false;
785
+ try {
786
+ const { createRequire } = require_module();
787
+ const req = createRequire(import.meta.url);
788
+ req.resolve("puppeteer-core");
789
+ hasPuppeteer = true;
790
+ }
791
+ catch {
792
+ hasPuppeteer = false;
793
+ }
794
+ return {
795
+ profile: {
796
+ path: profilePath,
797
+ exists: profileExists,
798
+ filled_standard: filled,
799
+ missing_standard: missing,
800
+ custom_keys: customKeys,
801
+ },
802
+ sessions,
803
+ memory: memSummary,
804
+ chrome: { found: !!chromePath, path: chromePath, puppeteer_core: hasPuppeteer },
805
+ };
806
+ }
807
+ function printStatus(compact) {
808
+ const r = buildStatusReport();
809
+ if (compact) {
810
+ console.log(JSON.stringify(r));
811
+ return;
812
+ }
813
+ console.log(`job-pro status (${VERSION})`);
814
+ console.log();
815
+ // Profile
816
+ const filledColor = (r.profile.missing_standard.length === 0 && r.profile.exists) ? "✓" : "✗";
817
+ console.log(`Profile ${filledColor} ${r.profile.path}`);
818
+ if (!r.profile.exists) {
819
+ console.log(` not found — run \`job-pro profile init\``);
820
+ }
821
+ else {
822
+ console.log(` filled: ${r.profile.filled_standard.join(", ") || "(none)"}`);
823
+ if (r.profile.missing_standard.length > 0) {
824
+ console.log(` missing: ${r.profile.missing_standard.join(", ")}`);
825
+ }
826
+ if (r.profile.custom_keys > 0) {
827
+ console.log(` custom: ${r.profile.custom_keys} keys`);
828
+ }
829
+ }
830
+ console.log();
831
+ // Sessions
832
+ if (r.sessions.length === 0) {
833
+ console.log(`Sessions ✗ no session.json files captured`);
834
+ console.log(` install extension/ in Chrome to capture sessions for non-anon adapters.`);
835
+ }
836
+ else {
837
+ console.log(`Sessions ✓ ${r.sessions.length} captured`);
838
+ for (const s of r.sessions) {
839
+ const stale = (s.age_days ?? 0) > 30 ? " (STALE — sessions usually expire ~30 days)" : "";
840
+ console.log(` ${s.adapter.padEnd(18)} ${s.cookies ?? 0}c+${s.headers ?? 0}h age=${s.age_days}d${stale}`);
841
+ }
842
+ }
843
+ console.log();
844
+ // Memory
845
+ console.log(`Memory ${r.memory.total_events > 0 ? "✓" : "·"} ${r.memory.path ?? "(none)"}`);
846
+ console.log(` fields=${r.memory.field_keys.length} events=${r.memory.total_events}`);
847
+ for (const e of r.memory.recent_events.slice(0, 5)) {
848
+ console.log(` ${e.ts} ${e.kind.padEnd(12)} ${(e.payload ?? "").slice(0, 60)}`);
849
+ }
850
+ console.log();
851
+ // Chrome
852
+ const ch = r.chrome.found && r.chrome.puppeteer_core ? "✓" : "✗";
853
+ console.log(`Chrome ${ch} ${r.chrome.path ?? "(not found)"}`);
854
+ console.log(` puppeteer-core: ${r.chrome.puppeteer_core ? "installed" : "missing"}`);
855
+ if (!r.chrome.found || !r.chrome.puppeteer_core) {
856
+ console.log(` needed for: lilith adapter, --proxy-server geo-bypass (hikvision).`);
857
+ }
858
+ }
649
859
  function printCompanyList(compact) {
650
860
  // Validate the directory still matches the ADAPTERS map. If a company
651
861
  // appears in only one place, treat it as a bug.
@@ -709,6 +919,11 @@ async function main() {
709
919
  printCompanyList(compact);
710
920
  return;
711
921
  }
922
+ if (cmd === "status") {
923
+ const compact = args.includes("--compact");
924
+ printStatus(compact);
925
+ return;
926
+ }
712
927
  if (cmd === "profile") {
713
928
  const sub = args[1];
714
929
  if (sub === "init") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "job-pro",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
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",