job-pro 0.8.2 → 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 ADDED
@@ -0,0 +1,272 @@
1
+ // Phase 2 — auto-apply infrastructure.
2
+ //
3
+ // This module is intentionally read-only (dry-run) right now. The user
4
+ // runs `job-pro <co> apply <postId>` and gets a fully-staged POST payload
5
+ // printed to stdout. Actually firing the submission ("--really-submit")
6
+ // is guarded: each adapter family must opt in by exporting an
7
+ // `executeApplication` function. Out of the 50 adapters, only a handful
8
+ // (Greenhouse boards / Lever boards) have well-documented public
9
+ // submission APIs; the rest need session capture (Phase 2.1, separate
10
+ // release).
11
+ //
12
+ // Profile shape — loaded from `~/.jobpro/profile.json` or via flags.
13
+ // Fields beyond first_name / last_name / email / phone / resume are
14
+ // passed through to whatever per-company custom question matches their
15
+ // `name` (e.g. `linkedin_url`, `nationality`).
16
+ import { readFileSync, existsSync, statSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { basename, join } from "node:path";
19
+ const PROFILE_PATH = process.env.JOB_PRO_PROFILE_PATH ?? join(homedir(), ".jobpro", "profile.json");
20
+ const TEMPLATE = {
21
+ first_name: "",
22
+ last_name: "",
23
+ email: "",
24
+ phone: "",
25
+ resume_path: "",
26
+ cover_letter_text: "",
27
+ custom: {
28
+ // Common Greenhouse / Lever questions:
29
+ // question_<n>: "answer"
30
+ // linkedin_url: "https://www.linkedin.com/in/your-handle",
31
+ // nationality: "China",
32
+ },
33
+ };
34
+ export function loadProfile() {
35
+ if (!existsSync(PROFILE_PATH)) {
36
+ return {
37
+ ok: false,
38
+ message: `profile not found at ${PROFILE_PATH}. Run \`job-pro profile init\` to create a template, ` +
39
+ `or set $JOB_PRO_PROFILE_PATH to override.`,
40
+ };
41
+ }
42
+ let raw;
43
+ try {
44
+ raw = readFileSync(PROFILE_PATH, "utf8");
45
+ }
46
+ catch (err) {
47
+ return { ok: false, message: `could not read ${PROFILE_PATH}: ${err instanceof Error ? err.message : err}` };
48
+ }
49
+ let parsed;
50
+ try {
51
+ parsed = JSON.parse(raw);
52
+ }
53
+ catch (err) {
54
+ return { ok: false, message: `${PROFILE_PATH} is not valid JSON: ${err instanceof Error ? err.message : err}` };
55
+ }
56
+ for (const required of ["first_name", "last_name", "email", "phone"]) {
57
+ if (!parsed[required]) {
58
+ return { ok: false, message: `${PROFILE_PATH}: missing required field "${required}"` };
59
+ }
60
+ }
61
+ return { ok: true, profile: parsed };
62
+ }
63
+ export function profileTemplate() {
64
+ return { path: PROFILE_PATH, template: TEMPLATE };
65
+ }
66
+ /** Fill in known answers from the profile; flag any unanswered required fields. */
67
+ export function stageApplication(schema, profile) {
68
+ const staged = [];
69
+ const unanswered_required = [];
70
+ for (const q of schema.questions) {
71
+ // The "primary" field is the first one; secondary fields are alternate
72
+ // formats (e.g. resume has both `resume` file + `resume_text` textarea).
73
+ const primary = q.fields[0];
74
+ if (!primary)
75
+ continue;
76
+ const filled = resolveAnswer(primary, profile);
77
+ const reason = filled.value || !q.required ? undefined : filled.reason;
78
+ const sf = {
79
+ name: primary.name,
80
+ type: primary.type,
81
+ value: filled.value,
82
+ required: q.required,
83
+ unanswered_reason: reason,
84
+ };
85
+ staged.push(sf);
86
+ if (q.required && !filled.value)
87
+ unanswered_required.push(sf);
88
+ }
89
+ return {
90
+ source: schema.source,
91
+ post_id: schema.post_id,
92
+ job_title: schema.job_title,
93
+ apply_url: schema.apply_url,
94
+ submit_endpoint: schema.submit_endpoint,
95
+ submit_method: schema.submit_method,
96
+ staged,
97
+ unanswered_required,
98
+ ready: unanswered_required.length === 0,
99
+ };
100
+ }
101
+ function resolveAnswer(field, profile) {
102
+ // Hard-coded standard mappings — these names are the canonical
103
+ // Greenhouse field names and are reused by Lever's submission form.
104
+ switch (field.name) {
105
+ case "first_name":
106
+ return { value: profile.first_name ?? "", reason: "profile.first_name missing" };
107
+ case "last_name":
108
+ return { value: profile.last_name ?? "", reason: "profile.last_name missing" };
109
+ case "email":
110
+ return { value: profile.email ?? "", reason: "profile.email missing" };
111
+ case "phone":
112
+ return { value: profile.phone ?? "", reason: "profile.phone missing" };
113
+ case "resume":
114
+ return {
115
+ value: profile.resume_path ?? "",
116
+ reason: "profile.resume_path missing — set to an absolute PDF/DOCX path",
117
+ };
118
+ case "resume_text":
119
+ // Optional companion field — leave empty if user supplies a file.
120
+ return { value: "", reason: "" };
121
+ case "cover_letter":
122
+ return { value: "", reason: "" };
123
+ case "cover_letter_text":
124
+ return { value: profile.cover_letter_text ?? "", reason: "" };
125
+ default:
126
+ // Custom passthroughs — match by question name (e.g. "question_36528765002").
127
+ const v = profile.custom?.[field.name];
128
+ if (typeof v === "string" && v.length > 0)
129
+ return { value: v, reason: "" };
130
+ return {
131
+ value: "",
132
+ reason: `unknown field "${field.name}" — add to profile.custom.${field.name} to auto-fill`,
133
+ };
134
+ }
135
+ }
136
+ // ---------- pretty-print for dry-run ----------
137
+ export function formatStaged(s) {
138
+ const lines = [];
139
+ lines.push(`source: ${s.source}`);
140
+ lines.push(`job: ${s.post_id} — ${s.job_title}`);
141
+ lines.push(`apply_url: ${s.apply_url}`);
142
+ if (s.submit_endpoint) {
143
+ lines.push(`submit: ${s.submit_method ?? "POST"} ${s.submit_endpoint}`);
144
+ }
145
+ lines.push("");
146
+ lines.push(`ready: ${s.ready ? "✓ all required fields filled" : `✗ ${s.unanswered_required.length} required field(s) unfilled`}`);
147
+ lines.push("");
148
+ lines.push("Staged payload:");
149
+ const widthName = Math.max(...s.staged.map((f) => f.name.length));
150
+ const widthType = Math.max(...s.staged.map((f) => f.type.length));
151
+ for (const f of s.staged) {
152
+ const flag = f.required ? "•" : " ";
153
+ const value = f.value
154
+ ? f.type === "input_file"
155
+ ? `<file: ${f.value}>`
156
+ : truncate(f.value, 60)
157
+ : f.unanswered_reason
158
+ ? `<unanswered: ${f.unanswered_reason}>`
159
+ : "<empty>";
160
+ lines.push(` ${flag} ${f.name.padEnd(widthName)} ${f.type.padEnd(widthType)} ${value}`);
161
+ }
162
+ return lines.join("\n");
163
+ }
164
+ function truncate(s, n) {
165
+ return s.length > n ? s.slice(0, n - 1) + "…" : s;
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
+ }
@@ -356,6 +356,63 @@ export function createAdapter(cfg) {
356
356
  "The only authority on selection is HR.",
357
357
  };
358
358
  }
359
+ async function fetchApplicationSchema(postId) {
360
+ const id = (postId ?? "").trim();
361
+ if (!id)
362
+ return { ok: false, source: SOURCE, message: "post_id is required" };
363
+ const url = `${API_ROOT}/jobs/${encodeURIComponent(id)}?questions=true`;
364
+ let response;
365
+ try {
366
+ response = await fetch(url, { headers: HEADERS });
367
+ }
368
+ catch (err) {
369
+ return {
370
+ ok: false,
371
+ source: SOURCE,
372
+ message: `network error: ${err instanceof Error ? err.message : String(err)}`,
373
+ };
374
+ }
375
+ if (!response.ok) {
376
+ return {
377
+ ok: false,
378
+ source: SOURCE,
379
+ message: `HTTP ${response.status}: ${response.statusText}`,
380
+ };
381
+ }
382
+ let job;
383
+ try {
384
+ job = (await response.json());
385
+ }
386
+ catch (err) {
387
+ return {
388
+ ok: false,
389
+ source: SOURCE,
390
+ message: `bad JSON: ${err instanceof Error ? err.message : String(err)}`,
391
+ };
392
+ }
393
+ const questions = (job.questions ?? []).map((q) => ({
394
+ label: q.label ?? "",
395
+ description: q.description ?? null,
396
+ required: q.required ?? false,
397
+ fields: (q.fields ?? []).map((f) => ({
398
+ name: f.name ?? "",
399
+ type: f.type ?? "input_text",
400
+ values: (f.values ?? []).map((v) => ({ value: v.value ?? "", label: v.label ?? "" })),
401
+ })),
402
+ }));
403
+ return {
404
+ ok: true,
405
+ schema: {
406
+ source: SOURCE,
407
+ post_id: id,
408
+ job_title: job.title ?? "",
409
+ apply_url: job.absolute_url ?? `${BOARD_URL}/jobs/${id}`,
410
+ submit_endpoint: `${API_ROOT}/jobs/${encodeURIComponent(id)}`,
411
+ submit_method: "POST",
412
+ questions,
413
+ },
414
+ };
415
+ }
359
416
  return {
360
417
  searchPositions,
361
418
  fetchAllPositions,
@@ -366,5 +423,6 @@ export function createAdapter(cfg) {
366
423
  findNoticesByQuestion,
367
424
  matchResume,
368
425
  checkResume,
426
+ fetchApplicationSchema,
369
427
  };
370
428
  }
package/dist/hoyoverse.js CHANGED
@@ -23,3 +23,4 @@ export const getNotice = adapter.getNotice;
23
23
  export const findNoticesByQuestion = adapter.findNoticesByQuestion;
24
24
  export const matchResume = adapter.matchResume;
25
25
  export const checkResume = adapter.checkResume;
26
+ export const fetchApplicationSchema = adapter.fetchApplicationSchema;
package/dist/index.js CHANGED
@@ -50,8 +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
54
  import { memoryList, memoryGet, memorySet, memoryEvent, memoryClear, } from "./memory.js";
54
- const VERSION = "0.8.2";
55
+ import { writeFileSync, mkdirSync, existsSync } from "node:fs";
56
+ import { dirname } from "node:path";
57
+ const VERSION = "0.9.1";
55
58
  const COMPANIES = [
56
59
  { key: "tencent", family: "Bespoke", source: "join.qq.com", label: "Tencent / 腾讯" },
57
60
  { key: "bytedance", family: "Bespoke", source: "jobs.bytedance.com", label: "ByteDance / 字节跳动" },
@@ -111,6 +114,8 @@ job-pro — query Chinese big-tech campus recruiting from your terminal
111
114
  USAGE
112
115
  job-pro <company> <verb> [options]
113
116
  job-pro list [--compact] list all 50 companies + source family
117
+ job-pro profile init [--force] write ~/.jobpro/profile.json template
118
+ job-pro profile show print the loaded profile
114
119
  job-pro --version
115
120
  job-pro help
116
121
 
@@ -118,6 +123,12 @@ USAGE
118
123
  by ATS family (Bespoke / Feishu / Beisen Wecruit / Beisen iTalent / Moka
119
124
  / Greenhouse-Lever / Liepin). Coverage summary at job.ha7ch.com.
120
125
 
126
+ PHASE 2 (auto-apply) is in early access. \`job-pro <co> apply <postId>\`
127
+ prints the staged POST in dry-run mode. Today only Greenhouse +
128
+ Lever boards (xpeng / hoyoverse / weride) expose the application
129
+ schema; the rest return a "not yet wired" note. See docs/auto-apply.md
130
+ for the rollout plan.
131
+
121
132
  VERBS (same surface for every company)
122
133
  search <kw> search openings (free text)
123
134
  detail <post_id> show full JD for one job
@@ -129,6 +140,9 @@ VERBS (same surface for every company)
129
140
  match <resume-text-or--> rank jobs by overlap with resume text
130
141
  pass "-" to read resume from stdin
131
142
  resume-check <resume-text-or--> structural sanity check on a resume
143
+ apply <post_id> stage an application (Phase 2 dry-run)
144
+ --really-submit is intentionally disabled
145
+ until per-ATS flows are validated.
132
146
  memory list | get <k> | set k=v | event <kind> [payload] | clear
133
147
 
134
148
  OUTPUT
@@ -375,6 +389,73 @@ async function runCompany(adapter, company, rawArgs) {
375
389
  const text = readResumeArg(args[0]);
376
390
  return emit(adapter.checkResume(text), compact);
377
391
  }
392
+ if (verb === "apply") {
393
+ const postId = args[0];
394
+ if (!postId)
395
+ die(`usage: job-pro ${company} apply <post_id> [--dry-run | --debug-submit-to <url> | --really-submit]`);
396
+ const reallySubmit = args.includes("--really-submit");
397
+ const { args: aDebug, value: debugUrl } = popFlagValue(args, "--debug-submit-to");
398
+ const fetchSchema = adapter.fetchApplicationSchema;
399
+ if (typeof fetchSchema !== "function") {
400
+ return emit({
401
+ ok: false,
402
+ source: company,
403
+ post_id: postId,
404
+ message: `apply: Phase 2 not yet wired for "${company}". Only Greenhouse + Lever ` +
405
+ `boards (xpeng / hoyoverse / weride) expose an application schema today. ` +
406
+ `See docs/auto-apply.md for the rollout plan.`,
407
+ }, compact);
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
+ }
420
+ const schemaResult = await fetchSchema.call(adapter, postId);
421
+ const sr = schemaResult;
422
+ if (!sr.ok || !sr.schema) {
423
+ return emit({ ok: false, source: company, post_id: postId, message: sr.message ?? "unknown error" }, compact);
424
+ }
425
+ const prof = loadProfile();
426
+ if (!prof.ok) {
427
+ return emit({
428
+ ok: false,
429
+ source: company,
430
+ post_id: postId,
431
+ schema: sr.schema,
432
+ message: prof.message,
433
+ hint: `run \`job-pro profile init\` to create a template.`,
434
+ }, compact);
435
+ }
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.
443
+ if (compact) {
444
+ return emit({ mode: "dry-run", staged }, compact);
445
+ }
446
+ console.log(formatStaged(staged));
447
+ if (!staged.ready) {
448
+ console.log(`\nFill the unanswered required fields in ${profileTemplate().path} ` +
449
+ `(profile.custom.<name> for unknown fields), then re-run.`);
450
+ }
451
+ 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.`);
455
+ }
456
+ void aDebug; // silence "unused" — `args` flow goes through popFlagValue
457
+ return;
458
+ }
378
459
  if (verb === "memory") {
379
460
  const [sub, ...subArgs] = args;
380
461
  if (!sub)
@@ -463,6 +544,30 @@ async function main() {
463
544
  printCompanyList(compact);
464
545
  return;
465
546
  }
547
+ if (cmd === "profile") {
548
+ const sub = args[1];
549
+ if (sub === "init") {
550
+ const { path, template } = profileTemplate();
551
+ if (existsSync(path) && !args.includes("--force")) {
552
+ console.error(`profile already exists at ${path}; pass --force to overwrite.`);
553
+ process.exit(1);
554
+ }
555
+ mkdirSync(dirname(path), { recursive: true });
556
+ writeFileSync(path, JSON.stringify(template, null, 2) + "\n", "utf8");
557
+ console.log(`Wrote ${path}. Fill in first_name / last_name / email / phone / resume_path before running \`job-pro <co> apply\`.`);
558
+ return;
559
+ }
560
+ if (sub === "show") {
561
+ const r = loadProfile();
562
+ if (!r.ok) {
563
+ console.error(r.message);
564
+ process.exit(1);
565
+ }
566
+ console.log(JSON.stringify(r.profile, null, 2));
567
+ return;
568
+ }
569
+ die(`usage: job-pro profile <init [--force] | show>`);
570
+ }
466
571
  const adapter = ADAPTERS[cmd];
467
572
  if (adapter) {
468
573
  await runCompany(adapter, cmd, args.slice(1));
package/dist/lever.js CHANGED
@@ -361,6 +361,80 @@ export function createAdapter(cfg) {
361
361
  "The only authority on selection is HR.",
362
362
  };
363
363
  }
364
+ async function fetchApplicationSchema(postId) {
365
+ const id = (postId ?? "").trim();
366
+ if (!id)
367
+ return { ok: false, source: SOURCE, message: "post_id is required" };
368
+ let response;
369
+ try {
370
+ response = await fetch(API_DETAIL(id), { headers: HEADERS });
371
+ }
372
+ catch (err) {
373
+ return {
374
+ ok: false,
375
+ source: SOURCE,
376
+ message: `network error: ${err instanceof Error ? err.message : String(err)}`,
377
+ };
378
+ }
379
+ if (!response.ok) {
380
+ return {
381
+ ok: false,
382
+ source: SOURCE,
383
+ message: `HTTP ${response.status}: ${response.statusText}`,
384
+ };
385
+ }
386
+ let job;
387
+ try {
388
+ job = (await response.json());
389
+ }
390
+ catch (err) {
391
+ return {
392
+ ok: false,
393
+ source: SOURCE,
394
+ message: `bad JSON: ${err instanceof Error ? err.message : String(err)}`,
395
+ };
396
+ }
397
+ // Lever's standard contact-info block.
398
+ const standard = [
399
+ { label: "First Name", required: true, fields: [{ name: "first_name", type: "input_text" }] },
400
+ { label: "Last Name", required: true, fields: [{ name: "last_name", type: "input_text" }] },
401
+ { label: "Email", required: true, fields: [{ name: "email", type: "input_text" }] },
402
+ { label: "Phone", required: true, fields: [{ name: "phone", type: "input_text" }] },
403
+ { label: "Resume", required: true, fields: [{ name: "resume", type: "input_file" }] },
404
+ ];
405
+ // Custom-question fields keyed by their human label so the staging
406
+ // step can match them via profile.custom["…"].
407
+ const custom = (job.customQuestions ?? []).flatMap((cq) => (cq.fields ?? []).map((f) => ({
408
+ label: f.text ?? cq.text ?? "",
409
+ description: cq.description ?? null,
410
+ required: f.required ?? false,
411
+ fields: [
412
+ {
413
+ name: (f.text ?? cq.text ?? "").slice(0, 60).replace(/\s+/g, "_").toLowerCase(),
414
+ type: f.type === "multiple-choice"
415
+ ? "single_select"
416
+ : f.type === "multi-choice"
417
+ ? "multi_select"
418
+ : f.type === "textarea"
419
+ ? "textarea"
420
+ : "input_text",
421
+ values: (f.options ?? []).map((o) => ({ value: o.text ?? "", label: o.text ?? "" })),
422
+ },
423
+ ],
424
+ })));
425
+ return {
426
+ ok: true,
427
+ schema: {
428
+ source: SOURCE,
429
+ post_id: id,
430
+ job_title: job.text ?? "",
431
+ apply_url: job.applyUrl ?? job.hostedUrl ?? `${BOARD_URL}/${id}/apply`,
432
+ submit_endpoint: `${BOARD_URL}/${id}/apply`,
433
+ submit_method: "POST",
434
+ questions: [...standard, ...custom],
435
+ },
436
+ };
437
+ }
364
438
  return {
365
439
  searchPositions,
366
440
  fetchAllPositions,
@@ -371,5 +445,6 @@ export function createAdapter(cfg) {
371
445
  findNoticesByQuestion,
372
446
  matchResume,
373
447
  checkResume,
448
+ fetchApplicationSchema,
374
449
  };
375
450
  }
package/dist/weride.js CHANGED
@@ -26,3 +26,4 @@ export const getNotice = adapter.getNotice;
26
26
  export const findNoticesByQuestion = adapter.findNoticesByQuestion;
27
27
  export const matchResume = adapter.matchResume;
28
28
  export const checkResume = adapter.checkResume;
29
+ export const fetchApplicationSchema = adapter.fetchApplicationSchema;
package/dist/xpeng.js CHANGED
@@ -31,3 +31,4 @@ export const getNotice = adapter.getNotice;
31
31
  export const findNoticesByQuestion = adapter.findNoticesByQuestion;
32
32
  export const matchResume = adapter.matchResume;
33
33
  export const checkResume = adapter.checkResume;
34
+ export const fetchApplicationSchema = adapter.fetchApplicationSchema;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "job-pro",
3
- "version": "0.8.2",
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",