atsfill 0.0.7.dev212__tar.gz → 0.0.8.dev221__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.7.dev212 → atsfill-0.0.8.dev221}/PKG-INFO +1 -1
  2. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/pyproject.toml +1 -1
  3. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/pyproject.toml.orig +1 -1
  4. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/adp.py +56 -3
  5. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/adpmyjobs.py +24 -7
  6. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/bamboohr.py +34 -0
  7. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/breezy.py +5 -0
  8. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/eightfold.py +14 -0
  9. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/greenhouse.py +42 -3
  10. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/gupy.py +88 -6
  11. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/hiringthing.py +20 -2
  12. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/hrmdirect.py +23 -7
  13. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/isolvedhire.py +20 -0
  14. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/jazzhr.py +3 -0
  15. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/jobaffinity.py +19 -0
  16. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/jobstreet.py +14 -0
  17. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/join.py +8 -0
  18. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/lever.py +19 -6
  19. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/manatal.py +20 -0
  20. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/oracle.py +73 -9
  21. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/paycor.py +4 -0
  22. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/personio.py +4 -0
  23. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/recruitee.py +4 -0
  24. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/rippling.py +33 -4
  25. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/successfactors.py +3 -0
  26. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/taleo.py +24 -10
  27. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/ukg.py +28 -5
  28. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/welcomekit.py +5 -0
  29. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/wellfound.py +16 -0
  30. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/werecruit.py +3 -0
  31. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/workable.py +3 -0
  32. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/workatastartup.py +18 -0
  33. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/workday.py +49 -18
  34. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/wttj.py +7 -2
  35. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/LICENSE +0 -0
  36. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/README.md +0 -0
  37. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/__init__.py +0 -0
  38. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/__init__.py +0 -0
  39. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/ashby.py +0 -0
  40. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/dayforce.py +0 -0
  41. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/hireology.py +0 -0
  42. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/paylocity.py +0 -0
  43. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/phenom.py +0 -0
  44. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/pinpoint.py +0 -0
  45. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/recruiterflow.py +0 -0
  46. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/smartrecruiters.py +0 -0
  47. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/successfactors.js +0 -0
  48. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/teamtailor.py +0 -0
  49. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/zohorecruit.py +0 -0
  50. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/cli.py +0 -0
  51. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/__init__.py +0 -0
  52. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/accounts.py +0 -0
  53. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/adapter.py +0 -0
  54. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/attempts.py +0 -0
  55. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/browser.py +0 -0
  56. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/commands.py +0 -0
  57. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/conditions.py +0 -0
  58. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/countries.py +0 -0
  59. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/html.py +0 -0
  60. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/matching.py +0 -0
  61. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/otp.py +0 -0
  62. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/passwords.py +0 -0
  63. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/paths.py +0 -0
  64. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/registry.py +0 -0
  65. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/results.py +0 -0
  66. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/snapshot.py +0 -0
  67. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/telemetry.py +0 -0
  68. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/py.typed +0 -0
  69. {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/registry.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atsfill
3
- Version: 0.0.7.dev212
3
+ Version: 0.0.8.dev221
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.7.dev212"
3
+ version = "0.0.8.dev221"
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.7.dev212"
3
+ version = "0.0.8.dev221"
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)):
@@ -328,6 +328,11 @@ class Breezy:
328
328
  case _:
329
329
  controls.first.fill(values[0])
330
330
 
331
+ def shown_fields(self, page: Page) -> list[str]:
332
+ """The controls `browser_read` names; a work history or education entry's have no name.
333
+ Every step stays in the page, the others hidden."""
334
+ return [raw["id"] for raw in page.locator(FORM).evaluate(_READ)]
335
+
331
336
  def read_back(self, page: Page) -> dict[str, Answer]:
332
337
  form = page.locator(FORM)
333
338
  out: dict[str, Answer] = {}
@@ -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 {}
@@ -11,13 +11,14 @@ import re
11
11
  import time
12
12
  import urllib.request
13
13
  from contextlib import suppress
14
+ from dataclasses import replace
14
15
  from typing import Any
15
16
  from urllib.parse import parse_qs, urlencode, urlsplit
16
17
 
17
18
  from atsfill.core import browser, otp
18
19
  from atsfill.core.adapter import Blocked, Gap, Unsupported
19
20
  from atsfill.core.browser import HTTP_TIMEOUT_S, BrowserError, Page, Timeout
20
- from atsfill.core.results import Answer, Field, FieldType, SideEffect
21
+ from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
21
22
 
22
23
  HOSTS = re.compile(r"^(job-boards|boards)(\.eu)?\.greenhouse\.io$")
23
24
  FORM = "#application-form"
@@ -29,6 +30,7 @@ EDUCATION_API = "https://boards-api.greenhouse.io/v1/boards/{board}/education/{k
29
30
  EMBED = "https://job-boards.greenhouse.io/embed/job_app"
30
31
  EMBED_TIMEOUT_MS = 15_000
31
32
  OPTIONS_TIMEOUT_MS = 3_000
33
+ RACE_TIMEOUT_MS = 3_000
32
34
  HYDRATE_TIMEOUT_MS = 15_000
33
35
  FORM_TIMEOUT_MS = 20_000
34
36
  ANSWER_TIMEOUT_MS = 15_000 # an upload's file name, a suggestion: past it, a mismatch
@@ -52,6 +54,7 @@ OTP_ERROR = "#email-verification-error"
52
54
  # Sections the page adds or names its own way, or shows a question of only on another answer
53
55
  # (EEOC race after Hispanic/Latino): `request_read` declines a posting with any (ADR 0011).
54
56
  UNMAPPED = ("education", "compliance", "demographic_questions")
57
+ ETHNICITY, NOT_HISPANIC, RACE = "hispanic_ethnicity", "No", "race"
55
58
 
56
59
  # The fields in page order. A select's options are read by opening it, from Python.
57
60
  _READ = """form => {
@@ -196,10 +199,11 @@ class Greenhouse:
196
199
  fields = [self._field(page, raw) for raw in raws]
197
200
  self._fields = {f.id: f for f in fields}
198
201
  self._kinds = {raw["id"]: raw["kind"] for raw in raws}
199
- return fields
202
+ return self._with_race(page, fields)
200
203
 
201
204
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
202
- for field_id, answer in answers.items():
205
+ # the page draws race only once the answer to Hispanic/Latino is in
206
+ for field_id, answer in sorted(answers.items(), key=lambda a: a[0] == RACE):
203
207
  values = [answer] if isinstance(answer, str) else answer
204
208
  match self._kinds[field_id]:
205
209
  case "file":
@@ -235,6 +239,9 @@ class Greenhouse:
235
239
  case _:
236
240
  page.locator(_by_id(field_id)).fill(values[0])
237
241
 
242
+ def shown_fields(self, page: Page) -> list[str]:
243
+ return [raw["id"] for raw in page.locator(FORM).evaluate(_READ)]
244
+
238
245
  def read_back(self, page: Page) -> dict[str, Answer]:
239
246
  values = page.locator(FORM).evaluate(_READ_BACK, list(self._fields))
240
247
  return {k: v for k, v in values.items() if v not in (None, "", [])}
@@ -295,6 +302,30 @@ class Greenhouse:
295
302
  one_of=raw.get("oneOf"),
296
303
  )
297
304
 
305
+ def _with_race(self, page: Page, fields: list[Field]) -> list[Field]:
306
+ """`fields` with the EEOC race question, a follow-up of Hispanic/Latino: its page's own
307
+ label and options show only with the question, so the read answers No, reads it and
308
+ clears the answer. The API's options are not the page's."""
309
+ ethnicity = self._fields.get(ETHNICITY)
310
+ if (
311
+ RACE in self._fields
312
+ or ethnicity is None
313
+ or NOT_HISPANIC not in (ethnicity.options or [])
314
+ ):
315
+ return fields
316
+ _select(page, ETHNICITY, NOT_HISPANIC, False)
317
+ with suppress(Timeout):
318
+ page.locator(_by_id(RACE)).wait_for(timeout=RACE_TIMEOUT_MS)
319
+ raws = page.locator(FORM).evaluate(_READ)
320
+ raw = next((r for r in raws if r["id"] == RACE), None)
321
+ if raw is not None:
322
+ race = replace(self._field(page, raw), shown_when=Is(ETHNICITY, [NOT_HISPANIC]))
323
+ self._fields[RACE], self._kinds[RACE] = race, raw["kind"]
324
+ fields = [*fields]
325
+ fields.insert(fields.index(ethnicity) + 1, race)
326
+ _clear(page, ETHNICITY)
327
+ return fields
328
+
298
329
  def _education(self, page: Page, field_id: str) -> list[str] | None:
299
330
  """An education select's options, every page of them, read once per kind; None for
300
331
  another field or when the endpoint does not answer."""
@@ -429,6 +460,14 @@ def _exact(value: str) -> re.Pattern[str]:
429
460
  return re.compile(rf"^\s*{re.escape(value)}\s*$")
430
461
 
431
462
 
463
+ def _clear(page: Page, field_id: str) -> None:
464
+ """Empties a single react-select."""
465
+ box = page.locator(_by_id(field_id))
466
+ box.click()
467
+ box.press("Backspace")
468
+ box.press("Escape")
469
+
470
+
432
471
  def _select(page: Page, field_id: str, value: str, suggested: bool) -> None:
433
472
  """Types into a react-select and picks the option: focused with the arrow keys then Enter,
434
473
  which every select takes (a hover does not move the focus once typed). A suggestion list
@@ -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('"', '\\"')