atsfill 0.0.4.dev92__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.dev92 → atsfill-0.0.4.dev94}/PKG-INFO +1 -1
  2. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/pyproject.toml +1 -1
  3. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/pyproject.toml.orig +1 -1
  4. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/bamboohr.py +9 -5
  5. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/dayforce.py +53 -21
  6. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/eightfold.py +54 -62
  7. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/hireology.py +36 -20
  8. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/jazzhr.py +17 -2
  9. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/manatal.py +4 -3
  10. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/oracle.py +1 -1
  11. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/paylocity.py +14 -9
  12. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/phenom.py +122 -63
  13. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/pinpoint.py +67 -30
  14. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/rippling.py +4 -4
  15. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/smartrecruiters.py +49 -19
  16. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/teamtailor.py +21 -16
  17. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/workday.py +50 -50
  18. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/wttj.py +3 -2
  19. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/zohorecruit.py +37 -35
  20. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/core/adapter.py +10 -0
  21. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/core/commands.py +59 -8
  22. atsfill-0.0.4.dev94/src/atsfill/core/conditions.py +96 -0
  23. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/core/matching.py +19 -4
  24. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/core/results.py +28 -1
  25. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/LICENSE +0 -0
  26. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/README.md +0 -0
  27. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/__init__.py +0 -0
  28. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/__init__.py +0 -0
  29. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/ashby.py +0 -0
  30. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/breezy.py +0 -0
  31. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/greenhouse.py +0 -0
  32. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/hiringthing.py +0 -0
  33. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/lever.py +0 -0
  34. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/paycor.py +0 -0
  35. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/personio.py +0 -0
  36. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/recruitee.py +0 -0
  37. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/workable.py +0 -0
  38. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/cli.py +0 -0
  39. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/core/__init__.py +0 -0
  40. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/core/accounts.py +0 -0
  41. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/core/attempts.py +0 -0
  42. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/core/browser.py +0 -0
  43. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/core/passwords.py +0 -0
  44. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/core/paths.py +0 -0
  45. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/core/registry.py +0 -0
  46. {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/py.typed +0 -0
  47. {atsfill-0.0.4.dev92 → 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.dev92
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.dev92"
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.dev92"
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
 
@@ -2,10 +2,10 @@
2
2
  the employer's own domain), as docs/adapters/eightfold.md observes.
3
3
 
4
4
  A posting's apply page (`/careers/apply?pid=<pid>`) renders its form from a questions API:
5
- sections of questions by id, type, requirement and options, and the questions another one's
6
- answer shows. `read_api` fetches it over HTTP, `read_form` through the page's browser context,
7
- and both list the fields from it. Each question renders as a control whose element id is its
8
- section's title and its id, with every other character an underscore.
5
+ sections of questions by id, type, requirement and options, and the questions whose answer
6
+ shows each follow-up. `read_api` fetches it over HTTP, `read_form` through the page's browser
7
+ context, and both list the fields from it. Each question renders as a control whose element id
8
+ is its section's title and its id, with every other character an underscore.
9
9
  """
10
10
 
11
11
  import http.cookiejar
@@ -13,15 +13,15 @@ import json
13
13
  import re
14
14
  import urllib.request
15
15
  from contextlib import suppress
16
- from dataclasses import dataclass, field, replace
16
+ from dataclasses import dataclass, replace
17
17
  from datetime import date, datetime
18
18
  from typing import Any
19
19
  from urllib.parse import parse_qs, urlencode, urlsplit
20
20
 
21
- from atsfill.core import browser
21
+ from atsfill.core import browser, conditions
22
22
  from atsfill.core.adapter import Blocked, Gap, NotTaken, Unsupported
23
23
  from atsfill.core.browser import HTTP_TIMEOUT_S, Page, Timeout
24
- from atsfill.core.results import Answer, Field, FieldType, SideEffect
24
+ from atsfill.core.results import Answer, AnyOf, Condition, Field, FieldType, Is, SideEffect
25
25
 
26
26
  HOST = re.compile(r"^[\w-]+\.eightfold\.ai$")
27
27
  JOB = re.compile(r"^/careers/job/(\d+)")
@@ -33,7 +33,6 @@ CSRF = re.compile(r'<meta name="_csrf" content="([^"]+)"')
33
33
  SUBMIT = "[data-test-id=submitApplicationButton]"
34
34
  AGREE = "#confirmUploadResume" # the privacy dialog a resume upload opens
35
35
  PICKER = "[data-testid=picker-dialog]"
36
- MAX_DEPTH = 5 # the deepest chain of questions rendering one another that a label names"
37
36
  FORM_TIMEOUT_MS = 30_000
38
37
  SHOWN_TIMEOUT_MS = 2_000
39
38
  OPTIONS_TIMEOUT_MS = 5_000
@@ -131,8 +130,6 @@ class _Control:
131
130
  field: Field
132
131
  kind: str
133
132
  ends: list[str] # what the control's element id ends with
134
- # the questions whose answer renders it, each with the answers that do (any when empty)
135
- parents: list[tuple[str, list[str]]] = field(default_factory=list)
136
133
  resume: bool = False
137
134
  dom: str | None = None
138
135
 
@@ -152,6 +149,7 @@ class Eightfold:
152
149
 
153
150
  def __init__(self) -> None:
154
151
  self._controls: list[_Control] = [] # in the form's order
152
+ self._fields: list[Field] = []
155
153
 
156
154
  def detect(self, url: str, page: Page | None) -> bool:
157
155
  if _pid(url) is None:
@@ -210,14 +208,16 @@ class Eightfold:
210
208
  control.dom = doms.get(control.field.id)
211
209
  if control.field.unfillable:
212
210
  continue
213
- if control.dom is None and not control.parents:
211
+ if control.dom is None and not control.field.shown_when:
214
212
  reason = f"unhandled_rendering: {control.field.id} is not on the page"
215
213
  control.field = replace(control.field, unfillable=reason)
216
- elif control.dom is not None and _prefilled(page, control):
217
- control.field = replace(control.field, prefilled=True)
218
- fields = [c.field for c in self._controls]
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])
218
+ self._fields = [c.field for c in self._controls]
219
219
  self._controls = [c for c in self._controls if not c.field.unfillable]
220
- return fields
220
+ return self._fields
221
221
 
222
222
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
223
223
  # the resume first: what its parse finds is written over the fields filled before it
@@ -226,8 +226,8 @@ class Eightfold:
226
226
  for control in order:
227
227
  if (answer := answers.get(control.field.id)) is None:
228
228
  continue
229
- if control.dom is None: # rendered once a question that shows it is answered
230
- if not any(_shows(given.get(p), values) for p, values in control.parents):
229
+ if control.dom is None: # a follow-up, rendered once its condition holds
230
+ if not conditions.Form(self._fields, given).shown(control.field):
231
231
  continue
232
232
  with suppress(Timeout):
233
233
  _box_of(page, control).wait_for(timeout=SHOWN_TIMEOUT_MS)
@@ -242,10 +242,14 @@ class Eightfold:
242
242
  doms = page.evaluate(_DOM_IDS, unplaced) if unplaced else {}
243
243
  for control in self._controls:
244
244
  control.dom = control.dom or doms.get(control.field.id)
245
- shown = [c for c in self._controls if c.dom]
246
- 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])
247
251
  out: dict[str, Answer] = {}
248
- for control in shown:
252
+ for control in controls:
249
253
  value = values.get(control.field.id)
250
254
  if control.kind == "date" and value:
251
255
  value = _iso(value)
@@ -320,21 +324,23 @@ def _plain(label: str) -> str:
320
324
  return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", label)).strip()
321
325
 
322
326
 
323
- def _label(q: dict[str, Any], by_id: dict[str, dict[str, Any]], depth: int = 0) -> str:
324
- """The question's text; one some of a parent's answers render says so, naming the first
325
- parent by its own label, so that a parent asked for other answers names it apart."""
327
+ def _label(q: dict[str, Any]) -> str:
326
328
  label = _plain(q.get("label") or "")
327
329
  if (q.get("textOptions") or {}).get("type") == "number": # a number input takes no text
328
330
  label += " (a number)"
329
- first = (q.get("parentDependencies") or [{}])[0]
330
- if not first.get("values") or depth > MAX_DEPTH:
331
- return label
332
- parent = by_id.get(str(first["questionId"]), {})
333
- labels = {str(c["value"]): c["label"] for c in _choices(parent)}
334
- shown = " or ".join(labels.get(str(v), str(v)) for v in first["values"])
335
- named = _label(parent, by_id, depth + 1)
336
- quote = '"' if "'" in named else "'" # never escaped: the outermost is the last
337
- return f"{label} (asked when {quote}{named}{quote} is {shown})"
331
+ return label
332
+
333
+
334
+ def _shown_when(q: dict[str, Any], by_id: dict[str, dict[str, Any]]) -> Condition | None:
335
+ """What renders a follow-up: any of its parents' answers it lists, any answer where it
336
+ lists none."""
337
+ parents: list[Condition] = []
338
+ for dependency in q.get("parentDependencies") or []:
339
+ parent = by_id.get(str(dependency["questionId"]), {})
340
+ labels = {str(c["value"]): c["label"] for c in _choices(parent)}
341
+ values = [labels.get(str(v), str(v)) for v in dependency.get("values") or []]
342
+ parents.append(Is(str(dependency["questionId"]), values or None))
343
+ return parents[0] if len(parents) == 1 else AnyOf(parents) if parents else None
338
344
 
339
345
 
340
346
  def _choices(q: dict[str, Any]) -> list[dict[str, Any]]:
@@ -356,33 +362,36 @@ def _form(data: dict[str, Any]) -> list[_Control]:
356
362
 
357
363
 
358
364
  def _control(q: dict[str, Any], by_id: dict[str, dict[str, Any]]) -> _Control:
359
- qid, label = str(q["questionId"]), _label(q, by_id)
365
+ qid, label, shown_when = str(q["questionId"]), _label(q), _shown_when(q, by_id)
360
366
  kind = KINDS.get(q.get("type", ""))
361
367
  choices = _choices(q)
362
368
  if kind == "select" and not choices and (q.get("selectOptions") or {}).get("apiEndpoint"):
363
369
  kind = "lookup" # options load once the question it depends on is answered
364
370
  required = bool(q.get("required"))
365
- parents = []
366
- for dependency in q.get("parentDependencies") or []:
367
- parent = by_id.get(str(dependency["questionId"]), {})
368
- labels = {str(c["value"]): c["label"] for c in _choices(parent)}
369
- values = [labels.get(str(v), str(v)) for v in dependency.get("values") or []]
370
- parents.append((str(dependency["questionId"]), values))
371
- if parents and parents[0][1]: # asked for some of its parent's answers only
372
- required = False
373
371
  ends = list(dict.fromkeys([_norm(qid), qid]))
374
372
  if kind is None:
375
373
  reason = f"unhandled_rendering: {qid} is a question of type {q.get('type')}"
376
- return _Control(Field(qid, label, FieldType.TEXT, required, unfillable=reason), "", ends)
374
+ unfillable = Field(qid, label, FieldType.TEXT, required, unfillable=reason)
375
+ return _Control(replace(unfillable, shown_when=shown_when), "", ends)
377
376
  options = [c["label"] for c in choices] or None
378
377
  accept = None
379
378
  if kind == "file":
380
379
  files = q.get("resumeOptions") or q.get("fileUploadOptions") or {}
381
380
  types = files.get("acceptedFileTypes") or (files.get("fileFormats") or "").split(",")
382
381
  accept = [t.strip() for t in types if t.strip()] or None
383
- today = bool((q.get("dateOptions") or {}).get("today")) # today's date, read-only
384
- question = Field(qid, label, TYPES[kind], required, options, accept=accept, prefilled=today)
385
- return _Control(question, kind, ends, parents, q.get("type") == "resumeUpload")
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
384
+ question = Field(
385
+ qid,
386
+ label,
387
+ TYPES[kind],
388
+ required,
389
+ options,
390
+ accept=accept,
391
+ prefilled=today,
392
+ shown_when=shown_when,
393
+ )
394
+ return _Control(question, kind, ends, q.get("type") == "resumeUpload")
386
395
 
387
396
 
388
397
  def _find(page: Page, control: _Control) -> str | None:
@@ -398,15 +407,6 @@ def _box(page: Page, control: _Control):
398
407
  return page.locator(f"[id={json.dumps(f'{control.dom}_error')}]").locator("xpath=..")
399
408
 
400
409
 
401
- def _prefilled(page: Page, control: _Control) -> bool:
402
- if control.kind in ("text", "select", "date"):
403
- el = _box(page, control).locator("input:not([type=file]), textarea").first
404
- return bool(el.count() and el.input_value())
405
- if control.kind == "pills":
406
- return _box(page, control).locator("[id^=selectPilllist]").count() > 0
407
- return False
408
-
409
-
410
410
  def _fill(page: Page, control: _Control, answer: Answer) -> None:
411
411
  box = _box(page, control)
412
412
  value = answer if isinstance(answer, str) else (answer[0] if answer else "")
@@ -533,13 +533,5 @@ def _iso(shown: str) -> str:
533
533
  return shown
534
534
 
535
535
 
536
- def _shows(answer: Answer | None, values: list[str]) -> bool:
537
- """Whether a parent's answer renders its dependent: any answer when `values` is empty."""
538
- if answer in (None, "", []):
539
- return False
540
- picks = [answer] if isinstance(answer, str) else answer
541
- return not values or any(p in values for p in picks)
542
-
543
-
544
536
  def _exact(value: str) -> re.Pattern[str]:
545
537
  return re.compile(rf"^\s*{re.escape(value)}\s*$")
@@ -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: