atsfill 0.0.4.dev89__tar.gz → 0.0.4.dev91__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.dev89 → atsfill-0.0.4.dev91}/PKG-INFO +5 -5
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/README.md +4 -4
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/adapters/phenom.py +1 -1
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/adapters/workday.py +23 -9
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/adapters/wttj.py +85 -8
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/cli.py +2 -0
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/core/accounts.py +4 -2
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/core/adapter.py +3 -1
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/core/browser.py +1 -1
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/core/commands.py +21 -7
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/core/results.py +2 -2
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/registry.toml +4 -3
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/LICENSE +0 -0
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev89 → atsfill-0.0.4.dev91}/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.dev91
|
|
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
|
|
@@ -55,7 +55,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
55
55
|
## Supported hiring systems
|
|
56
56
|
|
|
57
57
|
<!-- badges -->
|
|
58
|
-
                                                
|
|
59
59
|
<!-- /badges -->
|
|
60
60
|
|
|
61
61
|
| System | Account needed | `read` without a browser |
|
|
@@ -65,7 +65,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
65
65
|
| Teamtailor | no | no |
|
|
66
66
|
| Welcome to the Jungle | yes, `atsfill login wttj` | no |
|
|
67
67
|
|
|
68
|
-
Any other system returns `unsupported`, so the
|
|
68
|
+
Any other system returns `unsupported`, so the consumer can fall back to driving a browser itself.
|
|
69
69
|
|
|
70
70
|
## Why atsfill?
|
|
71
71
|
|
|
@@ -133,9 +133,9 @@ A dry run is not invisible: some systems save answers or the resume as they are
|
|
|
133
133
|
`atsfill status` lists each system's side effects, whether it is allowed to submit, and how far atsfill has verified it: [the systems](docs/adapters/README.md).
|
|
134
134
|
A dry run or a `send` through an adapter no send was verified on carries a `warning`; the send goes ahead anyway.
|
|
135
135
|
|
|
136
|
-
One home can
|
|
136
|
+
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)).
|
|
137
137
|
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.
|
|
138
|
-
Where a system's sign-up allows it, `atsfill login <adapter> --create --email <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`.
|
|
138
|
+
Where a system's sign-up allows it, `atsfill login <adapter> --create --email <address>` (with `--name` where the sign-up asks the user's name) 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`.
|
|
139
139
|
|
|
140
140
|
## How it works
|
|
141
141
|
|
|
@@ -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 |
|
|
@@ -39,7 +39,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
39
39
|
| Teamtailor | no | no |
|
|
40
40
|
| Welcome to the Jungle | yes, `atsfill login wttj` | no |
|
|
41
41
|
|
|
42
|
-
Any other system returns `unsupported`, so the
|
|
42
|
+
Any other system returns `unsupported`, so the consumer can fall back to driving a browser itself.
|
|
43
43
|
|
|
44
44
|
## Why atsfill?
|
|
45
45
|
|
|
@@ -107,9 +107,9 @@ A dry run is not invisible: some systems save answers or the resume as they are
|
|
|
107
107
|
`atsfill status` lists each system's side effects, whether it is allowed to submit, and how far atsfill has verified it: [the systems](docs/adapters/README.md).
|
|
108
108
|
A dry run or a `send` through an adapter no send was verified on carries a `warning`; the send goes ahead anyway.
|
|
109
109
|
|
|
110
|
-
One home can
|
|
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
|
-
Where a system's sign-up allows it, `atsfill login <adapter> --create --email <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`.
|
|
112
|
+
Where a system's sign-up allows it, `atsfill login <adapter> --create --email <address>` (with `--name` where the sign-up asks the user's name) 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
113
|
|
|
114
114
|
## How it works
|
|
115
115
|
|
|
@@ -236,7 +236,7 @@ class Phenom:
|
|
|
236
236
|
SideEffect(
|
|
237
237
|
"fill",
|
|
238
238
|
"hiring_system",
|
|
239
|
-
"each step is submitted to Phenom on Next, which keeps
|
|
239
|
+
"each step is submitted to Phenom on Next, which keeps an account profile from the"
|
|
240
240
|
" first step",
|
|
241
241
|
),
|
|
242
242
|
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
@@ -56,6 +56,7 @@ PICKED = "[data-automation-id=selectedItem]"
|
|
|
56
56
|
DELETE_PICK = "[data-automation-id=DELETE_charm]"
|
|
57
57
|
SEARCH = "input[data-uxi-widget-type=selectinput]"
|
|
58
58
|
BACK = "[data-automation-id=backButton]:visible"
|
|
59
|
+
ADDRESS = "address--"
|
|
59
60
|
PHONE_CODE_ID = "phoneNumber--countryPhoneCode"
|
|
60
61
|
PHONE_CODE = f'[data-fkit-id="{PHONE_CODE_ID}"]'
|
|
61
62
|
PHONE_CODES = "/values/phone/countryCodes"
|
|
@@ -178,6 +179,7 @@ def _profile_hosts(request: Request, scope: str | None) -> list[str]:
|
|
|
178
179
|
class Workday:
|
|
179
180
|
name = "workday"
|
|
180
181
|
per_employer = True
|
|
182
|
+
asks_name = False
|
|
181
183
|
known_gaps = {
|
|
182
184
|
"experience_entries": Gap.FLOW,
|
|
183
185
|
"long_wizard": Gap.FLOW,
|
|
@@ -234,7 +236,7 @@ class Workday:
|
|
|
234
236
|
_email_form(page)
|
|
235
237
|
_submit(page, "signInSubmitButton", email, password)
|
|
236
238
|
|
|
237
|
-
def sign_up(self, page: Page, email: str, password: str) -> None:
|
|
239
|
+
def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
|
|
238
240
|
_email_form(page)
|
|
239
241
|
page.locator("[data-automation-id=createAccountLink]").click()
|
|
240
242
|
page.locator("[data-automation-id=verifyPassword]").fill(password)
|
|
@@ -315,7 +317,7 @@ class Workday:
|
|
|
315
317
|
_until_rendered(page, pending)
|
|
316
318
|
page.wait_for_timeout(SETTLE_MS)
|
|
317
319
|
self._unread(page)
|
|
318
|
-
self._values |= {r["id"]: r["value"] for r in
|
|
320
|
+
self._values |= {r["id"]: r["value"] for r in _filled(page, answers)}
|
|
319
321
|
if self._values.get(PHONE_NUMBER) and (code := _phone_code(page)):
|
|
320
322
|
self._values[PHONE_NUMBER] = f"+{code} {self._values[PHONE_NUMBER]}"
|
|
321
323
|
_save_and_continue(page)
|
|
@@ -323,16 +325,17 @@ class Workday:
|
|
|
323
325
|
|
|
324
326
|
def _unread(self, page: Page) -> None:
|
|
325
327
|
"""Stops on required fields the read did not list: on My Information, the address of a
|
|
326
|
-
country other than the form's
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
]
|
|
328
|
+
country other than the form's, or a follow-up an answer opened (a referral's name); on a
|
|
329
|
+
later page, one the read could not reach."""
|
|
330
|
+
unread = [r for r in _page_fields(page) if r["required"] and r["id"] not in self._fields]
|
|
330
331
|
if not unread:
|
|
331
332
|
return
|
|
332
|
-
step, labels = _step(page), ", ".join(unread)
|
|
333
|
-
if step == MY_INFORMATION:
|
|
333
|
+
step, labels = _step(page), ", ".join(r["label"] for r in unread)
|
|
334
|
+
if step == MY_INFORMATION and any(r["id"].startswith(ADDRESS) for r in unread):
|
|
334
335
|
detail = f"the country answered shows fields the read did not: {labels}"
|
|
335
336
|
raise Unsupported(known_gap(self, "other_country", detail))
|
|
337
|
+
if step == MY_INFORMATION:
|
|
338
|
+
raise NotTaken(f"{step} shows fields the read did not: {labels}; an answer opened them")
|
|
336
339
|
raise NotTaken(f"{step} shows fields the read did not: {labels}; read again")
|
|
337
340
|
|
|
338
341
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
@@ -530,6 +533,17 @@ def _rendered(page: Page, answers: dict[str, Answer]) -> list[dict[str, Any]]:
|
|
|
530
533
|
return raws
|
|
531
534
|
|
|
532
535
|
|
|
536
|
+
def _filled(page: Page, answers: dict[str, Answer]) -> list[dict[str, Any]]:
|
|
537
|
+
"""The page's fields once each answered one shows a value: a picked item can leave the
|
|
538
|
+
page for a moment as it renders again."""
|
|
539
|
+
for _ in range(RENDER_TRIES):
|
|
540
|
+
raws = _page_fields(page)
|
|
541
|
+
if all(r["value"] not in (None, "", []) for r in raws if r["id"] in answers):
|
|
542
|
+
break
|
|
543
|
+
page.wait_for_timeout(SETTLE_MS)
|
|
544
|
+
return raws
|
|
545
|
+
|
|
546
|
+
|
|
533
547
|
def _field(raw: dict[str, Any]) -> Field | None:
|
|
534
548
|
kind, options = KINDS.get(raw["kind"]), raw["options"]
|
|
535
549
|
if raw["kind"] == "box":
|
|
@@ -703,7 +717,7 @@ def _fill(page: Page, field: Field, raw: dict[str, Any], answer: Answer) -> None
|
|
|
703
717
|
case "file", _:
|
|
704
718
|
files = [answer] if isinstance(answer, str) else answer
|
|
705
719
|
delete = box.locator(DELETE_FILE)
|
|
706
|
-
while (count := delete.count()) > 0: # the draft's or the profile's files
|
|
720
|
+
while (count := delete.count()) > 0: # the draft's or the account profile's files
|
|
707
721
|
delete.first.click()
|
|
708
722
|
deadline = time.monotonic() + UPLOAD_TIMEOUT_MS / 1000
|
|
709
723
|
while delete.count() == count and time.monotonic() < deadline:
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"""Welcome to the Jungle (`welcometothejungle.com` jobs and their `easyapply.jobs` links), as
|
|
2
2
|
docs/adapters/wttj.md observes.
|
|
3
3
|
|
|
4
|
-
Applying needs an account: the form opens in a modal,
|
|
5
|
-
sent before submit, so the read-back is the form's
|
|
4
|
+
Applying needs an account, which atsfill can sign in to and create: the form opens in a modal,
|
|
5
|
+
prefilled from the account profile. Nothing is sent before submit, so the read-back is the form's
|
|
6
|
+
inputs and the CV's shown name.
|
|
6
7
|
"""
|
|
7
8
|
|
|
8
9
|
import re
|
|
@@ -11,14 +12,18 @@ from typing import Any
|
|
|
11
12
|
from urllib.parse import urlsplit
|
|
12
13
|
|
|
13
14
|
from atsfill.core import browser, matching
|
|
14
|
-
from atsfill.core.adapter import Blocked, Gap, NotTaken, Unsupported
|
|
15
|
-
from atsfill.core.browser import Locator, Page, Request, Timeout
|
|
15
|
+
from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, Unsupported
|
|
16
|
+
from atsfill.core.browser import Locator, Page, Request, Response, Timeout
|
|
16
17
|
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
17
18
|
|
|
18
19
|
HOST = "www.welcometothejungle.com"
|
|
19
20
|
JOB = re.compile(r"^/(?:[a-z]{2}/)?companies/([^/]+)/jobs/([^/?#]+)")
|
|
20
21
|
FORM = "form:has([data-testid=apply-form-submit])"
|
|
21
22
|
ME = "https://api.welcometothejungle.com/api/v2/users/me"
|
|
23
|
+
SIGN_UP = f"https://{HOST}/en/authenticate/signup"
|
|
24
|
+
ACCOUNT_TIMEOUT_MS = 20_000
|
|
25
|
+
ACCOUNT_TIMEOUT_S = 20
|
|
26
|
+
ACCOUNT_POLL_MS = 250
|
|
22
27
|
FORM_TIMEOUT_MS = 20_000
|
|
23
28
|
SIGNED_IN_TIMEOUT_S = 10
|
|
24
29
|
SUBMIT_TIMEOUT_MS = 90_000
|
|
@@ -58,8 +63,12 @@ _READ_BACK = """(form, ids) => Object.fromEntries(ids.map(id => {
|
|
|
58
63
|
class WelcomeToTheJungle:
|
|
59
64
|
name = "wttj"
|
|
60
65
|
per_employer = False
|
|
66
|
+
asks_name = True # the profile's first name
|
|
61
67
|
known_gaps: dict[str, Gap] = {}
|
|
62
|
-
side_effects = [
|
|
68
|
+
side_effects = [
|
|
69
|
+
SideEffect("sign_up", "hiring_system", "login --create creates the account"),
|
|
70
|
+
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
71
|
+
]
|
|
63
72
|
|
|
64
73
|
def __init__(self) -> None:
|
|
65
74
|
self._fields: dict[str, Field] = {}
|
|
@@ -85,6 +94,17 @@ class WelcomeToTheJungle:
|
|
|
85
94
|
"""`users/me` answers 200 for an account, 204 for a visitor."""
|
|
86
95
|
return request.get(ME).status == 200
|
|
87
96
|
|
|
97
|
+
def sign_in(self, page: Page, email: str, password: str) -> None:
|
|
98
|
+
_account_form(page, "sign-in-form", {"email": email, "password": password}, "/sessions")
|
|
99
|
+
|
|
100
|
+
def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
|
|
101
|
+
"""Creates the account, signed in, then waits for its confirmation link: clicking it
|
|
102
|
+
signs every session out."""
|
|
103
|
+
page.goto(SIGN_UP)
|
|
104
|
+
values = {"firstname": name or "", "email": email, "password": password}
|
|
105
|
+
_account_form(page, "sign-up-form", values, "/registrations")
|
|
106
|
+
raise Blocked("email_link: the sign-up sent a confirmation link")
|
|
107
|
+
|
|
88
108
|
def read_form(self, page: Page) -> list[Field]:
|
|
89
109
|
"""Opens the apply modal; a job that applies on another site has no form here."""
|
|
90
110
|
if not JOB.match(urlsplit(page.url).path):
|
|
@@ -118,7 +138,7 @@ class WelcomeToTheJungle:
|
|
|
118
138
|
while location.count() and not location.input_value() and time.monotonic() < deadline:
|
|
119
139
|
page.wait_for_timeout(
|
|
120
140
|
LOCATION_POLL_MS
|
|
121
|
-
) # the profile's location comes last, a second later
|
|
141
|
+
) # the account profile's location comes last, a second later
|
|
122
142
|
fields: dict[str, Field] = {}
|
|
123
143
|
for raw in form.evaluate(_READ):
|
|
124
144
|
if raw["id"] not in fields and (field := _field(raw)) is not None:
|
|
@@ -135,7 +155,7 @@ class WelcomeToTheJungle:
|
|
|
135
155
|
box = target.locator("xpath=..")
|
|
136
156
|
remove = box.locator("button[title=Remove]")
|
|
137
157
|
if remove.count():
|
|
138
|
-
remove.click() # the profile's CV; never made the default
|
|
158
|
+
remove.click() # the account profile's CV; never made the default
|
|
139
159
|
target.set_input_files(answer)
|
|
140
160
|
box.locator(f'[data-id="{answer.rsplit("/", 1)[-1]}"]').wait_for()
|
|
141
161
|
case FieldType.CHOICE, str():
|
|
@@ -168,6 +188,63 @@ class WelcomeToTheJungle:
|
|
|
168
188
|
raise RuntimeError(f"POST {response.url} answered {response.status}")
|
|
169
189
|
|
|
170
190
|
|
|
191
|
+
def _account_form(page: Page, form: str, values: dict[str, str], posts: str) -> None:
|
|
192
|
+
"""Fills the sign-in or sign-up form, submits it and returns once the system took it; raises
|
|
193
|
+
what the system answered, or what the form says under a field it refused without posting."""
|
|
194
|
+
try:
|
|
195
|
+
page.locator(f"[data-testid={form}-submit-button]").wait_for(timeout=ACCOUNT_TIMEOUT_MS)
|
|
196
|
+
except Timeout:
|
|
197
|
+
if "too many requests" in page.locator("body").inner_text().lower():
|
|
198
|
+
reason = f"rate_limited: {urlsplit(page.url).path} answers too many requests"
|
|
199
|
+
raise Blocked(reason) from None
|
|
200
|
+
raise Unsupported(f"unhandled_rendering: no {form} on {page.url}") from None
|
|
201
|
+
for field, value in values.items():
|
|
202
|
+
page.locator(f"[data-testid={form}-{field}-input]").fill(value)
|
|
203
|
+
# posted before the page has its CSRF token, the form is answered 403 and says nothing
|
|
204
|
+
page.wait_for_function(
|
|
205
|
+
"() => document.cookie.includes('csrf-token=')", timeout=ACCOUNT_TIMEOUT_MS
|
|
206
|
+
)
|
|
207
|
+
answered: list[Response] = []
|
|
208
|
+
|
|
209
|
+
def on_response(response: Response) -> None:
|
|
210
|
+
if response.request.method == "POST" and urlsplit(response.url).path.endswith(posts):
|
|
211
|
+
answered.append(response)
|
|
212
|
+
|
|
213
|
+
page.on("response", on_response)
|
|
214
|
+
on_form = page.url
|
|
215
|
+
page.locator(f"[data-testid={form}-submit-button]").click()
|
|
216
|
+
said = page.locator(f"[data-testid^={form}-][data-testid$=-error]")
|
|
217
|
+
deadline = time.monotonic() + ACCOUNT_TIMEOUT_S
|
|
218
|
+
try:
|
|
219
|
+
while time.monotonic() < deadline:
|
|
220
|
+
if answered:
|
|
221
|
+
_account_answer(answered[0])
|
|
222
|
+
# the page leaves the form on its own: a navigation before then is aborted
|
|
223
|
+
page.wait_for_url(lambda url: url != on_form, timeout=ACCOUNT_TIMEOUT_MS)
|
|
224
|
+
return
|
|
225
|
+
if said.count():
|
|
226
|
+
field = said.first.get_attribute("data-testid") or ""
|
|
227
|
+
text = said.first.inner_text().strip()
|
|
228
|
+
if field.endswith("-password-error"):
|
|
229
|
+
raise Refused(f"password_rule: {text}")
|
|
230
|
+
raise Unsupported(f"unhandled_rendering: the form says {text!r}")
|
|
231
|
+
page.wait_for_timeout(ACCOUNT_POLL_MS)
|
|
232
|
+
finally:
|
|
233
|
+
page.remove_listener("response", on_response)
|
|
234
|
+
raise Unsupported("unhandled_rendering: the form answered nothing")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _account_answer(response: Response) -> None:
|
|
238
|
+
match response.status:
|
|
239
|
+
case 200 | 201:
|
|
240
|
+
return
|
|
241
|
+
case 401:
|
|
242
|
+
raise Refused("wrong_password")
|
|
243
|
+
case 422 if "email" in response.json().get("errors", {}):
|
|
244
|
+
raise Refused("email_in_use")
|
|
245
|
+
raise RuntimeError(f"POST {urlsplit(response.url).path} answered {response.status}")
|
|
246
|
+
|
|
247
|
+
|
|
171
248
|
def _suggest(page: Page, field: Field, box: Locator, answer: str) -> None:
|
|
172
249
|
"""Retypes the answer key by key, which loads the suggestions, and picks the first that
|
|
173
250
|
matches it, never "current position" (the item with an icon, which geolocates). Typed too
|
|
@@ -175,7 +252,7 @@ def _suggest(page: Page, field: Field, box: Locator, answer: str) -> None:
|
|
|
175
252
|
options = page.locator("[role=option]:visible").filter(has_not=page.locator("svg"))
|
|
176
253
|
for _ in range(SUGGEST_TRIES):
|
|
177
254
|
box.click()
|
|
178
|
-
box.press("Control+a") # `fill("")
|
|
255
|
+
box.press("Control+a") # `fill("")` (select-all, Delete) leaves the account profile's value
|
|
179
256
|
box.press("Backspace")
|
|
180
257
|
box.press_sequentially(answer, delay=KEY_DELAY_MS)
|
|
181
258
|
deadline = time.monotonic() + SUGGEST_TIMEOUT_S
|
|
@@ -105,6 +105,7 @@ def parser() -> argparse.ArgumentParser:
|
|
|
105
105
|
action="store_true",
|
|
106
106
|
help="create the account with --email and a password atsfill generates and stores",
|
|
107
107
|
)
|
|
108
|
+
login.add_argument("--name", help="the user's name, for a sign-up that asks it")
|
|
108
109
|
for name in ("read", "fill", "send", "status", "login", "logout"):
|
|
109
110
|
sub.choices[name].add_argument(
|
|
110
111
|
"-v", "--verbose", action="store_true", help="the whole result, every key, indented"
|
|
@@ -170,6 +171,7 @@ def run(args: argparse.Namespace) -> int:
|
|
|
170
171
|
password=password,
|
|
171
172
|
window=args.window,
|
|
172
173
|
create=args.create,
|
|
174
|
+
name=args.name,
|
|
173
175
|
**where,
|
|
174
176
|
)
|
|
175
177
|
except ValueError as e:
|
|
@@ -54,14 +54,16 @@ def sign_in(
|
|
|
54
54
|
password: str,
|
|
55
55
|
session: Path,
|
|
56
56
|
create: bool = False,
|
|
57
|
+
name: str | None = None,
|
|
57
58
|
) -> None:
|
|
58
|
-
"""Signs in, or creates the account
|
|
59
|
+
"""Signs in, or creates the account (with the user's `name` where it asks one), from the
|
|
60
|
+
system's login page and stores the session.
|
|
59
61
|
Raises what the adapter raises, without the password in it."""
|
|
60
62
|
page.goto(adapter.login_url(scope))
|
|
61
63
|
try:
|
|
62
64
|
if create:
|
|
63
65
|
assert isinstance(adapter, CreatesAccount)
|
|
64
|
-
adapter.sign_up(page, address, password)
|
|
66
|
+
adapter.sign_up(page, address, password, name)
|
|
65
67
|
else:
|
|
66
68
|
adapter.sign_in(page, address, password)
|
|
67
69
|
except Exception as e:
|
|
@@ -128,7 +128,9 @@ class CreatesAccount(SignsIn, Protocol):
|
|
|
128
128
|
"""An adapter atsfill can create an account through, where the system's sign-up allows it.
|
|
129
129
|
Account creation is a side effect it declares (ADR 0008)."""
|
|
130
130
|
|
|
131
|
-
|
|
131
|
+
asks_name: bool # the sign-up asks the user's name
|
|
132
|
+
|
|
133
|
+
def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
|
|
132
134
|
"""Creates the account from the page `login_url` loaded, signed in once it returns.
|
|
133
135
|
Raises as `sign_in` does; `Refused` names the rule a `password_rule` page showed."""
|
|
134
136
|
...
|
|
@@ -124,7 +124,7 @@ def _build_blocker() -> None:
|
|
|
124
124
|
local = f"assets/{BLOCKER_LISTS}/{key}.txt"
|
|
125
125
|
(build / local).parent.mkdir(exist_ok=True)
|
|
126
126
|
(build / local).write_text(_filter_list([*urls, *entry.get("cdnURLs", [])]))
|
|
127
|
-
entry.pop("off", None) # on from the start, the way a fresh profile reads it
|
|
127
|
+
entry.pop("off", None) # on from the start, the way a fresh browser profile reads it
|
|
128
128
|
entry["contentURL"] = [local, *urls]
|
|
129
129
|
entries["atsfill"] = {"content": "filters", "group": "custom", "title": "atsfill",
|
|
130
130
|
"contentURL": [FILTERS]} # fmt: skip
|
|
@@ -425,13 +425,16 @@ def login(
|
|
|
425
425
|
password: str | None = None,
|
|
426
426
|
window: bool = False,
|
|
427
427
|
create: bool = False,
|
|
428
|
+
name: str | None = None,
|
|
428
429
|
data_dir: Path | None = None,
|
|
429
430
|
) -> SessionReport:
|
|
430
431
|
"""Signs in with the account's password source, headless, and stores the session; without
|
|
431
432
|
a source, or with `window`, opens a window on the system's login page for a person.
|
|
432
433
|
|
|
433
434
|
`email` is remembered for the account; `password` is used for this sign-in only. `create`
|
|
434
|
-
creates the account with a password atsfill generates and stores first (a side effect)
|
|
435
|
+
creates the account with a password atsfill generates and stores first (a side effect),
|
|
436
|
+
giving `name` where the sign-up asks it; with a password already stored, the account may
|
|
437
|
+
exist, so it signs in with that password and keeps the account's email instead."""
|
|
435
438
|
account, data = _account(adapter), paths.data_dir(data_dir)
|
|
436
439
|
scope = _scope(account, url)
|
|
437
440
|
mine = paths.user_dir(data, user)
|
|
@@ -439,11 +442,18 @@ def login(
|
|
|
439
442
|
report = SessionReport(user, adapter, scope, "missing", session)
|
|
440
443
|
if create and not email:
|
|
441
444
|
raise ValueError("creating an account needs --email")
|
|
442
|
-
|
|
445
|
+
try:
|
|
446
|
+
kept = create and passwords.stored(mine, adapter, scope) is not None
|
|
447
|
+
except passwords.PasswordError as e:
|
|
448
|
+
report.status, report.reason = Status.BLOCKED, f"password_source: {e}"
|
|
449
|
+
return report
|
|
450
|
+
if email and not kept:
|
|
443
451
|
accounts.remember(mine, adapter, scope, email)
|
|
444
|
-
if create:
|
|
452
|
+
if create and not kept:
|
|
445
453
|
assert email
|
|
446
|
-
return _create(report, account, url, mine, email, data)
|
|
454
|
+
return _create(report, account, url, mine, (email, name), data)
|
|
455
|
+
if kept:
|
|
456
|
+
report.password = "kept"
|
|
447
457
|
if password and not (isinstance(account, SignsIn) and accounts.email(mine, adapter, scope)):
|
|
448
458
|
raise ValueError(f"signing in to {adapter} with a password needs --email")
|
|
449
459
|
try:
|
|
@@ -473,7 +483,7 @@ def _create(
|
|
|
473
483
|
account: NeedsAccount,
|
|
474
484
|
url: str | None,
|
|
475
485
|
mine: Path,
|
|
476
|
-
|
|
486
|
+
who: tuple[str, str | None],
|
|
477
487
|
data: Path,
|
|
478
488
|
) -> SessionReport:
|
|
479
489
|
"""Creates the account: the generated password is stored before the click, and deleted
|
|
@@ -482,13 +492,16 @@ def _create(
|
|
|
482
492
|
report.status = Status.UNSUPPORTED
|
|
483
493
|
report.reason = f"no_sign_up: atsfill cannot create a {account.name} account"
|
|
484
494
|
return report
|
|
495
|
+
email, name = who
|
|
496
|
+
if account.asks_name and not name:
|
|
497
|
+
raise ValueError(f"creating a {account.name} account needs --name")
|
|
485
498
|
password = passwords.generate()
|
|
486
499
|
try:
|
|
487
500
|
passwords.store(mine, account.name, report.scope, password)
|
|
488
501
|
except passwords.PasswordError as e:
|
|
489
502
|
report.status, report.reason = Status.BLOCKED, f"password_source: {e}"
|
|
490
503
|
return report
|
|
491
|
-
result = _headless(data, account, report.scope, (email, password), report.path,
|
|
504
|
+
result = _headless(data, account, report.scope, (email, password), report.path, True, name)
|
|
492
505
|
command = login_command(account.name, report.user, url)
|
|
493
506
|
report.status, report.reason = result.status, _email_link(result.reason, email, command)
|
|
494
507
|
report.password = "stored"
|
|
@@ -507,6 +520,7 @@ def _headless(
|
|
|
507
520
|
credentials: Credentials,
|
|
508
521
|
session: Path,
|
|
509
522
|
create: bool = False,
|
|
523
|
+
name: str | None = None,
|
|
510
524
|
) -> Result:
|
|
511
525
|
"""Signs in, or creates the account, through the adapter: `ready` once the session is
|
|
512
526
|
stored."""
|
|
@@ -516,7 +530,7 @@ def _headless(
|
|
|
516
530
|
def run(_: list[str]) -> Result:
|
|
517
531
|
result = Result(Status.READY, url, account.name)
|
|
518
532
|
with open_page(data, None) as page, _stops(result):
|
|
519
|
-
accounts.sign_in(page, account, scope, *credentials, session, create)
|
|
533
|
+
accounts.sign_in(page, account, scope, *credentials, session, create, name)
|
|
520
534
|
return result
|
|
521
535
|
|
|
522
536
|
return _in_budget(url, run)
|
|
@@ -34,7 +34,7 @@ class Field:
|
|
|
34
34
|
options: list[str] | None = None
|
|
35
35
|
max_length: int | None = None
|
|
36
36
|
accept: list[str] | None = None
|
|
37
|
-
prefilled: bool = False # holds a value before any answer, from the account
|
|
37
|
+
prefilled: bool = False # holds a value before any answer, from the account profile
|
|
38
38
|
unfillable: str | None = None # why atsfill cannot fill it, a reason (ADR 0014)
|
|
39
39
|
|
|
40
40
|
|
|
@@ -75,7 +75,7 @@ class SessionReport:
|
|
|
75
75
|
path: Path
|
|
76
76
|
status: str | None = None # a login's outcome: signed_in | refused | blocked | unsupported
|
|
77
77
|
reason: str | None = None # why a login stored nothing, `code` or `code: detail`
|
|
78
|
-
password: str | None = None # after `--create
|
|
78
|
+
password: str | None = None # after `--create`: stored | deleted, or kept
|
|
79
79
|
|
|
80
80
|
|
|
81
81
|
@dataclass
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
# send_verified
|
|
14
14
|
# unsupported
|
|
15
15
|
# date, version, commit, by, reason: a round that ended without the adapter passing
|
|
16
|
-
# the gate (ADR 0013), the known gap that stopped it
|
|
16
|
+
# the adapter gate (ADR 0013), the known gap that stopped it
|
|
17
17
|
# out_of_scope
|
|
18
18
|
# reason, date: a system atsfill will never fill, it needs the user live (ADR 0014)
|
|
19
19
|
# send_broken
|
|
@@ -53,7 +53,7 @@ account = "to_send"
|
|
|
53
53
|
hosting = ["hosted"]
|
|
54
54
|
adapter = "wttj"
|
|
55
55
|
send_verified = { date = 2026-10-20, version = "0.0.3", by = "shbernal", commit = "9bc6d84" }
|
|
56
|
-
dry_run_verified = { date = 2026-10-
|
|
56
|
+
dry_run_verified = { date = 2026-10-24, version = "0.0.3", by = "adapter_gate", commit = "90c58c3" }
|
|
57
57
|
|
|
58
58
|
[workday]
|
|
59
59
|
kind = "ats"
|
|
@@ -62,6 +62,7 @@ account = "to_read"
|
|
|
62
62
|
hosting = ["hosted"]
|
|
63
63
|
adapter = "workday"
|
|
64
64
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
65
|
+
dry_run_verified = { date = 2026-10-24, version = "0.0.3", by = "adapter_gate", commit = "fdb237a" }
|
|
65
66
|
|
|
66
67
|
[trudvsem]
|
|
67
68
|
kind = "board"
|
|
@@ -315,7 +316,7 @@ dry_run_verified = { date = 2026-10-22, version = "0.0.3", by = "adapter_gate",
|
|
|
315
316
|
[isolvedhire]
|
|
316
317
|
kind = "ats"
|
|
317
318
|
share = 0.0047
|
|
318
|
-
account = "to_read" # Apply now first verifies the email or phone with
|
|
319
|
+
account = "to_read" # Apply now first verifies the email or phone with an OTP, then creates an account on the employer's careers site; the application wizard (/apply/<job>-0.php) redirects to that site's login until then
|
|
319
320
|
hosting = ["hosted"]
|
|
320
321
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
321
322
|
|
|
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
|