atsfill 0.0.5.dev143__tar.gz → 0.0.5.dev145__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.dev143 → atsfill-0.0.5.dev145}/PKG-INFO +2 -2
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/README.md +1 -1
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/pyproject.toml +1 -1
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/pyproject.toml.orig +1 -1
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/cli.py +11 -2
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/core/__init__.py +1 -1
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/core/accounts.py +22 -5
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/core/adapter.py +27 -1
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/core/commands.py +171 -93
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/core/results.py +9 -1
- atsfill-0.0.5.dev145/src/atsfill/core/snapshot.py +75 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/core/telemetry.py +85 -13
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/LICENSE +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/gupy.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/isolvedhire.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/join.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/successfactors.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/core/otp.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/src/atsfill/py.typed +0 -0
- {atsfill-0.0.5.dev143 → atsfill-0.0.5.dev145}/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.dev145
|
|
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
|
|
@@ -153,7 +153,7 @@ The core owns every rule about when a send is allowed; an adapter only knows its
|
|
|
153
153
|
Every result has a `status` (`ready`, `refused`, `submitted`, `blocked`, `unknown`, `unsupported`) and, when it is not clean, a `reason` code.
|
|
154
154
|
The exit code is 0 for `ready` and `submitted`, 1 otherwise, 2 for a usage error.
|
|
155
155
|
|
|
156
|
-
Each `read`, `fill` and `send` sends one anonymous event (the system, the command, the status, a hash of the posting, never answers or URLs): [what is sent and how to turn it off](docs/telemetry.md).
|
|
156
|
+
Each `read`, `fill` and `send` sends one anonymous event (the system, the command, the status, a hash of the posting, never answers or URLs); `atsfill report` sends a failed run's URL and page, scrubbed of your answers, when you ask: [what is sent and how to turn it off](docs/telemetry.md).
|
|
157
157
|
|
|
158
158
|
## More
|
|
159
159
|
|
|
@@ -124,7 +124,7 @@ The core owns every rule about when a send is allowed; an adapter only knows its
|
|
|
124
124
|
Every result has a `status` (`ready`, `refused`, `submitted`, `blocked`, `unknown`, `unsupported`) and, when it is not clean, a `reason` code.
|
|
125
125
|
The exit code is 0 for `ready` and `submitted`, 1 otherwise, 2 for a usage error.
|
|
126
126
|
|
|
127
|
-
Each `read`, `fill` and `send` sends one anonymous event (the system, the command, the status, a hash of the posting, never answers or URLs): [what is sent and how to turn it off](docs/telemetry.md).
|
|
127
|
+
Each `read`, `fill` and `send` sends one anonymous event (the system, the command, the status, a hash of the posting, never answers or URLs); `atsfill report` sends a failed run's URL and page, scrubbed of your answers, when you ask: [what is sent and how to turn it off](docs/telemetry.md).
|
|
128
128
|
|
|
129
129
|
## More
|
|
130
130
|
|
|
@@ -8,7 +8,7 @@ from atsfill import core
|
|
|
8
8
|
from atsfill.adapters import adapters
|
|
9
9
|
from atsfill.core import browser, paths
|
|
10
10
|
from atsfill.core.adapter import NeedsAccount, SignUpDetails
|
|
11
|
-
from atsfill.core.results import Result, SessionReport, Status, StatusReport
|
|
11
|
+
from atsfill.core.results import ReportResult, Result, SessionReport, Status, StatusReport
|
|
12
12
|
|
|
13
13
|
OK = {Status.READY, Status.SUBMITTED}
|
|
14
14
|
|
|
@@ -83,6 +83,9 @@ def parser() -> argparse.ArgumentParser:
|
|
|
83
83
|
"--resend", action="store_true", help="send even though an earlier send may have gone"
|
|
84
84
|
)
|
|
85
85
|
sub.add_parser("status", help="version, paths, browser, and each adapter's state")
|
|
86
|
+
sub.add_parser(
|
|
87
|
+
"report", help="send the last failed run's URL and page, scrubbed, to the maintainer"
|
|
88
|
+
)
|
|
86
89
|
sub.add_parser("install-browser", help="download the Camoufox build atsfill is pinned to")
|
|
87
90
|
accounts = [a.name for a in adapters() if isinstance(a, NeedsAccount)]
|
|
88
91
|
for name, text in (
|
|
@@ -169,6 +172,10 @@ def run(args: argparse.Namespace) -> int:
|
|
|
169
172
|
return 1 if signed_out or report.browser != report.browser_pinned else 0
|
|
170
173
|
case "install-browser":
|
|
171
174
|
return browser.install()
|
|
175
|
+
case "report":
|
|
176
|
+
sent = core.report(data_dir=args.data_dir)
|
|
177
|
+
emit(sent, False)
|
|
178
|
+
return 0 if sent.status == "sent" else 1
|
|
172
179
|
case "login" | "logout":
|
|
173
180
|
where = {"user": args.user, "url": args.url, "data_dir": args.data_dir}
|
|
174
181
|
try:
|
|
@@ -197,7 +204,9 @@ def run(args: argparse.Namespace) -> int:
|
|
|
197
204
|
|
|
198
205
|
|
|
199
206
|
def emit(
|
|
200
|
-
value: Result | StatusReport | SessionReport
|
|
207
|
+
value: Result | StatusReport | SessionReport | ReportResult,
|
|
208
|
+
verbose: bool,
|
|
209
|
+
command: str | None = None,
|
|
201
210
|
) -> None:
|
|
202
211
|
"""Prints the whole value with `verbose`; else what its consumer acts on, on one line."""
|
|
203
212
|
data = dataclasses.asdict(value)
|
|
@@ -8,9 +8,11 @@ from pathlib import Path
|
|
|
8
8
|
from atsfill.core import browser
|
|
9
9
|
from atsfill.core.adapter import (
|
|
10
10
|
CreatesAccount,
|
|
11
|
+
CreatesAccountByOtp,
|
|
11
12
|
FinishesSignUp,
|
|
12
13
|
NeedsAccount,
|
|
13
14
|
SignsIn,
|
|
15
|
+
SignsInByOtp,
|
|
14
16
|
SignUpDetails,
|
|
15
17
|
Unsupported,
|
|
16
18
|
)
|
|
@@ -47,6 +49,12 @@ def email(user_dir: Path, adapter: str, scope: str | None) -> str | None:
|
|
|
47
49
|
return emails.get(account_key(adapter, scope))
|
|
48
50
|
|
|
49
51
|
|
|
52
|
+
def emails(user_dir: Path) -> list[str]:
|
|
53
|
+
"""Every account's email, for a snapshot to scrub."""
|
|
54
|
+
path = user_dir / EMAILS
|
|
55
|
+
return list(json.loads(path.read_text()).values()) if path.exists() else []
|
|
56
|
+
|
|
57
|
+
|
|
50
58
|
def remember(user_dir: Path, adapter: str, scope: str | None, address: str) -> None:
|
|
51
59
|
path = user_dir / EMAILS
|
|
52
60
|
emails = json.loads(path.read_text()) if path.exists() else {}
|
|
@@ -55,21 +63,30 @@ def remember(user_dir: Path, adapter: str, scope: str | None, address: str) -> N
|
|
|
55
63
|
|
|
56
64
|
def sign_in(
|
|
57
65
|
page: Page,
|
|
58
|
-
adapter: SignsIn,
|
|
66
|
+
adapter: SignsIn | SignsInByOtp,
|
|
59
67
|
scope: str | None,
|
|
60
68
|
address: str,
|
|
61
|
-
password: str,
|
|
69
|
+
password: str | None,
|
|
62
70
|
session: Path,
|
|
63
71
|
details: SignUpDetails | None = None,
|
|
64
72
|
create: bool = False,
|
|
65
73
|
) -> None:
|
|
66
74
|
"""Signs in from the system's login page and stores the session: with `create`, creates
|
|
67
75
|
the account from the user's sign-up `details`; without, `details` finish a sign-up the
|
|
68
|
-
account was left in, where the adapter knows that step.
|
|
76
|
+
account was left in, where the adapter knows that step. An account without a password
|
|
77
|
+
(`password` None) signs in by OTP, and creates the same way.
|
|
69
78
|
Raises what the adapter raises, without the password in it."""
|
|
70
79
|
page.goto(adapter.login_url(scope))
|
|
71
80
|
try:
|
|
72
|
-
if
|
|
81
|
+
if isinstance(adapter, SignsInByOtp):
|
|
82
|
+
if create:
|
|
83
|
+
assert isinstance(adapter, CreatesAccountByOtp) and details is not None
|
|
84
|
+
adapter.sign_up_by_otp(page, address, details)
|
|
85
|
+
else:
|
|
86
|
+
adapter.sign_in_by_otp(page, address)
|
|
87
|
+
elif password is None:
|
|
88
|
+
raise AssertionError(f"{adapter.name} signs in with a password")
|
|
89
|
+
elif create:
|
|
73
90
|
assert isinstance(adapter, CreatesAccount) and details is not None
|
|
74
91
|
adapter.sign_up(page, address, password, details)
|
|
75
92
|
elif details is not None and isinstance(adapter, FinishesSignUp):
|
|
@@ -77,7 +94,7 @@ def sign_in(
|
|
|
77
94
|
else:
|
|
78
95
|
adapter.sign_in(page, address, password)
|
|
79
96
|
except Exception as e:
|
|
80
|
-
if password in str(e):
|
|
97
|
+
if password and password in str(e):
|
|
81
98
|
raise redacted(e, password) from None
|
|
82
99
|
raise
|
|
83
100
|
if not adapter.signed_in(page.request, scope):
|
|
@@ -42,7 +42,7 @@ def out_of_scope(field_id: str, what: str) -> str:
|
|
|
42
42
|
|
|
43
43
|
class Refused(Exception):
|
|
44
44
|
"""The system turned down a sign-in or a sign-up for a reason it states:
|
|
45
|
-
`email_in_use`, `email_domain_rejected`, `password_rule`, `wrong_password`.
|
|
45
|
+
`email_in_use`, `email_domain_rejected`, `password_rule`, `wrong_password`, `no_account`.
|
|
46
46
|
|
|
47
47
|
The message is the result's reason, `code` or `code: detail`.
|
|
48
48
|
"""
|
|
@@ -125,6 +125,18 @@ class SignsIn(NeedsAccount, Protocol):
|
|
|
125
125
|
...
|
|
126
126
|
|
|
127
127
|
|
|
128
|
+
@runtime_checkable
|
|
129
|
+
class SignsInByOtp(NeedsAccount, Protocol):
|
|
130
|
+
"""An adapter atsfill can sign in through with an email and the OTP its system emails each
|
|
131
|
+
time, an account without a password (ADR 0015)."""
|
|
132
|
+
|
|
133
|
+
def sign_in_by_otp(self, page: Page, email: str) -> None:
|
|
134
|
+
"""Signs in on the page `login_url` loaded, typing the OTP with `otp.enter`. Raises as
|
|
135
|
+
`sign_in` does, and `Refused("no_account")` before the click that would turn an unknown
|
|
136
|
+
email's sign-in into a sign-up."""
|
|
137
|
+
...
|
|
138
|
+
|
|
139
|
+
|
|
128
140
|
ADDRESS = ("street", "city", "state", "zip", "country")
|
|
129
141
|
PHONE = re.compile(r"\+[1-9][0-9]{6,14}") # E.164
|
|
130
142
|
COUNTRY = re.compile(r"[A-Z]{2}") # ISO 3166 alpha-2
|
|
@@ -183,6 +195,20 @@ class CreatesAccount(SignsIn, Protocol):
|
|
|
183
195
|
...
|
|
184
196
|
|
|
185
197
|
|
|
198
|
+
@runtime_checkable
|
|
199
|
+
class CreatesAccountByOtp(SignsInByOtp, Protocol):
|
|
200
|
+
"""An adapter atsfill can create an account without a password through. Account creation is
|
|
201
|
+
a side effect it declares (ADR 0008)."""
|
|
202
|
+
|
|
203
|
+
asks: set[str] # the `SignUpDetails` fields its sign-up requires
|
|
204
|
+
|
|
205
|
+
def sign_up_by_otp(self, page: Page, email: str, details: SignUpDetails) -> None:
|
|
206
|
+
"""Creates the account from the page `login_url` loaded, or signs in where the email
|
|
207
|
+
has one, signed in once it returns. Raises as `sign_in_by_otp` does, `no_account` aside;
|
|
208
|
+
the details are given as `sign_up` gives them."""
|
|
209
|
+
...
|
|
210
|
+
|
|
211
|
+
|
|
186
212
|
@runtime_checkable
|
|
187
213
|
class FinishesSignUp(CreatesAccount, Protocol):
|
|
188
214
|
"""An adapter whose system can keep an account half made: signed up, but a step the
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import shlex
|
|
2
2
|
from collections.abc import Callable, Generator
|
|
3
|
-
from contextlib import contextmanager
|
|
3
|
+
from contextlib import contextmanager, suppress
|
|
4
|
+
from dataclasses import dataclass
|
|
4
5
|
from importlib.metadata import version
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
|
|
@@ -15,18 +16,21 @@ from atsfill.core import (
|
|
|
15
16
|
passwords,
|
|
16
17
|
paths,
|
|
17
18
|
registry,
|
|
19
|
+
snapshot,
|
|
18
20
|
telemetry,
|
|
19
21
|
)
|
|
20
22
|
from atsfill.core.adapter import (
|
|
21
23
|
Adapter,
|
|
22
24
|
Blocked,
|
|
23
25
|
CreatesAccount,
|
|
26
|
+
CreatesAccountByOtp,
|
|
24
27
|
ListsShownFields,
|
|
25
28
|
NeedsAccount,
|
|
26
29
|
NotTaken,
|
|
27
30
|
ReadsByRequest,
|
|
28
31
|
Refused,
|
|
29
32
|
SignsIn,
|
|
33
|
+
SignsInByOtp,
|
|
30
34
|
SignUpDetails,
|
|
31
35
|
Unsupported,
|
|
32
36
|
)
|
|
@@ -35,6 +39,7 @@ from atsfill.core.results import (
|
|
|
35
39
|
AdapterStatus,
|
|
36
40
|
Answer,
|
|
37
41
|
Field,
|
|
42
|
+
ReportResult,
|
|
38
43
|
Result,
|
|
39
44
|
SessionReport,
|
|
40
45
|
Status,
|
|
@@ -44,18 +49,26 @@ from atsfill.core.results import (
|
|
|
44
49
|
type Sessions = dict[str, Path]
|
|
45
50
|
|
|
46
51
|
|
|
52
|
+
@dataclass
|
|
53
|
+
class Seen:
|
|
54
|
+
"""What a command saw before it ended, for a result it did not get to make."""
|
|
55
|
+
|
|
56
|
+
adapter: str | None = None # the adapter detected
|
|
57
|
+
page: tuple[str, str] | None = None # a failed run's page, for its report (ADR 0016)
|
|
58
|
+
|
|
59
|
+
|
|
47
60
|
def _detect(candidates: list[Adapter], url: str, page: Page | None = None) -> Adapter | None:
|
|
48
61
|
return next((a for a in candidates if a.detect(url, page)), None)
|
|
49
62
|
|
|
50
63
|
|
|
51
|
-
def login_command(adapter: str, user: str, url: str | None) -> str:
|
|
64
|
+
def login_command(adapter: str, user: str, url: str | None, *more: str) -> str:
|
|
52
65
|
"""The `login` command line for one account: its user, and a posting where it is scoped."""
|
|
53
66
|
command = ["atsfill", "login", adapter]
|
|
54
67
|
if user != paths.DEFAULT_USER:
|
|
55
68
|
command += ["--user", user]
|
|
56
69
|
if url:
|
|
57
70
|
command += ["--url", url]
|
|
58
|
-
return shlex.join(command)
|
|
71
|
+
return shlex.join([*command, *more])
|
|
59
72
|
|
|
60
73
|
|
|
61
74
|
def _login_required(url: str, adapter: NeedsAccount, user: str) -> Result:
|
|
@@ -63,45 +76,57 @@ def _login_required(url: str, adapter: NeedsAccount, user: str) -> Result:
|
|
|
63
76
|
return Result(Status.BLOCKED, url, adapter.name, reason=f"login_required: run `{command}`")
|
|
64
77
|
|
|
65
78
|
|
|
66
|
-
type Credentials = tuple[str, str] # email, password
|
|
79
|
+
type Credentials = tuple[str, str | None] # email, password: None for an account without one
|
|
67
80
|
# Refusals the page states before any account exists: the generated password goes.
|
|
68
81
|
NO_ACCOUNT = {"email_in_use", "email_domain_rejected", "password_rule", "sign_up_details"}
|
|
69
82
|
|
|
70
83
|
|
|
71
|
-
def
|
|
72
|
-
|
|
73
|
-
|
|
84
|
+
def _next_step(
|
|
85
|
+
reason: str | None, email: str, adapter: str, user: str, url: str | None
|
|
86
|
+
) -> str | None:
|
|
87
|
+
"""Names the command to run next: once the verification link sent to the address is
|
|
88
|
+
clicked, or `--create` for an email the system has no account for."""
|
|
89
|
+
code = (reason or "").split(":")[0]
|
|
90
|
+
if code == "email_link":
|
|
91
|
+
command = login_command(adapter, user, url)
|
|
74
92
|
return f"email_link: click the link sent to {email}, then run `{command}`"
|
|
93
|
+
if code == "no_account":
|
|
94
|
+
command = login_command(adapter, user, url, "--email", email, "--create")
|
|
95
|
+
return f"no_account: {email} has no {adapter} account; run `{command}`"
|
|
75
96
|
return reason
|
|
76
97
|
|
|
77
98
|
|
|
78
99
|
def _credentials(
|
|
79
100
|
adapter: NeedsAccount, user: str, mine: Path, scope: str | None, given: str | None = None
|
|
80
101
|
) -> Credentials | None:
|
|
81
|
-
"""The account's email and a password from its password source,
|
|
102
|
+
"""The account's email and a password from its password source, or no password for an
|
|
103
|
+
account without one when the user has an OTP command for it, where atsfill can sign in
|
|
82
104
|
through the adapter; None otherwise."""
|
|
83
|
-
if not isinstance(adapter, SignsIn):
|
|
105
|
+
if not isinstance(adapter, (SignsIn, SignsInByOtp)):
|
|
84
106
|
return None
|
|
85
107
|
if not (email := accounts.email(mine, adapter.name, scope)):
|
|
86
108
|
return None
|
|
109
|
+
if isinstance(adapter, SignsInByOtp):
|
|
110
|
+
return (email, None) if otp.command(user, adapter.name) else None
|
|
87
111
|
password = passwords.password(user, mine, adapter.name, scope, email, given)
|
|
88
112
|
return (email, password) if password else None
|
|
89
113
|
|
|
90
114
|
|
|
91
115
|
@contextmanager
|
|
92
116
|
def _opened(
|
|
93
|
-
data: Path, user: str, url: str, sessions: Sessions | None,
|
|
117
|
+
data: Path, user: str, url: str, sessions: Sessions | None, seen: Seen
|
|
94
118
|
) -> Generator[tuple[Page, Adapter] | Result]:
|
|
95
119
|
"""The loaded page and the adapter that claims it by its URL, else by the URL a redirect
|
|
96
120
|
lands on, else by its page; or the result that ends the command first. An adapter that
|
|
97
121
|
needs an account gets the user's session, and only that adapter's; signed out with a
|
|
98
|
-
password source,
|
|
122
|
+
password source, or an OTP command for an account without a password, atsfill signs in
|
|
123
|
+
again (ADR 0015). The adapter's name goes in `seen`
|
|
99
124
|
once known. The adapters are fresh: none keeps state from another
|
|
100
125
|
posting (a survey or the adapter gate runs many commands in one process)."""
|
|
101
126
|
candidates = adapters()
|
|
102
127
|
adapter = _detect(candidates, url)
|
|
103
128
|
if adapter:
|
|
104
|
-
|
|
129
|
+
seen.adapter = adapter.name
|
|
105
130
|
mine = paths.user_dir(data, user)
|
|
106
131
|
session, credentials = None, None
|
|
107
132
|
if isinstance(adapter, NeedsAccount):
|
|
@@ -129,7 +154,7 @@ def _opened(
|
|
|
129
154
|
if adapter is None and (
|
|
130
155
|
adapter := _detect(candidates, page.url) or _detect(candidates, url, page)
|
|
131
156
|
):
|
|
132
|
-
|
|
157
|
+
seen.adapter = adapter.name
|
|
133
158
|
if adapter is None:
|
|
134
159
|
yield _no_adapter(url)
|
|
135
160
|
return
|
|
@@ -151,8 +176,8 @@ def _sign_in_again(
|
|
|
151
176
|
url: str,
|
|
152
177
|
credentials: Credentials | None,
|
|
153
178
|
) -> Result | None:
|
|
154
|
-
"""Signs in
|
|
155
|
-
|
|
179
|
+
"""Signs in as `_credentials` allows, stores the session and loads the posting again; the
|
|
180
|
+
result that ends the command instead, `login_required` where it does not."""
|
|
156
181
|
result = Result(Status.BLOCKED, url, adapter.name)
|
|
157
182
|
with _stops(result):
|
|
158
183
|
scope = adapter.account_scope(url)
|
|
@@ -161,14 +186,15 @@ def _sign_in_again(
|
|
|
161
186
|
except passwords.PasswordError as e:
|
|
162
187
|
result.reason = f"password_source: {e}"
|
|
163
188
|
return result
|
|
164
|
-
if credentials is None or not isinstance(adapter, SignsIn):
|
|
189
|
+
if credentials is None or not isinstance(adapter, (SignsIn, SignsInByOtp)):
|
|
165
190
|
return _login_required(url, adapter, user)
|
|
166
191
|
session = accounts.session_file(mine, adapter.name, scope)
|
|
167
192
|
accounts.sign_in(page, adapter, scope, *credentials, session)
|
|
168
193
|
page.goto(url)
|
|
169
194
|
return None
|
|
170
|
-
|
|
171
|
-
|
|
195
|
+
email = credentials[0] if credentials else ""
|
|
196
|
+
scoped = url if adapter.per_employer else None
|
|
197
|
+
result.reason = _next_step(result.reason, email, adapter.name, user, scoped)
|
|
172
198
|
return result
|
|
173
199
|
|
|
174
200
|
|
|
@@ -247,18 +273,41 @@ def _stops(result: Result) -> Generator[None]:
|
|
|
247
273
|
result.status, result.reason = Status.BLOCKED, f"overlaid: {e}"
|
|
248
274
|
|
|
249
275
|
|
|
250
|
-
def _in_budget(url: str, command: Callable[[
|
|
276
|
+
def _in_budget(url: str, seen: Seen, command: Callable[[], Result]) -> Result:
|
|
251
277
|
"""Runs a command to a status whatever happens: past the click it reports `unknown` itself,
|
|
252
278
|
before it an error the adapter did not expect means the page is not one it handles. The
|
|
253
279
|
result keeps the adapter detected before the stop."""
|
|
254
|
-
detected: list[str] = []
|
|
255
280
|
try:
|
|
256
|
-
return command(
|
|
281
|
+
return command()
|
|
257
282
|
except OutOfTime as e:
|
|
258
|
-
return Result(Status.BLOCKED, url,
|
|
283
|
+
return Result(Status.BLOCKED, url, seen.adapter, f"time_budget: {e}")
|
|
259
284
|
except Exception as e:
|
|
260
285
|
reason = f"adapter_error: {type(e).__name__}: {e}"
|
|
261
|
-
return Result(Status.UNSUPPORTED, url,
|
|
286
|
+
return Result(Status.UNSUPPORTED, url, seen.adapter, reason)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _kept(page: Page, seen: Seen, run: Callable[[], Result]) -> Result:
|
|
290
|
+
"""The run's result; a failed run's page goes in `seen`, values stripped, when telemetry
|
|
291
|
+
is on."""
|
|
292
|
+
try:
|
|
293
|
+
result = run()
|
|
294
|
+
except Exception:
|
|
295
|
+
_capture(page, seen)
|
|
296
|
+
raise
|
|
297
|
+
if telemetry.verdict(result) == "fail":
|
|
298
|
+
_capture(page, seen)
|
|
299
|
+
return result
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _capture(page: Page, seen: Seen) -> None:
|
|
303
|
+
with suppress(Exception): # a page the budget closed has nothing to keep
|
|
304
|
+
if telemetry.level() != "off":
|
|
305
|
+
seen.page = snapshot.capture(page)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _secrets(data: Path, user: str, answers: dict[str, Answer], result: Result) -> set[str]:
|
|
309
|
+
emails = accounts.emails(paths.user_dir(data, user))
|
|
310
|
+
return snapshot.secrets(answers, result.fields, emails)
|
|
262
311
|
|
|
263
312
|
|
|
264
313
|
def _fill(page: Page, adapter: Adapter, url: str, answers: dict[str, Answer], user: str) -> Result:
|
|
@@ -281,18 +330,20 @@ def _fill(page: Page, adapter: Adapter, url: str, answers: dict[str, Answer], us
|
|
|
281
330
|
return result
|
|
282
331
|
|
|
283
332
|
|
|
284
|
-
def _browser_read(
|
|
285
|
-
data
|
|
286
|
-
) -> Result:
|
|
287
|
-
with _opened(data, user, url, sessions, detected) as opened:
|
|
333
|
+
def _browser_read(data: Path, user: str, url: str, sessions: Sessions | None, seen: Seen) -> Result:
|
|
334
|
+
with _opened(data, user, url, sessions, seen) as opened:
|
|
288
335
|
if isinstance(opened, Result):
|
|
289
336
|
return opened
|
|
290
337
|
page, adapter = opened
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
338
|
+
|
|
339
|
+
def run() -> Result:
|
|
340
|
+
result = _result(adapter, url, user)
|
|
341
|
+
with _stops(result):
|
|
342
|
+
_listed(result, adapter.browser_read(page))
|
|
343
|
+
_unfillable(result)
|
|
344
|
+
return result
|
|
345
|
+
|
|
346
|
+
return _kept(page, seen, run)
|
|
296
347
|
|
|
297
348
|
|
|
298
349
|
def _form_command(
|
|
@@ -303,46 +354,59 @@ def _form_command(
|
|
|
303
354
|
sessions: Sessions | None,
|
|
304
355
|
send: bool,
|
|
305
356
|
resend: bool,
|
|
306
|
-
|
|
357
|
+
seen: Seen,
|
|
307
358
|
) -> Result:
|
|
308
359
|
mine = paths.user_dir(data, user) # before the page takes the lock its first use needs
|
|
309
|
-
with _opened(data, user, url, sessions,
|
|
360
|
+
with _opened(data, user, url, sessions, seen) as opened:
|
|
310
361
|
if isinstance(opened, Result):
|
|
311
362
|
return opened
|
|
312
363
|
page, adapter = opened
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
where = "" if user == paths.DEFAULT_USER else f" under [users.{user}]"
|
|
316
|
-
reason = (
|
|
317
|
-
f"submit_not_enabled: add {adapter.name} to `submit-allowed`{where}"
|
|
318
|
-
f" in {paths.config_file()}"
|
|
319
|
-
)
|
|
320
|
-
return Result(Status.REFUSED, url, adapter.name, reason=reason)
|
|
321
|
-
if send and not resend and attempts.blocks_send(mine, posting):
|
|
322
|
-
last = attempts.last(mine, posting)
|
|
323
|
-
reason = f"already_sent: last attempt ended {last}; pass --resend"
|
|
324
|
-
return Result(Status.REFUSED, url, adapter.name, reason=reason)
|
|
325
|
-
result = _fill(page, adapter, url, answers, user)
|
|
326
|
-
if result.status is not Status.READY:
|
|
327
|
-
return result
|
|
328
|
-
if not send:
|
|
329
|
-
return result
|
|
330
|
-
attempts.record(mine, posting, attempts.PENDING, url=url)
|
|
331
|
-
try:
|
|
332
|
-
result.evidence = adapter.submit(page)
|
|
333
|
-
if result.evidence:
|
|
334
|
-
result.status = Status.SUBMITTED
|
|
335
|
-
else:
|
|
336
|
-
result.status, result.reason = Status.UNKNOWN, "no_evidence"
|
|
337
|
-
except Blocked as e:
|
|
338
|
-
result.status, result.reason = Status.BLOCKED, str(e)
|
|
339
|
-
except Exception as e: # after the click, nothing tells a failed send from a sent one
|
|
340
|
-
result.status = Status.UNKNOWN
|
|
341
|
-
result.reason = f"error_after_click: {type(e).__name__}: {e}"
|
|
342
|
-
attempts.record(
|
|
343
|
-
mine, posting, result.status, evidence=result.evidence, reason=result.reason
|
|
364
|
+
return _kept(
|
|
365
|
+
page, seen, lambda: _on_page(page, adapter, mine, user, url, answers, send, resend)
|
|
344
366
|
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _on_page(
|
|
370
|
+
page: Page,
|
|
371
|
+
adapter: Adapter,
|
|
372
|
+
mine: Path,
|
|
373
|
+
user: str,
|
|
374
|
+
url: str,
|
|
375
|
+
answers: dict[str, Answer],
|
|
376
|
+
send: bool,
|
|
377
|
+
resend: bool,
|
|
378
|
+
) -> Result:
|
|
379
|
+
posting = adapter.posting(page.url) # past any redirect a link goes through
|
|
380
|
+
if send and not paths.submit_enabled(adapter.name, user):
|
|
381
|
+
where = "" if user == paths.DEFAULT_USER else f" under [users.{user}]"
|
|
382
|
+
reason = (
|
|
383
|
+
f"submit_not_enabled: add {adapter.name} to `submit-allowed`{where}"
|
|
384
|
+
f" in {paths.config_file()}"
|
|
385
|
+
)
|
|
386
|
+
return Result(Status.REFUSED, url, adapter.name, reason=reason)
|
|
387
|
+
if send and not resend and attempts.blocks_send(mine, posting):
|
|
388
|
+
last = attempts.last(mine, posting)
|
|
389
|
+
reason = f"already_sent: last attempt ended {last}; pass --resend"
|
|
390
|
+
return Result(Status.REFUSED, url, adapter.name, reason=reason)
|
|
391
|
+
result = _fill(page, adapter, url, answers, user)
|
|
392
|
+
if result.status is not Status.READY:
|
|
393
|
+
return result
|
|
394
|
+
if not send:
|
|
345
395
|
return result
|
|
396
|
+
attempts.record(mine, posting, attempts.PENDING, url=url)
|
|
397
|
+
try:
|
|
398
|
+
result.evidence = adapter.submit(page)
|
|
399
|
+
if result.evidence:
|
|
400
|
+
result.status = Status.SUBMITTED
|
|
401
|
+
else:
|
|
402
|
+
result.status, result.reason = Status.UNKNOWN, "no_evidence"
|
|
403
|
+
except Blocked as e:
|
|
404
|
+
result.status, result.reason = Status.BLOCKED, str(e)
|
|
405
|
+
except Exception as e: # after the click, nothing tells a failed send from a sent one
|
|
406
|
+
result.status = Status.UNKNOWN
|
|
407
|
+
result.reason = f"error_after_click: {type(e).__name__}: {e}"
|
|
408
|
+
attempts.record(mine, posting, result.status, evidence=result.evidence, reason=result.reason)
|
|
409
|
+
return result
|
|
346
410
|
|
|
347
411
|
|
|
348
412
|
def read(
|
|
@@ -354,11 +418,12 @@ def read(
|
|
|
354
418
|
) -> Result:
|
|
355
419
|
"""Lists the form's fields by a request read when an adapter has one (ADR 0011), else by a
|
|
356
420
|
browser read."""
|
|
357
|
-
data = paths.data_dir(data_dir)
|
|
358
|
-
|
|
421
|
+
data, seen = paths.data_dir(data_dir), Seen()
|
|
422
|
+
result = _read(data, user, url, sessions, seen)
|
|
423
|
+
return telemetry.sent("read", result, data, seen.page, _secrets(data, user, {}, result))
|
|
359
424
|
|
|
360
425
|
|
|
361
|
-
def _read(data: Path, user: str, url: str, sessions: Sessions | None) -> Result:
|
|
426
|
+
def _read(data: Path, user: str, url: str, sessions: Sessions | None, seen: Seen) -> Result:
|
|
362
427
|
for adapter in adapters():
|
|
363
428
|
if not isinstance(adapter, ReadsByRequest):
|
|
364
429
|
continue
|
|
@@ -370,7 +435,7 @@ def _read(data: Path, user: str, url: str, sessions: Sessions | None) -> Result:
|
|
|
370
435
|
return Result(Status.UNSUPPORTED, url, adapter.name, f"adapter_error: {e}")
|
|
371
436
|
_unfillable(result)
|
|
372
437
|
return result
|
|
373
|
-
return _in_budget(url, lambda
|
|
438
|
+
return _in_budget(url, seen, lambda: _browser_read(data, user, url, sessions, seen))
|
|
374
439
|
|
|
375
440
|
|
|
376
441
|
def fill(
|
|
@@ -381,11 +446,11 @@ def fill(
|
|
|
381
446
|
data_dir: Path | None = None,
|
|
382
447
|
sessions: Sessions | None = None,
|
|
383
448
|
) -> Result:
|
|
384
|
-
data = paths.data_dir(data_dir)
|
|
449
|
+
data, seen = paths.data_dir(data_dir), Seen()
|
|
385
450
|
result = _in_budget(
|
|
386
|
-
url, lambda
|
|
451
|
+
url, seen, lambda: _form_command(data, user, url, answers, sessions, False, False, seen)
|
|
387
452
|
)
|
|
388
|
-
return telemetry.sent("fill", result, data)
|
|
453
|
+
return telemetry.sent("fill", result, data, seen.page, _secrets(data, user, answers, result))
|
|
389
454
|
|
|
390
455
|
|
|
391
456
|
def send(
|
|
@@ -397,11 +462,16 @@ def send(
|
|
|
397
462
|
data_dir: Path | None = None,
|
|
398
463
|
sessions: Sessions | None = None,
|
|
399
464
|
) -> Result:
|
|
400
|
-
data = paths.data_dir(data_dir)
|
|
465
|
+
data, seen = paths.data_dir(data_dir), Seen()
|
|
401
466
|
result = _in_budget(
|
|
402
|
-
url, lambda
|
|
467
|
+
url, seen, lambda: _form_command(data, user, url, answers, sessions, True, resend, seen)
|
|
403
468
|
)
|
|
404
|
-
return telemetry.sent("send", result, data)
|
|
469
|
+
return telemetry.sent("send", result, data, seen.page, _secrets(data, user, answers, result))
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def report(*, data_dir: Path | None = None) -> ReportResult:
|
|
473
|
+
"""Sends the last failed run's URL and scrubbed page, once (ADR 0016)."""
|
|
474
|
+
return telemetry.report(paths.data_dir(data_dir))
|
|
405
475
|
|
|
406
476
|
|
|
407
477
|
def _sessions(data: Path, users: list[str], candidates: list[Adapter]) -> list[SessionReport]:
|
|
@@ -478,7 +548,9 @@ def login(
|
|
|
478
548
|
creates the account with a password atsfill generates and stores first (a side effect),
|
|
479
549
|
giving the sign-up the user's `details`; with a password already stored, the account may
|
|
480
550
|
exist, so it signs in with that password and keeps the account's email instead, the
|
|
481
|
-
`details` finishing a sign-up the account was left in.
|
|
551
|
+
`details` finishing a sign-up the account was left in. An account without a password signs
|
|
552
|
+
in through the user's OTP command in place of a password source, and is created the same
|
|
553
|
+
way."""
|
|
482
554
|
account, data = _account(adapter), paths.data_dir(data_dir)
|
|
483
555
|
scope = _scope(account, url)
|
|
484
556
|
mine = paths.user_dir(data, user)
|
|
@@ -488,8 +560,11 @@ def login(
|
|
|
488
560
|
raise ValueError("creating an account needs --email")
|
|
489
561
|
if details and not create:
|
|
490
562
|
raise ValueError("--details goes with --create")
|
|
563
|
+
by_otp = isinstance(account, SignsInByOtp)
|
|
564
|
+
if password and by_otp:
|
|
565
|
+
raise ValueError(f"{adapter} signs in with an emailed OTP, not a password")
|
|
491
566
|
try:
|
|
492
|
-
kept = create and passwords.stored(mine, adapter, scope) is not None
|
|
567
|
+
kept = create and not by_otp and passwords.stored(mine, adapter, scope) is not None
|
|
493
568
|
except passwords.PasswordError as e:
|
|
494
569
|
report.status, report.reason = Status.BLOCKED, f"password_source: {e}"
|
|
495
570
|
return report
|
|
@@ -523,11 +598,8 @@ def login(
|
|
|
523
598
|
)
|
|
524
599
|
else:
|
|
525
600
|
result = _headless(data, user, account, scope, credentials, session, details)
|
|
526
|
-
|
|
527
|
-
report.
|
|
528
|
-
result.status,
|
|
529
|
-
_email_link(result.reason, credentials[0], command),
|
|
530
|
-
)
|
|
601
|
+
report.status = result.status
|
|
602
|
+
report.reason = _next_step(result.reason, credentials[0], adapter, user, url)
|
|
531
603
|
if report.reason is None:
|
|
532
604
|
report.session = report.status = "signed_in"
|
|
533
605
|
return report
|
|
@@ -544,8 +616,9 @@ def _create(
|
|
|
544
616
|
) -> SessionReport:
|
|
545
617
|
"""Creates the account: refused before the browser opens when `details` lack one the
|
|
546
618
|
sign-up asks; the generated password is stored before the click, and deleted only on a
|
|
547
|
-
refusal the page states before any account exists.
|
|
548
|
-
|
|
619
|
+
refusal the page states before any account exists. An account without a password gets
|
|
620
|
+
none."""
|
|
621
|
+
if not isinstance(account, (CreatesAccount, CreatesAccountByOtp)):
|
|
549
622
|
report.status = Status.UNSUPPORTED
|
|
550
623
|
report.reason = f"no_sign_up: atsfill cannot create a {account.name} account"
|
|
551
624
|
return report
|
|
@@ -553,9 +626,10 @@ def _create(
|
|
|
553
626
|
report.status = Status.REFUSED
|
|
554
627
|
report.reason = f"sign_up_details: no {', '.join(missing)}"
|
|
555
628
|
return report
|
|
556
|
-
password = passwords.generate()
|
|
629
|
+
password = None if isinstance(account, CreatesAccountByOtp) else passwords.generate()
|
|
557
630
|
try:
|
|
558
|
-
|
|
631
|
+
if password:
|
|
632
|
+
passwords.store(mine, account.name, report.scope, password)
|
|
559
633
|
except passwords.PasswordError as e:
|
|
560
634
|
report.status, report.reason = Status.BLOCKED, f"password_source: {e}"
|
|
561
635
|
return report
|
|
@@ -563,10 +637,14 @@ def _create(
|
|
|
563
637
|
result = _headless(
|
|
564
638
|
data, report.user, account, report.scope, credentials, report.path, details, create=True
|
|
565
639
|
)
|
|
566
|
-
|
|
567
|
-
report.
|
|
568
|
-
report.password = "stored"
|
|
569
|
-
if
|
|
640
|
+
report.status = result.status
|
|
641
|
+
report.reason = _next_step(result.reason, email, account.name, report.user, url)
|
|
642
|
+
report.password = "stored" if password else "none"
|
|
643
|
+
if (
|
|
644
|
+
password
|
|
645
|
+
and result.status is Status.REFUSED
|
|
646
|
+
and (result.reason or "").split(":")[0] in NO_ACCOUNT
|
|
647
|
+
):
|
|
570
648
|
passwords.delete(mine, account.name, report.scope)
|
|
571
649
|
report.password = "deleted"
|
|
572
650
|
if report.reason is None:
|
|
@@ -586,16 +664,16 @@ def _headless(
|
|
|
586
664
|
) -> Result:
|
|
587
665
|
"""Signs in, or creates the account with `create`, through the adapter, an OTP it meets
|
|
588
666
|
from the user's `otp-commands` entry: `ready` once the session is stored."""
|
|
589
|
-
assert isinstance(account, SignsIn)
|
|
667
|
+
assert isinstance(account, (SignsIn, SignsInByOtp))
|
|
590
668
|
url = account.login_url(scope)
|
|
591
669
|
|
|
592
|
-
def run(
|
|
670
|
+
def run() -> Result:
|
|
593
671
|
result = Result(Status.READY, url, account.name)
|
|
594
672
|
with open_page(data, None) as page, _stops(result), otp.source(user, account.name, scope):
|
|
595
673
|
accounts.sign_in(page, account, scope, *credentials, session, details, create)
|
|
596
674
|
return result
|
|
597
675
|
|
|
598
|
-
return _in_budget(url, run)
|
|
676
|
+
return _in_budget(url, Seen(), run)
|
|
599
677
|
|
|
600
678
|
|
|
601
679
|
def logout(
|
|
@@ -102,6 +102,14 @@ class Result:
|
|
|
102
102
|
side_effects: list[SideEffect] = field(default_factory=list)
|
|
103
103
|
evidence: str | None = None
|
|
104
104
|
notice: str | None = None # telemetry's, the first time it is on (ADR 0016)
|
|
105
|
+
report: str | None = None # how to send a failed run's report, when it was not sent
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class ReportResult:
|
|
110
|
+
status: str # sent | refused | blocked
|
|
111
|
+
reason: str | None = None
|
|
112
|
+
url: str | None = None # the failed run's posting
|
|
105
113
|
|
|
106
114
|
|
|
107
115
|
@dataclass
|
|
@@ -113,7 +121,7 @@ class SessionReport:
|
|
|
113
121
|
path: Path
|
|
114
122
|
status: str | None = None # a login's outcome: signed_in | refused | blocked | unsupported
|
|
115
123
|
reason: str | None = None # why a login stored nothing, `code` or `code: detail`
|
|
116
|
-
password: str | None = None # after `--create`: stored | deleted, or
|
|
124
|
+
password: str | None = None # after `--create`: stored | deleted | kept, or none (OTP only)
|
|
117
125
|
|
|
118
126
|
|
|
119
127
|
@dataclass
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""A failed run's page, for `detailed` telemetry and `atsfill report` (ADR 0016): the DOM
|
|
2
|
+
without scripts and styles, every value a field holds stripped in the page, then every answer,
|
|
3
|
+
uploaded file name and account email replaced, here in the core, before it is kept."""
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from atsfill.core.browser import Page
|
|
11
|
+
from atsfill.core.results import Answer, Field
|
|
12
|
+
|
|
13
|
+
MAX_BYTES = 800 * 1024 # the SDK drops an event above 900 KiB; room for its other properties
|
|
14
|
+
MIN_SECRET = 3 # shorter answers ("No", "5") would wipe the page's own words, and tell little
|
|
15
|
+
NAME_WORDS = 4 # an answer of up to this many words is scrubbed word by word too
|
|
16
|
+
SCRUBBED = "[scrubbed]"
|
|
17
|
+
TRUNCATED = "<!-- truncated -->"
|
|
18
|
+
EMAIL = re.compile(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+")
|
|
19
|
+
|
|
20
|
+
# The page and the failing step's subtree, the smallest element holding every form control.
|
|
21
|
+
CLEAN = """() => {
|
|
22
|
+
const root = document.documentElement.cloneNode(true);
|
|
23
|
+
root.querySelectorAll("script, style, noscript, template, svg, link, meta").forEach(
|
|
24
|
+
e => e.remove());
|
|
25
|
+
for (const e of root.querySelectorAll("*")) {
|
|
26
|
+
for (const a of ["value", "checked", "selected", "aria-valuenow", "aria-valuetext"])
|
|
27
|
+
e.removeAttribute(a);
|
|
28
|
+
}
|
|
29
|
+
root.querySelectorAll("textarea, [contenteditable]").forEach(e => { e.textContent = ""; });
|
|
30
|
+
const controls = [...root.querySelectorAll(
|
|
31
|
+
"input, select, textarea, button, [role=combobox], [role=textbox], [contenteditable]")];
|
|
32
|
+
let step = controls[0] ?? root.querySelector("body") ?? root;
|
|
33
|
+
while (!controls.every(c => step.contains(c))) step = step.parentElement;
|
|
34
|
+
return [root.outerHTML, step.outerHTML];
|
|
35
|
+
}"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def capture(page: Page) -> tuple[str, str]:
|
|
39
|
+
"""The page and its failing step, values stripped, not yet scrubbed of secrets."""
|
|
40
|
+
whole, step = page.evaluate(CLEAN)
|
|
41
|
+
return whole, step
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def secrets(answers: dict[str, Answer], fields: Iterable[Field], emails: Iterable[str]) -> set[str]:
|
|
45
|
+
"""What may not leave the machine: each answer, a file answer's name and stem, each
|
|
46
|
+
prefilled value and each account email; each word of a short one, a name the page may
|
|
47
|
+
show in part."""
|
|
48
|
+
out = set(emails)
|
|
49
|
+
values = [*answers.values(), *(f.prefilled for f in fields if f.prefilled)]
|
|
50
|
+
for value in values:
|
|
51
|
+
for v in [value] if isinstance(value, str) else value:
|
|
52
|
+
out |= {v, Path(v).name, Path(v).stem} if "/" in v or "\\" in v else {v}
|
|
53
|
+
if len(words := v.split()) <= NAME_WORDS:
|
|
54
|
+
out |= set(words)
|
|
55
|
+
return {s.strip() for s in out if len(s.strip()) >= MIN_SECRET}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def scrub(text: str, secrets: Iterable[str]) -> str:
|
|
59
|
+
"""The text with each secret, as typed or HTML-escaped, and every email address replaced."""
|
|
60
|
+
forms = {f for s in secrets for f in (s, html.escape(s), html.escape(s, quote=False))}
|
|
61
|
+
if forms:
|
|
62
|
+
alternatives = "|".join(re.escape(f) for f in sorted(forms, key=len, reverse=True))
|
|
63
|
+
whole_word = re.compile(rf"(?<![^\W_])(?:{alternatives})(?![^\W_])", re.IGNORECASE)
|
|
64
|
+
text = whole_word.sub(SCRUBBED, text)
|
|
65
|
+
return EMAIL.sub(SCRUBBED, text)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def kept(captured: tuple[str, str], secrets: Iterable[str]) -> str:
|
|
69
|
+
"""The scrubbed page, or its failing step when the page is over the limit, cut at it."""
|
|
70
|
+
whole, step = (scrub(c, secrets) for c in captured)
|
|
71
|
+
for snapshot in (whole, step):
|
|
72
|
+
if len(snapshot.encode()) <= MAX_BYTES:
|
|
73
|
+
return snapshot
|
|
74
|
+
cut = step.encode()[: MAX_BYTES - len(TRUNCATED)].decode(errors="ignore")
|
|
75
|
+
return cut + TRUNCATED
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Anonymous telemetry (ADR 0016): one `run` event per `read`, `fill` and `send`, built from
|
|
2
|
-
the command's result
|
|
3
|
-
|
|
2
|
+
the command's result, and for a failed run a `report` event with its URL and scrubbed page, at
|
|
3
|
+
`detailed` or by `atsfill report`. Never in the way: any failure is dropped and the result goes
|
|
4
|
+
back as it came, but for the notices."""
|
|
4
5
|
|
|
5
6
|
import hashlib
|
|
6
7
|
import json
|
|
@@ -9,6 +10,7 @@ import os
|
|
|
9
10
|
import subprocess
|
|
10
11
|
import sys
|
|
11
12
|
import uuid
|
|
13
|
+
from collections.abc import Iterable
|
|
12
14
|
from contextlib import suppress
|
|
13
15
|
from functools import cache
|
|
14
16
|
from importlib.metadata import PackageNotFoundError, distribution, version
|
|
@@ -18,8 +20,8 @@ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
|
18
20
|
from posthog import Posthog
|
|
19
21
|
|
|
20
22
|
from atsfill.adapters import adapters
|
|
21
|
-
from atsfill.core import paths, registry
|
|
22
|
-
from atsfill.core.results import Result, Status
|
|
23
|
+
from atsfill.core import paths, registry, snapshot
|
|
24
|
+
from atsfill.core.results import ReportResult, Result, Status
|
|
23
25
|
|
|
24
26
|
PROJECT_KEY = "phc_xmrdiXySbbZCdUzdbdNUtjMco3rLL2Ko5j4hFrX9fUQ8" # ingestion only, safe to ship
|
|
25
27
|
HOST = "https://eu.i.posthog.com"
|
|
@@ -29,10 +31,16 @@ FLUSH_TIMEOUT_SECONDS = 2 # the SDK's shutdown() waits without one
|
|
|
29
31
|
GIT_TIMEOUT_SECONDS = 2
|
|
30
32
|
ROOT = Path(__file__).parents[3] # the checkout, for an editable install
|
|
31
33
|
NOTICE_FILE = "telemetry-notice" # in the data directory, once the notice was shown
|
|
34
|
+
FAILED_RUN = "failed-run.json" # in the data directory, the last failed run's report event
|
|
35
|
+
DOCS = "https://github.com/shbernal/atsfill/blob/main/docs/telemetry.md"
|
|
32
36
|
NOTICE = (
|
|
33
37
|
"telemetry: atsfill sends an anonymous event per read, fill and send, no answers or URLs;"
|
|
34
38
|
' set telemetry = "off" in {config} or DO_NOT_TRACK=1 to stop it:'
|
|
35
|
-
"
|
|
39
|
+
" " + DOCS
|
|
40
|
+
)
|
|
41
|
+
REPORT = (
|
|
42
|
+
"run `atsfill report` to send this run's URL and page, scrubbed of your answers,"
|
|
43
|
+
" to the maintainer: " + DOCS
|
|
36
44
|
)
|
|
37
45
|
# Reason codes that say nothing about the adapter: the user's input or a fact about the posting
|
|
38
46
|
EXCLUDED = (
|
|
@@ -132,17 +140,23 @@ def event(command: str, result: Result) -> dict:
|
|
|
132
140
|
return {k: v for k, v in properties.items() if v is not None}
|
|
133
141
|
|
|
134
142
|
|
|
135
|
-
def _capture(
|
|
143
|
+
def _capture(events: list[tuple[str, dict]]) -> None:
|
|
144
|
+
"""Sends the events; raises when the SDK reports a batch it could not send."""
|
|
145
|
+
failed: list[Exception] = []
|
|
136
146
|
client = Posthog(
|
|
137
147
|
PROJECT_KEY,
|
|
138
148
|
host=HOST,
|
|
139
149
|
timeout=REQUEST_TIMEOUT_SECONDS,
|
|
140
150
|
max_retries=MAX_RETRIES,
|
|
141
151
|
disable_geoip=True,
|
|
152
|
+
on_error=lambda e, _: failed.append(e),
|
|
142
153
|
)
|
|
143
|
-
|
|
144
|
-
|
|
154
|
+
for name, properties in events:
|
|
155
|
+
properties = {**properties, "$process_person_profile": False}
|
|
156
|
+
client.capture(name, distinct_id=str(uuid.uuid4()), properties=properties)
|
|
145
157
|
client.flush(timeout_seconds=FLUSH_TIMEOUT_SECONDS)
|
|
158
|
+
if failed:
|
|
159
|
+
raise failed[0]
|
|
146
160
|
|
|
147
161
|
|
|
148
162
|
def _notice(data: Path) -> str | None:
|
|
@@ -154,10 +168,68 @@ def _notice(data: Path) -> str | None:
|
|
|
154
168
|
return NOTICE.format(config=paths.config_file())
|
|
155
169
|
|
|
156
170
|
|
|
157
|
-
def
|
|
158
|
-
|
|
171
|
+
def _report(
|
|
172
|
+
run: dict, result: Result, captured: tuple[str, str] | None, secrets: Iterable[str]
|
|
173
|
+
) -> dict:
|
|
174
|
+
"""The `report` event: the run's, plus its URL, its reason's detail and its page, each
|
|
175
|
+
scrubbed of the secrets."""
|
|
176
|
+
detail = (result.reason or "").partition(":")[2].strip()
|
|
177
|
+
report = {
|
|
178
|
+
**run,
|
|
179
|
+
"url": result.url,
|
|
180
|
+
"detail": snapshot.scrub(detail, secrets) or None,
|
|
181
|
+
"snapshot": snapshot.kept(captured, secrets) if captured else None,
|
|
182
|
+
}
|
|
183
|
+
return {k: v for k, v in report.items() if v is not None}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def sent(
|
|
187
|
+
command: str,
|
|
188
|
+
result: Result,
|
|
189
|
+
data: Path,
|
|
190
|
+
captured: tuple[str, str] | None = None,
|
|
191
|
+
secrets: Iterable[str] = (),
|
|
192
|
+
) -> Result:
|
|
193
|
+
"""The result, its event sent at `basic` and `detailed`. A failed run is kept for `atsfill
|
|
194
|
+
report`, and its report sent along at `detailed`, else named in the result."""
|
|
159
195
|
with suppress(Exception): # never in the way
|
|
160
|
-
if level()
|
|
161
|
-
result
|
|
162
|
-
|
|
196
|
+
if (at := level()) == "off":
|
|
197
|
+
return result
|
|
198
|
+
result.notice = _notice(data)
|
|
199
|
+
run = event(command, result)
|
|
200
|
+
events = [("run", run | {"url": result.url} if at == "detailed" else run)]
|
|
201
|
+
if verdict(result) == "fail":
|
|
202
|
+
report = _report(run, result, captured, secrets)
|
|
203
|
+
(data / FAILED_RUN).write_text(json.dumps({"event": report, "reported": False}))
|
|
204
|
+
if at == "detailed":
|
|
205
|
+
events.append(("report", report))
|
|
206
|
+
else:
|
|
207
|
+
result.report = REPORT
|
|
208
|
+
_capture(events)
|
|
209
|
+
if len(events) > 1:
|
|
210
|
+
_mark_reported(data)
|
|
163
211
|
return result
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _mark_reported(data: Path) -> None:
|
|
215
|
+
path = data / FAILED_RUN
|
|
216
|
+
path.write_text(json.dumps(json.loads(path.read_text()) | {"reported": True}))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def report(data: Path) -> ReportResult:
|
|
220
|
+
"""Sends the last failed run's report, once, whatever the level but `off`."""
|
|
221
|
+
if level() == "off":
|
|
222
|
+
return ReportResult("refused", "telemetry_off: telemetry is off, nothing is sent")
|
|
223
|
+
path = data / FAILED_RUN
|
|
224
|
+
if not path.exists():
|
|
225
|
+
return ReportResult("refused", "no_failed_run: no failed run to report")
|
|
226
|
+
kept = json.loads(path.read_text())
|
|
227
|
+
report, url = kept["event"], kept["event"]["url"]
|
|
228
|
+
if kept["reported"]:
|
|
229
|
+
return ReportResult("refused", "already_reported: the last failed run was sent", url)
|
|
230
|
+
try:
|
|
231
|
+
_capture([("report", report)])
|
|
232
|
+
except Exception as e:
|
|
233
|
+
return ReportResult("blocked", f"not_sent: {type(e).__name__}: {e}", url)
|
|
234
|
+
_mark_reported(data)
|
|
235
|
+
return ReportResult("sent", url=url)
|
|
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
|