atsfill 0.0.5.dev142__tar.gz → 0.0.5.dev144__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.dev142 → atsfill-0.0.5.dev144}/PKG-INFO +2 -2
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/README.md +1 -1
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/pyproject.toml +1 -1
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/pyproject.toml.orig +1 -1
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/cli.py +11 -2
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/core/__init__.py +1 -1
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/core/accounts.py +6 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/core/commands.py +119 -64
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/core/results.py +8 -0
- atsfill-0.0.5.dev144/src/atsfill/core/snapshot.py +75 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/core/telemetry.py +85 -13
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/registry.toml +6 -6
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/LICENSE +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/gupy.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/isolvedhire.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/join.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/successfactors.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/core/otp.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.5.dev142 → atsfill-0.0.5.dev144}/src/atsfill/py.typed +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.dev144
|
|
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)
|
|
@@ -47,6 +47,12 @@ def email(user_dir: Path, adapter: str, scope: str | None) -> str | None:
|
|
|
47
47
|
return emails.get(account_key(adapter, scope))
|
|
48
48
|
|
|
49
49
|
|
|
50
|
+
def emails(user_dir: Path) -> list[str]:
|
|
51
|
+
"""Every account's email, for a snapshot to scrub."""
|
|
52
|
+
path = user_dir / EMAILS
|
|
53
|
+
return list(json.loads(path.read_text()).values()) if path.exists() else []
|
|
54
|
+
|
|
55
|
+
|
|
50
56
|
def remember(user_dir: Path, adapter: str, scope: str | None, address: str) -> None:
|
|
51
57
|
path = user_dir / EMAILS
|
|
52
58
|
emails = json.loads(path.read_text()) if path.exists() else {}
|
|
@@ -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,6 +16,7 @@ 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 (
|
|
@@ -35,6 +37,7 @@ from atsfill.core.results import (
|
|
|
35
37
|
AdapterStatus,
|
|
36
38
|
Answer,
|
|
37
39
|
Field,
|
|
40
|
+
ReportResult,
|
|
38
41
|
Result,
|
|
39
42
|
SessionReport,
|
|
40
43
|
Status,
|
|
@@ -44,6 +47,14 @@ from atsfill.core.results import (
|
|
|
44
47
|
type Sessions = dict[str, Path]
|
|
45
48
|
|
|
46
49
|
|
|
50
|
+
@dataclass
|
|
51
|
+
class Seen:
|
|
52
|
+
"""What a command saw before it ended, for a result it did not get to make."""
|
|
53
|
+
|
|
54
|
+
adapter: str | None = None # the adapter detected
|
|
55
|
+
page: tuple[str, str] | None = None # a failed run's page, for its report (ADR 0016)
|
|
56
|
+
|
|
57
|
+
|
|
47
58
|
def _detect(candidates: list[Adapter], url: str, page: Page | None = None) -> Adapter | None:
|
|
48
59
|
return next((a for a in candidates if a.detect(url, page)), None)
|
|
49
60
|
|
|
@@ -90,18 +101,18 @@ def _credentials(
|
|
|
90
101
|
|
|
91
102
|
@contextmanager
|
|
92
103
|
def _opened(
|
|
93
|
-
data: Path, user: str, url: str, sessions: Sessions | None,
|
|
104
|
+
data: Path, user: str, url: str, sessions: Sessions | None, seen: Seen
|
|
94
105
|
) -> Generator[tuple[Page, Adapter] | Result]:
|
|
95
106
|
"""The loaded page and the adapter that claims it by its URL, else by the URL a redirect
|
|
96
107
|
lands on, else by its page; or the result that ends the command first. An adapter that
|
|
97
108
|
needs an account gets the user's session, and only that adapter's; signed out with a
|
|
98
|
-
password source, atsfill signs in again (ADR 0015). The adapter's name goes in `
|
|
109
|
+
password source, atsfill signs in again (ADR 0015). The adapter's name goes in `seen`
|
|
99
110
|
once known. The adapters are fresh: none keeps state from another
|
|
100
111
|
posting (a survey or the adapter gate runs many commands in one process)."""
|
|
101
112
|
candidates = adapters()
|
|
102
113
|
adapter = _detect(candidates, url)
|
|
103
114
|
if adapter:
|
|
104
|
-
|
|
115
|
+
seen.adapter = adapter.name
|
|
105
116
|
mine = paths.user_dir(data, user)
|
|
106
117
|
session, credentials = None, None
|
|
107
118
|
if isinstance(adapter, NeedsAccount):
|
|
@@ -129,7 +140,7 @@ def _opened(
|
|
|
129
140
|
if adapter is None and (
|
|
130
141
|
adapter := _detect(candidates, page.url) or _detect(candidates, url, page)
|
|
131
142
|
):
|
|
132
|
-
|
|
143
|
+
seen.adapter = adapter.name
|
|
133
144
|
if adapter is None:
|
|
134
145
|
yield _no_adapter(url)
|
|
135
146
|
return
|
|
@@ -247,18 +258,41 @@ def _stops(result: Result) -> Generator[None]:
|
|
|
247
258
|
result.status, result.reason = Status.BLOCKED, f"overlaid: {e}"
|
|
248
259
|
|
|
249
260
|
|
|
250
|
-
def _in_budget(url: str, command: Callable[[
|
|
261
|
+
def _in_budget(url: str, seen: Seen, command: Callable[[], Result]) -> Result:
|
|
251
262
|
"""Runs a command to a status whatever happens: past the click it reports `unknown` itself,
|
|
252
263
|
before it an error the adapter did not expect means the page is not one it handles. The
|
|
253
264
|
result keeps the adapter detected before the stop."""
|
|
254
|
-
detected: list[str] = []
|
|
255
265
|
try:
|
|
256
|
-
return command(
|
|
266
|
+
return command()
|
|
257
267
|
except OutOfTime as e:
|
|
258
|
-
return Result(Status.BLOCKED, url,
|
|
268
|
+
return Result(Status.BLOCKED, url, seen.adapter, f"time_budget: {e}")
|
|
259
269
|
except Exception as e:
|
|
260
270
|
reason = f"adapter_error: {type(e).__name__}: {e}"
|
|
261
|
-
return Result(Status.UNSUPPORTED, url,
|
|
271
|
+
return Result(Status.UNSUPPORTED, url, seen.adapter, reason)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _kept(page: Page, seen: Seen, run: Callable[[], Result]) -> Result:
|
|
275
|
+
"""The run's result; a failed run's page goes in `seen`, values stripped, when telemetry
|
|
276
|
+
is on."""
|
|
277
|
+
try:
|
|
278
|
+
result = run()
|
|
279
|
+
except Exception:
|
|
280
|
+
_capture(page, seen)
|
|
281
|
+
raise
|
|
282
|
+
if telemetry.verdict(result) == "fail":
|
|
283
|
+
_capture(page, seen)
|
|
284
|
+
return result
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _capture(page: Page, seen: Seen) -> None:
|
|
288
|
+
with suppress(Exception): # a page the budget closed has nothing to keep
|
|
289
|
+
if telemetry.level() != "off":
|
|
290
|
+
seen.page = snapshot.capture(page)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _secrets(data: Path, user: str, answers: dict[str, Answer], result: Result) -> set[str]:
|
|
294
|
+
emails = accounts.emails(paths.user_dir(data, user))
|
|
295
|
+
return snapshot.secrets(answers, result.fields, emails)
|
|
262
296
|
|
|
263
297
|
|
|
264
298
|
def _fill(page: Page, adapter: Adapter, url: str, answers: dict[str, Answer], user: str) -> Result:
|
|
@@ -281,18 +315,20 @@ def _fill(page: Page, adapter: Adapter, url: str, answers: dict[str, Answer], us
|
|
|
281
315
|
return result
|
|
282
316
|
|
|
283
317
|
|
|
284
|
-
def _browser_read(
|
|
285
|
-
data
|
|
286
|
-
) -> Result:
|
|
287
|
-
with _opened(data, user, url, sessions, detected) as opened:
|
|
318
|
+
def _browser_read(data: Path, user: str, url: str, sessions: Sessions | None, seen: Seen) -> Result:
|
|
319
|
+
with _opened(data, user, url, sessions, seen) as opened:
|
|
288
320
|
if isinstance(opened, Result):
|
|
289
321
|
return opened
|
|
290
322
|
page, adapter = opened
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
323
|
+
|
|
324
|
+
def run() -> Result:
|
|
325
|
+
result = _result(adapter, url, user)
|
|
326
|
+
with _stops(result):
|
|
327
|
+
_listed(result, adapter.browser_read(page))
|
|
328
|
+
_unfillable(result)
|
|
329
|
+
return result
|
|
330
|
+
|
|
331
|
+
return _kept(page, seen, run)
|
|
296
332
|
|
|
297
333
|
|
|
298
334
|
def _form_command(
|
|
@@ -303,46 +339,59 @@ def _form_command(
|
|
|
303
339
|
sessions: Sessions | None,
|
|
304
340
|
send: bool,
|
|
305
341
|
resend: bool,
|
|
306
|
-
|
|
342
|
+
seen: Seen,
|
|
307
343
|
) -> Result:
|
|
308
344
|
mine = paths.user_dir(data, user) # before the page takes the lock its first use needs
|
|
309
|
-
with _opened(data, user, url, sessions,
|
|
345
|
+
with _opened(data, user, url, sessions, seen) as opened:
|
|
310
346
|
if isinstance(opened, Result):
|
|
311
347
|
return opened
|
|
312
348
|
page, adapter = opened
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
|
349
|
+
return _kept(
|
|
350
|
+
page, seen, lambda: _on_page(page, adapter, mine, user, url, answers, send, resend)
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _on_page(
|
|
355
|
+
page: Page,
|
|
356
|
+
adapter: Adapter,
|
|
357
|
+
mine: Path,
|
|
358
|
+
user: str,
|
|
359
|
+
url: str,
|
|
360
|
+
answers: dict[str, Answer],
|
|
361
|
+
send: bool,
|
|
362
|
+
resend: bool,
|
|
363
|
+
) -> Result:
|
|
364
|
+
posting = adapter.posting(page.url) # past any redirect a link goes through
|
|
365
|
+
if send and not paths.submit_enabled(adapter.name, user):
|
|
366
|
+
where = "" if user == paths.DEFAULT_USER else f" under [users.{user}]"
|
|
367
|
+
reason = (
|
|
368
|
+
f"submit_not_enabled: add {adapter.name} to `submit-allowed`{where}"
|
|
369
|
+
f" in {paths.config_file()}"
|
|
344
370
|
)
|
|
371
|
+
return Result(Status.REFUSED, url, adapter.name, reason=reason)
|
|
372
|
+
if send and not resend and attempts.blocks_send(mine, posting):
|
|
373
|
+
last = attempts.last(mine, posting)
|
|
374
|
+
reason = f"already_sent: last attempt ended {last}; pass --resend"
|
|
375
|
+
return Result(Status.REFUSED, url, adapter.name, reason=reason)
|
|
376
|
+
result = _fill(page, adapter, url, answers, user)
|
|
377
|
+
if result.status is not Status.READY:
|
|
378
|
+
return result
|
|
379
|
+
if not send:
|
|
345
380
|
return result
|
|
381
|
+
attempts.record(mine, posting, attempts.PENDING, url=url)
|
|
382
|
+
try:
|
|
383
|
+
result.evidence = adapter.submit(page)
|
|
384
|
+
if result.evidence:
|
|
385
|
+
result.status = Status.SUBMITTED
|
|
386
|
+
else:
|
|
387
|
+
result.status, result.reason = Status.UNKNOWN, "no_evidence"
|
|
388
|
+
except Blocked as e:
|
|
389
|
+
result.status, result.reason = Status.BLOCKED, str(e)
|
|
390
|
+
except Exception as e: # after the click, nothing tells a failed send from a sent one
|
|
391
|
+
result.status = Status.UNKNOWN
|
|
392
|
+
result.reason = f"error_after_click: {type(e).__name__}: {e}"
|
|
393
|
+
attempts.record(mine, posting, result.status, evidence=result.evidence, reason=result.reason)
|
|
394
|
+
return result
|
|
346
395
|
|
|
347
396
|
|
|
348
397
|
def read(
|
|
@@ -354,11 +403,12 @@ def read(
|
|
|
354
403
|
) -> Result:
|
|
355
404
|
"""Lists the form's fields by a request read when an adapter has one (ADR 0011), else by a
|
|
356
405
|
browser read."""
|
|
357
|
-
data = paths.data_dir(data_dir)
|
|
358
|
-
|
|
406
|
+
data, seen = paths.data_dir(data_dir), Seen()
|
|
407
|
+
result = _read(data, user, url, sessions, seen)
|
|
408
|
+
return telemetry.sent("read", result, data, seen.page, _secrets(data, user, {}, result))
|
|
359
409
|
|
|
360
410
|
|
|
361
|
-
def _read(data: Path, user: str, url: str, sessions: Sessions | None) -> Result:
|
|
411
|
+
def _read(data: Path, user: str, url: str, sessions: Sessions | None, seen: Seen) -> Result:
|
|
362
412
|
for adapter in adapters():
|
|
363
413
|
if not isinstance(adapter, ReadsByRequest):
|
|
364
414
|
continue
|
|
@@ -370,7 +420,7 @@ def _read(data: Path, user: str, url: str, sessions: Sessions | None) -> Result:
|
|
|
370
420
|
return Result(Status.UNSUPPORTED, url, adapter.name, f"adapter_error: {e}")
|
|
371
421
|
_unfillable(result)
|
|
372
422
|
return result
|
|
373
|
-
return _in_budget(url, lambda
|
|
423
|
+
return _in_budget(url, seen, lambda: _browser_read(data, user, url, sessions, seen))
|
|
374
424
|
|
|
375
425
|
|
|
376
426
|
def fill(
|
|
@@ -381,11 +431,11 @@ def fill(
|
|
|
381
431
|
data_dir: Path | None = None,
|
|
382
432
|
sessions: Sessions | None = None,
|
|
383
433
|
) -> Result:
|
|
384
|
-
data = paths.data_dir(data_dir)
|
|
434
|
+
data, seen = paths.data_dir(data_dir), Seen()
|
|
385
435
|
result = _in_budget(
|
|
386
|
-
url, lambda
|
|
436
|
+
url, seen, lambda: _form_command(data, user, url, answers, sessions, False, False, seen)
|
|
387
437
|
)
|
|
388
|
-
return telemetry.sent("fill", result, data)
|
|
438
|
+
return telemetry.sent("fill", result, data, seen.page, _secrets(data, user, answers, result))
|
|
389
439
|
|
|
390
440
|
|
|
391
441
|
def send(
|
|
@@ -397,11 +447,16 @@ def send(
|
|
|
397
447
|
data_dir: Path | None = None,
|
|
398
448
|
sessions: Sessions | None = None,
|
|
399
449
|
) -> Result:
|
|
400
|
-
data = paths.data_dir(data_dir)
|
|
450
|
+
data, seen = paths.data_dir(data_dir), Seen()
|
|
401
451
|
result = _in_budget(
|
|
402
|
-
url, lambda
|
|
452
|
+
url, seen, lambda: _form_command(data, user, url, answers, sessions, True, resend, seen)
|
|
403
453
|
)
|
|
404
|
-
return telemetry.sent("send", result, data)
|
|
454
|
+
return telemetry.sent("send", result, data, seen.page, _secrets(data, user, answers, result))
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def report(*, data_dir: Path | None = None) -> ReportResult:
|
|
458
|
+
"""Sends the last failed run's URL and scrubbed page, once (ADR 0016)."""
|
|
459
|
+
return telemetry.report(paths.data_dir(data_dir))
|
|
405
460
|
|
|
406
461
|
|
|
407
462
|
def _sessions(data: Path, users: list[str], candidates: list[Adapter]) -> list[SessionReport]:
|
|
@@ -589,13 +644,13 @@ def _headless(
|
|
|
589
644
|
assert isinstance(account, SignsIn)
|
|
590
645
|
url = account.login_url(scope)
|
|
591
646
|
|
|
592
|
-
def run(
|
|
647
|
+
def run() -> Result:
|
|
593
648
|
result = Result(Status.READY, url, account.name)
|
|
594
649
|
with open_page(data, None) as page, _stops(result), otp.source(user, account.name, scope):
|
|
595
650
|
accounts.sign_in(page, account, scope, *credentials, session, details, create)
|
|
596
651
|
return result
|
|
597
652
|
|
|
598
|
-
return _in_budget(url, run)
|
|
653
|
+
return _in_budget(url, Seen(), run)
|
|
599
654
|
|
|
600
655
|
|
|
601
656
|
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
|
|
@@ -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)
|
|
@@ -197,10 +197,10 @@ scope = { is = "supported", date = 2026-10-20, commit = "d35da0b" }
|
|
|
197
197
|
[adp]
|
|
198
198
|
kind = "ats"
|
|
199
199
|
share = 0.0108
|
|
200
|
-
account = "to_read" # Apply opens a talent community step, name, email and mobile number, verified with
|
|
200
|
+
account = "to_read" # Apply opens a talent community step, name, email and a required mobile number, verified with an OTP before the form
|
|
201
201
|
hosting = ["hosted"]
|
|
202
202
|
hosts = ["workforcenow.adp.com"]
|
|
203
|
-
scope = { is = "in_scope", date = 2026-10-26, reason = "unsupported_flow:
|
|
203
|
+
scope = { is = "in_scope", date = 2026-10-26, reason = "unsupported_flow: sign_up_phone: the talent community step before the form requires a mobile number, even when its OTP goes to the email (14 of 15 employers)" }
|
|
204
204
|
|
|
205
205
|
[eures]
|
|
206
206
|
kind = "aggregator"
|
|
@@ -242,10 +242,10 @@ scope = { is = "in_scope", date = 2026-10-26, reason = "unsupported_flow: profil
|
|
|
242
242
|
[adpmyjobs]
|
|
243
243
|
kind = "ats"
|
|
244
244
|
share = 0.0084
|
|
245
|
-
account = "to_read" # the posting loads only signed in; the sign-in has no password,
|
|
245
|
+
account = "to_read" # the posting loads only signed in; the sign-in has no password, an OTP alone each time
|
|
246
246
|
hosting = ["hosted"]
|
|
247
247
|
hosts = ["myjobs.adp.com"]
|
|
248
|
-
scope = { is = "in_scope", date = 2026-10-26, reason = "unsupported_flow:
|
|
248
|
+
scope = { is = "in_scope", date = 2026-10-26, reason = "unsupported_flow: sign_in_otp_only: no password, every sign-in, the sign-up's included, is an OTP alone, and the posting loads only signed in (11 of 11 employers)" }
|
|
249
249
|
|
|
250
250
|
[phenom]
|
|
251
251
|
kind = "ats"
|
|
@@ -305,10 +305,10 @@ scope = { is = "supported", date = 2026-10-26, commit = "867de7d" }
|
|
|
305
305
|
[jobstreet]
|
|
306
306
|
kind = "board"
|
|
307
307
|
share = 0.0047
|
|
308
|
-
account = "to_read" # Apply needs a SEEK account; its sign-in and sign-up have no password, an emailed
|
|
308
|
+
account = "to_read" # Apply needs a SEEK account; its sign-in and sign-up have no password, an emailed OTP alone each time
|
|
309
309
|
hosting = ["hosted"]
|
|
310
310
|
hosts = ["jobstreet.com", "jobsdb.com"]
|
|
311
|
-
scope = { is = "in_scope", date = 2026-10-26, reason = "unsupported_flow:
|
|
311
|
+
scope = { is = "in_scope", date = 2026-10-26, reason = "unsupported_flow: sign_in_otp_only: Apply goes to the SEEK sign-in, with no password, every sign-in and sign-up a six-digit OTP emailed to the address, alone (14 of 15 employers)" }
|
|
312
312
|
|
|
313
313
|
[whatjobs-uk]
|
|
314
314
|
kind = "aggregator"
|
|
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
|