atsfill 0.0.4.dev93__tar.gz → 0.0.4.dev95__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.4.dev93 → atsfill-0.0.4.dev95}/PKG-INFO +1 -1
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/bamboohr.py +9 -5
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/dayforce.py +53 -21
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/eightfold.py +13 -15
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/greenhouse.py +39 -13
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/hireology.py +36 -20
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/jazzhr.py +40 -9
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/manatal.py +4 -3
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/oracle.py +1 -1
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/paylocity.py +14 -9
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/phenom.py +122 -63
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/pinpoint.py +67 -30
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/recruitee.py +29 -5
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/rippling.py +4 -4
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/smartrecruiters.py +49 -19
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/workday.py +50 -50
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/wttj.py +3 -2
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/zohorecruit.py +37 -35
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/core/commands.py +10 -6
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/core/conditions.py +35 -7
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/core/matching.py +10 -3
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/core/results.py +3 -1
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/LICENSE +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/README.md +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/py.typed +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev95}/src/atsfill/registry.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atsfill
|
|
3
|
-
Version: 0.0.4.
|
|
3
|
+
Version: 0.0.4.dev95
|
|
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
|
|
@@ -17,7 +17,7 @@ from urllib.parse import urlsplit
|
|
|
17
17
|
from atsfill.core import browser
|
|
18
18
|
from atsfill.core.adapter import Blocked, Gap, Unsupported
|
|
19
19
|
from atsfill.core.browser import HTTP_TIMEOUT_S, Locator, Page, Timeout
|
|
20
|
-
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
20
|
+
from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
|
|
21
21
|
|
|
22
22
|
HOST = re.compile(r"^[\w-]+\.bamboohr\.com$")
|
|
23
23
|
JOB = re.compile(r"^/careers/(\d+)(?:/|$)")
|
|
@@ -407,7 +407,10 @@ def _form(
|
|
|
407
407
|
label, required = entry["label"].strip(), entry["isRequired"]
|
|
408
408
|
options = [o["text"] for o in entry.get("options") or []] or None
|
|
409
409
|
extra: Any = None
|
|
410
|
-
|
|
410
|
+
shown = None # the employer's country, the page's own before any answer
|
|
411
|
+
if key == "countryId" and country:
|
|
412
|
+
by_id = {str(o["id"]): o["text"] for o in entry.get("options") or []}
|
|
413
|
+
shown = by_id.get(str(country), country if country in by_id.values() else None)
|
|
411
414
|
if key == "state" and options:
|
|
412
415
|
options = _states(states, country)
|
|
413
416
|
if key == "state" and not options:
|
|
@@ -416,7 +419,7 @@ def _form(
|
|
|
416
419
|
extra = {o["id"]: o["text"] for o in entry["options"] if int(o["id"]) > 0}
|
|
417
420
|
options = list(VETERAN) + list(extra.values())
|
|
418
421
|
accept = FILE_TYPES if kind == "file" else None
|
|
419
|
-
field = Field(key, label, TYPES[kind], required, options, None, accept,
|
|
422
|
+
field = Field(key, label, TYPES[kind], required, options, None, accept, default=shown)
|
|
420
423
|
out.append((field, (kind, name, extra)))
|
|
421
424
|
return out
|
|
422
425
|
|
|
@@ -451,11 +454,12 @@ def _custom(question: dict[str, Any]) -> list[tuple[Field, tuple[str, str | None
|
|
|
451
454
|
field = Field(field_id, label, TYPES[kind], required, options, None, accept)
|
|
452
455
|
out = [(field, (kind, name, extra))]
|
|
453
456
|
if extra and OTHER in extra and question.get("hasOther") == "yes":
|
|
454
|
-
other = Field(
|
|
457
|
+
other = Field( # its placeholder the page's only label; marked optional
|
|
455
458
|
f"{field_id}_other",
|
|
456
|
-
f"{label}: please specify
|
|
459
|
+
f"{label}: please specify",
|
|
457
460
|
FieldType.TEXT,
|
|
458
461
|
False,
|
|
462
|
+
shown_when=Is(field_id, [OTHER]),
|
|
459
463
|
)
|
|
460
464
|
out.append((other, ("text", f"{name}_other", None)))
|
|
461
465
|
return out
|
|
@@ -18,7 +18,7 @@ from urllib.parse import urlsplit
|
|
|
18
18
|
|
|
19
19
|
from atsfill.core.adapter import Gap, Unsupported, known_gap
|
|
20
20
|
from atsfill.core.browser import Locator, Page, Timeout
|
|
21
|
-
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
21
|
+
from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
|
|
22
22
|
|
|
23
23
|
HOST = "jobs.dayforcehcm.com"
|
|
24
24
|
JOB = re.compile(r"^/(?:([a-z]{2}(?:-[A-Za-z]{2,4})?)/)?([^/]+)/([^/]+)/jobs/(\d+)")
|
|
@@ -36,6 +36,7 @@ UPDATE_TIMEOUT_MS = 5_000 # for a section to close, per Update click
|
|
|
36
36
|
SUBMIT_TIMEOUT_MS = 90_000
|
|
37
37
|
UPDATE_TRIES = 3
|
|
38
38
|
FOLLOWUP_TIMEOUT_MS = 3_000
|
|
39
|
+
FOLLOWUP = "Enter additional comments here" # an option's comment box, required once shown
|
|
39
40
|
DIAL_TIMEOUT_MS = 2_000 # per dial code tried, longest first
|
|
40
41
|
OPTIONS_TIMEOUT_MS = 5_000 # for a dropdown's options to render
|
|
41
42
|
NEXT_TRIES = 3
|
|
@@ -120,7 +121,9 @@ QUESTION_TYPES = {
|
|
|
120
121
|
CHECKBOX: FieldType.CHOICE,
|
|
121
122
|
MULTI: FieldType.MULTI_CHOICE,
|
|
122
123
|
}
|
|
123
|
-
|
|
124
|
+
# Its text questions (name, date, id) are the page's own, disabled and filled from other
|
|
125
|
+
# answers: not fields
|
|
126
|
+
DISABILITY = "DISABILITYQUESTIONNAIRE"
|
|
124
127
|
|
|
125
128
|
# Each option of a dropdown, by scrolling its virtual list: only a screenful is in the DOM.
|
|
126
129
|
_HARVEST = """async (id) => {
|
|
@@ -185,6 +188,7 @@ class Dayforce:
|
|
|
185
188
|
self._sections: list[dict[str, Any]] = [] # the questionnaires, in page order
|
|
186
189
|
self._options: dict[str, dict[str, str]] = {} # field id: page text to option
|
|
187
190
|
self._values: dict[str, Answer | None] = {} # read back from each page before leaving it
|
|
191
|
+
self._shown: list[str] = [] # the follow-ups each page showed before leaving it
|
|
188
192
|
|
|
189
193
|
def detect(self, url: str, page: Page | None) -> bool:
|
|
190
194
|
return _job(url) is not None
|
|
@@ -234,7 +238,7 @@ class Dayforce:
|
|
|
234
238
|
raise Unsupported("posting_gone: not a Dayforce job page")
|
|
235
239
|
page.evaluate("key => sessionStorage.removeItem(key)", _draft(found))
|
|
236
240
|
_open_form(page, found)
|
|
237
|
-
self._values = {}
|
|
241
|
+
self._values, self._shown = {}, []
|
|
238
242
|
self._dialogs(page, answers)
|
|
239
243
|
self._fill_candidate_info(page, answers)
|
|
240
244
|
if not _next(page):
|
|
@@ -254,6 +258,11 @@ class Dayforce:
|
|
|
254
258
|
values["userAcknowledged"] = "Yes" if box.is_checked() else "No"
|
|
255
259
|
return {k: v for k, v in values.items() if v not in (None, "", [])}
|
|
256
260
|
|
|
261
|
+
def shown_fields(self, page: Page) -> list[str]:
|
|
262
|
+
"""The follow-ups the fill saw shown on each page before leaving it: the wizard keeps
|
|
263
|
+
no page but the last, so the rest are not listed."""
|
|
264
|
+
return list(self._shown)
|
|
265
|
+
|
|
257
266
|
def submit(self, page: Page) -> str | None:
|
|
258
267
|
before = page.url
|
|
259
268
|
page.locator("main button", has_text=re.compile(r"^\s*Submit\s*$")).last.click()
|
|
@@ -292,9 +301,9 @@ class Dayforce:
|
|
|
292
301
|
}.get(key) or None
|
|
293
302
|
fields.append(Field(key, f["displayName"], kind, bool(f["isRequired"]), options))
|
|
294
303
|
if key == "candidateSource" and explained:
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
304
|
+
fields.append(Field("candidateSourceDescription", "Additional details",
|
|
305
|
+
FieldType.TEXT, False,
|
|
306
|
+
shown_when=Is("candidateSource", explained))) # fmt: skip
|
|
298
307
|
if context.get("hasActiveSMSProvider"):
|
|
299
308
|
fields.append(Field("smsConsent", "Consent to receive SMS messages", FieldType.CHOICE,
|
|
300
309
|
False, NO_YES)) # fmt: skip
|
|
@@ -373,6 +382,7 @@ class Dayforce:
|
|
|
373
382
|
description = page.locator(_id("candidateSourceDescription"))
|
|
374
383
|
if description.count():
|
|
375
384
|
self._values["candidateSourceDescription"] = description.input_value()
|
|
385
|
+
self._shown.append("candidateSourceDescription")
|
|
376
386
|
_update(page, f"{PERSONAL}email")
|
|
377
387
|
if page.locator(f"{HOURS}sunday_isChecked").count():
|
|
378
388
|
self._fill_hours(page, answers)
|
|
@@ -444,16 +454,19 @@ class Dayforce:
|
|
|
444
454
|
target = _question_target(page, base, question)
|
|
445
455
|
if target is None:
|
|
446
456
|
return
|
|
447
|
-
if field_id in answers and
|
|
457
|
+
if field_id in answers and target.is_enabled():
|
|
448
458
|
self._set_question(page, target, field, question, answers[field_id])
|
|
449
459
|
self._values[field_id] = self._get_question(page, target, field, question)
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
460
|
+
# a box group's comment box is one per option asking for it, a single choice's one
|
|
461
|
+
followups = {f"{field_id}.followup": f"[id='{base}answerFollowup'],"
|
|
462
|
+
f" [id*='questionnaireFollowUp-{question['questionId']}-']"} # fmt: skip
|
|
463
|
+
for option in question["options"]:
|
|
464
|
+
box_id = f"{base}answerFollowups_{option['optionId']}"
|
|
465
|
+
followups[f"{field_id}.followup.{option['optionId']}"] = f"[id='{box_id}']"
|
|
466
|
+
for followup, selector in followups.items():
|
|
467
|
+
if followup not in self._fields:
|
|
468
|
+
continue
|
|
469
|
+
boxes = page.locator(selector)
|
|
457
470
|
if followup in answers:
|
|
458
471
|
with suppress(Timeout): # it shows once an option asking for comments is picked
|
|
459
472
|
boxes.first.wait_for(timeout=FOLLOWUP_TIMEOUT_MS)
|
|
@@ -462,6 +475,13 @@ class Dayforce:
|
|
|
462
475
|
box.fill(_one(answers[followup]))
|
|
463
476
|
shown = [b.input_value() for b in boxes.all() if b.is_visible()]
|
|
464
477
|
self._values[followup] = shown[0] if shown else ""
|
|
478
|
+
self._shown += [followup] if shown else []
|
|
479
|
+
for box in page.locator(f"[id^='{base}answerFollowup']").all():
|
|
480
|
+
box_id = box.get_attribute("id") or ""
|
|
481
|
+
option = box_id.removeprefix(f"{base}answerFollowup").removeprefix("s_")
|
|
482
|
+
followup = f"{field_id}.followup" + (f".{option}" if option else "")
|
|
483
|
+
if box.is_visible() and followup not in self._fields:
|
|
484
|
+
self._shown.append(box_id) # a comment box `read_form` did not list
|
|
465
485
|
|
|
466
486
|
def _set_question(
|
|
467
487
|
self, page: Page, target: Locator, field: Field, question: dict[str, Any], answer: Answer
|
|
@@ -605,6 +625,8 @@ def _question_fields(section: dict[str, Any], question: dict[str, Any]) -> list[
|
|
|
605
625
|
if not label or len(label) > LONG_LABEL:
|
|
606
626
|
label = _plain(question["displayName"])
|
|
607
627
|
field_id = f"q{question['questionId']}"
|
|
628
|
+
if section.get("xRefCode") == DISABILITY and question["answerType"] == TEXT:
|
|
629
|
+
return []
|
|
608
630
|
if kind is None:
|
|
609
631
|
reason = f"unhandled_rendering: {field_id} has answer type {question['answerType']}"
|
|
610
632
|
required = bool(question["isRequired"])
|
|
@@ -614,13 +636,23 @@ def _question_fields(section: dict[str, Any], question: dict[str, Any]) -> list[
|
|
|
614
636
|
options = YES_NO
|
|
615
637
|
elif kind in (FieldType.CHOICE, FieldType.MULTI_CHOICE):
|
|
616
638
|
options = [_option_label(o) for o in question["options"]]
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
639
|
+
fields = [Field(field_id, label, kind, bool(question["isRequired"]), options)]
|
|
640
|
+
asking = [o for o in question["options"] if o.get("hasFollowUp")]
|
|
641
|
+
if question["answerType"] == MULTI: # a comment box per option ticked
|
|
642
|
+
fields += [
|
|
643
|
+
Field(
|
|
644
|
+
f"{field_id}.followup.{o['optionId']}",
|
|
645
|
+
FOLLOWUP,
|
|
646
|
+
FieldType.TEXT,
|
|
647
|
+
True,
|
|
648
|
+
shown_when=Is(field_id, [_option_label(o)]),
|
|
649
|
+
) # fmt: skip
|
|
650
|
+
for o in asking
|
|
651
|
+
]
|
|
652
|
+
elif asking:
|
|
653
|
+
picks = [_option_label(o) for o in asking]
|
|
654
|
+
fields.append(Field(f"{field_id}.followup", FOLLOWUP, FieldType.TEXT, True,
|
|
655
|
+
shown_when=Is(field_id, picks))) # fmt: skip
|
|
624
656
|
return fields
|
|
625
657
|
|
|
626
658
|
|
|
@@ -211,8 +211,10 @@ class Eightfold:
|
|
|
211
211
|
if control.dom is None and not control.field.shown_when:
|
|
212
212
|
reason = f"unhandled_rendering: {control.field.id} is not on the page"
|
|
213
213
|
control.field = replace(control.field, unfillable=reason)
|
|
214
|
-
|
|
215
|
-
|
|
214
|
+
held = self._values(page, [c for c in self._controls if c.dom and not c.field.unfillable])
|
|
215
|
+
for control in self._controls:
|
|
216
|
+
if control.field.id in held and control.field.prefilled is None:
|
|
217
|
+
control.field = replace(control.field, default=held[control.field.id])
|
|
216
218
|
self._fields = [c.field for c in self._controls]
|
|
217
219
|
self._controls = [c for c in self._controls if not c.field.unfillable]
|
|
218
220
|
return self._fields
|
|
@@ -240,10 +242,14 @@ class Eightfold:
|
|
|
240
242
|
doms = page.evaluate(_DOM_IDS, unplaced) if unplaced else {}
|
|
241
243
|
for control in self._controls:
|
|
242
244
|
control.dom = control.dom or doms.get(control.field.id)
|
|
243
|
-
|
|
244
|
-
|
|
245
|
+
return self._values(page, [c for c in self._controls if c.dom])
|
|
246
|
+
|
|
247
|
+
@staticmethod
|
|
248
|
+
def _values(page: Page, controls: list[_Control]) -> dict[str, Answer]:
|
|
249
|
+
"""What each rendered control shows, a date in ISO; those showing nothing left out."""
|
|
250
|
+
values = page.evaluate(_READ_BACK, [[c.field.id, c.dom, c.kind] for c in controls])
|
|
245
251
|
out: dict[str, Answer] = {}
|
|
246
|
-
for control in
|
|
252
|
+
for control in controls:
|
|
247
253
|
value = values.get(control.field.id)
|
|
248
254
|
if control.kind == "date" and value:
|
|
249
255
|
value = _iso(value)
|
|
@@ -373,7 +379,8 @@ def _control(q: dict[str, Any], by_id: dict[str, dict[str, Any]]) -> _Control:
|
|
|
373
379
|
files = q.get("resumeOptions") or q.get("fileUploadOptions") or {}
|
|
374
380
|
types = files.get("acceptedFileTypes") or (files.get("fileFormats") or "").split(",")
|
|
375
381
|
accept = [t.strip() for t in types if t.strip()] or None
|
|
376
|
-
|
|
382
|
+
# today's date, read-only: the page takes no other, so it stands for the answer
|
|
383
|
+
today = date.today().isoformat() if (q.get("dateOptions") or {}).get("today") else None
|
|
377
384
|
question = Field(
|
|
378
385
|
qid,
|
|
379
386
|
label,
|
|
@@ -400,15 +407,6 @@ def _box(page: Page, control: _Control):
|
|
|
400
407
|
return page.locator(f"[id={json.dumps(f'{control.dom}_error')}]").locator("xpath=..")
|
|
401
408
|
|
|
402
409
|
|
|
403
|
-
def _prefilled(page: Page, control: _Control) -> bool:
|
|
404
|
-
if control.kind in ("text", "select", "date"):
|
|
405
|
-
el = _box(page, control).locator("input:not([type=file]), textarea").first
|
|
406
|
-
return bool(el.count() and el.input_value())
|
|
407
|
-
if control.kind == "pills":
|
|
408
|
-
return _box(page, control).locator("[id^=selectPilllist]").count() > 0
|
|
409
|
-
return False
|
|
410
|
-
|
|
411
|
-
|
|
412
410
|
def _fill(page: Page, control: _Control, answer: Answer) -> None:
|
|
413
411
|
box = _box(page, control)
|
|
414
412
|
value = answer if isinstance(answer, str) else (answer[0] if answer else "")
|
|
@@ -61,6 +61,7 @@ _READ = """form => {
|
|
|
61
61
|
const found = [];
|
|
62
62
|
for (const el of form.querySelectorAll("input, textarea")) {
|
|
63
63
|
if (!el.id || ["hidden", "search", "file", "checkbox"].includes(el.type)) continue;
|
|
64
|
+
if (el.closest(".file-upload")) continue; // a file's paste-in textarea, listed with the file
|
|
64
65
|
const combo = el.getAttribute("role") === "combobox";
|
|
65
66
|
if (combo && el.closest(".phone-input")) continue; // the phone's country, set by its number
|
|
66
67
|
const shell = el.closest(".select");
|
|
@@ -84,10 +85,14 @@ _READ = """form => {
|
|
|
84
85
|
}
|
|
85
86
|
for (const group of form.querySelectorAll(".file-upload")) {
|
|
86
87
|
const input = group.querySelector("input[type=file]");
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
const id = group.getAttribute("aria-labelledby").replace(/^upload-label-/, "");
|
|
89
|
+
const label = text(group.querySelector(".upload-label"));
|
|
90
|
+
const required = group.getAttribute("aria-required") === "true";
|
|
91
|
+
const manual = group.querySelector(`button[data-testid="${id}-text"]`);
|
|
92
|
+
found.push([group, {id, kind: "file", label, required, oneOf: manual ? id : null,
|
|
90
93
|
accept: input?.accept ? input.accept.split(",") : null}]);
|
|
94
|
+
const pasted = {id: `${id}_text`, kind: "pasted", label, required, oneOf: id};
|
|
95
|
+
if (manual) found.push([manual, pasted]);
|
|
91
96
|
}
|
|
92
97
|
const before = (a, b) => a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING;
|
|
93
98
|
found.sort(([a], [b]) => before(a, b) ? -1 : 1);
|
|
@@ -196,6 +201,15 @@ class Greenhouse:
|
|
|
196
201
|
upload.locator("input[type=file]").set_input_files(values[0])
|
|
197
202
|
with suppress(Timeout):
|
|
198
203
|
upload.locator(".file-upload__filename").wait_for(timeout=ANSWER_TIMEOUT_MS)
|
|
204
|
+
case "pasted": # "Enter manually" adds the textarea beside the file input
|
|
205
|
+
text = page.locator(_by_id(field_id))
|
|
206
|
+
if not text.count():
|
|
207
|
+
browser.click(
|
|
208
|
+
page.locator(
|
|
209
|
+
f"button[data-testid='{field_id.removesuffix('_text')}-text']"
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
text.fill(values[0])
|
|
199
213
|
case "consent":
|
|
200
214
|
box = page.locator(f"{FORM} input[type=checkbox][name='{field_id}']").first
|
|
201
215
|
if box.is_checked() != (answer == "Yes"):
|
|
@@ -227,7 +241,7 @@ class Greenhouse:
|
|
|
227
241
|
def _field(self, page: Page, raw: dict[str, Any]) -> Field:
|
|
228
242
|
kind, options = raw["kind"], raw.get("options")
|
|
229
243
|
match kind:
|
|
230
|
-
case "text" | "number":
|
|
244
|
+
case "text" | "number" | "pasted":
|
|
231
245
|
field_type = FieldType.TEXT
|
|
232
246
|
case "tel":
|
|
233
247
|
field_type = FieldType.PHONE
|
|
@@ -256,6 +270,7 @@ class Greenhouse:
|
|
|
256
270
|
options,
|
|
257
271
|
raw.get("maxLength"),
|
|
258
272
|
raw.get("accept"),
|
|
273
|
+
one_of=raw.get("oneOf"),
|
|
259
274
|
)
|
|
260
275
|
|
|
261
276
|
def _education(self, page: Page, field_id: str) -> list[str] | None:
|
|
@@ -343,15 +358,26 @@ def _api_fields(job: dict[str, Any]) -> list[Field] | None:
|
|
|
343
358
|
return None
|
|
344
359
|
fields = []
|
|
345
360
|
for question in job["questions"] + (job.get("location_questions") or []):
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
361
|
+
entries = question["fields"] # a file's paste-in textarea comes second, its alternative
|
|
362
|
+
one_of = entries[0]["name"] if len(entries) > 1 else None
|
|
363
|
+
for entry in entries:
|
|
364
|
+
if entry["type"] == "input_hidden": # a location's latitude and longitude
|
|
365
|
+
continue
|
|
366
|
+
name = entry["name"]
|
|
367
|
+
field_type = FieldType.PHONE if name == "phone" else API_TYPES[entry["type"]]
|
|
368
|
+
if name == "location":
|
|
369
|
+
name, field_type = "candidate-location", FieldType.TYPEAHEAD
|
|
370
|
+
options = [v["label"] for v in entry.get("values") or []] or None
|
|
371
|
+
fields.append(
|
|
372
|
+
Field(
|
|
373
|
+
name,
|
|
374
|
+
question["label"],
|
|
375
|
+
field_type,
|
|
376
|
+
question["required"],
|
|
377
|
+
options,
|
|
378
|
+
one_of=one_of,
|
|
379
|
+
)
|
|
380
|
+
)
|
|
355
381
|
return fields
|
|
356
382
|
|
|
357
383
|
|
|
@@ -18,7 +18,7 @@ from urllib.parse import urlsplit
|
|
|
18
18
|
from atsfill.core import browser
|
|
19
19
|
from atsfill.core.adapter import Gap, Unsupported
|
|
20
20
|
from atsfill.core.browser import HTTP_TIMEOUT_S, Page, Timeout
|
|
21
|
-
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
21
|
+
from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
|
|
22
22
|
|
|
23
23
|
HOST = "careers.hireology.com"
|
|
24
24
|
JOB = re.compile(r"^/[^/]+/(\d+)(?:/|$)")
|
|
@@ -101,16 +101,17 @@ LABELS = {
|
|
|
101
101
|
}
|
|
102
102
|
# The page's heading of a section whose labels repeat others' ("City").
|
|
103
103
|
SECTIONS = {"experience": "Work history: ", "education": "Education: ", "military": "Military: "}
|
|
104
|
-
# A field the page shows only once another is answered
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
"
|
|
108
|
-
"
|
|
109
|
-
"
|
|
110
|
-
"
|
|
111
|
-
"
|
|
104
|
+
# A field the page shows only once another is answered, and on which answers: the school fields
|
|
105
|
+
# stay hidden until an education is picked.
|
|
106
|
+
SHOWN_BY: dict[str, Is] = {
|
|
107
|
+
"referred_by": Is("candidate_referred", ["Yes"]),
|
|
108
|
+
"non_complete_in_place_reason": Is("non_complete_in_place", ["Yes"]),
|
|
109
|
+
"relocate_reason": Is("relocate", ["Yes"]),
|
|
110
|
+
"extra_activities": Is("participation_extra_activities", ["Yes"]),
|
|
111
|
+
"part_time_text": Is("part_time", ["Yes"]),
|
|
112
|
+
"academic_awards": Is("gained_academic_awards", ["Yes"]),
|
|
112
113
|
**{
|
|
113
|
-
f: ("military", "
|
|
114
|
+
f: Is("military", ["Yes"])
|
|
114
115
|
for f in (
|
|
115
116
|
"army_branch",
|
|
116
117
|
"highest_rank",
|
|
@@ -122,7 +123,7 @@ SHOWN_BY = {
|
|
|
122
123
|
)
|
|
123
124
|
},
|
|
124
125
|
**{
|
|
125
|
-
f: ("highest_grade", "
|
|
126
|
+
f: Is("highest_grade", ["Did Not Complete High School"], negated=True)
|
|
126
127
|
for f in ("institute_name", "graduation_year", "education_city", "education_state_id")
|
|
127
128
|
},
|
|
128
129
|
}
|
|
@@ -230,6 +231,10 @@ class Hireology:
|
|
|
230
231
|
self._kinds = {field.id: kind for field, kind in out if not field.unfillable}
|
|
231
232
|
return [f for f, _ in out]
|
|
232
233
|
|
|
234
|
+
def shown_fields(self, page: Page) -> list[str]:
|
|
235
|
+
form = page.locator(FORM)
|
|
236
|
+
return [i for i in self._kinds if form.evaluate(_CONTROL, i) is not None]
|
|
237
|
+
|
|
233
238
|
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
234
239
|
form = page.locator(FORM)
|
|
235
240
|
for field_id, kind in self._kinds.items(): # a question before the fields it shows
|
|
@@ -293,7 +298,7 @@ def _exact(value: str) -> re.Pattern[str]:
|
|
|
293
298
|
|
|
294
299
|
def _form(api: dict[str, Any]) -> list[tuple[Field, str]]:
|
|
295
300
|
"""Each field of the form API's template with its API type; an entry the page can repeat
|
|
296
|
-
is read once, as its first."""
|
|
301
|
+
is read once, as its first. A field shown by one the form does not ask is never shown."""
|
|
297
302
|
out = []
|
|
298
303
|
for section in api["template"]["sections"]:
|
|
299
304
|
for fieldset in section["fieldsets"]:
|
|
@@ -301,7 +306,8 @@ def _form(api: dict[str, Any]) -> list[tuple[Field, str]]:
|
|
|
301
306
|
out.append(
|
|
302
307
|
(_field(entry, SECTIONS.get(section["id"], "")), entry["attributes"]["type"])
|
|
303
308
|
)
|
|
304
|
-
|
|
309
|
+
ids = {f.id for f, _ in out}
|
|
310
|
+
return [(f, k) for f, k in out if f.id not in SHOWN_BY or SHOWN_BY[f.id].field_id in ids]
|
|
305
311
|
|
|
306
312
|
|
|
307
313
|
def _field(entry: dict[str, Any], section: str) -> Field:
|
|
@@ -316,20 +322,30 @@ def _field(entry: dict[str, Any], section: str) -> Field:
|
|
|
316
322
|
if kind not in TYPES:
|
|
317
323
|
reason = f"unhandled_rendering: {field_id} is a {kind}"
|
|
318
324
|
return Field(
|
|
319
|
-
field_id, label, FieldType.TEXT, bool(attributes.get("required")), unfillable=reason
|
|
320
|
-
|
|
325
|
+
field_id, label, FieldType.TEXT, bool(attributes.get("required")), unfillable=reason,
|
|
326
|
+
shown_when=SHOWN_BY.get(field_id),
|
|
327
|
+
) # fmt: skip
|
|
321
328
|
required = attributes["required"] and kind != "checkbox" # a box left clear is an answer
|
|
322
329
|
options = [o["name"] for o in attributes["options"]] or None
|
|
323
330
|
if kind == "checkbox":
|
|
324
331
|
options = YES_NO
|
|
325
332
|
elif kind == "month":
|
|
326
333
|
label += MONTH
|
|
327
|
-
if shown_by := SHOWN_BY.get(field_id):
|
|
328
|
-
trigger, condition = shown_by
|
|
329
|
-
label += f" (asked when {section + LABELS[trigger]!r} {condition})"
|
|
330
|
-
required = False
|
|
331
334
|
max_length = attributes.get("maxlength") if TYPES[kind] is FieldType.TEXT else None
|
|
332
335
|
if kind == "month":
|
|
333
336
|
max_length = None
|
|
334
337
|
accept = FILE_TYPES if kind == "file" else None
|
|
335
|
-
return Field(
|
|
338
|
+
return Field(
|
|
339
|
+
field_id, label, TYPES[kind], required, options, max_length, accept,
|
|
340
|
+
default=_default(attributes, kind), shown_when=SHOWN_BY.get(field_id),
|
|
341
|
+
) # fmt: skip
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _default(attributes: dict[str, Any], kind: str) -> str | None:
|
|
345
|
+
"""The answer the page shows picked before any, from the API's `value`: an option's name,
|
|
346
|
+
a box's `Yes` or `No`."""
|
|
347
|
+
value = attributes.get("value")
|
|
348
|
+
if kind == "checkbox" and isinstance(value, bool):
|
|
349
|
+
return YES_NO[0] if value else YES_NO[1]
|
|
350
|
+
names = {str(o.get("value")): o["name"] for o in attributes.get("options") or []}
|
|
351
|
+
return names.get(str(value)) if isinstance(value, str | int) else None
|
|
@@ -8,13 +8,14 @@ by which boxes are checked.
|
|
|
8
8
|
|
|
9
9
|
import re
|
|
10
10
|
from contextlib import suppress
|
|
11
|
+
from dataclasses import replace
|
|
11
12
|
from typing import Any
|
|
12
13
|
from urllib.parse import urlsplit
|
|
13
14
|
|
|
14
15
|
from atsfill.core import browser
|
|
15
16
|
from atsfill.core.adapter import Blocked, Gap, Unsupported
|
|
16
17
|
from atsfill.core.browser import Page, Timeout
|
|
17
|
-
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
18
|
+
from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
|
|
18
19
|
|
|
19
20
|
HOST = re.compile(r"^[\w-]+\.applytojob\.com$")
|
|
20
21
|
JOB = re.compile(r"^/apply/([A-Za-z0-9]{10})\b")
|
|
@@ -23,6 +24,9 @@ CAPTCHA = "#resumator-recaptcha iframe[title=reCAPTCHA]"
|
|
|
23
24
|
CHALLENGE = "iframe[src*='recaptcha/api2/bframe']"
|
|
24
25
|
RESUME_TYPES = [".pdf", ".doc", ".docx", ".odt", ".txt", ".rtf"] # as the page states them
|
|
25
26
|
YES_NO = ["Yes", "No"]
|
|
27
|
+
DISABILITY = "eeoc_disability" # form CC-305: its name and date are signed once one is declared
|
|
28
|
+
SIGNED = ("eeoc_disability_signature", "eeoc_disability_date")
|
|
29
|
+
DECLARED = re.compile(r"^\s*YES\b", re.IGNORECASE)
|
|
26
30
|
FORM_TIMEOUT_MS = 20_000
|
|
27
31
|
CAPTCHA_TIMEOUT_MS = 20_000
|
|
28
32
|
SUBMIT_TIMEOUT_MS = 90_000
|
|
@@ -43,13 +47,15 @@ _READ = """form => {
|
|
|
43
47
|
const shown = el => el.offsetParent !== null;
|
|
44
48
|
const disability = "#resumator-new-disability-message";
|
|
45
49
|
const found = [], radios = new Set();
|
|
50
|
+
const pasted = "resumator-resumetext-value"; // "Paste resume", the file's alternative
|
|
51
|
+
const pasteable = !!form.querySelector(`#${pasted}`);
|
|
46
52
|
const controls = form.querySelectorAll(
|
|
47
53
|
"input:not([type=hidden]), select, textarea, input.resumator-questionnaire-checkbox-answer");
|
|
48
54
|
for (const el of controls) {
|
|
49
|
-
if (["resumator-
|
|
50
|
-
|
|
55
|
+
if (["resumator-xml-value", "g-recaptcha-response"].includes(el.id)
|
|
56
|
+
|| el.classList.contains("resumator-questionnaire-checkbox")) continue;
|
|
51
57
|
const hidden = el.type === "file" ? el.id !== "resumator-resume-value"
|
|
52
|
-
: !shown(el) && el.type !== "hidden";
|
|
58
|
+
: !shown(el) && el.type !== "hidden" && el.id !== pasted;
|
|
53
59
|
if (hidden) continue;
|
|
54
60
|
const group = el.closest(".form-group, .resumator-field-wrapper");
|
|
55
61
|
const groupLabel = group?.querySelector(".control-label, .resumator-label");
|
|
@@ -75,10 +81,11 @@ _READ = """form => {
|
|
|
75
81
|
field.kind = "select";
|
|
76
82
|
field.options = [...el.options].filter(o => !o.value.startsWith("resumator_no_selection")
|
|
77
83
|
&& !/^no answer$/i.test(o.text.trim())).map(o => o.text.trim());
|
|
78
|
-
} else if (el.type === "file") {
|
|
79
|
-
field.kind = "file";
|
|
84
|
+
} else if (el.type === "file" || el.id === pasted) {
|
|
85
|
+
field.kind = el.type === "file" ? "file" : "pasted";
|
|
80
86
|
field.label = text(groupLabel);
|
|
81
87
|
field.required = starred(groupLabel);
|
|
88
|
+
if (pasteable) field.oneOf = "resume";
|
|
82
89
|
} else {
|
|
83
90
|
field.kind = el.classList.contains("resumator-datepicker") ? "date"
|
|
84
91
|
: el.type === "tel" ? "tel" : "text";
|
|
@@ -143,7 +150,7 @@ class JazzHR:
|
|
|
143
150
|
for raw in form.evaluate(_READ):
|
|
144
151
|
fields.append(_field(raw))
|
|
145
152
|
self._fields[raw["id"]] = (raw["kind"], raw["selector"])
|
|
146
|
-
return fields
|
|
153
|
+
return _signed_on_disability(fields)
|
|
147
154
|
|
|
148
155
|
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
149
156
|
form = page.locator(FORM)
|
|
@@ -158,6 +165,12 @@ class JazzHR:
|
|
|
158
165
|
if attach.is_visible():
|
|
159
166
|
attach.click()
|
|
160
167
|
field.set_input_files(values[0])
|
|
168
|
+
case "pasted": # behind "Paste resume", from the choice or the file's own link
|
|
169
|
+
for link in ("#resumator-choose-paste", "#resumator-resume-switch-paste"):
|
|
170
|
+
if page.locator(link).is_visible():
|
|
171
|
+
page.locator(link).click()
|
|
172
|
+
break
|
|
173
|
+
field.fill(values[0])
|
|
161
174
|
case "select":
|
|
162
175
|
field.select_option(label=values[0])
|
|
163
176
|
case "radio":
|
|
@@ -217,10 +230,21 @@ def _job(url: str) -> tuple[str, str] | None:
|
|
|
217
230
|
return (host, match[1]) if match else None
|
|
218
231
|
|
|
219
232
|
|
|
233
|
+
def _signed_on_disability(fields: list[Field]) -> list[Field]:
|
|
234
|
+
"""The disability form's name and date, required by the page once a disability is
|
|
235
|
+
declared."""
|
|
236
|
+
status = next((f for f in fields if f.id == DISABILITY), None)
|
|
237
|
+
declared = [o for o in (status.options if status else None) or [] if DECLARED.match(o)]
|
|
238
|
+
if not declared:
|
|
239
|
+
return fields
|
|
240
|
+
signed = Is(DISABILITY, declared)
|
|
241
|
+
return [replace(f, required_when=signed) if f.id in SIGNED else f for f in fields]
|
|
242
|
+
|
|
243
|
+
|
|
220
244
|
def _field(raw: dict[str, Any]) -> Field:
|
|
221
245
|
kind, options = raw["kind"], raw.get("options")
|
|
222
246
|
match kind:
|
|
223
|
-
case "text":
|
|
247
|
+
case "text" | "pasted":
|
|
224
248
|
field_type = FieldType.TEXT
|
|
225
249
|
case "date":
|
|
226
250
|
field_type = FieldType.DATE
|
|
@@ -241,5 +265,12 @@ def _field(raw: dict[str, Any]) -> Field:
|
|
|
241
265
|
)
|
|
242
266
|
accept = RESUME_TYPES if kind == "file" else None
|
|
243
267
|
return Field(
|
|
244
|
-
raw["id"],
|
|
268
|
+
raw["id"],
|
|
269
|
+
raw["label"],
|
|
270
|
+
field_type,
|
|
271
|
+
raw["required"],
|
|
272
|
+
options,
|
|
273
|
+
raw.get("maxLength"),
|
|
274
|
+
accept,
|
|
275
|
+
one_of=raw.get("oneOf"),
|
|
245
276
|
)
|
|
@@ -13,7 +13,7 @@ from urllib.parse import urlsplit
|
|
|
13
13
|
|
|
14
14
|
from atsfill.core.adapter import Blocked, Gap, Unsupported, known_gap
|
|
15
15
|
from atsfill.core.browser import HTTP_TIMEOUT_S, Page
|
|
16
|
-
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
16
|
+
from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
|
|
17
17
|
|
|
18
18
|
HOST = "www.careers-page.com"
|
|
19
19
|
API = f"https://{HOST}/api/v1.0/"
|
|
@@ -333,10 +333,11 @@ class Manatal:
|
|
|
333
333
|
)
|
|
334
334
|
|
|
335
335
|
def _other_field(self, field: Field) -> Field:
|
|
336
|
-
"""The text box an answer of Other opens,
|
|
336
|
+
"""The text box an answer of Other opens, required where its list is."""
|
|
337
337
|
fid = f"{field.id}.other"
|
|
338
338
|
gap = known_gap(self, "other", f"{fid} is the text box an answer of {OTHER} opens")
|
|
339
|
-
return Field(fid, f"{field.label}: {OTHER}", FieldType.TEXT,
|
|
339
|
+
return Field(fid, f"{field.label}: {OTHER}", FieldType.TEXT, field.required,
|
|
340
|
+
unfillable=gap, shown_when=Is(field.id, [OTHER])) # fmt: skip
|
|
340
341
|
|
|
341
342
|
@staticmethod
|
|
342
343
|
def _shown(page: Page, entry: dict[str, Any]) -> list[str] | None:
|