atsfill 0.0.5.dev171__tar.gz → 0.0.5.dev177__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.5.dev171 → atsfill-0.0.5.dev177}/PKG-INFO +1 -1
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/pyproject.toml +1 -1
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/pyproject.toml.orig +1 -1
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/join.py +19 -13
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/lever.py +2 -1
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/successfactors.py +4 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/core/commands.py +29 -5
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/LICENSE +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/README.md +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/adpmyjobs.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/gupy.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/isolvedhire.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/jobaffinity.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/jobstreet.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/recruiterflow.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/welcomekit.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/workatastartup.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/core/otp.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/core/snapshot.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/core/telemetry.py +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/py.typed +0 -0
- {atsfill-0.0.5.dev171 → atsfill-0.0.5.dev177}/src/atsfill/registry.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atsfill
|
|
3
|
-
Version: 0.0.5.
|
|
3
|
+
Version: 0.0.5.dev177
|
|
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
|
|
@@ -58,8 +58,10 @@ QUESTIONS = """query JobScreeningQuestions($input: JobScreeningQuestionsInput!)
|
|
|
58
58
|
}
|
|
59
59
|
}"""
|
|
60
60
|
YES_NO = ["Yes", "No"] # a boolean's cards, in this order, in the job's language
|
|
61
|
-
FILES = {"cv": "cv"} # a file field, by its step
|
|
62
|
-
#
|
|
61
|
+
FILES = {"cv": "cv", "coverLetter": "coverLetter"} # a file field, by its step
|
|
62
|
+
# The documents a job asks, by their type in the wizard's `documentsConfig`
|
|
63
|
+
DOCUMENTS = {"CV": ("cv", "CV"), "COVER_LETTER": ("coverLetter", "Cover letter")}
|
|
64
|
+
# A question of a class has a step of its own; the others are `question-<position>`.
|
|
63
65
|
CLASS_STEPS = {
|
|
64
66
|
"workAuthorization": "WORK_AUTHORIZATION",
|
|
65
67
|
"salaryExpectation": "SALARY_EXPECTATION",
|
|
@@ -200,11 +202,6 @@ class Join:
|
|
|
200
202
|
answered = page.evaluate(_FETCH, [f"{GRAPHQL}?operationName=JobScreeningQuestions", body])
|
|
201
203
|
questions = answered["data"]["jobScreeningQuestions"]["questions"]
|
|
202
204
|
self._questions = sorted(questions, key=lambda q: q.get("position") or 0)
|
|
203
|
-
fields = [
|
|
204
|
-
Field("cv", "CV", FieldType.FILE, True, accept=PDF),
|
|
205
|
-
*(_question(q) for q in self._questions),
|
|
206
|
-
]
|
|
207
|
-
self._fields = {f.id: f for f in fields if not f.unfillable}
|
|
208
205
|
browser.click(page.locator("[data-testid=ApplyButton]").locator("visible=true").first)
|
|
209
206
|
page.wait_for_url("**/apply/**", timeout=STEP_TIMEOUT_MS)
|
|
210
207
|
# the wizard opens on `authentication`, and leaves it once it knows the account
|
|
@@ -216,6 +213,16 @@ class Join:
|
|
|
216
213
|
page.wait_for_timeout(POLL_MS)
|
|
217
214
|
if _step(page) == "authentication":
|
|
218
215
|
raise Blocked("login_required: run `atsfill login join`")
|
|
216
|
+
# the wizard's page names the documents the job asks, each required or optional
|
|
217
|
+
raw = page.locator("script#__NEXT_DATA__").text_content() or "{}"
|
|
218
|
+
state = json.loads(raw)["props"]["pageProps"].get("initialState") or {}
|
|
219
|
+
documents = (state.get("job") or {}).get("documentsConfig") or [{"type": "CV"}]
|
|
220
|
+
fields = [
|
|
221
|
+
*(Field(*DOCUMENTS[d["type"]], FieldType.FILE, d.get("constraint") != "OPTIONAL",
|
|
222
|
+
accept=PDF) for d in documents if d["type"] in DOCUMENTS),
|
|
223
|
+
*(_question(q) for q in self._questions),
|
|
224
|
+
] # fmt: skip
|
|
225
|
+
self._fields = {f.id: f for f in fields if not f.unfillable}
|
|
219
226
|
return fields
|
|
220
227
|
|
|
221
228
|
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
@@ -232,8 +239,6 @@ class Join:
|
|
|
232
239
|
field_id = f"question.{question['id']}"
|
|
233
240
|
if field_id in self._fields:
|
|
234
241
|
self._question(page, question, answers.get(field_id))
|
|
235
|
-
elif step == "coverLetter": # asked by some jobs only: left without one
|
|
236
|
-
pass
|
|
237
242
|
elif step in PROFILE_STEPS:
|
|
238
243
|
try: # shown when the job requires what the account profile lacks
|
|
239
244
|
_next(page, step)
|
|
@@ -252,10 +257,11 @@ class Join:
|
|
|
252
257
|
if kind := CLASS_STEPS.get(step):
|
|
253
258
|
return next((q for q in self._questions if q.get("questionClass") == kind), None)
|
|
254
259
|
if match := re.fullmatch(r"question-(\d+)", step):
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
260
|
+
position = int(match[1])
|
|
261
|
+
plain = (q for q in self._questions if not q.get("questionClass"))
|
|
262
|
+
if question := next((q for q in plain if q.get("position") == position), None):
|
|
263
|
+
return question
|
|
264
|
+
raise Unsupported(f"unhandled_rendering: a question step {step} not listed")
|
|
259
265
|
return None
|
|
260
266
|
|
|
261
267
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
@@ -19,6 +19,7 @@ from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
|
19
19
|
HOSTS = re.compile(r"^jobs\.(eu\.)?lever\.co$")
|
|
20
20
|
JOB = re.compile(r"^/([^/]+)/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b")
|
|
21
21
|
FORM = "form#application-form"
|
|
22
|
+
CAPTCHA = "iframe[src*='hcaptcha.com']" # hidden until hCaptcha asks the visitor a challenge
|
|
22
23
|
FORM_TIMEOUT_MS = 30_000
|
|
23
24
|
UPLOAD_TIMEOUT_MS = 30_000
|
|
24
25
|
SUGGEST_TIMEOUT_MS = 10_000
|
|
@@ -188,7 +189,7 @@ class Lever:
|
|
|
188
189
|
try:
|
|
189
190
|
page.wait_for_url(re.compile(r"/thanks\b"), timeout=SUBMIT_TIMEOUT_MS)
|
|
190
191
|
except Timeout:
|
|
191
|
-
if page.locator("
|
|
192
|
+
if page.locator(CAPTCHA).locator("visible=true").count():
|
|
192
193
|
raise Blocked("captcha: hCaptcha asked for a challenge") from None
|
|
193
194
|
raise
|
|
194
195
|
return page.url
|
|
@@ -173,6 +173,10 @@ class SuccessFactors:
|
|
|
173
173
|
|
|
174
174
|
def sign_in(self, page: Page, email: str, password: str) -> None:
|
|
175
175
|
self._candidate(page)
|
|
176
|
+
if page.locator(QUICK_APPLY).count(): # its sign-in is a dialog, beside its sign-up
|
|
177
|
+
raise Unsupported(
|
|
178
|
+
known_gap(self, "quick_apply_sign_up", "the application is a Quick Apply page")
|
|
179
|
+
)
|
|
176
180
|
if not page.locator(SIGN_IN).count():
|
|
177
181
|
page.locator(SIGN_IN_LINK).first.click()
|
|
178
182
|
page.locator(SIGN_IN).wait_for(timeout=LANDING_TIMEOUT_MS)
|
|
@@ -98,6 +98,9 @@ def _login_required(url: str, adapter: NeedsAccount, user: str) -> Result:
|
|
|
98
98
|
type Credentials = tuple[str, str | None] # email, password: None for an account without one
|
|
99
99
|
# Refusals the page states before any account exists: the generated password goes.
|
|
100
100
|
NO_ACCOUNT = {"email_in_use", "email_domain_rejected", "password_rule", "sign_up_details"}
|
|
101
|
+
# A sign-in's refusals that find no account with the password: a stored one may be from a
|
|
102
|
+
# sign-up blocked before the account existed.
|
|
103
|
+
NOT_SIGNED_UP = {"wrong_password", "no_account"}
|
|
101
104
|
|
|
102
105
|
|
|
103
106
|
def _next_step(
|
|
@@ -750,10 +753,11 @@ def login(
|
|
|
750
753
|
if password and by_otp:
|
|
751
754
|
raise ValueError(f"{adapter} signs in with an emailed OTP, not a password")
|
|
752
755
|
try:
|
|
753
|
-
|
|
756
|
+
stored = passwords.stored(mine, adapter, scope) if create and not by_otp else None
|
|
754
757
|
except passwords.PasswordError as e:
|
|
755
758
|
report.status, report.reason = Status.BLOCKED, f"password_source: {e}"
|
|
756
759
|
return report
|
|
760
|
+
kept = stored is not None
|
|
757
761
|
if email and not kept:
|
|
758
762
|
accounts.remember(mine, adapter, scope, email)
|
|
759
763
|
if create and not kept:
|
|
@@ -784,6 +788,23 @@ def login(
|
|
|
784
788
|
)
|
|
785
789
|
else:
|
|
786
790
|
result = _headless(data, user, account, scope, credentials, session, details)
|
|
791
|
+
if (
|
|
792
|
+
kept
|
|
793
|
+
and isinstance(account, CreatesAccount)
|
|
794
|
+
and (result.reason or "").split(":")[0] in NOT_SIGNED_UP
|
|
795
|
+
and credentials[1] == stored
|
|
796
|
+
):
|
|
797
|
+
# a sign-up blocked before the account existed: sign up again, same password
|
|
798
|
+
return _create(
|
|
799
|
+
report,
|
|
800
|
+
account,
|
|
801
|
+
url,
|
|
802
|
+
mine,
|
|
803
|
+
credentials[0],
|
|
804
|
+
details or SignUpDetails(),
|
|
805
|
+
data,
|
|
806
|
+
kept=credentials[1],
|
|
807
|
+
)
|
|
787
808
|
report.status = result.status
|
|
788
809
|
report.reason = _next_step(result.reason, credentials[0], adapter, user, url)
|
|
789
810
|
if report.reason is None:
|
|
@@ -799,10 +820,12 @@ def _create(
|
|
|
799
820
|
email: str,
|
|
800
821
|
details: SignUpDetails,
|
|
801
822
|
data: Path,
|
|
823
|
+
kept: str | None = None,
|
|
802
824
|
) -> SessionReport:
|
|
803
825
|
"""Creates the account: refused before the browser opens when `details` lack one the
|
|
804
826
|
sign-up asks; the generated password is stored before the click, and deleted only on a
|
|
805
|
-
refusal the page states before any account exists.
|
|
827
|
+
refusal the page states before any account exists. A `kept` password, stored by an
|
|
828
|
+
earlier sign-up, is used again and never deleted. An account without a password gets
|
|
806
829
|
none."""
|
|
807
830
|
if not isinstance(account, (CreatesAccount, CreatesAccountByOtp)):
|
|
808
831
|
report.status = Status.UNSUPPORTED
|
|
@@ -812,9 +835,9 @@ def _create(
|
|
|
812
835
|
report.status = Status.REFUSED
|
|
813
836
|
report.reason = f"sign_up_details: no {', '.join(missing)}"
|
|
814
837
|
return report
|
|
815
|
-
password = None if isinstance(account, CreatesAccountByOtp) else passwords.generate()
|
|
838
|
+
password = kept or (None if isinstance(account, CreatesAccountByOtp) else passwords.generate())
|
|
816
839
|
try:
|
|
817
|
-
if password:
|
|
840
|
+
if password and not kept:
|
|
818
841
|
passwords.store(mine, account.name, report.scope, password)
|
|
819
842
|
except passwords.PasswordError as e:
|
|
820
843
|
report.status, report.reason = Status.BLOCKED, f"password_source: {e}"
|
|
@@ -825,9 +848,10 @@ def _create(
|
|
|
825
848
|
)
|
|
826
849
|
report.status = result.status
|
|
827
850
|
report.reason = _next_step(result.reason, email, account.name, report.user, url)
|
|
828
|
-
report.password = "stored" if password else "none"
|
|
851
|
+
report.password = "kept" if kept else "stored" if password else "none"
|
|
829
852
|
if (
|
|
830
853
|
password
|
|
854
|
+
and not kept
|
|
831
855
|
and result.status is Status.REFUSED
|
|
832
856
|
and (result.reason or "").split(":")[0] in NO_ACCOUNT
|
|
833
857
|
):
|
|
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
|
|
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
|