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.
Files changed (38) hide show
  1. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/PKG-INFO +3 -3
  2. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/README.md +2 -2
  3. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/pyproject.toml +2 -3
  4. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/pyproject.toml.orig +2 -5
  5. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/__init__.py +2 -0
  6. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/ashby.py +6 -4
  7. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/bamboohr.py +2 -2
  8. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/breezy.py +0 -3
  9. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/dayforce.py +14 -9
  10. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/greenhouse.py +7 -5
  11. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/hireology.py +2 -2
  12. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/jazzhr.py +0 -3
  13. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/lever.py +2 -4
  14. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/manatal.py +3 -3
  15. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/oracle.py +8 -7
  16. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/paycor.py +0 -3
  17. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/paylocity.py +15 -9
  18. atsfill-0.0.4.dev47/src/atsfill/adapters/phenom.py +1353 -0
  19. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/recruitee.py +2 -4
  20. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/smartrecruiters.py +5 -6
  21. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/teamtailor.py +4 -5
  22. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/workable.py +5 -4
  23. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/adapters/wttj.py +12 -7
  24. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/core/adapter.py +9 -4
  25. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/core/browser.py +8 -4
  26. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/core/commands.py +3 -1
  27. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/core/registry.py +23 -5
  28. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/registry.toml +5 -0
  29. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/LICENSE +0 -0
  30. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/__init__.py +0 -0
  31. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/cli.py +0 -0
  32. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/core/__init__.py +0 -0
  33. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/core/accounts.py +0 -0
  34. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/core/attempts.py +0 -0
  35. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/core/matching.py +0 -0
  36. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/core/paths.py +0 -0
  37. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev47}/src/atsfill/core/results.py +0 -0
  38. {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.dev43
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
- ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![dayforce: dry run verified 2026-10-20](https://img.shields.io/badge/dayforce-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![bamboohr: dry run verified 2026-10-21](https://img.shields.io/badge/bamboohr-dry%20run%20verified%202026--10--21-7aa2f7) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![breezy: dry run verified 2026-10-21](https://img.shields.io/badge/breezy-dry%20run%20verified%202026--10--21-7aa2f7) ![recruitee: dry run verified 2026-10-21](https://img.shields.io/badge/recruitee-dry%20run%20verified%202026--10--21-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
57
+ ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![dayforce: dry run verified 2026-10-20](https://img.shields.io/badge/dayforce-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![bamboohr: dry run verified 2026-10-21](https://img.shields.io/badge/bamboohr-dry%20run%20verified%202026--10--21-7aa2f7) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![phenom: dry run verified 2026-10-21](https://img.shields.io/badge/phenom-dry%20run%20verified%202026--10--21-7aa2f7) ![breezy: dry run verified 2026-10-21](https://img.shields.io/badge/breezy-dry%20run%20verified%202026--10--21-7aa2f7) ![recruitee: dry run verified 2026-10-21](https://img.shields.io/badge/recruitee-dry%20run%20verified%202026--10--21-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
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, listed on GitHub as the [`next` pre-release](https://github.com/shbernal/atsfill/releases/tag/next); `uvx --prerelease allow atsfill` runs the newest.
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
- ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![dayforce: dry run verified 2026-10-20](https://img.shields.io/badge/dayforce-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![bamboohr: dry run verified 2026-10-21](https://img.shields.io/badge/bamboohr-dry%20run%20verified%202026--10--21-7aa2f7) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![breezy: dry run verified 2026-10-21](https://img.shields.io/badge/breezy-dry%20run%20verified%202026--10--21-7aa2f7) ![recruitee: dry run verified 2026-10-21](https://img.shields.io/badge/recruitee-dry%20run%20verified%202026--10--21-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
32
+ ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![dayforce: dry run verified 2026-10-20](https://img.shields.io/badge/dayforce-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![bamboohr: dry run verified 2026-10-21](https://img.shields.io/badge/bamboohr-dry%20run%20verified%202026--10--21-7aa2f7) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![phenom: dry run verified 2026-10-21](https://img.shields.io/badge/phenom-dry%20run%20verified%202026--10--21-7aa2f7) ![breezy: dry run verified 2026-10-21](https://img.shields.io/badge/breezy-dry%20run%20verified%202026--10--21-7aa2f7) ![recruitee: dry run verified 2026-10-21](https://img.shields.io/badge/recruitee-dry%20run%20verified%202026--10--21-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
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, listed on GitHub as the [`next` pre-release](https://github.com/shbernal/atsfill/releases/tag/next); `uvx --prerelease allow atsfill` runs the newest.
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.dev43"
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
- markers = ["live: drives a real hiring system page with data from .env and .tmp/; opt-in with -m live"]
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.dev43"
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
- markers = [
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=20) as response:
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(100)
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(250)
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=20) as response:
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=10)
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=10)
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=10)
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=STEP_TIMEOUT_MS // 4)
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 button.count() or not re.search(r"next", button.first.inner_text(), re.IGNORECASE):
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=20) as response:
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=20)
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=500)
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=20)
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=20) as response:
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=40)
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=20) as response:
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=20) as response:
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=STEP_TIMEOUT_MS * 3)
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=STEP_TIMEOUT_MS // 2,
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=20)
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=LIST_TIMEOUT_MS * 2)
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=20) as response:
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(500)
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=80)
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=30)
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=30)
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=40)
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=5_000)
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=20)
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)