atsfill 0.0.4.dev92__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.dev92 → atsfill-0.0.4.dev94}/PKG-INFO +1 -1
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/bamboohr.py +9 -5
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/dayforce.py +53 -21
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/eightfold.py +54 -62
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/hireology.py +36 -20
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/jazzhr.py +17 -2
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/manatal.py +4 -3
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/oracle.py +1 -1
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/paylocity.py +14 -9
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/phenom.py +122 -63
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/pinpoint.py +67 -30
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/rippling.py +4 -4
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/smartrecruiters.py +49 -19
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/teamtailor.py +21 -16
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/workday.py +50 -50
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/wttj.py +3 -2
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/zohorecruit.py +37 -35
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/core/adapter.py +10 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/core/commands.py +59 -8
- atsfill-0.0.4.dev94/src/atsfill/core/conditions.py +96 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/core/matching.py +19 -4
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/core/results.py +28 -1
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/LICENSE +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/README.md +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev94}/src/atsfill/py.typed +0 -0
- {atsfill-0.0.4.dev92 → 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
|
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
the employer's own domain), as docs/adapters/eightfold.md observes.
|
|
3
3
|
|
|
4
4
|
A posting's apply page (`/careers/apply?pid=<pid>`) renders its form from a questions API:
|
|
5
|
-
sections of questions by id, type, requirement and options, and the questions
|
|
6
|
-
|
|
7
|
-
and both list the fields from it. Each question renders as a control whose element id
|
|
8
|
-
section's title and its id, with every other character an underscore.
|
|
5
|
+
sections of questions by id, type, requirement and options, and the questions whose answer
|
|
6
|
+
shows each follow-up. `read_api` fetches it over HTTP, `read_form` through the page's browser
|
|
7
|
+
context, and both list the fields from it. Each question renders as a control whose element id
|
|
8
|
+
is its section's title and its id, with every other character an underscore.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
import http.cookiejar
|
|
@@ -13,15 +13,15 @@ import json
|
|
|
13
13
|
import re
|
|
14
14
|
import urllib.request
|
|
15
15
|
from contextlib import suppress
|
|
16
|
-
from dataclasses import dataclass,
|
|
16
|
+
from dataclasses import dataclass, replace
|
|
17
17
|
from datetime import date, datetime
|
|
18
18
|
from typing import Any
|
|
19
19
|
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
20
20
|
|
|
21
|
-
from atsfill.core import browser
|
|
21
|
+
from atsfill.core import browser, conditions
|
|
22
22
|
from atsfill.core.adapter import Blocked, Gap, NotTaken, Unsupported
|
|
23
23
|
from atsfill.core.browser import HTTP_TIMEOUT_S, Page, Timeout
|
|
24
|
-
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
24
|
+
from atsfill.core.results import Answer, AnyOf, Condition, Field, FieldType, Is, SideEffect
|
|
25
25
|
|
|
26
26
|
HOST = re.compile(r"^[\w-]+\.eightfold\.ai$")
|
|
27
27
|
JOB = re.compile(r"^/careers/job/(\d+)")
|
|
@@ -33,7 +33,6 @@ CSRF = re.compile(r'<meta name="_csrf" content="([^"]+)"')
|
|
|
33
33
|
SUBMIT = "[data-test-id=submitApplicationButton]"
|
|
34
34
|
AGREE = "#confirmUploadResume" # the privacy dialog a resume upload opens
|
|
35
35
|
PICKER = "[data-testid=picker-dialog]"
|
|
36
|
-
MAX_DEPTH = 5 # the deepest chain of questions rendering one another that a label names"
|
|
37
36
|
FORM_TIMEOUT_MS = 30_000
|
|
38
37
|
SHOWN_TIMEOUT_MS = 2_000
|
|
39
38
|
OPTIONS_TIMEOUT_MS = 5_000
|
|
@@ -131,8 +130,6 @@ class _Control:
|
|
|
131
130
|
field: Field
|
|
132
131
|
kind: str
|
|
133
132
|
ends: list[str] # what the control's element id ends with
|
|
134
|
-
# the questions whose answer renders it, each with the answers that do (any when empty)
|
|
135
|
-
parents: list[tuple[str, list[str]]] = field(default_factory=list)
|
|
136
133
|
resume: bool = False
|
|
137
134
|
dom: str | None = None
|
|
138
135
|
|
|
@@ -152,6 +149,7 @@ class Eightfold:
|
|
|
152
149
|
|
|
153
150
|
def __init__(self) -> None:
|
|
154
151
|
self._controls: list[_Control] = [] # in the form's order
|
|
152
|
+
self._fields: list[Field] = []
|
|
155
153
|
|
|
156
154
|
def detect(self, url: str, page: Page | None) -> bool:
|
|
157
155
|
if _pid(url) is None:
|
|
@@ -210,14 +208,16 @@ class Eightfold:
|
|
|
210
208
|
control.dom = doms.get(control.field.id)
|
|
211
209
|
if control.field.unfillable:
|
|
212
210
|
continue
|
|
213
|
-
if control.dom is None and not control.
|
|
211
|
+
if control.dom is None and not control.field.shown_when:
|
|
214
212
|
reason = f"unhandled_rendering: {control.field.id} is not on the page"
|
|
215
213
|
control.field = replace(control.field, unfillable=reason)
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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])
|
|
218
|
+
self._fields = [c.field for c in self._controls]
|
|
219
219
|
self._controls = [c for c in self._controls if not c.field.unfillable]
|
|
220
|
-
return
|
|
220
|
+
return self._fields
|
|
221
221
|
|
|
222
222
|
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
223
223
|
# the resume first: what its parse finds is written over the fields filled before it
|
|
@@ -226,8 +226,8 @@ class Eightfold:
|
|
|
226
226
|
for control in order:
|
|
227
227
|
if (answer := answers.get(control.field.id)) is None:
|
|
228
228
|
continue
|
|
229
|
-
if control.dom is None: #
|
|
230
|
-
if not
|
|
229
|
+
if control.dom is None: # a follow-up, rendered once its condition holds
|
|
230
|
+
if not conditions.Form(self._fields, given).shown(control.field):
|
|
231
231
|
continue
|
|
232
232
|
with suppress(Timeout):
|
|
233
233
|
_box_of(page, control).wait_for(timeout=SHOWN_TIMEOUT_MS)
|
|
@@ -242,10 +242,14 @@ class Eightfold:
|
|
|
242
242
|
doms = page.evaluate(_DOM_IDS, unplaced) if unplaced else {}
|
|
243
243
|
for control in self._controls:
|
|
244
244
|
control.dom = control.dom or doms.get(control.field.id)
|
|
245
|
-
|
|
246
|
-
|
|
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])
|
|
247
251
|
out: dict[str, Answer] = {}
|
|
248
|
-
for control in
|
|
252
|
+
for control in controls:
|
|
249
253
|
value = values.get(control.field.id)
|
|
250
254
|
if control.kind == "date" and value:
|
|
251
255
|
value = _iso(value)
|
|
@@ -320,21 +324,23 @@ def _plain(label: str) -> str:
|
|
|
320
324
|
return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", label)).strip()
|
|
321
325
|
|
|
322
326
|
|
|
323
|
-
def _label(q: dict[str, Any]
|
|
324
|
-
"""The question's text; one some of a parent's answers render says so, naming the first
|
|
325
|
-
parent by its own label, so that a parent asked for other answers names it apart."""
|
|
327
|
+
def _label(q: dict[str, Any]) -> str:
|
|
326
328
|
label = _plain(q.get("label") or "")
|
|
327
329
|
if (q.get("textOptions") or {}).get("type") == "number": # a number input takes no text
|
|
328
330
|
label += " (a number)"
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
331
|
+
return label
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _shown_when(q: dict[str, Any], by_id: dict[str, dict[str, Any]]) -> Condition | None:
|
|
335
|
+
"""What renders a follow-up: any of its parents' answers it lists, any answer where it
|
|
336
|
+
lists none."""
|
|
337
|
+
parents: list[Condition] = []
|
|
338
|
+
for dependency in q.get("parentDependencies") or []:
|
|
339
|
+
parent = by_id.get(str(dependency["questionId"]), {})
|
|
340
|
+
labels = {str(c["value"]): c["label"] for c in _choices(parent)}
|
|
341
|
+
values = [labels.get(str(v), str(v)) for v in dependency.get("values") or []]
|
|
342
|
+
parents.append(Is(str(dependency["questionId"]), values or None))
|
|
343
|
+
return parents[0] if len(parents) == 1 else AnyOf(parents) if parents else None
|
|
338
344
|
|
|
339
345
|
|
|
340
346
|
def _choices(q: dict[str, Any]) -> list[dict[str, Any]]:
|
|
@@ -356,33 +362,36 @@ def _form(data: dict[str, Any]) -> list[_Control]:
|
|
|
356
362
|
|
|
357
363
|
|
|
358
364
|
def _control(q: dict[str, Any], by_id: dict[str, dict[str, Any]]) -> _Control:
|
|
359
|
-
qid, label = str(q["questionId"]), _label(q, by_id)
|
|
365
|
+
qid, label, shown_when = str(q["questionId"]), _label(q), _shown_when(q, by_id)
|
|
360
366
|
kind = KINDS.get(q.get("type", ""))
|
|
361
367
|
choices = _choices(q)
|
|
362
368
|
if kind == "select" and not choices and (q.get("selectOptions") or {}).get("apiEndpoint"):
|
|
363
369
|
kind = "lookup" # options load once the question it depends on is answered
|
|
364
370
|
required = bool(q.get("required"))
|
|
365
|
-
parents = []
|
|
366
|
-
for dependency in q.get("parentDependencies") or []:
|
|
367
|
-
parent = by_id.get(str(dependency["questionId"]), {})
|
|
368
|
-
labels = {str(c["value"]): c["label"] for c in _choices(parent)}
|
|
369
|
-
values = [labels.get(str(v), str(v)) for v in dependency.get("values") or []]
|
|
370
|
-
parents.append((str(dependency["questionId"]), values))
|
|
371
|
-
if parents and parents[0][1]: # asked for some of its parent's answers only
|
|
372
|
-
required = False
|
|
373
371
|
ends = list(dict.fromkeys([_norm(qid), qid]))
|
|
374
372
|
if kind is None:
|
|
375
373
|
reason = f"unhandled_rendering: {qid} is a question of type {q.get('type')}"
|
|
376
|
-
|
|
374
|
+
unfillable = Field(qid, label, FieldType.TEXT, required, unfillable=reason)
|
|
375
|
+
return _Control(replace(unfillable, shown_when=shown_when), "", ends)
|
|
377
376
|
options = [c["label"] for c in choices] or None
|
|
378
377
|
accept = None
|
|
379
378
|
if kind == "file":
|
|
380
379
|
files = q.get("resumeOptions") or q.get("fileUploadOptions") or {}
|
|
381
380
|
types = files.get("acceptedFileTypes") or (files.get("fileFormats") or "").split(",")
|
|
382
381
|
accept = [t.strip() for t in types if t.strip()] or None
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
384
|
+
question = Field(
|
|
385
|
+
qid,
|
|
386
|
+
label,
|
|
387
|
+
TYPES[kind],
|
|
388
|
+
required,
|
|
389
|
+
options,
|
|
390
|
+
accept=accept,
|
|
391
|
+
prefilled=today,
|
|
392
|
+
shown_when=shown_when,
|
|
393
|
+
)
|
|
394
|
+
return _Control(question, kind, ends, q.get("type") == "resumeUpload")
|
|
386
395
|
|
|
387
396
|
|
|
388
397
|
def _find(page: Page, control: _Control) -> str | None:
|
|
@@ -398,15 +407,6 @@ def _box(page: Page, control: _Control):
|
|
|
398
407
|
return page.locator(f"[id={json.dumps(f'{control.dom}_error')}]").locator("xpath=..")
|
|
399
408
|
|
|
400
409
|
|
|
401
|
-
def _prefilled(page: Page, control: _Control) -> bool:
|
|
402
|
-
if control.kind in ("text", "select", "date"):
|
|
403
|
-
el = _box(page, control).locator("input:not([type=file]), textarea").first
|
|
404
|
-
return bool(el.count() and el.input_value())
|
|
405
|
-
if control.kind == "pills":
|
|
406
|
-
return _box(page, control).locator("[id^=selectPilllist]").count() > 0
|
|
407
|
-
return False
|
|
408
|
-
|
|
409
|
-
|
|
410
410
|
def _fill(page: Page, control: _Control, answer: Answer) -> None:
|
|
411
411
|
box = _box(page, control)
|
|
412
412
|
value = answer if isinstance(answer, str) else (answer[0] if answer else "")
|
|
@@ -533,13 +533,5 @@ def _iso(shown: str) -> str:
|
|
|
533
533
|
return shown
|
|
534
534
|
|
|
535
535
|
|
|
536
|
-
def _shows(answer: Answer | None, values: list[str]) -> bool:
|
|
537
|
-
"""Whether a parent's answer renders its dependent: any answer when `values` is empty."""
|
|
538
|
-
if answer in (None, "", []):
|
|
539
|
-
return False
|
|
540
|
-
picks = [answer] if isinstance(answer, str) else answer
|
|
541
|
-
return not values or any(p in values for p in picks)
|
|
542
|
-
|
|
543
|
-
|
|
544
536
|
def _exact(value: str) -> re.Pattern[str]:
|
|
545
537
|
return re.compile(rf"^\s*{re.escape(value)}\s*$")
|
|
@@ -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:
|