atsfill 0.0.4.dev134__tar.gz → 0.0.4.dev136__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.dev134 → atsfill-0.0.4.dev136}/PKG-INFO +2 -1
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/README.md +1 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/greenhouse.py +40 -5
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/isolvedhire.py +0 -1
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/oracle.py +27 -13
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/successfactors.py +18 -2
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/core/adapter.py +2 -2
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/core/commands.py +18 -11
- atsfill-0.0.4.dev136/src/atsfill/core/otp.py +109 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/registry.toml +1 -1
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/LICENSE +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/gupy.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/join.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev134 → atsfill-0.0.4.dev136}/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.dev136
|
|
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,6 +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: no_otp_command`.
|
|
140
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`.
|
|
141
142
|
|
|
142
143
|
## How it works
|
|
@@ -109,6 +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: no_otp_command`.
|
|
112
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`.
|
|
113
114
|
|
|
114
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)
|
|
@@ -173,7 +173,6 @@ class IsolvedHire:
|
|
|
173
173
|
per_employer = False
|
|
174
174
|
asks = {"name", "phone", "street", "city", "state", "zip", "country"}
|
|
175
175
|
known_gaps = {
|
|
176
|
-
"sign_up_otp": Gap.FLOW, # why the sign-up goes through the sign-in, not Apply now
|
|
177
176
|
"entry_list": Gap.RENDERING,
|
|
178
177
|
"drawn_signature": Gap.RENDERING,
|
|
179
178
|
}
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
as docs/adapters/oracle.md observes.
|
|
3
3
|
|
|
4
4
|
The form opens only past an email step, and an email the employer's Oracle already knows gets an
|
|
5
|
-
emailed OTP
|
|
6
|
-
the answer's. Oracle keeps a draft of the application from then on,
|
|
5
|
+
emailed OTP first, typed in by the core. Reading passes the step with a throwaway address;
|
|
6
|
+
filling starts over with the answer's. Oracle keeps a draft of the application from then on,
|
|
7
|
+
autosaved as fields change.
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
import re
|
|
@@ -15,7 +16,7 @@ from pathlib import PurePath
|
|
|
15
16
|
from typing import Any
|
|
16
17
|
from urllib.parse import urlsplit
|
|
17
18
|
|
|
18
|
-
from atsfill.core import browser, matching
|
|
19
|
+
from atsfill.core import browser, matching, otp
|
|
19
20
|
from atsfill.core.adapter import Blocked, Gap, NotTaken, Unsupported, known_gap
|
|
20
21
|
from atsfill.core.browser import Locator, Page
|
|
21
22
|
from atsfill.core.results import Answer, Field, FieldType, Is, OptionSet, SideEffect
|
|
@@ -32,9 +33,12 @@ PAGINATION = ".apply-flow-pagination__button"
|
|
|
32
33
|
PAGE_NUMBERS = ".apply-flow-navigation-pages__link:visible"
|
|
33
34
|
LOADING = ".app-loading-spinner, .cx-select--loading, .apply-flow-block .skeleton"
|
|
34
35
|
OTP_PAGE = ".apply-flow-pin-code-required"
|
|
36
|
+
PIN = f"{OTP_PAGE} .pin-code-input__input" # one per digit
|
|
37
|
+
VERIFY = f"{OTP_PAGE} button[type=submit]"
|
|
35
38
|
CAPTCHA = "iframe[src*='hcaptcha.com']"
|
|
36
39
|
MANUALLY = ".quick-apply-flow__manually--link:visible" # past an offer to import a profile
|
|
37
40
|
ATTACHED = ".attachment-upload-button__download, .misc-attachment-list__item-name"
|
|
41
|
+
REMOVE = ".attachment-upload-button__filled .attachment-upload-button__bottom-button"
|
|
38
42
|
STEP_TIMEOUT_MS = 30_000
|
|
39
43
|
LIST_TIMEOUT_MS = 3_000
|
|
40
44
|
SEARCH_TIMEOUT_MS = 6_000 # for a typed search's results
|
|
@@ -356,21 +360,26 @@ class Oracle:
|
|
|
356
360
|
if "--checked" not in (box.get_attribute("class") or ""):
|
|
357
361
|
_click(page, box)
|
|
358
362
|
_click(page, page.locator(NEXT).first)
|
|
359
|
-
opened = page.locator(f"apply-flow-block, {OTP_PAGE}, {MANUALLY}")
|
|
360
363
|
try:
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
page.locator("apply-flow-block").first.wait_for(timeout=STEP_TIMEOUT_MS)
|
|
364
|
+
page.locator(f"apply-flow-block, {OTP_PAGE}, {MANUALLY}").first.wait_for(
|
|
365
|
+
timeout=STEP_TIMEOUT_MS
|
|
366
|
+
)
|
|
365
367
|
except browser.Timeout:
|
|
366
368
|
if page.locator(CAPTCHA).count():
|
|
367
369
|
raise Blocked("captcha: the email step asks for an hCaptcha") from None
|
|
368
370
|
raise RuntimeError("the email step did not open the form") from None
|
|
369
|
-
if page.locator(OTP_PAGE).count():
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
371
|
+
if page.locator(OTP_PAGE).count(): # an address this employer's Oracle knows
|
|
372
|
+
otp.enter(page.locator(PIN), email, "the email step")
|
|
373
|
+
_click(page, page.locator(VERIFY))
|
|
374
|
+
try:
|
|
375
|
+
page.locator(f"apply-flow-block, {MANUALLY}").first.wait_for(
|
|
376
|
+
timeout=STEP_TIMEOUT_MS
|
|
377
|
+
)
|
|
378
|
+
except browser.Timeout:
|
|
379
|
+
raise Blocked("otp_rejected: the email step did not take the OTP") from None
|
|
380
|
+
if page.locator(MANUALLY).count():
|
|
381
|
+
_click(page, page.locator(MANUALLY).first)
|
|
382
|
+
page.locator("apply-flow-block").first.wait_for(timeout=STEP_TIMEOUT_MS)
|
|
374
383
|
page.locator(f"{PAGINATION}:visible").first.wait_for(timeout=STEP_TIMEOUT_MS)
|
|
375
384
|
_settle(page)
|
|
376
385
|
|
|
@@ -428,6 +437,11 @@ class Oracle:
|
|
|
428
437
|
return
|
|
429
438
|
if kind == "file":
|
|
430
439
|
assert isinstance(answer, str)
|
|
440
|
+
# a draft from an earlier visit keeps its file, the input gone until it is removed
|
|
441
|
+
remove = page.locator(f"{_upload(field_id)} {REMOVE}")
|
|
442
|
+
if remove.count():
|
|
443
|
+
_click(page, remove.first)
|
|
444
|
+
self._file_input(page, field_id).wait_for(state="attached", timeout=STEP_TIMEOUT_MS)
|
|
431
445
|
self._file_input(page, field_id).set_input_files(answer)
|
|
432
446
|
return
|
|
433
447
|
row = self._row(page, field_id)
|
|
@@ -13,6 +13,7 @@ from pathlib import Path
|
|
|
13
13
|
from typing import Any
|
|
14
14
|
from urllib.parse import parse_qs, urlsplit
|
|
15
15
|
|
|
16
|
+
from atsfill.core import otp
|
|
16
17
|
from atsfill.core.adapter import (
|
|
17
18
|
Blocked,
|
|
18
19
|
Gap,
|
|
@@ -26,7 +27,9 @@ from atsfill.core.browser import Locator, Page, PageRequest, Request, Timeout
|
|
|
26
27
|
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
27
28
|
|
|
28
29
|
JOB = re.compile(r"^(?:/[^/]+)?/job/[^/]+/(?P<id>\d{5,})/?$")
|
|
29
|
-
DC_HOST = re.compile(
|
|
30
|
+
DC_HOST = re.compile(
|
|
31
|
+
r"^(?:career\d*\.(?:sapsf|successfactors)\.(?:eu|com|cn)|career-[a-z]+\d*\.ns2cloud\.com)$"
|
|
32
|
+
)
|
|
30
33
|
APPLY = "a[href*='/talentcommunity/apply/']"
|
|
31
34
|
COMPANY_COOKIE = "careerSiteCompanyId"
|
|
32
35
|
PROFILE = "/career?company={company}&career_ns=job_listing_summary&navBarLevel=MY_PROFILE"
|
|
@@ -43,6 +46,7 @@ COUNTRY = "fbclc_country" # its options' values are ISO 3166 alpha-2 codes
|
|
|
43
46
|
PHONE, CALLING_CODE = "#fbclc_phoneNumber", "#fbclc_ituCode" # `CA`: `Canada (+1)`
|
|
44
47
|
SIGN_UP = "#fbclc_userName"
|
|
45
48
|
PASSCODE = "#passcode" # an OTP some sign-ups ask before the account exists
|
|
49
|
+
PASSCODE_STEP = "#otpContainer"
|
|
46
50
|
QUICK_APPLY = "#fbqa_apply"
|
|
47
51
|
FORM = ".RCMFormField"
|
|
48
52
|
LANDED = f"{SIGN_IN}, {SIGN_UP}, {FORM}"
|
|
@@ -243,7 +247,9 @@ class SuccessFactors:
|
|
|
243
247
|
if page.locator(FORM).count():
|
|
244
248
|
return
|
|
245
249
|
if page.locator(f"{PASSCODE}:visible").count():
|
|
246
|
-
|
|
250
|
+
self._passcode(page, email)
|
|
251
|
+
deadline = time.monotonic() + SIGN_IN_TIMEOUT_MS / 1000
|
|
252
|
+
continue
|
|
247
253
|
if not page.locator(f"{SIGN_UP}:visible").count():
|
|
248
254
|
break
|
|
249
255
|
if self._message(page, REFUSAL):
|
|
@@ -265,6 +271,16 @@ class SuccessFactors:
|
|
|
265
271
|
return
|
|
266
272
|
raise Unsupported(f"unhandled_rendering: the sign-up led to {urlsplit(page.url).path}")
|
|
267
273
|
|
|
274
|
+
def _passcode(self, page: Page, email: str) -> None:
|
|
275
|
+
"""Types the passcode the sign-up emailed; the account exists once it is taken."""
|
|
276
|
+
otp.enter(page.locator(PASSCODE), email, "the sign-up")
|
|
277
|
+
page.locator(PASSCODE_STEP).get_by_role("button").first.click()
|
|
278
|
+
try:
|
|
279
|
+
page.locator(PASSCODE).wait_for(state="hidden", timeout=SIGN_IN_TIMEOUT_MS)
|
|
280
|
+
except Timeout:
|
|
281
|
+
message = self._message(page)
|
|
282
|
+
raise Blocked(f"otp_rejected: the sign-up did not take the OTP: {message}") from None
|
|
283
|
+
|
|
268
284
|
def browser_read(self, page: Page) -> list[Field]:
|
|
269
285
|
self._candidate(page)
|
|
270
286
|
if not page.locator(FORM).count():
|
|
@@ -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
|
|
|
@@ -11,6 +11,7 @@ from atsfill.core import (
|
|
|
11
11
|
browser,
|
|
12
12
|
conditions,
|
|
13
13
|
matching,
|
|
14
|
+
otp,
|
|
14
15
|
passwords,
|
|
15
16
|
paths,
|
|
16
17
|
registry,
|
|
@@ -130,12 +131,15 @@ def _opened(
|
|
|
130
131
|
detected.append(adapter.name)
|
|
131
132
|
if adapter is None:
|
|
132
133
|
yield _no_adapter(url)
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
):
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
134
|
+
return
|
|
135
|
+
scope = adapter.account_scope(url) if isinstance(adapter, NeedsAccount) else None
|
|
136
|
+
with otp.source(user, adapter.name, scope):
|
|
137
|
+
if isinstance(adapter, NeedsAccount) and not (
|
|
138
|
+
session and adapter.signed_in(page.request, scope)
|
|
139
|
+
):
|
|
140
|
+
yield _sign_in_again(page, adapter, user, mine, url, credentials) or (page, adapter)
|
|
141
|
+
else:
|
|
142
|
+
yield page, adapter
|
|
139
143
|
|
|
140
144
|
|
|
141
145
|
def _sign_in_again(
|
|
@@ -536,7 +540,7 @@ def login(
|
|
|
536
540
|
accounts.wait_for_person(account, scope, data, session),
|
|
537
541
|
)
|
|
538
542
|
else:
|
|
539
|
-
result = _headless(data, account, scope, credentials, session, details)
|
|
543
|
+
result = _headless(data, user, account, scope, credentials, session, details)
|
|
540
544
|
command = login_command(adapter, user, url)
|
|
541
545
|
report.status, report.reason = (
|
|
542
546
|
result.status,
|
|
@@ -574,7 +578,9 @@ def _create(
|
|
|
574
578
|
report.status, report.reason = Status.BLOCKED, f"password_source: {e}"
|
|
575
579
|
return report
|
|
576
580
|
credentials = (email, password)
|
|
577
|
-
result = _headless(
|
|
581
|
+
result = _headless(
|
|
582
|
+
data, report.user, account, report.scope, credentials, report.path, details, create=True
|
|
583
|
+
)
|
|
578
584
|
command = login_command(account.name, report.user, url)
|
|
579
585
|
report.status, report.reason = result.status, _email_link(result.reason, email, command)
|
|
580
586
|
report.password = "stored"
|
|
@@ -588,6 +594,7 @@ def _create(
|
|
|
588
594
|
|
|
589
595
|
def _headless(
|
|
590
596
|
data: Path,
|
|
597
|
+
user: str,
|
|
591
598
|
account: NeedsAccount,
|
|
592
599
|
scope: str | None,
|
|
593
600
|
credentials: Credentials,
|
|
@@ -595,14 +602,14 @@ def _headless(
|
|
|
595
602
|
details: SignUpDetails | None = None,
|
|
596
603
|
create: bool = False,
|
|
597
604
|
) -> Result:
|
|
598
|
-
"""Signs in, or creates the account with `create`, through the adapter
|
|
599
|
-
session is stored."""
|
|
605
|
+
"""Signs in, or creates the account with `create`, through the adapter, an OTP it meets
|
|
606
|
+
from the user's `otp-commands` entry: `ready` once the session is stored."""
|
|
600
607
|
assert isinstance(account, SignsIn)
|
|
601
608
|
url = account.login_url(scope)
|
|
602
609
|
|
|
603
610
|
def run(_: list[str]) -> Result:
|
|
604
611
|
result = Result(Status.READY, url, account.name)
|
|
605
|
-
with open_page(data, None) as page, _stops(result):
|
|
612
|
+
with open_page(data, None) as page, _stops(result), otp.source(user, account.name, scope):
|
|
606
613
|
accounts.sign_in(page, account, scope, *credentials, session, details, create)
|
|
607
614
|
return result
|
|
608
615
|
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""OTPs a hiring system emails before it goes on (ADR 0015): the user's `otp-commands` entry
|
|
2
|
+
gives each one, and atsfill types it where the adapter found the OTP step's input. The core
|
|
3
|
+
sets the source for the command; an adapter only calls `enter`."""
|
|
4
|
+
|
|
5
|
+
import shlex
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Generator
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from contextvars import ContextVar
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
|
|
14
|
+
from atsfill.core import paths
|
|
15
|
+
from atsfill.core.adapter import Blocked
|
|
16
|
+
from atsfill.core.browser import Locator
|
|
17
|
+
|
|
18
|
+
OTP_TIMEOUT_S = 90 # from the step showing to the OTP typed in: a mail takes seconds
|
|
19
|
+
POLL_S = 3 # between runs of a command that has no OTP yet
|
|
20
|
+
EVERY_ADAPTER = "all" # the `otp-commands` key for the adapters without their own
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Source:
|
|
25
|
+
"""The user's `otp-commands` entry for the adapter, None without one, and when the last
|
|
26
|
+
OTP was asked for: an OTP from before is not this one."""
|
|
27
|
+
|
|
28
|
+
adapter: str
|
|
29
|
+
command: str | None
|
|
30
|
+
scope: str | None
|
|
31
|
+
since: datetime
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_source: ContextVar[Source | None] = ContextVar("otp_source", default=None)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def command(user: str, adapter: str) -> str | None:
|
|
38
|
+
"""The user's `otp-commands` entry for the adapter, else for every adapter."""
|
|
39
|
+
entries = paths.config(user).get("otp-commands", {})
|
|
40
|
+
return entries.get(adapter) or entries.get(EVERY_ADAPTER)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@contextmanager
|
|
44
|
+
def source(user: str, adapter: str, scope: str | None) -> Generator[None]:
|
|
45
|
+
"""Makes the user's entry the source of every OTP the adapter meets within, the first not
|
|
46
|
+
older than now."""
|
|
47
|
+
token = _source.set(Source(adapter, command(user, adapter), scope, _now()))
|
|
48
|
+
try:
|
|
49
|
+
yield
|
|
50
|
+
finally:
|
|
51
|
+
_source.reset(token)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def enter(inputs: Locator, email: str, step: str) -> None:
|
|
55
|
+
"""Types the OTP emailed to `email` into the step's input, or one character into each of
|
|
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."""
|
|
59
|
+
current = _source.get()
|
|
60
|
+
asked = f"{step} sent an OTP to {email}"
|
|
61
|
+
if current is None or current.command is None:
|
|
62
|
+
adapter = current.adapter if current else "the adapter"
|
|
63
|
+
raise Blocked(f"no_otp_command: {asked}; no `otp-commands` entry for {adapter}")
|
|
64
|
+
otp = _wait(current, email, asked)
|
|
65
|
+
count = inputs.count()
|
|
66
|
+
if count > 1 and count != len(otp):
|
|
67
|
+
raise Blocked(
|
|
68
|
+
f"otp_rejected: {asked}; the OTP command printed {len(otp)} characters"
|
|
69
|
+
f" for {count} inputs"
|
|
70
|
+
)
|
|
71
|
+
if count > 1:
|
|
72
|
+
for box, character in zip(inputs.all(), otp, strict=True):
|
|
73
|
+
box.press_sequentially(character)
|
|
74
|
+
else:
|
|
75
|
+
inputs.fill("")
|
|
76
|
+
inputs.press_sequentially(otp)
|
|
77
|
+
current.since = _now()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _wait(current: Source, email: str, asked: str) -> str:
|
|
81
|
+
"""Runs the command until it prints an OTP, each run within the time left: one that waits
|
|
82
|
+
for the mail itself runs once."""
|
|
83
|
+
assert current.command is not None
|
|
84
|
+
since = current.since.isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
85
|
+
filled = {"{email}": email, "{scope}": current.scope or "", "{since}": since}
|
|
86
|
+
args = shlex.split(current.command)
|
|
87
|
+
for placeholder, value in filled.items():
|
|
88
|
+
args = [a.replace(placeholder, value) for a in args]
|
|
89
|
+
deadline = time.monotonic() + OTP_TIMEOUT_S
|
|
90
|
+
while (left := deadline - time.monotonic()) > 0:
|
|
91
|
+
try:
|
|
92
|
+
run = subprocess.run(args, capture_output=True, text=True, timeout=left)
|
|
93
|
+
except subprocess.TimeoutExpired:
|
|
94
|
+
break
|
|
95
|
+
except OSError as e:
|
|
96
|
+
raise Blocked(
|
|
97
|
+
f"otp_not_received: {asked}; the OTP command `{args[0]}` failed: {type(e).__name__}"
|
|
98
|
+
) from None
|
|
99
|
+
if run.returncode == 0 and (otp := next(iter(run.stdout.split()), "")):
|
|
100
|
+
return otp
|
|
101
|
+
time.sleep(min(POLL_S, max(0.0, deadline - time.monotonic())))
|
|
102
|
+
raise Blocked(
|
|
103
|
+
f"otp_not_received: {asked}; the OTP command `{args[0]}` printed none"
|
|
104
|
+
f" within {OTP_TIMEOUT_S}s"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _now() -> datetime:
|
|
109
|
+
return datetime.now(UTC).replace(microsecond=0)
|
|
@@ -327,7 +327,7 @@ dry_run_verified = { date = 2026-10-22, version = "0.0.3", by = "adapter_gate",
|
|
|
327
327
|
[isolvedhire]
|
|
328
328
|
kind = "ats"
|
|
329
329
|
share = 0.0047
|
|
330
|
-
account = "to_read" # one account for the whole system, signed in per employer's site; Apply now verifies the email or phone with
|
|
330
|
+
account = "to_read" # one account for the whole system, signed in per employer's site; Apply now verifies the email or phone with an OTP and asks the same details as the sign-in's sign-up, which sets a password instead
|
|
331
331
|
hosting = ["hosted"]
|
|
332
332
|
adapter = "isolvedhire"
|
|
333
333
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
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
|