lazy-apply 0.1.1 → 0.1.3

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.1" : "dev";
9
+ var VERSION = true ? "0.1.3" : "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
@@ -144,7 +144,33 @@ var pick = (opts, ...prefer) => {
144
144
  };
145
145
  var or = (v, fallback) => v && v.trim() || fallback;
146
146
  var links = (s) => [s.website, s.linkedin, s.github].filter(Boolean).join(" | ");
147
+ var yesNo = (o, yes) => o?.length ? pick(o, yes ? /^yes\b/i : /^no\b/i) : yes ? "Yes" : "No";
148
+ var countriesIn = (text) => {
149
+ const out = /* @__PURE__ */ new Set();
150
+ for (const part of text.split(/,|\bor\b|\band\b|\//i)) {
151
+ const c = countryOf(part) ?? (/\bpoland\b/i.test(part) ? "Poland" : void 0);
152
+ if (c) out.add(c);
153
+ }
154
+ return out;
155
+ };
147
156
  var RULES = [
157
+ // Screening questions that sound like other rules but are not: answered before the broad rules below see them.
158
+ { test: /employment agreements?|post[- ]employment restrictions?|non[- ]?compete|non[- ]?solicit|restrictive covenants?/i, answer: (_s, o) => yesNo(o, false) },
159
+ { test: /(previously|ever|before) (worked|been employed|consulted|interned)( at| for| with)|former (employee|contractor) (of|at)/i, answer: (_s, o) => yesNo(o, false) },
160
+ // "...sponsorship to remain in your current location": about where the person lives, not where the job is.
161
+ { test: /sponsor[^?]*(current(ly)? (location|country)|where you (currently )?(live|reside|are based))/i, answer: (s, o) => {
162
+ const home = countryOf(s.location);
163
+ if (!home) return void 0;
164
+ const ok = s.workAuthorizedCountries.split(",").some((c) => c.trim().toLowerCase() === home.toLowerCase());
165
+ return yesNo(o, !ok);
166
+ } },
167
+ // "Are you currently located in either Canada, UK or Poland?"
168
+ { test: /(currently |presently )?(locat(ed|ion)|living|based|residing|reside) in (either )?[A-Z][^?]*\b(or|and)\b/i, answer: (s, o, _c, label) => {
169
+ const home = countryOf(s.location);
170
+ if (!home || !label) return void 0;
171
+ const listed = countriesIn(label.replace(/^.*?\b(?:in|either)\b/i, ""));
172
+ return listed.size ? yesNo(o, listed.has(home)) : void 0;
173
+ } },
148
174
  { test: /^first\s*name/i, answer: (s) => or(s.firstName) },
149
175
  { test: /^last\s*name|surname|family name/i, answer: (s) => or(s.lastName) },
150
176
  { test: /^(full |legal )?name$/i, answer: (s) => or(`${s.firstName} ${s.lastName}`.trim()) },
@@ -254,6 +280,25 @@ function resumeFileName(...nameParts) {
254
280
  // src/lib/apply/discover.ts
255
281
  function discoverFields() {
256
282
  const clean = (s) => (s || "").replace(/\s+/g, " ").replace(/[✱*]/g, "").trim();
283
+ const NOISE = "input, select, textarea, button, svg, script, style, [class*=dropdown], [role=listbox], [role=option], [aria-hidden=true]";
284
+ const textOf = (el) => {
285
+ if (!el) return "";
286
+ let out2 = "";
287
+ const walk = (n) => {
288
+ for (const c of Array.from(n.childNodes)) {
289
+ if (c.nodeType === 3) out2 += c.textContent;
290
+ else if (c.nodeType === 1) {
291
+ const e = c;
292
+ if (e.matches(NOISE)) continue;
293
+ const cs = getComputedStyle(e);
294
+ if (cs.display === "none" || cs.visibility === "hidden") continue;
295
+ walk(e);
296
+ }
297
+ }
298
+ };
299
+ walk(el);
300
+ return out2;
301
+ };
257
302
  const optionLabel = (el) => clean(el.id && document.querySelector(`label[for="${el.id}"]`)?.textContent || el.closest("label")?.textContent);
258
303
  const out = [];
259
304
  const seen = /* @__PURE__ */ new Set();
@@ -269,7 +314,7 @@ function discoverFields() {
269
314
  let gi = 0;
270
315
  for (const [box, buttons] of buttonGroups) {
271
316
  if (buttons.length < 2 || buttons.length > 8) continue;
272
- const label = clean(box.querySelector("label, legend, [class*=question-title], [class*=title], [class*=label]")?.textContent);
317
+ const label = clean(textOf(box.querySelector("label, legend, [class*=question-title], [class*=title], [class*=label]")));
273
318
  if (!label) continue;
274
319
  const key = `aa-btn-${gi++}`;
275
320
  box.setAttribute("data-aa-group", key);
@@ -289,7 +334,7 @@ function discoverFields() {
289
334
  const controls = Array.from(node.querySelectorAll("input, textarea, select")).filter((i) => !["hidden", "submit", "button"].includes(i.type));
290
335
  if (!controls.every((i) => (i.type === "radio" || i.type === "checkbox") && i.name === input.name)) break;
291
336
  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);
337
+ const t = clean(textOf(cand));
293
338
  if (t && t !== opt) question = t;
294
339
  node = node.parentElement;
295
340
  }
@@ -310,16 +355,16 @@ function discoverFields() {
310
355
  if (seen.has(selector)) return;
311
356
  seen.add(selector);
312
357
  let label = "";
313
- if (el.id) label = document.querySelector(`label[for="${el.id}"]`)?.textContent || "";
314
- if (!label) label = el.closest("label")?.textContent || "";
358
+ if (el.id) label = textOf(document.querySelector(`label[for="${el.id}"]`));
359
+ if (!label.trim()) label = textOf(el.closest("label"));
315
360
  if (!label) {
316
361
  let node = el.parentElement;
317
362
  while (node && node !== document.body) {
318
363
  const controls = node.querySelectorAll("input:not([type=hidden]):not([type=submit]):not([type=button]), textarea, select").length;
319
364
  if (controls > 1) break;
320
365
  const lab = node.querySelector(".application-label, label, legend, [class*=question-title], [class*=label]:not(input)");
321
- if (lab?.textContent?.trim()) {
322
- label = lab.textContent;
366
+ if (textOf(lab).trim()) {
367
+ label = textOf(lab);
323
368
  break;
324
369
  }
325
370
  node = node.parentElement;
@@ -349,24 +394,45 @@ async function discoverLiveFields(page) {
349
394
  }
350
395
  function findEmptyRequired() {
351
396
  const clean = (s) => (s || "").replace(/\s+/g, " ").replace(/[✱*]/g, "").trim();
397
+ const NOISE = "input, select, textarea, button, svg, script, style, [class*=dropdown], [role=listbox], [role=option], [aria-hidden=true]";
398
+ const textOf = (el) => {
399
+ if (!el) return "";
400
+ let out2 = "";
401
+ const walk = (n) => {
402
+ for (const c of Array.from(n.childNodes)) {
403
+ if (c.nodeType === 3) out2 += c.textContent;
404
+ else if (c.nodeType === 1) {
405
+ const e = c;
406
+ if (e.matches(NOISE)) continue;
407
+ const cs = getComputedStyle(e);
408
+ if (cs.display === "none" || cs.visibility === "hidden") continue;
409
+ walk(e);
410
+ }
411
+ }
412
+ };
413
+ walk(el);
414
+ return out2;
415
+ };
352
416
  const seen = /* @__PURE__ */ new Set();
353
417
  const out = [];
354
418
  const labels = Array.from(document.querySelectorAll("label, legend, [class*=question-title]"));
355
419
  for (const lab of labels) {
356
- const raw = lab.textContent || "";
420
+ const raw = textOf(lab);
357
421
  const ctl = lab.control;
358
422
  const box = lab.closest("[data-field-entry-id], fieldset, [class*=field-entry], [class*=question], [class*=field], li") || lab.parentElement;
359
423
  if (!box) continue;
360
424
  const required = /\*|✱/.test(raw) || /required/i.test(lab.className) || !!(ctl && (ctl.hasAttribute("required") || ctl.getAttribute("aria-required") === "true")) || !!box.querySelector("[required], [aria-required=true]");
361
425
  if (!required) continue;
362
- const scope = ctl && !/radio|checkbox/.test(ctl.type || "") ? ctl.parentElement || box : box;
426
+ const combo = !!ctl && (ctl.getAttribute("role") === "combobox" || !!ctl.closest("[class*=select__], [class*=-container]"));
427
+ const scope = ctl && !combo && !/radio|checkbox/.test(ctl.type || "") ? ctl.parentElement || box : box;
363
428
  const texts = Array.from(scope.querySelectorAll("input:not([type=hidden]):not([type=radio]):not([type=checkbox]):not([type=file]):not([type=submit]), textarea, select"));
364
429
  const files = Array.from(scope.querySelectorAll("input[type=file]"));
365
430
  const checks = Array.from(scope.querySelectorAll("input[type=radio], input[type=checkbox]"));
366
431
  const buttons = Array.from(scope.querySelectorAll("button, [role=radio], [role=checkbox]")).filter((b) => b.type !== "submit");
367
432
  if (!texts.length && !files.length && !checks.length && !buttons.length) continue;
368
433
  const scopeText = scope.textContent || "";
369
- const filled = texts.some((t) => t.value?.trim()) || files.some((f) => (f.files?.length || 0) > 0) || files.length > 0 && /\.(pdf|docx?|rtf|txt)\b/i.test(scopeText) || checks.some((c) => c.checked) || buttons.some((b) => b.getAttribute("aria-pressed") === "true" || b.getAttribute("aria-checked") === "true" || /selected|active|checked/i.test(b.className) && !/unselected/i.test(b.className));
434
+ const chosen = !!Array.from(scope.querySelectorAll("[class*=single-value], [class*=multi-value], [class*=singleValue], [class*=multiValue]")).some((e) => (e.textContent || "").trim()) || !!scope.querySelector("[class*=value-container--has-value]") || Array.from(scope.querySelectorAll("input[type=hidden]")).some((h) => h.value.trim() && /question|answer|select|value/i.test(h.name || h.id || ""));
435
+ const filled = chosen || texts.some((t) => t.value?.trim()) || files.some((f) => (f.files?.length || 0) > 0) || files.length > 0 && /\.(pdf|docx?|rtf|txt)\b/i.test(scopeText) || checks.some((c) => c.checked) || buttons.some((b) => b.getAttribute("aria-pressed") === "true" || b.getAttribute("aria-checked") === "true" || /selected|active|checked/i.test(b.className) && !/unselected/i.test(b.className));
370
436
  const label = clean(raw).replace(/(ATTACH RESUME|No location found|Couldn't auto-read|Analyzing resume|Loading).*$/i, "").trim();
371
437
  if (!filled && label && !seen.has(label)) {
372
438
  seen.add(label);
@@ -449,6 +515,14 @@ var hostBackoffUntil = /* @__PURE__ */ new Map();
449
515
  var SUBMIT_GAP_MS = 9e4;
450
516
  var SPAM_BACKOFF_MS = 6 * 6e4;
451
517
  var jitter = (min, max) => min + Math.random() * (max - min);
518
+ async function typeAnswer(el, value) {
519
+ if (value.length <= 120) {
520
+ await el.pressSequentially(value, { delay: jitter(35, 80), timeout: 0 });
521
+ return;
522
+ }
523
+ await el.fill(value.slice(0, -1));
524
+ await el.pressSequentially(value.slice(-1), { delay: 40 });
525
+ }
452
526
  var LOCK = join(tmpdir(), `auto-apply-runner-${PROFILE_DIR}-${APP_URL.replace(/[^a-z0-9]+/gi, "_")}.lock`);
453
527
  if (existsSync(LOCK)) {
454
528
  const pid = Number(readSync(LOCK, "utf8"));
@@ -535,7 +609,7 @@ async function fillGreenhouse(page, app, notes) {
535
609
  await el.scrollIntoViewIfNeeded();
536
610
  await el.click();
537
611
  await el.fill("");
538
- await el.pressSequentially(value, { delay: jitter(35, 80) });
612
+ await typeAnswer(el, value);
539
613
  return true;
540
614
  };
541
615
  const norm = (t) => t.replace(/[\u2018\u2019]/g, "'").replace(/[\u201c\u201d]/g, '"').replace(/\s+/g, " ").trim().toLowerCase();
@@ -988,7 +1062,7 @@ async function discoverAndFillGeneric(page, app, notes) {
988
1062
  await el.fill("");
989
1063
  const isPlace = /location|city|where (are|do) you/i.test(f.label);
990
1064
  const typed = isPlace ? value.split(",")[0].trim() : value;
991
- await el.pressSequentially(typed, { delay: jitter(35, 80) });
1065
+ await typeAnswer(el, typed);
992
1066
  const picked = await pickSuggestion(page, typed, isPlace ? 8e3 : 1300);
993
1067
  if (picked) notes.push(`${f.label}: chose "${picked}" from the suggestions`);
994
1068
  else if (isPlace && await el.inputValue() !== typed) notes.push(`${f.label}: the field did not keep "${typed}"; check it in the window`);
@@ -1003,7 +1077,7 @@ async function discoverAndFillGeneric(page, app, notes) {
1003
1077
  }
1004
1078
  await fillAshbyEducation(page, notes);
1005
1079
  const eduLabel = /search schools|^degree$|field of study|still (a )?student/i;
1006
- const stillOpen = unanswered.filter((u) => !eduLabel.test(u.label));
1080
+ const stillOpen = unanswered.filter((u) => !eduLabel.test(u.label) && u.required);
1007
1081
  if (stillOpen.length) {
1008
1082
  notes.push(`${stillOpen.length} field(s) need your answer: ${stillOpen.map((u) => u.label).join("; ")}`);
1009
1083
  await report(app.id, { questions: stillOpen });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazy-apply",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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" },