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.
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/PKG-INFO +1 -1
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/pyproject.toml +1 -1
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/pyproject.toml.orig +1 -1
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/adp.py +56 -3
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/adpmyjobs.py +24 -7
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/bamboohr.py +34 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/breezy.py +5 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/eightfold.py +14 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/greenhouse.py +42 -3
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/gupy.py +88 -6
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/hiringthing.py +20 -2
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/hrmdirect.py +23 -7
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/isolvedhire.py +20 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/jazzhr.py +3 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/jobaffinity.py +19 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/jobstreet.py +14 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/join.py +8 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/lever.py +19 -6
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/manatal.py +20 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/oracle.py +73 -9
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/paycor.py +4 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/personio.py +4 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/recruitee.py +4 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/rippling.py +33 -4
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/successfactors.py +3 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/taleo.py +24 -10
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/ukg.py +28 -5
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/welcomekit.py +5 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/wellfound.py +16 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/werecruit.py +3 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/workable.py +3 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/workatastartup.py +18 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/workday.py +49 -18
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/wttj.py +7 -2
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/LICENSE +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/README.md +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/recruiterflow.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/countries.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/otp.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/snapshot.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/core/telemetry.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev221}/src/atsfill/py.typed +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
658
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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('"', '\\"')
|