lazy-apply 0.1.0 → 0.1.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/cli.js CHANGED
@@ -6,7 +6,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node
6
6
  import { homedir, hostname, platform, tmpdir, userInfo } from "node:os";
7
7
  import { dirname, join } from "node:path";
8
8
  import { createInterface } from "node:readline/promises";
9
- var VERSION = true ? "0.1.0" : "dev";
9
+ var VERSION = true ? "0.1.2" : "dev";
10
10
  var DEFAULT_APP = "https://lazyapply.online";
11
11
  var HOME = join(homedir(), ".lazy-apply");
12
12
  var CONFIG = join(HOME, "config.json");
package/dist/runner.js CHANGED
@@ -254,6 +254,25 @@ function resumeFileName(...nameParts) {
254
254
  // src/lib/apply/discover.ts
255
255
  function discoverFields() {
256
256
  const clean = (s) => (s || "").replace(/\s+/g, " ").replace(/[✱*]/g, "").trim();
257
+ const NOISE = "input, select, textarea, button, svg, script, style, [class*=dropdown], [role=listbox], [role=option], [aria-hidden=true]";
258
+ const textOf = (el) => {
259
+ if (!el) return "";
260
+ let out2 = "";
261
+ const walk = (n) => {
262
+ for (const c of Array.from(n.childNodes)) {
263
+ if (c.nodeType === 3) out2 += c.textContent;
264
+ else if (c.nodeType === 1) {
265
+ const e = c;
266
+ if (e.matches(NOISE)) continue;
267
+ const cs = getComputedStyle(e);
268
+ if (cs.display === "none" || cs.visibility === "hidden") continue;
269
+ walk(e);
270
+ }
271
+ }
272
+ };
273
+ walk(el);
274
+ return out2;
275
+ };
257
276
  const optionLabel = (el) => clean(el.id && document.querySelector(`label[for="${el.id}"]`)?.textContent || el.closest("label")?.textContent);
258
277
  const out = [];
259
278
  const seen = /* @__PURE__ */ new Set();
@@ -269,7 +288,7 @@ function discoverFields() {
269
288
  let gi = 0;
270
289
  for (const [box, buttons] of buttonGroups) {
271
290
  if (buttons.length < 2 || buttons.length > 8) continue;
272
- const label = clean(box.querySelector("label, legend, [class*=question-title], [class*=title], [class*=label]")?.textContent);
291
+ const label = clean(textOf(box.querySelector("label, legend, [class*=question-title], [class*=title], [class*=label]")));
273
292
  if (!label) continue;
274
293
  const key = `aa-btn-${gi++}`;
275
294
  box.setAttribute("data-aa-group", key);
@@ -289,7 +308,7 @@ function discoverFields() {
289
308
  const controls = Array.from(node.querySelectorAll("input, textarea, select")).filter((i) => !["hidden", "submit", "button"].includes(i.type));
290
309
  if (!controls.every((i) => (i.type === "radio" || i.type === "checkbox") && i.name === input.name)) break;
291
310
  const cand = node.querySelector("legend, h3, h4, h5, h6, [class*=question], [class*=Label], [class*=label]:not(label), .application-label");
292
- const t = clean(cand?.textContent);
311
+ const t = clean(textOf(cand));
293
312
  if (t && t !== opt) question = t;
294
313
  node = node.parentElement;
295
314
  }
@@ -310,16 +329,16 @@ function discoverFields() {
310
329
  if (seen.has(selector)) return;
311
330
  seen.add(selector);
312
331
  let label = "";
313
- if (el.id) label = document.querySelector(`label[for="${el.id}"]`)?.textContent || "";
314
- if (!label) label = el.closest("label")?.textContent || "";
332
+ if (el.id) label = textOf(document.querySelector(`label[for="${el.id}"]`));
333
+ if (!label.trim()) label = textOf(el.closest("label"));
315
334
  if (!label) {
316
335
  let node = el.parentElement;
317
336
  while (node && node !== document.body) {
318
337
  const controls = node.querySelectorAll("input:not([type=hidden]):not([type=submit]):not([type=button]), textarea, select").length;
319
338
  if (controls > 1) break;
320
339
  const lab = node.querySelector(".application-label, label, legend, [class*=question-title], [class*=label]:not(input)");
321
- if (lab?.textContent?.trim()) {
322
- label = lab.textContent;
340
+ if (textOf(lab).trim()) {
341
+ label = textOf(lab);
323
342
  break;
324
343
  }
325
344
  node = node.parentElement;
@@ -349,11 +368,30 @@ async function discoverLiveFields(page) {
349
368
  }
350
369
  function findEmptyRequired() {
351
370
  const clean = (s) => (s || "").replace(/\s+/g, " ").replace(/[✱*]/g, "").trim();
371
+ const NOISE = "input, select, textarea, button, svg, script, style, [class*=dropdown], [role=listbox], [role=option], [aria-hidden=true]";
372
+ const textOf = (el) => {
373
+ if (!el) return "";
374
+ let out2 = "";
375
+ const walk = (n) => {
376
+ for (const c of Array.from(n.childNodes)) {
377
+ if (c.nodeType === 3) out2 += c.textContent;
378
+ else if (c.nodeType === 1) {
379
+ const e = c;
380
+ if (e.matches(NOISE)) continue;
381
+ const cs = getComputedStyle(e);
382
+ if (cs.display === "none" || cs.visibility === "hidden") continue;
383
+ walk(e);
384
+ }
385
+ }
386
+ };
387
+ walk(el);
388
+ return out2;
389
+ };
352
390
  const seen = /* @__PURE__ */ new Set();
353
391
  const out = [];
354
392
  const labels = Array.from(document.querySelectorAll("label, legend, [class*=question-title]"));
355
393
  for (const lab of labels) {
356
- const raw = lab.textContent || "";
394
+ const raw = textOf(lab);
357
395
  const ctl = lab.control;
358
396
  const box = lab.closest("[data-field-entry-id], fieldset, [class*=field-entry], [class*=question], [class*=field], li") || lab.parentElement;
359
397
  if (!box) continue;
@@ -380,6 +418,48 @@ async function findEmptyRequiredLive(page) {
380
418
  return page.evaluate(findEmptyRequired);
381
419
  }
382
420
 
421
+ // src/lib/apply/autocomplete.ts
422
+ async function pickSuggestion(page, typed, patience = 1300) {
423
+ const items = page.locator('[role="option"], [role="listbox"] li, [class*="dropdown-location"], [class*="dropdown-results"] > *, .pac-item, [class*="suggestion"] li, [class*="autocomplete"] li, [class*="autocomplete"] [class*="item"]').filter({ visible: true });
424
+ const until = Date.now() + patience;
425
+ await page.waitForTimeout(Math.min(700, patience));
426
+ while (!await items.count() && Date.now() < until) await page.waitForTimeout(250);
427
+ const n = await items.count();
428
+ if (!n) return void 0;
429
+ const texts = (await items.allInnerTexts()).map((t) => t.replace(/\s+/g, " ").trim());
430
+ const want = typed.toLowerCase();
431
+ let i = texts.findIndex((t) => t.toLowerCase().startsWith(want));
432
+ if (i < 0) i = texts.findIndex((t) => t.toLowerCase().includes(want));
433
+ if (i < 0) i = 0;
434
+ await items.nth(i).click();
435
+ await page.waitForTimeout(500);
436
+ return texts[i];
437
+ }
438
+ async function leverLocationPicked(page) {
439
+ const hidden = page.locator("#selected-location");
440
+ if (!await hidden.count()) return true;
441
+ return !!(await hidden.inputValue().catch(() => "")).trim();
442
+ }
443
+ async function ensureLeverLocation(page, city) {
444
+ const input = page.locator("#location-input");
445
+ if (!await input.count() || !city) return void 0;
446
+ for (const how of ["click", "keyboard"]) {
447
+ await input.scrollIntoViewIfNeeded();
448
+ await input.fill("");
449
+ await input.pressSequentially(city, { delay: 45 + Math.random() * 40 });
450
+ if (how === "click") await pickSuggestion(page, city, 8e3);
451
+ else {
452
+ await page.locator(".dropdown-location").first().waitFor({ state: "visible", timeout: 8e3 }).catch(() => {
453
+ });
454
+ await input.press("ArrowDown");
455
+ await input.press("Enter");
456
+ await page.waitForTimeout(500);
457
+ }
458
+ if (await leverLocationPicked(page)) return input.inputValue();
459
+ }
460
+ return void 0;
461
+ }
462
+
383
463
  // scripts/runner.ts
384
464
  import { existsSync, readFileSync as readSync, unlinkSync } from "node:fs";
385
465
  var SETTINGS;
@@ -407,6 +487,14 @@ var hostBackoffUntil = /* @__PURE__ */ new Map();
407
487
  var SUBMIT_GAP_MS = 9e4;
408
488
  var SPAM_BACKOFF_MS = 6 * 6e4;
409
489
  var jitter = (min, max) => min + Math.random() * (max - min);
490
+ async function typeAnswer(el, value) {
491
+ if (value.length <= 120) {
492
+ await el.pressSequentially(value, { delay: jitter(35, 80), timeout: 0 });
493
+ return;
494
+ }
495
+ await el.fill(value.slice(0, -1));
496
+ await el.pressSequentially(value.slice(-1), { delay: 40 });
497
+ }
410
498
  var LOCK = join(tmpdir(), `auto-apply-runner-${PROFILE_DIR}-${APP_URL.replace(/[^a-z0-9]+/gi, "_")}.lock`);
411
499
  if (existsSync(LOCK)) {
412
500
  const pid = Number(readSync(LOCK, "utf8"));
@@ -493,7 +581,7 @@ async function fillGreenhouse(page, app, notes) {
493
581
  await el.scrollIntoViewIfNeeded();
494
582
  await el.click();
495
583
  await el.fill("");
496
- await el.pressSequentially(value, { delay: jitter(35, 80) });
584
+ await typeAnswer(el, value);
497
585
  return true;
498
586
  };
499
587
  const norm = (t) => t.replace(/[\u2018\u2019]/g, "'").replace(/[\u201c\u201d]/g, '"').replace(/\s+/g, " ").trim().toLowerCase();
@@ -886,20 +974,6 @@ async function attachResumeGeneric(page, resumePath, notes) {
886
974
  }
887
975
  notes.push(`Resume upload could not be confirmed on the page (looked for "${fileName}")`);
888
976
  }
889
- async function pickSuggestion(page, typed) {
890
- await page.waitForTimeout(1300);
891
- const items = page.locator('[role="option"], [role="listbox"] li, [class*="dropdown-location"], [class*="dropdown-results"] > *, .pac-item, [class*="suggestion"] li, [class*="autocomplete"] li, [class*="autocomplete"] [class*="item"]').filter({ visible: true });
892
- const n = await items.count();
893
- if (!n) return void 0;
894
- const texts = (await items.allInnerTexts()).map((t) => t.replace(/\s+/g, " ").trim());
895
- const want = typed.toLowerCase();
896
- let i = texts.findIndex((t) => t.toLowerCase().startsWith(want));
897
- if (i < 0) i = texts.findIndex((t) => t.toLowerCase().includes(want));
898
- if (i < 0) i = 0;
899
- await items.nth(i).click();
900
- await page.waitForTimeout(500);
901
- return texts[i];
902
- }
903
977
  async function discoverAndFillGeneric(page, app, notes) {
904
978
  await page.waitForLoadState("domcontentloaded");
905
979
  await page.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
@@ -960,10 +1034,14 @@ async function discoverAndFillGeneric(page, app, notes) {
960
1034
  await el.fill("");
961
1035
  const isPlace = /location|city|where (are|do) you/i.test(f.label);
962
1036
  const typed = isPlace ? value.split(",")[0].trim() : value;
963
- await el.pressSequentially(typed, { delay: jitter(35, 80) });
964
- const picked = await pickSuggestion(page, typed);
1037
+ await typeAnswer(el, typed);
1038
+ const picked = await pickSuggestion(page, typed, isPlace ? 8e3 : 1300);
965
1039
  if (picked) notes.push(`${f.label}: chose "${picked}" from the suggestions`);
966
1040
  else if (isPlace && await el.inputValue() !== typed) notes.push(`${f.label}: the field did not keep "${typed}"; check it in the window`);
1041
+ if (isPlace && !await leverLocationPicked(page)) {
1042
+ const fixed = await ensureLeverLocation(page, typed);
1043
+ notes.push(fixed ? `${f.label}: picked "${fixed}" on a second try` : `${f.label}: Lever did not accept a location; pick one in the window`);
1044
+ }
967
1045
  }
968
1046
  } catch (e) {
969
1047
  notes.push(`Could not fill "${f.label}": ${e.message.slice(0, 80)}`);
@@ -971,7 +1049,7 @@ async function discoverAndFillGeneric(page, app, notes) {
971
1049
  }
972
1050
  await fillAshbyEducation(page, notes);
973
1051
  const eduLabel = /search schools|^degree$|field of study|still (a )?student/i;
974
- const stillOpen = unanswered.filter((u) => !eduLabel.test(u.label));
1052
+ const stillOpen = unanswered.filter((u) => !eduLabel.test(u.label) && u.required);
975
1053
  if (stillOpen.length) {
976
1054
  notes.push(`${stillOpen.length} field(s) need your answer: ${stillOpen.map((u) => u.label).join("; ")}`);
977
1055
  await report(app.id, { questions: stillOpen });
@@ -1028,6 +1106,10 @@ async function submit(app) {
1028
1106
  if (!entry) return;
1029
1107
  }
1030
1108
  try {
1109
+ if (!await leverLocationPicked(entry.page) && SETTINGS.location) {
1110
+ const fixed = await ensureLeverLocation(entry.page, SETTINGS.location.split(",")[0].trim());
1111
+ log(fixed ? `repaired location for ${app.id}: "${fixed}"` : `could not repair location for ${app.id}`);
1112
+ }
1031
1113
  const missing = await findEmptyRequiredLive(entry.page).catch(() => []);
1032
1114
  if (missing.length) {
1033
1115
  const known = new Set(app.questions.map((q) => q.label.toLowerCase()));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazy-apply",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "The Lazy Apply runner: fills your approved job applications in Chrome on your own computer. Run `npx lazy-apply`.",
5
5
  "type": "module",
6
6
  "bin": { "lazy-apply": "dist/cli.js" },