atsfill 0.0.8.dev219__tar.gz → 0.0.8.dev223__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 (69) hide show
  1. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/PKG-INFO +1 -1
  2. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/pyproject.toml +1 -1
  3. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/pyproject.toml.orig +1 -1
  4. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/adp.py +56 -3
  5. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/adpmyjobs.py +24 -7
  6. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/bamboohr.py +34 -0
  7. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/eightfold.py +14 -0
  8. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/gupy.py +88 -6
  9. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/hrmdirect.py +24 -7
  10. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/isolvedhire.py +20 -0
  11. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/recruiterflow.py +1 -0
  12. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/taleo.py +25 -10
  13. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/workday.py +41 -16
  14. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/core/adapter.py +8 -0
  15. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/LICENSE +0 -0
  16. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/README.md +0 -0
  17. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/__init__.py +0 -0
  18. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/__init__.py +0 -0
  19. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/ashby.py +0 -0
  20. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/breezy.py +0 -0
  21. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/dayforce.py +0 -0
  22. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/greenhouse.py +0 -0
  23. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/hireology.py +0 -0
  24. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/hiringthing.py +0 -0
  25. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/jazzhr.py +0 -0
  26. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/jobaffinity.py +0 -0
  27. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/jobstreet.py +0 -0
  28. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/join.py +0 -0
  29. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/lever.py +0 -0
  30. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/manatal.py +0 -0
  31. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/oracle.py +0 -0
  32. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/paycor.py +0 -0
  33. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/paylocity.py +0 -0
  34. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/personio.py +0 -0
  35. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/phenom.py +0 -0
  36. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/pinpoint.py +0 -0
  37. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/recruitee.py +0 -0
  38. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/rippling.py +0 -0
  39. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/smartrecruiters.py +0 -0
  40. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/successfactors.js +0 -0
  41. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/successfactors.py +0 -0
  42. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/teamtailor.py +0 -0
  43. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/ukg.py +0 -0
  44. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/welcomekit.py +0 -0
  45. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/wellfound.py +0 -0
  46. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/werecruit.py +0 -0
  47. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/workable.py +0 -0
  48. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/workatastartup.py +0 -0
  49. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/wttj.py +0 -0
  50. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/adapters/zohorecruit.py +0 -0
  51. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/cli.py +0 -0
  52. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/core/__init__.py +0 -0
  53. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/core/accounts.py +0 -0
  54. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/core/attempts.py +0 -0
  55. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/core/browser.py +0 -0
  56. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/core/commands.py +0 -0
  57. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/core/conditions.py +0 -0
  58. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/core/countries.py +0 -0
  59. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/core/html.py +0 -0
  60. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/core/matching.py +0 -0
  61. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/core/otp.py +0 -0
  62. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/core/passwords.py +0 -0
  63. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/core/paths.py +0 -0
  64. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/core/registry.py +0 -0
  65. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/core/results.py +0 -0
  66. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/core/snapshot.py +0 -0
  67. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/core/telemetry.py +0 -0
  68. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/py.typed +0 -0
  69. {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev223}/src/atsfill/registry.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atsfill
3
- Version: 0.0.8.dev219
3
+ Version: 0.0.8.dev223
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.8.dev219"
3
+ version = "0.0.8.dev223"
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.8.dev219"
3
+ version = "0.0.8.dev223"
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"
@@ -49,6 +49,9 @@ NEXT = "#ja_sv_cw_next_footer_btn"
49
49
  PREVIOUS = "#ja_sv_cw_prev_footer_btn"
50
50
  SUBMIT = "#submitApplication"
51
51
  ATTEST = "input[name=self_att_agree_chk]"
52
+ SIGNATURE = "attestation.signature"
53
+ OPT_OUT = "resume.ai_opt_out"
54
+ LATE = {SIGNATURE: OPTION_TIMEOUT_MS, OPT_OUT: UPLOAD_TIMEOUT_MS} # boxes shown a moment after
52
55
  PERSON = "/jobApplication/applicant/person"
53
56
  PAY = "/jobApplication/requestedPayRate"
54
57
 
@@ -74,6 +77,7 @@ SHOWN_BY_COUNTRY = {"county"} # once a country with counties is picked
74
77
  ADDRESS_MAX = 30 # "Address Line 1 must not exceed 30 characters"
75
78
  FIXED = {"personal.email"} # the verified email, read only
76
79
  QUESTIONS = "#question_0, #questionYes_0, #desiredSalaryId, #refferalOptions, #commentTextArea"
80
+ NOT_FIELDS = re.compile(r"select-filter-input|react-select-.*") # a list's own inputs
77
81
 
78
82
  # Voluntary Self-ID: its lists and radio groups, each with the meta's indicator that shows it
79
83
  # and its code list; a radio's value is its code
@@ -113,6 +117,7 @@ class ADP:
113
117
  self._values: dict[str, Answer] = {}
114
118
  self._codes: dict[str, dict[str, str]] = {} # a radio's label by its value, by field id
115
119
  self._amounts: set[str] = set() # the page shows them formatted: `50,000.00`
120
+ self._shown: dict[str, None] = {} # the fields each step showed, in order
116
121
 
117
122
  def detect(self, url: str, page: Page | None) -> bool:
118
123
  return _posting(url) is not None
@@ -166,7 +171,7 @@ class ADP:
166
171
  asked = self._questions(control, transforms)
167
172
  self._steps = [
168
173
  Step("Personal Information", PERSONAL[0][1], self._personal(page, transforms)),
169
- Step("Resume", "input[type=file]", [self._resume()]),
174
+ Step("Resume", "input[type=file]", [self._resume(), self._opt_out()]),
170
175
  ] # fmt: skip
171
176
  # A wizard with nothing to ask has no Questions step; one that asks nothing the meta
172
177
  # lists has no box of its own: Next moves past it
@@ -176,25 +181,44 @@ class ADP:
176
181
  fields = self._self_id(transforms, indicators)
177
182
  self._steps.append(Step("Voluntary Self-ID", "#vsidWhy, #vsidEthRace", fields))
178
183
  self._steps.append(Step("Review Your Application", ""))
179
- self._steps.append(Step("Self-Attest & Submit", SUBMIT, [self._attestation(control)]))
184
+ attest = [self._attestation(control), self._signature()]
185
+ self._steps.append(Step("Self-Attest & Submit", SUBMIT, attest))
180
186
  return [f for s in self._steps for f in s.fields]
181
187
 
182
188
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
183
189
  """Answers each step and moves on with Next, up to the attestation, which it agrees to
184
190
  without Submit."""
185
- self._values = {}
191
+ self._values, self._shown = {}, {}
186
192
  answers = {f.id: f.prefilled for s in self._steps for f in s.fields
187
193
  if f.prefilled is not None} | answers # fmt: skip
188
194
  for step in self._steps:
189
195
  _wait_for_step(page, step)
196
+ before = {key for key, _ in page.evaluate(_CONTROLS)}
190
197
  for f in step.fields:
191
198
  if f.id in answers and f.id not in FIXED:
192
199
  self._set(page, f, answers[f.id])
200
+ self._shown |= dict.fromkeys(self._shown_on(page, step, before))
193
201
  self._values |= self._read(page, step)
194
202
  if step.marker == SUBMIT:
195
203
  return
196
204
  _next(page, step)
197
205
 
206
+ def shown_fields(self, page: Page) -> list[str]:
207
+ """The fields each step showed once answered: the wizard holds one step at a time."""
208
+ return list(self._shown)
209
+
210
+ def _shown_on(self, page: Page, step: Step, before: set[str]) -> list[str]:
211
+ """The open step's fields: those with an enabled box, then the controls its answers
212
+ added that no field has. What it showed before them is not all fields, such as a
213
+ preferred name's box, so a control counts only as an answer's."""
214
+ boxes = {name for box in self._boxes.values() for name in re.findall(r"[\w-]+", box)}
215
+ shown = [f.id for f in step.fields if _enabled(page.locator(self._boxes[f.id]).first)]
216
+ for key, dom in page.evaluate(_CONTROLS):
217
+ listed = key in boxes or dom in boxes or NOT_FIELDS.fullmatch(key)
218
+ if key and key not in before and not listed:
219
+ shown.append(key)
220
+ return shown
221
+
198
222
  def read_back(self, page: Page) -> dict[str, Answer]:
199
223
  """Each step's values as it was left."""
200
224
  return dict(self._values)
@@ -246,6 +270,17 @@ class ADP:
246
270
  self._boxes["resume"] = "input[type=file]"
247
271
  return Field("resume", "Resume", FieldType.FILE, True, accept=[".pdf", ".doc", ".docx"])
248
272
 
273
+ def _opt_out(self) -> Field:
274
+ """The opt-out an employer's uploaded résumé shows, in no meta: listed on every form,
275
+ its box left alone unless answered."""
276
+ self._boxes[OPT_OUT] = "input[name=candidateProfileRelevanceOptionDivId]"
277
+ label = (
278
+ "I want to opt out from having my resume reviewed by artificial intelligence as "
279
+ "part of the application process."
280
+ )
281
+ return Field(OPT_OUT, label, FieldType.CHOICE, False, YES_NO, default="No",
282
+ shown_when=Is("resume")) # fmt: skip
283
+
249
284
  def _questions(self, control: dict[str, Any], transforms: dict[str, Any]) -> list[Field]:
250
285
  """The questionnaire, each question its `#question_<n>` by position; then the desired
251
286
  pay and the comment where the meta does not hide them."""
@@ -356,8 +391,17 @@ class ADP:
356
391
  self._boxes["attestation"] = ATTEST
357
392
  return Field("attestation", label, FieldType.CHOICE, True, YES_NO)
358
393
 
394
+ def _signature(self) -> Field:
395
+ """The name agreeing shows beneath the box, in no meta; the page's own words."""
396
+ self._boxes[SIGNATURE] = "#electronicSignature"
397
+ return Field(SIGNATURE, "Please type your full name.", FieldType.TEXT, True,
398
+ shown_when=Is("attestation", ["Yes"])) # fmt: skip
399
+
359
400
  def _set(self, page: Page, f: Field, answer: Answer) -> None:
360
401
  box = page.locator(self._boxes[f.id]).first
402
+ if f.id in LATE:
403
+ with suppress(Timeout):
404
+ box.wait_for(state="attached", timeout=LATE[f.id])
361
405
  if not box.count():
362
406
  return # not shown: its condition is unmet
363
407
  if f.type is FieldType.FILE:
@@ -416,6 +460,10 @@ class ADP:
416
460
  return values
417
461
 
418
462
 
463
+ # The open step's controls: what names each, and its id
464
+ _CONTROLS = """() => [...document.querySelectorAll(
465
+ "input, textarea, select, sdf-select-simple, wfn-dropdown-list"
466
+ )].map(e => [e.name || e.id, e.id])"""
419
467
  # The meta lists the questionnaire of the posting its header names
420
468
  _FETCH = """async ([url, requisition]) => {
421
469
  const headers = {requisitionoid: requisition, 'x-requested-with': 'XMLHttpRequest'};
@@ -520,6 +568,11 @@ def _label(page: Page, selector: str) -> str:
520
568
  return ""
521
569
 
522
570
 
571
+ def _enabled(box: Locator) -> bool:
572
+ """A box the step shows and takes an answer in: race stays disabled until ethnicity allows."""
573
+ return box.count() > 0 and not box.evaluate("e => e.disabled === true")
574
+
575
+
523
576
  def _wait_for_step(page: Page, step: Step) -> None:
524
577
  if not step.marker:
525
578
  page.wait_for_timeout(SETTLE_MS)
@@ -95,6 +95,7 @@ class ADPMyJobs:
95
95
  self._codes: dict[str, dict[str, str]] = {} # by field id: an option's label by code
96
96
  self._values: dict[str, Answer] = {}
97
97
  self._fixed: set[str] = set() # disabled questions, their value shown as text
98
+ self._shown: dict[str, None] = {} # the fields each page showed, in order
98
99
 
99
100
  def detect(self, url: str, page: Page | None) -> bool:
100
101
  return _posting(url) is not None
@@ -202,7 +203,7 @@ class ADPMyJobs:
202
203
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
203
204
  """Answers each section page by page, saving each with Next, up to the last section's
204
205
  last page, or its review."""
205
- self._values = {}
206
+ self._values, self._shown = {}, {}
206
207
  answers = {f.id: f.prefilled for s in self._sections for f in s.fields
207
208
  if f.prefilled is not None} | answers # fmt: skip
208
209
  for section in self._sections:
@@ -210,9 +211,10 @@ class ADPMyJobs:
210
211
  _on_section(page, section)
211
212
  _to_page(page, section, ids)
212
213
  _add_entries(page, section, number)
213
- if unlisted := _unlisted(page, section):
214
- raise NotTaken(f"{section.title} shows {unlisted}, which the read did not list")
214
+ _all_listed(page, section)
215
215
  self._answer(page, section, answers)
216
+ _all_listed(page, section) # before a Next saves what the answers showed
217
+ self._shown |= dict.fromkeys(_shown_ids(page, section))
216
218
  self._values |= self._read(page, section)
217
219
  last = number == max(len(section.pages), 1) - 1
218
220
  if section is self._sections[-1] and last and not section.review:
@@ -221,6 +223,10 @@ class ADPMyJobs:
221
223
  if section.review and section is not self._sections[-1]:
222
224
  self._next(page, section) # the review's Next saves the section
223
225
 
226
+ def shown_fields(self, page: Page) -> list[str]:
227
+ """The fields each page showed once answered: the workflow keeps one page at a time."""
228
+ return list(self._shown)
229
+
224
230
  def read_back(self, page: Page) -> dict[str, Answer]:
225
231
  """Each section's values as it was left."""
226
232
  return dict(self._values)
@@ -654,13 +660,24 @@ _SHOWN = """es => es
654
660
  .map(e => [e.dataset.name, e.querySelector('label')?.innerText.trim()])"""
655
661
 
656
662
 
657
- def _unlisted(page: Page, section: Section) -> str:
658
- """The questions the open page shows that the read did not list, by label."""
663
+ def _all_listed(page: Page, section: Section) -> None:
664
+ """Stops on a question the open page shows that the read did not list, before its Next
665
+ saves the section for good."""
659
666
  if section.component.startswith("prescreen"):
660
- return ""
667
+ return
661
668
  listed = {f.id.removeprefix(f"{section.component}.") for f in section.fields}
662
669
  shown = page.locator("adp-form-group").evaluate_all(_SHOWN)
663
- return ", ".join(repr(label or name) for name, label in shown if name not in listed)
670
+ unlisted = ", ".join(repr(label or name) for name, label in shown if name not in listed)
671
+ if unlisted:
672
+ raise NotTaken(f"{section.title} shows {unlisted}, which the read did not list")
673
+
674
+
675
+ def _shown_ids(page: Page, section: Section) -> list[str]:
676
+ """The ids of the fields the open page shows; a prescreen's questions have no group."""
677
+ if section.component.startswith("prescreen"):
678
+ return [f.id for f in section.fields if _box(page, section, f).count()]
679
+ shown = page.locator("adp-form-group").evaluate_all(_SHOWN)
680
+ return [f"{section.component}.{name}" for name, _ in shown]
664
681
 
665
682
 
666
683
  def _on_section(page: Page, section: Section) -> None:
@@ -95,6 +95,9 @@ TYPES = {
95
95
  "file": FieldType.FILE,
96
96
  }
97
97
 
98
+ CUSTOM_CONTROL = re.compile(r"customQuestionAnswers\.[a-z_]+?_(\d+)(_other)?")
99
+ VETERAN_RADIOS = ("declineToAnswer", "notVeteran", "veteran")
100
+
98
101
  # The form's JSON answer and the state lists, fetched as the page fetches them.
99
102
  _FETCH = """async urls => {
100
103
  const out = [];
@@ -105,6 +108,12 @@ _FETCH = """async urls => {
105
108
  return out;
106
109
  }"""
107
110
 
111
+ # Each control of the form: what names it, and whether a person sees it.
112
+ _CONTROLS = """form => [...form.querySelectorAll("input, select, textarea")].map(e => ({
113
+ name: e.name, id: e.id, date: (e.placeholder ?? "").includes("yyyy"),
114
+ visible: e.offsetParent !== null,
115
+ }))"""
116
+
108
117
  # What each present control shows, by the field's kind.
109
118
  _READ_BACK = """(form, specs) => {
110
119
  const named = name => form.querySelector(`[name="${name}"]`);
@@ -151,6 +160,7 @@ class BambooHR:
151
160
  # each field's (kind, control name, extra): a radio's option ids by text, the
152
161
  # veteran kinds' texts by id, a date's nothing
153
162
  self._specs: dict[str, tuple[str, str | None, Any]] = {}
163
+ self._on_read: set[str] = set() # the controls' names as the form opened: its honeypot
154
164
 
155
165
  def detect(self, url: str, page: Page | None) -> bool:
156
166
  return _job(url) is not None
@@ -208,6 +218,7 @@ class BambooHR:
208
218
  self._specs = {
209
219
  f.id: spec for f, (_, spec) in zip(out, fields, strict=True) if not f.unfillable
210
220
  }
221
+ self._on_read = {control["name"] for control in form.evaluate(_CONTROLS)}
211
222
  return out
212
223
 
213
224
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
@@ -266,6 +277,17 @@ class BambooHR:
266
277
  continue # its question was not answered Other
267
278
  control.fill(value)
268
279
 
280
+ def shown_fields(self, page: Page) -> list[str]:
281
+ """The fields the form holds a control for, once filled; a visible control that came
282
+ after the read and no field names, by its name."""
283
+ shown: dict[str, None] = {}
284
+ for control in page.locator(FORM).evaluate(_CONTROLS):
285
+ if field_id := _field_of(control):
286
+ shown[field_id] = None
287
+ elif control["visible"] and control["name"] not in self._on_read:
288
+ shown[control["name"]] = None
289
+ return [i for i in shown if i != "dateAvailable" or i in self._specs]
290
+
269
291
  def read_back(self, page: Page) -> dict[str, Answer]:
270
292
  specs = {
271
293
  i: (kind, name, extra if kind == "veteran" else None)
@@ -307,6 +329,18 @@ class BambooHR:
307
329
  return f"{response.url} {response.status}"
308
330
 
309
331
 
332
+ def _field_of(control: dict[str, Any]) -> str | None:
333
+ """The field a control belongs to: by its name, the date by its placeholder and the veteran
334
+ radios, whose name React makes up, by their ids."""
335
+ if control["date"]:
336
+ return "dateAvailable"
337
+ if control["id"] in VETERAN_RADIOS:
338
+ return "veteranStatusId"
339
+ if custom := CUSTOM_CONTROL.fullmatch(control["name"]):
340
+ return f"question_{custom[1]}{custom[2] or ''}"
341
+ return next((k for k, (_, name) in STANDARD.items() if name == control["name"]), None)
342
+
343
+
310
344
  def _job(url: str) -> tuple[str, str] | None:
311
345
  parts = urlsplit(url)
312
346
  if not (parts.hostname and HOST.match(parts.hostname)) or not (m := JOB.match(parts.path)):
@@ -92,6 +92,9 @@ _DOM_IDS = """keys => {
92
92
  return out;
93
93
  }"""
94
94
 
95
+ # The element ids of the controls on the page.
96
+ _SHOWN = """() => [...document.querySelectorAll('[id$="_error"]')].map(e => e.id.slice(0, -6))"""
97
+
95
98
  _READ_BACK = """controls => {
96
99
  const out = {};
97
100
  for (const [qid, dom, kind] of controls) {
@@ -150,6 +153,7 @@ class Eightfold:
150
153
  def __init__(self) -> None:
151
154
  self._controls: list[_Control] = [] # in the form's order
152
155
  self._fields: list[Field] = []
156
+ self._ends: dict[str, str] = {} # each question's id, by what its element id ends with
153
157
 
154
158
  def detect(self, url: str, page: Page | None) -> bool:
155
159
  if _pid(url) is None:
@@ -216,6 +220,7 @@ class Eightfold:
216
220
  if control.field.id in held and control.field.prefilled is None:
217
221
  control.field = replace(control.field, default=held[control.field.id])
218
222
  self._fields = [c.field for c in self._controls]
223
+ self._ends = {end: c.field.id for c in self._controls for end in c.ends}
219
224
  self._controls = [c for c in self._controls if not c.field.unfillable]
220
225
  return self._fields
221
226
 
@@ -237,6 +242,15 @@ class Eightfold:
237
242
  _fill(page, control, answer)
238
243
  given[control.field.id] = answer
239
244
 
245
+ def shown_fields(self, page: Page) -> list[str]:
246
+ """The questions rendered once filled, each control's by the longest question id its
247
+ element id ends with; one no question's id ends as the page names it."""
248
+ shown: dict[str, None] = {}
249
+ for dom in page.evaluate(_SHOWN):
250
+ ends = [end for end in self._ends if dom == end or dom.endswith(f"_{end}")]
251
+ shown[self._ends[max(ends, key=len)] if ends else dom] = None
252
+ return list(shown)
253
+
240
254
  def read_back(self, page: Page) -> dict[str, Answer]:
241
255
  unplaced = [[c.field.id, c.ends] for c in self._controls if c.dom is None]
242
256
  doms = page.evaluate(_DOM_IDS, unplaced) if unplaced else {}
@@ -9,12 +9,13 @@ information; the questions are saved by submit, for good.
9
9
 
10
10
  import re
11
11
  import time
12
+ from collections.abc import Callable
12
13
  from typing import Any
13
14
  from urllib.parse import urlsplit
14
15
 
15
16
  from atsfill.core import browser
16
17
  from atsfill.core.adapter import Blocked, Gap, NotTaken, Unsupported, known_gap
17
- from atsfill.core.browser import Locator, Page, Request, Response, Timeout
18
+ from atsfill.core.browser import BrowserError, Locator, Page, Request, Response, Timeout
18
19
  from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
19
20
 
20
21
  HOST = re.compile(r"^([a-z0-9-]+)\.gupy\.io$")
@@ -32,9 +33,26 @@ RESUME_WAIT_S = 15
32
33
  SAVE_TIMEOUT_MS = 30_000
33
34
  SIGN_IN_TIMEOUT_MS = 30_000
34
35
  FORMS_TIMEOUT_S = 10
36
+ CLICK_ATTEMPTS = 3
35
37
  YES_NO = ["Yes", "No"]
38
+ # What a Yes on an Additional information radio shows, the same on every posting, in no source a
39
+ # read can see; the page will not save it empty.
40
+ FOLLOW_UPS = {
41
+ "radioGroupIsIndicatedTitle": {
42
+ "guiddedApplicationAdditionalDataIndicatedByNameInput": (
43
+ "Name of the person who recommended you"
44
+ ),
45
+ "guiddedApplicationAdditionalDataIndicatedByEmailInput": (
46
+ "E-mail of the person who has referred you"
47
+ ),
48
+ },
49
+ "radioGroupIsCompanyEmployeeTitle": {
50
+ "guiddedApplicationAdditionalDataCorporateEmailInput": "Provide your corporate email",
51
+ },
52
+ }
36
53
  TYPES = {
37
54
  "SELECT": FieldType.CHOICE,
55
+ "CONDITIONAL": FieldType.CHOICE, # a SELECT with questions shown by its answer
38
56
  "MULTI_SELECT": FieldType.MULTI_CHOICE,
39
57
  "TEXT": FieldType.TEXT,
40
58
  "TEXT_AREA": FieldType.TEXT,
@@ -71,6 +89,11 @@ _READ_QUESTIONS = """(ids) => Object.fromEntries(ids.map(([id, title]) => {
71
89
  return [`question-${id}`, text?.value ?? null];
72
90
  }))"""
73
91
 
92
+ # The questions page's controls: `question-<id>` radios, `checkbox-<id>-<n>` boxes, a text named by
93
+ # its question's title.
94
+ _SHOWN_QUESTIONS = f"""() => [...document.querySelectorAll({QUESTIONS!r})]
95
+ .map(e => e.name || e.id)"""
96
+
74
97
 
75
98
  class Gupy:
76
99
  name = "gupy"
@@ -86,6 +109,8 @@ class Gupy:
86
109
  self._questions: dict[str, dict[str, Any]] = {}
87
110
  self._fields: dict[str, Field] = {}
88
111
  self._additional: dict[str, Answer] = {}
112
+ self._additional_shown: list[str] = [] # saved before the questions open
113
+ self._given: set[str] = set()
89
114
 
90
115
  def detect(self, url: str, page: Page | None) -> bool:
91
116
  parts = urlsplit(url)
@@ -145,7 +170,7 @@ class Gupy:
145
170
  page.wait_for_timeout(STEP_POLL_MS)
146
171
  finally:
147
172
  page.remove_listener("response", on_response)
148
- fields = [_additional(raw) for raw in page.evaluate(_READ_ADDITIONAL)]
173
+ fields = _additional_fields(page.evaluate(_READ_ADDITIONAL))
149
174
  questions = (forms[-1].json().get("questionForm") or {}).get("questions") if forms else []
150
175
  self._questions = {f"question-{q['questionId']}": q for q in questions or []}
151
176
  fields += [_question(q) for q in self._questions.values()]
@@ -167,7 +192,7 @@ class Gupy:
167
192
  continue
168
193
  if button.is_enabled():
169
194
  disabled_since = None
170
- browser.click(button)
195
+ _click(lambda found=button: found)
171
196
  # Continue Application is disabled while the resume loads, for good when the
172
197
  # resume is incomplete
173
198
  elif (disabled_since := disabled_since or time.monotonic()) < (
@@ -182,22 +207,44 @@ class Gupy:
182
207
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
183
208
  """Fills Additional information and, where the posting has questions, saves it and
184
209
  opens them; the questions are filled and left unsaved for submit."""
210
+ self._given = set(answers)
185
211
  for field_id, answer in answers.items():
186
212
  if field_id not in self._questions:
187
213
  _set(page, self._fields[field_id], field_id, answer)
188
214
  self._additional = self._read_additional(page)
215
+ self._additional_shown = [raw["id"] for raw in page.evaluate(_READ_ADDITIONAL)]
189
216
  if not self._questions:
190
217
  return
191
218
  if page.locator(ADDITIONAL).count():
192
219
  browser.click(page.locator("button:has-text('Save and continue')").first)
193
220
  answer_now = page.locator(ANSWER_NOW).first
194
- answer_now.wait_for(timeout=SAVE_TIMEOUT_MS)
221
+ try:
222
+ answer_now.wait_for(timeout=SAVE_TIMEOUT_MS)
223
+ except Timeout:
224
+ if page.locator(ADDITIONAL).count():
225
+ raise NotTaken("the page would not save Additional information") from None
226
+ raise
195
227
  browser.click(answer_now)
196
228
  page.locator(QUESTIONS).first.wait_for(timeout=SAVE_TIMEOUT_MS)
197
229
  for field_id, answer in answers.items():
198
230
  if (question := self._questions.get(field_id)) is not None:
199
231
  _answer(page, self._fields[field_id], question, answer)
200
232
 
233
+ def shown_fields(self, page: Page) -> list[str]:
234
+ """Additional information as it was saved, and the questions the page renders: a text
235
+ is known only by its title, which questions can share."""
236
+ by_title: dict[str, list[str]] = {}
237
+ for field_id, question in self._questions.items():
238
+ by_title.setdefault(question["title"], []).append(field_id)
239
+ for ids in by_title.values():
240
+ ids.sort(key=lambda i: i not in self._given)
241
+ shown = list(self._additional_shown)
242
+ for control in page.evaluate(_SHOWN_QUESTIONS) if page.locator(QUESTIONS).count() else []:
243
+ ids = by_title.get(control.removeprefix("input-"))
244
+ field_id = ids.pop(0) if ids else _question_id(control)
245
+ shown += [field_id] if field_id not in shown else []
246
+ return shown
247
+
201
248
  def _read_additional(self, page: Page) -> dict[str, Answer]:
202
249
  values = {raw["id"]: raw["value"] for raw in page.evaluate(_READ_ADDITIONAL)}
203
250
  return {k: v for k, v in values.items() if k in self._fields and v}
@@ -222,6 +269,23 @@ class Gupy:
222
269
  raise RuntimeError(f"{response.request.method} {response.url} answered {response.status}")
223
270
 
224
271
 
272
+ def _additional_fields(raws: list[dict[str, Any]]) -> list[Field]:
273
+ """Additional information as the page has it, each radio before what its Yes shows."""
274
+ values = {raw["id"]: raw["value"] for raw in raws}
275
+ shown_by = {name for names in FOLLOW_UPS.values() for name in names}
276
+ fields = []
277
+ for raw in raws:
278
+ if raw["id"] in shown_by:
279
+ continue
280
+ fields.append(_additional(raw))
281
+ for name, label in FOLLOW_UPS.get(raw["id"], {}).items():
282
+ yes = Is(raw["id"], ["Yes"])
283
+ fields.append(
284
+ Field(name, label, FieldType.TEXT, True, prefilled=values.get(name), shown_when=yes)
285
+ )
286
+ return fields
287
+
288
+
225
289
  def _additional(raw: dict[str, Any]) -> Field:
226
290
  """A radio group is a choice; the source list, a combobox, is not filled."""
227
291
  name, label = raw["id"], raw["label"]
@@ -243,7 +307,8 @@ def _question(q: dict[str, Any]) -> Field:
243
307
  shown = Is(f"question-{q['parentQuestionId']}", q.get("conditionalTriggerOptions") or None)
244
308
  if kind is None:
245
309
  reason = f"unhandled_rendering: {q['title']!r} is a {q['type']} question"
246
- return Field(field_id, q["title"], FieldType.TEXT, q["required"], unfillable=reason)
310
+ return Field(field_id, q["title"], FieldType.TEXT, q["required"], unfillable=reason,
311
+ shown_when=shown) # fmt: skip
247
312
  return Field(
248
313
  field_id, q["title"], kind, bool(q["required"]),
249
314
  options if kind is not FieldType.TEXT else None, prefilled=q.get("answer") or None,
@@ -251,6 +316,17 @@ def _question(q: dict[str, Any]) -> Field:
251
316
  ) # fmt: skip
252
317
 
253
318
 
319
+ def _click(find: Callable[[], Locator]) -> None:
320
+ """Clicks what `find` finds: the page renders again as it loads, detaching what it found."""
321
+ for _ in range(CLICK_ATTEMPTS - 1):
322
+ try:
323
+ return browser.click(find())
324
+ except BrowserError as e:
325
+ if "not attached" not in str(e):
326
+ raise
327
+ browser.click(find())
328
+
329
+
254
330
  def _label(page: Page, selector: str, text: str) -> Locator:
255
331
  """The label, wrapping it, of the input `selector` matches whose text is `text`."""
256
332
  inputs = page.locator(selector)
@@ -263,7 +339,7 @@ def _label(page: Page, selector: str, text: str) -> Locator:
263
339
  def _set(page: Page, field: Field, field_id: str, answer: Answer) -> None:
264
340
  match field.type, answer:
265
341
  case FieldType.CHOICE, str():
266
- browser.click(_label(page, f'main input[name="{field_id}"]', answer))
342
+ _click(lambda: _label(page, f'main input[name="{field_id}"]', answer))
267
343
  case FieldType.TEXT, str():
268
344
  page.locator(f'main input[name="{field_id}"], main input[id="{field_id}"]').fill(answer)
269
345
  case kind, _:
@@ -290,5 +366,11 @@ def _answer(page: Page, field: Field, question: dict[str, Any], answer: Answer)
290
366
  raise Unsupported(f"unhandled_rendering: cannot fill question-{qid}, a {kind}")
291
367
 
292
368
 
369
+ def _question_id(control: str) -> str:
370
+ """The question of a radio or a checkbox's name; anything else as the page names it."""
371
+ box = re.fullmatch(r"checkbox-(\d+)-\d+", control)
372
+ return f"question-{box[1]}" if box else control
373
+
374
+
293
375
  def _css(text: str) -> str:
294
376
  return text.replace("\\", "\\\\").replace('"', '\\"')
@@ -95,6 +95,7 @@ _FIELDS = """form => [...form.querySelectorAll('.form-field')].flatMap(f => {
95
95
 
96
96
  class HRMDirect:
97
97
  name = "hrmdirect"
98
+ lists_every_field = "the fill stops on a field the read did not list, page by page"
98
99
  per_employer = True
99
100
  asks = {"name"}
100
101
  known_gaps = {"screening_first": Gap.FLOW}
@@ -109,6 +110,7 @@ class HRMDirect:
109
110
  def __init__(self) -> None:
110
111
  self._jobs: dict[str, str] = {} # by org, the last posting's job
111
112
  self._fields: dict[str, Field] = {}
113
+ self._walked: set[str] = set() # the pages the read reached
112
114
  self._kinds: dict[str, str] = {}
113
115
  self._values: dict[str, Answer] = {}
114
116
  self._apply = ""
@@ -225,8 +227,9 @@ class HRMDirect:
225
227
  """Walks the wizard from its first page as far as its pages save with what they hold,
226
228
  reading each page it reaches."""
227
229
  self._open(page)
228
- self._fields = {}
230
+ self._fields, self._walked = {}, set()
229
231
  for _ in range(MAX_PAGES):
232
+ self._walked.add(_active(page))
230
233
  for f in self._page(page):
231
234
  self._fields.setdefault(f.id, f)
232
235
  if not self._continue(page):
@@ -234,8 +237,9 @@ class HRMDirect:
234
237
  return list(self._fields.values())
235
238
 
236
239
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
237
- """Fills each page from the first and saves it, up to the last; a page showing a
238
- required field the read did not list is refused, the pages before it saved."""
240
+ """Fills each page from the first and saves it, up to the last; a page that shows a
241
+ field the read did not list, or does not save, stops the fill, the pages before it
242
+ saved."""
239
243
  self._open(page)
240
244
  self._values = {}
241
245
  for _ in range(MAX_PAGES):
@@ -250,12 +254,25 @@ class HRMDirect:
250
254
  control = page.locator(PAGE_FORM).locator(f"[id='{f.id}']")
251
255
  tag = control.evaluate("el => el.tagName") if control.count() else ""
252
256
  _fill(page, f, "select" if tag == "SELECT" else "text", answer)
253
- unread = [f.id for f in shown if f.required and f.id not in self._fields]
254
- if unread:
255
- raise NotTaken(f"the page shows fields the read did not list: {unread}; read again")
257
+ self._unread(page)
256
258
  self._values |= self._read(page)
257
- if _last(page) or not self._continue(page):
259
+ if _last(page):
258
260
  return
261
+ step = _active(page)
262
+ if not self._continue(page):
263
+ raise NotTaken(f"{step} was not saved")
264
+
265
+ def _unread(self, page: Page) -> None:
266
+ """Stops on fields the read did not list, optional ones too, once the answers are in. A
267
+ page the read did not reach is read once the pages before it are saved. On one it read,
268
+ an answer opened the field: the page stops unsaved, so no read would list it."""
269
+ unread = [f for f in self._page(page) if f.id not in self._fields]
270
+ if not unread:
271
+ return
272
+ step, labels = _active(page), ", ".join(f.label for f in unread)
273
+ if step in self._walked:
274
+ raise Unsupported(f"unhandled_rendering: an answer showed {labels}, not listed")
275
+ raise NotTaken(f"{step} shows fields the read did not list: {labels}; read again")
259
276
 
260
277
  def read_back(self, page: Page) -> dict[str, Answer]:
261
278
  return self._values | self._read(page)
@@ -139,6 +139,10 @@ _READ = """html => {
139
139
  } else field.kind = "text";
140
140
  out.push(field);
141
141
  }
142
+ if (html.includes("applicantDisabledData") && !seen.has("disabled_employee_id")) {
143
+ out.push({key: "disabled_employee_id", kind: "scripted", label: "Employee ID (if applicable)",
144
+ required: false, options: [], value: "", disabled: false});
145
+ }
142
146
  const app = root.querySelector("input[name=applications_id]")?.value;
143
147
  return {fields: out, app};
144
148
  }"""
@@ -190,6 +194,7 @@ class IsolvedHire:
190
194
  self._steps: list[str] = []
191
195
  self._fields: dict[str, Field] = {}
192
196
  self._raws: dict[str, dict[str, Any]] = {} # by field id, as the read found it
197
+ self._shown: dict[str, None] = {} # the fields each step showed, in order
193
198
 
194
199
  def detect(self, url: str, page: Page | None) -> bool:
195
200
  return _job(url) is not None
@@ -284,14 +289,20 @@ class IsolvedHire:
284
289
  """Confirms the account's contact details, then walks the steps in order: each answered
285
290
  and saved with Proceed to Next Step, which goes no further while one is invalid."""
286
291
  _confirm_contact(page)
292
+ self._shown = {}
287
293
  for step in self._steps:
288
294
  _go(page, step)
289
295
  for field_id, field in self._fields.items():
290
296
  if field_id.startswith(f"{step}.") and field_id in answers:
291
297
  self._answer(page, field, answers[field_id])
292
298
  _settle(page)
299
+ self._shown |= dict.fromkeys(_shown(page, step))
293
300
  _next(page, step)
294
301
 
302
+ def shown_fields(self, page: Page) -> list[str]:
303
+ """The fields each step showed once answered: the wizard holds one step at a time."""
304
+ return list(self._shown)
305
+
295
306
  def read_back(self, page: Page) -> dict[str, Answer]:
296
307
  """Each step as the system saved it, fetched again."""
297
308
  values: dict[str, Answer] = {}
@@ -476,6 +487,15 @@ def _read_step(page: Page, step: str) -> tuple[list[dict[str, Any]], str | None]
476
487
  return raws, read["app"]
477
488
 
478
489
 
490
+ def _shown(page: Page, step: str) -> list[str]:
491
+ """The ids of the open step's fields, read as `_read_step` reads them; a disabled and empty
492
+ control takes no answer, `source_other` until a source asks for it."""
493
+ if step in ENTRY_LISTS:
494
+ return [f"{step}.entries"]
495
+ raws = page.evaluate(_READ, page.locator("#landingStrip").inner_html())["fields"]
496
+ return [f"{step}.{raw['key']}" for raw in raws if raw["value"] or not raw["disabled"]]
497
+
498
+
479
499
  def _texts(options: Locator) -> list[str]:
480
500
  return [" ".join(t.split()) for t in options.all_text_contents()]
481
501
 
@@ -116,6 +116,7 @@ class _Control:
116
116
 
117
117
  class Recruiterflow:
118
118
  name = "recruiterflow"
119
+ lists_every_field = "the page's JSON holds every field whatever the answers"
119
120
  known_gaps: dict[str, Gap] = {}
120
121
  filters = ["recruiterflow.com##.rf-careers-cookie-banner-container"] # over the questions
121
122
  side_effects = [
@@ -208,6 +208,7 @@ def posting(url: str) -> Posting | None:
208
208
 
209
209
  class Taleo:
210
210
  name = "taleo"
211
+ lists_every_field = "the fill stops on a field the read did not list, page by page"
211
212
  per_employer = True
212
213
  asks: set[str] = set()
213
214
  known_gaps = {LOCATION_GAP: Gap.RENDERING, DATE_PICKER_GAP: Gap.RENDERING}
@@ -220,7 +221,8 @@ class Taleo:
220
221
 
221
222
  def __init__(self) -> None:
222
223
  self._postings: dict[str, Posting] = {} # by zone, the last posting's
223
- self._kinds: dict[str, str] = {} # each read field's rendering, by its id
224
+ self._listed: set[str] = set() # the ids the read listed
225
+ self._walked: set[str] = set() # the pages the read reached
224
226
  self._read_back: dict[str, Answer] = {}
225
227
 
226
228
  def detect(self, url: str, page: Page | None) -> bool:
@@ -289,23 +291,24 @@ class Taleo:
289
291
  fields listed. Reading saves pages, as a fill does."""
290
292
  self._open(page)
291
293
  fields: list[Field] = []
292
- self._kinds = {}
294
+ self._walked = set()
293
295
  while not _on_review(page):
294
296
  step = _step(page)
297
+ self._walked.add(step)
295
298
  if step not in SKIPPED_STEPS:
296
299
  for raw in _read(page):
297
- field = _field(step, raw)
298
- self._kinds[field.id] = raw["kind"]
299
- fields.append(field)
300
+ fields.append(_field(step, raw))
300
301
  if raw["id"].endswith(SOURCE_TYPE):
301
302
  fields.append(_sources(page, step, raw))
302
303
  if not _save(page):
303
304
  break
305
+ self._listed = {f.id for f in fields}
304
306
  return fields
305
307
 
306
308
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
307
309
  """Fills each page from the first and saves it, until Review and Submit. A page that
308
- does not save stops the fill with its messages."""
310
+ shows a field the read did not list stops the fill unsaved; one that does not save, with
311
+ its messages."""
309
312
  self._open(page)
310
313
  self._read_back = {}
311
314
  while not _on_review(page):
@@ -318,14 +321,26 @@ class Taleo:
318
321
  if field_id in answers and field_id not in filled:
319
322
  _fill(page, raw, answers[field_id])
320
323
  filled.add(field_id)
321
- for raw in _read(page) if raws else []:
324
+ answered = _read(page) if raws else []
325
+ self._unread(step, answered)
326
+ for raw in answered:
322
327
  value = _value(raw)
323
328
  if value not in (None, "", []):
324
329
  self._read_back[_field_id(step, raw)] = value
325
330
  if not _save(page):
326
- unread = any(_field_id(step, r) not in self._kinds for r in raws)
327
- errors = "; ".join(_errors(page))
328
- raise NotTaken(f"{step} was not saved: {errors}" + ("; read again" * unread))
331
+ raise NotTaken(f"{step} was not saved: {'; '.join(_errors(page))}")
332
+
333
+ def _unread(self, step: str, raws: list[dict[str, Any]]) -> None:
334
+ """Stops on fields the read did not list, optional ones too, once the answers are in. A
335
+ page the read did not reach is read once the pages before it are saved. On one it read,
336
+ an answer opened the field: the page stops unsaved, so no read would list it."""
337
+ unread = [r["label"] for r in raws if _field_id(step, r) not in self._listed]
338
+ if not unread:
339
+ return
340
+ labels = ", ".join(unread)
341
+ if step in self._walked:
342
+ raise Unsupported(f"unhandled_rendering: an answer showed {labels}, not listed")
343
+ raise NotTaken(f"{step} shows fields the read did not list: {labels}; read again")
329
344
 
330
345
  def read_back(self, page: Page) -> dict[str, Answer]:
331
346
  return dict(self._read_back)
@@ -194,6 +194,7 @@ def _profile_hosts(request: Request, scope: str | None) -> list[str]:
194
194
 
195
195
  class Workday:
196
196
  name = "workday"
197
+ lists_every_field = "the fill stops on a field the read did not list, page by page"
197
198
  per_employer = True
198
199
  asks: set[str] = set()
199
200
  known_gaps = {
@@ -213,6 +214,9 @@ class Workday:
213
214
  def __init__(self) -> None:
214
215
  self._sites: dict[str, Site] = {} # by tenant, the last posting's
215
216
  self._fields: dict[str, Field] = {}
217
+ self._listed: set[str] = set() # the ids the read listed, unfillable ones too
218
+ self._walked: set[str] = set() # the pages the read reached
219
+ self._walled = False # no read gets past the page this one stopped on
216
220
  self._values: dict[str, Answer] = {}
217
221
  self._apply = ""
218
222
 
@@ -263,9 +267,9 @@ class Workday:
263
267
 
264
268
  def browser_read(self, page: Page) -> list[Field]:
265
269
  """Walks the wizard as far as its pages save with what they hold, reading each page
266
- it reaches: from the page, else My Experience from the apply flow's sections and the
267
- questions from the questionnaires, which also stand for those pages when they are not
268
- reached. A page after the first that does not save is read once it does."""
270
+ it reaches: from the page, My Experience from the apply flow's sections and then the
271
+ page, and the questions from the questionnaires, which also stand for those pages when
272
+ they are not reached. A page after the first that does not save is read once it does."""
269
273
  found = site(page.url)
270
274
  if found is None or not (job := JOB.match(found.rest)):
271
275
  raise RuntimeError(f"{page.url} is not a Workday posting")
@@ -289,15 +293,19 @@ class Workday:
289
293
  page.goto(self._apply)
290
294
  _wait_for_page(page)
291
295
  seen: dict[str, list[Field]] = {}
296
+ self._walked, self._walled = set(), False
292
297
  for _ in range(MAX_PAGES):
293
298
  step = _step(page)
294
299
  if step == REVIEW: # repeats the answers, an upload without its input
295
300
  break
296
- if not (_questions_page(step) or step == EXPERIENCE):
297
- seen[step] = _read(page, f"{calypso}/jobapplication/{found.tenant}{PHONE_CODES}")
301
+ self._walked.add(step)
302
+ if not _questions_page(step):
303
+ phone_codes = f"{calypso}/jobapplication/{found.tenant}{PHONE_CODES}"
304
+ seen[step] = _read(page, phone_codes, {f.id for f in from_api.get(step, [])})
298
305
  try:
299
306
  _save_and_continue(page)
300
307
  except NotTaken:
308
+ self._walled = _only_a_tick_missing(_page_fields(page))
301
309
  break
302
310
  steps = [p["descriptor"] for p in flow.get("data") or []]
303
311
  steps += [s for s in seen if s not in steps]
@@ -305,6 +313,7 @@ class Workday:
305
313
  f for s in steps for f in seen.get(s, []) + from_api.get(_questionnaire_step(s), [])
306
314
  ]
307
315
  self._fields = {f.id: f for f in fields if not f.unfillable}
316
+ self._listed = {f.id for f in fields}
308
317
  return fields
309
318
 
310
319
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
@@ -342,19 +351,22 @@ class Workday:
342
351
  raise Unsupported(known_gap(self, "long_wizard", f"more than {MAX_PAGES} pages"))
343
352
 
344
353
  def _unread(self, page: Page) -> None:
345
- """Stops on required fields the read did not list: on My Information, the address of a
346
- country other than the form's, or a follow-up an answer opened (a referral's name); on a
347
- later page, one the read could not reach."""
348
- unread = [r for r in _page_fields(page) if r["required"] and r["id"] not in self._fields]
354
+ """Stops on fields the read did not list, optional ones too. A page the read did not
355
+ reach is read once the pages before it are saved, unless no read can get there. On one
356
+ it read, an answer opened the field (a country's address, a referral's name): the page
357
+ stops unsaved, so no read would list it."""
358
+ unread = _not_listed(_page_fields(page), self._listed)
349
359
  if not unread:
350
360
  return
351
361
  step, labels = _step(page), ", ".join(r["label"] for r in unread)
362
+ if step not in self._walked and self._walled:
363
+ raise Unsupported(f"unhandled_rendering: {step} shows {labels}, which no read reaches")
364
+ if step not in self._walked:
365
+ raise NotTaken(f"{step} shows fields the read did not: {labels}; read again")
352
366
  if step == MY_INFORMATION and any(r["id"].startswith(ADDRESS) for r in unread):
353
367
  detail = f"the country answered shows fields the read did not: {labels}"
354
368
  raise Unsupported(known_gap(self, "other_country", detail))
355
- if step == MY_INFORMATION:
356
- raise Unsupported(f"unhandled_rendering: an answer showed {labels}, not listed")
357
- raise NotTaken(f"{step} shows fields the read did not: {labels}; read again")
369
+ raise Unsupported(f"unhandled_rendering: an answer showed {labels}, not listed")
358
370
 
359
371
  def read_back(self, page: Page) -> dict[str, Answer]:
360
372
  """Each page's values, read before it was saved; one file as its name."""
@@ -472,10 +484,23 @@ def _questionnaire_step(step: str) -> str:
472
484
  return re.sub(r" 1 of \d+$", "", step)
473
485
 
474
486
 
475
- def _read(page: Page, phone_codes: str) -> list[Field]:
476
- """The fields the page shows, a dropdown's or a short prompt's options read by opening it,
477
- the country phone codes from `phone_codes`, the API's list."""
478
- raws = _page_fields(page)
487
+ def _not_listed(raws: list[dict[str, Any]], listed: set[str]) -> list[dict[str, Any]]:
488
+ """The fields not in `listed`, but for a later entry of a list: the draft's, numbered anew
489
+ by each load, and no answer's."""
490
+ return [r for r in raws if r["id"] not in listed and not ENTRY_ID.match(r["id"])]
491
+
492
+
493
+ def _only_a_tick_missing(raws: list[dict[str, Any]]) -> bool:
494
+ """Whether a page holds all it requires but a box: the draft keeps a page's answers but not
495
+ its consent's tick, so no read saves that page, whatever the fills before it saved."""
496
+ empty = [r for r in raws if r["required"] and r["value"] in (None, "", [], "No")]
497
+ return bool(empty) and all(r["kind"] == "box" for r in empty)
498
+
499
+
500
+ def _read(page: Page, phone_codes: str, listed: set[str]) -> list[Field]:
501
+ """The fields the page shows that are not in `listed`, a dropdown's or a short prompt's
502
+ options read by opening it, the country phone codes from `phone_codes`, the API's list."""
503
+ raws = _not_listed(_page_fields(page), listed)
479
504
  for raw in raws:
480
505
  box = page.locator(f'[data-fkit-id="{raw["dom"]}"]')
481
506
  if raw["kind"] == "list":
@@ -325,3 +325,11 @@ class ListsShownFields(Adapter, Protocol):
325
325
  def shown_fields(self, page: Page) -> list[str]:
326
326
  """The ids of the fields the filled page shows, each as `browser_read` would name it."""
327
327
  ...
328
+
329
+
330
+ @runtime_checkable
331
+ class ListsEveryField(Adapter, Protocol):
332
+ """An adapter that is neither `ListsShownFields` nor `RevealsFields`: it says why no field
333
+ can appear after a read, or how it checks for one itself (ADR 0005)."""
334
+
335
+ lists_every_field: str
File without changes
File without changes