atsfill 0.0.4.dev93__tar.gz → 0.0.4.dev94__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.dev94}/PKG-INFO +1 -1
  2. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/pyproject.toml +1 -1
  3. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/pyproject.toml.orig +1 -1
  4. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/bamboohr.py +9 -5
  5. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/dayforce.py +53 -21
  6. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/eightfold.py +13 -15
  7. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/hireology.py +36 -20
  8. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/jazzhr.py +17 -2
  9. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/manatal.py +4 -3
  10. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/oracle.py +1 -1
  11. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/paylocity.py +14 -9
  12. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/phenom.py +122 -63
  13. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/pinpoint.py +67 -30
  14. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/rippling.py +4 -4
  15. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/smartrecruiters.py +49 -19
  16. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/workday.py +50 -50
  17. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/wttj.py +3 -2
  18. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/zohorecruit.py +37 -35
  19. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/core/commands.py +1 -1
  20. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/core/conditions.py +8 -4
  21. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/core/matching.py +1 -1
  22. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/core/results.py +2 -1
  23. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/LICENSE +0 -0
  24. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/README.md +0 -0
  25. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/__init__.py +0 -0
  26. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/__init__.py +0 -0
  27. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/ashby.py +0 -0
  28. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/breezy.py +0 -0
  29. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/greenhouse.py +0 -0
  30. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/hiringthing.py +0 -0
  31. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/lever.py +0 -0
  32. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/paycor.py +0 -0
  33. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/personio.py +0 -0
  34. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/recruitee.py +0 -0
  35. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/teamtailor.py +0 -0
  36. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/workable.py +0 -0
  37. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/cli.py +0 -0
  38. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/core/__init__.py +0 -0
  39. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/core/accounts.py +0 -0
  40. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/core/adapter.py +0 -0
  41. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/core/attempts.py +0 -0
  42. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/core/browser.py +0 -0
  43. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/core/passwords.py +0 -0
  44. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/core/paths.py +0 -0
  45. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/core/registry.py +0 -0
  46. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/py.typed +0 -0
  47. {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/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.dev94
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.dev94"
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.dev94"
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 "")
@@ -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
@@ -143,7 +147,7 @@ class JazzHR:
143
147
  for raw in form.evaluate(_READ):
144
148
  fields.append(_field(raw))
145
149
  self._fields[raw["id"]] = (raw["kind"], raw["selector"])
146
- return fields
150
+ return _signed_on_disability(fields)
147
151
 
148
152
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
149
153
  form = page.locator(FORM)
@@ -217,6 +221,17 @@ def _job(url: str) -> tuple[str, str] | None:
217
221
  return (host, match[1]) if match else None
218
222
 
219
223
 
224
+ def _signed_on_disability(fields: list[Field]) -> list[Field]:
225
+ """The disability form's name and date, required by the page once a disability is
226
+ declared."""
227
+ status = next((f for f in fields if f.id == DISABILITY), None)
228
+ declared = [o for o in (status.options if status else None) or [] if DECLARED.match(o)]
229
+ if not declared:
230
+ return fields
231
+ signed = Is(DISABILITY, declared)
232
+ return [replace(f, required_when=signed) if f.id in SIGNED else f for f in fields]
233
+
234
+
220
235
  def _field(raw: dict[str, Any]) -> Field:
221
236
  kind, options = raw["kind"], raw.get("options")
222
237
  match kind:
@@ -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
  )
@@ -20,7 +20,7 @@ from urllib.parse import urlsplit
20
20
 
21
21
  from atsfill.core.adapter import Gap, Unsupported, known_gap
22
22
  from atsfill.core.browser import HTTP_TIMEOUT_S, Locator, Page, Timeout
23
- from atsfill.core.results import Answer, Field, FieldType, SideEffect
23
+ from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
24
24
 
25
25
  HOST = "recruiting.paylocity.com"
26
26
  JOB = re.compile(r"^/Recruiting/Jobs/(?:Details|Apply)/(\d+)", re.IGNORECASE)
@@ -317,6 +317,7 @@ class Paylocity:
317
317
  def __init__(self) -> None:
318
318
  self._form: list[tuple[Field, Control]] = []
319
319
  self._values: dict[str, Answer] = {} # the read-back of the steps left behind
320
+ self._shown: list[str] = [] # the fields each step showed
320
321
 
321
322
  def detect(self, url: str, page: Page | None) -> bool:
322
323
  return _job(url) is not None
@@ -351,7 +352,7 @@ class Paylocity:
351
352
  return [f for f, _ in form]
352
353
 
353
354
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
354
- self._values = {}
355
+ self._values, self._shown = {}, []
355
356
  page.locator("form, #app").first.wait_for(timeout=FORM_TIMEOUT_MS)
356
357
  if "resume" in answers: # first: a parsed resume fills fields the answers then override
357
358
  _upload_resume(page, _one(answers["resume"]))
@@ -360,9 +361,14 @@ class Paylocity:
360
361
  if field.id in answers and field.id != "resume" and _present(page, control):
361
362
  _fill(page, control, answers[field.id])
362
363
  self._values.update(self._read(page))
364
+ self._shown += [f.id for f, c in self._form if _present(page, c)]
363
365
  if not page.locator(NEXT).count() or not _next(page):
364
366
  return
365
367
 
368
+ def shown_fields(self, page: Page) -> list[str]:
369
+ """The listed fields each step showed once filled: the wizard keeps only its last."""
370
+ return list(dict.fromkeys(self._shown))
371
+
366
372
  def read_back(self, page: Page) -> dict[str, Answer]:
367
373
  values = {**self._values, **self._read(page)}
368
374
  return {k: v for k, v in values.items() if v not in (None, "", [])}
@@ -398,7 +404,7 @@ def _form(data: dict[str, Any]) -> list[tuple[Field, Control]]:
398
404
  """The fields and their controls, in the page's order, from the apply page's pageData."""
399
405
  custom = data["customJobApplication"]
400
406
  out: list[tuple[Field, Control]] = []
401
- labels: dict[str, str] = {}
407
+ ids: dict[str, str] = {} # a listed control's field id, by its DOM id
402
408
 
403
409
  def add(spec: Spec, field: dict[str, Any], prefix: str = "", n: int = 0) -> None:
404
410
  dom = spec.dom.format(n=n)
@@ -410,13 +416,12 @@ def _form(data: dict[str, Any]) -> list[tuple[Field, Control]]:
410
416
  options = [o["value" if spec.kind == "state" else "text"] for o in listed]
411
417
  label = prefix + (spec.label or _plain(field["displayName"]))
412
418
  required = field["isRequired"] if spec.required is None else spec.required
413
- if spec.when:
414
- trigger, value = spec.when
415
- label += f" (asked when {labels.get(trigger, trigger)!r} is {value})"
416
- labels[dom] = label
417
- field_id = FILE_IDS.get(dom, dom)
419
+ shown_when = None
420
+ if spec.when and spec.when[0] in ids:
421
+ shown_when = Is(ids[spec.when[0]], [spec.when[1]])
422
+ field_id = ids[dom] = FILE_IDS.get(dom, dom)
418
423
  out.append((Field(field_id, label, TYPES[spec.kind], bool(required), options,
419
- spec.max_length),
424
+ spec.max_length, shown_when=shown_when),
420
425
  Control(spec.kind, dom, options))) # fmt: skip
421
426
 
422
427
  add(RESUME, {"isRequired": bool(custom.get("requireResume") or data.get("requireResume"))})