job-pro 0.9.0 → 0.9.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
@@ -13,10 +13,37 @@
13
13
  // Fields beyond first_name / last_name / email / phone / resume are
14
14
  // passed through to whatever per-company custom question matches their
15
15
  // `name` (e.g. `linkedin_url`, `nationality`).
16
- import { readFileSync, existsSync } from "node:fs";
16
+ import { readFileSync, existsSync, statSync } from "node:fs";
17
17
  import { homedir } from "node:os";
18
- import { join } from "node:path";
18
+ import { basename, join } from "node:path";
19
19
  const PROFILE_PATH = process.env.JOB_PRO_PROFILE_PATH ?? join(homedir(), ".jobpro", "profile.json");
20
+ const SESSION_DIR = process.env.JOB_PRO_SESSION_DIR ?? join(homedir(), ".jobpro");
21
+ /** Read a captured session for an adapter, or null if none exists. */
22
+ export function loadSession(adapterKey) {
23
+ const path = join(SESSION_DIR, `${adapterKey}.session.json`);
24
+ if (!existsSync(path))
25
+ return null;
26
+ try {
27
+ const raw = readFileSync(path, "utf8");
28
+ return JSON.parse(raw);
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ /** Convert a CapturedSession into a single Cookie header string. */
35
+ export function serializeCookieHeader(session, targetHost) {
36
+ const cookies = session.cookies.filter((c) => {
37
+ if (!targetHost)
38
+ return true;
39
+ if (!c.domain)
40
+ return true;
41
+ // RFC-style domain match: ".example.com" matches any subdomain.
42
+ const dom = c.domain.startsWith(".") ? c.domain.slice(1) : c.domain;
43
+ return targetHost === dom || targetHost.endsWith("." + dom);
44
+ });
45
+ return cookies.map((c) => `${c.name}=${c.value}`).join("; ");
46
+ }
20
47
  const TEMPLATE = {
21
48
  first_name: "",
22
49
  last_name: "",
@@ -164,3 +191,138 @@ export function formatStaged(s) {
164
191
  function truncate(s, n) {
165
192
  return s.length > n ? s.slice(0, n - 1) + "…" : s;
166
193
  }
194
+ export async function submitApplication(staged, target, options = {}) {
195
+ if (!staged.submit_endpoint) {
196
+ return {
197
+ ok: false,
198
+ posted_to: "",
199
+ message: "no submit_endpoint on staged application — this adapter family doesn't expose a public submission API",
200
+ };
201
+ }
202
+ if (!staged.ready) {
203
+ return {
204
+ ok: false,
205
+ posted_to: "",
206
+ message: `${staged.unanswered_required.length} required field(s) still unanswered; fill them before submitting`,
207
+ };
208
+ }
209
+ if (target.kind === "dry-run") {
210
+ return {
211
+ ok: false,
212
+ posted_to: "dry-run (no network)",
213
+ message: "dry-run requested — no HTTP call fired",
214
+ };
215
+ }
216
+ const url = target.kind === "debug" ? target.url : staged.submit_endpoint;
217
+ const fd = await buildMultipartForm(staged);
218
+ const headers = {
219
+ // Don't set Content-Type — fetch/undici picks the correct
220
+ // multipart/form-data boundary for the FormData instance.
221
+ Accept: "application/json, text/plain, */*",
222
+ "User-Agent": "job-pro/0.9 (https://github.com/HA7CH/job-pro)",
223
+ };
224
+ // Layer in captured-session headers (Cookie, X-Xsrf-Token, etc.) only
225
+ // when we're actually hitting the upstream endpoint. Debug echo endpoints
226
+ // (httpbin) don't need them and might log them, so we strip there.
227
+ if (target.kind === "upstream" && options.session) {
228
+ const targetHost = (() => {
229
+ try {
230
+ return new URL(url).hostname;
231
+ }
232
+ catch {
233
+ return undefined;
234
+ }
235
+ })();
236
+ const cookieHeader = serializeCookieHeader(options.session, targetHost);
237
+ if (cookieHeader)
238
+ headers.Cookie = cookieHeader;
239
+ for (const [k, v] of Object.entries(options.session.headers ?? {})) {
240
+ // Skip cookie — already handled. Skip content-type — let undici set
241
+ // the multipart boundary one. Skip authorization-bearer only if the
242
+ // upstream's auth model isn't cookie-based.
243
+ if (k === "cookie" || k === "content-type")
244
+ continue;
245
+ // Normalise to canonical casing — fetch's Headers preserves what we set.
246
+ headers[k] = v;
247
+ }
248
+ }
249
+ if (options.extraHeaders) {
250
+ Object.assign(headers, options.extraHeaders);
251
+ }
252
+ let response;
253
+ try {
254
+ response = await fetch(url, {
255
+ method: staged.submit_method ?? "POST",
256
+ headers,
257
+ body: fd,
258
+ });
259
+ }
260
+ catch (err) {
261
+ return {
262
+ ok: false,
263
+ posted_to: url,
264
+ message: `network error: ${err instanceof Error ? err.message : String(err)}`,
265
+ };
266
+ }
267
+ let preview = "";
268
+ try {
269
+ preview = (await response.text()).slice(0, 4000);
270
+ }
271
+ catch {
272
+ /* binary response is fine */
273
+ }
274
+ return {
275
+ ok: response.ok,
276
+ status: response.status,
277
+ posted_to: url,
278
+ response_preview: preview,
279
+ message: response.ok
280
+ ? `submission accepted (HTTP ${response.status})`
281
+ : `upstream rejected: HTTP ${response.status} ${response.statusText}`,
282
+ };
283
+ }
284
+ async function buildMultipartForm(staged) {
285
+ const fd = new FormData();
286
+ for (const field of staged.staged) {
287
+ if (!field.value)
288
+ continue;
289
+ if (field.type === "input_file") {
290
+ // Read the file synchronously — these are resumes, KB-range PDFs.
291
+ // For debug endpoints we still attach the actual file so the
292
+ // multipart wire format matches production exactly.
293
+ let stat;
294
+ try {
295
+ stat = statSync(field.value);
296
+ }
297
+ catch (err) {
298
+ throw new Error(`could not stat resume file ${field.value}: ${err instanceof Error ? err.message : err}`);
299
+ }
300
+ if (!stat.isFile()) {
301
+ throw new Error(`resume path is not a file: ${field.value}`);
302
+ }
303
+ const bytes = readFileSync(field.value);
304
+ const filename = basename(field.value);
305
+ // Best-effort content type from extension; ATS-side typically
306
+ // re-detects from magic bytes anyway.
307
+ const ext = filename.toLowerCase().split(".").pop() ?? "";
308
+ const mime = ext === "pdf"
309
+ ? "application/pdf"
310
+ : ext === "docx"
311
+ ? "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
312
+ : ext === "doc"
313
+ ? "application/msword"
314
+ : "application/octet-stream";
315
+ // Node 20+ has a global File constructor; for older runtimes, fall
316
+ // back to a Blob. We bumped engines.node >=18 — Blob is universal.
317
+ const FileCtor = globalThis.File;
318
+ const part = typeof FileCtor === "function"
319
+ ? new FileCtor([new Uint8Array(bytes)], filename, { type: mime })
320
+ : new Blob([new Uint8Array(bytes)], { type: mime });
321
+ fd.append(field.name, part, filename);
322
+ }
323
+ else {
324
+ fd.append(field.name, field.value);
325
+ }
326
+ }
327
+ return fd;
328
+ }
package/dist/index.js CHANGED
@@ -50,11 +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, formatStaged, } from "./apply.js";
53
+ import { loadProfile, loadSession, profileTemplate, stageApplication, submitApplication, formatStaged, } from "./apply.js";
54
54
  import { memoryList, memoryGet, memorySet, memoryEvent, memoryClear, } from "./memory.js";
55
55
  import { writeFileSync, mkdirSync, existsSync } from "node:fs";
56
56
  import { dirname } from "node:path";
57
- const VERSION = "0.9.0";
57
+ const VERSION = "0.9.2";
58
58
  const COMPANIES = [
59
59
  { key: "tencent", family: "Bespoke", source: "join.qq.com", label: "Tencent / 腾讯" },
60
60
  { key: "bytedance", family: "Bespoke", source: "jobs.bytedance.com", label: "ByteDance / 字节跳动" },
@@ -392,8 +392,9 @@ async function runCompany(adapter, company, rawArgs) {
392
392
  if (verb === "apply") {
393
393
  const postId = args[0];
394
394
  if (!postId)
395
- die(`usage: job-pro ${company} apply <post_id> [--dry-run | --really-submit]`);
395
+ die(`usage: job-pro ${company} apply <post_id> [--dry-run | --debug-submit-to <url> | --really-submit]`);
396
396
  const reallySubmit = args.includes("--really-submit");
397
+ const { args: aDebug, value: debugUrl } = popFlagValue(args, "--debug-submit-to");
397
398
  const fetchSchema = adapter.fetchApplicationSchema;
398
399
  if (typeof fetchSchema !== "function") {
399
400
  return emit({
@@ -401,20 +402,14 @@ async function runCompany(adapter, company, rawArgs) {
401
402
  source: company,
402
403
  post_id: postId,
403
404
  message: `apply: Phase 2 not yet wired for "${company}". Only Greenhouse + Lever ` +
404
- `boards (xpeng / weride / hoyoverse) expose an application schema today. ` +
405
+ `boards (xpeng / hoyoverse / weride) expose an application schema today. ` +
405
406
  `See docs/auto-apply.md for the rollout plan.`,
406
407
  }, compact);
407
408
  }
408
- if (reallySubmit) {
409
- return emit({
410
- ok: false,
411
- source: company,
412
- post_id: postId,
413
- message: `--really-submit is intentionally not implemented yet. Phase 2 ships the ` +
414
- `staging path first so you can see exactly what would be POSTed before any ` +
415
- `submission actually fires. Re-run without --really-submit for the dry-run.`,
416
- }, compact);
417
- }
409
+ // Note: we DON'T early-return on reallySubmit here — we fall through
410
+ // to stage the application first, then re-gate before actually posting.
411
+ // This lets the user verify the staged payload one last time even
412
+ // when they pass --really-submit by accident.
418
413
  const schemaResult = await fetchSchema.call(adapter, postId);
419
414
  const sr = schemaResult;
420
415
  if (!sr.ok || !sr.schema) {
@@ -432,18 +427,82 @@ async function runCompany(adapter, company, rawArgs) {
432
427
  }, compact);
433
428
  }
434
429
  const staged = stageApplication(sr.schema, prof.profile);
430
+ const session = loadSession(company);
431
+ // Mode selection: --debug-submit-to <url> overrides everything.
432
+ if (debugUrl) {
433
+ const result = await submitApplication(staged, { kind: "debug", url: debugUrl });
434
+ return emit({ mode: "debug-submit", staged, result }, compact);
435
+ }
436
+ // --really-submit: actually hit the upstream endpoint. Guarded by both
437
+ // an env-var attestation and (for non-anon adapters) a session.json.
438
+ if (reallySubmit) {
439
+ const understood = process.env.JOB_PRO_I_UNDERSTAND_REAL_SUBMIT === "yes";
440
+ if (!understood) {
441
+ return emit({
442
+ ok: false,
443
+ source: company,
444
+ post_id: postId,
445
+ mode: "really-submit-blocked",
446
+ staged,
447
+ message: `--really-submit is gated by an env-var attestation. To unlock, set ` +
448
+ `JOB_PRO_I_UNDERSTAND_REAL_SUBMIT=yes in your shell. This submission will ` +
449
+ `POST a real application to ${staged.submit_endpoint}; doing so without a ` +
450
+ `valid resume / answers is spam against the company's recruiters.`,
451
+ }, compact);
452
+ }
453
+ if (!staged.ready) {
454
+ return emit({
455
+ ok: false,
456
+ source: company,
457
+ post_id: postId,
458
+ mode: "really-submit-blocked",
459
+ staged,
460
+ message: `${staged.unanswered_required.length} required field(s) still unanswered; refusing to submit incomplete application`,
461
+ }, compact);
462
+ }
463
+ // Anon adapters (Greenhouse / Lever) don't need session.json.
464
+ // Bespoke / Beisen / Moka / Feishu adapters do — bail with a hint
465
+ // pointing at the browser extension.
466
+ const isAnonFamily = staged.source.startsWith("boards-api.greenhouse.io/") ||
467
+ staged.source.startsWith("api.lever.co/");
468
+ if (!isAnonFamily && !session) {
469
+ return emit({
470
+ ok: false,
471
+ source: company,
472
+ post_id: postId,
473
+ mode: "really-submit-blocked",
474
+ staged,
475
+ message: `no captured session at ~/.jobpro/${company}.session.json. Install the ` +
476
+ `extension/ directory in Chrome, log into the careers site, click Export, ` +
477
+ `then mv ~/Downloads/jobpro/${company}.session.json ~/.jobpro/`,
478
+ }, compact);
479
+ }
480
+ const result = await submitApplication(staged, { kind: "upstream" }, { session });
481
+ return emit({ mode: "really-submit", staged, session_used: !!session, result }, compact);
482
+ }
483
+ // Default: dry-run print, no network.
435
484
  if (compact) {
436
- return emit({ ok: true, staged }, compact);
485
+ return emit({ mode: "dry-run", staged, has_session: !!session }, compact);
437
486
  }
438
487
  console.log(formatStaged(staged));
488
+ if (session) {
489
+ console.log(`\nSession captured (~/.jobpro/${company}.session.json): ${session.cookies.length} cookies + ${Object.keys(session.headers).length} auth headers.`);
490
+ }
439
491
  if (!staged.ready) {
440
492
  console.log(`\nFill the unanswered required fields in ${profileTemplate().path} ` +
441
493
  `(profile.custom.<name> for unknown fields), then re-run.`);
442
494
  }
443
495
  else {
444
- console.log(`\nDry-run complete. --really-submit will be enabled in a future release ` +
445
- `once per-ATS submission flows have been validated against live boards.`);
496
+ const isAnon = staged.source.startsWith("boards-api.greenhouse.io/") ||
497
+ staged.source.startsWith("api.lever.co/");
498
+ console.log(`\nDry-run complete. To actually submit:\n` +
499
+ ` • --debug-submit-to https://httpbin.org/post — verify wire format\n` +
500
+ ` • JOB_PRO_I_UNDERSTAND_REAL_SUBMIT=yes job-pro ${company} apply ${postId} --really-submit\n` +
501
+ (isAnon
502
+ ? ` ${company} is Greenhouse/Lever (anonymous submission, no session needed).\n`
503
+ : ` ${company} needs ~/.jobpro/${company}.session.json — capture via the browser extension.\n`));
446
504
  }
505
+ void aDebug; // silence "unused" — `args` flow goes through popFlagValue
447
506
  return;
448
507
  }
449
508
  if (verb === "memory") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "job-pro",
3
- "version": "0.9.0",
3
+ "version": "0.9.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",