atsfill 0.0.4.dev133__tar.gz → 0.0.4.dev135__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.dev133 → atsfill-0.0.4.dev135}/PKG-INFO +3 -2
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/README.md +2 -1
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/isolvedhire.py +0 -1
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/oracle.py +27 -13
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/successfactors.js +3 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/successfactors.py +84 -15
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/core/commands.py +18 -11
- atsfill-0.0.4.dev135/src/atsfill/core/otp.py +102 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/registry.toml +2 -2
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/LICENSE +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/gupy.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/join.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/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.dev135
|
|
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
|
|
@@ -57,7 +57,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
57
57
|
## Supported hiring systems
|
|
58
58
|
|
|
59
59
|
<!-- badges -->
|
|
60
|
-
                                                       
|
|
61
61
|
<!-- /badges -->
|
|
62
62
|
|
|
63
63
|
| System | Account needed | `read` without a browser |
|
|
@@ -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: email_otp`.
|
|
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
|
|
@@ -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
|
-
                                                       
|
|
33
33
|
<!-- /badges -->
|
|
34
34
|
|
|
35
35
|
| System | Account needed | `read` without a browser |
|
|
@@ -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: email_otp`.
|
|
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
|
|
@@ -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("email_otp: 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)
|
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
d.kind = 'picklist';
|
|
27
27
|
d.id = picklist.id;
|
|
28
28
|
d.owns = picklist.getAttribute('aria-owns');
|
|
29
|
+
d.placeholder = picklist.placeholder; // its empty item's text, in the page's language
|
|
30
|
+
const opener = box.querySelector('button.rcmpaginatedselectbutton');
|
|
31
|
+
d.disabled = !!opener && opener.disabled; // a cascading list before its parent is chosen
|
|
29
32
|
d.value = picklist.value === picklist.placeholder ? '' : picklist.value;
|
|
30
33
|
d.required = d.required || picklist.getAttribute('aria-required') === 'true';
|
|
31
34
|
} else if (radios.length) {
|
|
@@ -11,8 +11,9 @@ import re
|
|
|
11
11
|
import time
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
from typing import Any
|
|
14
|
-
from urllib.parse import urlsplit
|
|
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,
|
|
@@ -22,21 +23,30 @@ from atsfill.core.adapter import (
|
|
|
22
23
|
Unsupported,
|
|
23
24
|
known_gap,
|
|
24
25
|
)
|
|
25
|
-
from atsfill.core.browser import Locator, Page, Request, Timeout
|
|
26
|
+
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"
|
|
33
|
-
|
|
36
|
+
JOB_REQ = "career_job_req_id" # the job the apply link posts, the candidate pages' own id
|
|
37
|
+
APPLICATION = "/career?company={company}&career_ns=job_application&career_job_req_id={req}"
|
|
38
|
+
GUIDED = "body.cxApplyEnabled" # a site whose application is a step-by-step one, not sections
|
|
39
|
+
HOME = "body.careerUserLoggedIn" # any candidate page signed in, the candidate home's too
|
|
40
|
+
# The body's class for an account; a signed-out page also holds a log-out link and its label
|
|
41
|
+
SIGNED_IN = re.compile(r"<body[^>]*\bclass=[\"'][^\"']*\bcareerUserLoggedIn\b")
|
|
34
42
|
FIELDS = (Path(__file__).with_suffix(".js")).read_text()
|
|
35
43
|
# What the candidate pages show once loaded
|
|
36
44
|
SIGN_IN = "#username"
|
|
37
45
|
COUNTRY = "fbclc_country" # its options' values are ISO 3166 alpha-2 codes
|
|
38
46
|
PHONE, CALLING_CODE = "#fbclc_phoneNumber", "#fbclc_ituCode" # `CA`: `Canada (+1)`
|
|
39
47
|
SIGN_UP = "#fbclc_userName"
|
|
48
|
+
PASSCODE = "#passcode" # an OTP some sign-ups ask before the account exists
|
|
49
|
+
PASSCODE_STEP = "#otpContainer"
|
|
40
50
|
QUICK_APPLY = "#fbqa_apply"
|
|
41
51
|
FORM = ".RCMFormField"
|
|
42
52
|
LANDED = f"{SIGN_IN}, {SIGN_UP}, {FORM}"
|
|
@@ -47,6 +57,8 @@ SIGN_IN_MODAL = "a[onclick*=openSignInModal]" # a Quick Apply page's
|
|
|
47
57
|
REFUSAL = re.compile(r"required|exist|already|invalid|bereits|erforderlich|ungültig", re.I)
|
|
48
58
|
ACTIVATE = re.compile(r"activat|verify your|verification e-?mail|bestätig", re.I)
|
|
49
59
|
PRIVACY = "#dataPrivacyId"
|
|
60
|
+
VISIBILITY = "input[name=fbclc_searPref]" # who may see the profile
|
|
61
|
+
APPLIED_ONLY = "2" # its option for only the recruiters of the jobs applied to
|
|
50
62
|
EXPAND = "[id$=_expandAllSections]"
|
|
51
63
|
SUBMIT = "[id$=_submitBtn]"
|
|
52
64
|
OVERLAY = "#sfOverlayMgr" # a confirmation dialog
|
|
@@ -88,6 +100,15 @@ def _company(request: Request) -> list[tuple[str, str]]:
|
|
|
88
100
|
)
|
|
89
101
|
|
|
90
102
|
|
|
103
|
+
def _posts_job(request: PageRequest) -> bool:
|
|
104
|
+
"""Whether the apply link's request posts a job to the candidate pages."""
|
|
105
|
+
return (
|
|
106
|
+
request.method == "POST"
|
|
107
|
+
and bool(DC_HOST.match(urlsplit(request.url).hostname or ""))
|
|
108
|
+
and JOB_REQ in (request.post_data or "")
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
91
112
|
def _calling_code(select: Locator, details: SignUpDetails) -> tuple[str, str]:
|
|
92
113
|
"""The calling code's option for the E.164 phone, the one of `country` among the countries
|
|
93
114
|
sharing it, and the national number that follows the code."""
|
|
@@ -108,7 +129,10 @@ class SuccessFactors:
|
|
|
108
129
|
name = "successfactors"
|
|
109
130
|
per_employer = True
|
|
110
131
|
asks = {"name"}
|
|
111
|
-
known_gaps = {
|
|
132
|
+
known_gaps = {
|
|
133
|
+
"quick_apply_sign_up": Gap.FLOW,
|
|
134
|
+
"guided_application": Gap.FLOW,
|
|
135
|
+
}
|
|
112
136
|
side_effects = [
|
|
113
137
|
SideEffect("sign_up", "employer", "the account is created in the employer's tenant"),
|
|
114
138
|
SideEffect("fill", "hiring_system", "an attachment is uploaded to the account's profile"),
|
|
@@ -140,7 +164,7 @@ class SuccessFactors:
|
|
|
140
164
|
return self._postings.get(scope or "", f"https://{scope}/")
|
|
141
165
|
|
|
142
166
|
def signed_in(self, request: Request, scope: str | None) -> bool:
|
|
143
|
-
"""The profile page of the session's company
|
|
167
|
+
"""The profile page of the session's company marks its body for an account."""
|
|
144
168
|
for host, company in _company(request):
|
|
145
169
|
response = request.get(f"https://{host}{PROFILE.format(company=company)}")
|
|
146
170
|
if response.ok and SIGNED_IN.search(response.text()):
|
|
@@ -165,8 +189,9 @@ class SuccessFactors:
|
|
|
165
189
|
|
|
166
190
|
def sign_up(self, page: Page, email: str, password: str, details: SignUpDetails) -> None:
|
|
167
191
|
"""Creates the account from the career site's sign-up: the name, the country of
|
|
168
|
-
residence from `country` (the list's first without it),
|
|
169
|
-
requires one, its calling code picked from the number
|
|
192
|
+
residence from `country` (the list's first without it), the phone where the site
|
|
193
|
+
requires one, its calling code picked from the number, and the profile visible only to
|
|
194
|
+
the recruiters of the jobs applied to where the site asks; a missing phone is refused
|
|
170
195
|
before anything is sent."""
|
|
171
196
|
self._candidate(page)
|
|
172
197
|
if page.locator(SIGN_IN).count():
|
|
@@ -206,6 +231,11 @@ class SuccessFactors:
|
|
|
206
231
|
if wanted and wanted not in values:
|
|
207
232
|
raise Unsupported(f"unhandled_rendering: the sign-up's countries lack {wanted}")
|
|
208
233
|
select.select_option(wanted or values[0])
|
|
234
|
+
if page.locator(f"{VISIBILITY}:visible").count():
|
|
235
|
+
least = page.locator(f"{VISIBILITY}[value='{APPLIED_ONLY}']")
|
|
236
|
+
if not least.count():
|
|
237
|
+
raise Unsupported("unhandled_rendering: the profile's visibility has no jobs only")
|
|
238
|
+
least.check()
|
|
209
239
|
if page.locator(PRIVACY).count():
|
|
210
240
|
page.locator(PRIVACY).click()
|
|
211
241
|
dialog = page.locator("[role=dialog]:visible").last
|
|
@@ -216,6 +246,10 @@ class SuccessFactors:
|
|
|
216
246
|
page.wait_for_timeout(POLL_MS)
|
|
217
247
|
if page.locator(FORM).count():
|
|
218
248
|
return
|
|
249
|
+
if page.locator(f"{PASSCODE}:visible").count():
|
|
250
|
+
self._passcode(page, email)
|
|
251
|
+
deadline = time.monotonic() + SIGN_IN_TIMEOUT_MS / 1000
|
|
252
|
+
continue
|
|
219
253
|
if not page.locator(f"{SIGN_UP}:visible").count():
|
|
220
254
|
break
|
|
221
255
|
if self._message(page, REFUSAL):
|
|
@@ -226,7 +260,7 @@ class SuccessFactors:
|
|
|
226
260
|
if re.search(r"password|kennwort", message, re.IGNORECASE):
|
|
227
261
|
raise Refused(f"password_rule: {message}")
|
|
228
262
|
if message:
|
|
229
|
-
raise Unsupported(
|
|
263
|
+
raise Unsupported(f"unhandled_rendering: the sign-up refused: {message}")
|
|
230
264
|
text = page.locator("body").inner_text()
|
|
231
265
|
if ACTIVATE.search(text):
|
|
232
266
|
raise Blocked("email_link: the sign-up sent an activation email")
|
|
@@ -237,6 +271,16 @@ class SuccessFactors:
|
|
|
237
271
|
return
|
|
238
272
|
raise Unsupported(f"unhandled_rendering: the sign-up led to {urlsplit(page.url).path}")
|
|
239
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"email_otp: the sign-up did not take the OTP: {message}") from None
|
|
283
|
+
|
|
240
284
|
def browser_read(self, page: Page) -> list[Field]:
|
|
241
285
|
self._candidate(page)
|
|
242
286
|
if not page.locator(FORM).count():
|
|
@@ -249,8 +293,8 @@ class SuccessFactors:
|
|
|
249
293
|
page.wait_for_timeout(SETTLE_MS)
|
|
250
294
|
fields = []
|
|
251
295
|
for d in page.evaluate(FIELDS):
|
|
252
|
-
if d["kind"] == "picklist":
|
|
253
|
-
d["options"] = self._options(page, d)
|
|
296
|
+
if d["kind"] == "picklist": # a disabled one waits for its parent: no items yet
|
|
297
|
+
d["options"] = [] if d["disabled"] else self._options(page, d)
|
|
254
298
|
self._fields[d["key"]] = d
|
|
255
299
|
fields.append(self._field(d))
|
|
256
300
|
return fields
|
|
@@ -288,7 +332,7 @@ class SuccessFactors:
|
|
|
288
332
|
items = self._open(page, d)
|
|
289
333
|
options = [t for t in items.evaluate_all("as => as.map(a => a.title.trim())") if t]
|
|
290
334
|
self._close(page, d)
|
|
291
|
-
return [o for o in options if o
|
|
335
|
+
return [o for o in options if o not in (NO_SELECTION, d["placeholder"])]
|
|
292
336
|
|
|
293
337
|
def _open(self, page: Page, d: dict[str, Any]) -> Locator:
|
|
294
338
|
"""Opens a picklist; a click can go unheard, or close a list another left open."""
|
|
@@ -343,6 +387,8 @@ class SuccessFactors:
|
|
|
343
387
|
self._upload(page, d, value)
|
|
344
388
|
|
|
345
389
|
def _pick(self, page: Page, d: dict[str, Any], value: str) -> None:
|
|
390
|
+
if d["disabled"]:
|
|
391
|
+
raise NotTaken(f"{d['key']} stays disabled: its parent's answer offers no items")
|
|
346
392
|
items = self._open(page, d)
|
|
347
393
|
titles = [t.strip() for t in items.evaluate_all("as => as.map(a => a.title)")]
|
|
348
394
|
wanted = next((i for i, t in enumerate(titles) if t == value), None)
|
|
@@ -364,8 +410,8 @@ class SuccessFactors:
|
|
|
364
410
|
confirm.first.wait_for(timeout=LIST_TIMEOUT_MS)
|
|
365
411
|
confirm.first.click()
|
|
366
412
|
deadline = time.monotonic() + UPLOAD_TIMEOUT_S
|
|
367
|
-
while self._current(page, d["key"])["multiple"]:
|
|
368
|
-
if time.monotonic() > deadline:
|
|
413
|
+
while (now := self._current(page, d["key"]))["multiple"] or now["id"] is None:
|
|
414
|
+
if time.monotonic() > deadline: # deleting, it shows neither button
|
|
369
415
|
raise NotTaken(f"{d['key']} kept its file")
|
|
370
416
|
page.wait_for_timeout(POLL_MS)
|
|
371
417
|
source = page.locator("[role=dialog]:visible input[type=file]")
|
|
@@ -407,16 +453,39 @@ class SuccessFactors:
|
|
|
407
453
|
return page.url
|
|
408
454
|
|
|
409
455
|
def _candidate(self, page: Page) -> None:
|
|
410
|
-
"""From the posting, follows its apply link to the candidate pages.
|
|
456
|
+
"""From the posting, follows its apply link to the candidate pages. The link posts the
|
|
457
|
+
job there; signed in, some sites drop it and land on the candidate home, so the
|
|
458
|
+
application is opened from the job the link posted."""
|
|
411
459
|
host = urlsplit(page.url).hostname or ""
|
|
460
|
+
posted: list[PageRequest] = []
|
|
412
461
|
if not DC_HOST.match(host):
|
|
413
462
|
apply = page.locator(APPLY)
|
|
414
463
|
if not apply.count():
|
|
415
464
|
raise Unsupported("posting_gone: the posting has no apply link")
|
|
465
|
+
|
|
466
|
+
def keep(request: PageRequest) -> None:
|
|
467
|
+
if _posts_job(request):
|
|
468
|
+
posted.append(request)
|
|
469
|
+
|
|
470
|
+
page.on("request", keep)
|
|
416
471
|
apply.first.evaluate("a => a.click()") # some sites open a dialog on a real click
|
|
417
472
|
try:
|
|
473
|
+
page.locator(f"{LANDED}, {HOME}").first.wait_for(
|
|
474
|
+
state="attached", timeout=LANDING_TIMEOUT_MS
|
|
475
|
+
)
|
|
476
|
+
page.wait_for_load_state(timeout=LANDING_TIMEOUT_MS)
|
|
477
|
+
if posted and JOB_REQ not in page.url and not page.locator(LANDED).count():
|
|
478
|
+
post = posted[0]
|
|
479
|
+
parts = urlsplit(post.url)
|
|
480
|
+
job = parse_qs(parts.query) | parse_qs(post.post_data or "")
|
|
481
|
+
application = APPLICATION.format(company=job["company"][0], req=job[JOB_REQ][0])
|
|
482
|
+
page.goto(f"https://{parts.hostname}{application}")
|
|
418
483
|
page.locator(LANDED).first.wait_for(state="attached", timeout=LANDING_TIMEOUT_MS)
|
|
419
484
|
except Timeout:
|
|
485
|
+
if page.locator(GUIDED).count():
|
|
486
|
+
raise Unsupported(
|
|
487
|
+
known_gap(self, "guided_application", "the application is a step-by-step one")
|
|
488
|
+
) from None
|
|
420
489
|
raise Unsupported(
|
|
421
490
|
f"unhandled_rendering: the apply link led to {urlsplit(page.url).path}"
|
|
422
491
|
) from None
|
|
@@ -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,102 @@
|
|
|
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` (`email_otp`) when there is no entry, or no OTP within
|
|
57
|
+
`OTP_TIMEOUT_S`; `step` names where the OTP was asked, for the reason."""
|
|
58
|
+
current = _source.get()
|
|
59
|
+
asked = f"email_otp: {step} sent an OTP to {email}"
|
|
60
|
+
if current is None or current.command is None:
|
|
61
|
+
adapter = current.adapter if current else "the adapter"
|
|
62
|
+
raise Blocked(f"{asked}; no `otp-commands` entry for {adapter}")
|
|
63
|
+
otp = _wait(current, email, asked)
|
|
64
|
+
count = inputs.count()
|
|
65
|
+
if count > 1 and count != len(otp):
|
|
66
|
+
raise Blocked(f"{asked}; the OTP command printed {len(otp)} characters for {count} inputs")
|
|
67
|
+
if count > 1:
|
|
68
|
+
for box, character in zip(inputs.all(), otp, strict=True):
|
|
69
|
+
box.press_sequentially(character)
|
|
70
|
+
else:
|
|
71
|
+
inputs.fill("")
|
|
72
|
+
inputs.press_sequentially(otp)
|
|
73
|
+
current.since = _now()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _wait(current: Source, email: str, asked: str) -> str:
|
|
77
|
+
"""Runs the command until it prints an OTP, each run within the time left: one that waits
|
|
78
|
+
for the mail itself runs once."""
|
|
79
|
+
assert current.command is not None
|
|
80
|
+
since = current.since.isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
81
|
+
filled = {"{email}": email, "{scope}": current.scope or "", "{since}": since}
|
|
82
|
+
args = shlex.split(current.command)
|
|
83
|
+
for placeholder, value in filled.items():
|
|
84
|
+
args = [a.replace(placeholder, value) for a in args]
|
|
85
|
+
deadline = time.monotonic() + OTP_TIMEOUT_S
|
|
86
|
+
while (left := deadline - time.monotonic()) > 0:
|
|
87
|
+
try:
|
|
88
|
+
run = subprocess.run(args, capture_output=True, text=True, timeout=left)
|
|
89
|
+
except subprocess.TimeoutExpired:
|
|
90
|
+
break
|
|
91
|
+
except OSError as e:
|
|
92
|
+
raise Blocked(
|
|
93
|
+
f"{asked}; the OTP command `{args[0]}` failed: {type(e).__name__}"
|
|
94
|
+
) from None
|
|
95
|
+
if run.returncode == 0 and (otp := next(iter(run.stdout.split()), "")):
|
|
96
|
+
return otp
|
|
97
|
+
time.sleep(min(POLL_S, max(0.0, deadline - time.monotonic())))
|
|
98
|
+
raise Blocked(f"{asked}; the OTP command `{args[0]}` printed none within {OTP_TIMEOUT_S}s")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _now() -> datetime:
|
|
102
|
+
return datetime.now(UTC).replace(microsecond=0)
|
|
@@ -96,8 +96,8 @@ share = 0.0350
|
|
|
96
96
|
account = "to_read"
|
|
97
97
|
hosting = ["hosted"]
|
|
98
98
|
adapter = "successfactors"
|
|
99
|
-
unsupported = { date = 2026-10-26, version = "0.0.3", commit = "0d8baea", by = "agent", reason = "quick_apply_sign_up: a Quick Apply page creates the account and sends the application in one submit, its sign-in offering no sign-up; sign_up_fields: a sign-up asking who may see the profile" }
|
|
100
99
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
100
|
+
dry_run_verified = { date = 2026-10-26, version = "0.0.3", by = "adapter_gate", commit = "79c8327" }
|
|
101
101
|
|
|
102
102
|
[ukg]
|
|
103
103
|
kind = "ats"
|
|
@@ -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
|
|
File without changes
|