job-pro 0.9.1 → 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
@@ -17,6 +17,33 @@ import { readFileSync, existsSync, statSync } from "node:fs";
17
17
  import { homedir } from "node:os";
18
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,7 +191,7 @@ export function formatStaged(s) {
164
191
  function truncate(s, n) {
165
192
  return s.length > n ? s.slice(0, n - 1) + "…" : s;
166
193
  }
167
- export async function submitApplication(staged, target) {
194
+ export async function submitApplication(staged, target, options = {}) {
168
195
  if (!staged.submit_endpoint) {
169
196
  return {
170
197
  ok: false,
@@ -188,16 +215,45 @@ export async function submitApplication(staged, target) {
188
215
  }
189
216
  const url = target.kind === "debug" ? target.url : staged.submit_endpoint;
190
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
+ }
191
252
  let response;
192
253
  try {
193
254
  response = await fetch(url, {
194
255
  method: staged.submit_method ?? "POST",
195
- headers: {
196
- // Don't set Content-Type — fetch/undici picks the correct
197
- // multipart/form-data boundary for the FormData instance.
198
- Accept: "application/json, text/plain, */*",
199
- "User-Agent": "job-pro/0.9 (https://github.com/HA7CH/job-pro)",
200
- },
256
+ headers,
201
257
  body: fd,
202
258
  });
203
259
  }
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, submitApplication, 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.1";
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 / 字节跳动" },
@@ -406,17 +406,10 @@ async function runCompany(adapter, company, rawArgs) {
406
406
  `See docs/auto-apply.md for the rollout plan.`,
407
407
  }, compact);
408
408
  }
409
- if (reallySubmit) {
410
- return emit({
411
- ok: false,
412
- source: company,
413
- post_id: postId,
414
- message: `--really-submit is intentionally disabled until per-ATS submission flows ` +
415
- `are validated end-to-end against live boards. Use --debug-submit-to <url> ` +
416
- `to verify the multipart wire format against your own echo server (e.g. ` +
417
- `https://httpbin.org/post) without spamming the real ATS.`,
418
- }, compact);
419
- }
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.
420
413
  const schemaResult = await fetchSchema.call(adapter, postId);
421
414
  const sr = schemaResult;
422
415
  if (!sr.ok || !sr.schema) {
@@ -434,24 +427,80 @@ async function runCompany(adapter, company, rawArgs) {
434
427
  }, compact);
435
428
  }
436
429
  const staged = stageApplication(sr.schema, prof.profile);
437
- // Mode selection: --debug-submit-to <url> overrides dry-run.
430
+ const session = loadSession(company);
431
+ // Mode selection: --debug-submit-to <url> overrides everything.
438
432
  if (debugUrl) {
439
433
  const result = await submitApplication(staged, { kind: "debug", url: debugUrl });
440
434
  return emit({ mode: "debug-submit", staged, result }, compact);
441
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
+ }
442
483
  // Default: dry-run print, no network.
443
484
  if (compact) {
444
- return emit({ mode: "dry-run", staged }, compact);
485
+ return emit({ mode: "dry-run", staged, has_session: !!session }, compact);
445
486
  }
446
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
+ }
447
491
  if (!staged.ready) {
448
492
  console.log(`\nFill the unanswered required fields in ${profileTemplate().path} ` +
449
493
  `(profile.custom.<name> for unknown fields), then re-run.`);
450
494
  }
451
495
  else {
452
- console.log(`\nDry-run complete. Use --debug-submit-to https://httpbin.org/post to ` +
453
- `verify the multipart wire format. --really-submit will be enabled per-ATS ` +
454
- `once each family's submission flow has been validated end-to-end.`);
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`));
455
504
  }
456
505
  void aDebug; // silence "unused" — `args` flow goes through popFlagValue
457
506
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "job-pro",
3
- "version": "0.9.1",
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",