atsfill 0.0.4.dev43__tar.gz → 0.0.4.dev47__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.dev47}/PKG-INFO +3 -3
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/README.md +2 -2
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/pyproject.toml +2 -3
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/pyproject.toml.orig +2 -5
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/__init__.py +2 -0
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/ashby.py +6 -4
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/bamboohr.py +2 -2
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/breezy.py +0 -3
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/dayforce.py +14 -9
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/greenhouse.py +7 -5
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/hireology.py +2 -2
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/jazzhr.py +0 -3
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/lever.py +2 -4
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/manatal.py +3 -3
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/oracle.py +8 -7
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/paycor.py +0 -3
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/paylocity.py +15 -9
- atsfill-0.0.4.dev47/src/atsfill/adapters/phenom.py +1353 -0
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/recruitee.py +2 -4
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/smartrecruiters.py +5 -6
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/teamtailor.py +4 -5
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/workable.py +5 -4
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/wttj.py +12 -7
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/core/adapter.py +9 -4
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/core/browser.py +8 -4
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/core/commands.py +3 -1
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/core/registry.py +23 -5
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/registry.toml +5 -0
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/LICENSE +0 -0
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/py.typed +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.dev47
|
|
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
|
|
@@ -54,7 +54,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
54
54
|
## Supported hiring systems
|
|
55
55
|
|
|
56
56
|
<!-- badges -->
|
|
57
|
-
                
|
|
57
|
+
                 
|
|
58
58
|
<!-- /badges -->
|
|
59
59
|
|
|
60
60
|
| System | Account needed | `read` without a browser |
|
|
@@ -89,7 +89,7 @@ uvx atsfill install-browser
|
|
|
89
89
|
|
|
90
90
|
This downloads the Camoufox browser build atsfill is pinned to: 1.3 GB, about 2.5 GB once unpacked.
|
|
91
91
|
|
|
92
|
-
Every push to `main` that passes CI is also published as a dev release
|
|
92
|
+
Every push to `main` that passes CI is also published as a dev release; `uvx --prerelease allow atsfill` runs the newest.
|
|
93
93
|
|
|
94
94
|
## Quickstart
|
|
95
95
|
|
|
@@ -29,7 +29,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
29
29
|
## Supported hiring systems
|
|
30
30
|
|
|
31
31
|
<!-- badges -->
|
|
32
|
-
                
|
|
32
|
+
                 
|
|
33
33
|
<!-- /badges -->
|
|
34
34
|
|
|
35
35
|
| System | Account needed | `read` without a browser |
|
|
@@ -64,7 +64,7 @@ uvx atsfill install-browser
|
|
|
64
64
|
|
|
65
65
|
This downloads the Camoufox browser build atsfill is pinned to: 1.3 GB, about 2.5 GB once unpacked.
|
|
66
66
|
|
|
67
|
-
Every push to `main` that passes CI is also published as a dev release
|
|
67
|
+
Every push to `main` that passes CI is also published as a dev release; `uvx --prerelease allow atsfill` runs the newest.
|
|
68
68
|
|
|
69
69
|
## Quickstart
|
|
70
70
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "atsfill"
|
|
3
|
-
version = "0.0.4.
|
|
3
|
+
version = "0.0.4.dev47"
|
|
4
4
|
description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -70,8 +70,7 @@ select = [
|
|
|
70
70
|
|
|
71
71
|
[tool.pytest.ini_options]
|
|
72
72
|
testpaths = ["tests"]
|
|
73
|
-
|
|
74
|
-
addopts = "-m 'not live' -n auto --dist loadscope"
|
|
73
|
+
addopts = "-n auto --dist loadscope"
|
|
75
74
|
|
|
76
75
|
[[tool.uv.index]]
|
|
77
76
|
name = "testpypi"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "atsfill"
|
|
3
|
-
version = "0.0.4.
|
|
3
|
+
version = "0.0.4.dev47"
|
|
4
4
|
description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -53,10 +53,7 @@ select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
|
53
53
|
|
|
54
54
|
[tool.pytest.ini_options]
|
|
55
55
|
testpaths = ["tests"]
|
|
56
|
-
|
|
57
|
-
"live: drives a real hiring system page with data from .env and .tmp/; opt-in with -m live",
|
|
58
|
-
]
|
|
59
|
-
addopts = "-m 'not live' -n auto --dist loadscope"
|
|
56
|
+
addopts = "-n auto --dist loadscope"
|
|
60
57
|
|
|
61
58
|
[[tool.uv.index]]
|
|
62
59
|
name = "testpypi"
|
|
@@ -10,6 +10,7 @@ from atsfill.adapters.manatal import Manatal
|
|
|
10
10
|
from atsfill.adapters.oracle import Oracle
|
|
11
11
|
from atsfill.adapters.paycor import Paycor
|
|
12
12
|
from atsfill.adapters.paylocity import Paylocity
|
|
13
|
+
from atsfill.adapters.phenom import Phenom
|
|
13
14
|
from atsfill.adapters.recruitee import Recruitee
|
|
14
15
|
from atsfill.adapters.smartrecruiters import SmartRecruiters
|
|
15
16
|
from atsfill.adapters.teamtailor import Teamtailor
|
|
@@ -35,4 +36,5 @@ ADAPTERS: list[Adapter] = [
|
|
|
35
36
|
Recruitee(),
|
|
36
37
|
Breezy(),
|
|
37
38
|
BambooHR(),
|
|
39
|
+
Phenom(), # last: it claims a URL by its path alone
|
|
38
40
|
]
|
|
@@ -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
|
|
|
@@ -232,9 +232,6 @@ class Breezy:
|
|
|
232
232
|
match = JOB.match(parts.path)
|
|
233
233
|
return f"https://{parts.hostname}/p/{match[1]}" if match else url
|
|
234
234
|
|
|
235
|
-
def read_api(self, url: str) -> list[Field] | None:
|
|
236
|
-
return None # the form's configuration is only in the page
|
|
237
|
-
|
|
238
235
|
def read_form(self, page: Page) -> list[Field]:
|
|
239
236
|
if not urlsplit(page.url).path.rstrip("/").endswith("/apply"):
|
|
240
237
|
page.goto(f"{self.posting(page.url)}/apply")
|
|
@@ -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
|
|
@@ -190,9 +192,6 @@ class Dayforce:
|
|
|
190
192
|
found = _job(url)
|
|
191
193
|
return f"https://{HOST}/{found[1]}/{found[2]}/jobs/{found[3]}" if found else url
|
|
192
194
|
|
|
193
|
-
def read_api(self, url: str) -> list[Field] | None:
|
|
194
|
-
return None # the template leaves out the options of the country and contact dropdowns
|
|
195
|
-
|
|
196
195
|
def read_form(self, page: Page) -> list[Field]:
|
|
197
196
|
found = _job(page.url)
|
|
198
197
|
if found is None:
|
|
@@ -700,7 +699,7 @@ def _pick(page: Page, target: Locator, texts: list[str]) -> bool:
|
|
|
700
699
|
options = page.locator(_option(target))
|
|
701
700
|
if target.get_attribute("readonly") is None:
|
|
702
701
|
target.fill("")
|
|
703
|
-
target.press_sequentially(text[:40], delay=
|
|
702
|
+
target.press_sequentially(text[:40], delay=KEY_DELAY_MS)
|
|
704
703
|
with suppress(Timeout):
|
|
705
704
|
options.first.wait_for(timeout=OPTIONS_TIMEOUT_MS)
|
|
706
705
|
page.evaluate(_REVEAL, [target.get_attribute("id"), [text]])
|
|
@@ -750,7 +749,7 @@ def _fill_control(page: Page, selector: str, field: Field, answer: Answer) -> No
|
|
|
750
749
|
def _pick_suggestion(page: Page, target: Locator, value: str) -> None:
|
|
751
750
|
"""Types the answer and clicks the first option that starts with it."""
|
|
752
751
|
_open(target)
|
|
753
|
-
target.press_sequentially(value[:40], delay=
|
|
752
|
+
target.press_sequentially(value[:40], delay=KEY_DELAY_MS)
|
|
754
753
|
with suppress(Timeout):
|
|
755
754
|
page.locator(_option(target)).first.wait_for(timeout=OPTIONS_TIMEOUT_MS)
|
|
756
755
|
if not _click_option(page, target, lambda shown: shown.startswith(_plain(value).casefold())):
|
|
@@ -768,7 +767,7 @@ def _fill_phone(page: Page, selector: str, value: str) -> None:
|
|
|
768
767
|
code = digits[:size]
|
|
769
768
|
_open(dial)
|
|
770
769
|
dial.fill("")
|
|
771
|
-
dial.press_sequentially(f"+{code}", delay=
|
|
770
|
+
dial.press_sequentially(f"+{code}", delay=KEY_DELAY_MS)
|
|
772
771
|
options = page.locator(_option(dial)).filter(has_text=re.compile(rf"\+{code}(\s|$)"))
|
|
773
772
|
with suppress(Timeout):
|
|
774
773
|
options.first.wait_for(timeout=DIAL_TIMEOUT_MS)
|
|
@@ -844,7 +843,7 @@ def _update(page: Page, field: str) -> None:
|
|
|
844
843
|
button.evaluate("e => e.scrollIntoView({block: 'center'})")
|
|
845
844
|
button.click()
|
|
846
845
|
with suppress(Timeout):
|
|
847
|
-
first.wait_for(state="detached", timeout=
|
|
846
|
+
first.wait_for(state="detached", timeout=UPDATE_TIMEOUT_MS)
|
|
848
847
|
return
|
|
849
848
|
if page.locator(".ant-form-item-explain-error:visible").count():
|
|
850
849
|
return # the section refuses its answers: Next reports the stop
|
|
@@ -854,7 +853,7 @@ def _next(page: Page) -> bool:
|
|
|
854
853
|
"""Goes to the next step; False when the page stays (a validation error), or when the
|
|
855
854
|
next button is Submit, which `fill` never presses."""
|
|
856
855
|
button = page.locator(NEXT)
|
|
857
|
-
if not
|
|
856
|
+
if not _says_next(button):
|
|
858
857
|
return False
|
|
859
858
|
before = _controls(page)
|
|
860
859
|
for _ in range(NEXT_TRIES): # a click right after a section's save can go unheeded
|
|
@@ -863,7 +862,7 @@ def _next(page: Page) -> bool:
|
|
|
863
862
|
button.first.click()
|
|
864
863
|
for _ in range(STEP_POLLS):
|
|
865
864
|
page.wait_for_timeout(POLL_MS)
|
|
866
|
-
if _controls(page) != before:
|
|
865
|
+
if _controls(page) != before or not _says_next(button): # Submit: the last step
|
|
867
866
|
page.wait_for_timeout(POLL_MS) # the step renders its controls in turn
|
|
868
867
|
return True
|
|
869
868
|
if page.locator(".ant-form-item-explain-error:visible").count():
|
|
@@ -871,6 +870,12 @@ def _next(page: Page) -> bool:
|
|
|
871
870
|
return False
|
|
872
871
|
|
|
873
872
|
|
|
873
|
+
def _says_next(button: Locator) -> bool:
|
|
874
|
+
return bool(button.count()) and bool(
|
|
875
|
+
re.search(r"next", button.first.inner_text(), re.IGNORECASE)
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
|
|
874
879
|
def _controls(page: Page) -> list[str]:
|
|
875
880
|
"""The ids of the step's controls: what changes when the wizard moves on, not a control's
|
|
876
881
|
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
|
|
@@ -134,9 +134,6 @@ class JazzHR:
|
|
|
134
134
|
job = _job(url)
|
|
135
135
|
return f"https://{job[0]}/apply/{job[1]}" if job else url
|
|
136
136
|
|
|
137
|
-
def read_api(self, url: str) -> list[Field] | None:
|
|
138
|
-
return None
|
|
139
|
-
|
|
140
137
|
def read_form(self, page: Page) -> list[Field]:
|
|
141
138
|
form = page.locator(FORM)
|
|
142
139
|
try:
|
|
@@ -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,
|
|
@@ -131,9 +132,6 @@ class Lever:
|
|
|
131
132
|
parts, job = urlsplit(url), _job(url)
|
|
132
133
|
return f"https://{parts.hostname}/{job[0]}/{job[1]}" if job else url
|
|
133
134
|
|
|
134
|
-
def read_api(self, url: str) -> list[Field] | None:
|
|
135
|
-
return None # Lever's postings API leaves the application's questions out
|
|
136
|
-
|
|
137
135
|
def read_form(self, page: Page) -> list[Field]:
|
|
138
136
|
if not urlsplit(page.url).path.rstrip("/").endswith("/apply"):
|
|
139
137
|
page.goto(f"{self.posting(page.url)}/apply")
|
|
@@ -245,7 +243,7 @@ def _attach(control: Locator, path: str, resume: bool) -> None:
|
|
|
245
243
|
def _locate(page: Page, control: Locator, value: str) -> None:
|
|
246
244
|
"""Types the location and picks Lever's first suggestion: the field takes only a pick."""
|
|
247
245
|
control.fill("")
|
|
248
|
-
control.press_sequentially(value, delay=
|
|
246
|
+
control.press_sequentially(value, delay=KEY_DELAY_MS)
|
|
249
247
|
suggestion = page.locator(".dropdown-results .dropdown-location").first
|
|
250
248
|
with suppress(Timeout):
|
|
251
249
|
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"]
|
|
@@ -255,9 +259,6 @@ class Oracle:
|
|
|
255
259
|
assert match, url
|
|
256
260
|
return f"https://{parts.hostname}/hcmUI/CandidateExperience/job/{match[1]}"
|
|
257
261
|
|
|
258
|
-
def read_api(self, url: str) -> list[Field] | None:
|
|
259
|
-
return None # the form's metadata is served only past the email step
|
|
260
|
-
|
|
261
262
|
def read_form(self, page: Page) -> list[Field]:
|
|
262
263
|
parts = urlsplit(page.url)
|
|
263
264
|
match = JOB.search(parts.path)
|
|
@@ -327,7 +328,7 @@ class Oracle:
|
|
|
327
328
|
def submit(self, page: Page) -> str | None:
|
|
328
329
|
_go_to(page, len(_page_numbers(page)) or 1)
|
|
329
330
|
_click(page, page.locator(SUBMIT))
|
|
330
|
-
page.wait_for_url(lambda url: "/apply/" not in url, timeout=
|
|
331
|
+
page.wait_for_url(lambda url: "/apply/" not in url, timeout=SUBMIT_TIMEOUT_MS)
|
|
331
332
|
return page.url
|
|
332
333
|
|
|
333
334
|
def _email_step(self, page: Page) -> str | None:
|
|
@@ -535,7 +536,7 @@ def _go_to(page: Page, number: int) -> None:
|
|
|
535
536
|
with suppress(browser.Timeout):
|
|
536
537
|
page.wait_for_url(
|
|
537
538
|
re.compile(rf"/apply/section/{number}\b"),
|
|
538
|
-
timeout=
|
|
539
|
+
timeout=PAGE_TIMEOUT_MS,
|
|
539
540
|
wait_until="commit",
|
|
540
541
|
)
|
|
541
542
|
_settle(page)
|
|
@@ -593,10 +594,10 @@ def _choose(
|
|
|
593
594
|
_click(page, box)
|
|
594
595
|
box.fill("")
|
|
595
596
|
if typed:
|
|
596
|
-
box.press_sequentially(typed, delay=
|
|
597
|
+
box.press_sequentially(typed, delay=KEY_DELAY_MS)
|
|
597
598
|
cells = page.locator(f"[id='{box.get_attribute('aria-controls')}'] [role=gridcell]")
|
|
598
599
|
with suppress(browser.Timeout):
|
|
599
|
-
cells.first.wait_for(timeout=
|
|
600
|
+
cells.first.wait_for(timeout=SEARCH_TIMEOUT_MS)
|
|
600
601
|
for i in range(cells.count()):
|
|
601
602
|
item = _text(cells.nth(i).inner_text())
|
|
602
603
|
if (
|
|
@@ -160,9 +160,6 @@ class Paycor:
|
|
|
160
160
|
job = (query.get("id") or query.get("gni") or [url])[0]
|
|
161
161
|
return f"{CAREER}JobIntroduction.action?id={job}"
|
|
162
162
|
|
|
163
|
-
def read_api(self, url: str) -> list[Field] | None:
|
|
164
|
-
return None # the form is behind the pages before it, posted in a session
|
|
165
|
-
|
|
166
163
|
def read_form(self, page: Page) -> list[Field]:
|
|
167
164
|
self._before, self._codes = [], {}
|
|
168
165
|
self._walk(page, None)
|
|
@@ -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)
|