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 +108 -2
- package/dist/index.js +20 -10
- package/package.json +1 -1
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.
|
|
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 /
|
|
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
|
|
414
|
-
`
|
|
415
|
-
`
|
|
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({
|
|
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. --
|
|
445
|
-
`
|
|
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.
|
|
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",
|