atsfill 0.0.4.dev93__tar.gz → 0.0.4.dev94__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/PKG-INFO +1 -1
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/bamboohr.py +9 -5
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/dayforce.py +53 -21
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/eightfold.py +13 -15
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/hireology.py +36 -20
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/jazzhr.py +17 -2
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/manatal.py +4 -3
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/oracle.py +1 -1
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/paylocity.py +14 -9
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/phenom.py +122 -63
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/pinpoint.py +67 -30
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/rippling.py +4 -4
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/smartrecruiters.py +49 -19
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/workday.py +50 -50
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/wttj.py +3 -2
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/zohorecruit.py +37 -35
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/core/commands.py +1 -1
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/core/conditions.py +8 -4
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/core/matching.py +1 -1
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/core/results.py +2 -1
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/LICENSE +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/README.md +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/py.typed +0 -0
- {atsfill-0.0.4.dev93 → atsfill-0.0.4.dev94}/src/atsfill/registry.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atsfill
|
|
3
|
-
Version: 0.0.4.
|
|
3
|
+
Version: 0.0.4.dev94
|
|
4
4
|
Summary: Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle.
|
|
5
5
|
Keywords: job-applications,ats,ashby,greenhouse,teamtailor,ai-agents,cli
|
|
6
6
|
Author: shbernal
|
|
@@ -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 "")
|
|
@@ -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
|
|
@@ -143,7 +147,7 @@ class JazzHR:
|
|
|
143
147
|
for raw in form.evaluate(_READ):
|
|
144
148
|
fields.append(_field(raw))
|
|
145
149
|
self._fields[raw["id"]] = (raw["kind"], raw["selector"])
|
|
146
|
-
return fields
|
|
150
|
+
return _signed_on_disability(fields)
|
|
147
151
|
|
|
148
152
|
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
149
153
|
form = page.locator(FORM)
|
|
@@ -217,6 +221,17 @@ def _job(url: str) -> tuple[str, str] | None:
|
|
|
217
221
|
return (host, match[1]) if match else None
|
|
218
222
|
|
|
219
223
|
|
|
224
|
+
def _signed_on_disability(fields: list[Field]) -> list[Field]:
|
|
225
|
+
"""The disability form's name and date, required by the page once a disability is
|
|
226
|
+
declared."""
|
|
227
|
+
status = next((f for f in fields if f.id == DISABILITY), None)
|
|
228
|
+
declared = [o for o in (status.options if status else None) or [] if DECLARED.match(o)]
|
|
229
|
+
if not declared:
|
|
230
|
+
return fields
|
|
231
|
+
signed = Is(DISABILITY, declared)
|
|
232
|
+
return [replace(f, required_when=signed) if f.id in SIGNED else f for f in fields]
|
|
233
|
+
|
|
234
|
+
|
|
220
235
|
def _field(raw: dict[str, Any]) -> Field:
|
|
221
236
|
kind, options = raw["kind"], raw.get("options")
|
|
222
237
|
match kind:
|
|
@@ -13,7 +13,7 @@ from urllib.parse import urlsplit
|
|
|
13
13
|
|
|
14
14
|
from atsfill.core.adapter import Blocked, Gap, Unsupported, known_gap
|
|
15
15
|
from atsfill.core.browser import HTTP_TIMEOUT_S, Page
|
|
16
|
-
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
16
|
+
from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
|
|
17
17
|
|
|
18
18
|
HOST = "www.careers-page.com"
|
|
19
19
|
API = f"https://{HOST}/api/v1.0/"
|
|
@@ -333,10 +333,11 @@ class Manatal:
|
|
|
333
333
|
)
|
|
334
334
|
|
|
335
335
|
def _other_field(self, field: Field) -> Field:
|
|
336
|
-
"""The text box an answer of Other opens,
|
|
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:
|
|
@@ -20,7 +20,7 @@ from urllib.parse import urlsplit
|
|
|
20
20
|
|
|
21
21
|
from atsfill.core.adapter import Gap, Unsupported, known_gap
|
|
22
22
|
from atsfill.core.browser import HTTP_TIMEOUT_S, Locator, Page, Timeout
|
|
23
|
-
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
23
|
+
from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
|
|
24
24
|
|
|
25
25
|
HOST = "recruiting.paylocity.com"
|
|
26
26
|
JOB = re.compile(r"^/Recruiting/Jobs/(?:Details|Apply)/(\d+)", re.IGNORECASE)
|
|
@@ -317,6 +317,7 @@ class Paylocity:
|
|
|
317
317
|
def __init__(self) -> None:
|
|
318
318
|
self._form: list[tuple[Field, Control]] = []
|
|
319
319
|
self._values: dict[str, Answer] = {} # the read-back of the steps left behind
|
|
320
|
+
self._shown: list[str] = [] # the fields each step showed
|
|
320
321
|
|
|
321
322
|
def detect(self, url: str, page: Page | None) -> bool:
|
|
322
323
|
return _job(url) is not None
|
|
@@ -351,7 +352,7 @@ class Paylocity:
|
|
|
351
352
|
return [f for f, _ in form]
|
|
352
353
|
|
|
353
354
|
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
354
|
-
self._values = {}
|
|
355
|
+
self._values, self._shown = {}, []
|
|
355
356
|
page.locator("form, #app").first.wait_for(timeout=FORM_TIMEOUT_MS)
|
|
356
357
|
if "resume" in answers: # first: a parsed resume fills fields the answers then override
|
|
357
358
|
_upload_resume(page, _one(answers["resume"]))
|
|
@@ -360,9 +361,14 @@ class Paylocity:
|
|
|
360
361
|
if field.id in answers and field.id != "resume" and _present(page, control):
|
|
361
362
|
_fill(page, control, answers[field.id])
|
|
362
363
|
self._values.update(self._read(page))
|
|
364
|
+
self._shown += [f.id for f, c in self._form if _present(page, c)]
|
|
363
365
|
if not page.locator(NEXT).count() or not _next(page):
|
|
364
366
|
return
|
|
365
367
|
|
|
368
|
+
def shown_fields(self, page: Page) -> list[str]:
|
|
369
|
+
"""The listed fields each step showed once filled: the wizard keeps only its last."""
|
|
370
|
+
return list(dict.fromkeys(self._shown))
|
|
371
|
+
|
|
366
372
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
367
373
|
values = {**self._values, **self._read(page)}
|
|
368
374
|
return {k: v for k, v in values.items() if v not in (None, "", [])}
|
|
@@ -398,7 +404,7 @@ def _form(data: dict[str, Any]) -> list[tuple[Field, Control]]:
|
|
|
398
404
|
"""The fields and their controls, in the page's order, from the apply page's pageData."""
|
|
399
405
|
custom = data["customJobApplication"]
|
|
400
406
|
out: list[tuple[Field, Control]] = []
|
|
401
|
-
|
|
407
|
+
ids: dict[str, str] = {} # a listed control's field id, by its DOM id
|
|
402
408
|
|
|
403
409
|
def add(spec: Spec, field: dict[str, Any], prefix: str = "", n: int = 0) -> None:
|
|
404
410
|
dom = spec.dom.format(n=n)
|
|
@@ -410,13 +416,12 @@ def _form(data: dict[str, Any]) -> list[tuple[Field, Control]]:
|
|
|
410
416
|
options = [o["value" if spec.kind == "state" else "text"] for o in listed]
|
|
411
417
|
label = prefix + (spec.label or _plain(field["displayName"]))
|
|
412
418
|
required = field["isRequired"] if spec.required is None else spec.required
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
field_id = FILE_IDS.get(dom, dom)
|
|
419
|
+
shown_when = None
|
|
420
|
+
if spec.when and spec.when[0] in ids:
|
|
421
|
+
shown_when = Is(ids[spec.when[0]], [spec.when[1]])
|
|
422
|
+
field_id = ids[dom] = FILE_IDS.get(dom, dom)
|
|
418
423
|
out.append((Field(field_id, label, TYPES[spec.kind], bool(required), options,
|
|
419
|
-
spec.max_length),
|
|
424
|
+
spec.max_length, shown_when=shown_when),
|
|
420
425
|
Control(spec.kind, dom, options))) # fmt: skip
|
|
421
426
|
|
|
422
427
|
add(RESUME, {"isRequired": bool(custom.get("requireResume") or data.get("requireResume"))})
|