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.
Files changed (37) hide show
  1. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/PKG-INFO +1 -1
  2. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/pyproject.toml +1 -1
  3. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/pyproject.toml.orig +1 -1
  4. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/ashby.py +6 -4
  5. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/bamboohr.py +2 -2
  6. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/dayforce.py +14 -6
  7. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/greenhouse.py +7 -5
  8. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/hireology.py +2 -2
  9. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/lever.py +2 -1
  10. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/manatal.py +3 -3
  11. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/oracle.py +8 -4
  12. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/paylocity.py +15 -9
  13. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/recruitee.py +2 -1
  14. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/smartrecruiters.py +5 -3
  15. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/teamtailor.py +4 -2
  16. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/workable.py +5 -4
  17. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/wttj.py +12 -4
  18. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/core/browser.py +3 -1
  19. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/LICENSE +0 -0
  20. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/README.md +0 -0
  21. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/__init__.py +0 -0
  22. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/__init__.py +0 -0
  23. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/breezy.py +0 -0
  24. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/jazzhr.py +0 -0
  25. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/adapters/paycor.py +0 -0
  26. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/cli.py +0 -0
  27. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/core/__init__.py +0 -0
  28. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/core/accounts.py +0 -0
  29. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/core/adapter.py +0 -0
  30. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/core/attempts.py +0 -0
  31. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/core/commands.py +0 -0
  32. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/core/matching.py +0 -0
  33. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/core/paths.py +0 -0
  34. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/core/registry.py +0 -0
  35. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/core/results.py +0 -0
  36. {atsfill-0.0.4.dev43 → atsfill-0.0.4.dev44}/src/atsfill/py.typed +0 -0
  37. {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.dev43
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev43"
3
+ version = "0.0.4.dev44"
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"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev43"
3
+ version = "0.0.4.dev44"
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"
@@ -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
 
@@ -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=10)
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=10)
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=10)
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=STEP_TIMEOUT_MS // 4)
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 button.count() or not re.search(r"next", button.first.inner_text(), re.IGNORECASE):
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=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
@@ -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=40)
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=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"]
@@ -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=STEP_TIMEOUT_MS * 3)
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=STEP_TIMEOUT_MS // 2,
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=20)
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=LIST_TIMEOUT_MS * 2)
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=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)
@@ -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=20)
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=30)
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=20)
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=20)
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(500)
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=30)
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=20) as response:
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=20)
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=20)
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(250) # rendered for a visitor until the page knows the account
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(200) # the profile's location comes last, a second later
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=30)
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(250)
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(0.1)
111
+ time.sleep(LOCK_POLL_S)
110
112
  try:
111
113
  yield
112
114
  finally:
File without changes
File without changes