atsfill 0.0.4.dev135__tar.gz → 0.0.5.dev137__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.dev135 → atsfill-0.0.5.dev137}/PKG-INFO +2 -2
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/README.md +1 -1
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/greenhouse.py +40 -5
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/oracle.py +1 -1
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/successfactors.py +1 -1
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/core/adapter.py +2 -2
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/core/otp.py +14 -7
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/LICENSE +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/gupy.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/isolvedhire.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/join.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/py.typed +0 -0
- {atsfill-0.0.4.dev135 → atsfill-0.0.5.dev137}/src/atsfill/registry.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atsfill
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.5.dev137
|
|
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
|
|
@@ -137,7 +137,7 @@ A dry run or a `send` through an adapter no send was verified on carries a `warn
|
|
|
137
137
|
|
|
138
138
|
One home can send for more than one user: `--user <name>` gives each their own sessions, double-send guard and `[users.<name>]` section in the config file ([ADR 0015](docs/adr/0015-users-and-accounts.md)).
|
|
139
139
|
Given an account's email (`atsfill login <adapter> --email`) and a password source (a `[password-commands]` entry such as `rbw get example.com {email}`, or `--password -` on stdin), atsfill signs in by itself, and again whenever a form command finds the session signed out.
|
|
140
|
-
When a system emails an OTP, on a sign-in, a sign-up or a form, atsfill gets it from the user's `[otp-commands]` entry, a command that reads the mailbox (`{email}` and `{since}` are filled in), and types it in; without one the command ends `blocked:
|
|
140
|
+
When a system emails an OTP, on a sign-in, a sign-up or a form, atsfill gets it from the user's `[otp-commands]` entry, a command that reads the mailbox (`{email}` and `{since}` are filled in), and types it in; without one the command ends `blocked: no_otp_command`.
|
|
141
141
|
Where a system's sign-up allows it, `atsfill login <adapter> --create --email <address>` (with `--details <file.json>` where the sign-up asks the user's name, phone or address) creates the account with a password atsfill generates and keeps in the system's secret store, never printed; creating an account is a side effect, never part of `fill` or `send`.
|
|
142
142
|
|
|
143
143
|
## How it works
|
|
@@ -109,7 +109,7 @@ A dry run or a `send` through an adapter no send was verified on carries a `warn
|
|
|
109
109
|
|
|
110
110
|
One home can send for more than one user: `--user <name>` gives each their own sessions, double-send guard and `[users.<name>]` section in the config file ([ADR 0015](docs/adr/0015-users-and-accounts.md)).
|
|
111
111
|
Given an account's email (`atsfill login <adapter> --email`) and a password source (a `[password-commands]` entry such as `rbw get example.com {email}`, or `--password -` on stdin), atsfill signs in by itself, and again whenever a form command finds the session signed out.
|
|
112
|
-
When a system emails an OTP, on a sign-in, a sign-up or a form, atsfill gets it from the user's `[otp-commands]` entry, a command that reads the mailbox (`{email}` and `{since}` are filled in), and types it in; without one the command ends `blocked:
|
|
112
|
+
When a system emails an OTP, on a sign-in, a sign-up or a form, atsfill gets it from the user's `[otp-commands]` entry, a command that reads the mailbox (`{email}` and `{since}` are filled in), and types it in; without one the command ends `blocked: no_otp_command`.
|
|
113
113
|
Where a system's sign-up allows it, `atsfill login <adapter> --create --email <address>` (with `--details <file.json>` where the sign-up asks the user's name, phone or address) creates the account with a password atsfill generates and keeps in the system's secret store, never printed; creating an account is a side effect, never part of `fill` or `send`.
|
|
114
114
|
|
|
115
115
|
## How it works
|
|
@@ -14,9 +14,9 @@ from contextlib import suppress
|
|
|
14
14
|
from typing import Any
|
|
15
15
|
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
16
16
|
|
|
17
|
-
from atsfill.core import browser
|
|
18
|
-
from atsfill.core.adapter import Gap, Unsupported
|
|
19
|
-
from atsfill.core.browser import HTTP_TIMEOUT_S, Page, Timeout
|
|
17
|
+
from atsfill.core import browser, otp
|
|
18
|
+
from atsfill.core.adapter import Blocked, Gap, Unsupported
|
|
19
|
+
from atsfill.core.browser import HTTP_TIMEOUT_S, BrowserError, Page, Timeout
|
|
20
20
|
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
21
21
|
|
|
22
22
|
HOSTS = re.compile(r"^(job-boards|boards)(\.eu)?\.greenhouse\.io$")
|
|
@@ -33,6 +33,7 @@ HYDRATE_TIMEOUT_MS = 15_000
|
|
|
33
33
|
FORM_TIMEOUT_MS = 20_000
|
|
34
34
|
ANSWER_TIMEOUT_MS = 15_000 # an upload's file name, a suggestion: past it, a mismatch
|
|
35
35
|
SUBMIT_TIMEOUT_MS = 90_000
|
|
36
|
+
SUBMIT_POLL_MS = 500 # for the confirmation or the OTP step
|
|
36
37
|
HYDRATE_POLL_MS = 500 # for a select's list to open, per click
|
|
37
38
|
KEY_DELAY_MS = 20
|
|
38
39
|
API_TYPES = {
|
|
@@ -43,6 +44,11 @@ API_TYPES = {
|
|
|
43
44
|
"multi_value_multi_select": FieldType.MULTI_CHOICE,
|
|
44
45
|
}
|
|
45
46
|
YES_NO = ["Yes", "No"]
|
|
47
|
+
CONFIRMATION = re.compile(r"/confirmation")
|
|
48
|
+
# The OTP step a submit can stop at: a box per character of the OTP it emails.
|
|
49
|
+
OTP_STEP = "fieldset#email-verification"
|
|
50
|
+
OTP_INPUTS = f"{OTP_STEP} input[id^='security-input-']"
|
|
51
|
+
OTP_ERROR = "#email-verification-error"
|
|
46
52
|
# Sections the page adds or names its own way, or shows a question of only on another answer
|
|
47
53
|
# (EEOC race after Hispanic/Latino): `request_read` declines a posting with any (ADR 0011).
|
|
48
54
|
UNMAPPED = ("education", "compliance", "demographic_questions")
|
|
@@ -234,8 +240,24 @@ class Greenhouse:
|
|
|
234
240
|
return {k: v for k, v in values.items() if v not in (None, "", [])}
|
|
235
241
|
|
|
236
242
|
def submit(self, page: Page) -> str | None:
|
|
237
|
-
|
|
238
|
-
page.
|
|
243
|
+
"""Submits, then types the emailed OTP where the step asks for one and submits again.
|
|
244
|
+
The evidence is the confirmation page."""
|
|
245
|
+
button = page.locator(f"{FORM} button[type=submit]")
|
|
246
|
+
browser.click(button)
|
|
247
|
+
if not _confirmed(page, OTP_INPUTS):
|
|
248
|
+
email = page.locator(f"{FORM} {_by_id('email')}").input_value()
|
|
249
|
+
otp.enter(page.locator(OTP_INPUTS), email, "the submit")
|
|
250
|
+
browser.click(button)
|
|
251
|
+
try:
|
|
252
|
+
confirmed = _confirmed(page, OTP_ERROR)
|
|
253
|
+
except Timeout:
|
|
254
|
+
if not page.locator(f"{OTP_INPUTS}:visible").count():
|
|
255
|
+
raise
|
|
256
|
+
stayed = "unhandled_rendering: the submit stayed at the OTP step"
|
|
257
|
+
raise Unsupported(stayed) from None
|
|
258
|
+
if not confirmed:
|
|
259
|
+
message = page.locator(OTP_ERROR).first.inner_text().strip()
|
|
260
|
+
raise Blocked(f"otp_rejected: the submit did not take the OTP: {message}")
|
|
239
261
|
return page.url
|
|
240
262
|
|
|
241
263
|
def _field(self, page: Page, raw: dict[str, Any]) -> Field:
|
|
@@ -317,6 +339,19 @@ def _wait_until_live(page: Page) -> None:
|
|
|
317
339
|
return
|
|
318
340
|
|
|
319
341
|
|
|
342
|
+
def _confirmed(page: Page, step: str) -> bool:
|
|
343
|
+
"""True once the page reaches the confirmation, False once `step` shows instead."""
|
|
344
|
+
deadline = time.monotonic() + SUBMIT_TIMEOUT_MS / 1000
|
|
345
|
+
while time.monotonic() < deadline:
|
|
346
|
+
with suppress(BrowserError): # the page navigating away
|
|
347
|
+
if CONFIRMATION.search(page.url):
|
|
348
|
+
return True
|
|
349
|
+
if page.locator(f"{step}:visible").count():
|
|
350
|
+
return False
|
|
351
|
+
page.wait_for_timeout(SUBMIT_POLL_MS)
|
|
352
|
+
raise Timeout(f"no confirmation within {SUBMIT_TIMEOUT_MS} ms")
|
|
353
|
+
|
|
354
|
+
|
|
320
355
|
def _board_job(url: str) -> tuple[str, str] | None:
|
|
321
356
|
"""The board and job id of a board URL: `/<board>/jobs/<id>` or `embed/job_app?for&token`."""
|
|
322
357
|
parts = urlsplit(url)
|
|
@@ -376,7 +376,7 @@ class Oracle:
|
|
|
376
376
|
timeout=STEP_TIMEOUT_MS
|
|
377
377
|
)
|
|
378
378
|
except browser.Timeout:
|
|
379
|
-
raise Blocked("
|
|
379
|
+
raise Blocked("otp_rejected: the email step did not take the OTP") from None
|
|
380
380
|
if page.locator(MANUALLY).count():
|
|
381
381
|
_click(page, page.locator(MANUALLY).first)
|
|
382
382
|
page.locator("apply-flow-block").first.wait_for(timeout=STEP_TIMEOUT_MS)
|
|
@@ -279,7 +279,7 @@ class SuccessFactors:
|
|
|
279
279
|
page.locator(PASSCODE).wait_for(state="hidden", timeout=SIGN_IN_TIMEOUT_MS)
|
|
280
280
|
except Timeout:
|
|
281
281
|
message = self._message(page)
|
|
282
|
-
raise Blocked(f"
|
|
282
|
+
raise Blocked(f"otp_rejected: the sign-up did not take the OTP: {message}") from None
|
|
283
283
|
|
|
284
284
|
def browser_read(self, page: Page) -> list[Field]:
|
|
285
285
|
self._candidate(page)
|
|
@@ -120,8 +120,8 @@ class SignsIn(NeedsAccount, Protocol):
|
|
|
120
120
|
|
|
121
121
|
def sign_in(self, page: Page, email: str, password: str) -> None:
|
|
122
122
|
"""Signs in on the page `login_url` loaded. Raises `Blocked` for what the system waits
|
|
123
|
-
for (`email_link`, `email_otp`, `sms_otp`, `captcha
|
|
124
|
-
`Unsupported` for a page it does not know."""
|
|
123
|
+
for (`email_link`, `email_otp`, `sms_otp`, `captcha`, the OTP command's reasons),
|
|
124
|
+
`Refused` for what it turns down, `Unsupported` for a page it does not know."""
|
|
125
125
|
...
|
|
126
126
|
|
|
127
127
|
|
|
@@ -53,17 +53,21 @@ def source(user: str, adapter: str, scope: str | None) -> Generator[None]:
|
|
|
53
53
|
|
|
54
54
|
def enter(inputs: Locator, email: str, step: str) -> None:
|
|
55
55
|
"""Types the OTP emailed to `email` into the step's input, or one character into each of
|
|
56
|
-
its inputs. Raises `Blocked
|
|
57
|
-
`OTP_TIMEOUT_S
|
|
56
|
+
its inputs. Raises `Blocked`: `no_otp_command` without an entry, `otp_not_received` when the
|
|
57
|
+
command prints none within `OTP_TIMEOUT_S`, `otp_rejected` when what it prints does not fit
|
|
58
|
+
the inputs; `step` names where the OTP was asked, for the reason."""
|
|
58
59
|
current = _source.get()
|
|
59
|
-
asked = f"
|
|
60
|
+
asked = f"{step} sent an OTP to {email}"
|
|
60
61
|
if current is None or current.command is None:
|
|
61
62
|
adapter = current.adapter if current else "the adapter"
|
|
62
|
-
raise Blocked(f"{asked}; no `otp-commands` entry for {adapter}")
|
|
63
|
+
raise Blocked(f"no_otp_command: {asked}; no `otp-commands` entry for {adapter}")
|
|
63
64
|
otp = _wait(current, email, asked)
|
|
64
65
|
count = inputs.count()
|
|
65
66
|
if count > 1 and count != len(otp):
|
|
66
|
-
raise Blocked(
|
|
67
|
+
raise Blocked(
|
|
68
|
+
f"otp_rejected: {asked}; the OTP command printed {len(otp)} characters"
|
|
69
|
+
f" for {count} inputs"
|
|
70
|
+
)
|
|
67
71
|
if count > 1:
|
|
68
72
|
for box, character in zip(inputs.all(), otp, strict=True):
|
|
69
73
|
box.press_sequentially(character)
|
|
@@ -90,12 +94,15 @@ def _wait(current: Source, email: str, asked: str) -> str:
|
|
|
90
94
|
break
|
|
91
95
|
except OSError as e:
|
|
92
96
|
raise Blocked(
|
|
93
|
-
f"{asked}; the OTP command `{args[0]}` failed: {type(e).__name__}"
|
|
97
|
+
f"otp_not_received: {asked}; the OTP command `{args[0]}` failed: {type(e).__name__}"
|
|
94
98
|
) from None
|
|
95
99
|
if run.returncode == 0 and (otp := next(iter(run.stdout.split()), "")):
|
|
96
100
|
return otp
|
|
97
101
|
time.sleep(min(POLL_S, max(0.0, deadline - time.monotonic())))
|
|
98
|
-
raise Blocked(
|
|
102
|
+
raise Blocked(
|
|
103
|
+
f"otp_not_received: {asked}; the OTP command `{args[0]}` printed none"
|
|
104
|
+
f" within {OTP_TIMEOUT_S}s"
|
|
105
|
+
)
|
|
99
106
|
|
|
100
107
|
|
|
101
108
|
def _now() -> datetime:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|