atsfill 0.0.4.dev80__tar.gz → 0.0.4.dev83__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.dev80 → atsfill-0.0.4.dev83}/PKG-INFO +7 -2
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/README.md +5 -1
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/pyproject.toml +2 -1
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/pyproject.toml.orig +2 -1
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/adapters/oracle.py +6 -6
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/adapters/paycor.py +1 -1
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/adapters/wttj.py +8 -2
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/cli.py +73 -16
- atsfill-0.0.4.dev83/src/atsfill/core/accounts.py +111 -0
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/core/adapter.py +44 -5
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/core/attempts.py +7 -7
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/core/browser.py +1 -1
- atsfill-0.0.4.dev83/src/atsfill/core/commands.py +534 -0
- atsfill-0.0.4.dev83/src/atsfill/core/passwords.py +127 -0
- atsfill-0.0.4.dev83/src/atsfill/core/paths.py +97 -0
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/core/results.py +18 -12
- atsfill-0.0.4.dev80/src/atsfill/core/accounts.py +0 -42
- atsfill-0.0.4.dev80/src/atsfill/core/commands.py +0 -315
- atsfill-0.0.4.dev80/src/atsfill/core/paths.py +0 -40
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/LICENSE +0 -0
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/py.typed +0 -0
- {atsfill-0.0.4.dev80 → atsfill-0.0.4.dev83}/src/atsfill/registry.toml +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.dev83
|
|
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
|
|
@@ -16,6 +16,7 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
16
16
|
Classifier: Topic :: Office/Business
|
|
17
17
|
Classifier: Typing :: Typed
|
|
18
18
|
Requires-Dist: camoufox==0.5.8
|
|
19
|
+
Requires-Dist: keyring==25.7.0
|
|
19
20
|
Requires-Dist: packaging==26.3
|
|
20
21
|
Requires-Python: >=3.14
|
|
21
22
|
Project-URL: Homepage, https://github.com/shbernal/atsfill
|
|
@@ -119,7 +120,7 @@ Every push to `main` that passes CI is also published as a dev release; `uvx --p
|
|
|
119
120
|
4. Allow the system to submit in `~/.config/atsfill/config.toml`:
|
|
120
121
|
|
|
121
122
|
```toml
|
|
122
|
-
submit = ["greenhouse"]
|
|
123
|
+
submit-allowed = ["greenhouse"]
|
|
123
124
|
```
|
|
124
125
|
|
|
125
126
|
5. Send. A `submitted` result carries the evidence of the send:
|
|
@@ -132,6 +133,10 @@ A dry run is not invisible: some systems save answers or the resume as they are
|
|
|
132
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).
|
|
133
134
|
A `send` through an adapter no send was verified on goes ahead with a `warning`.
|
|
134
135
|
|
|
136
|
+
One home can apply for more than one person: `--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
|
+
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`.
|
|
139
|
+
|
|
135
140
|
## How it works
|
|
136
141
|
|
|
137
142
|
```mermaid
|
|
@@ -94,7 +94,7 @@ Every push to `main` that passes CI is also published as a dev release; `uvx --p
|
|
|
94
94
|
4. Allow the system to submit in `~/.config/atsfill/config.toml`:
|
|
95
95
|
|
|
96
96
|
```toml
|
|
97
|
-
submit = ["greenhouse"]
|
|
97
|
+
submit-allowed = ["greenhouse"]
|
|
98
98
|
```
|
|
99
99
|
|
|
100
100
|
5. Send. A `submitted` result carries the evidence of the send:
|
|
@@ -107,6 +107,10 @@ 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 `send` through an adapter no send was verified on goes ahead with a `warning`.
|
|
109
109
|
|
|
110
|
+
One home can apply for more than one person: `--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
|
+
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`.
|
|
113
|
+
|
|
110
114
|
## How it works
|
|
111
115
|
|
|
112
116
|
```mermaid
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "atsfill"
|
|
3
|
-
version = "0.0.4.
|
|
3
|
+
version = "0.0.4.dev83"
|
|
4
4
|
description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -28,6 +28,7 @@ classifiers = [
|
|
|
28
28
|
]
|
|
29
29
|
dependencies = [
|
|
30
30
|
"camoufox==0.5.8",
|
|
31
|
+
"keyring==25.7.0",
|
|
31
32
|
"packaging==26.3",
|
|
32
33
|
]
|
|
33
34
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "atsfill"
|
|
3
|
-
version = "0.0.4.
|
|
3
|
+
version = "0.0.4.dev83"
|
|
4
4
|
description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -21,6 +21,7 @@ classifiers = [
|
|
|
21
21
|
]
|
|
22
22
|
dependencies = [
|
|
23
23
|
"camoufox==0.5.8",
|
|
24
|
+
"keyring==25.7.0",
|
|
24
25
|
"packaging==26.3",
|
|
25
26
|
]
|
|
26
27
|
|
|
@@ -2,7 +2,7 @@
|
|
|
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
|
|
5
|
+
emailed OTP instead. Reading passes the step with a throwaway address; filling starts over with
|
|
6
6
|
the answer's. Oracle keeps a draft of the application from then on, autosaved as fields change.
|
|
7
7
|
"""
|
|
8
8
|
|
|
@@ -31,7 +31,7 @@ SUBMIT = ".apply-flow-pagination__submit-button"
|
|
|
31
31
|
PAGINATION = ".apply-flow-pagination__button"
|
|
32
32
|
PAGE_NUMBERS = ".apply-flow-navigation-pages__link:visible"
|
|
33
33
|
LOADING = ".app-loading-spinner, .cx-select--loading, .apply-flow-block .skeleton"
|
|
34
|
-
|
|
34
|
+
OTP_PAGE = ".apply-flow-pin-code-required"
|
|
35
35
|
CAPTCHA = "iframe[src*='hcaptcha.com']"
|
|
36
36
|
MANUALLY = ".quick-apply-flow__manually--link:visible" # past an offer to import a profile
|
|
37
37
|
COOKIES = ".cookie-consent button:visible"
|
|
@@ -356,7 +356,7 @@ class Oracle:
|
|
|
356
356
|
if "--checked" not in (box.get_attribute("class") or ""):
|
|
357
357
|
_click(page, box)
|
|
358
358
|
_click(page, page.locator(NEXT).first)
|
|
359
|
-
opened = page.locator(f"apply-flow-block, {
|
|
359
|
+
opened = page.locator(f"apply-flow-block, {OTP_PAGE}, {MANUALLY}")
|
|
360
360
|
try:
|
|
361
361
|
opened.first.wait_for(timeout=STEP_TIMEOUT_MS)
|
|
362
362
|
if page.locator(MANUALLY).count():
|
|
@@ -366,10 +366,10 @@ class Oracle:
|
|
|
366
366
|
if page.locator(CAPTCHA).count():
|
|
367
367
|
raise Blocked("captcha: the email step asks for an hCaptcha") from None
|
|
368
368
|
raise RuntimeError("the email step did not open the form") from None
|
|
369
|
-
if page.locator(
|
|
369
|
+
if page.locator(OTP_PAGE).count():
|
|
370
370
|
raise Blocked(
|
|
371
|
-
f"
|
|
372
|
-
"
|
|
371
|
+
f"email_otp: {email} is known to this employer's Oracle, which emails it an"
|
|
372
|
+
" OTP before it shows the form"
|
|
373
373
|
)
|
|
374
374
|
page.locator(f"{PAGINATION}:visible").first.wait_for(timeout=STEP_TIMEOUT_MS)
|
|
375
375
|
_settle(page)
|
|
@@ -259,7 +259,7 @@ class Paycor:
|
|
|
259
259
|
named.nth(n).evaluate(_CHECK, option in labels)
|
|
260
260
|
|
|
261
261
|
def _walk(self, page: Page, answers: dict[str, Answer] | None) -> None:
|
|
262
|
-
"""Opens the apply page and answers the pages before the form: the
|
|
262
|
+
"""Opens the apply page and answers the pages before the form: the Test user's picks
|
|
263
263
|
when filling, the last option and Yes when only reading, which list the fields."""
|
|
264
264
|
if self._start is None:
|
|
265
265
|
raise RuntimeError("no Paycor job in the page")
|
|
@@ -57,7 +57,7 @@ _READ_BACK = """(form, ids) => Object.fromEntries(ids.map(id => {
|
|
|
57
57
|
|
|
58
58
|
class WelcomeToTheJungle:
|
|
59
59
|
name = "wttj"
|
|
60
|
-
|
|
60
|
+
per_employer = False
|
|
61
61
|
known_gaps: dict[str, Gap] = {}
|
|
62
62
|
side_effects = [SideEffect("submit", "employer", "the application reaches the employer")]
|
|
63
63
|
|
|
@@ -75,7 +75,13 @@ class WelcomeToTheJungle:
|
|
|
75
75
|
match = JOB.match(urlsplit(url).path)
|
|
76
76
|
return f"https://{HOST}/companies/{match[1]}/jobs/{match[2]}" if match else url
|
|
77
77
|
|
|
78
|
-
def
|
|
78
|
+
def account_scope(self, url: str) -> None:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
def login_url(self, scope: str | None) -> str:
|
|
82
|
+
return f"https://{HOST}/en/signin"
|
|
83
|
+
|
|
84
|
+
def signed_in(self, request: Request, scope: str | None) -> bool:
|
|
79
85
|
"""`users/me` answers 200 for an account, 204 for a visitor."""
|
|
80
86
|
return request.get(ME).status == 200
|
|
81
87
|
|
|
@@ -6,7 +6,7 @@ from pathlib import Path
|
|
|
6
6
|
|
|
7
7
|
from atsfill import core
|
|
8
8
|
from atsfill.adapters import adapters
|
|
9
|
-
from atsfill.core import browser
|
|
9
|
+
from atsfill.core import browser, paths
|
|
10
10
|
from atsfill.core.adapter import NeedsAccount
|
|
11
11
|
from atsfill.core.results import Result, SessionReport, Status, StatusReport
|
|
12
12
|
|
|
@@ -20,6 +20,22 @@ def session(value: str) -> tuple[str, Path]:
|
|
|
20
20
|
return adapter, Path(path)
|
|
21
21
|
|
|
22
22
|
|
|
23
|
+
def user(value: str) -> str:
|
|
24
|
+
if not paths.USER_NAME.fullmatch(value):
|
|
25
|
+
raise argparse.ArgumentTypeError(f"lowercase letters, digits and -, got {value!r}")
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def with_user(command: argparse.ArgumentParser) -> argparse.ArgumentParser:
|
|
30
|
+
command.add_argument(
|
|
31
|
+
"--user",
|
|
32
|
+
type=user,
|
|
33
|
+
default=paths.DEFAULT_USER,
|
|
34
|
+
help="the user applications are sent for (default: %(default)s)",
|
|
35
|
+
)
|
|
36
|
+
return command
|
|
37
|
+
|
|
38
|
+
|
|
23
39
|
HELP = {
|
|
24
40
|
"read": "list a posting's form fields",
|
|
25
41
|
"fill": "dry run: fill the form and read it back, never submit",
|
|
@@ -42,7 +58,9 @@ def parser() -> argparse.ArgumentParser:
|
|
|
42
58
|
p.add_argument("--data-dir", type=Path, help="default: $ATSFILL_DATA_DIR, then the state dir")
|
|
43
59
|
sub = p.add_subparsers(dest="command", required=True)
|
|
44
60
|
for name in ("read", "fill", "send"):
|
|
45
|
-
c =
|
|
61
|
+
c = with_user(
|
|
62
|
+
sub.add_parser(name, help=HELP[name], epilog=EPILOG, formatter_class=p.formatter_class)
|
|
63
|
+
)
|
|
46
64
|
c.add_argument("url")
|
|
47
65
|
c.add_argument(
|
|
48
66
|
"--session",
|
|
@@ -65,11 +83,25 @@ def parser() -> argparse.ArgumentParser:
|
|
|
65
83
|
sub.add_parser("status", help="version, paths, browser, and each adapter's state")
|
|
66
84
|
sub.add_parser("install-browser", help="download the Camoufox build atsfill is pinned to")
|
|
67
85
|
accounts = [a.name for a in adapters() if isinstance(a, NeedsAccount)]
|
|
68
|
-
|
|
69
|
-
"login",
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
86
|
+
for name, text in (
|
|
87
|
+
("login", "sign in to a system that needs an account"),
|
|
88
|
+
("logout", "delete a stored session"),
|
|
89
|
+
):
|
|
90
|
+
c = with_user(sub.add_parser(name, help=text))
|
|
91
|
+
c.add_argument("adapter", choices=accounts)
|
|
92
|
+
c.add_argument("--url", help="a posting, for a system with an account per employer")
|
|
93
|
+
login = sub.choices["login"]
|
|
94
|
+
login.add_argument("--email", help="the account's email, remembered for it")
|
|
95
|
+
login.add_argument(
|
|
96
|
+
"--password", choices=["-"], help="read the password from stdin, for this sign-in only"
|
|
97
|
+
)
|
|
98
|
+
login.add_argument(
|
|
99
|
+
"--window", action="store_true", help="sign in in a visible window, by a person"
|
|
100
|
+
)
|
|
101
|
+
login.add_argument(
|
|
102
|
+
"--create",
|
|
103
|
+
action="store_true",
|
|
104
|
+
help="create the account with --email and a password atsfill generates and stores",
|
|
73
105
|
)
|
|
74
106
|
return p
|
|
75
107
|
|
|
@@ -85,35 +117,60 @@ def answers_file(path: str) -> dict:
|
|
|
85
117
|
|
|
86
118
|
|
|
87
119
|
def run(args: argparse.Namespace) -> int:
|
|
120
|
+
try:
|
|
121
|
+
paths.config(getattr(args, "user", paths.DEFAULT_USER))
|
|
122
|
+
except paths.ConfigError as e:
|
|
123
|
+
print(f"atsfill: {e}", file=sys.stderr)
|
|
124
|
+
return 2
|
|
88
125
|
match args.command:
|
|
89
126
|
case "read":
|
|
90
|
-
result = core.read(
|
|
127
|
+
result = core.read(
|
|
128
|
+
args.url, user=args.user, data_dir=args.data_dir, sessions=dict(args.session)
|
|
129
|
+
)
|
|
91
130
|
case "fill":
|
|
92
131
|
result = core.fill(
|
|
93
|
-
args.url,
|
|
132
|
+
args.url,
|
|
133
|
+
args.answers,
|
|
134
|
+
user=args.user,
|
|
135
|
+
data_dir=args.data_dir,
|
|
136
|
+
sessions=dict(args.session),
|
|
94
137
|
)
|
|
95
138
|
case "send":
|
|
96
139
|
result = core.send(
|
|
97
140
|
args.url,
|
|
98
141
|
args.answers,
|
|
99
142
|
resend=args.resend,
|
|
143
|
+
user=args.user,
|
|
100
144
|
data_dir=args.data_dir,
|
|
101
145
|
sessions=dict(args.session),
|
|
102
146
|
)
|
|
103
147
|
case "status":
|
|
104
148
|
report = core.status(data_dir=args.data_dir)
|
|
105
149
|
emit(report)
|
|
106
|
-
signed_out = any(
|
|
150
|
+
signed_out = any(s.session == "signed_out" for s in report.sessions)
|
|
107
151
|
return 1 if signed_out or report.browser != report.browser_pinned else 0
|
|
108
152
|
case "install-browser":
|
|
109
153
|
return browser.install()
|
|
110
|
-
case "login":
|
|
111
|
-
|
|
154
|
+
case "login" | "logout":
|
|
155
|
+
where = {"user": args.user, "url": args.url, "data_dir": args.data_dir}
|
|
156
|
+
try:
|
|
157
|
+
if args.command == "logout":
|
|
158
|
+
session = core.logout(args.adapter, **where)
|
|
159
|
+
else:
|
|
160
|
+
password = sys.stdin.readline().rstrip("\n") if args.password else None
|
|
161
|
+
session = core.login(
|
|
162
|
+
args.adapter,
|
|
163
|
+
email=args.email,
|
|
164
|
+
password=password,
|
|
165
|
+
window=args.window,
|
|
166
|
+
create=args.create,
|
|
167
|
+
**where,
|
|
168
|
+
)
|
|
169
|
+
except ValueError as e:
|
|
170
|
+
print(f"atsfill: {e}", file=sys.stderr)
|
|
171
|
+
return 2
|
|
112
172
|
emit(session)
|
|
113
|
-
return 0 if session.session == "signed_in" else 1
|
|
114
|
-
case "logout":
|
|
115
|
-
emit(core.logout(args.adapter, data_dir=args.data_dir))
|
|
116
|
-
return 0
|
|
173
|
+
return 0 if args.command == "logout" or session.session == "signed_in" else 1
|
|
117
174
|
emit(result)
|
|
118
175
|
return 0 if result.status in OK else 1
|
|
119
176
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Stored sessions for the systems that need an account (ADRs 0007, 0015)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from atsfill.core import browser
|
|
9
|
+
from atsfill.core.adapter import CreatesAccount, NeedsAccount, SignsIn, Unsupported
|
|
10
|
+
from atsfill.core.browser import Page
|
|
11
|
+
from atsfill.core.passwords import account_key
|
|
12
|
+
|
|
13
|
+
LOGIN_TIMEOUT_S = 600
|
|
14
|
+
POLL_MS = 1000
|
|
15
|
+
SCOPE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*") # one path segment
|
|
16
|
+
EMAILS = "accounts.json" # in the user's directory, each account's email
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def session_file(user_dir: Path, adapter: str, scope: str | None) -> Path:
|
|
20
|
+
"""`sessions/<adapter>.json`, or `sessions/<adapter>/<scope>.json` for a scoped account."""
|
|
21
|
+
if scope is None:
|
|
22
|
+
return user_dir / "sessions" / f"{adapter}.json"
|
|
23
|
+
if not SCOPE.fullmatch(scope):
|
|
24
|
+
raise ValueError(f"{scope!r} is not an account scope")
|
|
25
|
+
return user_dir / "sessions" / adapter / f"{scope}.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def stored(user_dir: Path, adapter: str) -> list[tuple[str | None, Path]]:
|
|
29
|
+
"""Every session the user stored for the adapter, with its scope."""
|
|
30
|
+
sessions = user_dir / "sessions"
|
|
31
|
+
whole = (
|
|
32
|
+
[(None, sessions / f"{adapter}.json")] if (sessions / f"{adapter}.json").exists() else []
|
|
33
|
+
)
|
|
34
|
+
return whole + [(p.stem, p) for p in sorted((sessions / adapter).glob("*.json"))]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def email(user_dir: Path, adapter: str, scope: str | None) -> str | None:
|
|
38
|
+
path = user_dir / EMAILS
|
|
39
|
+
emails = json.loads(path.read_text()) if path.exists() else {}
|
|
40
|
+
return emails.get(account_key(adapter, scope))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def remember(user_dir: Path, adapter: str, scope: str | None, address: str) -> None:
|
|
44
|
+
path = user_dir / EMAILS
|
|
45
|
+
emails = json.loads(path.read_text()) if path.exists() else {}
|
|
46
|
+
path.write_text(json.dumps(emails | {account_key(adapter, scope): address}, indent=2))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def sign_in(
|
|
50
|
+
page: Page,
|
|
51
|
+
adapter: SignsIn,
|
|
52
|
+
scope: str | None,
|
|
53
|
+
address: str,
|
|
54
|
+
password: str,
|
|
55
|
+
session: Path,
|
|
56
|
+
create: bool = False,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Signs in, or creates the account, from the system's login page and stores the session.
|
|
59
|
+
Raises what the adapter raises, without the password in it."""
|
|
60
|
+
page.goto(adapter.login_url(scope))
|
|
61
|
+
try:
|
|
62
|
+
if create:
|
|
63
|
+
assert isinstance(adapter, CreatesAccount)
|
|
64
|
+
adapter.sign_up(page, address, password)
|
|
65
|
+
else:
|
|
66
|
+
adapter.sign_in(page, address, password)
|
|
67
|
+
except Exception as e:
|
|
68
|
+
if password in str(e):
|
|
69
|
+
raise redacted(e, password) from None
|
|
70
|
+
raise
|
|
71
|
+
if not adapter.signed_in(page.request, scope):
|
|
72
|
+
raise Unsupported("unhandled_rendering: still signed out after signing in")
|
|
73
|
+
browser.save_session(page, session)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def redacted(error: Exception, password: str) -> Exception:
|
|
77
|
+
message = str(error).replace(password, "<password>")
|
|
78
|
+
try:
|
|
79
|
+
return type(error)(message)
|
|
80
|
+
except TypeError:
|
|
81
|
+
return RuntimeError(f"{type(error).__name__}: {message}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def state(adapter: NeedsAccount, session: Path, scope: str | None) -> str:
|
|
85
|
+
"""`signed_in` or `signed_out` from the system itself, `missing` with no session stored."""
|
|
86
|
+
if not session.exists():
|
|
87
|
+
return "missing"
|
|
88
|
+
with browser.session_request(session) as request:
|
|
89
|
+
return "signed_in" if adapter.signed_in(request, scope) else "signed_out"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def wait_for_person(
|
|
93
|
+
adapter: NeedsAccount, scope: str | None, data_dir: Path, session: Path
|
|
94
|
+
) -> str | None:
|
|
95
|
+
"""Waits for a person to sign in in the login window, then stores the session.
|
|
96
|
+
|
|
97
|
+
Returns the reason it did not: `window_closed` or `login_timeout`; None once stored.
|
|
98
|
+
"""
|
|
99
|
+
with browser.login_window(data_dir, adapter.login_url(scope)) as page:
|
|
100
|
+
deadline = time.monotonic() + LOGIN_TIMEOUT_S
|
|
101
|
+
try:
|
|
102
|
+
while time.monotonic() < deadline:
|
|
103
|
+
if adapter.signed_in(page.request, scope):
|
|
104
|
+
browser.save_session(page, session)
|
|
105
|
+
return None
|
|
106
|
+
page.wait_for_timeout(POLL_MS)
|
|
107
|
+
except browser.BrowserError:
|
|
108
|
+
if page.is_closed():
|
|
109
|
+
return "window_closed"
|
|
110
|
+
raise
|
|
111
|
+
return "login_timeout"
|
|
@@ -6,8 +6,8 @@ from atsfill.core.results import Answer, Field, SideEffect
|
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class Blocked(Exception):
|
|
9
|
-
"""The system stopped the command: a captcha, a low reCAPTCHA score, an
|
|
10
|
-
|
|
9
|
+
"""The system stopped the command: a captcha, a low reCAPTCHA score, an OTP, a login
|
|
10
|
+
wall.
|
|
11
11
|
|
|
12
12
|
The message is the result's reason, `code` or `code: detail`.
|
|
13
13
|
"""
|
|
@@ -38,6 +38,14 @@ def out_of_scope(field_id: str, what: str) -> str:
|
|
|
38
38
|
return f"out_of_scope: {field_id} is {what}"
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
class Refused(Exception):
|
|
42
|
+
"""The system turned down a sign-in or a sign-up for a reason it states:
|
|
43
|
+
`email_in_use`, `email_domain_rejected`, `password_rule`, `wrong_password`.
|
|
44
|
+
|
|
45
|
+
The message is the result's reason, `code` or `code: detail`.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
41
49
|
class NotTaken(Exception):
|
|
42
50
|
"""The page would not take an answer the user gave: no suggestion or option holds it.
|
|
43
51
|
|
|
@@ -78,15 +86,46 @@ class Adapter(Protocol):
|
|
|
78
86
|
|
|
79
87
|
@runtime_checkable
|
|
80
88
|
class NeedsAccount(Protocol):
|
|
81
|
-
"""An adapter whose system needs a signed-in session
|
|
89
|
+
"""An adapter whose system needs a signed-in session, one per user and account scope
|
|
90
|
+
(ADR 0015)."""
|
|
82
91
|
|
|
83
|
-
|
|
92
|
+
name: str
|
|
93
|
+
per_employer: bool # each employer keeps its own accounts, so a scope is always given
|
|
94
|
+
|
|
95
|
+
def account_scope(self, url: str) -> str | None:
|
|
96
|
+
"""The account scope of a posting's URL: None where an account covers the whole system,
|
|
97
|
+
the employer where each keeps its own, the same for every URL of that employer."""
|
|
98
|
+
...
|
|
84
99
|
|
|
85
|
-
def
|
|
100
|
+
def login_url(self, scope: str | None) -> str: ...
|
|
101
|
+
|
|
102
|
+
def signed_in(self, request: Request, scope: str | None) -> bool:
|
|
86
103
|
"""Whether the cookies `request` carries are signed in, from one request to the system."""
|
|
87
104
|
...
|
|
88
105
|
|
|
89
106
|
|
|
107
|
+
@runtime_checkable
|
|
108
|
+
class SignsIn(NeedsAccount, Protocol):
|
|
109
|
+
"""An adapter atsfill can sign in through with an email and a password (ADR 0015)."""
|
|
110
|
+
|
|
111
|
+
def sign_in(self, page: Page, email: str, password: str) -> None:
|
|
112
|
+
"""Signs in on the page `login_url` loaded. Raises `Blocked` for what the system waits
|
|
113
|
+
for (`email_link`, `email_otp`, `sms_otp`, `captcha`), `Refused` for what it turns down,
|
|
114
|
+
`Unsupported` for a page it does not know."""
|
|
115
|
+
...
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@runtime_checkable
|
|
119
|
+
class CreatesAccount(SignsIn, Protocol):
|
|
120
|
+
"""An adapter atsfill can create an account through, where the system's sign-up allows it.
|
|
121
|
+
Account creation is a side effect it declares (ADR 0008)."""
|
|
122
|
+
|
|
123
|
+
def sign_up(self, page: Page, email: str, password: str) -> None:
|
|
124
|
+
"""Creates the account from the page `login_url` loaded, signed in once it returns.
|
|
125
|
+
Raises as `sign_in` does; `Refused` names the rule a `password_rule` page showed."""
|
|
126
|
+
...
|
|
127
|
+
|
|
128
|
+
|
|
90
129
|
@runtime_checkable
|
|
91
130
|
class ReadsOverHttp(Adapter, Protocol):
|
|
92
131
|
"""An adapter that can list a form's fields over HTTP, without a browser (ADR 0011)."""
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""The double-send guard: `attempts.jsonl` in the
|
|
1
|
+
"""The double-send guard: `attempts.jsonl` in the user's directory (ADRs 0006, 0015)."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
from datetime import UTC, datetime
|
|
@@ -9,8 +9,8 @@ from atsfill.core.results import Status
|
|
|
9
9
|
PENDING = "clicked" # appended before the click; a later line records the outcome
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
def last(
|
|
13
|
-
path =
|
|
12
|
+
def last(user_dir: Path, posting: str) -> str | None:
|
|
13
|
+
path = user_dir / "attempts.jsonl"
|
|
14
14
|
if not path.exists():
|
|
15
15
|
return None
|
|
16
16
|
outcomes = [
|
|
@@ -21,12 +21,12 @@ def last(data_dir: Path, posting: str) -> str | None:
|
|
|
21
21
|
return outcomes[-1] if outcomes else None
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
def blocks_send(
|
|
24
|
+
def blocks_send(user_dir: Path, posting: str) -> bool:
|
|
25
25
|
"""A last attempt that was clicked without an outcome counts as `unknown`."""
|
|
26
|
-
return last(
|
|
26
|
+
return last(user_dir, posting) in (Status.SUBMITTED, Status.UNKNOWN, PENDING)
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
def record(
|
|
29
|
+
def record(user_dir: Path, posting: str, status: str, **extra: str | None) -> None:
|
|
30
30
|
line = {"at": datetime.now(UTC).isoformat(), "posting": posting, "status": status, **extra}
|
|
31
|
-
with (
|
|
31
|
+
with (user_dir / "attempts.jsonl").open("a") as f:
|
|
32
32
|
f.write(json.dumps(line, ensure_ascii=False) + "\n")
|
|
@@ -179,7 +179,7 @@ def login_window(data_dir: Path, url: str, headless: bool = False) -> Generator[
|
|
|
179
179
|
|
|
180
180
|
|
|
181
181
|
def save_session(page: Page, path: Path) -> None:
|
|
182
|
-
path.parent.mkdir(mode=0o700, exist_ok=True)
|
|
182
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
183
183
|
path.touch(mode=0o600)
|
|
184
184
|
page.context.storage_state(path=path)
|
|
185
185
|
|