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 +1 -1
- package/dist/runner.js +107 -25
- package/package.json +1 -1
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.
|
|
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]")
|
|
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
|
|
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}"]`)
|
|
314
|
-
if (!label) label = el.closest("label")
|
|
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
|
|
322
|
-
label = lab
|
|
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
|
|
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
|
|
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
|
|
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