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 +85 -0
- package/dist/index.js +219 -4
- package/package.json +1 -1
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
|
-
|
|
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.
|
|
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",
|