job-pro 0.9.0 → 0.9.1

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,9 +13,9 @@
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
20
  const TEMPLATE = {
21
21
  first_name: "",
@@ -164,3 +164,109 @@ export function formatStaged(s) {
164
164
  function truncate(s, n) {
165
165
  return s.length > n ? s.slice(0, n - 1) + "…" : s;
166
166
  }
167
+ export async function submitApplication(staged, target) {
168
+ if (!staged.submit_endpoint) {
169
+ return {
170
+ ok: false,
171
+ posted_to: "",
172
+ message: "no submit_endpoint on staged application — this adapter family doesn't expose a public submission API",
173
+ };
174
+ }
175
+ if (!staged.ready) {
176
+ return {
177
+ ok: false,
178
+ posted_to: "",
179
+ message: `${staged.unanswered_required.length} required field(s) still unanswered; fill them before submitting`,
180
+ };
181
+ }
182
+ if (target.kind === "dry-run") {
183
+ return {
184
+ ok: false,
185
+ posted_to: "dry-run (no network)",
186
+ message: "dry-run requested — no HTTP call fired",
187
+ };
188
+ }
189
+ const url = target.kind === "debug" ? target.url : staged.submit_endpoint;
190
+ const fd = await buildMultipartForm(staged);
191
+ let response;
192
+ try {
193
+ response = await fetch(url, {
194
+ 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
+ },
201
+ body: fd,
202
+ });
203
+ }
204
+ catch (err) {
205
+ return {
206
+ ok: false,
207
+ posted_to: url,
208
+ message: `network error: ${err instanceof Error ? err.message : String(err)}`,
209
+ };
210
+ }
211
+ let preview = "";
212
+ try {
213
+ preview = (await response.text()).slice(0, 4000);
214
+ }
215
+ catch {
216
+ /* binary response is fine */
217
+ }
218
+ return {
219
+ ok: response.ok,
220
+ status: response.status,
221
+ posted_to: url,
222
+ response_preview: preview,
223
+ message: response.ok
224
+ ? `submission accepted (HTTP ${response.status})`
225
+ : `upstream rejected: HTTP ${response.status} ${response.statusText}`,
226
+ };
227
+ }
228
+ async function buildMultipartForm(staged) {
229
+ const fd = new FormData();
230
+ for (const field of staged.staged) {
231
+ if (!field.value)
232
+ continue;
233
+ if (field.type === "input_file") {
234
+ // Read the file synchronously — these are resumes, KB-range PDFs.
235
+ // For debug endpoints we still attach the actual file so the
236
+ // multipart wire format matches production exactly.
237
+ let stat;
238
+ try {
239
+ stat = statSync(field.value);
240
+ }
241
+ catch (err) {
242
+ throw new Error(`could not stat resume file ${field.value}: ${err instanceof Error ? err.message : err}`);
243
+ }
244
+ if (!stat.isFile()) {
245
+ throw new Error(`resume path is not a file: ${field.value}`);
246
+ }
247
+ const bytes = readFileSync(field.value);
248
+ const filename = basename(field.value);
249
+ // Best-effort content type from extension; ATS-side typically
250
+ // re-detects from magic bytes anyway.
251
+ const ext = filename.toLowerCase().split(".").pop() ?? "";
252
+ const mime = ext === "pdf"
253
+ ? "application/pdf"
254
+ : ext === "docx"
255
+ ? "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
256
+ : ext === "doc"
257
+ ? "application/msword"
258
+ : "application/octet-stream";
259
+ // Node 20+ has a global File constructor; for older runtimes, fall
260
+ // back to a Blob. We bumped engines.node >=18 — Blob is universal.
261
+ const FileCtor = globalThis.File;
262
+ const part = typeof FileCtor === "function"
263
+ ? new FileCtor([new Uint8Array(bytes)], filename, { type: mime })
264
+ : new Blob([new Uint8Array(bytes)], { type: mime });
265
+ fd.append(field.name, part, filename);
266
+ }
267
+ else {
268
+ fd.append(field.name, field.value);
269
+ }
270
+ }
271
+ return fd;
272
+ }
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, 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.1";
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,7 +402,7 @@ 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
  }
@@ -410,9 +411,10 @@ async function runCompany(adapter, company, rawArgs) {
410
411
  ok: false,
411
412
  source: company,
412
413
  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.`,
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.`,
416
418
  }, compact);
417
419
  }
418
420
  const schemaResult = await fetchSchema.call(adapter, postId);
@@ -432,8 +434,14 @@ async function runCompany(adapter, company, rawArgs) {
432
434
  }, compact);
433
435
  }
434
436
  const staged = stageApplication(sr.schema, prof.profile);
437
+ // Mode selection: --debug-submit-to <url> overrides dry-run.
438
+ if (debugUrl) {
439
+ const result = await submitApplication(staged, { kind: "debug", url: debugUrl });
440
+ return emit({ mode: "debug-submit", staged, result }, compact);
441
+ }
442
+ // Default: dry-run print, no network.
435
443
  if (compact) {
436
- return emit({ ok: true, staged }, compact);
444
+ return emit({ mode: "dry-run", staged }, compact);
437
445
  }
438
446
  console.log(formatStaged(staged));
439
447
  if (!staged.ready) {
@@ -441,9 +449,11 @@ async function runCompany(adapter, company, rawArgs) {
441
449
  `(profile.custom.<name> for unknown fields), then re-run.`);
442
450
  }
443
451
  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.`);
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.`);
446
455
  }
456
+ void aDebug; // silence "unused" — `args` flow goes through popFlagValue
447
457
  return;
448
458
  }
449
459
  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.1",
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",