atsfill 0.0.4.dev43__tar.gz → 0.0.4.dev44__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.dev43 → atsfill-0.0.4.dev44}/PKG-INFO +1 -1
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/ashby.py +6 -4
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/bamboohr.py +2 -2
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/dayforce.py +14 -6
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/greenhouse.py +7 -5
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/hireology.py +2 -2
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/lever.py +2 -1
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/manatal.py +3 -3
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/oracle.py +8 -4
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/paylocity.py +15 -9
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/recruitee.py +2 -1
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/smartrecruiters.py +5 -3
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/teamtailor.py +4 -2
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/workable.py +5 -4
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/wttj.py +12 -4
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/core/browser.py +3 -1
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/LICENSE +0 -0
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/README.md +0 -0
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/py.typed +0 -0
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/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.dev44
|
|
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
|
|
@@ -13,7 +13,7 @@ from typing import Any
|
|
|
13
13
|
from urllib.parse import parse_qs, urlsplit
|
|
14
14
|
|
|
15
15
|
from atsfill.core.adapter import Blocked, Unsupported
|
|
16
|
-
from atsfill.core.browser import Page, Response, Timeout
|
|
16
|
+
from atsfill.core.browser import HTTP_TIMEOUT_S, Page, Response, Timeout
|
|
17
17
|
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
18
18
|
|
|
19
19
|
HOST = "jobs.ashbyhq.com"
|
|
@@ -47,6 +47,8 @@ SETTLE_S = 1.5
|
|
|
47
47
|
READ_BACK_TIMEOUT_S = 30
|
|
48
48
|
SAVE_TIMEOUT_MS = 30_000
|
|
49
49
|
SUBMIT_TIMEOUT_MS = 90_000
|
|
50
|
+
SAVE_POLL_MS = 100
|
|
51
|
+
READ_BACK_POLL_MS = 250
|
|
50
52
|
|
|
51
53
|
|
|
52
54
|
def _hosted(url: str) -> str:
|
|
@@ -112,7 +114,7 @@ class Ashby:
|
|
|
112
114
|
},
|
|
113
115
|
)
|
|
114
116
|
try:
|
|
115
|
-
with urllib.request.urlopen(request, timeout=
|
|
117
|
+
with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
|
|
116
118
|
job_posting = json.load(response)["data"]["jobPosting"]
|
|
117
119
|
if not job_posting:
|
|
118
120
|
return None
|
|
@@ -180,7 +182,7 @@ class Ashby:
|
|
|
180
182
|
return
|
|
181
183
|
deadline = time.monotonic() + SAVE_TIMEOUT_MS / 1000
|
|
182
184
|
while self._pending and time.monotonic() < deadline:
|
|
183
|
-
page.wait_for_timeout(
|
|
185
|
+
page.wait_for_timeout(SAVE_POLL_MS)
|
|
184
186
|
|
|
185
187
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
186
188
|
"""Ashby's last returned form, once every answer is held and no request is in flight."""
|
|
@@ -190,7 +192,7 @@ class Ashby:
|
|
|
190
192
|
quiet = not self._pending and time.monotonic() - self._last_change > SETTLE_S
|
|
191
193
|
if quiet and all(values.get(i) is not None for i in self._answered):
|
|
192
194
|
break
|
|
193
|
-
page.wait_for_timeout(
|
|
195
|
+
page.wait_for_timeout(READ_BACK_POLL_MS)
|
|
194
196
|
return {k: v for k, v in self._values().items() if v is not None}
|
|
195
197
|
|
|
196
198
|
def submit(self, page: Page) -> str | None:
|
|
@@ -15,7 +15,7 @@ from urllib.parse import urlsplit
|
|
|
15
15
|
|
|
16
16
|
from atsfill.core import browser
|
|
17
17
|
from atsfill.core.adapter import Blocked, Unsupported
|
|
18
|
-
from atsfill.core.browser import Locator, Page, Timeout
|
|
18
|
+
from atsfill.core.browser import HTTP_TIMEOUT_S, Locator, Page, Timeout
|
|
19
19
|
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
20
20
|
|
|
21
21
|
HOST = re.compile(r"^[\w-]+\.bamboohr\.com$")
|
|
@@ -307,7 +307,7 @@ def _job(url: str) -> tuple[str, str] | None:
|
|
|
307
307
|
|
|
308
308
|
def _get(url: str) -> Any:
|
|
309
309
|
request = urllib.request.Request(url, headers={"User-Agent": "atsfill"})
|
|
310
|
-
with urllib.request.urlopen(request, timeout=
|
|
310
|
+
with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
|
|
311
311
|
return json.load(response)
|
|
312
312
|
|
|
313
313
|
|
|
@@ -32,6 +32,7 @@ FORM_TIMEOUT_MS = 30_000
|
|
|
32
32
|
SETTLE_MS = 2_500
|
|
33
33
|
UPLOAD_TIMEOUT_MS = 30_000
|
|
34
34
|
STEP_TIMEOUT_MS = 20_000
|
|
35
|
+
UPDATE_TIMEOUT_MS = 5_000 # for a section to close, per Update click
|
|
35
36
|
SUBMIT_TIMEOUT_MS = 90_000
|
|
36
37
|
UPDATE_TRIES = 3
|
|
37
38
|
FOLLOWUP_TIMEOUT_MS = 3_000
|
|
@@ -40,6 +41,7 @@ OPTIONS_TIMEOUT_MS = 5_000 # for a dropdown's options to render
|
|
|
40
41
|
NEXT_TRIES = 3
|
|
41
42
|
STEP_POLLS = 16 # of POLL_MS, before Next is clicked again
|
|
42
43
|
POLL_MS = 500
|
|
44
|
+
KEY_DELAY_MS = 10
|
|
43
45
|
YES_NO = ["Yes", "No"]
|
|
44
46
|
NO_YES = ["No", "Yes"] # a box left as it is first
|
|
45
47
|
LONG_LABEL = 300 # a question whose description is a whole form is named by its display name
|
|
@@ -700,7 +702,7 @@ def _pick(page: Page, target: Locator, texts: list[str]) -> bool:
|
|
|
700
702
|
options = page.locator(_option(target))
|
|
701
703
|
if target.get_attribute("readonly") is None:
|
|
702
704
|
target.fill("")
|
|
703
|
-
target.press_sequentially(text[:40], delay=
|
|
705
|
+
target.press_sequentially(text[:40], delay=KEY_DELAY_MS)
|
|
704
706
|
with suppress(Timeout):
|
|
705
707
|
options.first.wait_for(timeout=OPTIONS_TIMEOUT_MS)
|
|
706
708
|
page.evaluate(_REVEAL, [target.get_attribute("id"), [text]])
|
|
@@ -750,7 +752,7 @@ def _fill_control(page: Page, selector: str, field: Field, answer: Answer) -> No
|
|
|
750
752
|
def _pick_suggestion(page: Page, target: Locator, value: str) -> None:
|
|
751
753
|
"""Types the answer and clicks the first option that starts with it."""
|
|
752
754
|
_open(target)
|
|
753
|
-
target.press_sequentially(value[:40], delay=
|
|
755
|
+
target.press_sequentially(value[:40], delay=KEY_DELAY_MS)
|
|
754
756
|
with suppress(Timeout):
|
|
755
757
|
page.locator(_option(target)).first.wait_for(timeout=OPTIONS_TIMEOUT_MS)
|
|
756
758
|
if not _click_option(page, target, lambda shown: shown.startswith(_plain(value).casefold())):
|
|
@@ -768,7 +770,7 @@ def _fill_phone(page: Page, selector: str, value: str) -> None:
|
|
|
768
770
|
code = digits[:size]
|
|
769
771
|
_open(dial)
|
|
770
772
|
dial.fill("")
|
|
771
|
-
dial.press_sequentially(f"+{code}", delay=
|
|
773
|
+
dial.press_sequentially(f"+{code}", delay=KEY_DELAY_MS)
|
|
772
774
|
options = page.locator(_option(dial)).filter(has_text=re.compile(rf"\+{code}(\s|$)"))
|
|
773
775
|
with suppress(Timeout):
|
|
774
776
|
options.first.wait_for(timeout=DIAL_TIMEOUT_MS)
|
|
@@ -844,7 +846,7 @@ def _update(page: Page, field: str) -> None:
|
|
|
844
846
|
button.evaluate("e => e.scrollIntoView({block: 'center'})")
|
|
845
847
|
button.click()
|
|
846
848
|
with suppress(Timeout):
|
|
847
|
-
first.wait_for(state="detached", timeout=
|
|
849
|
+
first.wait_for(state="detached", timeout=UPDATE_TIMEOUT_MS)
|
|
848
850
|
return
|
|
849
851
|
if page.locator(".ant-form-item-explain-error:visible").count():
|
|
850
852
|
return # the section refuses its answers: Next reports the stop
|
|
@@ -854,7 +856,7 @@ def _next(page: Page) -> bool:
|
|
|
854
856
|
"""Goes to the next step; False when the page stays (a validation error), or when the
|
|
855
857
|
next button is Submit, which `fill` never presses."""
|
|
856
858
|
button = page.locator(NEXT)
|
|
857
|
-
if not
|
|
859
|
+
if not _says_next(button):
|
|
858
860
|
return False
|
|
859
861
|
before = _controls(page)
|
|
860
862
|
for _ in range(NEXT_TRIES): # a click right after a section's save can go unheeded
|
|
@@ -863,7 +865,7 @@ def _next(page: Page) -> bool:
|
|
|
863
865
|
button.first.click()
|
|
864
866
|
for _ in range(STEP_POLLS):
|
|
865
867
|
page.wait_for_timeout(POLL_MS)
|
|
866
|
-
if _controls(page) != before:
|
|
868
|
+
if _controls(page) != before or not _says_next(button): # Submit: the last step
|
|
867
869
|
page.wait_for_timeout(POLL_MS) # the step renders its controls in turn
|
|
868
870
|
return True
|
|
869
871
|
if page.locator(".ant-form-item-explain-error:visible").count():
|
|
@@ -871,6 +873,12 @@ def _next(page: Page) -> bool:
|
|
|
871
873
|
return False
|
|
872
874
|
|
|
873
875
|
|
|
876
|
+
def _says_next(button: Locator) -> bool:
|
|
877
|
+
return bool(button.count()) and bool(
|
|
878
|
+
re.search(r"next", button.first.inner_text(), re.IGNORECASE)
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
|
|
874
882
|
def _controls(page: Page) -> list[str]:
|
|
875
883
|
"""The ids of the step's controls: what changes when the wizard moves on, not a control's
|
|
876
884
|
error (`<id>_help`) showing."""
|
|
@@ -15,7 +15,7 @@ from urllib.parse import parse_qs, urlencode, urlsplit
|
|
|
15
15
|
|
|
16
16
|
from atsfill.core import browser
|
|
17
17
|
from atsfill.core.adapter import Unsupported
|
|
18
|
-
from atsfill.core.browser import Page, Timeout
|
|
18
|
+
from atsfill.core.browser import HTTP_TIMEOUT_S, Page, Timeout
|
|
19
19
|
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
20
20
|
|
|
21
21
|
HOSTS = re.compile(r"^(job-boards|boards)(\.eu)?\.greenhouse\.io$")
|
|
@@ -28,6 +28,8 @@ HYDRATE_TIMEOUT_MS = 15_000
|
|
|
28
28
|
FORM_TIMEOUT_MS = 20_000
|
|
29
29
|
ANSWER_TIMEOUT_MS = 15_000 # an upload's file name, a suggestion: past it, a mismatch
|
|
30
30
|
SUBMIT_TIMEOUT_MS = 90_000
|
|
31
|
+
HYDRATE_POLL_MS = 500 # for a select's list to open, per click
|
|
32
|
+
KEY_DELAY_MS = 20
|
|
31
33
|
API_TYPES = {
|
|
32
34
|
"input_text": FieldType.TEXT,
|
|
33
35
|
"textarea": FieldType.TEXT,
|
|
@@ -163,7 +165,7 @@ class Greenhouse:
|
|
|
163
165
|
request = urllib.request.Request(
|
|
164
166
|
API.format(board=found[0], job=found[1]), headers={"User-Agent": "atsfill"}
|
|
165
167
|
)
|
|
166
|
-
with urllib.request.urlopen(request, timeout=
|
|
168
|
+
with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
|
|
167
169
|
return _api_fields(json.load(response))
|
|
168
170
|
except OSError, ValueError, KeyError, TypeError:
|
|
169
171
|
return None
|
|
@@ -205,7 +207,7 @@ class Greenhouse:
|
|
|
205
207
|
for value in values:
|
|
206
208
|
_select(page, field_id, value, suggested)
|
|
207
209
|
case "tel": # the number's dial code picks the country
|
|
208
|
-
page.locator(_by_id(field_id)).press_sequentially(values[0], delay=
|
|
210
|
+
page.locator(_by_id(field_id)).press_sequentially(values[0], delay=KEY_DELAY_MS)
|
|
209
211
|
case "number" if not re.fullmatch(r"-?\d+(\.\d+)?", values[0]):
|
|
210
212
|
pass # the input takes no text: left empty, it reads back as a mismatch
|
|
211
213
|
case _:
|
|
@@ -277,7 +279,7 @@ def _wait_until_live(page: Page) -> None:
|
|
|
277
279
|
while time.monotonic() < deadline:
|
|
278
280
|
select.click()
|
|
279
281
|
try:
|
|
280
|
-
listbox.first.wait_for(timeout=
|
|
282
|
+
listbox.first.wait_for(timeout=HYDRATE_POLL_MS)
|
|
281
283
|
except Timeout:
|
|
282
284
|
continue
|
|
283
285
|
select.press("Escape")
|
|
@@ -335,7 +337,7 @@ def _select(page: Page, field_id: str, value: str, suggested: bool) -> None:
|
|
|
335
337
|
takes. A suggestion list takes its first suggestion. No option leaves the select empty."""
|
|
336
338
|
box = page.locator(_by_id(field_id))
|
|
337
339
|
box.click()
|
|
338
|
-
box.press_sequentially(value, delay=
|
|
340
|
+
box.press_sequentially(value, delay=KEY_DELAY_MS)
|
|
339
341
|
menu = page.locator(f"{_by_id(f'react-select-{field_id}-listbox')} [role=option]")
|
|
340
342
|
option = menu.first if suggested else menu.filter(has_text=_exact(value)).first
|
|
341
343
|
try:
|
|
@@ -16,7 +16,7 @@ from urllib.parse import urlsplit
|
|
|
16
16
|
|
|
17
17
|
from atsfill.core import browser
|
|
18
18
|
from atsfill.core.adapter import Unsupported
|
|
19
|
-
from atsfill.core.browser import Page, Timeout
|
|
19
|
+
from atsfill.core.browser import HTTP_TIMEOUT_S, Page, Timeout
|
|
20
20
|
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
21
21
|
|
|
22
22
|
HOST = "careers.hireology.com"
|
|
@@ -204,7 +204,7 @@ class Hireology:
|
|
|
204
204
|
return None
|
|
205
205
|
request = urllib.request.Request(API.format(job=job), headers={"User-Agent": "atsfill"})
|
|
206
206
|
try:
|
|
207
|
-
with urllib.request.urlopen(request, timeout=
|
|
207
|
+
with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
|
|
208
208
|
return [f for f, _ in _form(json.load(response))]
|
|
209
209
|
except OSError, ValueError, KeyError, TypeError, Unsupported:
|
|
210
210
|
return None
|
|
@@ -23,6 +23,7 @@ FORM_TIMEOUT_MS = 30_000
|
|
|
23
23
|
UPLOAD_TIMEOUT_MS = 30_000
|
|
24
24
|
SUGGEST_TIMEOUT_MS = 10_000
|
|
25
25
|
SUBMIT_TIMEOUT_MS = 90_000
|
|
26
|
+
KEY_DELAY_MS = 40
|
|
26
27
|
YES_NO = ["Yes", "No"]
|
|
27
28
|
KINDS = {
|
|
28
29
|
"text": FieldType.TEXT,
|
|
@@ -245,7 +246,7 @@ def _attach(control: Locator, path: str, resume: bool) -> None:
|
|
|
245
246
|
def _locate(page: Page, control: Locator, value: str) -> None:
|
|
246
247
|
"""Types the location and picks Lever's first suggestion: the field takes only a pick."""
|
|
247
248
|
control.fill("")
|
|
248
|
-
control.press_sequentially(value, delay=
|
|
249
|
+
control.press_sequentially(value, delay=KEY_DELAY_MS)
|
|
249
250
|
suggestion = page.locator(".dropdown-results .dropdown-location").first
|
|
250
251
|
with suppress(Timeout):
|
|
251
252
|
suggestion.wait_for(timeout=SUGGEST_TIMEOUT_MS)
|
|
@@ -12,7 +12,7 @@ from typing import Any
|
|
|
12
12
|
from urllib.parse import urlsplit
|
|
13
13
|
|
|
14
14
|
from atsfill.core.adapter import Blocked, Unsupported
|
|
15
|
-
from atsfill.core.browser import Page
|
|
15
|
+
from atsfill.core.browser import HTTP_TIMEOUT_S, Page
|
|
16
16
|
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
17
17
|
|
|
18
18
|
HOST = "www.careers-page.com"
|
|
@@ -136,7 +136,7 @@ def _field(entry: dict[str, Any], options: list[str] | None) -> Field | None:
|
|
|
136
136
|
|
|
137
137
|
def _get(path: str) -> Any:
|
|
138
138
|
request = urllib.request.Request(API + path, headers=HEADERS)
|
|
139
|
-
with urllib.request.urlopen(request, timeout=
|
|
139
|
+
with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
|
|
140
140
|
return json.load(response)
|
|
141
141
|
|
|
142
142
|
|
|
@@ -177,7 +177,7 @@ class Manatal:
|
|
|
177
177
|
client = urlsplit(url).path.strip("/").split("/")[0]
|
|
178
178
|
try:
|
|
179
179
|
apply = urllib.request.Request(_posting(url) + "/apply", headers=HEADERS)
|
|
180
|
-
with urllib.request.urlopen(apply, timeout=
|
|
180
|
+
with urllib.request.urlopen(apply, timeout=HTTP_TIMEOUT_S) as response:
|
|
181
181
|
html = response.read().decode()
|
|
182
182
|
job = JOB_ID.search(html)
|
|
183
183
|
if job is None:
|
|
@@ -38,6 +38,10 @@ COOKIES = ".cookie-consent button:visible"
|
|
|
38
38
|
ATTACHED = ".attachment-upload-button__download, .misc-attachment-list__item-name"
|
|
39
39
|
STEP_TIMEOUT_MS = 30_000
|
|
40
40
|
LIST_TIMEOUT_MS = 3_000
|
|
41
|
+
SEARCH_TIMEOUT_MS = 6_000 # for a typed search's results
|
|
42
|
+
PAGE_TIMEOUT_MS = 15_000 # per click on a page number
|
|
43
|
+
SUBMIT_TIMEOUT_MS = 90_000
|
|
44
|
+
KEY_DELAY_MS = 20
|
|
41
45
|
SETTLE_MS = 1_500
|
|
42
46
|
UPLOAD_TIMEOUT_MS = 30_000
|
|
43
47
|
CHECKBOX = ["Yes", "No"]
|
|
@@ -327,7 +331,7 @@ class Oracle:
|
|
|
327
331
|
def submit(self, page: Page) -> str | None:
|
|
328
332
|
_go_to(page, len(_page_numbers(page)) or 1)
|
|
329
333
|
_click(page, page.locator(SUBMIT))
|
|
330
|
-
page.wait_for_url(lambda url: "/apply/" not in url, timeout=
|
|
334
|
+
page.wait_for_url(lambda url: "/apply/" not in url, timeout=SUBMIT_TIMEOUT_MS)
|
|
331
335
|
return page.url
|
|
332
336
|
|
|
333
337
|
def _email_step(self, page: Page) -> str | None:
|
|
@@ -535,7 +539,7 @@ def _go_to(page: Page, number: int) -> None:
|
|
|
535
539
|
with suppress(browser.Timeout):
|
|
536
540
|
page.wait_for_url(
|
|
537
541
|
re.compile(rf"/apply/section/{number}\b"),
|
|
538
|
-
timeout=
|
|
542
|
+
timeout=PAGE_TIMEOUT_MS,
|
|
539
543
|
wait_until="commit",
|
|
540
544
|
)
|
|
541
545
|
_settle(page)
|
|
@@ -593,10 +597,10 @@ def _choose(
|
|
|
593
597
|
_click(page, box)
|
|
594
598
|
box.fill("")
|
|
595
599
|
if typed:
|
|
596
|
-
box.press_sequentially(typed, delay=
|
|
600
|
+
box.press_sequentially(typed, delay=KEY_DELAY_MS)
|
|
597
601
|
cells = page.locator(f"[id='{box.get_attribute('aria-controls')}'] [role=gridcell]")
|
|
598
602
|
with suppress(browser.Timeout):
|
|
599
|
-
cells.first.wait_for(timeout=
|
|
603
|
+
cells.first.wait_for(timeout=SEARCH_TIMEOUT_MS)
|
|
600
604
|
for i in range(cells.count()):
|
|
601
605
|
item = _text(cells.nth(i).inner_text())
|
|
602
606
|
if (
|
|
@@ -19,7 +19,7 @@ from typing import Any
|
|
|
19
19
|
from urllib.parse import urlsplit
|
|
20
20
|
|
|
21
21
|
from atsfill.core.adapter import Unsupported
|
|
22
|
-
from atsfill.core.browser import Locator, Page, Timeout
|
|
22
|
+
from atsfill.core.browser import HTTP_TIMEOUT_S, Locator, Page, Timeout
|
|
23
23
|
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
24
24
|
|
|
25
25
|
HOST = "recruiting.paylocity.com"
|
|
@@ -35,6 +35,12 @@ COOKIE_TIMEOUT_MS = 5_000
|
|
|
35
35
|
STEP_TIMEOUT_MS = 20_000
|
|
36
36
|
UPLOAD_TIMEOUT_MS = 30_000
|
|
37
37
|
SUBMIT_TIMEOUT_MS = 90_000
|
|
38
|
+
STATE_TIMEOUT_MS = 5_000
|
|
39
|
+
RENDER_MS = 500 # for a new step to render its controls
|
|
40
|
+
KEY_DELAY_MS = 30
|
|
41
|
+
ADDRESS_KEY_DELAY_MS = 80 # its suggestion list drops keys typed while it loads
|
|
42
|
+
STATE_KEY_DELAY_MS = 40
|
|
43
|
+
TAG_KEY_DELAY_MS = 20
|
|
38
44
|
YES_NO = ["Yes", "No"]
|
|
39
45
|
ADDRESS = "public-site-address" # an address block: its inputs' ids start with this
|
|
40
46
|
COMPANY = "public-candidate-work-history-address-0"
|
|
@@ -297,7 +303,7 @@ class Paylocity:
|
|
|
297
303
|
return None
|
|
298
304
|
request = urllib.request.Request(APPLY + job, headers={"User-Agent": "atsfill"})
|
|
299
305
|
try:
|
|
300
|
-
with urllib.request.urlopen(request, timeout=
|
|
306
|
+
with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
|
|
301
307
|
html = response.read().decode()
|
|
302
308
|
return [f for f, _ in _form(_page_data(html))]
|
|
303
309
|
except OSError, ValueError, KeyError, TypeError, Unsupported:
|
|
@@ -511,7 +517,7 @@ def _next(page: Page) -> bool:
|
|
|
511
517
|
)
|
|
512
518
|
except Timeout:
|
|
513
519
|
return False
|
|
514
|
-
page.wait_for_timeout(
|
|
520
|
+
page.wait_for_timeout(RENDER_MS)
|
|
515
521
|
return True
|
|
516
522
|
|
|
517
523
|
|
|
@@ -536,7 +542,7 @@ def _fill(page: Page, control: Control, answer: Answer) -> None:
|
|
|
536
542
|
case "address": # a suggestion list that drops keys typed while it loads
|
|
537
543
|
target.click()
|
|
538
544
|
target.fill("")
|
|
539
|
-
target.press_sequentially(value, delay=
|
|
545
|
+
target.press_sequentially(value, delay=ADDRESS_KEY_DELAY_MS)
|
|
540
546
|
page.keyboard.press("Escape")
|
|
541
547
|
if target.input_value() != value:
|
|
542
548
|
target.fill(value)
|
|
@@ -545,22 +551,22 @@ def _fill(page: Page, control: Control, answer: Answer) -> None:
|
|
|
545
551
|
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
|
|
546
552
|
y, m, d = value.split("-")
|
|
547
553
|
target.click()
|
|
548
|
-
target.press_sequentially(f"{m}/{d}/{y}", delay=
|
|
554
|
+
target.press_sequentially(f"{m}/{d}/{y}", delay=KEY_DELAY_MS)
|
|
549
555
|
target.blur()
|
|
550
556
|
case "month":
|
|
551
557
|
target.click()
|
|
552
|
-
target.press_sequentially(value, delay=
|
|
558
|
+
target.press_sequentially(value, delay=KEY_DELAY_MS)
|
|
553
559
|
target.blur()
|
|
554
560
|
case "dropdown":
|
|
555
561
|
_pick(page, control.dom, value)
|
|
556
562
|
case "state":
|
|
557
563
|
page.locator(f"#{control.dom}-select-wrapper").click()
|
|
558
|
-
page.keyboard.type(value, delay=
|
|
564
|
+
page.keyboard.type(value, delay=STATE_KEY_DELAY_MS)
|
|
559
565
|
item = page.locator(
|
|
560
566
|
f"#{control.dom}-dropdown-list-container [role=listitem][title='{value}']"
|
|
561
567
|
)
|
|
562
568
|
try:
|
|
563
|
-
item.first.click(timeout=
|
|
569
|
+
item.first.click(timeout=STATE_TIMEOUT_MS)
|
|
564
570
|
except Timeout:
|
|
565
571
|
page.keyboard.press("Escape")
|
|
566
572
|
case "radio":
|
|
@@ -570,7 +576,7 @@ def _fill(page: Page, control: Control, answer: Answer) -> None:
|
|
|
570
576
|
target.check(force=True) if value == "Yes" else target.uncheck(force=True)
|
|
571
577
|
case "tags": # takes typed keys only
|
|
572
578
|
target.click()
|
|
573
|
-
target.press_sequentially(value, delay=
|
|
579
|
+
target.press_sequentially(value, delay=TAG_KEY_DELAY_MS)
|
|
574
580
|
target.press("Enter")
|
|
575
581
|
case "file":
|
|
576
582
|
target.set_input_files(answer if isinstance(answer, list) else value)
|
|
@@ -20,6 +20,7 @@ CAREER_SITE = "script[src*='/js/PublicApp/'], script[src*='recruiteecdn.com']"
|
|
|
20
20
|
FORM = "form#offer-application-form"
|
|
21
21
|
FORM_TIMEOUT_MS = 30_000
|
|
22
22
|
SUBMIT_TIMEOUT_MS = 90_000
|
|
23
|
+
KEY_DELAY_MS = 20
|
|
23
24
|
YES_NO = ["Yes", "No"]
|
|
24
25
|
EXTENSIONS = {"image/png": [".png"], "image/jpeg": [".jpg", ".jpeg"], "application/pdf": [".pdf"]}
|
|
25
26
|
KINDS = {
|
|
@@ -164,7 +165,7 @@ class Recruitee:
|
|
|
164
165
|
_set(controls.first, answer == "Yes")
|
|
165
166
|
case "phone": # the country picker follows the number's own prefix
|
|
166
167
|
controls.first.fill("")
|
|
167
|
-
controls.first.press_sequentially(values[0], delay=
|
|
168
|
+
controls.first.press_sequentially(values[0], delay=KEY_DELAY_MS)
|
|
168
169
|
case _:
|
|
169
170
|
controls.first.fill(values[0])
|
|
170
171
|
|
|
@@ -29,6 +29,8 @@ OPTION_TIMEOUT_MS = 10_000
|
|
|
29
29
|
UPLOAD_TIMEOUT_MS = 30_000
|
|
30
30
|
SUBMIT_TIMEOUT_MS = 90_000
|
|
31
31
|
NEXT_TIMEOUT_MS = 8_000
|
|
32
|
+
KEY_DELAY_MS = 20
|
|
33
|
+
LOCATION_KEY_DELAY_MS = 30
|
|
32
34
|
NEXT_TRIES = 3
|
|
33
35
|
YES_NO = ["Yes", "No"]
|
|
34
36
|
|
|
@@ -307,7 +309,7 @@ def _fill_profile(page: Page, field_id: str, selector: str, answer: Answer) -> N
|
|
|
307
309
|
)
|
|
308
310
|
case "location": # takes one of its suggestions: the first for the typed text
|
|
309
311
|
target.click()
|
|
310
|
-
target.press_sequentially(value, delay=
|
|
312
|
+
target.press_sequentially(value, delay=LOCATION_KEY_DELAY_MS)
|
|
311
313
|
options = page.locator("oc-location-autocomplete spl-select-option")
|
|
312
314
|
option = options.filter(has_text=re.compile(rf"^\s*{re.escape(value)}", re.I)).first
|
|
313
315
|
try:
|
|
@@ -317,7 +319,7 @@ def _fill_profile(page: Page, field_id: str, selector: str, answer: Answer) -> N
|
|
|
317
319
|
return
|
|
318
320
|
option.click()
|
|
319
321
|
case "phone": # the number's dial code picks the country
|
|
320
|
-
target.press_sequentially(value, delay=
|
|
322
|
+
target.press_sequentially(value, delay=KEY_DELAY_MS)
|
|
321
323
|
case _:
|
|
322
324
|
target.fill(value)
|
|
323
325
|
|
|
@@ -381,7 +383,7 @@ def _fill_question(
|
|
|
381
383
|
if option_id is None:
|
|
382
384
|
continue
|
|
383
385
|
target.click()
|
|
384
|
-
target.press_sequentially(value[:20], delay=
|
|
386
|
+
target.press_sequentially(value[:20], delay=KEY_DELAY_MS)
|
|
385
387
|
option = box.locator(f"spl-select-option[value='{option_id}']").first
|
|
386
388
|
try:
|
|
387
389
|
option.wait_for(timeout=OPTION_TIMEOUT_MS)
|
|
@@ -28,6 +28,8 @@ JOB = re.compile(r"/jobs/(\d+)[^/]*")
|
|
|
28
28
|
SUBMIT_TIMEOUT_MS = 90_000
|
|
29
29
|
FOLLOW_UP_TIMEOUT_MS = 1_000
|
|
30
30
|
UPLOAD_TIMEOUT_S = 60
|
|
31
|
+
UPLOAD_POLL_MS = 500
|
|
32
|
+
KEY_DELAY_MS = 30
|
|
31
33
|
QUESTION_TYPES = {
|
|
32
34
|
"text": FieldType.TEXT,
|
|
33
35
|
"number": FieldType.TEXT,
|
|
@@ -307,7 +309,7 @@ class Teamtailor:
|
|
|
307
309
|
held = form.evaluate(_READ_BACK, QUESTION)
|
|
308
310
|
if all(held.get(i) == _stored_name(n) for i, n in self._uploads.items()):
|
|
309
311
|
return
|
|
310
|
-
form.page.wait_for_timeout(
|
|
312
|
+
form.page.wait_for_timeout(UPLOAD_POLL_MS)
|
|
311
313
|
|
|
312
314
|
|
|
313
315
|
def _stored_name(name: str) -> str:
|
|
@@ -358,7 +360,7 @@ def _suggest(page: Page, field: Field, host: Locator, answer: str) -> None:
|
|
|
358
360
|
matches it: the form keeps an address only once a suggestion is picked."""
|
|
359
361
|
box = host.locator("input[role=combobox]")
|
|
360
362
|
box.fill("")
|
|
361
|
-
box.press_sequentially(answer, delay=
|
|
363
|
+
box.press_sequentially(answer, delay=KEY_DELAY_MS)
|
|
362
364
|
options = host.locator("[role=option]")
|
|
363
365
|
options.first.wait_for()
|
|
364
366
|
for i in range(options.count()):
|
|
@@ -16,7 +16,7 @@ from urllib.parse import urlsplit
|
|
|
16
16
|
|
|
17
17
|
from atsfill.core import browser
|
|
18
18
|
from atsfill.core.adapter import Unsupported
|
|
19
|
-
from atsfill.core.browser import Locator, Page, Timeout
|
|
19
|
+
from atsfill.core.browser import HTTP_TIMEOUT_S, Locator, Page, Timeout
|
|
20
20
|
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
21
21
|
|
|
22
22
|
HOSTS = re.compile(r"^(apply|[\w-]+)\.workable\.com$")
|
|
@@ -31,6 +31,7 @@ OPTIONS_TIMEOUT_MS = 5_000
|
|
|
31
31
|
CHECK_TIMEOUT_MS = 3_000
|
|
32
32
|
UPLOAD_TIMEOUT_MS = 30_000
|
|
33
33
|
SUBMIT_TIMEOUT_MS = 90_000
|
|
34
|
+
KEY_DELAY_MS = 20
|
|
34
35
|
YES_NO = ["Yes", "No"]
|
|
35
36
|
API_TYPES = {
|
|
36
37
|
"text": FieldType.TEXT,
|
|
@@ -165,7 +166,7 @@ class Workable:
|
|
|
165
166
|
request = urllib.request.Request(
|
|
166
167
|
API.format(code=code), headers={"User-Agent": "atsfill"}
|
|
167
168
|
)
|
|
168
|
-
with urllib.request.urlopen(request, timeout=
|
|
169
|
+
with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
|
|
169
170
|
return _api_fields(json.load(response))
|
|
170
171
|
except OSError, ValueError, KeyError, TypeError:
|
|
171
172
|
return None
|
|
@@ -210,11 +211,11 @@ class Workable:
|
|
|
210
211
|
case "phone": # the number's dial code picks the country
|
|
211
212
|
number = field.locator("input[type=tel]")
|
|
212
213
|
number.fill("")
|
|
213
|
-
number.press_sequentially(values[0], delay=
|
|
214
|
+
number.press_sequentially(values[0], delay=KEY_DELAY_MS)
|
|
214
215
|
case "date":
|
|
215
216
|
if typed := _typed_date(values[0], self._formats[field_id]):
|
|
216
217
|
field.click()
|
|
217
|
-
field.press_sequentially(typed, delay=
|
|
218
|
+
field.press_sequentially(typed, delay=KEY_DELAY_MS)
|
|
218
219
|
field.press("Tab")
|
|
219
220
|
case "yes_no":
|
|
220
221
|
value = {"Yes": "true", "No": "false"}.get(values[0])
|
|
@@ -25,6 +25,10 @@ SUBMIT_TIMEOUT_MS = 90_000
|
|
|
25
25
|
SUGGEST_TRIES = 3
|
|
26
26
|
LOCATION_TIMEOUT_S = 3
|
|
27
27
|
SUGGEST_TIMEOUT_S = 5
|
|
28
|
+
SIGNED_IN_POLL_MS = 250
|
|
29
|
+
LOCATION_POLL_MS = 200
|
|
30
|
+
SUGGEST_POLL_MS = 250
|
|
31
|
+
KEY_DELAY_MS = 30
|
|
28
32
|
YES_NO = ["Yes", "No"]
|
|
29
33
|
|
|
30
34
|
# The fillable fields: an input's name is its id. Email comes from the account, disabled.
|
|
@@ -86,7 +90,9 @@ class WelcomeToTheJungle:
|
|
|
86
90
|
href = apply.get_attribute("href")
|
|
87
91
|
deadline = time.monotonic() + SIGNED_IN_TIMEOUT_S
|
|
88
92
|
while href and "/signin" in href and time.monotonic() < deadline:
|
|
89
|
-
page.wait_for_timeout(
|
|
93
|
+
page.wait_for_timeout(
|
|
94
|
+
SIGNED_IN_POLL_MS
|
|
95
|
+
) # rendered for a visitor until the page knows the account
|
|
90
96
|
href = apply.get_attribute("href")
|
|
91
97
|
if href and "/signin" in href:
|
|
92
98
|
raise Blocked("login_required: run `atsfill login wttj`")
|
|
@@ -106,7 +112,9 @@ class WelcomeToTheJungle:
|
|
|
106
112
|
location = form.locator("[name=location]")
|
|
107
113
|
deadline = time.monotonic() + LOCATION_TIMEOUT_S
|
|
108
114
|
while location.count() and not location.input_value() and time.monotonic() < deadline:
|
|
109
|
-
page.wait_for_timeout(
|
|
115
|
+
page.wait_for_timeout(
|
|
116
|
+
LOCATION_POLL_MS
|
|
117
|
+
) # the profile's location comes last, a second later
|
|
110
118
|
fields = []
|
|
111
119
|
for raw in form.evaluate(_READ):
|
|
112
120
|
field = _field(raw)
|
|
@@ -166,14 +174,14 @@ def _suggest(page: Page, field: Field, box: Locator, answer: str) -> None:
|
|
|
166
174
|
box.click()
|
|
167
175
|
box.press("Control+a") # `fill("")`, select-all and Delete, leaves the profile's value
|
|
168
176
|
box.press("Backspace")
|
|
169
|
-
box.press_sequentially(answer, delay=
|
|
177
|
+
box.press_sequentially(answer, delay=KEY_DELAY_MS)
|
|
170
178
|
deadline = time.monotonic() + SUGGEST_TIMEOUT_S
|
|
171
179
|
while time.monotonic() < deadline:
|
|
172
180
|
for option in options.all():
|
|
173
181
|
if matching.matches(field, answer, option.inner_text().strip()):
|
|
174
182
|
option.click()
|
|
175
183
|
return
|
|
176
|
-
page.wait_for_timeout(
|
|
184
|
+
page.wait_for_timeout(SUGGEST_POLL_MS)
|
|
177
185
|
raise Unsupported(f"form_shape: no suggestion for {answer!r} in {field.id}")
|
|
178
186
|
|
|
179
187
|
|
|
@@ -47,6 +47,8 @@ __all__ = [
|
|
|
47
47
|
MIN_SCREEN_HEIGHT = 1050 # leaves the window the 900px Greenhouse and Teamtailor need
|
|
48
48
|
HOST_OS = {"Linux": "linux", "Darwin": "macos", "Windows": "windows"}[platform.system()]
|
|
49
49
|
BUDGET = 180.0 # seconds per command, lock wait included
|
|
50
|
+
LOCK_POLL_S = 0.1
|
|
51
|
+
HTTP_TIMEOUT_S = 20 # per request to a hiring system's API
|
|
50
52
|
BUILD = "156.0.1-beta.36" # the Camoufox browser build, pinned so results compare across machines
|
|
51
53
|
|
|
52
54
|
|
|
@@ -106,7 +108,7 @@ def lock(data_dir: Path, deadline: float) -> Generator[None]:
|
|
|
106
108
|
except BlockingIOError:
|
|
107
109
|
if time.monotonic() > deadline:
|
|
108
110
|
raise OutOfTime("time budget ran out waiting for another command") from None
|
|
109
|
-
time.sleep(
|
|
111
|
+
time.sleep(LOCK_POLL_S)
|
|
110
112
|
try:
|
|
111
113
|
yield
|
|
112
114
|
finally:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|