atsfill 0.0.4.dev93__tar.gz → 0.0.4.dev95__tar.gz

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.
Files changed (47) hide show
  1. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/PKG-INFO +1 -1
  2. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/pyproject.toml +1 -1
  3. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/pyproject.toml.orig +1 -1
  4. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/bamboohr.py +9 -5
  5. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/dayforce.py +53 -21
  6. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/eightfold.py +13 -15
  7. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/greenhouse.py +39 -13
  8. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/hireology.py +36 -20
  9. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/jazzhr.py +40 -9
  10. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/manatal.py +4 -3
  11. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/oracle.py +1 -1
  12. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/paylocity.py +14 -9
  13. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/phenom.py +122 -63
  14. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/pinpoint.py +67 -30
  15. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/recruitee.py +29 -5
  16. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/rippling.py +4 -4
  17. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/smartrecruiters.py +49 -19
  18. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/workday.py +50 -50
  19. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/wttj.py +3 -2
  20. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/zohorecruit.py +37 -35
  21. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/core/commands.py +10 -6
  22. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/core/conditions.py +35 -7
  23. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/core/matching.py +10 -3
  24. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/core/results.py +3 -1
  25. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/LICENSE +0 -0
  26. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/README.md +0 -0
  27. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/__init__.py +0 -0
  28. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/__init__.py +0 -0
  29. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/ashby.py +0 -0
  30. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/breezy.py +0 -0
  31. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/hiringthing.py +0 -0
  32. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/lever.py +0 -0
  33. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/paycor.py +0 -0
  34. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/personio.py +0 -0
  35. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/teamtailor.py +0 -0
  36. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/workable.py +0 -0
  37. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/cli.py +0 -0
  38. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/core/__init__.py +0 -0
  39. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/core/accounts.py +0 -0
  40. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/core/adapter.py +0 -0
  41. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/core/attempts.py +0 -0
  42. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/core/browser.py +0 -0
  43. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/core/passwords.py +0 -0
  44. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/core/paths.py +0 -0
  45. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/core/registry.py +0 -0
  46. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/py.typed +0 -0
  47. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/registry.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atsfill
3
- Version: 0.0.4.dev93
3
+ Version: 0.0.4.dev95
4
4
  Summary: Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle.
5
5
  Keywords: job-applications,ats,ashby,greenhouse,teamtailor,ai-agents,cli
6
6
  Author: shbernal
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev93"
3
+ version = "0.0.4.dev95"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev93"
3
+ version = "0.0.4.dev95"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -17,7 +17,7 @@ from urllib.parse import urlsplit
17
17
  from atsfill.core import browser
18
18
  from atsfill.core.adapter import Blocked, Gap, Unsupported
19
19
  from atsfill.core.browser import HTTP_TIMEOUT_S, Locator, Page, Timeout
20
- from atsfill.core.results import Answer, Field, FieldType, SideEffect
20
+ from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
21
21
 
22
22
  HOST = re.compile(r"^[\w-]+\.bamboohr\.com$")
23
23
  JOB = re.compile(r"^/careers/(\d+)(?:/|$)")
@@ -407,7 +407,10 @@ def _form(
407
407
  label, required = entry["label"].strip(), entry["isRequired"]
408
408
  options = [o["text"] for o in entry.get("options") or []] or None
409
409
  extra: Any = None
410
- prefilled = key == "countryId" and bool(country)
410
+ shown = None # the employer's country, the page's own before any answer
411
+ if key == "countryId" and country:
412
+ by_id = {str(o["id"]): o["text"] for o in entry.get("options") or []}
413
+ shown = by_id.get(str(country), country if country in by_id.values() else None)
411
414
  if key == "state" and options:
412
415
  options = _states(states, country)
413
416
  if key == "state" and not options:
@@ -416,7 +419,7 @@ def _form(
416
419
  extra = {o["id"]: o["text"] for o in entry["options"] if int(o["id"]) > 0}
417
420
  options = list(VETERAN) + list(extra.values())
418
421
  accept = FILE_TYPES if kind == "file" else None
419
- field = Field(key, label, TYPES[kind], required, options, None, accept, prefilled)
422
+ field = Field(key, label, TYPES[kind], required, options, None, accept, default=shown)
420
423
  out.append((field, (kind, name, extra)))
421
424
  return out
422
425
 
@@ -451,11 +454,12 @@ def _custom(question: dict[str, Any]) -> list[tuple[Field, tuple[str, str | None
451
454
  field = Field(field_id, label, TYPES[kind], required, options, None, accept)
452
455
  out = [(field, (kind, name, extra))]
453
456
  if extra and OTHER in extra and question.get("hasOther") == "yes":
454
- other = Field(
457
+ other = Field( # its placeholder the page's only label; marked optional
455
458
  f"{field_id}_other",
456
- f"{label}: please specify (asked when {label!r} is {OTHER})",
459
+ f"{label}: please specify",
457
460
  FieldType.TEXT,
458
461
  False,
462
+ shown_when=Is(field_id, [OTHER]),
459
463
  )
460
464
  out.append((other, ("text", f"{name}_other", None)))
461
465
  return out
@@ -18,7 +18,7 @@ from urllib.parse import urlsplit
18
18
 
19
19
  from atsfill.core.adapter import Gap, Unsupported, known_gap
20
20
  from atsfill.core.browser import Locator, Page, Timeout
21
- from atsfill.core.results import Answer, Field, FieldType, SideEffect
21
+ from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
22
22
 
23
23
  HOST = "jobs.dayforcehcm.com"
24
24
  JOB = re.compile(r"^/(?:([a-z]{2}(?:-[A-Za-z]{2,4})?)/)?([^/]+)/([^/]+)/jobs/(\d+)")
@@ -36,6 +36,7 @@ UPDATE_TIMEOUT_MS = 5_000 # for a section to close, per Update click
36
36
  SUBMIT_TIMEOUT_MS = 90_000
37
37
  UPDATE_TRIES = 3
38
38
  FOLLOWUP_TIMEOUT_MS = 3_000
39
+ FOLLOWUP = "Enter additional comments here" # an option's comment box, required once shown
39
40
  DIAL_TIMEOUT_MS = 2_000 # per dial code tried, longest first
40
41
  OPTIONS_TIMEOUT_MS = 5_000 # for a dropdown's options to render
41
42
  NEXT_TRIES = 3
@@ -120,7 +121,9 @@ QUESTION_TYPES = {
120
121
  CHECKBOX: FieldType.CHOICE,
121
122
  MULTI: FieldType.MULTI_CHOICE,
122
123
  }
123
- DISABILITY = "DISABILITYQUESTIONNAIRE" # its text questions (name, date, id) are the page's
124
+ # Its text questions (name, date, id) are the page's own, disabled and filled from other
125
+ # answers: not fields
126
+ DISABILITY = "DISABILITYQUESTIONNAIRE"
124
127
 
125
128
  # Each option of a dropdown, by scrolling its virtual list: only a screenful is in the DOM.
126
129
  _HARVEST = """async (id) => {
@@ -185,6 +188,7 @@ class Dayforce:
185
188
  self._sections: list[dict[str, Any]] = [] # the questionnaires, in page order
186
189
  self._options: dict[str, dict[str, str]] = {} # field id: page text to option
187
190
  self._values: dict[str, Answer | None] = {} # read back from each page before leaving it
191
+ self._shown: list[str] = [] # the follow-ups each page showed before leaving it
188
192
 
189
193
  def detect(self, url: str, page: Page | None) -> bool:
190
194
  return _job(url) is not None
@@ -234,7 +238,7 @@ class Dayforce:
234
238
  raise Unsupported("posting_gone: not a Dayforce job page")
235
239
  page.evaluate("key => sessionStorage.removeItem(key)", _draft(found))
236
240
  _open_form(page, found)
237
- self._values = {}
241
+ self._values, self._shown = {}, []
238
242
  self._dialogs(page, answers)
239
243
  self._fill_candidate_info(page, answers)
240
244
  if not _next(page):
@@ -254,6 +258,11 @@ class Dayforce:
254
258
  values["userAcknowledged"] = "Yes" if box.is_checked() else "No"
255
259
  return {k: v for k, v in values.items() if v not in (None, "", [])}
256
260
 
261
+ def shown_fields(self, page: Page) -> list[str]:
262
+ """The follow-ups the fill saw shown on each page before leaving it: the wizard keeps
263
+ no page but the last, so the rest are not listed."""
264
+ return list(self._shown)
265
+
257
266
  def submit(self, page: Page) -> str | None:
258
267
  before = page.url
259
268
  page.locator("main button", has_text=re.compile(r"^\s*Submit\s*$")).last.click()
@@ -292,9 +301,9 @@ class Dayforce:
292
301
  }.get(key) or None
293
302
  fields.append(Field(key, f["displayName"], kind, bool(f["isRequired"]), options))
294
303
  if key == "candidateSource" and explained:
295
- asked = f"asked when {f['displayName']!r} is {' or '.join(explained)}"
296
- label = f"Additional details ({asked})"
297
- fields.append(Field("candidateSourceDescription", label, FieldType.TEXT, False))
304
+ fields.append(Field("candidateSourceDescription", "Additional details",
305
+ FieldType.TEXT, False,
306
+ shown_when=Is("candidateSource", explained))) # fmt: skip
298
307
  if context.get("hasActiveSMSProvider"):
299
308
  fields.append(Field("smsConsent", "Consent to receive SMS messages", FieldType.CHOICE,
300
309
  False, NO_YES)) # fmt: skip
@@ -373,6 +382,7 @@ class Dayforce:
373
382
  description = page.locator(_id("candidateSourceDescription"))
374
383
  if description.count():
375
384
  self._values["candidateSourceDescription"] = description.input_value()
385
+ self._shown.append("candidateSourceDescription")
376
386
  _update(page, f"{PERSONAL}email")
377
387
  if page.locator(f"{HOURS}sunday_isChecked").count():
378
388
  self._fill_hours(page, answers)
@@ -444,16 +454,19 @@ class Dayforce:
444
454
  target = _question_target(page, base, question)
445
455
  if target is None:
446
456
  return
447
- if field_id in answers and not field.prefilled and target.is_enabled():
457
+ if field_id in answers and target.is_enabled():
448
458
  self._set_question(page, target, field, question, answers[field_id])
449
459
  self._values[field_id] = self._get_question(page, target, field, question)
450
- followup = f"{field_id}.followup"
451
- if followup in self._fields:
452
- boxes = page.locator( # a box group's is one per option asking for comments
453
- f"[id='{base}answerFollowup'],"
454
- f" :is(input, textarea)[id^='{base}answerFollowups_'],"
455
- f" [id*='questionnaireFollowUp-{question['questionId']}-']"
456
- )
460
+ # a box group's comment box is one per option asking for it, a single choice's one
461
+ followups = {f"{field_id}.followup": f"[id='{base}answerFollowup'],"
462
+ f" [id*='questionnaireFollowUp-{question['questionId']}-']"} # fmt: skip
463
+ for option in question["options"]:
464
+ box_id = f"{base}answerFollowups_{option['optionId']}"
465
+ followups[f"{field_id}.followup.{option['optionId']}"] = f"[id='{box_id}']"
466
+ for followup, selector in followups.items():
467
+ if followup not in self._fields:
468
+ continue
469
+ boxes = page.locator(selector)
457
470
  if followup in answers:
458
471
  with suppress(Timeout): # it shows once an option asking for comments is picked
459
472
  boxes.first.wait_for(timeout=FOLLOWUP_TIMEOUT_MS)
@@ -462,6 +475,13 @@ class Dayforce:
462
475
  box.fill(_one(answers[followup]))
463
476
  shown = [b.input_value() for b in boxes.all() if b.is_visible()]
464
477
  self._values[followup] = shown[0] if shown else ""
478
+ self._shown += [followup] if shown else []
479
+ for box in page.locator(f"[id^='{base}answerFollowup']").all():
480
+ box_id = box.get_attribute("id") or ""
481
+ option = box_id.removeprefix(f"{base}answerFollowup").removeprefix("s_")
482
+ followup = f"{field_id}.followup" + (f".{option}" if option else "")
483
+ if box.is_visible() and followup not in self._fields:
484
+ self._shown.append(box_id) # a comment box `read_form` did not list
465
485
 
466
486
  def _set_question(
467
487
  self, page: Page, target: Locator, field: Field, question: dict[str, Any], answer: Answer
@@ -605,6 +625,8 @@ def _question_fields(section: dict[str, Any], question: dict[str, Any]) -> list[
605
625
  if not label or len(label) > LONG_LABEL:
606
626
  label = _plain(question["displayName"])
607
627
  field_id = f"q{question['questionId']}"
628
+ if section.get("xRefCode") == DISABILITY and question["answerType"] == TEXT:
629
+ return []
608
630
  if kind is None:
609
631
  reason = f"unhandled_rendering: {field_id} has answer type {question['answerType']}"
610
632
  required = bool(question["isRequired"])
@@ -614,13 +636,23 @@ def _question_fields(section: dict[str, Any], question: dict[str, Any]) -> list[
614
636
  options = YES_NO
615
637
  elif kind in (FieldType.CHOICE, FieldType.MULTI_CHOICE):
616
638
  options = [_option_label(o) for o in question["options"]]
617
- prefilled = section.get("xRefCode") == DISABILITY and question["answerType"] == TEXT
618
- fields = [Field(field_id, label, kind, bool(question["isRequired"]), options,
619
- prefilled=prefilled)] # fmt: skip
620
- asking = [_option_label(o) for o in question["options"] if o.get("hasFollowUp")]
621
- if asking:
622
- followup = f"Additional comments (asked when {label!r} is {' or '.join(asking)})"
623
- fields.append(Field(f"{field_id}.followup", followup, FieldType.TEXT, False))
639
+ fields = [Field(field_id, label, kind, bool(question["isRequired"]), options)]
640
+ asking = [o for o in question["options"] if o.get("hasFollowUp")]
641
+ if question["answerType"] == MULTI: # a comment box per option ticked
642
+ fields += [
643
+ Field(
644
+ f"{field_id}.followup.{o['optionId']}",
645
+ FOLLOWUP,
646
+ FieldType.TEXT,
647
+ True,
648
+ shown_when=Is(field_id, [_option_label(o)]),
649
+ ) # fmt: skip
650
+ for o in asking
651
+ ]
652
+ elif asking:
653
+ picks = [_option_label(o) for o in asking]
654
+ fields.append(Field(f"{field_id}.followup", FOLLOWUP, FieldType.TEXT, True,
655
+ shown_when=Is(field_id, picks))) # fmt: skip
624
656
  return fields
625
657
 
626
658
 
@@ -211,8 +211,10 @@ class Eightfold:
211
211
  if control.dom is None and not control.field.shown_when:
212
212
  reason = f"unhandled_rendering: {control.field.id} is not on the page"
213
213
  control.field = replace(control.field, unfillable=reason)
214
- elif control.dom is not None and _prefilled(page, control):
215
- control.field = replace(control.field, prefilled=True)
214
+ held = self._values(page, [c for c in self._controls if c.dom and not c.field.unfillable])
215
+ for control in self._controls:
216
+ if control.field.id in held and control.field.prefilled is None:
217
+ control.field = replace(control.field, default=held[control.field.id])
216
218
  self._fields = [c.field for c in self._controls]
217
219
  self._controls = [c for c in self._controls if not c.field.unfillable]
218
220
  return self._fields
@@ -240,10 +242,14 @@ class Eightfold:
240
242
  doms = page.evaluate(_DOM_IDS, unplaced) if unplaced else {}
241
243
  for control in self._controls:
242
244
  control.dom = control.dom or doms.get(control.field.id)
243
- shown = [c for c in self._controls if c.dom]
244
- values = page.evaluate(_READ_BACK, [[c.field.id, c.dom, c.kind] for c in shown])
245
+ return self._values(page, [c for c in self._controls if c.dom])
246
+
247
+ @staticmethod
248
+ def _values(page: Page, controls: list[_Control]) -> dict[str, Answer]:
249
+ """What each rendered control shows, a date in ISO; those showing nothing left out."""
250
+ values = page.evaluate(_READ_BACK, [[c.field.id, c.dom, c.kind] for c in controls])
245
251
  out: dict[str, Answer] = {}
246
- for control in shown:
252
+ for control in controls:
247
253
  value = values.get(control.field.id)
248
254
  if control.kind == "date" and value:
249
255
  value = _iso(value)
@@ -373,7 +379,8 @@ def _control(q: dict[str, Any], by_id: dict[str, dict[str, Any]]) -> _Control:
373
379
  files = q.get("resumeOptions") or q.get("fileUploadOptions") or {}
374
380
  types = files.get("acceptedFileTypes") or (files.get("fileFormats") or "").split(",")
375
381
  accept = [t.strip() for t in types if t.strip()] or None
376
- today = bool((q.get("dateOptions") or {}).get("today")) # today's date, read-only
382
+ # today's date, read-only: the page takes no other, so it stands for the answer
383
+ today = date.today().isoformat() if (q.get("dateOptions") or {}).get("today") else None
377
384
  question = Field(
378
385
  qid,
379
386
  label,
@@ -400,15 +407,6 @@ def _box(page: Page, control: _Control):
400
407
  return page.locator(f"[id={json.dumps(f'{control.dom}_error')}]").locator("xpath=..")
401
408
 
402
409
 
403
- def _prefilled(page: Page, control: _Control) -> bool:
404
- if control.kind in ("text", "select", "date"):
405
- el = _box(page, control).locator("input:not([type=file]), textarea").first
406
- return bool(el.count() and el.input_value())
407
- if control.kind == "pills":
408
- return _box(page, control).locator("[id^=selectPilllist]").count() > 0
409
- return False
410
-
411
-
412
410
  def _fill(page: Page, control: _Control, answer: Answer) -> None:
413
411
  box = _box(page, control)
414
412
  value = answer if isinstance(answer, str) else (answer[0] if answer else "")
@@ -61,6 +61,7 @@ _READ = """form => {
61
61
  const found = [];
62
62
  for (const el of form.querySelectorAll("input, textarea")) {
63
63
  if (!el.id || ["hidden", "search", "file", "checkbox"].includes(el.type)) continue;
64
+ if (el.closest(".file-upload")) continue; // a file's paste-in textarea, listed with the file
64
65
  const combo = el.getAttribute("role") === "combobox";
65
66
  if (combo && el.closest(".phone-input")) continue; // the phone's country, set by its number
66
67
  const shell = el.closest(".select");
@@ -84,10 +85,14 @@ _READ = """form => {
84
85
  }
85
86
  for (const group of form.querySelectorAll(".file-upload")) {
86
87
  const input = group.querySelector("input[type=file]");
87
- found.push([group, {id: group.getAttribute("aria-labelledby").replace(/^upload-label-/, ""),
88
- kind: "file", label: text(group.querySelector(".upload-label")),
89
- required: group.getAttribute("aria-required") === "true",
88
+ const id = group.getAttribute("aria-labelledby").replace(/^upload-label-/, "");
89
+ const label = text(group.querySelector(".upload-label"));
90
+ const required = group.getAttribute("aria-required") === "true";
91
+ const manual = group.querySelector(`button[data-testid="${id}-text"]`);
92
+ found.push([group, {id, kind: "file", label, required, oneOf: manual ? id : null,
90
93
  accept: input?.accept ? input.accept.split(",") : null}]);
94
+ const pasted = {id: `${id}_text`, kind: "pasted", label, required, oneOf: id};
95
+ if (manual) found.push([manual, pasted]);
91
96
  }
92
97
  const before = (a, b) => a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING;
93
98
  found.sort(([a], [b]) => before(a, b) ? -1 : 1);
@@ -196,6 +201,15 @@ class Greenhouse:
196
201
  upload.locator("input[type=file]").set_input_files(values[0])
197
202
  with suppress(Timeout):
198
203
  upload.locator(".file-upload__filename").wait_for(timeout=ANSWER_TIMEOUT_MS)
204
+ case "pasted": # "Enter manually" adds the textarea beside the file input
205
+ text = page.locator(_by_id(field_id))
206
+ if not text.count():
207
+ browser.click(
208
+ page.locator(
209
+ f"button[data-testid='{field_id.removesuffix('_text')}-text']"
210
+ )
211
+ )
212
+ text.fill(values[0])
199
213
  case "consent":
200
214
  box = page.locator(f"{FORM} input[type=checkbox][name='{field_id}']").first
201
215
  if box.is_checked() != (answer == "Yes"):
@@ -227,7 +241,7 @@ class Greenhouse:
227
241
  def _field(self, page: Page, raw: dict[str, Any]) -> Field:
228
242
  kind, options = raw["kind"], raw.get("options")
229
243
  match kind:
230
- case "text" | "number":
244
+ case "text" | "number" | "pasted":
231
245
  field_type = FieldType.TEXT
232
246
  case "tel":
233
247
  field_type = FieldType.PHONE
@@ -256,6 +270,7 @@ class Greenhouse:
256
270
  options,
257
271
  raw.get("maxLength"),
258
272
  raw.get("accept"),
273
+ one_of=raw.get("oneOf"),
259
274
  )
260
275
 
261
276
  def _education(self, page: Page, field_id: str) -> list[str] | None:
@@ -343,15 +358,26 @@ def _api_fields(job: dict[str, Any]) -> list[Field] | None:
343
358
  return None
344
359
  fields = []
345
360
  for question in job["questions"] + (job.get("location_questions") or []):
346
- entry = question["fields"][0] # a file's paste-in textarea comes second
347
- if entry["type"] == "input_hidden": # a location's latitude and longitude
348
- continue
349
- name = entry["name"]
350
- field_type = FieldType.PHONE if name == "phone" else API_TYPES[entry["type"]]
351
- if name == "location":
352
- name, field_type = "candidate-location", FieldType.TYPEAHEAD
353
- options = [v["label"] for v in entry.get("values") or []] or None
354
- fields.append(Field(name, question["label"], field_type, question["required"], options))
361
+ entries = question["fields"] # a file's paste-in textarea comes second, its alternative
362
+ one_of = entries[0]["name"] if len(entries) > 1 else None
363
+ for entry in entries:
364
+ if entry["type"] == "input_hidden": # a location's latitude and longitude
365
+ continue
366
+ name = entry["name"]
367
+ field_type = FieldType.PHONE if name == "phone" else API_TYPES[entry["type"]]
368
+ if name == "location":
369
+ name, field_type = "candidate-location", FieldType.TYPEAHEAD
370
+ options = [v["label"] for v in entry.get("values") or []] or None
371
+ fields.append(
372
+ Field(
373
+ name,
374
+ question["label"],
375
+ field_type,
376
+ question["required"],
377
+ options,
378
+ one_of=one_of,
379
+ )
380
+ )
355
381
  return fields
356
382
 
357
383
 
@@ -18,7 +18,7 @@ from urllib.parse import urlsplit
18
18
  from atsfill.core import browser
19
19
  from atsfill.core.adapter import Gap, Unsupported
20
20
  from atsfill.core.browser import HTTP_TIMEOUT_S, Page, Timeout
21
- from atsfill.core.results import Answer, Field, FieldType, SideEffect
21
+ from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
22
22
 
23
23
  HOST = "careers.hireology.com"
24
24
  JOB = re.compile(r"^/[^/]+/(\d+)(?:/|$)")
@@ -101,16 +101,17 @@ LABELS = {
101
101
  }
102
102
  # The page's heading of a section whose labels repeat others' ("City").
103
103
  SECTIONS = {"experience": "Work history: ", "education": "Education: ", "military": "Military: "}
104
- # A field the page shows only once another is answered: (that field, the answers that show it).
105
- SHOWN_BY = {
106
- "referred_by": ("candidate_referred", "is Yes"),
107
- "non_complete_in_place_reason": ("non_complete_in_place", "is Yes"),
108
- "relocate_reason": ("relocate", "is Yes"),
109
- "extra_activities": ("participation_extra_activities", "is Yes"),
110
- "part_time_text": ("part_time", "is Yes"),
111
- "academic_awards": ("gained_academic_awards", "is Yes"),
104
+ # A field the page shows only once another is answered, and on which answers: the school fields
105
+ # stay hidden until an education is picked.
106
+ SHOWN_BY: dict[str, Is] = {
107
+ "referred_by": Is("candidate_referred", ["Yes"]),
108
+ "non_complete_in_place_reason": Is("non_complete_in_place", ["Yes"]),
109
+ "relocate_reason": Is("relocate", ["Yes"]),
110
+ "extra_activities": Is("participation_extra_activities", ["Yes"]),
111
+ "part_time_text": Is("part_time", ["Yes"]),
112
+ "academic_awards": Is("gained_academic_awards", ["Yes"]),
112
113
  **{
113
- f: ("military", "is Yes")
114
+ f: Is("military", ["Yes"])
114
115
  for f in (
115
116
  "army_branch",
116
117
  "highest_rank",
@@ -122,7 +123,7 @@ SHOWN_BY = {
122
123
  )
123
124
  },
124
125
  **{
125
- f: ("highest_grade", "is not Did Not Complete High School")
126
+ f: Is("highest_grade", ["Did Not Complete High School"], negated=True)
126
127
  for f in ("institute_name", "graduation_year", "education_city", "education_state_id")
127
128
  },
128
129
  }
@@ -230,6 +231,10 @@ class Hireology:
230
231
  self._kinds = {field.id: kind for field, kind in out if not field.unfillable}
231
232
  return [f for f, _ in out]
232
233
 
234
+ def shown_fields(self, page: Page) -> list[str]:
235
+ form = page.locator(FORM)
236
+ return [i for i in self._kinds if form.evaluate(_CONTROL, i) is not None]
237
+
233
238
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
234
239
  form = page.locator(FORM)
235
240
  for field_id, kind in self._kinds.items(): # a question before the fields it shows
@@ -293,7 +298,7 @@ def _exact(value: str) -> re.Pattern[str]:
293
298
 
294
299
  def _form(api: dict[str, Any]) -> list[tuple[Field, str]]:
295
300
  """Each field of the form API's template with its API type; an entry the page can repeat
296
- is read once, as its first."""
301
+ is read once, as its first. A field shown by one the form does not ask is never shown."""
297
302
  out = []
298
303
  for section in api["template"]["sections"]:
299
304
  for fieldset in section["fieldsets"]:
@@ -301,7 +306,8 @@ def _form(api: dict[str, Any]) -> list[tuple[Field, str]]:
301
306
  out.append(
302
307
  (_field(entry, SECTIONS.get(section["id"], "")), entry["attributes"]["type"])
303
308
  )
304
- return out
309
+ ids = {f.id for f, _ in out}
310
+ return [(f, k) for f, k in out if f.id not in SHOWN_BY or SHOWN_BY[f.id].field_id in ids]
305
311
 
306
312
 
307
313
  def _field(entry: dict[str, Any], section: str) -> Field:
@@ -316,20 +322,30 @@ def _field(entry: dict[str, Any], section: str) -> Field:
316
322
  if kind not in TYPES:
317
323
  reason = f"unhandled_rendering: {field_id} is a {kind}"
318
324
  return Field(
319
- field_id, label, FieldType.TEXT, bool(attributes.get("required")), unfillable=reason
320
- )
325
+ field_id, label, FieldType.TEXT, bool(attributes.get("required")), unfillable=reason,
326
+ shown_when=SHOWN_BY.get(field_id),
327
+ ) # fmt: skip
321
328
  required = attributes["required"] and kind != "checkbox" # a box left clear is an answer
322
329
  options = [o["name"] for o in attributes["options"]] or None
323
330
  if kind == "checkbox":
324
331
  options = YES_NO
325
332
  elif kind == "month":
326
333
  label += MONTH
327
- if shown_by := SHOWN_BY.get(field_id):
328
- trigger, condition = shown_by
329
- label += f" (asked when {section + LABELS[trigger]!r} {condition})"
330
- required = False
331
334
  max_length = attributes.get("maxlength") if TYPES[kind] is FieldType.TEXT else None
332
335
  if kind == "month":
333
336
  max_length = None
334
337
  accept = FILE_TYPES if kind == "file" else None
335
- return Field(field_id, label, TYPES[kind], required, options, max_length, accept)
338
+ return Field(
339
+ field_id, label, TYPES[kind], required, options, max_length, accept,
340
+ default=_default(attributes, kind), shown_when=SHOWN_BY.get(field_id),
341
+ ) # fmt: skip
342
+
343
+
344
+ def _default(attributes: dict[str, Any], kind: str) -> str | None:
345
+ """The answer the page shows picked before any, from the API's `value`: an option's name,
346
+ a box's `Yes` or `No`."""
347
+ value = attributes.get("value")
348
+ if kind == "checkbox" and isinstance(value, bool):
349
+ return YES_NO[0] if value else YES_NO[1]
350
+ names = {str(o.get("value")): o["name"] for o in attributes.get("options") or []}
351
+ return names.get(str(value)) if isinstance(value, str | int) else None
@@ -8,13 +8,14 @@ by which boxes are checked.
8
8
 
9
9
  import re
10
10
  from contextlib import suppress
11
+ from dataclasses import replace
11
12
  from typing import Any
12
13
  from urllib.parse import urlsplit
13
14
 
14
15
  from atsfill.core import browser
15
16
  from atsfill.core.adapter import Blocked, Gap, Unsupported
16
17
  from atsfill.core.browser import Page, Timeout
17
- from atsfill.core.results import Answer, Field, FieldType, SideEffect
18
+ from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
18
19
 
19
20
  HOST = re.compile(r"^[\w-]+\.applytojob\.com$")
20
21
  JOB = re.compile(r"^/apply/([A-Za-z0-9]{10})\b")
@@ -23,6 +24,9 @@ CAPTCHA = "#resumator-recaptcha iframe[title=reCAPTCHA]"
23
24
  CHALLENGE = "iframe[src*='recaptcha/api2/bframe']"
24
25
  RESUME_TYPES = [".pdf", ".doc", ".docx", ".odt", ".txt", ".rtf"] # as the page states them
25
26
  YES_NO = ["Yes", "No"]
27
+ DISABILITY = "eeoc_disability" # form CC-305: its name and date are signed once one is declared
28
+ SIGNED = ("eeoc_disability_signature", "eeoc_disability_date")
29
+ DECLARED = re.compile(r"^\s*YES\b", re.IGNORECASE)
26
30
  FORM_TIMEOUT_MS = 20_000
27
31
  CAPTCHA_TIMEOUT_MS = 20_000
28
32
  SUBMIT_TIMEOUT_MS = 90_000
@@ -43,13 +47,15 @@ _READ = """form => {
43
47
  const shown = el => el.offsetParent !== null;
44
48
  const disability = "#resumator-new-disability-message";
45
49
  const found = [], radios = new Set();
50
+ const pasted = "resumator-resumetext-value"; // "Paste resume", the file's alternative
51
+ const pasteable = !!form.querySelector(`#${pasted}`);
46
52
  const controls = form.querySelectorAll(
47
53
  "input:not([type=hidden]), select, textarea, input.resumator-questionnaire-checkbox-answer");
48
54
  for (const el of controls) {
49
- if (["resumator-resumetext-value", "resumator-xml-value", "g-recaptcha-response"]
50
- .includes(el.id) || el.classList.contains("resumator-questionnaire-checkbox")) continue;
55
+ if (["resumator-xml-value", "g-recaptcha-response"].includes(el.id)
56
+ || el.classList.contains("resumator-questionnaire-checkbox")) continue;
51
57
  const hidden = el.type === "file" ? el.id !== "resumator-resume-value"
52
- : !shown(el) && el.type !== "hidden";
58
+ : !shown(el) && el.type !== "hidden" && el.id !== pasted;
53
59
  if (hidden) continue;
54
60
  const group = el.closest(".form-group, .resumator-field-wrapper");
55
61
  const groupLabel = group?.querySelector(".control-label, .resumator-label");
@@ -75,10 +81,11 @@ _READ = """form => {
75
81
  field.kind = "select";
76
82
  field.options = [...el.options].filter(o => !o.value.startsWith("resumator_no_selection")
77
83
  && !/^no answer$/i.test(o.text.trim())).map(o => o.text.trim());
78
- } else if (el.type === "file") {
79
- field.kind = "file";
84
+ } else if (el.type === "file" || el.id === pasted) {
85
+ field.kind = el.type === "file" ? "file" : "pasted";
80
86
  field.label = text(groupLabel);
81
87
  field.required = starred(groupLabel);
88
+ if (pasteable) field.oneOf = "resume";
82
89
  } else {
83
90
  field.kind = el.classList.contains("resumator-datepicker") ? "date"
84
91
  : el.type === "tel" ? "tel" : "text";
@@ -143,7 +150,7 @@ class JazzHR:
143
150
  for raw in form.evaluate(_READ):
144
151
  fields.append(_field(raw))
145
152
  self._fields[raw["id"]] = (raw["kind"], raw["selector"])
146
- return fields
153
+ return _signed_on_disability(fields)
147
154
 
148
155
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
149
156
  form = page.locator(FORM)
@@ -158,6 +165,12 @@ class JazzHR:
158
165
  if attach.is_visible():
159
166
  attach.click()
160
167
  field.set_input_files(values[0])
168
+ case "pasted": # behind "Paste resume", from the choice or the file's own link
169
+ for link in ("#resumator-choose-paste", "#resumator-resume-switch-paste"):
170
+ if page.locator(link).is_visible():
171
+ page.locator(link).click()
172
+ break
173
+ field.fill(values[0])
161
174
  case "select":
162
175
  field.select_option(label=values[0])
163
176
  case "radio":
@@ -217,10 +230,21 @@ def _job(url: str) -> tuple[str, str] | None:
217
230
  return (host, match[1]) if match else None
218
231
 
219
232
 
233
+ def _signed_on_disability(fields: list[Field]) -> list[Field]:
234
+ """The disability form's name and date, required by the page once a disability is
235
+ declared."""
236
+ status = next((f for f in fields if f.id == DISABILITY), None)
237
+ declared = [o for o in (status.options if status else None) or [] if DECLARED.match(o)]
238
+ if not declared:
239
+ return fields
240
+ signed = Is(DISABILITY, declared)
241
+ return [replace(f, required_when=signed) if f.id in SIGNED else f for f in fields]
242
+
243
+
220
244
  def _field(raw: dict[str, Any]) -> Field:
221
245
  kind, options = raw["kind"], raw.get("options")
222
246
  match kind:
223
- case "text":
247
+ case "text" | "pasted":
224
248
  field_type = FieldType.TEXT
225
249
  case "date":
226
250
  field_type = FieldType.DATE
@@ -241,5 +265,12 @@ def _field(raw: dict[str, Any]) -> Field:
241
265
  )
242
266
  accept = RESUME_TYPES if kind == "file" else None
243
267
  return Field(
244
- raw["id"], raw["label"], field_type, raw["required"], options, raw.get("maxLength"), accept
268
+ raw["id"],
269
+ raw["label"],
270
+ field_type,
271
+ raw["required"],
272
+ options,
273
+ raw.get("maxLength"),
274
+ accept,
275
+ one_of=raw.get("oneOf"),
245
276
  )
@@ -13,7 +13,7 @@ from urllib.parse import urlsplit
13
13
 
14
14
  from atsfill.core.adapter import Blocked, Gap, Unsupported, known_gap
15
15
  from atsfill.core.browser import HTTP_TIMEOUT_S, Page
16
- from atsfill.core.results import Answer, Field, FieldType, SideEffect
16
+ from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
17
17
 
18
18
  HOST = "www.careers-page.com"
19
19
  API = f"https://{HOST}/api/v1.0/"
@@ -333,10 +333,11 @@ class Manatal:
333
333
  )
334
334
 
335
335
  def _other_field(self, field: Field) -> Field:
336
- """The text box an answer of Other opens, asked only then."""
336
+ """The text box an answer of Other opens, required where its list is."""
337
337
  fid = f"{field.id}.other"
338
338
  gap = known_gap(self, "other", f"{fid} is the text box an answer of {OTHER} opens")
339
- return Field(fid, f"{field.label}: {OTHER}", FieldType.TEXT, False, unfillable=gap)
339
+ return Field(fid, f"{field.label}: {OTHER}", FieldType.TEXT, field.required,
340
+ unfillable=gap, shown_when=Is(field.id, [OTHER])) # fmt: skip
340
341
 
341
342
  @staticmethod
342
343
  def _shown(page: Page, entry: dict[str, Any]) -> list[str] | None:
@@ -635,5 +635,5 @@ def _field(raw: dict[str, Any]) -> Field:
635
635
  options,
636
636
  raw.get("maxLength"),
637
637
  raw.get("accept"),
638
- prefilled=bool(raw.get("value")),
638
+ default=raw.get("value") or None, # no account: the page's own, the visitor's country
639
639
  )